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
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. |