a73x

e11ca299

test: eleven claims the comments made and nothing checked

a73x   2026-08-23 10:25

Commit message
test: eleven claims the comments made and nothing checked

Mutation-auditing the comments found sixty statements no test enforced.
These eleven are the ones where the mutation is a security bug: a host
that can name itself, tenant reads that answer from the wrong side of the
partition, guards with no witness.

Each test is proven by the mutant it exists to kill — applied, watched fail
with its intended message, restored. Two of the mutants fail nothing else in
their entire package.

- a session's identity is the credential's, never the Hello's
- snapshot recompute fans each tenant its own bytes
- TenantHasUserCA asked by the tenant that registered nothing
- token list and revoke called by someone who is not the seeded tenant
- revoked serials inspected rather than counted
- a prefixed-but-invalid PAT beside a valid session cookie
- HardDeleteVM against a live row, and its exposures
- sanitizeOrigin's eight clauses, one row apiece
- a hostile origin in the state param reaches neither page nor log
- the join blob is the sole trust root
- a host that advertised a network and then went quiet still refuses
- the cidr_pool is fixed at first Open

The failure message carries the why the deleted comment used to: a test that
says "must be" without saying what breaks is a comment with extra steps.

internal/agent/run/cli_test.go
Old New
@@ -2,11 +2,15 @@ package run
2 2
3 import ( 3 import (
4 "context" 4 "context"
5 "fmt"
6 "net/http"
7 "net/http/httptest"
5 "strings" 8 "strings"
6 "testing" 9 "testing"
7 "time" 10 "time"
8 11
9 "github.com/a73x/eitri/internal/agent/state" 12 "github.com/a73x/eitri/internal/agent/state"
13 "github.com/a73x/eitri/internal/joinblob"
10 "github.com/stretchr/testify/assert" 14 "github.com/stretchr/testify/assert"
11 "github.com/stretchr/testify/require" 15 "github.com/stretchr/testify/require"
12 ) 16 )
@@ -206,3 +210,40 @@ func TestServeNotEnrolled(t *testing.T) {
206 require.Error(t, err) 210 require.Error(t, err)
207 assert.True(t, strings.Contains(err.Error(), "not enrolled"), "got %q", err.Error()) 211 assert.True(t, strings.Contains(err.Error(), "not enrolled"), "got %q", err.Error())
208 } 212 }
213
214 // TestJoinPinsTheBlobsCertFingerprint proves the join blob is the sole trust
215 // root. The agent's TLS config compares every server it dials against this one
216 // stored fingerprint, so whatever join persists here decides which control
217 // plane the host will ever talk to. The fake plane answers with a decoy
218 // fingerprint in its JSON body: enrollclient.Response deliberately has no field
219 // to receive it, and this test is the witness that the omission is load-bearing
220 // rather than an oversight someone later "completes".
221 func TestJoinPinsTheBlobsCertFingerprint(t *testing.T) {
222 const blobFP = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
223 const decoyFP = "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff"
224
225 plane := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
226 require.Equal(t, "/api/v1/enroll", r.URL.Path)
227 w.Header().Set("Content-Type", "application/json")
228 w.WriteHeader(http.StatusCreated)
229 fmt.Fprintf(w, `{"host_id":"h1","credential":"c1","bridge_cidr":"10.77.1.0/24","server_cert_sha256":%q}`, decoyFP)
230 }))
231 defer plane.Close()
232
233 // Built through the real encoder: Encode validates the fingerprint shape,
234 // so a blob a test hand-rolls would not be one an operator can produce.
235 blob, err := joinblob.Encode(plane.URL, "127.0.0.1:4443", "tok", blobFP)
236 require.NoError(t, err)
237
238 st, err := state.Open(t.TempDir())
239 require.NoError(t, err)
240 require.NoError(t, join(st, Config{StateDir: t.TempDir()}, blob))
241
242 id, ok := st.Identity()
243 require.True(t, ok, "join must persist an identity")
244 assert.Equal(t, blobFP, id.ServerCertSHA256,
245 "the pin must be the join blob's fingerprint: the enroll response is server-controlled, so pinning what the server says makes the pin prove nothing — and an empty pin bricks the host, since ClientTLS fails closed on every dial")
246 assert.Equal(t, "h1", id.HostID)
247 assert.Equal(t, "c1", id.Credential)
248 assert.Equal(t, "127.0.0.1:4443", id.ServerQUICAddr, "the QUIC address is the blob's too, not the response's")
249 }
internal/server/api/api_test.go
Old New
@@ -463,6 +463,26 @@ func TestUserAuthMiddleware(t *testing.T) {
463 t.Run("unknown session cookie rejected", func(t *testing.T) { 463 t.Run("unknown session cookie rejected", func(t *testing.T) {
464 assert.Equal(t, 401, doCookie(t, "GET", ts.URL+"/api/v1/vms", "not-a-session", nil).StatusCode) 464 assert.Equal(t, 401, doCookie(t, "GET", ts.URL+"/api/v1/vms", "not-a-session", nil).StatusCode)
465 }) 465 })
466
467 // The console SPA carries an ambient session cookie on every request, so a
468 // PAT branch that falls through on failure means a revoked or expired PAT
469 // presented from a browser context keeps working — authenticated as
470 // whatever tenant the cookie holds, not the one the caller presented.
471 t.Run("prefixed but invalid PAT does not fall through to a valid session cookie", func(t *testing.T) {
472 sess := sessionFor(t, st, testTenant)
473 req, _ := http.NewRequest("GET", ts.URL+"/api/v1/vms", nil)
474 req.Header.Set("Authorization", "Bearer eitri_pat_"+strings.Repeat("0", 64))
475 req.AddCookie(&http.Cookie{Name: "eitri_session", Value: sess})
476 resp, err := http.DefaultClient.Do(req)
477 require.NoError(t, err)
478 defer resp.Body.Close()
479 body, _ := io.ReadAll(resp.Body)
480
481 require.Equal(t, 401, resp.StatusCode,
482 "a prefixed-but-invalid PAT must be a hard 401: falling through to the ambient session cookie makes PAT revocation a no-op in any browser context")
483 assert.Contains(t, string(body), "invalid token",
484 "the refusal must come from the PAT branch, not the cookie branch — a `sign in required` here means the PAT was never judged")
485 })
466 } 486 }
467 487
468 // TestUserAuthResolvesCredentialTenant proves the middleware threads each 488 // TestUserAuthResolvesCredentialTenant proves the middleware threads each
internal/server/api/auth_test.go
Old New
@@ -394,6 +394,7 @@ func TestAuthCallbackRejectionsSayWhy(t *testing.T) {
394 wantReason string 394 wantReason string
395 wantLogged []string // extra key=value pairs the log line must carry 395 wantLogged []string // extra key=value pairs the log line must carry
396 wantText []string 396 wantText []string
397 wantAbsent []string // substrings that must appear in neither body nor log
397 }{ 398 }{
398 { 399 {
399 name: "wrong origin: signed in at an address public_url does not name", 400 name: "wrong origin: signed in at an address public_url does not name",
@@ -437,6 +438,21 @@ func TestAuthCallbackRejectionsSayWhy(t *testing.T) {
437 wantText: []string{"PKCE verifier", "fresh tab"}, 438 wantText: []string{"PKCE verifier", "fresh tab"},
438 }, 439 },
439 { 440 {
441 // A crafted callback link can put anything in the state's origin
442 // half. Sanitizing it to "" makes the diagnosis fall back to the
443 // request's own origin, which matches public_url — so the hostile
444 // string neither reaches the victim's eyes nor the log, and the
445 // wrong-origin page never quotes an attacker's address back.
446 name: "hostile origin in the state: sanitized away, not echoed",
447 setup: func(t *testing.T, e authEnv) (url.Values, []*http.Cookie) {
448 hostile := base64.RawURLEncoding.EncodeToString(
449 []byte("https://evil.example/lure?x=<script>alert(1)</script>"))
450 return url.Values{"state": {strings.Repeat("a", 32) + "." + hostile}, "code": {"c"}}, nil
451 },
452 wantReason: "no_state_cookie",
453 wantAbsent: []string{"evil.example", "<script>", "lure"},
454 },
455 {
440 name: "no state at all: /auth/callback opened directly", 456 name: "no state at all: /auth/callback opened directly",
441 setup: func(t *testing.T, e authEnv) (url.Values, []*http.Cookie) { 457 setup: func(t *testing.T, e authEnv) (url.Values, []*http.Cookie) {
442 return url.Values{}, nil 458 return url.Values{}, nil
@@ -460,6 +476,12 @@ func TestAuthCallbackRejectionsSayWhy(t *testing.T) {
460 for _, want := range tc.wantText { 476 for _, want := range tc.wantText {
461 assert.Contains(t, body, want, "the refusal must say what to do about %s", tc.wantReason) 477 assert.Contains(t, body, want, "the refusal must say what to do about %s", tc.wantReason)
462 } 478 }
479 for _, absent := range tc.wantAbsent {
480 assert.NotContains(t, body, absent,
481 "a caller-controlled string reached the error page: %q is quoted back to whoever followed the link", absent)
482 assert.NotContains(t, logs.String(), absent,
483 "a caller-controlled string reached the log line: %q is written unbounded into the operator's journal", absent)
484 }
463 // Every refusal points at the console's real address, and none of 485 // Every refusal points at the console's real address, and none of
464 // them leaks the state value it was given. 486 // them leaks the state value it was given.
465 assert.Contains(t, body, env.apiURL) 487 assert.Contains(t, body, env.apiURL)
internal/server/api/authreject_test.go
Old New
@@ -0,0 +1,69 @@
1 package api
2
3 import (
4 "strings"
5 "testing"
6
7 "github.com/stretchr/testify/assert"
8 )
9
10 // TestSanitizeOriginPassesOnlyPlainHTTPOrigins pins every clause of the guard
11 // set, one row per independently-removable clause.
12 //
13 // What the guards protect is not HTML injection — the value is emitted through
14 // http.Error (text/plain, nosniff) and slog. It is the phishing-copy surface
15 // and log hygiene: the wrong_origin page prints "it started at <this string>"
16 // to whoever followed a crafted callback link, and the log line carries it as
17 // got_host=. The length cap bounds what one request can push into both. So the
18 // clauses are not paranoia and must not be simplified away.
19 func TestSanitizeOriginPassesOnlyPlainHTTPOrigins(t *testing.T) {
20 // 7 for "http://" + 193 of host = exactly the 200-char cap.
21 atCap := "http://" + strings.Repeat("a", 189) + ".com"
22 overCap := "http://" + strings.Repeat("a", 190) + ".com"
23
24 for _, tc := range []struct {
25 guard string // the clause this row fails if removed
26 in string
27 want string
28 }{
29 {"length cap", overCap, ""},
30 {"length cap boundary", atCap, atCap},
31
32 {"scheme allowlist", "javascript:alert(1)", ""},
33 {"scheme allowlist", "data:text/html,x", ""},
34 {"scheme allowlist", "ftp://h", ""},
35 {"scheme allowlist", "evil.com", ""}, // scheme-less: url.Parse reads it as a path
36 {"scheme allowlist", "http://h", "http://h"},
37 {"scheme allowlist", "https://h", "https://h"},
38
39 {"non-empty host", "http://", ""},
40
41 {"no path", "http://h/path", ""},
42 {"no query", "http://h?q=1", ""},
43 {"no fragment", "http://h#f", ""},
44 {"no userinfo", "http://u:p@h", ""},
45
46 {"host charset", "http://h<x>", ""},
47 {"host charset", "http://h%41", ""}, // percent-encoding hides anything
48 {"host charset", "http://h x", ""},
49 {"host charset", "http://hé", ""},
50 {"host charset: IPv6 and port stay legal", "http://[::1]:8080", "http://[::1]:8080"},
51 {"host charset: hyphen and dot stay legal", "http://a-b.c:9090", "http://a-b.c:9090"},
52
53 // The echoed string is re-emitted from the parsed parts, so the scheme
54 // comes back lowercased and the host does not. Pinned so a rewrite
55 // cannot quietly change what the error page shows.
56 {"re-emission is scheme-normalized, host-verbatim", "HTTP://H", "http://H"},
57 } {
58 t.Run(tc.guard+" "+tc.in, func(t *testing.T) {
59 got := sanitizeOrigin(tc.in)
60 if tc.want == "" {
61 assert.Empty(t, got,
62 "the %s guard must reject %q: it reached an error page a victim reads and a log line, and nothing downstream re-checks it", tc.guard, tc.in)
63 return
64 }
65 assert.Equal(t, tc.want, got,
66 "the %s guard must pass %q unchanged: rejecting it costs a real sign-in its wrong-origin diagnosis", tc.guard, tc.in)
67 })
68 }
69 }
internal/server/api/network_api_test.go
Old New
@@ -233,3 +233,27 @@ func TestHostViewServesEmptyNetworksNotNull(t *testing.T) {
233 }) 233 })
234 } 234 }
235 } 235 }
236
237 // TestCreateVMBridgedRefusedWhenAdvertiserIsNotReporting is the state between
238 // the two refusals above: the registry still holds what this host advertised,
239 // but the host stopped reporting. TestCreateVMBridgedRefusedWhenHostSilent does
240 // not reach it — there the network list is empty too, so the name-match clause
241 // refuses on its own and the online clause is never consulted.
242 func TestCreateVMBridgedRefusedWhenAdvertiserIsNotReporting(t *testing.T) {
243 ts, st, _ := testServer(t)
244 out := enroll(t, ts)
245
246 // The advertisement without the report: exactly what the registry holds
247 // after a host that served "lan" goes away.
248 testReg.SetHostNetworks(out["host_id"], []string{"lan"})
249
250 resp := createVMOnNetwork(t, ts, out["host_id"], "bridged", "lan")
251 require.Equal(t, 409, resp.StatusCode,
252 "a cached advertisement from a host that is not reporting must not be trusted: the host may have been reconfigured or rebuilt since, and a guest placed on a network it no longer serves lands on NAT without a word")
253 assert.Contains(t, bodyOf(t, resp), "it is not currently reporting, so its networks cannot be confirmed",
254 "the refusal must blame the silence, not the network name — the host did advertise it")
255
256 vms, err := st.ListVMs()
257 require.NoError(t, err)
258 assert.Empty(t, vms, "a refused create must leave no row behind")
259 }
internal/server/api/snapshot_hub_test.go
Old New
@@ -221,3 +221,60 @@ func TestSnapshotHubDropsTenantOnLastUnsub(t *testing.T) {
221 h.mu.Unlock() 221 h.mu.Unlock()
222 assert.False(t, present, "last unsubscribe must drop the tenant's cached payload") 222 assert.False(t, present, "last unsubscribe must drop the tenant's cached payload")
223 } 223 }
224
225 // TestSnapshotHubRecomputeFansEachTenantItsOwnBytes pins tenant isolation on the
226 // path that carries every snapshot after the first one.
227 //
228 // TestSnapshotHubPerTenantPayloads covers the subscribe() delivery, which is a
229 // different code path: subscribe hands back h.current[tenant] directly, while
230 // recompute walks h.subs and picks a payload per channel. A mixup in that fan
231 // loop is a live cross-tenant fleet disclosure — every VM, host and address the
232 // other tenant owns — and it survives the whole hub suite today.
233 //
234 // The payload carries both halves of the claim: the tenant it was built for,
235 // and the build generation, so the assertion proves the bytes came from THIS
236 // recompute rather than a stale initial delivery.
237 func TestSnapshotHubRecomputeFansEachTenantItsOwnBytes(t *testing.T) {
238 var builds atomic.Int64
239 build := func(tenants []string) (map[string][]byte, error) {
240 n := builds.Add(1)
241 out := make(map[string][]byte, len(tenants))
242 for _, tn := range tenants {
243 out[tn] = fmt.Appendf(nil, "%s-v%d", tn, n)
244 }
245 return out, nil
246 }
247 notif := newNotifier()
248 h := newSnapshotHub(build, notif)
249 go h.run()
250 defer h.Close()
251
252 chA, unsubA := h.subscribe("alpha")
253 defer unsubA()
254 chB, unsubB := h.subscribe("beta")
255 defer unsubB()
256
257 // Drain the two first-subscriber builds so what follows can only be the
258 // recompute delivery.
259 drainOne(t, chA, "alpha-v1")
260 drainOne(t, chB, "beta-v2")
261
262 notif.notify()
263 waitFor(t, time.Second, func() bool { return builds.Load() == 3 })
264
265 drainOne(t, chA, "alpha-v3")
266 drainOne(t, chB, "beta-v3")
267 }
268
269 // drainOne reads one snapshot and asserts its exact bytes, naming the leak in
270 // the failure so the tenant that received the wrong payload is in the message.
271 func drainOne(t *testing.T, ch <-chan []byte, want string) {
272 t.Helper()
273 select {
274 case b := <-ch:
275 assert.Equal(t, want, string(b),
276 "recompute delivered %q where %q was due — the fan-out must route each tenant its own payload; crossing them is a cross-tenant fleet disclosure", b, want)
277 case <-time.After(time.Second):
278 t.Fatalf("no snapshot delivered; expected %q", want)
279 }
280 }
internal/server/api/tokens_test.go
Old New
@@ -169,3 +169,41 @@ func TestListAPITokensTenantScoped(t *testing.T) {
169 assert.NotEqual(t, betaID, row["id"], "default's list must not include beta's token") 169 assert.NotEqual(t, betaID, row["id"], "default's list must not include beta's token")
170 } 170 }
171 } 171 }
172
173 // TestTokenEndpointsActAsTheCallersTenant runs list and revoke from the side
174 // that is not the seeded tenant. The tenant-scoped tests above only ever call
175 // AS default, so a handler that reads a hard-coded "default" instead of the
176 // calling principal's tenant satisfies every one of them — the caller's own
177 // tokens still come back, because the caller IS default. Asking as beta is the
178 // only question a hard-coded tenant answers wrongly.
179 func TestTokenEndpointsActAsTheCallersTenant(t *testing.T) {
180 ts, st, _, _, _ := newServer(t)
181
182 beta, err := st.CreateTenantForIdentity("https://idp", "sub-beta", "beta@example.com")
183 require.NoError(t, err)
184 betaSecret, betaID, err := st.CreateAPIToken(beta.ID, "beta-worker", 0)
185 require.NoError(t, err)
186 _, defaultID, err := st.CreateAPIToken(testTenant, "default-worker", 0)
187 require.NoError(t, err)
188
189 resp := do(t, "GET", ts.URL+"/api/v1/tokens", betaSecret, nil)
190 require.Equal(t, 200, resp.StatusCode)
191 var ids []string
192 for _, row := range decodeJSONKeys(t, resp) {
193 id, _ := row["id"].(string)
194 ids = append(ids, id)
195 }
196 assert.Contains(t, ids, betaID, "list must answer as the CALLER's tenant: beta asked and was not shown its own token")
197 assert.NotContains(t, ids, defaultID,
198 "list must answer as the CALLER's tenant: beta was shown "+testTenant+"'s token id, so the handler read a fixed tenant rather than the principal's")
199
200 resp = do(t, "DELETE", ts.URL+"/api/v1/tokens/"+defaultID, betaSecret, nil)
201 assert.Equal(t, 404, resp.StatusCode, "beta must not be able to revoke "+testTenant+"'s token")
202
203 // Beta revokes its own last: the call that answers 401 afterwards is the
204 // one that proves the DELETE landed on beta's row and not somewhere else.
205 resp = do(t, "DELETE", ts.URL+"/api/v1/tokens/"+betaID, betaSecret, nil)
206 assert.Equal(t, 204, resp.StatusCode, "beta must be able to revoke beta's own token")
207 assert.Equal(t, 401, do(t, "GET", ts.URL+"/api/v1/vms", betaSecret, nil).StatusCode,
208 "the revoke must land on the CALLER's row: beta's secret still authenticates, so the DELETE scoped to a fixed tenant and deleted nothing")
209 }
internal/server/store/store_test.go
Old New
@@ -4,6 +4,7 @@ import (
4 "context" 4 "context"
5 "database/sql" 5 "database/sql"
6 "path/filepath" 6 "path/filepath"
7 "strings"
7 "testing" 8 "testing"
8 "time" 9 "time"
9 10
@@ -557,6 +558,9 @@ func TestSSHCertRevocationLargeSerial(t *testing.T) {
557 list, err := s.ListRevokedSSHCerts(testTenant) 558 list, err := s.ListRevokedSSHCerts(testTenant)
558 require.NoError(t, err) 559 require.NoError(t, err)
559 require.Len(t, list, 2) 560 require.Len(t, list, 2)
561 assert.ElementsMatch(t, []uint64{big, other}, []uint64{list[0].Serial, list[1].Serial},
562 "ListRevokedSSHCerts must round-trip the uint64 serial exactly: serials ride SQLite as an int64 bit-cast, "+
563 "so a lost sign bit lists a different cert than the one revoked and an operator auditing revocations matches the wrong key")
560 } 564 }
561 565
562 func TestTenantUserCAs(t *testing.T) { 566 func TestTenantUserCAs(t *testing.T) {
@@ -595,6 +599,34 @@ func TestTenantUserCAs(t *testing.T) {
595 assert.False(t, ok, "removed CA no longer resolves") 599 assert.False(t, ok, "removed CA no longer resolves")
596 } 600 }
597 601
602 // TestTenantHasUserCAIsTenantScoped asserts the gate from the side that has no
603 // CA. TenantHasUserCA is the precondition for VM create, so a query that reads
604 // the table fleet-wide lets one tenant's registration unlock every other
605 // tenant's guests -- and every existing test asks only the tenant that just
606 // registered one, which any widened WHERE clause still answers correctly.
607 func TestTenantHasUserCAIsTenantScoped(t *testing.T) {
608 s := newStore(t)
609 const ca = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIONE"
610
611 beta, err := s.CreateTenantForIdentity("https://issuer.test", "sub-beta", "beta@test.local")
612 require.NoError(t, err)
613 require.NoError(t, s.AddTenantUserCA(testTenant, ca, "tenant", "laptop", "admin"))
614
615 has, err := s.TenantHasUserCA(beta.ID)
616 require.NoError(t, err)
617 assert.False(t, has,
618 "a CA registered by tenant "+testTenant+" must not satisfy tenant "+beta.ID+"'s VM-create gate: "+
619 "TenantHasUserCA read across tenants, so beta can boot guests trusting a CA it never registered")
620
621 list, err := s.ListTenantUserCAs(beta.ID)
622 require.NoError(t, err)
623 assert.Empty(t, list, "tenant "+beta.ID+" registered no CA and must be shown none")
624
625 has, err = s.TenantHasUserCA(testTenant)
626 require.NoError(t, err)
627 assert.True(t, has, "the registering tenant must still pass its own gate")
628 }
629
598 func TestEnrollmentAssignsSequentialCIDRsAndIsOneTimeUse(t *testing.T) { 630 func TestEnrollmentAssignsSequentialCIDRsAndIsOneTimeUse(t *testing.T) {
599 s := newStore(t) 631 s := newStore(t)
600 tok1, _ := s.CreateEnrollmentToken(testTenant) 632 tok1, _ := s.CreateEnrollmentToken(testTenant)
@@ -1278,3 +1310,61 @@ func TestRecordHostUplinkRefusesWhatIsNotAnAddress(t *testing.T) {
1278 require.NoError(t, err) 1310 require.NoError(t, err)
1279 assert.Equal(t, "", got.UplinkAddr, "a value that is not an address never reaches the row") 1311 assert.Equal(t, "", got.UplinkAddr, "a value that is not an address never reaches the row")
1280 } 1312 }
1313
1314 // TestHardDeleteVMRefusesLiveRows pins the tombstone-only guard. Both callers
1315 // swallow HardDeleteVM's error — the decommission sweep skips to the next host
1316 // and the sync ack path only warns — so no HTTP status ever surfaces this
1317 // refusal and the store is the only altitude with a signal.
1318 func TestHardDeleteVMRefusesLiveRows(t *testing.T) {
1319 s := newStore(t)
1320 h := enrollHost(t, s)
1321 vm := makeVM(t, s, h, "live-1")
1322 _, err := s.CreateExposure(vm.ID, 22, 10022, "tcp")
1323 require.NoError(t, err)
1324
1325 err = s.HardDeleteVM(vm.ID)
1326 assert.ErrorIs(t, err, sql.ErrNoRows,
1327 "HardDeleteVM must refuse a live row: deleting it here skips the tombstone/teardown grace entirely, so the agent never learns the guest should die and it runs on orphaned")
1328
1329 _, err = s.GetVM(vm.ID)
1330 assert.NoError(t, err, "the refused delete must leave the row standing")
1331 exps, err := s.ListExposuresForVM(vm.ID)
1332 require.NoError(t, err)
1333 assert.Len(t, exps, 1,
1334 "the refused delete must not fire ON DELETE CASCADE: the VM's exposures went with it, tearing down published ports for a guest that is still running")
1335
1336 require.NoError(t, s.TombstoneVM(vm.ID))
1337 require.NoError(t, s.HardDeleteVM(vm.ID), "a tombstoned row is deletable — that is the whole point of the guard")
1338 _, err = s.GetVM(vm.ID)
1339 assert.Error(t, err, "the row is gone")
1340 exps, err = s.ListExposuresForVM(vm.ID)
1341 require.NoError(t, err)
1342 assert.Empty(t, exps, "the cascade fires on the legal path")
1343 }
1344
1345 // TestCidrPoolFirstOpenWins pins that the guest-subnet pool is fixed at the
1346 // first Open and a later --cidr-pool is ignored. Hosts hold their allocation
1347 // for life, so honoring a changed flag would re-plan subnets under a fleet that
1348 // is already routing on the old ones.
1349 func TestCidrPoolFirstOpenWins(t *testing.T) {
1350 path := t.TempDir() + "/eitri.db"
1351
1352 first, err := Open(path, "10.77.0.0/16")
1353 require.NoError(t, err)
1354 tn, err := first.CreateTenantForIdentity("https://test-issuer", "test-subject", testTenant+"@test.local")
1355 require.NoError(t, err)
1356 require.Equal(t, testTenant, tn.ID)
1357 require.NoError(t, first.Close())
1358
1359 // The operator restarts the server with a different pool. Note this is
1360 // silently ignored, with no signal either way — a footgun in its own right,
1361 // tracked separately; what the test pins is that the fleet's subnets do not
1362 // move underneath it.
1363 second, err := Open(path, "10.99.0.0/16")
1364 require.NoError(t, err)
1365 t.Cleanup(func() { second.Close() })
1366
1367 h := enrollHost(t, second)
1368 assert.True(t, strings.HasPrefix(h.BridgeCIDR, "10.77."),
1369 "the cidr_pool is fixed at first Open: this host got %s, allocated from the pool passed at restart, so a fleet already routing 10.77.x has a peer on a subnet nothing routes to", h.BridgeCIDR)
1370 }
internal/server/syncsvc/syncsvc_test.go
Old New
@@ -993,3 +993,58 @@ func TestSecondSessionEvictsTheFirst(t *testing.T) {
993 require.NotNil(t, snap, "the surviving session must still be poked") 993 require.NotNil(t, snap, "the surviving session must still be poked")
994 require.Len(t, snap.Vms, 1) 994 require.Len(t, snap.Vms, 1)
995 } 995 }
996
997 // TestSessionIdentityComesFromCredentialNotHello pins the claim that makes the
998 // Hello's host_id inert: a session is the host its CREDENTIAL names, never the
999 // host the Hello claims to be. The two are separable — the field is agent-sent
1000 // and unauthenticated — so a valid credential paired with a foreign host_id is
1001 // directly constructible, and is what an agent would send if it were lying.
1002 //
1003 // The stakes are a tenant's whole desired state. Under a server that trusts the
1004 // field, the first snapshot pushed down this connection is the OTHER tenant's
1005 // fleet, cloud-init included, and every fact this session reports lands on a
1006 // host it does not own.
1007 func TestSessionIdentityComesFromCredentialNotHello(t *testing.T) {
1008 f := setup(t)
1009
1010 // A second host, under a second tenant, with a VM whose desired state must
1011 // never cross to f.host's agent.
1012 _, err := f.st.CreateTenantForIdentity("https://test-issuer", "beta-subject", "beta@test.local")
1013 require.NoError(t, err)
1014 betaTok, err := f.st.CreateEnrollmentToken("beta")
1015 require.NoError(t, err)
1016 hostB, err := f.st.RedeemEnrollmentToken(betaTok, store.EnrollFacts{
1017 Name: "beta-host", OS: "linux", Arch: "amd64", Provisioner: "cloudhv", Remote: ""})
1018 require.NoError(t, err)
1019 require.NoError(t, f.st.CreateVM(store.VM{ID: "beta-vm", HostID: hostB.ID, Name: "secret",
1020 ImageURL: "u", ImageSHA256: "s", VCPUs: 1, MemMB: 512, DiskGB: 5, PowerState: "running"}))
1021
1022 // f.cred is host A's. The Hello claims to be host B.
1023 c, err := dialHello(t, f.addr, f.fp, &pb.Hello{
1024 HostId: hostB.ID,
1025 Credential: f.cred,
1026 Facts: &pb.HostFacts{OsId: "spoofed"},
1027 })
1028 require.NoError(t, err, "the credential is valid, so the session must open — the claim is which identity it gets")
1029 t.Cleanup(func() { c.conn.CloseWithError(0, "") })
1030
1031 snap := c.recv(t).GetSnapshot()
1032 require.NotNil(t, snap)
1033 for _, vm := range snap.Vms {
1034 assert.NotEqual(t, "beta-vm", vm.VmId,
1035 "the session snapshot must be the CREDENTIALED host's: a spoofed Hello.HostId leaked tenant beta's desired state to host %s's agent", f.host.ID)
1036 }
1037
1038 require.Eventually(t, func() bool {
1039 h, err := f.st.GetHost(f.host.ID)
1040 return err == nil && h.OSID == "spoofed"
1041 }, 2*time.Second, 10*time.Millisecond,
1042 "Hello facts must land on the host the credential names")
1043
1044 hb, err := f.st.GetHost(hostB.ID)
1045 require.NoError(t, err)
1046 assert.Empty(t, hb.OSID, "the spoofed host must be untouched — it never connected")
1047
1048 _, onlineB := f.reg.Get(hostB.ID)
1049 assert.False(t, onlineB, "a spoofed host_id must not make another host read as online")
1050 }