1 Commits

Author SHA1 Message Date
584b5f5b39 build: update golangci-lint to v2.12.2 with commit-pinned installs
All checks were successful
check / check (push) Successful in 1m0s
Pin golangci-lint to commit c0d3ddc9cf3faa61a4e378e879ece580256d76e5
(v2.12.2) in Dockerfile and script/bootstrap. Fix the goconst findings
the new version reports under the unchanged canonical .golangci.yml by
extracting shared test fixture constants and a statusError constant in
the watcher.
2026-08-07 20:21:36 +00:00
8 changed files with 153 additions and 187 deletions

View File

@@ -12,38 +12,21 @@ linters:
- depguard # Dependency allow/block lists - depguard # Dependency allow/block lists
- godot # Requires comments to end with periods - godot # Requires comments to end with periods
- wsl # Deprecated, replaced by wsl_v5 - wsl # Deprecated, replaced by wsl_v5
- gomodguard # Deprecated, replaced by gomodguard_v2
- wrapcheck # Too verbose for internal packages - wrapcheck # Too verbose for internal packages
- varnamelen # Short names like db, id are idiomatic Go - varnamelen # Short names like db, id are idiomatic Go
settings:
lll: linters-settings:
line-length: 88 lll:
funlen: line-length: 88
lines: 80 funlen:
statements: 50 lines: 80
cyclop: statements: 50
max-complexity: 15 cyclop:
dupl: max-complexity: 15
threshold: 100 dupl:
exclusions: threshold: 100
generated: lax
paths:
- third_party$
- builtin$
- examples$
issues: issues:
exclude-use-default: false
max-issues-per-linter: 0 max-issues-per-linter: 0
max-same-issues: 0 max-same-issues: 0
formatters:
enable:
- gofmt
- gofumpt
- goimports
exclusions:
generated: lax
paths:
- third_party$
- builtin$
- examples$

View File

@@ -17,26 +17,6 @@ without requiring an external database.
--- ---
## No DNS mocking. Ever.
**DNS is never mocked in this project — not in tests, not anywhere else.**
No mock resolvers, no fake DNS servers, no stubbed lookups.
dnswatcher's entire purpose is correct behavior against the real DNS.
Tests exercise real iterative resolution against live nameservers by
design; a test suite that passes against a mock proves nothing about the
one thing this program exists to do.
When live tests are flaky, that is a robustness problem, and it gets
fixed with robustness: retries with backoff, querying multiple
independent nameservers, longer timeouts — or explicit opt-in gating
decided by the project owner. Never with mocks.
Contributions that introduce mocked, faked, or stubbed DNS will be
rejected.
---
## Features ## Features
### DNS Domain Monitoring (Apex Domains) ### DNS Domain Monitoring (Apex Domains)

View File

@@ -26,11 +26,8 @@ confirm make check still passes.
# Completed Steps # Completed Steps
- 2026-08-07: golangci-lint bumped to v2.12.2 (commit-pinned installs - 2026-08-07: golangci-lint bumped to v2.12.2 (commit-pinned installs
in `Dockerfile` and `script/bootstrap`); `.golangci.yml` migrated to in `Dockerfile` and `script/bootstrap`); fixed the resulting
the v2 schema (owner-authorized; becomes the new org canonical), so `goconst` findings. `.golangci.yml` unchanged (canonical)
the lll/funlen/cyclop/dupl thresholds now apply; deprecated
`gomodguard` disabled in favor of `gomodguard_v2`; fixed the
resulting `goconst`, `dupl`, and `lll` findings
- 2026-07-07 Adopted scripts-to-rule-them-all: `script/` entrypoints, - 2026-07-07 Adopted scripts-to-rule-them-all: `script/` entrypoints,
Makefile shims, README Entrypoints section Makefile shims, README Entrypoints section
- 2026-02-20: iterative DNS resolver implemented; tests made hermetic - 2026-02-20: iterative DNS resolver implemented; tests made hermetic

View File

