package healthcheck_test import ( "context" "encoding/json" "log/slog" "testing" "time" "go.uber.org/fx" "go.uber.org/fx/fxtest" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "sneak.berlin/go/dnswatcher/internal/config" "sneak.berlin/go/dnswatcher/internal/globals" "sneak.berlin/go/dnswatcher/internal/healthcheck" "sneak.berlin/go/dnswatcher/internal/logger" "sneak.berlin/go/dnswatcher/internal/notify" "sneak.berlin/go/dnswatcher/internal/portcheck" "sneak.berlin/go/dnswatcher/internal/resolver" "sneak.berlin/go/dnswatcher/internal/state" "sneak.berlin/go/dnswatcher/internal/tlscheck" "sneak.berlin/go/dnswatcher/internal/watcher" ) // recordingLifecycle is a minimal fx.Lifecycle that records the hooks // appended to it, so healthcheck.New can be exercised through its real // constructor without standing up a whole fx application. type recordingLifecycle struct { hooks []fx.Hook } func (l *recordingLifecycle) Append(hook fx.Hook) { l.hooks = append(l.hooks, hook) } // newHealthcheck builds a Healthcheck through the real constructor and // runs the registered OnStart hook so StartupTime is set the same way // the fx lifecycle would set it. It follows the watcher it returns, // which watches example.org with res and has run no round of checks. func newHealthcheck( t *testing.T, res *resolver.Resolver, maintenance bool, version string, ) (*healthcheck.Healthcheck, *watcher.Watcher) { t.Helper() g := &globals.Globals{Appname: "dnswatcher", Version: version} log, err := logger.New(nil, logger.Params{Globals: g}) require.NoError(t, err) cfg := &config.Config{ MaintenanceMode: maintenance, Hostnames: []string{"example.org"}, DataDir: t.TempDir(), } w := newWatcher(t, log, cfg, res) lifecycle := &recordingLifecycle{} hc, err := healthcheck.New(lifecycle, healthcheck.Params{ Globals: g, Config: cfg, Logger: log, Watcher: w, }) require.NoError(t, err) require.Len(t, lifecycle.hooks, 1, "New must register exactly one lifecycle hook") require.NotNil(t, lifecycle.hooks[0].OnStart) require.NoError(t, lifecycle.hooks[0].OnStart(context.Background())) return hc, w } // newWatcher builds a watcher through the real constructor, with res as // its resolver. Its lifecycle is never started, so it runs a round of // checks only when the test calls RunOnce. func newWatcher( t *testing.T, log *logger.Logger, cfg *config.Config, res *resolver.Resolver, ) *watcher.Watcher { t.Helper() lifecycle := fxtest.NewLifecycle(t) st, err := state.New(lifecycle, state.Params{Logger: log, Config: cfg}) require.NoError(t, err) notifier, err := notify.New( lifecycle, notify.Params{Logger: log, Config: cfg}, ) require.NoError(t, err) w, err := watcher.New(lifecycle, watcher.Params{ Logger: log, Config: cfg, State: st, Resolver: res, PortCheck: portcheck.NewStandalone(), TLSCheck: tlscheck.NewStandalone(), Notify: notifier, }) require.NoError(t, err) return w } func TestCheckStatusAndPayloadShape(t *testing.T) { t.Parallel() hc, _ := newHealthcheck( t, resolver.NewFromLogger(slog.Default()), false, "v9.9.9", ) resp := hc.Check() assert.Equal(t, healthcheck.StatusOK, resp.Status) // The JSON shape and field names are part of the contract for the // /health and /.well-known/healthcheck routes, so assert on the // exact set of keys the response marshals to. raw, err := json.Marshal(resp) require.NoError(t, err) var fields map[string]json.RawMessage require.NoError(t, json.Unmarshal(raw, &fields)) wantKeys := []string{ "status", "now", "uptimeSeconds", "uptimeHuman", "version", "appname", "maintenanceMode", } assert.Len(t, fields, len(wantKeys), "response must marshal to exactly the documented fields") for _, key := range wantKeys { assert.Contains(t, fields, key, "missing JSON field %q", key) } // The Now field is documented as RFC3339Nano; a change to the // format constant should turn this red. _, err = time.Parse(time.RFC3339Nano, resp.Now) assert.NoError(t, err, "Now must be RFC3339Nano") } func TestCheckMaintenanceModeReflectsConfig(t *testing.T) { t.Parallel() tests := []struct { name string maintenance bool }{ {"maintenance off", false}, {"maintenance on", true}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() hc, _ := newHealthcheck( t, resolver.NewFromLogger(slog.Default()), tt.maintenance, "test", ) resp := hc.Check() assert.Equal(t, tt.maintenance, resp.Maintenance, "maintenanceMode must mirror Config.MaintenanceMode") }) } } func TestCheckSurfacesVersionAndAppname(t *testing.T) { t.Parallel() hc, _ := newHealthcheck( t, resolver.NewFromLogger(slog.Default()), false, "surfaced-version-123", ) resp := hc.Check() assert.Equal(t, "surfaced-version-123", resp.Version, "version from globals must appear in the payload") assert.Equal(t, "dnswatcher", resp.Appname) } // TestCheckStatusFollowsTheDNSChecks checks that the status is ok // before the watcher's first round of DNS checks, and error after a // round that could not reach the DNS. The watcher's resolver starts // every walk at public recursive resolvers of four operators, whose // replies are marked recursion available, as on a network that // intercepts DNS queries. If none of them replies, no root server // answered, and the round could not reach the DNS either. func TestCheckStatusFollowsTheDNSChecks(t *testing.T) { t.Parallel() res := resolver.NewWithRootServers( slog.Default(), []string{"1.1.1.1", "8.8.8.8", "9.9.9.9", "208.67.222.222"}, ) hc, w := newHealthcheck(t, res, false, "test") assert.Equal(t, healthcheck.StatusOK, hc.Check().Status, "status before the first round") w.RunOnce(t.Context()) assert.Equal(t, healthcheck.StatusError, hc.Check().Status, "status after a round that could not reach the DNS") }