a73x

d8cdbdd7

test: the suite catches what it was only assuming

a73x   2026-08-12 19:51

Commit message
test: the suite catches what it was only assuming

Test-infrastructure hygiene: close the gaps where a passing suite was
proving less than it looked, and stop one real goroutine leak.

- apiServer (decommission_api_test.go) was a near-duplicate of newServer
  that omitted its t.Cleanup(a.Close), so every test built on it leaked the
  snapshot-hub goroutine. Deleted it; its eight call sites across five files
  now use newServer's five-value return.
- covsnap: Install's SIGUSR1 handler outlived its test — signal.Stop ran
  only after ctx.Done, which the test never awaited, so a later test's signal
  raced the previous handler's package-level write seam. Install now returns a
  synchronous stop (signal.Stop + wait for the goroutine to exit); production
  ignores it and lets ctx end the handler, the test defers it. Verified with
  go test -race -count=50.
- Coverage floors ratcheted to just under each package's measured coverage
  (100% packages floor at 100). Ten previously-ungated packages gain a floor —
  server/sshgate, joinblob, agent/serialpump, agent/enrollclient, cloudinit,
  covsnap, agent/dhcp, gateclient, server/health, and the new leaf
  agent/permanent (100%). names and random are measured only through their
  callers, so they carry a documented 0 floor rather than a fictional one.
  exposeproxy carries a margin: its proxy goroutines make its coverage wobble
  92.5-93.8%. internal/guest has no tests and no coverable line, so it cannot
  be gated yet.
- Makefile test target gains -shuffle=on as a standing order-dependence
  tripwire; the suite passes shuffled under -race.
- Compile-time Provisioner conformance: wire_linux.go and wire_darwin.go now
  assert *cloudhv.Provisioner / *vfkit.Provisioner satisfy reconcile.Provisioner.
- Wire-contract gaps in server/api: golden fixtures for DelegationRequest and
  for the TrustedCAs null-vs-empty distinction (null vs []); the VM required-key
  allowlist regains status_detail, trusted_cas, injected_key; and every audit
  detail an action in api.go/tokens.go/usercas.go emits is now driven for real
  and its key set pinned (Detail is json.RawMessage, invisible to the wire golden).
- A source walk of the types package fails on any omitempty or json:"-": the
  wire golden's ability to detect an ADDED field rests on that discipline.
- smoke's remote tool count/list are tied to mcpserver.NewServer's real output
  instead of agreeing only with each other.
- Two require.* calls inside httptest handler goroutines (mcpserver/api_test.go)
  became assert.* + return, so a failed decode reports the assertion, not EOF.

Left for v0.0.7 (out of scope here): inverting the coverage gate to enumerate
from go list; retiring the testPAT/testReg globals; a shared test-helper harness;
testifylint; real CI.

