Files
ctrld/cmd/cli/change_logger_test.go
T
Anthony Wong 8ae09ac98e feat(cli): put the network state of the host in the log journal
The journal records the network state of the host and each change of
that state. The support team reads one file to see the interfaces, the
routes, the resolvers, and the recoveries around an incident. The same
file shows the health of the query path. A change that touches only
AirDrop or virtual interfaces runs no DNS work.
2026-09-28 16:49:05 +07:00

199 lines
6.5 KiB
Go

package cli
import (
"slices"
"testing"
"time"
)
func Test_repeatLogger(t *testing.T) {
var repeats repeatLogger
for _, step := range []struct {
name string
key string
value string
wantChanged bool
wantRepeats uint64
}{
{"the first value of a key is a change", "pf anchor", "intact", true, 0},
{"the same value repeats", "pf anchor", "intact", false, 1},
{"the same value repeats again", "pf anchor", "intact", false, 2},
{"a new value reports the run before it", "pf anchor", "missing", true, 2},
{"the new value repeats", "pf anchor", "missing", false, 1},
{"the value before is a change again", "pf anchor", "intact", true, 1},
{"a second key starts on its own", "tunnels", "utun0", true, 0},
{"the second key repeats", "tunnels", "utun0", false, 1},
{"the first key keeps its own count", "pf anchor", "intact", false, 1},
} {
t.Run(step.name, func(t *testing.T) {
changed, count := repeats.changed(step.key, step.value)
if changed != step.wantChanged || count != step.wantRepeats {
t.Errorf("changed(%q, %q) = %v, %d, want %v, %d",
step.key, step.value, changed, count, step.wantChanged, step.wantRepeats)
}
})
}
}
func Test_wakeReporter(t *testing.T) {
base := time.Date(2026, 9, 17, 10, 0, 0, 0, time.UTC)
var wake wakeReporter
var armed []func()
wake.after = func(_ time.Duration, fn func()) { armed = append(armed, fn) }
var emitted []wakeReport
emit := func(report wakeReport) { emitted = append(emitted, report) }
fireHeld := func() {
for _, fn := range armed {
fn()
}
armed = nil
}
// netmon reports first and knows no gap, so its report waits.
wake.note(base, wakeReport{source: "netmon"}, emit)
if len(emitted) != 0 || len(armed) != 1 {
t.Fatalf("a report without a gap must wait: emitted %d, timers %d", len(emitted), len(armed))
}
// The detector reports inside the hold with the gap, so it replaces the
// held report.
wake.note(base.Add(2*time.Second), wakeReport{source: "detector", gap: 90 * time.Second}, emit)
fireHeld()
if len(emitted) != 1 || emitted[0].source != "detector" {
t.Fatalf("emitted %+v, want the detector report alone", emitted)
}
// Every other report of this wake is the same wake.
wake.note(base.Add(10*time.Second), wakeReport{source: "netmon"}, emit)
wake.note(base.Add(12*time.Second), wakeReport{source: "detector", gap: 91 * time.Second}, emit)
if len(emitted) != 1 {
t.Fatalf("emitted %d reports inside the window, want 1", len(emitted))
}
// A wake that only netmon sees reports without a gap when the hold ends.
wake.note(base.Add(40*time.Second), wakeReport{source: "netmon"}, emit)
if len(emitted) != 1 || len(armed) != 1 {
t.Fatalf("a new wake must arm one hold: emitted %d, timers %d", len(emitted), len(armed))
}
fireHeld()
if len(emitted) != 2 || emitted[1].source != "netmon" || emitted[1].gap != 0 {
t.Fatalf("emitted %+v, want the netmon report after the hold", emitted)
}
// A gap that arrives after the hold is too late: one event per wake.
wake.note(base.Add(50*time.Second), wakeReport{source: "detector", gap: 92 * time.Second}, emit)
if len(emitted) != 2 {
t.Fatalf("emitted %d reports, want no second report of one wake", len(emitted))
}
// The detector first reports at once, and netmon adds nothing.
wake.note(base.Add(80*time.Second), wakeReport{source: "detector", gap: 93 * time.Second}, emit)
wake.note(base.Add(82*time.Second), wakeReport{source: "netmon"}, emit)
fireHeld()
if len(emitted) != 3 || emitted[2].source != "detector" || len(armed) != 0 {
t.Fatalf("emitted %+v with %d timers, want one detector report and no hold", emitted, len(armed))
}
}
func Test_noiseCoalescer(t *testing.T) {
base := time.Date(2026, 9, 17, 10, 0, 0, 0, time.UTC)
t.Run("a storm reports once every ten minutes", func(t *testing.T) {
const deltas = 100
const cadence = 15 * time.Second
var noise noiseCoalescer
var got []noiseSummary
for i := 0; i < deltas; i++ {
interfaces := []string{"awdl0"}
if i%2 == 1 {
interfaces = []string{"llw0", "awdl0"}
}
summary, emit := noise.add(interfaces, base.Add(time.Duration(i)*cadence))
if !emit {
continue
}
got = append(got, summary)
}
want := []noiseSummary{{
Count: 1,
First: base,
Last: base,
Interfaces: []string{"awdl0"},
}, {
Count: 40,
First: base.Add(cadence),
Last: base.Add(10 * time.Minute),
Interfaces: []string{"awdl0", "llw0"},
}, {
Count: 40,
First: base.Add(10*time.Minute + cadence),
Last: base.Add(20 * time.Minute),
Interfaces: []string{"awdl0", "llw0"},
}}
assertSummaries(t, got, want)
})
t.Run("a flush reports the open window", func(t *testing.T) {
var noise noiseCoalescer
if _, emit := noise.flush(base); emit {
t.Fatal("an empty coalescer reported a summary")
}
for _, offset := range []time.Duration{0, time.Minute, 2 * time.Minute} {
noise.add([]string{"awdl0"}, base.Add(offset))
}
summary, emit := noise.flush(base.Add(3 * time.Minute))
if !emit {
t.Fatal("the flush reported no summary for an open window")
}
assertSummaries(t, []noiseSummary{summary}, []noiseSummary{{
Count: 2,
First: base.Add(time.Minute),
Last: base.Add(2 * time.Minute),
Interfaces: []string{"awdl0"},
}})
if _, emit = noise.flush(base.Add(4 * time.Minute)); emit {
t.Fatal("the flush reported the same window twice")
}
})
t.Run("a gap longer than the interval starts a new run", func(t *testing.T) {
var noise noiseCoalescer
var got []noiseSummary
for _, offset := range []time.Duration{0, time.Minute, 2 * time.Minute, 17 * time.Minute} {
summary, emit := noise.add([]string{"awdl0"}, base.Add(offset))
if !emit {
continue
}
got = append(got, summary)
}
want := []noiseSummary{{
Count: 1,
First: base,
Last: base,
Interfaces: []string{"awdl0"},
}, {
Count: 1,
First: base.Add(17 * time.Minute),
Last: base.Add(17 * time.Minute),
Interfaces: []string{"awdl0"},
}}
assertSummaries(t, got, want)
})
}
func assertSummaries(t *testing.T, got, want []noiseSummary) {
t.Helper()
if len(got) != len(want) {
t.Fatalf("got %d summaries, want %d: %+v", len(got), len(want), got)
}
for i := range want {
if !sameSummary(got[i], want[i]) {
t.Errorf("summary %d = %+v, want %+v", i, got[i], want[i])
}
}
}
func sameSummary(got, want noiseSummary) bool {
return got.Count == want.Count &&
got.First.Equal(want.First) &&
got.Last.Equal(want.Last) &&
slices.Equal(got.Interfaces, want.Interfaces)
}