Files
ctrld/cmd/cli/dns_target_diagnostic_test.go
T

201 lines
6.9 KiB
Go

package cli
import (
"context"
"errors"
"fmt"
"os"
"os/exec"
"strings"
"testing"
"time"
)
const (
dnsTargetFailedMessage = "intercept DNS target: decision unavailable; DNS unchanged"
dnsTargetResolvedMessage = "intercept DNS target: decision available again"
)
func TestDNSTargetReadErrorClass(t *testing.T) {
for _, tc := range []struct {
name string
err error
want string
}{
{"missing", nil, "unavailable"},
{"deadline", fmt.Errorf("wrapped: %w", context.DeadlineExceeded), "timeout"},
{"canceled", context.Canceled, "canceled"},
{"permission", &os.PathError{Op: "read", Path: "private", Err: os.ErrPermission}, "permission_denied"},
{"command", &exec.ExitError{}, "command_failed"},
{"wrapped command", fmt.Errorf("wrapped: %w", &exec.ExitError{}), "command_failed"},
{"untyped error", errors.New("exit status 1"), "read_failed"},
} {
t.Run(tc.name, func(t *testing.T) {
if got := dnsTargetReadErrorClass(tc.err); got != tc.want {
t.Fatalf("class = %q, want %q", got, tc.want)
}
})
}
}
func TestDNSTargetDiagnosticFailureAndResolution(t *testing.T) {
for _, owned := range []bool{false, true} {
t.Run(fmt.Sprintf("owned=%t", owned), func(t *testing.T) {
logs := captureDebugMainLog(t)
d := dnsTargetDecisionDiagnostic{}
c := dnsTargetDecisionContext{iface: "en1", service: "Wi-Fi", generation: 3, transitionID: 7}
if owned {
c.ownership = dnsTargetOwnership{"Wi-Fi", "127.0.0.53"}
}
// A healthy pass must not turn the watchdog into a journal heartbeat.
d.resolved(c, c.ownership, "dns_less_network")
failure := errors.New("private.example must not be retained")
d.failed(c, "dhcp_dns", failure)
for range 100 {
c.generation++
c.transitionID++
d.failed(c, "dhcp_dns", failure)
}
failed := oneRecoveryEvent(t, logs, dnsTargetFailedMessage)
wantField(t, failed, "journal", true)
wantField(t, failed, "level", "warn")
wantField(t, failed, "interface", "en1")
wantField(t, failed, "service", "Wi-Fi")
wantField(t, failed, "stage", "dhcp_dns")
wantField(t, failed, "error_class", "read_failed")
wantField(t, failed, "action", "unchanged")
wantField(t, failed, "ownership_before", owned)
wantField(t, failed, "ownership_after", owned)
wantField(t, failed, "recovery_generation", float64(3))
wantField(t, failed, "transition_id", float64(7))
d.resolved(c, c.ownership, "dns_less_network")
d.resolved(c, c.ownership, "dns_less_network")
resolved := oneRecoveryEvent(t, logs, dnsTargetResolvedMessage)
wantField(t, resolved, "journal", true)
wantField(t, resolved, "level", "info")
wantField(t, resolved, "repeat_count", float64(100))
wantField(t, resolved, "failure_recovery_generation", float64(3))
wantField(t, resolved, "failure_transition_id", float64(7))
wantField(t, resolved, "recovery_generation", float64(103))
wantField(t, resolved, "transition_id", float64(107))
if strings.Contains(logs.String(), "private.example") {
t.Fatal("raw read error leaked into diagnostics")
}
if d != (dnsTargetDecisionDiagnostic{}) {
t.Fatal("resolved diagnostic retained failure state")
}
if len(jsonLogEvents(t, logs, "")) != 2 {
t.Fatal("repeated failures or healthy decisions were logged")
}
})
}
}
func TestDNSTargetDiagnosticConditionChanges(t *testing.T) {
for _, change := range []string{"interface", "service", "ownership", "stage", "class"} {
t.Run(change, func(t *testing.T) {
logs := captureDebugMainLog(t)
d := dnsTargetDecisionDiagnostic{}
c := dnsTargetDecisionContext{iface: "en1", service: "Wi-Fi"}
err := errors.New("read failed")
stage := "dhcp_dns"
d.failed(c, stage, err)
d.failed(c, stage, err)
switch change {
case "interface":
c.iface = "en2"
case "service":
c.service = "USB"
case "ownership":
c.ownership = dnsTargetOwnership{"USB", "127.0.0.53"}
case "stage":
stage = "static_dns"
case "class":
err = context.DeadlineExceeded
}
d.failed(c, stage, err)
d.failed(c, stage, err)
if got := len(jsonLogEvents(t, logs, dnsTargetFailedMessage)); got != 2 {
t.Fatalf("condition-change failures = %d, want 2", got)
}
d.resolved(c, dnsTargetOwnership{}, "dhcp_ipv4_dns")
wantField(t, oneRecoveryEvent(t, logs, dnsTargetResolvedMessage), "repeat_count", float64(2))
// A new episode must log again and reset the suppressed count.
d.failed(c, stage, err)
if d.repeats != 0 {
t.Fatal("new episode retained old repeat count")
}
})
}
}
func TestDNSTargetDiagnosticRepeatCountSaturates(t *testing.T) {
captureDebugMainLog(t)
d := dnsTargetDecisionDiagnostic{}
c := dnsTargetDecisionContext{}
d.failed(c, "dhcp_dns", nil)
d.repeats = ^uint64(0)
d.failed(c, "dhcp_dns", nil)
if d.repeats != ^uint64(0) {
t.Fatal("repeat count wrapped")
}
}
func TestDNSTargetDiagnosticOwnershipActions(t *testing.T) {
owned := dnsTargetOwnership{"Wi-Fi", "127.0.0.53"}
for _, tc := range []struct {
action string
before, after dnsTargetOwnership
}{
{"set", dnsTargetOwnership{}, owned},
{"removed", owned, dnsTargetOwnership{}},
{"changed", owned, dnsTargetOwnership{"USB", "127.0.0.53"}},
{"unchanged", owned, owned},
} {
t.Run(tc.action, func(t *testing.T) {
logs := captureDebugMainLog(t)
d := dnsTargetDecisionDiagnostic{}
c := dnsTargetDecisionContext{ownership: tc.before}
d.failed(c, "dhcp_dns", os.ErrPermission)
d.resolved(c, tc.after, "dns_less_network")
event := oneRecoveryEvent(t, logs, dnsTargetResolvedMessage)
wantField(t, event, "action", tc.action)
wantField(t, event, "owned_service_before", tc.before.service)
wantField(t, event, "owned_service_after", tc.after.service)
wantField(t, event, "target_before", tc.before.value)
wantField(t, event, "target_after", tc.after.value)
})
}
}
func TestDNSTargetDiagnosticSurvivesDebugTruncation(t *testing.T) {
p, _ := startInternalLogging(t)
useTestSendBudget(t, 1024)
d := dnsTargetDecisionDiagnostic{}
c := dnsTargetDecisionContext{iface: "en1", service: "Wi-Fi"}
d.failed(c, "dhcp_dns", errors.New("private.example"))
for range 5 {
d.failed(c, "dhcp_dns", errors.New("private.example"))
}
d.resolved(c, dnsTargetOwnership{"Wi-Fi", "127.0.0.53"}, "dns_less_network")
for range 40 {
mainLog.Load().Debug().Msg(strings.Repeat("debug filler", 20))
}
start := time.Now()
debugPart, journalPart := splitUpload(t, readLogReader(t, p, false), start)
for _, message := range []string{dnsTargetFailedMessage, dnsTargetResolvedMessage} {
if strings.Contains(string(debugPart), message) {
t.Fatalf("fixture did not truncate %q from debug", message)
}
if strings.Count(string(journalPart), message) != 1 {
t.Fatalf("journal did not retain exactly one %q", message)
}
}
if !strings.Contains(string(journalPart), `"repeat_count":5`) {
t.Fatal("journal lost repeat count")
}
if strings.Contains(string(journalPart), "private.example") {
t.Fatal("raw error leaked into journal")
}
}