a73x

46dfdd1e

test(api): the enroll limiter, the key-comment bound and the reap grace carry their own numbers

a73x   2026-08-23 11:12

Commit message
test(api): the enroll limiter, the key-comment bound and the reap grace carry their own numbers

The limiter file was written entirely in terms of enrollBurst,
enrollRefillEvery and maxLimiterEntries, so it passed with a 30ms refill
and a 10-entry cap — a limiter that limits nothing. Three tests now drive
the boundary against literals: five requests then a refusal, a token that
takes the full 30s to drip back, ten thousand addresses tracked before
pruning starts. describeKey's truncation was already pinned; the 128-byte
bound now is too, from both sides. The reap sweep is driven at 14 and 16
minutes rather than at multiples of the grace it is testing.

internal/server/api/abandoned_vm_test.go
Old New
@@ -46,9 +46,13 @@ func TestAbandonedTombstoneOnOfflineHostIsReaped(t *testing.T) {
46 ts, st, _, _, a := newServer(t) 46 ts, st, _, _, a := newServer(t)
47 id := tombstoneOneVM(t, ts, st) 47 id := tombstoneOneVM(t, ts, st)
48 48
49 // Well past the abandoned-reap grace, host still offline (never reported). 49 // Sixteen minutes on, host still offline (never reported). The clock offsets
50 assert.True(t, a.sweepAbandonedVMs(time.Now().Add(2*abandonedVMReapGrace)), 50 // here are literals, not multiples of abandonedVMReapGrace: a sweep driven
51 "sweep should reap a long-tombstoned VM on an offline host") 51 // by the constant it is testing reaps on schedule at any grace, including
52 // the millisecond that would force-delete a VM whose agent is mid-restart.
53 assert.True(t, a.sweepAbandonedVMs(time.Now().Add(16*time.Minute)),
54 "a VM tombstoned sixteen minutes ago on an offline host must be reaped: past the grace nobody is coming to ack the "+
55 "destroy, and the row sits in the operator's console forever")
52 56
53 _, err := st.GetVM(id) 57 _, err := st.GetVM(id)
54 assert.True(t, errors.Is(err, sql.ErrNoRows), "the zombie row must be gone after the sweep") 58 assert.True(t, errors.Is(err, sql.ErrNoRows), "the zombie row must be gone after the sweep")
@@ -63,6 +67,10 @@ func TestFreshTombstoneIsNotReaped(t *testing.T) {
63 67
64 assert.False(t, a.sweepAbandonedVMs(time.Now()), 68 assert.False(t, a.sweepAbandonedVMs(time.Now()),
65 "a VM tombstoned within the grace must not be force-deleted") 69 "a VM tombstoned within the grace must not be force-deleted")
70 assert.False(t, a.sweepAbandonedVMs(time.Now().Add(14*time.Minute)),
71 "and the grace must still be running fourteen minutes in: it is the window an agent has to come back and ack its "+
72 "own destroy — an agent restart or a host redeploy takes minutes, and a grace shorter than that turns every "+
73 "transient outage into a server-side force-delete of a VM the agent was about to tear down properly")
66 74
67 _, err := st.GetVM(id) 75 _, err := st.GetVM(id)
68 assert.NoError(t, err, "row must survive a within-grace sweep") 76 assert.NoError(t, err, "row must survive a within-grace sweep")
@@ -80,7 +88,7 @@ func TestOnlineHostTombstoneIsNotReaped(t *testing.T) {
80 require.NoError(t, err) 88 require.NoError(t, err)
81 a.reg.UpdateReport(vms[0].HostID, registry.Report{}) 89 a.reg.UpdateReport(vms[0].HostID, registry.Report{})
82 90
83 assert.False(t, a.sweepAbandonedVMs(time.Now().Add(2*abandonedVMReapGrace)), 91 assert.False(t, a.sweepAbandonedVMs(time.Now().Add(16*time.Minute)),
84 "a live agent owns the reap; the server must not force-delete") 92 "a live agent owns the reap; the server must not force-delete")
85 93
86 _, err = st.GetVM(id) 94 _, err = st.GetVM(id)
internal/server/api/injectedkey_test.go
Old New
@@ -44,6 +44,18 @@ func TestDescribeKey(t *testing.T) {
44 long := "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIM0uJyEWzAFdAelGXHwoFgSRL+py8ZMonqWw+M4wj6HG " + 44 long := "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIM0uJyEWzAFdAelGXHwoFgSRL+py8ZMonqWw+M4wj6HG " +
45 strings.Repeat("c", 5000) 45 strings.Repeat("c", 5000)
46 _, _, comment := describeKey(long) 46 _, _, comment := describeKey(long)
47 assert.LessOrEqual(t, len(comment), maxKeyCommentLen) 47 // 128 is a literal on both sides on purpose: the truncation itself is
48 // pinned by the assertion above, but a bound compared to the constant
49 // that sets it holds at 4096 too, and the number is the whole point.
50 assert.LessOrEqual(t, len(comment), 128,
51 "a key comment is free text that lands on the VM row and renders in the console: 128 bytes is the bound that "+
52 "keeps a hostile 5000-byte line from filling the column and the page, and an authorized_keys comment is "+
53 "conventionally a short user@host, so nothing legitimate is near it")
54
55 _, _, ordinary := describeKey("ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIM0uJyEWzAFdAelGXHwoFgSRL+py8ZMonqWw+M4wj6HG " +
56 "alex@workstation.example.com")
57 assert.Equal(t, "alex@workstation.example.com", ordinary,
58 "and the bound must stay well clear of a real comment: cut it short and every operator sees their own key "+
59 "described by a mangled prefix of the name they gave it")
48 }) 60 })
49 } 61 }
internal/server/api/ratelimit_test.go
Old New
@@ -6,6 +6,7 @@ import (
6 "time" 6 "time"
7 7
8 "github.com/stretchr/testify/assert" 8 "github.com/stretchr/testify/assert"
9 "github.com/stretchr/testify/require"
9 ) 10 )
10 11
11 // TestLimiterRefillAndCap pins the token-bucket math against a fake clock: 12 // TestLimiterRefillAndCap pins the token-bucket math against a fake clock:
@@ -54,6 +55,64 @@ func TestLimiterPruneAndFailOpen(t *testing.T) {
54 assert.Len(t, l.buckets, 1, "idle buckets pruned, new bucket tracked") 55 assert.Len(t, l.buckets, 1, "idle buckets pruned, new bucket tracked")
55 } 56 }
56 57
58 // The three tests below pin the limiter's numbers against literals. The rest of
59 // this file is written in terms of enrollBurst, enrollRefillEvery and
60 // maxLimiterEntries — correct as construction, but it means the whole file
61 // passes with the refill at 30ms and the entry cap at 10, which is a limiter
62 // that limits nothing.
63
64 func TestEnrollBurstIsFiveRequests(t *testing.T) {
65 now := time.Unix(1_750_000_000, 0)
66 l := newIPLimiter(func() time.Time { return now })
67
68 allowed := 0
69 for range 100 {
70 if !l.allow("10.0.0.1") {
71 break
72 }
73 allowed++
74 }
75
76 assert.Equal(t, 5, allowed,
77 "POST /api/v1/enroll is the plane's only unauthenticated endpoint, and this burst is how many enrollment tokens one "+
78 "address may guess before it has to wait. Enrollment is operator-paced — one paste per host — so five covers a "+
79 "fat-fingered retry; raise it and token-guessing gets cheaper by exactly that factor, drop it to zero and no "+
80 "host can ever enroll")
81 }
82
83 func TestEnrollRefillTakesThirtySeconds(t *testing.T) {
84 now := time.Unix(1_750_000_000, 0)
85 l := newIPLimiter(func() time.Time { return now })
86 for range enrollBurst {
87 require.True(t, l.allow("10.0.0.1"))
88 }
89 require.False(t, l.allow("10.0.0.1"), "burst exhausted")
90
91 now = now.Add(29 * time.Second)
92 assert.False(t, l.allow("10.0.0.1"),
93 "a token must take a full 30 seconds to drip back. The burst is only a brake if the wait after it costs an attacker "+
94 "real time: at a refill of milliseconds the bucket is always full and the limiter is decoration")
95
96 now = now.Add(time.Second)
97 assert.True(t, l.allow("10.0.0.1"),
98 "and no longer than 30 seconds: an operator enrolling a rack should not be made to wait minutes between hosts")
99 }
100
101 func TestLimiterTracksTenThousandAddresses(t *testing.T) {
102 now := time.Unix(1_750_000_000, 0)
103 l := newIPLimiter(func() time.Time { return now })
104
105 for i := range 10_000 {
106 l.allow(fmt.Sprintf("10.%d.%d.%d", i>>16&0xff, i>>8&0xff, i&0xff))
107 }
108
109 assert.Len(t, l.buckets, 10_000,
110 "the bucket map must hold ten thousand live addresses before it starts pruning. The cap is there so a spoofed-source "+
111 "flood cannot grow the map without bound, but it is also how many distinct clients the limiter can brake at once: "+
112 "set it low and the eleventh address evicts a real one, so a flood of fresh sources prunes the buckets holding "+
113 "the actual attacker and the requests that matter fail open")
114 }
115
57 // TestBucketKeyGroupsIPv6BySlash64 pins the bucket-key normalization: IPv4 116 // TestBucketKeyGroupsIPv6BySlash64 pins the bucket-key normalization: IPv4
58 // (and v4-mapped-v6) keep per-address buckets; IPv6 collapses to the /64 so a 117 // (and v4-mapped-v6) keep per-address buckets; IPv6 collapses to the /64 so a
59 // single host's billions of addresses share one bucket; garbage stays raw. 118 // single host's billions of addresses share one bucket; garbage stays raw.