@@ -17,33 +17,13 @@ func TestClassifyDNSName(t *testing.T) {
}{ }{
{name: "apex domain simple", input: "example.com", want: config.DNSNameTypeDomain}, {name: "apex domain simple", input: "example.com", want: config.DNSNameTypeDomain},
{name: "hostname simple", input: "www.example.com", want: config.DNSNameTypeHostname}, {name: "hostname simple", input: "www.example.com", want: config.DNSNameTypeHostname},
{ {name: "apex domain multi-part TLD", input: "example.co.uk", want: config.DNSNameTypeDomain},
name: "apex domain multi-part TLD", {name: "hostname multi-part TLD", input: "api.example.co.uk", want: config.DNSNameTypeHostname},
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: "public suffix itself", input: "co.uk", wantErr: true},
{name: "empty string", input: "", wantErr: true}, {name: "empty string", input: "", wantErr: true},
{ {name: "deeply nested hostname", input: "a.b.c.example.com", want: config.DNSNameTypeHostname},
name: "deeply nested hostname", {name: "trailing dot stripped", input: "example.com.", want: config.DNSNameTypeDomain},
input: "a.b.c.example.com", {name: "uppercase normalized", input: "WWW.Example.COM", want: config.DNSNameTypeHostname},
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 { for _, tt := range tests {

View File

@@ -25,20 +25,18 @@ const (
colorDefault = "#6c757d" colorDefault = "#6c757d"
) )
// Priority strings used across multiple tests. // Priority and fixture values shared across tests.
const ( const (
prioError = "error" prioError = "error"
prioWarning = "warning" prioWarning = "warning"
prioSuccess = "success"
prioInfo = "info" prioInfo = "info"
prioSuccess = "success"
prioUnknown = "unknown" prioUnknown = "unknown"
prioDefault = "default" ntfyUrgent = "urgent"
prioUrgent = "urgent" ntfyDefault = "default"
testHost = "example.com"
) )
// testHost is the hostname used in request construction tests.
const testHost = "example.com"
// errSimulated is a static error for transport failures. // errSimulated is a static error for transport failures.
var errSimulated = errors.New("simulated transport failure") var errSimulated = errors.New("simulated transport failure")
@@ -115,13 +113,13 @@ func TestNtfyPriority(t *testing.T) {
input string input string
want string want string
}{ }{
{prioError, prioUrgent}, {prioError, ntfyUrgent},
{prioWarning, "high"}, {prioWarning, "high"},
{prioSuccess, prioDefault}, {prioSuccess, ntfyDefault},
{prioInfo, "low"}, {prioInfo, "low"},
{"", prioDefault}, {"", ntfyDefault},
{prioUnknown, prioDefault}, {prioUnknown, ntfyDefault},
{"critical", prioDefault}, {"critical", ntfyDefault},
} }
for _, tc := range cases { for _, tc := range cases {
@@ -303,10 +301,10 @@ func TestSendNtfyHeaders(t *testing.T) {
) )
} }
if captured.priority != prioUrgent { if captured.priority != ntfyUrgent {
t.Errorf( t.Errorf(
"Priority header = %q, want %q", "Priority header = %q, want %q",
captured.priority, prioUrgent, captured.priority, ntfyUrgent,
) )
} }
@@ -325,9 +323,9 @@ func TestSendNtfyAllPriorities(t *testing.T) {
input string input string
want string want string
}{ }{
{prioError, prioUrgent}, {prioError, ntfyUrgent},
{prioWarning, "high"}, {prioWarning, "high"},
{prioSuccess, prioDefault}, {prioSuccess, ntfyDefault},
{prioInfo, "low"}, {prioInfo, "low"},
} }
@@ -370,69 +368,56 @@ func TestSendNtfyAllPriorities(t *testing.T) {
} }
} }
// assertSendStatusError verifies that send returns an error func TestSendNtfyClientError(t *testing.T) {
// wrapping wantErr when the server responds with status. t.Parallel()
func assertSendStatusError(
t *testing.T,
status int,
wantErr error,
send func(*notify.Service, *url.URL) error,
) {
t.Helper()
srv := httptest.NewServer( srv := httptest.NewServer(
http.HandlerFunc( http.HandlerFunc(
func(w http.ResponseWriter, _ *http.Request) { func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(status) w.WriteHeader(http.StatusForbidden)
}), }),
) )
defer srv.Close() defer srv.Close()
svc := notify.NewTestService(srv.Client().Transport) svc := notify.NewTestService(srv.Client().Transport)
target, _ := url.Parse(srv.URL) topicURL, _ := url.Parse(srv.URL)
err := send(svc, target) err := svc.SendNtfy(
context.Background(), topicURL, "t", "m", "info",
)
if err == nil { if err == nil {
t.Fatalf("expected error for %d response", status) t.Fatal("expected error for 403 response")
} }
if !errors.Is(err, wantErr) { if !errors.Is(err, notify.ErrNtfyFailed) {
t.Errorf("error = %v, want %v", err, wantErr) t.Errorf("error = %v, want ErrNtfyFailed", err)
} }
} }
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) { func TestSendNtfyServerError(t *testing.T) {
t.Parallel() t.Parallel()
assertSendStatusError( srv := httptest.NewServer(
t, http.StatusInternalServerError, http.HandlerFunc(
notify.ErrNtfyFailed, sendNtfyInfo, func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
}),
) )
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) { func TestSendNtfySuccess(t *testing.T) {
@@ -633,19 +618,53 @@ func TestSendSlackAllColors(t *testing.T) {
func TestSendSlackClientError(t *testing.T) { func TestSendSlackClientError(t *testing.T) {
t.Parallel() t.Parallel()
assertSendStatusError( srv := httptest.NewServer(
t, http.StatusBadRequest, http.HandlerFunc(
notify.ErrSlackFailed, sendSlackInfo, func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusBadRequest)
}),
) )
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) { func TestSendSlackServerError(t *testing.T) {
t.Parallel() t.Parallel()
assertSendStatusError( srv := httptest.NewServer(
t, http.StatusBadGateway, http.HandlerFunc(
notify.ErrSlackFailed, sendSlackInfo, func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusBadGateway)
}),
) )
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) { func TestSendSlackNetworkError(t *testing.T) {
@@ -970,62 +989,74 @@ func TestSendNotificationMattermostOnly(t *testing.T) {
} }
} }
// assertSendNotificationTolerates verifies SendNotification func TestSendNotificationNtfyError(t *testing.T) {
// neither panics nor blocks when the endpoint configured by t.Parallel()
// setURL responds with status.
func assertSendNotificationTolerates(
t *testing.T,
status int,
priority string,
setURL func(*notify.Service, *url.URL),
) {
t.Helper()
srv := httptest.NewServer( srv := httptest.NewServer(
http.HandlerFunc( http.HandlerFunc(
func(w http.ResponseWriter, _ *http.Request) { func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(status) w.WriteHeader(http.StatusInternalServerError)
}), }),
) )
defer srv.Close() defer srv.Close()
target, _ := url.Parse(srv.URL) ntfyURL, _ := url.Parse(srv.URL)
svc := notify.NewTestService(http.DefaultTransport) svc := notify.NewTestService(http.DefaultTransport)
setURL(svc, target) svc.SetNtfyURL(ntfyURL)
// Should not panic or block.
svc.SendNotification( svc.SendNotification(
context.Background(), "t", "m", priority, context.Background(), "t", "m", "error",
) )
time.Sleep(100 * time.Millisecond) 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) { func TestSendNotificationSlackError(t *testing.T) {
t.Parallel() t.Parallel()
assertSendNotificationTolerates( srv := httptest.NewServer(
t, http.StatusForbidden, prioError, http.HandlerFunc(
(*notify.Service).SetSlackWebhookURL, func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusForbidden)
}),
) )
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) { func TestSendNotificationMattermostError(t *testing.T) {
t.Parallel() t.Parallel()
assertSendNotificationTolerates( srv := httptest.NewServer(
t, http.StatusBadGateway, prioWarning, http.HandlerFunc(
(*notify.Service).SetMattermostWebhookURL, func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusBadGateway)
}),
) )
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 ────────────────────────── // ── SlackPayload JSON marshaling ──────────────────────────