Makefile
Old New
@@ -43,7 +43,7 @@ build: web
43 go build $(GO_LDFLAGS) -o $(BIN)/eitri ./cmd/eitri 43 go build $(GO_LDFLAGS) -o $(BIN)/eitri ./cmd/eitri
44 44
45 test: 45 test:
46 go test -race ./... 46 go test -race -shuffle=on ./...
47 47
48 vet: 48 vet:
49 go vet ./... 49 go vet ./...
internal/agent/run/wire_darwin.go
Old New
@@ -14,6 +14,12 @@ import (
14 "github.com/a73x/eitri/internal/agent/vfkit" 14 "github.com/a73x/eitri/internal/agent/vfkit"
15 ) 15 )
16 16
17 // vfkit.Provisioner is what serve() drives through the reconcile.Provisioner
18 // seam. Pin the method-set match at compile time: a drift on either side (a
19 // renamed Boot, a changed Capabilities signature) fails the build here rather
20 // than silently at the wiring call below.
21 var _ reconcile.Provisioner = (*vfkit.Provisioner)(nil)
22
17 // platformProvisioner is what this host advertises to the server at join 23 // platformProvisioner is what this host advertises to the server at join
18 // time — an opaque label the server stores and never interprets. 24 // time — an opaque label the server stores and never interprets.
19 const platformProvisioner = "vfkit" 25 const platformProvisioner = "vfkit"
internal/agent/run/wire_linux.go
Old New
@@ -14,6 +14,12 @@ import (
14 "github.com/a73x/eitri/internal/agent/state" 14 "github.com/a73x/eitri/internal/agent/state"
15 ) 15 )
16 16
17 // cloudhv.Provisioner is what serve() drives through the reconcile.Provisioner
18 // seam. Pin the method-set match at compile time: a drift on either side (a
19 // renamed Boot, a changed Capabilities signature) fails the build here rather
20 // than silently at the wiring call below.
21 var _ reconcile.Provisioner = (*cloudhv.Provisioner)(nil)
22
17 // platformProvisioner is what this host advertises to the server at join 23 // platformProvisioner is what this host advertises to the server at join
18 // time — an opaque label the server stores and never interprets. 24 // time — an opaque label the server stores and never interprets.
19 const platformProvisioner = "cloudhv" 25 const platformProvisioner = "cloudhv"
internal/covsnap/covsnap.go
Old New
@@ -9,6 +9,7 @@ import (
9 "os" 9 "os"
10 "os/signal" 10 "os/signal"
11 "runtime/coverage" 11 "runtime/coverage"
12 "sync"
12 "syscall" 13 "syscall"
13 ) 14 )
14 15
@@ -22,19 +23,31 @@ var write = func(dir string) error {
22 23
23 // Install starts a goroutine that, on each SIGUSR1, snapshots coverage into 24 // Install starts a goroutine that, on each SIGUSR1, snapshots coverage into
24 // $GOCOVERDIR. Unset GOCOVERDIR -> returns without registering anything. 25 // $GOCOVERDIR. Unset GOCOVERDIR -> returns without registering anything.
25 func Install(ctx context.Context) { 26 //
27 // The returned stop tears the handler down synchronously: it unregisters the
28 // signal and blocks until the goroutine has exited, so no handler outlives the
29 // caller. Production wires this to the process lifetime and lets ctx end it, so
30 // it ignores the return; a test that installs and uninstalls in the same
31 // process must call stop, or its handler leaks into the next test and a stray
32 // SIGUSR1 races the package-level write seam. stop is idempotent.
33 func Install(ctx context.Context) (stop func()) {
26 dir := os.Getenv("GOCOVERDIR") 34 dir := os.Getenv("GOCOVERDIR")
27 if dir == "" { 35 if dir == "" {
28 return 36 return func() {}
29 } 37 }
30 ch := make(chan os.Signal, 1) 38 ch := make(chan os.Signal, 1)
31 signal.Notify(ch, syscall.SIGUSR1) 39 signal.Notify(ch, syscall.SIGUSR1)
40 quit := make(chan struct{})
41 done := make(chan struct{})
32 go func() { 42 go func() {
43 defer close(done)
33 defer signal.Stop(ch) 44 defer signal.Stop(ch)
34 for { 45 for {
35 select { 46 select {
36 case <-ctx.Done(): 47 case <-ctx.Done():
37 return 48 return
49 case <-quit:
50 return
38 case <-ch: 51 case <-ch:
39 if err := write(dir); err != nil { 52 if err := write(dir); err != nil {
40 slog.Warn("covsnap: write failed", "dir", dir, "err", err) 53 slog.Warn("covsnap: write failed", "dir", dir, "err", err)
@@ -44,4 +57,12 @@ func Install(ctx context.Context) {
44 } 57 }
45 } 58 }
46 }() 59 }()
60 var once sync.Once
61 return func() {
62 once.Do(func() {
63 signal.Stop(ch)
64 close(quit)
65 })
66 <-done
67 }
47 } 68 }
internal/covsnap/covsnap_test.go
Old New
@@ -27,7 +27,11 @@ func TestInstall_SignalTriggersWrite(t *testing.T) {
27 ctx, cancel := context.WithCancel(context.Background()) 27 ctx, cancel := context.WithCancel(context.Background())
28 t.Cleanup(cancel) 28 t.Cleanup(cancel)
29 29
30 Install(ctx) 30 // Tear the handler down synchronously when the test ends: without stop the
31 // SIGUSR1 handler outlives this test, and the next test's signal races this
32 // test's write seam through the package-level var.
33 stop := Install(ctx)
34 t.Cleanup(stop)
31 35
32 if err := syscall.Kill(os.Getpid(), syscall.SIGUSR1); err != nil { 36 if err := syscall.Kill(os.Getpid(), syscall.SIGUSR1); err != nil {
33 t.Fatalf("failed to send SIGUSR1: %v", err) 37 t.Fatalf("failed to send SIGUSR1: %v", err)
@@ -61,7 +65,8 @@ func TestInstall_NoGOCOVERDIR(t *testing.T) {
61 ctx, cancel := context.WithCancel(context.Background()) 65 ctx, cancel := context.WithCancel(context.Background())
62 t.Cleanup(cancel) 66 t.Cleanup(cancel)
63 67
64 Install(ctx) 68 stop := Install(ctx)
69 t.Cleanup(stop)
65 70
66 if err := syscall.Kill(os.Getpid(), syscall.SIGUSR1); err != nil { 71 if err := syscall.Kill(os.Getpid(), syscall.SIGUSR1); err != nil {
67 t.Fatalf("failed to send SIGUSR1: %v", err) 72 t.Fatalf("failed to send SIGUSR1: %v", err)
internal/mcpserver/api_test.go
Old New
@@ -43,7 +43,9 @@ func TestCreateVMSendsRequestAndParsesID(t *testing.T) {
43 assert.Equal(t, "POST", r.Method) 43 assert.Equal(t, "POST", r.Method)
44 assert.Equal(t, "/api/v1/vms", r.URL.Path) 44 assert.Equal(t, "/api/v1/vms", r.URL.Path)
45 var req map[string]any 45 var req map[string]any
46 require.NoError(t, json.NewDecoder(r.Body).Decode(&req)) 46 if !assert.NoError(t, json.NewDecoder(r.Body).Decode(&req)) {
47 return
48 }
47 assert.NotContains(t, req, "persistent", "the retired field must not go on the wire — the server refuses a body carrying it") 49 assert.NotContains(t, req, "persistent", "the retired field must not go on the wire — the server refuses a body carrying it")
48 assert.Equal(t, "h1", req["host_id"]) 50 assert.Equal(t, "h1", req["host_id"])
49 assert.Equal(t, "ssh-ed25519 AAA test", req["ssh_authorized_key"]) 51 assert.Equal(t, "ssh-ed25519 AAA test", req["ssh_authorized_key"])
@@ -225,7 +227,9 @@ func TestUploadUserCA(t *testing.T) {
225 assert.Equal(t, "POST", r.Method) 227 assert.Equal(t, "POST", r.Method)
226 assert.Equal(t, "/api/v1/tenants/default/user-cas", r.URL.Path) 228 assert.Equal(t, "/api/v1/tenants/default/user-cas", r.URL.Path)
227 assert.Equal(t, "Bearer tok123", r.Header.Get("Authorization")) 229 assert.Equal(t, "Bearer tok123", r.Header.Get("Authorization"))
228 require.NoError(t, json.NewDecoder(r.Body).Decode(&gotBody)) 230 if !assert.NoError(t, json.NewDecoder(r.Body).Decode(&gotBody)) {
231 return
232 }
229 w.WriteHeader(http.StatusNoContent) 233 w.WriteHeader(http.StatusNoContent)
230 }) 234 })
231 235
internal/server/api/abandoned_vm_test.go
Old New
@@ -43,7 +43,7 @@ func tombstoneOneVM(t *testing.T, ts *httptest.Server, st *store.Store) string {
43 // otherwise linger forever. Past the grace, the server-side sweep hard-deletes 43 // otherwise linger forever. Past the grace, the server-side sweep hard-deletes
44 // it so the console row clears. 44 // it so the console row clears.
45 func TestAbandonedTombstoneOnOfflineHostIsReaped(t *testing.T) { 45 func TestAbandonedTombstoneOnOfflineHostIsReaped(t *testing.T) {
46 ts, a, st := apiServer(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 // Well past the abandoned-reap grace, host still offline (never reported).
@@ -58,7 +58,7 @@ func TestAbandonedTombstoneOnOfflineHostIsReaped(t *testing.T) {
58 // left for the agent to ack normally — a transient host outage (agent restart, 58 // left for the agent to ack normally — a transient host outage (agent restart,
59 // redeploy) must not trip an immediate server-side force-delete. 59 // redeploy) must not trip an immediate server-side force-delete.
60 func TestFreshTombstoneIsNotReaped(t *testing.T) { 60 func TestFreshTombstoneIsNotReaped(t *testing.T) {
61 ts, a, st := apiServer(t) 61 ts, st, _, _, a := newServer(t)
62 id := tombstoneOneVM(t, ts, st) 62 id := tombstoneOneVM(t, ts, st)
63 63
64 assert.False(t, a.sweepAbandonedVMs(time.Now()), 64 assert.False(t, a.sweepAbandonedVMs(time.Now()),
@@ -72,7 +72,7 @@ func TestFreshTombstoneIsNotReaped(t *testing.T) {
72 // agent is live it will ack the destroy through the normal quarantine→destroy 72 // agent is live it will ack the destroy through the normal quarantine→destroy
73 // path, so the server must not race it — even for an old tombstone. 73 // path, so the server must not race it — even for an old tombstone.
74 func TestOnlineHostTombstoneIsNotReaped(t *testing.T) { 74 func TestOnlineHostTombstoneIsNotReaped(t *testing.T) {
75 ts, a, st := apiServer(t) 75 ts, st, _, _, a := newServer(t)
76 id := tombstoneOneVM(t, ts, st) 76 id := tombstoneOneVM(t, ts, st)
77 77
78 // Make the host online: a fresh report sets LastSeen to now. 78 // Make the host online: a fresh report sets LastSeen to now.
internal/server/api/allocation_api_test.go
Old New
@@ -11,7 +11,7 @@ import (
11 // TestHostResponseIncludesAllocated verifies the host wire shape carries 11 // TestHostResponseIncludesAllocated verifies the host wire shape carries
12 // server-computed allocation summed from the host's live VMs. 12 // server-computed allocation summed from the host's live VMs.
13 func TestHostResponseIncludesAllocated(t *testing.T) { 13 func TestHostResponseIncludesAllocated(t *testing.T) {
14 ts, _, _ := apiServer(t) 14 ts, _, _, _, _ := newServer(t)
15 out := enroll(t, ts) 15 out := enroll(t, ts)
16 hostID := out["host_id"] 16 hostID := out["host_id"]
17 17
internal/server/api/api_test.go
Old New
@@ -79,9 +79,9 @@ func TestResponseJSONKeysAreSnakeCase(t *testing.T) {
79 // Required snake_case keys must be present. 79 // Required snake_case keys must be present.
80 for _, k := range []string{ 80 for _, k := range []string{
81 "id", "host_id", "name", "image_url", "vcpus", "mem_mb", "disk_gb", 81 "id", "host_id", "name", "image_url", "vcpus", "mem_mb", "disk_gb",
82 "power_state", "status", "last_error", "assigned_ip", 82 "power_state", "status", "status_detail", "last_error", "assigned_ip",
83 "created_at", "deleted", "actual_power", "phase", 83 "created_at", "deleted", "actual_power", "phase",
84 "destroy_at", "lifecycle", 84 "destroy_at", "lifecycle", "trusted_cas", "injected_key",
85 } { 85 } {
86 assert.Contains(t, v, k, "vm response must contain key %q", k) 86 assert.Contains(t, v, k, "vm response must contain key %q", k)
87 } 87 }
@@ -958,6 +958,87 @@ func TestEnrollMintsGenerationCredential(t *testing.T) {
958 assert.Equal(t, "1", parts[1], "fresh enrollment mints generation 1") 958 assert.Equal(t, "1", parts[1], "fresh enrollment mints generation 1")
959 } 959 }
960 960
961 // TestAuditDetailKeysArePinned nails down the KEY SET of every audit detail an
962 // action in api.go, tokens.go, and usercas.go emits. AuditEvent.Detail is a
963 // json.RawMessage, so the wire golden marshals it as opaque bytes and cannot
964 // see inside — a rename like host_id→hostId would sail through every other
965 // test. Here each action is driven for real and its emitted detail decoded, so
966 // a changed key name (or an added/dropped one) fails against the pinned set.
967 func TestAuditDetailKeysArePinned(t *testing.T) {
968 ts, st, _ := testServer(t)
969
970 // enroll() already emits enroll-token.mint (api.go).
971 out := enroll(t, ts)
972 hostID := out["host_id"]
973
974 // api.go: the VM lifecycle audits.
975 resp := do(t, "POST", ts.URL+"/api/v1/vms", testPAT,
976 map[string]any{"host_id": hostID, "name": "audited-vm"})
977 require.Equal(t, 201, resp.StatusCode)
978 var created map[string]string
979 require.NoError(t, json.NewDecoder(resp.Body).Decode(&created))
980 vmID := created["id"]
981
982 require.Equal(t, 204, do(t, "PATCH", ts.URL+"/api/v1/vms/"+vmID, testPAT,
983 map[string]any{"power_state": "stopped"}).StatusCode)
984 require.Equal(t, 204, do(t, "DELETE", ts.URL+"/api/v1/vms/"+vmID, testPAT, nil).StatusCode)
985 require.Equal(t, 204, do(t, "POST", ts.URL+"/api/v1/vms/"+vmID+"/restore", testPAT, nil).StatusCode)
986
987 // tokens.go: mint then revoke a PAT.
988 resp = do(t, "POST", ts.URL+"/api/v1/tokens", testPAT,
989 map[string]any{"name": "audited-token", "ttl_seconds": 3600})
990 require.Equal(t, 201, resp.StatusCode)
991 var mintedTok map[string]string
992 require.NoError(t, json.NewDecoder(resp.Body).Decode(&mintedTok))
993 require.Equal(t, 204, do(t, "DELETE", ts.URL+"/api/v1/tokens/"+mintedTok["id"], testPAT, nil).StatusCode)
994
995 // usercas.go: register a CA for the caller's own tenant (a valid ed25519
996 // line distinct from the seed).
997 require.Equal(t, 201, do(t, "POST", ts.URL+"/api/v1/user-cas", testPAT, map[string]any{
998 "public_key": "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIPZK1zVJTG0Opn0BktxOpCYhRXRPMFhZDwoT1PVCM1Sq audit-ca",
999 "label": "audit-ca",
1000 }).StatusCode)
1001
1002 rows, err := st.ListAudit(testTenant, 100)
1003 require.NoError(t, err)
1004 // Newest row per action wins; the detail shape is one per action.
1005 got := map[string]map[string]bool{}
1006 for _, row := range rows {
1007 if _, seen := got[row.Action]; seen {
1008 continue
1009 }
1010 var detail map[string]any
1011 require.NoError(t, json.Unmarshal([]byte(row.Detail), &detail),
1012 "audit detail for %s is not a JSON object", row.Action)
1013 keys := map[string]bool{}
1014 for k := range detail {
1015 keys[k] = true
1016 }
1017 got[row.Action] = keys
1018 }
1019
1020 want := map[string][]string{
1021 "vm.create": {"vm_id", "name", "host_id"},
1022 "vm.power": {"vm_id", "name", "power"},
1023 "vm.delete": {"vm_id", "name"},
1024 "vm.restore": {"vm_id", "name"},
1025 "enroll-token.mint": {"remote", "token_hash_prefix"},
1026 "api-token.mint": {"token_id", "name"},
1027 "api-token.revoke": {"token_id"},
1028 "user-ca.upload": {"tenant", "fingerprint"},
1029 }
1030 for action, keys := range want {
1031 gotKeys, ok := got[action]
1032 require.True(t, ok, "no audit row emitted for %s", action)
1033 wantKeys := map[string]bool{}
1034 for _, k := range keys {
1035 wantKeys[k] = true
1036 }
1037 assert.Equal(t, wantKeys, gotKeys,
1038 "audit detail keys for %s drifted — a renamed/added/dropped key the wire golden cannot see", action)
1039 }
1040 }
1041
961 // TestAuditEndpoint pins the forensic read API: GET /api/v1/audit returns 1042 // TestAuditEndpoint pins the forensic read API: GET /api/v1/audit returns
962 // newest-first rows (detail as embedded JSON), honors ?limit, requires admin. 1043 // newest-first rows (detail as embedded JSON), honors ?limit, requires admin.
963 func TestAuditEndpoint(t *testing.T) { 1044 func TestAuditEndpoint(t *testing.T) {
internal/server/api/decommission_api_test.go
Old New
@@ -4,48 +4,16 @@ import (
4 "bufio" 4 "bufio"
5 "context" 5 "context"
6 "net/http" 6 "net/http"
7 "net/http/httptest"
8 "strings" 7 "strings"
9 "testing" 8 "testing"
10 "time" 9 "time"
11 10
12 "github.com/a73x/eitri/internal/server/hub"
13 "github.com/a73x/eitri/internal/server/registry"
14 "github.com/a73x/eitri/internal/server/store"
15 "github.com/stretchr/testify/assert" 11 "github.com/stretchr/testify/assert"
16 "github.com/stretchr/testify/require" 12 "github.com/stretchr/testify/require"
17 ) 13 )
18 14
19 // apiServer is like testServer but also returns the *API so tests can drive the
20 // background sweeper deterministically.
21 func apiServer(t *testing.T) (*httptest.Server, *API, *store.Store) {
22 t.Helper()
23 st, err := store.Open(t.TempDir()+"/db", "10.77.0.0/16")
24 require.NoError(t, err)
25 t.Cleanup(func() { st.Close() })
26 seedTestTenant(t, st)
27 reg := registry.New(time.Now)
28 testReg = reg
29 a := New(Config{
30 HostSecret: []byte("hostsecret"),
31 DefaultImages: map[string]DefaultImage{"amd64": {URL: "http://img", SHA256: strings.Repeat("a", 64)}},
32 AdvertiseHTTP: "http://127.0.0.1:8080",
33 AdvertiseQUIC: "127.0.0.1:8443",
34 ServerCertSHA256: strings.Repeat("c", 64),
35 }, st, reg, hub.New())
36 // BYO-CA precondition: VM create requires the tenant to have ≥1 registered
37 // SSH user CA. Seed the default tenant so VM-create tests reach the create
38 // path rather than the precondition (mirrors newServer in api_test.go).
39 require.NoError(t, st.AddTenantUserCA(testTenant,
40 "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAITESTSEEDCA eitri-test-seed", "tenant", "test-seed", "test"))
41 mintTestPAT(t, st)
42 ts := httptest.NewServer(a.Handler())
43 t.Cleanup(ts.Close)
44 return ts, a, st
45 }
46
47 func TestDecommissionEndpointThenSweepRemovesDrainedHost(t *testing.T) { 15 func TestDecommissionEndpointThenSweepRemovesDrainedHost(t *testing.T) {
48 ts, a, _ := apiServer(t) 16 ts, _, _, _, a := newServer(t)
49 out := enroll(t, ts) 17 out := enroll(t, ts)
50 hostID := out["host_id"] 18 hostID := out["host_id"]
51 19
@@ -65,13 +33,13 @@ func TestDecommissionEndpointThenSweepRemovesDrainedHost(t *testing.T) {
65 } 33 }
66 34
67 func TestDecommissionUnknownHostIs404(t *testing.T) { 35 func TestDecommissionUnknownHostIs404(t *testing.T) {
68 ts, _, _ := apiServer(t) 36 ts, _, _, _, _ := newServer(t)
69 resp := do(t, "DELETE", ts.URL+"/api/v1/hosts/nope", testPAT, nil) 37 resp := do(t, "DELETE", ts.URL+"/api/v1/hosts/nope", testPAT, nil)
70 assert.Equal(t, http.StatusNotFound, resp.StatusCode) 38 assert.Equal(t, http.StatusNotFound, resp.StatusCode)
71 } 39 }
72 40
73 func TestSweepLeavesHostWithVMs(t *testing.T) { 41 func TestSweepLeavesHostWithVMs(t *testing.T) {
74 ts, a, _ := apiServer(t) 42 ts, _, _, _, a := newServer(t)
75 out := enroll(t, ts) 43 out := enroll(t, ts)
76 hostID := out["host_id"] 44 hostID := out["host_id"]
77 resp := do(t, "POST", ts.URL+"/api/v1/vms", testPAT, map[string]any{"host_id": hostID, "name": "vm-a"}) 45 resp := do(t, "POST", ts.URL+"/api/v1/vms", testPAT, map[string]any{"host_id": hostID, "name": "vm-a"})
@@ -85,7 +53,7 @@ func TestSweepLeavesHostWithVMs(t *testing.T) {
85 } 53 }
86 54
87 func TestEventsStreamSendsSnapshot(t *testing.T) { 55 func TestEventsStreamSendsSnapshot(t *testing.T) {
88 ts, _, _ := apiServer(t) 56 ts, _, _, _, _ := newServer(t)
89 enroll(t, ts) 57 enroll(t, ts)
90 58
91 ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) 59 ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
@@ -117,7 +85,7 @@ func TestEventsStreamSendsSnapshot(t *testing.T) {
117 } 85 }
118 86
119 func TestEventsRejectsBadToken(t *testing.T) { 87 func TestEventsRejectsBadToken(t *testing.T) {
120 ts, _, _ := apiServer(t) 88 ts, _, _, _, _ := newServer(t)
121 resp := do(t, "GET", ts.URL+"/api/v1/events?ticket=wrong", "", nil) 89 resp := do(t, "GET", ts.URL+"/api/v1/events?ticket=wrong", "", nil)
122 assert.Equal(t, http.StatusUnauthorized, resp.StatusCode) 90 assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
123 } 91 }
internal/server/api/decommission_poke_test.go
Old New
@@ -14,7 +14,7 @@ import (
14 // handler does), otherwise an online host never learns its VMs were tombstoned 14 // handler does), otherwise an online host never learns its VMs were tombstoned
15 // and stalls in `decommissioning` forever. 15 // and stalls in `decommissioning` forever.
16 func TestDecommissionPokesAgent(t *testing.T) { 16 func TestDecommissionPokesAgent(t *testing.T) {
17 ts, a, _ := apiServer(t) 17 ts, _, _, _, a := newServer(t)
18 out := enroll(t, ts) 18 out := enroll(t, ts)
19 hostID := out["host_id"] 19 hostID := out["host_id"]
20 20
@@ -35,7 +35,7 @@ func TestDecommissionPokesAgent(t *testing.T) {
35 // ?force=true purges VM rows and removes the host immediately, without waiting 35 // ?force=true purges VM rows and removes the host immediately, without waiting
36 // for an agent drain that (for dead hardware) can never happen. 36 // for an agent drain that (for dead hardware) can never happen.
37 func TestForceDecommissionRemovesHostWithVMs(t *testing.T) { 37 func TestForceDecommissionRemovesHostWithVMs(t *testing.T) {
38 ts, _, _ := apiServer(t) 38 ts, _, _, _, _ := newServer(t)
39 out := enroll(t, ts) 39 out := enroll(t, ts)
40 hostID := out["host_id"] 40 hostID := out["host_id"]
41 41
@@ -54,7 +54,7 @@ func TestForceDecommissionRemovesHostWithVMs(t *testing.T) {
54 // no longer accepts new VMs (previously only the FK was enforced, so a create 54 // no longer accepts new VMs (previously only the FK was enforced, so a create
55 // could land on a host being torn down). 55 // could land on a host being torn down).
56 func TestCreateVMRejectedOnDecommissioningHost(t *testing.T) { 56 func TestCreateVMRejectedOnDecommissioningHost(t *testing.T) {
57 ts, _, _ := apiServer(t) 57 ts, _, _, _, _ := newServer(t)
58 out := enroll(t, ts) 58 out := enroll(t, ts)
59 hostID := out["host_id"] 59 hostID := out["host_id"]
60 60
internal/server/api/hostinfo_api_test.go
Old New
@@ -12,7 +12,7 @@ import (
12 // Facts are persisted, so the HTTP host response carries them regardless of 12 // Facts are persisted, so the HTTP host response carries them regardless of
13 // online state. 13 // online state.
14 func TestHostResponseIncludesFacts(t *testing.T) { 14 func TestHostResponseIncludesFacts(t *testing.T) {
15 ts, _, st := apiServer(t) 15 ts, st, _, _, _ := newServer(t)
16 out := enroll(t, ts) 16 out := enroll(t, ts)
17 hostID := out["host_id"] 17 hostID := out["host_id"]
18 18
internal/server/api/testdata/delegation-request.golden.json
Old New
@@ -0,0 +1,3 @@
1 {
2 "certificate": "ssh-ed25519-cert-v01@openssh.com AAAAdelegation alex@laptop"
3 }
internal/server/api/testdata/vm-trusted-cas-empty.golden.json
Old New
@@ -0,0 +1,26 @@
1 {
2 "id": "v-5678",
3 "host_id": "h-1234",
4 "name": "sandbox-abc123",
5 "image_url": "https://images.example.com/resolute.img",
6 "vcpus": 2,
7 "mem_mb": 2048,
8 "disk_gb": 10,
9 "power_state": "running",
10 "status": "ready",
11 "last_error": "boot timeout",
12 "assigned_ip": "10.77.1.2",
13 "created_at": "2026-07-27T12:01:00Z",
14 "deleted": true,
15 "actual_power": "stopped",
16 "phase": "creating",
17 "status_detail": "downloading image 1.2/3.7 GiB",
18 "destroy_at": 1785153600,
19 "lifecycle": "deleting",
20 "injected_key": {
21 "type": "ssh-ed25519",
22 "fingerprint": "SHA256:0000000000000000000000000000000000000000000",
23 "comment": "alex@laptop"
24 },
25 "trusted_cas": []
26 }
internal/server/api/testdata/vm-trusted-cas-null.golden.json
Old New
@@ -0,0 +1,26 @@
1 {
2 "id": "v-5678",
3 "host_id": "h-1234",
4 "name": "sandbox-abc123",
5 "image_url": "https://images.example.com/resolute.img",
6 "vcpus": 2,
7 "mem_mb": 2048,
8 "disk_gb": 10,
9 "power_state": "running",
10 "status": "ready",
11 "last_error": "boot timeout",
12 "assigned_ip": "10.77.1.2",
13 "created_at": "2026-07-27T12:01:00Z",
14 "deleted": true,
15 "actual_power": "stopped",
16 "phase": "creating",
17 "status_detail": "downloading image 1.2/3.7 GiB",
18 "destroy_at": 1785153600,
19 "lifecycle": "deleting",
20 "injected_key": {
21 "type": "ssh-ed25519",
22 "fingerprint": "SHA256:0000000000000000000000000000000000000000000",
23 "comment": "alex@laptop"
24 },
25 "trusted_cas": null
26 }
internal/server/api/types/wire_tags_test.go
Old New
@@ -0,0 +1,84 @@
1 package types_test
2
3 import (
4 "go/ast"
5 "go/parser"
6 "go/token"
7 "os"
8 "path/filepath"
9 "reflect"
10 "runtime"
11 "strconv"
12 "strings"
13 "testing"
14 )
15
16 // TestNoOmitemptyOrIgnoredTags enforces the discipline the wire golden silently
17 // depends on. TestWireGolden (internal/server/api) byte-pins each DTO by
18 // marshalling an all-fields-set exemplar, which can catch a DROPPED or renamed
19 // field. Its ability to catch an ADDED field, though, rests entirely on every
20 // field always serialising: an `omitempty` field left at its zero value would
21 // vanish from the exemplar's bytes, so a new one could be added without any
22 // golden changing — and `json:"-"` hides a field from the wire golden
23 // completely. spec.go documents this assumption; nothing enforced it until now.
24 //
25 // The check walks the types package SOURCE (not just the wire-reachable set) so
26 // it covers every struct in the contract package, and fails if any json tag
27 // carries omitempty or is "-".
28 func TestNoOmitemptyOrIgnoredTags(t *testing.T) {
29 _, self, _, ok := runtime.Caller(0)
30 if !ok {
31 t.Fatal("runtime.Caller failed; cannot locate the types package source")
32 }
33 dir := filepath.Dir(self)
34
35 entries, err := os.ReadDir(dir)
36 if err != nil {
37 t.Fatalf("read types package dir: %v", err)
38 }
39
40 fset := token.NewFileSet()
41 checked := 0
42 for _, e := range entries {
43 name := e.Name()
44 if !strings.HasSuffix(name, ".go") || strings.HasSuffix(name, "_test.go") {
45 continue
46 }
47 f, err := parser.ParseFile(fset, filepath.Join(dir, name), nil, 0)
48 if err != nil {
49 t.Fatalf("parse %s: %v", name, err)
50 }
51 ast.Inspect(f, func(n ast.Node) bool {
52 field, ok := n.(*ast.Field)
53 if !ok || field.Tag == nil {
54 return true
55 }
56 raw, err := strconv.Unquote(field.Tag.Value)
57 if err != nil {
58 return true
59 }
60 jsonTag := reflect.StructTag(raw).Get("json")
61 if jsonTag == "" {
62 return true
63 }
64 checked++
65 opts := strings.Split(jsonTag, ",")
66 fieldName := jsonTag
67 if len(field.Names) > 0 {
68 fieldName = field.Names[0].Name
69 }
70 if opts[0] == "-" {
71 t.Errorf(`%s: field %s carries json:"-" — a field hidden from the wire is invisible to the wire golden; a contract type must not carry one`, name, fieldName)
72 }
73 for _, opt := range opts[1:] {
74 if opt == "omitempty" {
75 t.Errorf(`%s: field %s carries omitempty — the wire golden cannot detect an ADDED field once a struct drops its zero-valued fields; every contract field must always serialise`, name, fieldName)
76 }
77 }
78 return true
79 })
80 }
81 if checked == 0 {
82 t.Fatal("walked the types package but found no json tags — the parser or the file layout changed")
83 }
84 }
internal/server/api/wire_golden_test.go
Old New
@@ -121,6 +121,21 @@ func TestWireGolden(t *testing.T) {
121 } 121 }
122 goldenCheck(t, "vm", vm) 122 goldenCheck(t, "vm", vm)
123 123
124 // TrustedCAs' null-vs-empty distinction is load-bearing (see the field's doc
125 // in types.go): null means the VM predates the record and its trusted set was
126 // never written down, while an empty list cannot occur — create refuses a
127 // tenant with no CA. A client must not conflate them, so both marshalings are
128 // pinned here, not only the populated case in "vm" above. null must serialise
129 // as JSON null (present, not [] and not omitted); a non-nil empty slice as [].
130 vmTrustUnknown := vm
131 vmTrustUnknown.TrustedCAs = nil
132 goldenCheck(t, "vm-trusted-cas-null", vmTrustUnknown)
133
134 vmTrustEmpty := vm
135 emptyCAs := []types.TrustedCA{}
136 vmTrustEmpty.TrustedCAs = &emptyCAs
137 goldenCheck(t, "vm-trusted-cas-empty", vmTrustEmpty)
138
124 goldenCheck(t, "snapshot", types.StateSnapshot{ 139 goldenCheck(t, "snapshot", types.StateSnapshot{
125 Hosts: []types.Host{host}, 140 Hosts: []types.Host{host},
126 VMs: []types.VM{vm}, 141 VMs: []types.VM{vm},
@@ -220,6 +235,10 @@ func TestWireGolden(t *testing.T) {
220 Instructions: "ssh-keygen -s <your-ca-key> -I eitri-delegation -n ubuntu -V +8h eitri-delegation.pub", 235 Instructions: "ssh-keygen -s <your-ca-key> -I eitri-delegation -n ubuntu -V +8h eitri-delegation.pub",
221 }) 236 })
222 237
238 goldenCheck(t, "delegation-request", types.DelegationRequest{
239 Certificate: "ssh-ed25519-cert-v01@openssh.com AAAAdelegation alex@laptop",
240 })
241
223 goldenCheck(t, "delegation", types.Delegation{ 242 goldenCheck(t, "delegation", types.Delegation{
224 PublicKey: "ssh-ed25519 AAAAC3Nza eitri-delegation", 243 PublicKey: "ssh-ed25519 AAAAC3Nza eitri-delegation",
225 CAFingerprint: "SHA256:abcdefghijk", 244 CAFingerprint: "SHA256:abcdefghijk",
internal/smoke/mcp_contract_test.go
Old New
@@ -0,0 +1,57 @@
1 package smoke
2
3 import (
4 "context"
5 "testing"
6
7 "github.com/modelcontextprotocol/go-sdk/mcp"
8 "github.com/stretchr/testify/assert"
9 "github.com/stretchr/testify/require"
10
11 "github.com/a73x/eitri/internal/mcpserver"
12 )
13
14 // stubDelegator satisfies mcpserver.Delegator so NewServer builds the FULL
15 // remote toolset: the two delegate tools are registered only when a Delegator
16 // is present, and a bearer-PAT remote caller always gets one.
17 type stubDelegator struct{}
18
19 func (stubDelegator) Begin(context.Context) (mcpserver.BeginResult, error) {
20 return mcpserver.BeginResult{}, nil
21 }
22
23 func (stubDelegator) Complete(context.Context, string) (mcpserver.DelegationResult, error) {
24 return mcpserver.DelegationResult{}, nil
25 }
26
27 // TestRemoteToolsMatchTheServerThatPublishesThem ties the smoke's idea of the
28 // remote toolset to the server that actually serves it. remoteToolCount (a
29 // production const the leg checks against) and the fake's scripted tool list
30 // agreed only with each other; a tool added to mcpserver.NewServer would leave
31 // both silently stale, and the gate would keep checking a number that no longer
32 // describes the endpoint. Listing NewServer's real tools makes that drift fail
33 // here instead.
34 func TestRemoteToolsMatchTheServerThatPublishesThem(t *testing.T) {
35 srv := mcpserver.NewServer(&mcpserver.Tools{}, mcpserver.Options{Delegator: stubDelegator{}})
36
37 ctx := t.Context()
38 serverTr, clientTr := mcp.NewInMemoryTransports()
39 ss, err := srv.Connect(ctx, serverTr, nil)
40 require.NoError(t, err)
41 defer ss.Close()
42 cs, err := mcp.NewClient(&mcp.Implementation{Name: "smoke-contract", Version: "0"}, nil).Connect(ctx, clientTr, nil)
43 require.NoError(t, err)
44 defer cs.Close()
45
46 res, err := cs.ListTools(ctx, nil)
47 require.NoError(t, err)
48 names := make([]string, 0, len(res.Tools))
49 for _, tool := range res.Tools {
50 names = append(names, tool.Name)
51 }
52
53 assert.Len(t, names, remoteToolCount,
54 "remoteToolCount is stale: mcpserver.NewServer now publishes a different number of tools than the leg checks for")
55 assert.ElementsMatch(t, newFakeMCP(t).tools, names,
56 "the fake MCP's scripted tool list has drifted from what mcpserver.NewServer publishes")
57 }
scripts/coverage.sh
Old New
@@ -15,48 +15,73 @@ set -euo pipefail
15 cd "$(dirname "$0")/.." 15 cd "$(dirname "$0")/.."
16 16
17 # package (module-relative) -> minimum acceptable coverage % 17 # package (module-relative) -> minimum acceptable coverage %
18 #
19 # Each floor sits just under the package's measured coverage — the largest
20 # integer strictly below it (a 100% package floors at 100). Raise a floor
21 # whenever you raise the coverage; never lower one to make a drop pass.
18 declare -A FLOOR=( 22 declare -A FLOOR=(
19 [internal/agent/reconcile]=81 23 [internal/agent/reconcile]=90
20 [internal/agent/state]=50 24 [internal/agent/state]=67
21 [internal/agent/statelock]=75 25 [internal/agent/statelock]=77
22 [internal/agent/seed]=79 26 [internal/agent/seed]=87
23 [internal/agent/ipalloc]=83 27 [internal/agent/ipalloc]=90
24 [internal/agent/hostinfo]=95 28 [internal/agent/hostinfo]=99
25 [internal/agent/imagecache]=72 29 [internal/agent/imagecache]=80
26 [internal/agent/netenv]=76 30 [internal/agent/netenv]=85
27 [internal/agent/cloudhv]=80 31 [internal/agent/cloudhv]=86
28 [internal/agent/hyperlog]=90 32 [internal/agent/hyperlog]=92
29 [internal/agent/pidfile]=95 33 [internal/agent/pidfile]=100
30 [internal/agent/vfkit]=85 34 [internal/agent/vfkit]=88
31 [internal/agent/syncclient]=74 35 [internal/agent/syncclient]=82
32 [internal/agent/exposeproxy]=80 36 # exposeproxy runs real proxy goroutines; its coverage wobbles run to run
33 [internal/server/api]=76 37 # (measured 92.5–93.8%), so this floor carries a margin the others don't need.
34 [internal/server/delegation]=90 38 [internal/agent/exposeproxy]=91
35 [internal/server/seal]=85 39 [internal/agent/serialpump]=85
36 [internal/server/sshca]=65 40 [internal/agent/enrollclient]=81
37 [internal/server/mcphttp]=88 41 [internal/agent/dhcp]=64
38 [internal/server/vmssh]=90 42 [internal/agent/permanent]=100
39 [internal/server/boot]=20 43 [internal/server/api]=83
40 [internal/server/api/client]=89 44 [internal/server/delegation]=91
41 [internal/server/api/spec]=91 45 [internal/server/seal]=90
42 [internal/server/store]=76 46 [internal/server/sshca]=72
43 [internal/server/registry]=95 47 [internal/server/sshgate]=87
44 [internal/server/release]=90 48 [internal/server/mcphttp]=93
45 [internal/agent/selfupdate]=65 49 [internal/server/vmssh]=100
46 [internal/agent/bootstrap]=70 50 [internal/server/boot]=33
47 [internal/agent/run]=30 51 [internal/server/health]=100
48 [internal/server/hosttoken]=95 52 [internal/server/api/client]=93
49 [internal/server/hub]=90 53 [internal/server/api/spec]=93
50 [internal/server/syncsvc]=75 54 [internal/server/store]=80
51 [internal/server/web]=90 55 [internal/server/registry]=100
52 [internal/transport]=77 56 [internal/server/release]=93
53 [internal/shape]=88 57 [internal/agent/selfupdate]=70
58 [internal/agent/bootstrap]=73
59 [internal/agent/run]=40
60 [internal/server/hosttoken]=100
61 [internal/server/hub]=94
62 [internal/server/syncsvc]=85
63 [internal/server/web]=95
64 [internal/transport]=82
65 [internal/shape]=91
54 [internal/site]=84 66 [internal/site]=84
55 [internal/smoke]=50 67 [internal/smoke]=56
56 [internal/cli]=64 68 [internal/cli]=71
57 [internal/mcpserver]=57 69 [internal/mcpserver]=73
58 [internal/oidcprovider]=79 70 [internal/oidcprovider]=79
59 [internal/server/config]=95 71 [internal/server/config]=100
72 [internal/cloudinit]=77
73 [internal/covsnap]=77
74 [internal/joinblob]=96
75 [internal/gateclient]=60
76 # internal/names and internal/random have no tests of their own: they are
77 # exercised only through the packages that call them, so they report 0.0%
78 # here. A real floor would be a lie. The 0 floor keeps them COUNTED — so the
79 # renamed/removed-package guard still accounts for them — while asserting
80 # nothing about a number nothing measures. Give a package a real floor only
81 # once it has tests of its own. (internal/pb is generated and stays unlisted;
82 # internal/guest has no tests AND no coverable line to gate yet.)
83 [internal/names]=0
84 [internal/random]=0
60 ) 85 )
61 86
62 profile="$(mktemp)" 87 profile="$(mktemp)"