1 Commits
Author SHA1 Message Date
sneak 9077ec3582 docs: bring TODO.md up to date (closes #146)
check / check (push) Successful in 1m42s
Next Step and Future Steps now list the open issues by full URL, in the
order of the review on #144,
without DNSSEC (post-1.0) or issues waiting on the owner; two issues filed
since the review sit next to the entries they relate to. Shipped work, the
dropped domains and hostnames endpoints and the line about mocked resolver
tests are gone. Every Completed Steps entry is at most two lines; none was
dropped. Workflow now branches from `next` and opens PRs against it.
Wrapped by hand at 80 columns, since `make fmt` does not format Markdown
yet (#119). The old Next Step,
README sections required by policy, is not marked done: it is now
#173.

Model: opus-5-5
2026-10-01 18:27:55 +00:00
15 changed files with 175 additions and 459 deletions
+10 -8
View File
@@ -41,15 +41,18 @@ RUN make build
# alpine 3.21, 2026-02-28
FROM alpine@sha256:c3f8e73fdb79deaebaa2037150150191b9dcbfba68b4a46d70103204c53f4709
RUN apk add --no-cache ca-certificates tzdata su-exec
RUN apk add --no-cache ca-certificates tzdata
COPY --from=builder /src/bin/dnswatcher /usr/local/bin/dnswatcher
COPY deploy/docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
# dnswatcher runs as this unprivileged user. The entrypoint creates the
# data directory and gives it to this user on every start.
# Run as an unprivileged user that owns the data directory. A fresh named
# volume inherits this ownership; a bind-mounted host directory must be
# owned by uid 10001 (see "Running under upaas" in README.md), or startup
# fails.
RUN addgroup -S -g 10001 dnswatcher \
&& adduser -S -G dnswatcher -u 10001 dnswatcher
&& adduser -S -G dnswatcher -u 10001 dnswatcher \
&& mkdir -p /var/lib/dnswatcher \
&& chown dnswatcher:dnswatcher /var/lib/dnswatcher
ENV DNSWATCHER_DATA_DIR=/var/lib/dnswatcher
@@ -59,8 +62,7 @@ ENV DNSWATCHER_DATA_DIR=/var/lib/dnswatcher
# data directory, or the binary's directory, the working directory.
WORKDIR /
# No USER: the entrypoint must start as root to set up the data
# directory; it then runs dnswatcher as the dnswatcher user.
USER dnswatcher
EXPOSE 8080
@@ -70,4 +72,4 @@ EXPOSE 8080
HEALTHCHECK --interval=10s --timeout=5s --start-period=10s --retries=3 \
CMD wget -q -O /dev/null "http://127.0.0.1:${PORT:-8080}/.well-known/healthcheck" || exit 1
ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"]
ENTRYPOINT ["/usr/local/bin/dnswatcher"]
+11 -1
View File
@@ -533,7 +533,17 @@ repository's `Dockerfile` and runs it. The app needs:
- **Branch:** `prod`. `prod` is cut from `main`, and merging a `main` to
`prod` pull request is a deploy.
- **Volume:** one host directory mounted at `/var/lib/dnswatcher`, where
the state file lives.
the state file lives. upaas bind-mounts the host path it is given and
does not create it. The container runs as uid 10001 and does not start
unless it can write there. Create the directory before the first
deploy:
```sh
mkdir -p /path/to/data
chown 10001:10001 /path/to/data
chmod 700 /path/to/data
```
- **Network and port:** the dashboard is unauthenticated and shows every
watched name and recent alert, and upaas publishes every mapped port on
all interfaces of the host
+5 -14
View File
@@ -22,14 +22,6 @@ https://git.eeqj.de/sneak/dnswatcher/issues/104
- 2026-10-01: `TODO.md` brought up to date: open issues listed by URL, every
Completed Steps entry cut to at most two lines (closes #146).
- 2026-10-01: wildcard CORS now applies only to the public routes, not to
`/metrics`, and allows only the methods they serve (closes #100).
- 2026-10-01: `internal/state` and `internal/watcher` no longer export test-only
constructors: two moved to `export_test.go`, one is deleted (closes #111).
- 2026-10-01: notify shutdown tests use one timing constant per meaning, name
the bound they check, and require the drain's debug line (closes #116).
- 2026-09-29: the entrypoint chowns the data directory to `dnswatcher` and runs
dnswatcher as that user, so a host bind mount needs no chown (closes #166).
- 2026-09-29: the live-DNS test package is renamed `internal/livednstest`;
`make lint` fails when program code imports it (closes #164).
- 2026-09-29: `.golangci.yml` re-fetched from `sneak/prompts`, with
@@ -89,18 +81,19 @@ https://git.eeqj.de/sneak/dnswatcher/issues/104
- nameserver IP address changes: https://git.eeqj.de/sneak/dnswatcher/issues/105
- `DNSWATCHER_SENTRY_DSN` does nothing:
https://git.eeqj.de/sneak/dnswatcher/issues/107
- invalid DNS or TLS interval silently replaced by the default:
https://git.eeqj.de/sneak/dnswatcher/issues/177
- CORS on `/metrics`: https://git.eeqj.de/sneak/dnswatcher/issues/100
- rate limit on `/metrics` Basic Auth:
https://git.eeqj.de/sneak/dnswatcher/issues/101
- images report version `dev`: https://git.eeqj.de/sneak/dnswatcher/issues/109
- trial run of the finished image:
https://git.eeqj.de/sneak/dnswatcher/issues/149
- 1.0 readiness: run it with a real config and read the logs:
https://git.eeqj.de/sneak/dnswatcher/issues/66
- `goimports` in `make fmt-check`, Markdown formatting:
https://git.eeqj.de/sneak/dnswatcher/issues/119
- final state save at shutdown: https://git.eeqj.de/sneak/dnswatcher/issues/114
- test-only constructors in `internal/state`:
https://git.eeqj.de/sneak/dnswatcher/issues/111
- `internal/notify` shutdown tests:
https://git.eeqj.de/sneak/dnswatcher/issues/116
- `internal/notify` shutdown tests hang when a drain returns early:
https://git.eeqj.de/sneak/dnswatcher/issues/176
- README accuracy sweep: https://git.eeqj.de/sneak/dnswatcher/issues/108
@@ -108,5 +101,3 @@ https://git.eeqj.de/sneak/dnswatcher/issues/104
https://git.eeqj.de/sneak/dnswatcher/issues/173
- `script/install-precommit` in a linked worktree:
https://git.eeqj.de/sneak/dnswatcher/issues/129
- fixed root server order: https://git.eeqj.de/sneak/dnswatcher/issues/138
- review toward 1.0: https://git.eeqj.de/sneak/dnswatcher/issues/144
-17
View File
@@ -1,17 +0,0 @@
#!/bin/sh
# deploy/docker-entrypoint.sh: the Docker image's ENTRYPOINT. It runs as
# root only to give the data directory to the dnswatcher user: a host
# directory bind-mounted there keeps its host owner, often root, and may
# hold a state file left by another uid, which dnswatcher could neither
# read nor replace. dnswatcher itself always runs as the dnswatcher user.
set -eu
main() {
dir="${DNSWATCHER_DATA_DIR:-/var/lib/dnswatcher}"
mkdir -p "$dir"
chown -R dnswatcher:dnswatcher "$dir"
chmod 700 "$dir"
exec su-exec dnswatcher /usr/local/bin/dnswatcher "$@"
}
main "$@"
+9 -6
View File
@@ -223,14 +223,17 @@ func realIP(r *http.Request) string {
return addr
}
// CORS returns middleware that lets any origin read a response. It is
// for the public, read-only routes only, so it allows only the
// methods those routes serve and no Authorization header.
// CORS returns CORS middleware.
func (m *Middleware) CORS() func(http.Handler) http.Handler {
return cors.Handler(cors.Options{
AllowedOrigins: []string{"*"},
AllowedMethods: []string{"GET", "OPTIONS"},
AllowedHeaders: []string{"Accept", "Content-Type"},
AllowedOrigins: []string{"*"},
AllowedMethods: []string{
"GET", "POST", "PUT", "DELETE", "OPTIONS",
},
AllowedHeaders: []string{
"Accept", "Authorization",
"Content-Type", "X-CSRF-Token",
},
ExposedHeaders: []string{"Link"},
AllowCredentials: false,
MaxAge: corsMaxAge,
+1 -9
View File
@@ -276,18 +276,10 @@ func newTestHandlers(t *testing.T) *handlers.Handlers {
t.Fatalf("notify.New: %v", err)
}
st, err := state.New(fxtest.NewLifecycle(t), state.Params{
Logger: log,
Config: &config.Config{DataDir: t.TempDir()},
})
if err != nil {
t.Fatalf("state.New: %v", err)
}
hnd, err := handlers.New(nil, handlers.Params{
Logger: log,
Globals: glob,
State: st,
State: state.NewForTest(),
Notify: notifier,
})
if err != nil {
+28 -83
View File
@@ -33,29 +33,10 @@ const (
// out.
drainDeadline = 50 * time.Millisecond
// timeoutDrainBound is how long a drain given drainDeadline
// may take to return before the test gives up on it. At
// forty times drainDeadline it leaves ample room for
// scheduling delay on a loaded box under -race, yet it is far
// below the test binary's -timeout, so a drain that its
// deadline does not bound fails that one test instead of
// hanging the package.
timeoutDrainBound = 2 * time.Second
// longDrainDeadline is the deadline given to a drain that is
// expected to finish well before it: when the in-flight
// delivery completes after inFlightHold, or at once when
// nothing is in flight. It is far above inFlightHold, so
// those drains never reach it, and four times
// idleDrainBound, so an idle drain that waited for its
// deadline instead of returning fails that bound.
longDrainDeadline = 2 * time.Second
// reachEndpointTimeout is how long a submitted delivery may
// take to reach the test server. That normally takes a few
// milliseconds; the margin is for a loaded box under -race,
// and only a failing run ever waits this long.
reachEndpointTimeout = 2 * time.Second
// drainSlack is the upper bound on how long a bounded
// drain may take; generous enough for a loaded CI box,
// still far below the 20s test ceiling.
drainSlack = 2 * time.Second
// settleDelay is how long to wait before asserting that
// something did *not* happen.
@@ -65,11 +46,10 @@ const (
// nothing in flight. It is deliberately far above the cost
// of the goroutine hop through inFlight.Wait() — which
// reached 57ms on a loaded box under -race with the package's
// parallel tests — and far below longDrainDeadline, the
// deadline such a drain is given. A drain that blocked until
// its deadline instead of returning on the WaitGroup
// therefore still fails this bound, but scheduling delay
// alone cannot.
// parallel tests — and far below drainSlack, the deadline
// such a drain is given. A drain that blocked until its
// deadline instead of returning on the WaitGroup therefore
// still fails this bound, but scheduling delay alone cannot.
idleDrainBound = 500 * time.Millisecond
)
@@ -95,14 +75,12 @@ func (sb *syncBuffer) String() string {
}
// newLoggingService returns a Service writing JSON logs into
// the returned buffer, debug level included.
// the returned buffer.
func newLoggingService(
transport http.RoundTripper,
) (*notify.Service, *syncBuffer) {
logs := &syncBuffer{}
handler := slog.NewJSONHandler(
logs, &slog.HandlerOptions{Level: slog.LevelDebug},
)
handler := slog.NewJSONHandler(logs, nil)
return notify.NewTestServiceWithLogger(transport, handler),
logs
@@ -156,7 +134,7 @@ func TestDrainWaitsForInFlightDelivery(t *testing.T) {
// drain begins.
select {
case <-entered:
case <-time.After(reachEndpointTimeout):
case <-time.After(drainSlack):
t.Fatal("delivery never reached the endpoint")
}
@@ -173,7 +151,7 @@ func TestDrainWaitsForInFlightDelivery(t *testing.T) {
defer timer.Stop()
ctx, cancel := context.WithTimeout(
context.Background(), longDrainDeadline,
context.Background(), drainSlack,
)
defer cancel()
@@ -279,11 +257,11 @@ func TestDrainBoundedByContextDeadline(t *testing.T) {
select {
case <-returned:
case <-time.After(timeoutDrainBound):
case <-time.After(drainSlack):
t.Fatalf(
"drain did not return within %v; its %v deadline "+
"did not bound it",
timeoutDrainBound, drainDeadline,
drainSlack, drainDeadline,
)
}
@@ -355,7 +333,7 @@ func TestDrainRefusesNewDeliveries(t *testing.T) {
svc.SetMattermostWebhookURL(target)
ctx, cancel := context.WithTimeout(
context.Background(), longDrainDeadline,
context.Background(), drainSlack,
)
defer cancel()
@@ -468,7 +446,7 @@ func TestNewRegistersDrainingStopHook(t *testing.T) {
select {
case <-entered:
case <-time.After(reachEndpointTimeout):
case <-time.After(drainSlack):
t.Fatal("delivery never reached the endpoint")
}
@@ -478,7 +456,7 @@ func TestNewRegistersDrainingStopHook(t *testing.T) {
defer timer.Stop()
ctx, cancel := context.WithTimeout(
context.Background(), longDrainDeadline,
context.Background(), drainSlack,
)
defer cancel()
@@ -509,7 +487,7 @@ func TestDrainWithoutDeliveriesReturnsImmediately(t *testing.T) {
start := time.Now()
ctx, cancel := context.WithTimeout(
context.Background(), longDrainDeadline,
context.Background(), drainSlack,
)
defer cancel()
@@ -517,9 +495,9 @@ func TestDrainWithoutDeliveriesReturnsImmediately(t *testing.T) {
if elapsed := time.Since(start); elapsed > idleDrainBound {
t.Errorf(
"drain of an idle service took %v, want at most "+
"%v; its deadline was %v",
elapsed, idleDrainBound, longDrainDeadline,
"drain of an idle service took %v, want well "+
"under its %v deadline",
elapsed, drainSlack,
)
}
}
@@ -527,11 +505,10 @@ func TestDrainWithoutDeliveriesReturnsImmediately(t *testing.T) {
// TestDrainWithCancelledContextDoesNotWarn verifies that an
// OnStop context that is already dead on entry does not produce
// an "abandoning them" warning when there was nothing in flight
// to abandon, and that the drain returns and says at debug level
// that nothing was in flight. The expired context wins the
// select immediately, so only the outstanding count can tell the
// difference between a genuine timeout and a shutdown that had
// simply already run out of time with no work left.
// to abandon. The expired context wins the select immediately,
// so only the outstanding count can tell the difference between
// a genuine timeout and a shutdown that had simply already run
// out of time with no work left.
func TestDrainWithCancelledContextDoesNotWarn(t *testing.T) {
t.Parallel()
@@ -540,43 +517,11 @@ func TestDrainWithCancelledContextDoesNotWarn(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
cancel()
// A watchdog, as in TestDrainBoundedByContextDeadline, so
// that a drain which never returns fails here instead of
// hanging the package.
returned := make(chan struct{})
svc.Drain(ctx)
go func() {
defer close(returned)
svc.Drain(ctx)
}()
select {
case <-returned:
case <-time.After(idleDrainBound):
t.Fatalf(
"drain with nothing in flight and a cancelled "+
"context did not return within %v",
idleDrainBound,
)
}
output := logs.String()
// The absence of a warning alone would also pass if the drain
// logged nothing at all, so require the debug line it writes
// when it finds nothing outstanding.
if !strings.Contains(
output, "all in-flight notifications completed",
if output := logs.String(); strings.Contains(
output, `"level":"WARN"`,
) {
t.Errorf(
"drain did not log that nothing was in flight; "+
"log output: %s",
output,
)
}
if strings.Contains(output, `"level":"WARN"`) {
t.Errorf(
"drain with nothing in flight warned about "+
"abandoned deliveries; log output: %s",
+14 -23
View File
@@ -23,20 +23,14 @@ func (s *Server) SetupRoutes() {
s.router.Use(chimw.RequestID)
s.router.Use(s.mw.SecurityHeaders())
s.router.Use(s.mw.Logging())
s.router.Use(s.mw.CORS())
s.router.Use(chimw.Timeout(requestTimeout))
// Public, unauthenticated, read-only routes, the only ones
// REPO_POLICIES.md allows wildcard CORS on. CORS is middleware of
// this whole router, not of a Group, so that it also answers
// OPTIONS preflight requests, which no route here registers.
public := chi.NewRouter()
public.Use(s.mw.CORS())
// Dashboard (read-only web UI)
public.Get("/", s.handlers.HandleDashboard())
s.router.Get("/", s.handlers.HandleDashboard())
// Static assets (embedded CSS/JS)
public.Mount(
s.router.Mount(
"/s",
http.StripPrefix(
"/s",
@@ -45,30 +39,27 @@ func (s *Server) SetupRoutes() {
)
// Health check (standard well-known path)
public.Get(
s.router.Get(
"/.well-known/healthcheck",
s.handlers.HandleHealthCheck(),
)
// Legacy health check (keep for backward compatibility)
public.Get("/health", s.handlers.HandleHealthCheck())
s.router.Get("/health", s.handlers.HandleHealthCheck())
// API v1 routes
public.Route("/api/v1", func(r chi.Router) {
s.router.Route("/api/v1", func(r chi.Router) {
r.Get("/status", s.handlers.HandleStatus())
})
s.router.Mount("/", public)
// Metrics endpoint (optional, with basic auth) and no CORS: a
// Prometheus scraper is not a browser. It is mounted rather than
// added with Get so that every method on /metrics, OPTIONS
// included, ends here instead of falling through to the public
// router and its CORS.
// Metrics endpoint (optional, with basic auth)
if s.params.Config.MetricsUsername != "" {
metrics := chi.NewRouter()
metrics.Use(s.mw.MetricsAuth())
metrics.Get("/", promhttp.Handler().ServeHTTP)
s.router.Mount("/metrics", metrics)
s.router.Group(func(r chi.Router) {
r.Use(s.mw.MetricsAuth())
r.Get(
"/metrics",
promhttp.Handler().ServeHTTP,
)
})
}
}
-221
View File
@@ -1,221 +0,0 @@
package server_test
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/spf13/viper"
"sneak.berlin/go/dnswatcher/internal/server"
)
// Credentials for /metrics, which is only routed when a username is set.
const (
metricsUsername = "scraper"
metricsPassword = "scrape-secret"
)
// The tests below set env vars and touch viper global state, so like
// the config tests they cannot use t.Parallel.
// routedServer builds the server with its routes set up, ready to serve
// test requests. The caller must first configure viper.
func routedServer(t *testing.T) *server.Server {
t.Helper()
srv := buildServer(t)
srv.SetupRoutes()
return srv
}
// crossOriginRequest builds a request as a browser sends it from a page
// on another site.
func crossOriginRequest(
t *testing.T,
method string,
target string,
) *http.Request {
t.Helper()
req := httptest.NewRequestWithContext(t.Context(), method, target, nil)
req.Header.Set("Origin", "https://example.net")
return req
}
// preflightRequest builds the OPTIONS request a browser sends before a
// cross-origin request with the given method and request headers.
func preflightRequest(
t *testing.T,
target string,
method string,
headers string,
) *http.Request {
t.Helper()
req := crossOriginRequest(t, http.MethodOptions, target)
req.Header.Set("Access-Control-Request-Method", method)
if headers != "" {
req.Header.Set("Access-Control-Request-Headers", headers)
}
return req
}
func serve(
srv *server.Server,
req *http.Request,
) *httptest.ResponseRecorder {
rec := httptest.NewRecorder()
srv.ServeHTTP(rec, req)
return rec
}
// publicPaths returns one path on each public route.
func publicPaths() []string {
return []string{
"/",
"/s/css/tailwind.min.css",
"/api/v1/status",
"/health",
"/.well-known/healthcheck",
}
}
// TestPublicRoutesAllowAnyOrigin checks that every public route answers
// a cross-origin GET with the CORS wildcard.
func TestPublicRoutesAllowAnyOrigin(t *testing.T) {
viper.Reset()
t.Setenv("DNSWATCHER_TARGETS", "example.com")
t.Setenv("DNSWATCHER_METRICS_USERNAME", metricsUsername)
t.Setenv("DNSWATCHER_METRICS_PASSWORD", metricsPassword)
srv := routedServer(t)
for _, path := range publicPaths() {
rec := serve(srv, crossOriginRequest(t, http.MethodGet, path))
if rec.Code != http.StatusOK {
t.Errorf("GET %s: status = %d, want 200", path, rec.Code)
}
got := rec.Header().Get("Access-Control-Allow-Origin")
if got != "*" {
t.Errorf(
"GET %s: Access-Control-Allow-Origin = %q, want %q",
path, got, "*",
)
}
}
}
// TestMetricsHasNoCORS checks that no request to the Basic-Auth
// protected /metrics, preflight included, gets a CORS header.
func TestMetricsHasNoCORS(t *testing.T) {
viper.Reset()
t.Setenv("DNSWATCHER_TARGETS", "example.com")
t.Setenv("DNSWATCHER_METRICS_USERNAME", metricsUsername)
t.Setenv("DNSWATCHER_METRICS_PASSWORD", metricsPassword)
srv := routedServer(t)
authenticated := crossOriginRequest(t, http.MethodGet, "/metrics")
authenticated.SetBasicAuth(metricsUsername, metricsPassword)
tests := []struct {
name string
req *http.Request
wantStatus int
}{
{
"authenticated GET",
authenticated,
http.StatusOK,
},
{
"unauthenticated GET",
crossOriginRequest(t, http.MethodGet, "/metrics"),
http.StatusUnauthorized,
},
{
"preflight",
preflightRequest(t, "/metrics", http.MethodGet, ""),
http.StatusUnauthorized,
},
}
for _, tt := range tests {
rec := serve(srv, tt.req)
if rec.Code != tt.wantStatus {
t.Errorf(
"%s: status = %d, want %d",
tt.name, rec.Code, tt.wantStatus,
)
}
got := rec.Header().Get("Access-Control-Allow-Origin")
if got != "" {
t.Errorf(
"%s: Access-Control-Allow-Origin = %q, want none",
tt.name, got,
)
}
}
}
// TestPreflightAllowsOnlyWhatPublicRoutesServe checks what each public
// route agrees to in a CORS preflight: GET, but not POST, PUT or
// DELETE, which no route serves, and not the Authorization or
// X-CSRF-Token headers, which no public route reads. It checks every
// public route because one added with Get, such as /health, answers a
// preflight only while CORS is middleware of a whole router; in a
// Group, chi would answer it with 405 and no CORS headers.
func TestPreflightAllowsOnlyWhatPublicRoutesServe(t *testing.T) {
viper.Reset()
t.Setenv("DNSWATCHER_TARGETS", "example.com")
t.Setenv("DNSWATCHER_METRICS_USERNAME", metricsUsername)
t.Setenv("DNSWATCHER_METRICS_PASSWORD", metricsPassword)
srv := routedServer(t)
tests := []struct {
method string
headers string
allowed bool
}{
{http.MethodGet, "", true},
{http.MethodGet, "Content-Type", true},
{http.MethodPost, "", false},
{http.MethodPut, "", false},
{http.MethodDelete, "", false},
{http.MethodGet, "Authorization", false},
{http.MethodGet, "X-CSRF-Token", false},
}
for _, path := range publicPaths() {
for _, tt := range tests {
rec := serve(srv, preflightRequest(
t, path, tt.method, tt.headers,
))
want := ""
if tt.allowed {
want = tt.method
}
got := rec.Header().Get("Access-Control-Allow-Methods")
if got != want {
t.Errorf(
"preflight to %s for %s with headers %q: "+
"Access-Control-Allow-Methods = %q, want %q",
path, tt.method, tt.headers, got, want,
)
}
}
}
}
-23
View File
@@ -1,23 +0,0 @@
package state
import (
"log/slog"
"sneak.berlin/go/dnswatcher/internal/config"
)
// NewForTestWithDataDir creates an empty State that saves to dataDir,
// without the fx lifecycle.
func NewForTestWithDataDir(dataDir string) *State {
return &State{
log: slog.Default(),
snapshot: &Snapshot{
Version: stateVersion,
Domains: make(map[string]*DomainState),
Hostnames: make(map[string]*HostnameState),
Ports: make(map[string]*PortState),
Certificates: make(map[string]*CertificateState),
},
config: &config.Config{DataDir: dataDir},
}
}
+36 -7
View File
@@ -739,7 +739,7 @@ func TestPortStateUnmarshalJSON_BothFormats(t *testing.T) {
func TestGetSnapshot_ReturnsCopy(t *testing.T) {
t.Parallel()
s := state.NewForTestWithDataDir(t.TempDir())
s := state.NewForTest()
populateState(t, s)
@@ -761,7 +761,7 @@ func TestGetSnapshot_ReturnsCopy(t *testing.T) {
func TestDomainState_GetSet(t *testing.T) {
t.Parallel()
s := state.NewForTestWithDataDir(t.TempDir())
s := state.NewForTest()
// Get on missing key returns false.
_, ok := s.GetDomainState("nonexistent.com")
@@ -812,7 +812,7 @@ func TestDomainState_GetSet(t *testing.T) {
func TestHostnameState_GetSet(t *testing.T) {
t.Parallel()
s := state.NewForTestWithDataDir(t.TempDir())
s := state.NewForTest()
_, ok := s.GetHostnameState("missing.example.com")
if ok {
@@ -857,7 +857,7 @@ func TestHostnameState_GetSet(t *testing.T) {
func TestPortState_GetSetDelete(t *testing.T) {
t.Parallel()
s := state.NewForTestWithDataDir(t.TempDir())
s := state.NewForTest()
_, ok := s.GetPortState("1.2.3.4:80")
if ok {
@@ -895,7 +895,7 @@ func TestPortState_GetSetDelete(t *testing.T) {
func TestGetAllPortKeys(t *testing.T) {
t.Parallel()
s := state.NewForTestWithDataDir(t.TempDir())
s := state.NewForTest()
keys := s.GetAllPortKeys()
if len(keys) != 0 {
@@ -937,7 +937,7 @@ func TestGetAllPortKeys(t *testing.T) {
func TestCertificateState_GetSet(t *testing.T) {
t.Parallel()
s := state.NewForTestWithDataDir(t.TempDir())
s := state.NewForTest()
_, ok := s.GetCertificateState("1.2.3.4:443:www.example.com")
if ok {
@@ -1158,7 +1158,7 @@ func TestLoadPreservesExistingStateOnMissingFile(t *testing.T) {
func TestConcurrentGetSet(t *testing.T) {
t.Parallel()
s := state.NewForTestWithDataDir(t.TempDir())
s := state.NewForTest()
const goroutines = 20
@@ -1368,6 +1368,35 @@ func TestMultipleSavesOverwrite(t *testing.T) {
}
}
// TestNewForTest verifies the test helper creates a valid empty state.
func TestNewForTest(t *testing.T) {
t.Parallel()
s := state.NewForTest()
snap := s.GetSnapshot()
if snap.Version != 1 {
t.Errorf("version: got %d, want 1", snap.Version)
}
if snap.Domains == nil {
t.Error("Domains map should be initialized")
}
if snap.Hostnames == nil {
t.Error("Hostnames map should be initialized")
}
if snap.Ports == nil {
t.Error("Ports map should be initialized")
}
if snap.Certificates == nil {
t.Error("Certificates map should be initialized")
}
}
// TestSaveFilePermissions verifies the saved file has restricted permissions.
func TestSaveFilePermissions(t *testing.T) {
t.Parallel()
+38
View File
@@ -0,0 +1,38 @@
package state
import (
"log/slog"
"sneak.berlin/go/dnswatcher/internal/config"
)
// NewForTest creates a State for unit testing with no persistence.
func NewForTest() *State {
return &State{
log: slog.Default(),
snapshot: &Snapshot{
Version: stateVersion,
Domains: make(map[string]*DomainState),
Hostnames: make(map[string]*HostnameState),
Ports: make(map[string]*PortState),
Certificates: make(map[string]*CertificateState),
},
config: &config.Config{DataDir: ""},
}
}
// NewForTestWithDataDir creates a State backed by the given directory
// for tests that need file persistence.
func NewForTestWithDataDir(dataDir string) *State {
return &State{
log: slog.Default(),
snapshot: &Snapshot{
Version: stateVersion,
Domains: make(map[string]*DomainState),
Hostnames: make(map[string]*HostnameState),
Ports: make(map[string]*PortState),
Certificates: make(map[string]*CertificateState),
},
config: &config.Config{DataDir: dataDir},
}
}
-25
View File
@@ -2,35 +2,10 @@ package watcher
import (
"context"
"log/slog"
"time"
"sneak.berlin/go/dnswatcher/internal/config"
"sneak.berlin/go/dnswatcher/internal/state"
)
// NewForTest creates a Watcher without fx for unit testing.
func NewForTest(
cfg *config.Config,
st *state.State,
res DNSResolver,
pc PortChecker,
tc TLSChecker,
n Notifier,
) *Watcher {
return &Watcher{
log: slog.Default(),
config: cfg,
state: st,
resolver: res,
portCheck: pc,
tlsCheck: tc,
notify: n,
firstRun: true,
expiryNotified: make(map[string]time.Time),
}
}
// NewlyDisagreeingPairs exports newlyDisagreeingPairs for testing.
func NewlyDisagreeingPairs(
prev *state.HostnameState,
+22
View File
@@ -102,6 +102,28 @@ func New(
return w, nil
}
// NewForTest creates a Watcher without fx for unit testing.
func NewForTest(
cfg *config.Config,
st *state.State,
res DNSResolver,
pc PortChecker,
tc TLSChecker,
n Notifier,
) *Watcher {
return &Watcher{
log: slog.Default(),
config: cfg,
state: st,
resolver: res,
portCheck: pc,
tlsCheck: tc,
notify: n,
firstRun: true,
expiryNotified: make(map[string]time.Time),
}
}
// Run starts the monitoring loop with periodic scheduling.
func (w *Watcher) Run(ctx context.Context) {
w.log.Info(
+1 -22
View File
@@ -9,12 +9,8 @@ import (
"testing"
"time"
"go.uber.org/fx/fxtest"
"sneak.berlin/go/dnswatcher/internal/config"
"sneak.berlin/go/dnswatcher/internal/globals"
"sneak.berlin/go/dnswatcher/internal/livednstest"
"sneak.berlin/go/dnswatcher/internal/logger"
"sneak.berlin/go/dnswatcher/internal/portcheck"
"sneak.berlin/go/dnswatcher/internal/resolver"
"sneak.berlin/go/dnswatcher/internal/state"
@@ -152,24 +148,7 @@ func newTestWatcher(
config: cfg,
}
g, err := globals.New(nil)
if err != nil {
t.Fatalf("globals.New: %v", err)
}
log, err := logger.New(nil, logger.Params{Globals: g})
if err != nil {
t.Fatalf("logger.New: %v", err)
}
// The watcher saves state after every check, into cfg.DataDir.
deps.state, err = state.New(fxtest.NewLifecycle(t), state.Params{
Logger: log,
Config: cfg,
})
if err != nil {
t.Fatalf("state.New: %v", err)
}
deps.state = state.NewForTest()
w := watcher.NewForTest(
deps.config,