mirror of
https://github.com/Control-D-Inc/ctrld.git
synced 2026-10-12 01:58:46 +02:00
201 lines
6.9 KiB
Go
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")
|
|
}
|
|
}
|