check / check (push) Canceled after 0s
The health check answered ok while every DNS lookup failed, as on a network that intercepts DNS queries. A lookup whose root servers give no usable reply now fails with ErrNoRootServerAnswered. At the end of each round of DNS checks the watcher records whether every lookup failed with it or with ErrIntercepted; the health check then gives status error, and /.well-known/healthcheck and /health answer 503. Until the first round finishes it stays ok. The health check now depends on the watcher, so fx builds the watcher first: its first round starts before the HTTP server listens, and the server tests' fx graph includes it. NewWithRootServers lets tests start a resolver's walks at real servers that are not root servers. Model: opus-5-5
222 lines
5.8 KiB
Go
222 lines
5.8 KiB
Go
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")
|
|
}
|