d8cdbdd7
test: the suite catches what it was only assuming
a73x 2026-08-12 19:51
Commit message
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)" |