diff --git a/appview/knotcompat/version_test.go b/appview/knotcompat/version_test.go new file mode 100644 --- /dev/null +++ b/appview/knotcompat/version_test.go @@ -0,0 +1,340 @@ +package knotcompat + +import ( + "strconv" + "sync" + "testing" + "time" +) + +type fakeLatch struct { + mu sync.Mutex + native map[string]bool + marks []string + reads int +} + +func newFakeLatch() *fakeLatch { + return &fakeLatch{native: map[string]bool{}} +} + +func (f *fakeLatch) IsNative(host string) bool { + f.mu.Lock() + defer f.mu.Unlock() + f.reads++ + return f.native[host] +} + +func (f *fakeLatch) readCount() int { + f.mu.Lock() + defer f.mu.Unlock() + return f.reads +} + +func (f *fakeLatch) MarkNative(host string) { + f.mu.Lock() + defer f.mu.Unlock() + f.native[host] = true + f.marks = append(f.marks, host) +} + +func (f *fakeLatch) markCount() int { + f.mu.Lock() + defer f.mu.Unlock() + return len(f.marks) +} + +func probeReturning(v bool, calls *int) func() bool { + return func() bool { + *calls++ + return v + } +} + +func TestNativeGateMemoSkipsSecondProbe(t *testing.T) { + g := &nativeGate{} + calls := 0 + probe := probeReturning(true, &calls) + + if !g.isNative("clam.nel.pet", probe) { + t.Fatal("first probe true: want native") + } + if !g.isNative("clam.nel.pet", probe) { + t.Fatal("memoized: want native") + } + if calls != 1 { + t.Fatalf("calls = %d, want 1; a memoized native host must skip the probe", calls) + } +} + +func TestNativeGateLatchHitSkipsProbe(t *testing.T) { + g := &nativeGate{} + fl := newFakeLatch() + fl.native["whelk.nel.pet"] = true + g.use(fl) + + calls := 0 + if !g.isNative("whelk.nel.pet", probeReturning(false, &calls)) { + t.Fatal("latched native: want native even though the probe would fail") + } + if calls != 0 { + t.Fatalf("calls = %d, want 0; a latched host must never be probed", calls) + } + if fl.markCount() != 0 { + t.Fatalf("marks = %d, want 0; a latch hit must not re-mark", fl.markCount()) + } +} + +func TestNativeGateProbeMarksLatchOnce(t *testing.T) { + g := &nativeGate{} + fl := newFakeLatch() + g.use(fl) + + calls := 0 + probe := probeReturning(true, &calls) + if !g.isNative("limpet.nel.pet", probe) { + t.Fatal("probe true: want native") + } + if fl.markCount() != 1 { + t.Fatalf("marks = %d, want 1; a first successful probe must latch the host", fl.markCount()) + } + + g.isNative("limpet.nel.pet", probe) + if fl.markCount() != 1 { + t.Fatalf("marks = %d, want 1; the memo must prevent a second mark", fl.markCount()) + } +} + +func TestNativeGateProbeFalseDoesNotMark(t *testing.T) { + g := &nativeGate{} + fl := newFakeLatch() + g.use(fl) + + calls := 0 + if g.isNative("clam.nel.pet", probeReturning(false, &calls)) { + t.Fatal("probe false on a fresh host: want not native") + } + if fl.markCount() != 0 { + t.Fatalf("marks = %d, want 0; a failed probe must not latch", fl.markCount()) + } +} + +func TestNativeGateDurableAcrossMemoReset(t *testing.T) { + fl := newFakeLatch() + + warm := &nativeGate{} + warm.use(fl) + calls := 0 + if !warm.isNative("whelk.nel.pet", probeReturning(true, &calls)) { + t.Fatal("warm gate probe true: want native") + } + + restarted := &nativeGate{} + restarted.use(fl) + cold := 0 + if !restarted.isNative("whelk.nel.pet", probeReturning(false, &cold)) { + t.Fatal("after restart the durable latch must resolve native without a probe") + } + if cold != 0 { + t.Fatalf("calls = %d, want 0; a durably latched host survives a memo reset without probing", cold) + } +} + +func TestNativeGateNegativeMemoThrottlesLatchReads(t *testing.T) { + now := time.Now() + g := &nativeGate{now: func() time.Time { return now }} + fl := newFakeLatch() + g.use(fl) + + calls := 0 + probe := probeReturning(false, &calls) + + for range 5 { + if g.isNative("clam.nel.pet", probe) { + t.Fatal("a probe-false host must not be native") + } + } + if fl.readCount() != 1 { + t.Fatalf("latch reads = %d, want 1; the negative memo must throttle repeat latch reads within the window", fl.readCount()) + } + if calls != 1 { + t.Fatalf("probe calls = %d, want 1; the negative memo must throttle repeat probes within the window", calls) + } + + now = now.Add(versionProbeFresh + time.Second) + if g.isNative("clam.nel.pet", probe) { + t.Fatal("still not native after the window") + } + if fl.readCount() != 2 { + t.Fatalf("latch reads = %d, want 2; an expired negative memo must re-read the latch", fl.readCount()) + } + if calls != 2 { + t.Fatalf("probe calls = %d, want 2; an expired negative memo must re-probe", calls) + } +} + +func TestNativeGateNegativeMemoNeverShadowsLatchedNative(t *testing.T) { + now := time.Now() + g := &nativeGate{now: func() time.Time { return now }} + fl := newFakeLatch() + g.use(fl) + + calls := 0 + if g.isNative("whelk.nel.pet", probeReturning(false, &calls)) { + t.Fatal("probe false on a fresh host: want not native") + } + + fl.mu.Lock() + fl.native["whelk.nel.pet"] = true + fl.mu.Unlock() + + now = now.Add(versionProbeFresh + time.Second) + if !g.isNative("whelk.nel.pet", probeReturning(false, &calls)) { + t.Fatal("once the negative memo expires a latched host must resolve native again") + } +} + +func TestAtLeast(t *testing.T) { + cases := []struct { + in string + minMajor int + minMinor int + want bool + }{ + {"v1.14.0", 1, 14, true}, + {"v1.14.0-alpha", 1, 14, true}, + {"v1.14.5", 1, 14, true}, + {"v1.13.0", 1, 14, false}, + {"v1.13.0-alpha", 1, 14, false}, + {"v1.0.0", 1, 14, false}, + {"v2.0.0", 1, 14, true}, + {"1.14.0", 1, 14, true}, + {"1.13.99", 1, 14, false}, + {"(devel)", 1, 14, true}, + {"", 1, 14, false}, + {"garbagio-furioso", 1, 14, false}, + {"v1", 1, 14, false}, + {"vX.Y.Z", 1, 14, false}, + {"unknown", 1, 14, false}, + {"unknown-abc1234", 1, 14, false}, + {"unknown-abc1234-modified", 1, 14, false}, + {"v1.15.0", 1, 15, true}, + {"v1.15.2-alpha", 1, 15, true}, + {"v1.16.0", 1, 15, true}, + {"v1.14.1-alpha", 1, 15, false}, + {"v1.14.9", 1, 15, false}, + {"v2.0.0", 1, 15, true}, + {"(devel)", 1, 15, true}, + {"", 1, 15, false}, + } + for _, c := range cases { + t.Run(c.in, func(t *testing.T) { + if got := atLeast(c.in, c.minMajor, c.minMinor); got != c.want { + t.Errorf("atLeast(%q, %d, %d) = %v, want %v", c.in, c.minMajor, c.minMinor, got, c.want) + } + }) + } +} + +func newProbeCache() *versionProbeCache { + return &versionProbeCache{entries: map[string]versionProbeEntry{}} +} + +func TestVersionProbeCacheFreshSkipsProbe(t *testing.T) { + c := newProbeCache() + now := time.Unix(1_000_000, 0) + calls := 0 + probe := func() (string, bool) { calls++; return "v1.15.0", true } + + if !c.supports(now, "knot.nel.pet", 1, 15, false, probe) { + t.Fatal("cold probe: want supported") + } + if calls != 1 { + t.Fatalf("calls = %d, want 1 after cold probe", calls) + } + if !c.supports(now.Add(time.Minute), "knot.nel.pet", 1, 15, false, probe) { + t.Fatal("cached: want supported") + } + if calls != 1 { + t.Fatalf("calls = %d, want 1; a fresh cache entry must skip the probe", calls) + } +} + +func TestVersionProbeCacheServesStaleOnFailure(t *testing.T) { + c := newProbeCache() + now := time.Unix(1_000_000, 0) + if !c.supports(now, "knot.nel.pet", 1, 15, false, func() (string, bool) { return "v1.15.0", true }) { + t.Fatal("seed: want supported") + } + + failProbe := func() (string, bool) { return "", false } + if !c.supports(now.Add(10*time.Minute), "knot.nel.pet", 1, 15, false, failProbe) { + t.Error("a probe failure within the trust window must serve the last-known version, not fail closed") + } +} + +func TestVersionProbeCacheFailsClosedWhenUntrusted(t *testing.T) { + c := newProbeCache() + now := time.Unix(1_000_000, 0) + failProbe := func() (string, bool) { return "", false } + if c.supports(now, "knot.nel.pet", 1, 15, false, failProbe) { + t.Error("a cold probe failure on a fail-closed gate must return false") + } + if !c.supports(now, "knot.nel.pet", 1, 14, true, failProbe) { + t.Error("a cold probe failure on a fail-open gate must return true") + } +} + +func TestVersionProbeCacheExpiresTrust(t *testing.T) { + c := newProbeCache() + now := time.Unix(1_000_000, 0) + if !c.supports(now, "knot.nel.pet", 1, 15, false, func() (string, bool) { return "v1.15.0", true }) { + t.Fatal("seed: want supported") + } + if c.supports(now.Add(2*time.Hour), "knot.nel.pet", 1, 15, false, func() (string, bool) { return "", false }) { + t.Error("a probe failure past the trust window must fail closed") + } +} + +func TestVersionProbeCacheRefreshesAfterFresh(t *testing.T) { + c := newProbeCache() + now := time.Unix(1_000_000, 0) + if c.supports(now, "knot.nel.pet", 1, 15, false, func() (string, bool) { return "v1.14.0", true }) { + t.Fatal("seed: 1.14 must not satisfy 1.15") + } + if !c.supports(now.Add(10*time.Minute), "knot.nel.pet", 1, 15, false, func() (string, bool) { return "v1.15.0", true }) { + t.Error("a re-probe past the fresh window must pick up the upgraded version") + } +} + +func TestVersionProbeCacheEnforcesHardCap(t *testing.T) { + c := newProbeCache() + now := time.Unix(1_000_000, 0) + probe := func() (string, bool) { return "v1.15.0", true } + for i := 0; i < versionProbeCacheMax+200; i++ { + c.supports(now, "knot"+strconv.Itoa(i)+".nel.pet", 1, 15, false, probe) + } + if len(c.entries) > versionProbeCacheMax { + t.Fatalf("entries = %d, must never exceed cap %d even when every entry is fresh", len(c.entries), versionProbeCacheMax) + } +} + +func TestVersionProbeCacheEvictsOldestWhenFull(t *testing.T) { + c := newProbeCache() + base := time.Unix(1_000_000, 0) + probe := func() (string, bool) { return "v1.15.0", true } + for i := 0; i < versionProbeCacheMax; i++ { + c.supports(base.Add(time.Duration(i)*time.Millisecond), "knot"+strconv.Itoa(i)+".nel.pet", 1, 15, false, probe) + } + c.supports(base.Add(versionProbeCacheMax*time.Millisecond), "newcomer.nel.pet", 1, 15, false, probe) + + if len(c.entries) != versionProbeCacheMax { + t.Fatalf("entries = %d, want exactly cap %d after evicting one for the newcomer", len(c.entries), versionProbeCacheMax) + } + if _, ok := c.get("knot0.nel.pet"); ok { + t.Error("oldest entry must be evicted to make room") + } + if _, ok := c.get("newcomer.nel.pet"); !ok { + t.Error("newcomer must be retained") + } +}