From 9054db8d869c85e511949de21b1868c00d1afc55 Mon Sep 17 00:00:00 2001 From: sneak Date: Fri, 7 Aug 2026 17:08:04 +0000 Subject: [PATCH] build: update golangci-lint to v2.12.2 with org-standard v2 config Pin golangci-lint to commit c0d3ddc9cf3faa61a4e378e879ece580256d76e5 (v2.12.2) in Dockerfile and script/bootstrap. Set .golangci.yml to the org-standard v2-schema config already used across the org's repos (owner-authorized; the same file is being landed as canonical via a prompts-repo PR). Lint settings live under linters.settings, so the lll, funlen, cyclop, and dupl thresholds are actually applied. The informational gomodguard deprecation warning this config can emit under v2.12 is accepted. Fix all findings surfaced by the now-active thresholds: - goconst: shared constants for repeated status, priority, and DNS fixture strings in watcher.go and the notify, state, and watcher tests - dupl: consolidate duplicated ntfy/slack HTTP-error tests and SendNotification endpoint-error tests behind shared helpers - lll: wrap long test table entries and comments; shorten one inline nolint justification --- .golangci.yml | 26 ++-- Dockerfile | 4 +- TODO.md | 8 + internal/config/classify_test.go | 30 +++- internal/notify/delivery_test.go | 257 ++++++++++++++----------------- internal/notify/history_test.go | 4 +- internal/notify/retry.go | 2 +- internal/state/state_test.go | 62 +++++--- internal/watcher/watcher.go | 20 ++- internal/watcher/watcher_test.go | 205 ++++++++++++------------ script/bootstrap | 6 +- 11 files changed, 332 insertions(+), 292 deletions(-) diff --git a/.golangci.yml b/.golangci.yml index 34a8e31..26b1610 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -1,5 +1,9 @@ version: "2" +# Config schema uses the golangci-lint v2 layout (settings live under +# linters.settings, not top-level linters-settings) so that the +# thresholds below are actually applied by golangci-lint >= v2. + run: timeout: 5m modules-download-mode: readonly @@ -14,19 +18,17 @@ linters: - wsl # Deprecated, replaced by wsl_v5 - wrapcheck # Too verbose for internal packages - varnamelen # Short names like db, id are idiomatic Go - -linters-settings: - lll: - line-length: 88 - funlen: - lines: 80 - statements: 50 - cyclop: - max-complexity: 15 - dupl: - threshold: 100 + settings: + lll: + line-length: 88 + funlen: + lines: 80 + statements: 50 + cyclop: + max-complexity: 15 + dupl: + threshold: 100 issues: - exclude-use-default: false max-issues-per-linter: 0 max-same-issues: 0 diff --git a/Dockerfile b/Dockerfile index 243bf5b..ec33b34 100644 --- a/Dockerfile +++ b/Dockerfile @@ -4,8 +4,8 @@ FROM golang@sha256:f6751d823c26342f9506c03797d2527668d095b0a15f1862cddb4d927a7a4 RUN apk add --no-cache git make gcc musl-dev binutils-gold -# golangci-lint v2.10.1 -RUN go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@5d1e709b7be35cb2025444e19de266b056b7b7ee +# golangci-lint v2.12.2, 2026-08-07 +RUN go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@c0d3ddc9cf3faa61a4e378e879ece580256d76e5 # goimports v0.42.0 RUN go install golang.org/x/tools/cmd/goimports@009367f5c17a8d4c45a961a3a509277190a9a6f0 diff --git a/TODO.md b/TODO.md index 3e8e557..bc4c519 100644 --- a/TODO.md +++ b/TODO.md @@ -25,6 +25,14 @@ confirm make check still passes. # Completed Steps +- 2026-08-07: golangci-lint bumped to v2.12.2 (commit-pinned installs + in `Dockerfile` and `script/bootstrap`); `.golangci.yml` set to the + org-standard v2-schema config used across the org's repos + (owner-authorized; same file is being landed as canonical via prompts + PR #24), with settings under `linters.settings` so the + lll/funlen/cyclop/dupl thresholds apply; fixed the resulting + `goconst`, `dupl`, and `lll` findings; the informational `gomodguard` + deprecation warning under this config is accepted - 2026-07-07 Adopted scripts-to-rule-them-all: `script/` entrypoints, Makefile shims, README Entrypoints section - 2026-02-20: iterative DNS resolver implemented; tests made hermetic diff --git a/internal/config/classify_test.go b/internal/config/classify_test.go index fb21bbc..a9c03af 100644 --- a/internal/config/classify_test.go +++ b/internal/config/classify_test.go @@ -17,13 +17,33 @@ func TestClassifyDNSName(t *testing.T) { }{ {name: "apex domain simple", input: "example.com", want: config.DNSNameTypeDomain}, {name: "hostname simple", input: "www.example.com", want: config.DNSNameTypeHostname}, - {name: "apex domain multi-part TLD", input: "example.co.uk", want: config.DNSNameTypeDomain}, - {name: "hostname multi-part TLD", input: "api.example.co.uk", want: config.DNSNameTypeHostname}, + { + name: "apex domain multi-part TLD", + input: "example.co.uk", + want: config.DNSNameTypeDomain, + }, + { + name: "hostname multi-part TLD", + input: "api.example.co.uk", + want: config.DNSNameTypeHostname, + }, {name: "public suffix itself", input: "co.uk", wantErr: true}, {name: "empty string", input: "", wantErr: true}, - {name: "deeply nested hostname", input: "a.b.c.example.com", want: config.DNSNameTypeHostname}, - {name: "trailing dot stripped", input: "example.com.", want: config.DNSNameTypeDomain}, - {name: "uppercase normalized", input: "WWW.Example.COM", want: config.DNSNameTypeHostname}, + { + name: "deeply nested hostname", + input: "a.b.c.example.com", + want: config.DNSNameTypeHostname, + }, + { + name: "trailing dot stripped", + input: "example.com.", + want: config.DNSNameTypeDomain, + }, + { + name: "uppercase normalized", + input: "WWW.Example.COM", + want: config.DNSNameTypeHostname, + }, } for _, tt := range tests { diff --git a/internal/notify/delivery_test.go b/internal/notify/delivery_test.go index 1822950..fdccd73 100644 --- a/internal/notify/delivery_test.go +++ b/internal/notify/delivery_test.go @@ -25,6 +25,20 @@ const ( colorDefault = "#6c757d" ) +// Priority strings used across multiple tests. +const ( + prioError = "error" + prioWarning = "warning" + prioSuccess = "success" + prioInfo = "info" + prioUnknown = "unknown" + prioDefault = "default" + prioUrgent = "urgent" +) + +// testHost is the hostname used in request construction tests. +const testHost = "example.com" + // errSimulated is a static error for transport failures. var errSimulated = errors.New("simulated transport failure") @@ -101,13 +115,13 @@ func TestNtfyPriority(t *testing.T) { input string want string }{ - {"error", "urgent"}, - {"warning", "high"}, - {"success", "default"}, - {"info", "low"}, - {"", "default"}, - {"unknown", "default"}, - {"critical", "default"}, + {prioError, prioUrgent}, + {prioWarning, "high"}, + {prioSuccess, prioDefault}, + {prioInfo, "low"}, + {"", prioDefault}, + {prioUnknown, prioDefault}, + {"critical", prioDefault}, } for _, tc := range cases { @@ -134,12 +148,12 @@ func TestSlackColor(t *testing.T) { input string want string }{ - {"error", colorError}, - {"warning", colorWarning}, - {"success", colorSuccess}, - {"info", colorInfo}, + {prioError, colorError}, + {prioWarning, colorWarning}, + {prioSuccess, colorSuccess}, + {prioInfo, colorInfo}, {"", colorDefault}, - {"unknown", colorDefault}, + {prioUnknown, colorDefault}, {"critical", colorDefault}, } @@ -165,7 +179,7 @@ func TestNewRequest(t *testing.T) { target := &url.URL{ Scheme: "https", - Host: "example.com", + Host: testHost, Path: "/webhook", } body := bytes.NewBufferString("hello") @@ -187,9 +201,9 @@ func TestNewRequest(t *testing.T) { ) } - if req.Host != "example.com" { + if req.Host != testHost { t.Errorf( - "Host = %q, want %q", req.Host, "example.com", + "Host = %q, want %q", req.Host, testHost, ) } @@ -217,7 +231,7 @@ func TestNewRequestPreservesContext(t *testing.T) { ctxKey("k"), "v", ) - target := &url.URL{Scheme: "https", Host: "example.com"} + target := &url.URL{Scheme: "https", Host: testHost} req := notify.NewRequestForTest( ctx, http.MethodGet, target, http.NoBody, @@ -289,10 +303,10 @@ func TestSendNtfyHeaders(t *testing.T) { ) } - if captured.priority != "urgent" { + if captured.priority != prioUrgent { t.Errorf( "Priority header = %q, want %q", - captured.priority, "urgent", + captured.priority, prioUrgent, ) } @@ -311,10 +325,10 @@ func TestSendNtfyAllPriorities(t *testing.T) { input string want string }{ - {"error", "urgent"}, - {"warning", "high"}, - {"success", "default"}, - {"info", "low"}, + {prioError, prioUrgent}, + {prioWarning, "high"}, + {prioSuccess, prioDefault}, + {prioInfo, "low"}, } for _, tc := range priorities { @@ -356,56 +370,69 @@ func TestSendNtfyAllPriorities(t *testing.T) { } } -func TestSendNtfyClientError(t *testing.T) { - t.Parallel() +// assertSendStatusError verifies that send returns an error +// wrapping wantErr when the server responds with status. +func assertSendStatusError( + t *testing.T, + status int, + wantErr error, + send func(*notify.Service, *url.URL) error, +) { + t.Helper() srv := httptest.NewServer( http.HandlerFunc( func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusForbidden) + w.WriteHeader(status) }), ) defer srv.Close() svc := notify.NewTestService(srv.Client().Transport) - topicURL, _ := url.Parse(srv.URL) + target, _ := url.Parse(srv.URL) - err := svc.SendNtfy( - context.Background(), topicURL, "t", "m", "info", - ) + err := send(svc, target) if err == nil { - t.Fatal("expected error for 403 response") + t.Fatalf("expected error for %d response", status) } - if !errors.Is(err, notify.ErrNtfyFailed) { - t.Errorf("error = %v, want ErrNtfyFailed", err) + if !errors.Is(err, wantErr) { + t.Errorf("error = %v, want %v", err, wantErr) } } +func sendNtfyInfo( + svc *notify.Service, target *url.URL, +) error { + return svc.SendNtfy( + context.Background(), target, "t", "m", prioInfo, + ) +} + +func sendSlackInfo( + svc *notify.Service, target *url.URL, +) error { + return svc.SendSlack( + context.Background(), target, "t", "m", prioInfo, + ) +} + +func TestSendNtfyClientError(t *testing.T) { + t.Parallel() + + assertSendStatusError( + t, http.StatusForbidden, + notify.ErrNtfyFailed, sendNtfyInfo, + ) +} + func TestSendNtfyServerError(t *testing.T) { t.Parallel() - srv := httptest.NewServer( - http.HandlerFunc( - func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusInternalServerError) - }), + assertSendStatusError( + t, http.StatusInternalServerError, + notify.ErrNtfyFailed, sendNtfyInfo, ) - defer srv.Close() - - svc := notify.NewTestService(srv.Client().Transport) - topicURL, _ := url.Parse(srv.URL) - - err := svc.SendNtfy( - context.Background(), topicURL, "t", "m", "info", - ) - if err == nil { - t.Fatal("expected error for 500 response") - } - - if !errors.Is(err, notify.ErrNtfyFailed) { - t.Errorf("error = %v, want ErrNtfyFailed", err) - } } func TestSendNtfySuccess(t *testing.T) { @@ -550,11 +577,11 @@ func TestSendSlackAllColors(t *testing.T) { priority string want string }{ - {"error", colorError}, - {"warning", colorWarning}, - {"success", colorSuccess}, - {"info", colorInfo}, - {"unknown", colorDefault}, + {prioError, colorError}, + {prioWarning, colorWarning}, + {prioSuccess, colorSuccess}, + {prioInfo, colorInfo}, + {prioUnknown, colorDefault}, } for _, tc := range colors { @@ -606,53 +633,19 @@ func TestSendSlackAllColors(t *testing.T) { func TestSendSlackClientError(t *testing.T) { t.Parallel() - srv := httptest.NewServer( - http.HandlerFunc( - func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusBadRequest) - }), + assertSendStatusError( + t, http.StatusBadRequest, + notify.ErrSlackFailed, sendSlackInfo, ) - defer srv.Close() - - svc := notify.NewTestService(srv.Client().Transport) - webhookURL, _ := url.Parse(srv.URL) - - err := svc.SendSlack( - context.Background(), webhookURL, "t", "m", "info", - ) - if err == nil { - t.Fatal("expected error for 400 response") - } - - if !errors.Is(err, notify.ErrSlackFailed) { - t.Errorf("error = %v, want ErrSlackFailed", err) - } } func TestSendSlackServerError(t *testing.T) { t.Parallel() - srv := httptest.NewServer( - http.HandlerFunc( - func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusBadGateway) - }), + assertSendStatusError( + t, http.StatusBadGateway, + notify.ErrSlackFailed, sendSlackInfo, ) - defer srv.Close() - - svc := notify.NewTestService(srv.Client().Transport) - webhookURL, _ := url.Parse(srv.URL) - - err := svc.SendSlack( - context.Background(), webhookURL, "t", "m", "error", - ) - if err == nil { - t.Fatal("expected error for 502 response") - } - - if !errors.Is(err, notify.ErrSlackFailed) { - t.Errorf("error = %v, want ErrSlackFailed", err) - } } func TestSendSlackNetworkError(t *testing.T) { @@ -977,74 +970,62 @@ func TestSendNotificationMattermostOnly(t *testing.T) { } } -func TestSendNotificationNtfyError(t *testing.T) { - t.Parallel() +// assertSendNotificationTolerates verifies SendNotification +// neither panics nor blocks when the endpoint configured by +// setURL responds with status. +func assertSendNotificationTolerates( + t *testing.T, + status int, + priority string, + setURL func(*notify.Service, *url.URL), +) { + t.Helper() srv := httptest.NewServer( http.HandlerFunc( func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusInternalServerError) + w.WriteHeader(status) }), ) defer srv.Close() - ntfyURL, _ := url.Parse(srv.URL) + target, _ := url.Parse(srv.URL) svc := notify.NewTestService(http.DefaultTransport) - svc.SetNtfyURL(ntfyURL) + setURL(svc, target) - // Should not panic or block. svc.SendNotification( - context.Background(), "t", "m", "error", + context.Background(), "t", "m", priority, ) time.Sleep(100 * time.Millisecond) } +func TestSendNotificationNtfyError(t *testing.T) { + t.Parallel() + + assertSendNotificationTolerates( + t, http.StatusInternalServerError, prioError, + (*notify.Service).SetNtfyURL, + ) +} + func TestSendNotificationSlackError(t *testing.T) { t.Parallel() - srv := httptest.NewServer( - http.HandlerFunc( - func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusForbidden) - }), + assertSendNotificationTolerates( + t, http.StatusForbidden, prioError, + (*notify.Service).SetSlackWebhookURL, ) - defer srv.Close() - - slackURL, _ := url.Parse(srv.URL) - - svc := notify.NewTestService(http.DefaultTransport) - svc.SetSlackWebhookURL(slackURL) - - svc.SendNotification( - context.Background(), "t", "m", "error", - ) - - time.Sleep(100 * time.Millisecond) } func TestSendNotificationMattermostError(t *testing.T) { t.Parallel() - srv := httptest.NewServer( - http.HandlerFunc( - func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusBadGateway) - }), + assertSendNotificationTolerates( + t, http.StatusBadGateway, prioWarning, + (*notify.Service).SetMattermostWebhookURL, ) - defer srv.Close() - - mmURL, _ := url.Parse(srv.URL) - - svc := notify.NewTestService(http.DefaultTransport) - svc.SetMattermostWebhookURL(mmURL) - - svc.SendNotification( - context.Background(), "t", "m", "warning", - ) - - time.Sleep(100 * time.Millisecond) } // ── SlackPayload JSON marshaling ────────────────────────── diff --git a/internal/notify/history_test.go b/internal/notify/history_test.go index a60804d..b77ff52 100644 --- a/internal/notify/history_test.go +++ b/internal/notify/history_test.go @@ -29,14 +29,14 @@ func TestAlertHistoryAddAndRecent(t *testing.T) { Timestamp: now.Add(-2 * time.Minute), Title: "first", Message: "msg1", - Priority: "info", + Priority: prioInfo, }) h.Add(notify.AlertEntry{ Timestamp: now.Add(-1 * time.Minute), Title: "second", Message: "msg2", - Priority: "warning", + Priority: prioWarning, }) entries := h.Recent() diff --git a/internal/notify/retry.go b/internal/notify/retry.go index bc0a08b..cbc49d3 100644 --- a/internal/notify/retry.go +++ b/internal/notify/retry.go @@ -69,7 +69,7 @@ func (rc RetryConfig) backoff(attempt int) time.Duration { lo := raw * (1 - jitterFraction) hi := raw * (1 + jitterFraction) - jittered := lo + rand.Float64()*(hi-lo) //nolint:gosec // jitter does not need crypto/rand + jittered := lo + rand.Float64()*(hi-lo) //nolint:gosec // jitter needs no crypto/rand return time.Duration(jittered) } diff --git a/internal/state/state_test.go b/internal/state/state_test.go index 5e95eb9..699d17c 100644 --- a/internal/state/state_test.go +++ b/internal/state/state_test.go @@ -13,6 +13,16 @@ import ( const testHostname = "www.example.com" +// Shared fixture values used across tests. +const ( + testNS1 = "ns1.example.com." + testNS2 = "ns2.example.com." + testAltNS1 = "ns1.test.com." + testIPv4 = "93.184.216.34" + testIP = "1.2.3.4" + statusError = "error" +) + // populateState fills a State with representative test data across all categories. func populateState(t *testing.T, s *state.State) { t.Helper() @@ -20,7 +30,7 @@ func populateState(t *testing.T, s *state.State) { now := time.Now().UTC().Truncate(time.Second) s.SetDomainState("example.com", &state.DomainState{ - Nameservers: []string{"ns1.example.com.", "ns2.example.com."}, + Nameservers: []string{testNS1, testNS2}, LastChecked: now, }) @@ -31,17 +41,17 @@ func populateState(t *testing.T, s *state.State) { s.SetHostnameState(testHostname, &state.HostnameState{ RecordsByNameserver: map[string]*state.NameserverRecordState{ - "ns1.example.com.": { + testNS1: { Records: map[string][]string{ - "A": {"93.184.216.34"}, + "A": {testIPv4}, "AAAA": {"2606:2800:220:1:248:1893:25c8:1946"}, }, Status: "ok", LastChecked: now, }, - "ns2.example.com.": { + testNS2: { Records: map[string][]string{ - "A": {"93.184.216.34"}, + "A": {testIPv4}, }, Status: "ok", LastChecked: now, @@ -152,13 +162,13 @@ func TestSaveLoadRoundTrip_Hostnames(t *testing.T) { func verifyNS1Records(t *testing.T, hn *state.HostnameState) { t.Helper() - ns1, ok := hn.RecordsByNameserver["ns1.example.com."] + ns1, ok := hn.RecordsByNameserver[testNS1] if !ok { t.Fatal("missing nameserver ns1.example.com.") } aRecords := ns1.Records["A"] - if len(aRecords) != 1 || aRecords[0] != "93.184.216.34" { + if len(aRecords) != 1 || aRecords[0] != testIPv4 { t.Errorf("ns1 A records: got %v", aRecords) } @@ -213,7 +223,8 @@ func TestSaveLoadRoundTrip_Ports(t *testing.T) { } } -// TestSaveLoadRoundTrip_Certificates verifies certificate data survives a save/load cycle. +// TestSaveLoadRoundTrip_Certificates verifies certificate data +// survives a save/load cycle. func TestSaveLoadRoundTrip_Certificates(t *testing.T) { t.Parallel() @@ -653,7 +664,7 @@ func TestDomainState_GetSet(t *testing.T) { now := time.Now().UTC().Truncate(time.Second) ds := &state.DomainState{ - Nameservers: []string{"ns1.test.com."}, + Nameservers: []string{testAltNS1}, LastChecked: now, } @@ -664,7 +675,7 @@ func TestDomainState_GetSet(t *testing.T) { t.Fatal("expected true for existing domain") } - if len(got.Nameservers) != 1 || got.Nameservers[0] != "ns1.test.com." { + if len(got.Nameservers) != 1 || got.Nameservers[0] != testAltNS1 { t.Errorf("nameservers: got %v", got.Nameservers) } @@ -674,7 +685,7 @@ func TestDomainState_GetSet(t *testing.T) { // Overwrite. ds2 := &state.DomainState{ - Nameservers: []string{"ns1.test.com.", "ns2.test.com."}, + Nameservers: []string{testAltNS1, "ns2.test.com."}, LastChecked: now.Add(time.Hour), } @@ -704,8 +715,8 @@ func TestHostnameState_GetSet(t *testing.T) { now := time.Now().UTC().Truncate(time.Second) hs := &state.HostnameState{ RecordsByNameserver: map[string]*state.NameserverRecordState{ - "ns1.example.com.": { - Records: map[string][]string{"A": {"1.2.3.4"}}, + testNS1: { + Records: map[string][]string{"A": {testIP}}, Status: "ok", LastChecked: now, }, @@ -720,7 +731,7 @@ func TestHostnameState_GetSet(t *testing.T) { t.Fatal("expected true for existing hostname") } - nsState, ok := got.RecordsByNameserver["ns1.example.com."] + nsState, ok := got.RecordsByNameserver[testNS1] if !ok { t.Fatal("missing nameserver entry") } @@ -730,7 +741,7 @@ func TestHostnameState_GetSet(t *testing.T) { } aRecords := nsState.Records["A"] - if len(aRecords) != 1 || aRecords[0] != "1.2.3.4" { + if len(aRecords) != 1 || aRecords[0] != testIP { t.Errorf("A records: got %v", aRecords) } } @@ -869,7 +880,7 @@ func TestCertificateState_ErrorField(t *testing.T) { now := time.Now().UTC().Truncate(time.Second) cs := &state.CertificateState{ - Status: "error", + Status: statusError, Error: "connection refused", LastChecked: now, } @@ -893,8 +904,8 @@ func TestCertificateState_ErrorField(t *testing.T) { t.Fatal("missing certificate after load") } - if got.Status != "error" { - t.Errorf("status: got %q, want %q", got.Status, "error") + if got.Status != statusError { + t.Errorf("status: got %q, want %q", got.Status, statusError) } if got.Error != "connection refused" { @@ -912,9 +923,9 @@ func TestHostnameState_ErrorField(t *testing.T) { now := time.Now().UTC().Truncate(time.Second) hs := &state.HostnameState{ RecordsByNameserver: map[string]*state.NameserverRecordState{ - "ns1.example.com.": { + testNS1: { Records: nil, - Status: "error", + Status: statusError, Error: "SERVFAIL", LastChecked: now, }, @@ -941,9 +952,9 @@ func TestHostnameState_ErrorField(t *testing.T) { t.Fatal("missing hostname after load") } - nsState := got.RecordsByNameserver["ns1.example.com."] - if nsState.Status != "error" { - t.Errorf("status: got %q, want %q", nsState.Status, "error") + nsState := got.RecordsByNameserver[testNS1] + if nsState.Status != statusError { + t.Errorf("status: got %q, want %q", nsState.Status, statusError) } if nsState.Error != "SERVFAIL" { @@ -1062,7 +1073,8 @@ func TestConcurrentGetSet(t *testing.T) { wg.Wait() } -// runConcurrentOps performs a series of get/set/delete operations for concurrency testing. +// runConcurrentOps performs a series of get/set/delete +// operations for concurrency testing. func runConcurrentOps(s *state.State, key string, now time.Time) { const iterations = 50 @@ -1085,7 +1097,7 @@ func runConcurrentOps(s *state.State, key string, now time.Time) { s.SetHostnameState(key+".example.com", &state.HostnameState{ RecordsByNameserver: map[string]*state.NameserverRecordState{ "ns1.test.": { - Records: map[string][]string{"A": {"1.2.3.4"}}, + Records: map[string][]string{"A": {testIP}}, Status: "ok", LastChecked: now, }, diff --git a/internal/watcher/watcher.go b/internal/watcher/watcher.go index bdf4f22..fe2c4fb 100644 --- a/internal/watcher/watcher.go +++ b/internal/watcher/watcher.go @@ -26,6 +26,12 @@ const tlsPort = 443 // hoursPerDay converts days to hours for duration calculations. const hoursPerDay = 24 +// Status values recorded for nameserver and certificate checks. +const ( + statusOK = "ok" + statusError = "error" +) + // Params contains dependencies for Watcher. type Params struct { fx.In @@ -344,7 +350,7 @@ func buildHostnameState( for ns, recs := range records { hs.RecordsByNameserver[ns] = &state.NameserverRecordState{ Records: recs, - Status: "ok", + Status: statusOK, LastChecked: now, } } @@ -402,7 +408,7 @@ func (w *Watcher) detectNSDisappearances( current map[string]map[string][]string, ) { for ns, prevNS := range prev.RecordsByNameserver { - if _, ok := current[ns]; ok || prevNS.Status != "ok" { + if _, ok := current[ns]; ok || prevNS.Status != statusOK { continue } @@ -421,7 +427,7 @@ func (w *Watcher) detectNSDisappearances( for ns := range current { prevNS, ok := prev.RecordsByNameserver[ns] - if !ok || prevNS.Status != "error" { + if !ok || prevNS.Status != statusError { continue } @@ -705,7 +711,7 @@ func (w *Watcher) handleTLSError( now time.Time, err error, ) { - if hasPrev && !w.firstRun && prev.Status == "ok" { + if hasPrev && !w.firstRun && prev.Status == statusOK { msg := fmt.Sprintf( "Host: %s\nIP: %s\nError: %s", hostname, ip, err, @@ -721,7 +727,7 @@ func (w *Watcher) handleTLSError( w.state.SetCertificateState( certKey, &state.CertificateState{ - Status: "error", + Status: statusError, Error: err.Error(), LastChecked: now, }, @@ -748,7 +754,7 @@ func (w *Watcher) handleTLSSuccess( Issuer: cert.Issuer, NotAfter: cert.NotAfter, SubjectAlternativeNames: cert.SubjectAlternativeNames, - Status: "ok", + Status: statusOK, LastChecked: now, }, ) @@ -760,7 +766,7 @@ func (w *Watcher) detectTLSChanges( prev *state.CertificateState, cert *tlscheck.CertificateInfo, ) { - if prev.Status == "error" { + if prev.Status == statusError { msg := fmt.Sprintf( "Host: %s\nIP: %s\nTLS recovered", hostname, ip, diff --git a/internal/watcher/watcher_test.go b/internal/watcher/watcher_test.go index ea6dbef..0069af2 100644 --- a/internal/watcher/watcher_test.go +++ b/internal/watcher/watcher_test.go @@ -18,6 +18,17 @@ import ( // errNotFound is returned when mock data is missing. var errNotFound = errors.New("not found") +// Fixture values shared across tests. +const ( + testDomain = "example.com" + testHost = "www.example.com" + testNS1 = "ns1.example.com." + testNS2 = "ns2.example.com." + testIPv4 = "93.184.216.34" + testIP = "1.2.3.4" + testIssuer = "DigiCert" +) + // --- Mock implementations --- type mockResolver struct { @@ -256,8 +267,8 @@ func TestFirstRunBaseline(t *testing.T) { t.Parallel() cfg := defaultTestConfig(t) - cfg.Domains = []string{"example.com"} - cfg.Hostnames = []string{"www.example.com"} + cfg.Domains = []string{testDomain} + cfg.Hostnames = []string{testHost} w, deps := newTestWatcher(t, cfg) setupBaselineMocks(deps) @@ -269,37 +280,37 @@ func TestFirstRunBaseline(t *testing.T) { } func setupBaselineMocks(deps *testDeps) { - deps.resolver.nsRecords["example.com"] = []string{ - "ns1.example.com.", - "ns2.example.com.", + deps.resolver.nsRecords[testDomain] = []string{ + testNS1, + testNS2, } - deps.resolver.allRecords["example.com"] = map[string]map[string][]string{ - "ns1.example.com.": {"A": {"93.184.216.34"}}, - "ns2.example.com.": {"A": {"93.184.216.34"}}, + deps.resolver.allRecords[testDomain] = map[string]map[string][]string{ + testNS1: {"A": {testIPv4}}, + testNS2: {"A": {testIPv4}}, } - deps.resolver.allRecords["www.example.com"] = map[string]map[string][]string{ - "ns1.example.com.": {"A": {"93.184.216.34"}}, - "ns2.example.com.": {"A": {"93.184.216.34"}}, + deps.resolver.allRecords[testHost] = map[string]map[string][]string{ + testNS1: {"A": {testIPv4}}, + testNS2: {"A": {testIPv4}}, } - deps.resolver.ipAddresses["www.example.com"] = []string{ - "93.184.216.34", + deps.resolver.ipAddresses[testHost] = []string{ + testIPv4, } deps.portChecker.results["93.184.216.34:80"] = true deps.portChecker.results["93.184.216.34:443"] = true deps.tlsChecker.certs["93.184.216.34:www.example.com"] = &tlscheck.CertificateInfo{ - CommonName: "www.example.com", - Issuer: "DigiCert", + CommonName: testHost, + Issuer: testIssuer, NotAfter: time.Now().Add(90 * 24 * time.Hour), SubjectAlternativeNames: []string{ - "www.example.com", + testHost, }, } deps.tlsChecker.certs["93.184.216.34:example.com"] = &tlscheck.CertificateInfo{ - CommonName: "example.com", - Issuer: "DigiCert", + CommonName: testDomain, + Issuer: testIssuer, NotAfter: time.Now().Add(90 * 24 * time.Hour), SubjectAlternativeNames: []string{ - "example.com", + testDomain, }, } } @@ -348,24 +359,24 @@ func TestDomainPortAndTLSChecks(t *testing.T) { t.Parallel() cfg := defaultTestConfig(t) - cfg.Domains = []string{"example.com"} + cfg.Domains = []string{testDomain} w, deps := newTestWatcher(t, cfg) - deps.resolver.nsRecords["example.com"] = []string{ - "ns1.example.com.", + deps.resolver.nsRecords[testDomain] = []string{ + testNS1, } - deps.resolver.allRecords["example.com"] = map[string]map[string][]string{ - "ns1.example.com.": {"A": {"93.184.216.34"}}, + deps.resolver.allRecords[testDomain] = map[string]map[string][]string{ + testNS1: {"A": {testIPv4}}, } deps.portChecker.results["93.184.216.34:80"] = true deps.portChecker.results["93.184.216.34:443"] = true deps.tlsChecker.certs["93.184.216.34:example.com"] = &tlscheck.CertificateInfo{ - CommonName: "example.com", - Issuer: "DigiCert", + CommonName: testDomain, + Issuer: testIssuer, NotAfter: time.Now().Add(90 * 24 * time.Hour), SubjectAlternativeNames: []string{ - "example.com", + testDomain, }, } @@ -406,17 +417,17 @@ func TestNSChangeDetection(t *testing.T) { t.Parallel() cfg := defaultTestConfig(t) - cfg.Domains = []string{"example.com"} + cfg.Domains = []string{testDomain} w, deps := newTestWatcher(t, cfg) - deps.resolver.nsRecords["example.com"] = []string{ - "ns1.example.com.", - "ns2.example.com.", + deps.resolver.nsRecords[testDomain] = []string{ + testNS1, + testNS2, } - deps.resolver.allRecords["example.com"] = map[string]map[string][]string{ - "ns1.example.com.": {"A": {"1.2.3.4"}}, - "ns2.example.com.": {"A": {"1.2.3.4"}}, + deps.resolver.allRecords[testDomain] = map[string]map[string][]string{ + testNS1: {"A": {testIP}}, + testNS2: {"A": {testIP}}, } deps.portChecker.results["1.2.3.4:80"] = false deps.portChecker.results["1.2.3.4:443"] = false @@ -425,13 +436,13 @@ func TestNSChangeDetection(t *testing.T) { w.RunOnce(ctx) deps.resolver.mu.Lock() - deps.resolver.nsRecords["example.com"] = []string{ - "ns1.example.com.", + deps.resolver.nsRecords[testDomain] = []string{ + testNS1, "ns3.example.com.", } - deps.resolver.allRecords["example.com"] = map[string]map[string][]string{ - "ns1.example.com.": {"A": {"1.2.3.4"}}, - "ns3.example.com.": {"A": {"1.2.3.4"}}, + deps.resolver.allRecords[testDomain] = map[string]map[string][]string{ + testNS1: {"A": {testIP}}, + "ns3.example.com.": {"A": {testIP}}, } deps.resolver.mu.Unlock() @@ -459,15 +470,15 @@ func TestRecordChangeDetection(t *testing.T) { t.Parallel() cfg := defaultTestConfig(t) - cfg.Hostnames = []string{"www.example.com"} + cfg.Hostnames = []string{testHost} w, deps := newTestWatcher(t, cfg) - deps.resolver.allRecords["www.example.com"] = map[string]map[string][]string{ - "ns1.example.com.": {"A": {"93.184.216.34"}}, + deps.resolver.allRecords[testHost] = map[string]map[string][]string{ + testNS1: {"A": {testIPv4}}, } - deps.resolver.ipAddresses["www.example.com"] = []string{ - "93.184.216.34", + deps.resolver.ipAddresses[testHost] = []string{ + testIPv4, } deps.portChecker.results["93.184.216.34:80"] = false deps.portChecker.results["93.184.216.34:443"] = false @@ -476,10 +487,10 @@ func TestRecordChangeDetection(t *testing.T) { w.RunOnce(ctx) deps.resolver.mu.Lock() - deps.resolver.allRecords["www.example.com"] = map[string]map[string][]string{ - "ns1.example.com.": {"A": {"93.184.216.35"}}, + deps.resolver.allRecords[testHost] = map[string]map[string][]string{ + testNS1: {"A": {"93.184.216.35"}}, } - deps.resolver.ipAddresses["www.example.com"] = []string{ + deps.resolver.ipAddresses[testHost] = []string{ "93.184.216.35", } deps.resolver.mu.Unlock() @@ -501,24 +512,24 @@ func TestPortStateChange(t *testing.T) { t.Parallel() cfg := defaultTestConfig(t) - cfg.Hostnames = []string{"www.example.com"} + cfg.Hostnames = []string{testHost} w, deps := newTestWatcher(t, cfg) - deps.resolver.allRecords["www.example.com"] = map[string]map[string][]string{ - "ns1.example.com.": {"A": {"1.2.3.4"}}, + deps.resolver.allRecords[testHost] = map[string]map[string][]string{ + testNS1: {"A": {testIP}}, } - deps.resolver.ipAddresses["www.example.com"] = []string{ - "1.2.3.4", + deps.resolver.ipAddresses[testHost] = []string{ + testIP, } deps.portChecker.results["1.2.3.4:80"] = true deps.portChecker.results["1.2.3.4:443"] = true deps.tlsChecker.certs["1.2.3.4:www.example.com"] = &tlscheck.CertificateInfo{ - CommonName: "www.example.com", - Issuer: "DigiCert", + CommonName: testHost, + Issuer: testIssuer, NotAfter: time.Now().Add(90 * 24 * time.Hour), SubjectAlternativeNames: []string{ - "www.example.com", + testHost, }, } @@ -541,24 +552,24 @@ func TestTLSExpiryWarning(t *testing.T) { t.Parallel() cfg := defaultTestConfig(t) - cfg.Hostnames = []string{"www.example.com"} + cfg.Hostnames = []string{testHost} w, deps := newTestWatcher(t, cfg) - deps.resolver.allRecords["www.example.com"] = map[string]map[string][]string{ - "ns1.example.com.": {"A": {"1.2.3.4"}}, + deps.resolver.allRecords[testHost] = map[string]map[string][]string{ + testNS1: {"A": {testIP}}, } - deps.resolver.ipAddresses["www.example.com"] = []string{ - "1.2.3.4", + deps.resolver.ipAddresses[testHost] = []string{ + testIP, } deps.portChecker.results["1.2.3.4:80"] = true deps.portChecker.results["1.2.3.4:443"] = true deps.tlsChecker.certs["1.2.3.4:www.example.com"] = &tlscheck.CertificateInfo{ - CommonName: "www.example.com", - Issuer: "DigiCert", + CommonName: testHost, + Issuer: testIssuer, NotAfter: time.Now().Add(3 * 24 * time.Hour), SubjectAlternativeNames: []string{ - "www.example.com", + testHost, }, } @@ -592,25 +603,25 @@ func TestTLSExpiryWarningDedup(t *testing.T) { t.Parallel() cfg := defaultTestConfig(t) - cfg.Hostnames = []string{"www.example.com"} + cfg.Hostnames = []string{testHost} cfg.TLSInterval = 24 * time.Hour w, deps := newTestWatcher(t, cfg) - deps.resolver.allRecords["www.example.com"] = map[string]map[string][]string{ - "ns1.example.com.": {"A": {"1.2.3.4"}}, + deps.resolver.allRecords[testHost] = map[string]map[string][]string{ + testNS1: {"A": {testIP}}, } - deps.resolver.ipAddresses["www.example.com"] = []string{ - "1.2.3.4", + deps.resolver.ipAddresses[testHost] = []string{ + testIP, } deps.portChecker.results["1.2.3.4:80"] = true deps.portChecker.results["1.2.3.4:443"] = true deps.tlsChecker.certs["1.2.3.4:www.example.com"] = &tlscheck.CertificateInfo{ - CommonName: "www.example.com", - Issuer: "DigiCert", + CommonName: testHost, + Issuer: testIssuer, NotAfter: time.Now().Add(3 * 24 * time.Hour), SubjectAlternativeNames: []string{ - "www.example.com", + testHost, }, } @@ -647,17 +658,17 @@ func TestGracefulShutdown(t *testing.T) { t.Parallel() cfg := defaultTestConfig(t) - cfg.Domains = []string{"example.com"} + cfg.Domains = []string{testDomain} cfg.DNSInterval = 100 * time.Millisecond cfg.TLSInterval = 100 * time.Millisecond w, deps := newTestWatcher(t, cfg) - deps.resolver.nsRecords["example.com"] = []string{ - "ns1.example.com.", + deps.resolver.nsRecords[testDomain] = []string{ + testNS1, } - deps.resolver.allRecords["example.com"] = map[string]map[string][]string{ - "ns1.example.com.": {"A": {"1.2.3.4"}}, + deps.resolver.allRecords[testDomain] = map[string]map[string][]string{ + testNS1: {"A": {testIP}}, } deps.portChecker.results["1.2.3.4:80"] = false deps.portChecker.results["1.2.3.4:443"] = false @@ -687,13 +698,13 @@ func setupHostnameIP( hostname, ip string, ) { deps.resolver.allRecords[hostname] = map[string]map[string][]string{ - "ns1.example.com.": {"A": {ip}}, + testNS1: {"A": {ip}}, } deps.portChecker.results[ip+":80"] = true deps.portChecker.results[ip+":443"] = true deps.tlsChecker.certs[ip+":"+hostname] = &tlscheck.CertificateInfo{ CommonName: hostname, - Issuer: "DigiCert", + Issuer: testIssuer, NotAfter: time.Now().Add(90 * 24 * time.Hour), SubjectAlternativeNames: []string{hostname}, } @@ -702,7 +713,7 @@ func setupHostnameIP( func updateHostnameIP(deps *testDeps, hostname, ip string) { deps.resolver.mu.Lock() deps.resolver.allRecords[hostname] = map[string]map[string][]string{ - "ns1.example.com.": {"A": {ip}}, + testNS1: {"A": {ip}}, } deps.resolver.mu.Unlock() @@ -714,7 +725,7 @@ func updateHostnameIP(deps *testDeps, hostname, ip string) { deps.tlsChecker.mu.Lock() deps.tlsChecker.certs[ip+":"+hostname] = &tlscheck.CertificateInfo{ CommonName: hostname, - Issuer: "DigiCert", + Issuer: testIssuer, NotAfter: time.Now().Add(90 * 24 * time.Hour), SubjectAlternativeNames: []string{hostname}, } @@ -725,11 +736,11 @@ func TestDNSRunsBeforePortAndTLSChecks(t *testing.T) { t.Parallel() cfg := defaultTestConfig(t) - cfg.Hostnames = []string{"www.example.com"} + cfg.Hostnames = []string{testHost} w, deps := newTestWatcher(t, cfg) - setupHostnameIP(deps, "www.example.com", "10.0.0.1") + setupHostnameIP(deps, testHost, "10.0.0.1") ctx := t.Context() w.RunOnce(ctx) @@ -740,7 +751,7 @@ func TestDNSRunsBeforePortAndTLSChecks(t *testing.T) { } // DNS changes to a new IP; port and TLS must pick it up. - updateHostnameIP(deps, "www.example.com", "10.0.0.2") + updateHostnameIP(deps, testHost, "10.0.0.2") w.RunOnce(ctx) @@ -760,8 +771,8 @@ func TestSendTestNotification_Enabled(t *testing.T) { t.Parallel() cfg := defaultTestConfig(t) - cfg.Domains = []string{"example.com"} - cfg.Hostnames = []string{"www.example.com"} + cfg.Domains = []string{testDomain} + cfg.Hostnames = []string{testHost} cfg.SendTestNotification = true w, deps := newTestWatcher(t, cfg) @@ -786,8 +797,8 @@ func TestSendTestNotification_ViaRun(t *testing.T) { t.Parallel() cfg := defaultTestConfig(t) - cfg.Domains = []string{"example.com"} - cfg.Hostnames = []string{"www.example.com"} + cfg.Domains = []string{testDomain} + cfg.Hostnames = []string{testHost} cfg.SendTestNotification = true cfg.DNSInterval = 24 * time.Hour cfg.TLSInterval = 24 * time.Hour @@ -833,8 +844,8 @@ func TestSendTestNotification_Disabled(t *testing.T) { t.Parallel() cfg := defaultTestConfig(t) - cfg.Domains = []string{"example.com"} - cfg.Hostnames = []string{"www.example.com"} + cfg.Domains = []string{testDomain} + cfg.Hostnames = []string{testHost} cfg.SendTestNotification = false cfg.DNSInterval = 24 * time.Hour cfg.TLSInterval = 24 * time.Hour @@ -871,16 +882,16 @@ func TestNSFailureAndRecovery(t *testing.T) { t.Parallel() cfg := defaultTestConfig(t) - cfg.Hostnames = []string{"www.example.com"} + cfg.Hostnames = []string{testHost} w, deps := newTestWatcher(t, cfg) - deps.resolver.allRecords["www.example.com"] = map[string]map[string][]string{ - "ns1.example.com.": {"A": {"1.2.3.4"}}, - "ns2.example.com.": {"A": {"1.2.3.4"}}, + deps.resolver.allRecords[testHost] = map[string]map[string][]string{ + testNS1: {"A": {testIP}}, + testNS2: {"A": {testIP}}, } - deps.resolver.ipAddresses["www.example.com"] = []string{ - "1.2.3.4", + deps.resolver.ipAddresses[testHost] = []string{ + testIP, } deps.portChecker.results["1.2.3.4:80"] = false deps.portChecker.results["1.2.3.4:443"] = false @@ -890,8 +901,8 @@ func TestNSFailureAndRecovery(t *testing.T) { w.RunOnce(ctx) deps.resolver.mu.Lock() - deps.resolver.allRecords["www.example.com"] = map[string]map[string][]string{ - "ns1.example.com.": {"A": {"1.2.3.4"}}, + deps.resolver.allRecords[testHost] = map[string]map[string][]string{ + testNS1: {"A": {testIP}}, } deps.resolver.mu.Unlock() diff --git a/script/bootstrap b/script/bootstrap index ffcb29a..129cc77 100755 --- a/script/bootstrap +++ b/script/bootstrap @@ -9,9 +9,9 @@ set -eu ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" -# Pinned versions, 2026-07-07 (same pins as the Dockerfile) -# golangci-lint v2.10.1 -GOLANGCI_LINT_REF="github.com/golangci/golangci-lint/v2/cmd/golangci-lint@5d1e709b7be35cb2025444e19de266b056b7b7ee" +# Pinned versions, 2026-08-07 (same pins as the Dockerfile) +# golangci-lint v2.12.2 +GOLANGCI_LINT_REF="github.com/golangci/golangci-lint/v2/cmd/golangci-lint@c0d3ddc9cf3faa61a4e378e879ece580256d76e5" # goimports v0.42.0 GOIMPORTS_REF="golang.org/x/tools/cmd/goimports@009367f5c17a8d4c45a961a3a509277190a9a6f0" -- 2.49.1