e11ca299
test: eleven claims the comments made and nothing checked
a73x 2026-08-23 10:25
Commit message
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 | } | ||