View File

@@ -69,7 +69,7 @@ func (rc RetryConfig) backoff(attempt int) time.Duration {
lo := raw * (1 - jitterFraction) lo := raw * (1 - jitterFraction)
hi := raw * (1 + jitterFraction) hi := raw * (1 + jitterFraction)
jittered := lo + rand.Float64()*(hi-lo) //nolint:gosec // jitter needs no crypto/rand jittered := lo + rand.Float64()*(hi-lo) //nolint:gosec // jitter does not need crypto/rand
return time.Duration(jittered) return time.Duration(jittered)
} }

View File

@@ -223,8 +223,7 @@ func TestSaveLoadRoundTrip_Ports(t *testing.T) {
} }
} }
// TestSaveLoadRoundTrip_Certificates verifies certificate data // TestSaveLoadRoundTrip_Certificates verifies certificate data survives a save/load cycle.
// survives a save/load cycle.
func TestSaveLoadRoundTrip_Certificates(t *testing.T) { func TestSaveLoadRoundTrip_Certificates(t *testing.T) {
t.Parallel() t.Parallel()
@@ -1073,8 +1072,7 @@ func TestConcurrentGetSet(t *testing.T) {
wg.Wait() wg.Wait()
} }
// runConcurrentOps performs a series of get/set/delete // runConcurrentOps performs a series of get/set/delete operations for concurrency testing.
// operations for concurrency testing.
func runConcurrentOps(s *state.State, key string, now time.Time) { func runConcurrentOps(s *state.State, key string, now time.Time) {
const iterations = 50 const iterations = 50

View File

@@ -26,11 +26,8 @@ const tlsPort = 443
// hoursPerDay converts days to hours for duration calculations. // hoursPerDay converts days to hours for duration calculations.
const hoursPerDay = 24 const hoursPerDay = 24
// Status values recorded for nameserver and certificate checks. // statusError is the status value recorded for failed checks.
const ( const statusError = "error"
statusOK = "ok"
statusError = "error"
)
// Params contains dependencies for Watcher. // Params contains dependencies for Watcher.
type Params struct { type Params struct {
@@ -350,7 +347,7 @@ func buildHostnameState(
for ns, recs := range records { for ns, recs := range records {
hs.RecordsByNameserver[ns] = &state.NameserverRecordState{ hs.RecordsByNameserver[ns] = &state.NameserverRecordState{
Records: recs, Records: recs,
Status: statusOK, Status: "ok",
LastChecked: now, LastChecked: now,
} }
} }
@@ -408,7 +405,7 @@ func (w *Watcher) detectNSDisappearances(
current map[string]map[string][]string, current map[string]map[string][]string,
) { ) {
for ns, prevNS := range prev.RecordsByNameserver { for ns, prevNS := range prev.RecordsByNameserver {
if _, ok := current[ns]; ok || prevNS.Status != statusOK { if _, ok := current[ns]; ok || prevNS.Status != "ok" {
continue continue
} }
@@ -711,7 +708,7 @@ func (w *Watcher) handleTLSError(
now time.Time, now time.Time,
err error, err error,
) { ) {
if hasPrev && !w.firstRun && prev.Status == statusOK { if hasPrev && !w.firstRun && prev.Status == "ok" {
msg := fmt.Sprintf( msg := fmt.Sprintf(
"Host: %s\nIP: %s\nError: %s", "Host: %s\nIP: %s\nError: %s",
hostname, ip, err, hostname, ip, err,
@@ -754,7 +751,7 @@ func (w *Watcher) handleTLSSuccess(
Issuer: cert.Issuer, Issuer: cert.Issuer,
NotAfter: cert.NotAfter, NotAfter: cert.NotAfter,
SubjectAlternativeNames: cert.SubjectAlternativeNames, SubjectAlternativeNames: cert.SubjectAlternativeNames,
Status: statusOK, Status: "ok",
LastChecked: now, LastChecked: now,
}, },
) )