a73x

internal/server/api/api_test.go

Ref:   Size: 57.9 KiB   History

package api

import (
	"bytes"
	"encoding/json"
	"io"
	"maps"
	"net/http"
	"net/http/httptest"
	"strings"
	"testing"
	"time"

	"github.com/a73x/eitri/internal/joinblob"
	"github.com/a73x/eitri/internal/server/api/types"
	"github.com/a73x/eitri/internal/server/hub"
	"github.com/a73x/eitri/internal/server/registry"
	"github.com/a73x/eitri/internal/server/release"
	"github.com/a73x/eitri/internal/server/store"
	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"
)

// --- contract-pinning helpers ---

func decodeJSONKeys(t *testing.T, resp *http.Response) []map[string]any {
	t.Helper()
	var out []map[string]any
	require.NoError(t, json.NewDecoder(resp.Body).Decode(&out))
	return out
}

// TestResponseJSONKeysAreSnakeCase pins the wire shape of GET /api/v1/hosts and
// GET /api/v1/vms: only snake_case keys allowed, PascalCase keys (from embedded
// structs) must be absent, and write-only fields must not appear.
func TestResponseJSONKeysAreSnakeCase(t *testing.T) {
	ts, _, _ := testServer(t)
	out := enroll(t, ts)

	// Create a VM so the list is non-empty.
	resp := do(t, "POST", ts.URL+"/api/v1/vms", testPAT,
		map[string]any{"host_id": out["host_id"], "name": "test-vm"})
	require.Equal(t, 201, resp.StatusCode)

	t.Run("hosts", func(t *testing.T) {
		resp := do(t, "GET", ts.URL+"/api/v1/hosts", testPAT, nil)
		require.Equal(t, 200, resp.StatusCode)
		items := decodeJSONKeys(t, resp)
		require.Len(t, items, 1)
		h := items[0]

		// Required snake_case keys must be present.
		for _, k := range []string{"id", "name", "os", "arch", "provisioner", "bridge_cidr", "status", "enrolled_at", "online", "capacity"} {
			assert.Contains(t, h, k, "host response must contain key %q", k)
		}
		// PascalCase keys from embedded store.Host must be absent, and overlay
		// is gone entirely (deleted server-side — Plan D).
		for _, k := range []string{"ID", "Name", "OS", "Arch", "Provisioner", "Overlay", "BridgeCIDR", "Status", "EnrolledAt", "overlay"} {
			assert.NotContains(t, h, k, "host response must NOT contain key %q", k)
		}
		// Capacity sub-object must use snake_case.
		cap, ok := h["capacity"].(map[string]any)
		require.True(t, ok, "capacity must be an object")
		for _, k := range []string{"vcpus", "mem_mb", "disk_gb"} {
			assert.Contains(t, cap, k, "capacity must contain key %q", k)
		}
		for _, k := range []string{"VCPUs", "MemMB", "DiskGB"} {
			assert.NotContains(t, cap, k, "capacity must NOT contain PascalCase key %q", k)
		}
	})

	t.Run("vms", func(t *testing.T) {
		resp := do(t, "GET", ts.URL+"/api/v1/vms", testPAT, nil)
		require.Equal(t, 200, resp.StatusCode)
		items := decodeJSONKeys(t, resp)
		require.Len(t, items, 1)
		v := items[0]

		// Required snake_case keys must be present.
		for _, k := range []string{
			"id", "host_id", "name", "image_url", "vcpus", "mem_mb", "disk_gb",
			"power_state", "status", "status_detail", "last_error", "assigned_ip",
			"network", "network_ip", "created_at", "deleted", "actual_power", "phase",
			"destroy_at", "lifecycle", "trusted_cas", "injected_key",
		} {
			assert.Contains(t, v, k, "vm response must contain key %q", k)
		}
		// Retired: persistence is not a property of a VM, so it is not one of
		// its fields. Every consumer ships with the server, so there is nobody
		// left to tell.
		assert.NotContains(t, v, "persistent")
		// PascalCase keys from embedded store.VM must be absent.
		for _, k := range []string{
			"ID", "HostID", "Name", "ImageURL", "ImageSHA256", "CloudInit",
			"VCPUs", "MemMB", "DiskGB", "PowerState",
			"Status", "LastError", "AssignedIP", "SSHAuthorizedKey",
			"CreatedAt", "DeletedAt",
		} {
			assert.NotContains(t, v, k, "vm response must NOT contain PascalCase key %q", k)
		}
		// Write-only fields must not be on the wire.
		for _, k := range []string{
			"image_sha256", "cloud_init", "ssh_authorized_key",
			"ssh_host_key", "ssh_host_cert",
		} {
			assert.NotContains(t, v, k, "write-only field %q must not appear in response", k)
		}
		// deleted should be false (not tombstoned).
		assert.Equal(t, false, v["deleted"])
	})
}

// TestCreateVMDuplicateNameReturns409 pins that a duplicate live VM name returns
// 409 with no error details leaked.
func TestCreateVMDuplicateNameReturns409(t *testing.T) {
	ts, _, _ := testServer(t)
	out := enroll(t, ts)

	resp1 := do(t, "POST", ts.URL+"/api/v1/vms", testPAT,
		map[string]any{"host_id": out["host_id"], "name": "clash"})
	require.Equal(t, 201, resp1.StatusCode)

	resp2 := do(t, "POST", ts.URL+"/api/v1/vms", testPAT,
		map[string]any{"host_id": out["host_id"], "name": "clash"})
	assert.Equal(t, 409, resp2.StatusCode)

	// Body must not leak raw error text.
	var body map[string]any
	json.NewDecoder(resp2.Body).Decode(&body)
	bodyStr, _ := json.Marshal(body)
	assert.NotContains(t, string(bodyStr), "UNIQUE", "raw SQLite error must not leak into response")
}

// enrollArch enrols a host with a given name/os/arch, so a test can place a VM
// on something other than the default linux/amd64 host.
func enrollArch(t *testing.T, ts *httptest.Server, name, os, arch, prov string) map[string]string {
	t.Helper()
	resp := do(t, "POST", ts.URL+"/api/v1/enroll-tokens", testPAT, nil)
	require.Equal(t, 201, resp.StatusCode)
	var tok map[string]string
	json.NewDecoder(resp.Body).Decode(&tok)
	resp = do(t, "POST", ts.URL+"/api/v1/enroll", "", map[string]string{
		"token": tok["token"], "name": name, "os": os, "arch": arch, "provisioner": prov})
	require.Equal(t, 201, resp.StatusCode)
	var out map[string]string
	json.NewDecoder(resp.Body).Decode(&out)
	agentJoins(out["host_id"])
	return out
}

// TestCreateVMDefaultImageFollowsHostArch pins the rule a mixed-arch fleet
// depends on: a one-click create takes the default image for the architecture of
// the host it lands on. The fleet-wide default that preceded this handed an
// arm64 Mac an amd64 image, which boots into nothing and surfaces only as
// "ephemeral VM lost" once the hypervisor exits.
func TestCreateVMDefaultImageFollowsHostArch(t *testing.T) {
	ts, st, _ := testServer(t)
	linux := enroll(t, ts)                                              // linux/amd64
	mac := enrollArch(t, ts, "host-m", "darwin", "arm64", "vfkit")      //nolint:misspell // vfkit
	riscv := enrollArch(t, ts, "host-r", "linux", "riscv64", "cloudhv") // no configured image

	// testServer configures amd64 and arm64 (see the Config literal above).
	for _, tc := range []struct{ name, hostID, wantImage string }{
		{"amd64 host", linux["host_id"], "amd64"},
		{"arm64 host", mac["host_id"], "arm64"},
	} {
		t.Run(tc.name, func(t *testing.T) {
			resp := do(t, "POST", ts.URL+"/api/v1/vms", testPAT,
				map[string]any{"host_id": tc.hostID, "name": "vm-" + tc.wantImage})
			require.Equal(t, 201, resp.StatusCode)
			var out map[string]string
			json.NewDecoder(resp.Body).Decode(&out)
			vm, err := st.GetVM(out["id"])
			require.NoError(t, err)
			assert.Contains(t, vm.ImageURL, tc.wantImage,
				"the default image must match the host's architecture")
		})
	}

	// An architecture with no configured image is a 400 that names it — not a
	// silent fallback to some other arch's image.
	t.Run("unconfigured arch refuses", func(t *testing.T) {
		resp := do(t, "POST", ts.URL+"/api/v1/vms", testPAT,
			map[string]any{"host_id": riscv["host_id"], "name": "vm-riscv"})
		require.Equal(t, 400, resp.StatusCode)
		body, _ := io.ReadAll(resp.Body)
		assert.Contains(t, string(body), "riscv64", "the error must name the architecture")
	})

	// An EXPLICIT image is never arch-checked: a URL says nothing about what it
	// can execute, and guessing would reject legitimate custom images.
	t.Run("explicit image is not second-guessed", func(t *testing.T) {
		resp := do(t, "POST", ts.URL+"/api/v1/vms", testPAT, map[string]any{
			"host_id": mac["host_id"], "name": "vm-explicit",
			"image_url": "https://example.test/my-amd64-build.img", "image_sha256": strings.Repeat("b", 64)})
		assert.Equal(t, 201, resp.StatusCode)
	})
}

// TestCreateVMUnknownHostReturns400 pins that an unknown host_id returns 400.
func TestCreateVMUnknownHostReturns400(t *testing.T) {
	ts, _, _ := testServer(t)
	resp := do(t, "POST", ts.URL+"/api/v1/vms", testPAT,
		map[string]any{"host_id": "deadbeef00000000000000000000000000000000", "name": "vm-orphan"})
	assert.Equal(t, 400, resp.StatusCode)
}

// TestHostResponseSurfacesSyncHealth pins the sync-health mapping in
// toHostResponse: a live host surfaces last_seen, seconds_since_last_seen,
// stale and sessions; a host with no registry entry (never connected) and a
// host that connected but never reported both leave the age fields null.
func TestHostResponseSurfacesSyncHealth(t *testing.T) {
	h := store.Host{ID: "h1", Name: "host-1"}

	t.Run("online and stale", func(t *testing.T) {
		st := registry.HostState{
			LastSeen:      time.Unix(1700000000, 0),
			SinceLastSeen: 20 * time.Second,
			Online:        true,
			Stale:         true,
			Sessions:      3,
		}
		hr := toHostResponse(h, st, true, store.Alloc{})
		require.NotNil(t, hr.LastSeen)
		assert.True(t, hr.LastSeen.Equal(time.Unix(1700000000, 0)))
		require.NotNil(t, hr.SecondsSinceLastSeen)
		assert.Equal(t, int64(20), *hr.SecondsSinceLastSeen)
		assert.True(t, hr.Online)
		assert.True(t, hr.Stale, "past half the online window ⇒ stale")
		assert.Equal(t, 3, hr.Sessions)
	})

	t.Run("connected but never reported", func(t *testing.T) {
		// RecordConnect created an entry (ok=true, Sessions>0) but LastSeen is
		// unset, so the age fields must stay null rather than emit the zero time.
		st := registry.HostState{Sessions: 1}
		hr := toHostResponse(h, st, true, store.Alloc{})
		assert.Nil(t, hr.LastSeen)
		assert.Nil(t, hr.SecondsSinceLastSeen)
		assert.False(t, hr.Online)
		assert.Equal(t, 1, hr.Sessions)
	})

	t.Run("no registry entry", func(t *testing.T) {
		hr := toHostResponse(h, registry.HostState{}, false, store.Alloc{})
		assert.Nil(t, hr.LastSeen)
		assert.Nil(t, hr.SecondsSinceLastSeen)
		assert.False(t, hr.Online)
		assert.False(t, hr.Stale)
		assert.Equal(t, 0, hr.Sessions)
	})
}

// testPAT is the default-tenant personal access token the harness mints for the
// current server; do() and enroll() pass it as the Bearer credential. It is
// package-global because it flows through nearly every test's do() call without
// threading it through every signature — safe because these tests never run in
// parallel (no t.Parallel anywhere), so newServer/apiServer set it before any
// request reads it.
var testPAT string

// testTenant is the tenant the shared builders provision — through the real
// JIT path, the only way tenants are born. The name has no significance.
const testTenant = "default"

// seedTestTenant JIT-provisions testTenant on a fresh store.
func seedTestTenant(t *testing.T, st *store.Store) {
	t.Helper()
	_, err := st.CreateTenantForIdentity("https://test-issuer", "test-subject", testTenant+"@test.local")
	require.NoError(t, err)
}

// mintTestPAT mints the test-tenant PAT for st and records it in testPAT.
func mintTestPAT(t *testing.T, st *store.Store) {
	t.Helper()
	secret, _, err := st.CreateAPIToken(testTenant, "test", 0)
	require.NoError(t, err)
	testPAT = secret
}

// testAgentVersion is what the enrol helpers report on a joining host's behalf:
// a release that certifies guest host keys, so a host these tests enrol is
// never one an operator would be told to upgrade.
const testAgentVersion = release.FirstCertifiedHostKeys

// testReg is the registry newServer built. Like testPAT it is package-level
// because the enrol helpers take only the server, and what an agent said about
// itself lives in the registry, not the store.
var testReg *registry.Registry

// agentJoins records the Hello a real agent sends the moment after it enrols:
// a host is in the fleet precisely because its agent connected and named its
// version. It stops there, at connected-but-not-yet-reporting, because several
// tests need an enrolled host whose agent is offline (the abandoned-tombstone
// sweep, for one). Tests that exercise the certified-host-key precondition
// bring their host online themselves with agentReports.
func agentJoins(hostID string) { testReg.SetAgentVersion(hostID, testAgentVersion) }

// agentReports is the first report that follows the Hello: it sets LastSeen, so
// registry.Get reads the host as online and the create path will judge what its
// agent said about itself.
func agentReports(hostID string) { testReg.UpdateReport(hostID, registry.Report{}) }

// newServer is the shared builder. It also returns the *API itself for tests
// that need post-construction wiring (SetConsoleDialer, SetCertMinter).
func newServer(t *testing.T) (*httptest.Server, *store.Store, *hub.Hub, *registry.Registry, *API) {
	t.Helper()
	st, err := store.Open(t.TempDir()+"/db", "10.77.0.0/16")
	require.NoError(t, err)
	t.Cleanup(func() { st.Close() })
	seedTestTenant(t, st)
	h := hub.New()
	reg := registry.New(time.Now)
	testReg = reg
	a := New(Config{
		HostSecret: []byte("hostsecret"),
		DefaultImages: map[string]DefaultImage{
			"amd64": {
				URL:    "https://cloud-images.ubuntu.com/noble/current/noble-server-cloudimg-amd64.img",
				SHA256: strings.Repeat("a", 64)},
			"arm64": {
				URL:    "https://cloud-images.ubuntu.com/noble/current/noble-server-cloudimg-arm64.img",
				SHA256: strings.Repeat("a", 64)},
		},
		AdvertiseHTTP:    "http://127.0.0.1:8080",
		AdvertiseQUIC:    "127.0.0.1:8443",
		ServerCertSHA256: strings.Repeat("c", 64),
	}, st, reg, h)
	// BYO-CA precondition: VM create now requires the tenant to have ≥1
	// registered SSH user CA. Seed the default tenant with a throwaway CA line
	// so existing VM-create tests exercise the create path, not the precondition.
	require.NoError(t, st.AddTenantUserCA(testTenant,
		"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAITESTSEEDCA eitri-test-seed", "tenant", "test-seed", "test"))
	mintTestPAT(t, st)
	ts := httptest.NewServer(a.Handler())
	t.Cleanup(ts.Close)
	t.Cleanup(a.Close) // stop the snapshot hub goroutine
	return ts, st, h, reg, a
}

// sessionFor mints a console session row for tenant and returns its id — the
// eitri_session cookie value the middleware's cookie path consumes.
func sessionFor(t *testing.T, st *store.Store, tenant string) string {
	t.Helper()
	id, err := st.CreateSession(tenant, time.Hour)
	require.NoError(t, err)
	return id
}

// doCookie issues a request authenticated by an eitri_session cookie rather than
// a Bearer token — the console (browser) auth path.
func doCookie(t *testing.T, method, url, session string, body any) *http.Response {
	t.Helper()
	var buf bytes.Buffer
	if body != nil {
		require.NoError(t, json.NewEncoder(&buf).Encode(body))
	}
	req, _ := http.NewRequest(method, url, &buf)
	req.AddCookie(&http.Cookie{Name: "eitri_session", Value: session})
	resp, err := http.DefaultClient.Do(req)
	require.NoError(t, err)
	t.Cleanup(func() { resp.Body.Close() })
	return resp
}

func testServer(t *testing.T) (*httptest.Server, *store.Store, *hub.Hub) {
	t.Helper()
	ts, st, h, _, _ := newServer(t)
	return ts, st, h
}

func do(t *testing.T, method, url, token string, body any) *http.Response {
	t.Helper()
	var buf bytes.Buffer
	if body != nil {
		require.NoError(t, json.NewEncoder(&buf).Encode(body))
	}
	req, _ := http.NewRequest(method, url, &buf)
	if token != "" {
		req.Header.Set("Authorization", "Bearer "+token)
	}
	resp, err := http.DefaultClient.Do(req)
	require.NoError(t, err)
	t.Cleanup(func() { resp.Body.Close() })
	return resp
}

func enroll(t *testing.T, ts *httptest.Server) map[string]string {
	t.Helper()
	out := enrollSilent(t, ts)
	agentJoins(out["host_id"])
	return out // host_id, credential, bridge_cidr
}

// enrollSilent enrols a host whose agent never connects: the row is in the
// store and the registry has never heard of it. That is the state of every
// host in the fleet for a moment after the server restarts.
func enrollSilent(t *testing.T, ts *httptest.Server) map[string]string {
	t.Helper()
	return enrollSilentOS(t, ts, "linux")
}

// enrollSilentOS is enrollSilent with the host's reported OS as a parameter,
// for tests that need a host whose OS itself is the refusal — a Mac cannot
// serve a named network no matter what it later advertises.
func enrollSilentOS(t *testing.T, ts *httptest.Server, os string) map[string]string {
	t.Helper()
	resp := do(t, "POST", ts.URL+"/api/v1/enroll-tokens", testPAT, nil)
	require.Equal(t, 201, resp.StatusCode)
	var tok map[string]string
	json.NewDecoder(resp.Body).Decode(&tok)
	resp = do(t, "POST", ts.URL+"/api/v1/enroll", "", map[string]string{
		"token": tok["token"], "name": "host-a", "os": os, "arch": "amd64", "provisioner": "cloudhv"})
	require.Equal(t, 201, resp.StatusCode)
	var out map[string]string
	json.NewDecoder(resp.Body).Decode(&out)
	return out // host_id, credential, bridge_cidr
}

// TestUserAuthMiddleware pins the PAT/session middleware: a valid PAT or session
// cookie authenticates and scopes to its tenant; every unknown, expired, revoked,
// malformed, or absent credential is an indistinguishable 401 (spec §3).
func TestUserAuthMiddleware(t *testing.T) {
	ts, st, _ := testServer(t)

	t.Run("valid PAT authenticates", func(t *testing.T) {
		assert.Equal(t, 200, do(t, "GET", ts.URL+"/api/v1/vms", testPAT, nil).StatusCode)
	})

	t.Run("absent credential rejected", func(t *testing.T) {
		assert.Equal(t, 401, do(t, "GET", ts.URL+"/api/v1/vms", "", nil).StatusCode)
	})

	t.Run("garbage bearer rejected", func(t *testing.T) {
		// No eitri_pat_ prefix ⇒ falls through to the cookie path ⇒ no cookie ⇒ 401.
		assert.Equal(t, 401, do(t, "GET", ts.URL+"/api/v1/vms", "wrong", nil).StatusCode)
	})

	t.Run("prefixed but unknown PAT rejected", func(t *testing.T) {
		assert.Equal(t, 401, do(t, "GET", ts.URL+"/api/v1/vms", "eitri_pat_"+strings.Repeat("0", 64), nil).StatusCode)
	})

	t.Run("expired PAT rejected", func(t *testing.T) {
		// A negative TTL mints an already-expired token (expires_at in the past).
		expired, _, err := st.CreateAPIToken(testTenant, "expired", -time.Hour)
		require.NoError(t, err)
		assert.Equal(t, 401, do(t, "GET", ts.URL+"/api/v1/vms", expired, nil).StatusCode)
	})

	t.Run("revoked PAT rejected", func(t *testing.T) {
		secret, id, err := st.CreateAPIToken(testTenant, "doomed", 0)
		require.NoError(t, err)
		require.Equal(t, 200, do(t, "GET", ts.URL+"/api/v1/vms", secret, nil).StatusCode)
		require.NoError(t, st.RevokeAPIToken(testTenant, id))
		assert.Equal(t, 401, do(t, "GET", ts.URL+"/api/v1/vms", secret, nil).StatusCode)
	})

	t.Run("session cookie authenticates", func(t *testing.T) {
		sess := sessionFor(t, st, testTenant)
		assert.Equal(t, 200, doCookie(t, "GET", ts.URL+"/api/v1/vms", sess, nil).StatusCode)
	})

	t.Run("unknown session cookie rejected", func(t *testing.T) {
		assert.Equal(t, 401, doCookie(t, "GET", ts.URL+"/api/v1/vms", "not-a-session", nil).StatusCode)
	})

	// The console SPA carries an ambient session cookie on every request, so a
	// PAT branch that falls through on failure means a revoked or expired PAT
	// presented from a browser context keeps working — authenticated as
	// whatever tenant the cookie holds, not the one the caller presented.
	t.Run("prefixed but invalid PAT does not fall through to a valid session cookie", func(t *testing.T) {
		sess := sessionFor(t, st, testTenant)
		req, _ := http.NewRequest("GET", ts.URL+"/api/v1/vms", nil)
		req.Header.Set("Authorization", "Bearer eitri_pat_"+strings.Repeat("0", 64))
		req.AddCookie(&http.Cookie{Name: "eitri_session", Value: sess})
		resp, err := http.DefaultClient.Do(req)
		require.NoError(t, err)
		defer resp.Body.Close()
		body, _ := io.ReadAll(resp.Body)

		require.Equal(t, 401, resp.StatusCode,
			"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")
		assert.Contains(t, string(body), "invalid token",
			"the refusal must come from the PAT branch, not the cookie branch — a `sign in required` here means the PAT was never judged")
	})
}

// TestUserAuthResolvesCredentialTenant proves the middleware threads each
// credential's OWN tenant onto the principal: an enroll token minted with a
// second tenant's PAT produces a host owned by that tenant, not the default.
// (List-level and stream cross-tenant scoping is proven separately in
// isolation_test.go; every credential is now scoped to exactly its own tenant.)
func TestUserAuthResolvesCredentialTenant(t *testing.T) {
	ts, st, _ := testServer(t)

	beta, err := st.CreateTenantForIdentity("https://issuer.example", "sub-beta", "beta@example.com")
	require.NoError(t, err)
	betaPAT, _, err := st.CreateAPIToken(beta.ID, "beta", 0)
	require.NoError(t, err)

	// Mint an enroll token as beta, then redeem it: the host must belong to beta.
	resp := do(t, "POST", ts.URL+"/api/v1/enroll-tokens", betaPAT, nil)
	require.Equal(t, 201, resp.StatusCode)
	var tok map[string]string
	require.NoError(t, json.NewDecoder(resp.Body).Decode(&tok))
	resp = do(t, "POST", ts.URL+"/api/v1/enroll", "", map[string]string{
		"token": tok["token"], "name": "beta-host", "os": "linux", "arch": "amd64", "provisioner": "cloudhv"})
	require.Equal(t, 201, resp.StatusCode)
	var out map[string]string
	require.NoError(t, json.NewDecoder(resp.Body).Decode(&out))

	host, err := st.GetHost(out["host_id"])
	require.NoError(t, err)
	assert.Equal(t, beta.ID, host.Tenant, "host must be owned by the tenant whose PAT minted the token")
}

func TestEnrollIssuesCredentialAndCIDR(t *testing.T) {
	ts, _, _ := testServer(t)
	out := enroll(t, ts)
	assert.NotEmpty(t, out["host_id"])
	assert.Contains(t, out["credential"], out["host_id"]+".")
	assert.Equal(t, "10.77.1.0/24", out["bridge_cidr"])
}

func TestOneClickCreateFillsDefaultsAndPokesHub(t *testing.T) {
	ts, st, h := testServer(t)
	out := enroll(t, ts)
	poked, cancel := h.Subscribe(out["host_id"])
	defer cancel()

	resp := do(t, "POST", ts.URL+"/api/v1/vms", testPAT,
		map[string]any{"host_id": out["host_id"]}) // one-click: everything else defaulted
	require.Equal(t, 201, resp.StatusCode)

	vms, _ := st.ListVMs()
	require.Len(t, vms, 1)
	assert.Equal(t, types.DefaultVCPUs, vms[0].VCPUs)
	assert.Equal(t, types.DefaultMemMB, vms[0].MemMB)
	assert.Equal(t, types.DefaultDiskGB, vms[0].DiskGB)
	assert.Equal(t, "running", vms[0].PowerState)
	assert.NotEmpty(t, vms[0].Name)
	assert.Contains(t, vms[0].ImageURL, "ubuntu")
	select {
	case <-poked:
	default:
		t.Fatal("create must poke the host's stream")
	}
}

// TestCreateVMRefusesTheRetiredPersistentField pins the one thing a client
// carrying the old field must not get: silence. Persistence is no longer a
// choice, so a body still asking for one is refused with the sentence that says
// so — accepting "persistent": false and then creating a persistent VM anyway
// would be the API agreeing to something it does not do. Both values are
// refused: sending true is just as stale as sending false.
func TestCreateVMRefusesTheRetiredPersistentField(t *testing.T) {
	ts, st, _ := testServer(t)
	out := enroll(t, ts)

	for _, want := range []bool{false, true} {
		resp := do(t, "POST", ts.URL+"/api/v1/vms", testPAT,
			map[string]any{"host_id": out["host_id"], "persistent": want})
		require.Equal(t, 400, resp.StatusCode)
		body, _ := io.ReadAll(resp.Body)
		assert.Contains(t, string(body), "every VM is persistent now; drop the persistent field")
	}

	vms, _ := st.ListVMs()
	assert.Empty(t, vms, "a refused create must not have made a VM")
}

// TestCreateVMRefusesAHostThatCannotCertifyItsGuest pins the create-time half
// of the certified-host-key story. A guest whose host key nothing signed cannot
// be verified and is unreachable through the gate for the rest of its life, so
// the create is refused while an operator can still fix it by upgrading the
// agent — the refusal names the host, what it reported, and the endpoint that
// upgrades it. A version that cannot be read, and a connected host that has
// named none at all, are treated the same as an old one: neither proves the
// guest could be certified.
func TestCreateVMRefusesAHostThatCannotCertifyItsGuest(t *testing.T) {
	for _, tc := range []struct{ name, version, want string }{
		{"an older release", "v0.0.3", "runs agent v0.0.3, which predates certified host keys (v0.0.4)"},
		{"an unstamped build", "dev", "runs agent dev, which predates certified host keys (v0.0.4)"},
		{"nothing reported", "", "has reported no agent version"},
	} {
		t.Run(tc.name, func(t *testing.T) {
			ts, st, _ := testServer(t)
			out := enroll(t, ts)
			testReg.SetAgentVersion(out["host_id"], tc.version)
			agentReports(out["host_id"])

			resp := do(t, "POST", ts.URL+"/api/v1/vms", testPAT,
				map[string]any{"host_id": out["host_id"], "name": "doomed"})
			require.Equal(t, 409, resp.StatusCode)
			body, _ := io.ReadAll(resp.Body)
			msg := string(body)
			assert.Contains(t, msg, tc.want)
			assert.Contains(t, msg, "host host-a ("+out["host_id"]+")", "the refusal must name the host to act on")
			assert.Contains(t, msg, "/api/v1/hosts/"+out["host_id"]+"/upgrade-agent", "the refusal must name the fix")

			vms, err := st.ListVMs()
			require.NoError(t, err)
			assert.Empty(t, vms, "a refused create must leave no row behind")
		})
	}
}

// TestCreateVMOnACertifyingAgentIsUntouched is the other side of the same
// guard: every version at or past the floor — including a build described past
// it — places a VM exactly as before.
func TestCreateVMOnACertifyingAgentIsUntouched(t *testing.T) {
	for _, version := range []string{"v0.0.4", "v0.0.5", "v0.0.5-2-gabc1234"} {
		t.Run(version, func(t *testing.T) {
			ts, _, _ := testServer(t)
			out := enroll(t, ts)
			testReg.SetAgentVersion(out["host_id"], version)
			agentReports(out["host_id"])

			resp := do(t, "POST", ts.URL+"/api/v1/vms", testPAT,
				map[string]any{"host_id": out["host_id"], "name": "fine"})
			assert.Equal(t, 201, resp.StatusCode)
		})
	}
}

// TestCreateVMJudgesOnlyAConnectedHostsAgent pins who the certified-host-key
// refusal is allowed to accuse. The registry is in-memory and filled by the
// agent's Hello, so a host that is not connected right now has told this server
// process nothing: after a restart that is the whole fleet, and the empty
// version an absent entry carries must never be read as "too old" and answered
// with "upgrade this host" — the wrong diagnosis, delivered during a roll, about
// a host running the newest agent there is. A host that has gone quiet is the
// same: what it last said is not what the agent that eventually picks this VM up
// will be. Both take the create like any host that cannot serve one this second,
// and the connect-time refusal remains the backstop.
func TestCreateVMJudgesOnlyAConnectedHostsAgent(t *testing.T) {
	t.Run("absent from the registry", func(t *testing.T) {
		ts, st, _ := testServer(t)
		out := enrollSilent(t, ts)

		resp := do(t, "POST", ts.URL+"/api/v1/vms", testPAT,
			map[string]any{"host_id": out["host_id"], "name": "pending"})
		body, _ := io.ReadAll(resp.Body)
		require.Equal(t, 201, resp.StatusCode, "body: %s", body)
		assert.NotContains(t, string(body), "upgrade-agent")

		vms, err := st.ListVMs()
		require.NoError(t, err)
		assert.Len(t, vms, 1, "the create must land as desired state")
	})

	t.Run("connected once, now quiet", func(t *testing.T) {
		ts, st, _ := testServer(t)
		out := enroll(t, ts)
		// The last thing this host said was pre-CSR, and it has not reported
		// since — stale memory, not a diagnosis.
		testReg.SetAgentVersion(out["host_id"], "v0.0.3")

		resp := do(t, "POST", ts.URL+"/api/v1/vms", testPAT,
			map[string]any{"host_id": out["host_id"], "name": "pending"})
		body, _ := io.ReadAll(resp.Body)
		require.Equal(t, 201, resp.StatusCode, "body: %s", body)

		vms, err := st.ListVMs()
		require.NoError(t, err)
		assert.Len(t, vms, 1)
	})
}

func TestDeleteTombstones(t *testing.T) {
	ts, st, _ := testServer(t)
	out := enroll(t, ts)
	do(t, "POST", ts.URL+"/api/v1/vms", testPAT, map[string]any{"host_id": out["host_id"], "name": "doomed"})
	vms, _ := st.ListVMs()
	resp := do(t, "DELETE", ts.URL+"/api/v1/vms/"+vms[0].ID, testPAT, nil)
	assert.Equal(t, 204, resp.StatusCode)
	vms, _ = st.ListVMs()
	assert.NotNil(t, vms[0].DeletedAt, "DELETE tombstones; the agent reaps")
}

// TestVMEventsTimeline pins the per-VM lifecycle timeline: creating then
// deleting a VM records vm.create and vm.delete events retrievable via
// GET /api/v1/vms/{id}/events, scoped to that VM (a sibling VM's events must
// not appear), and the endpoint still serves the history after the row is gone.
func TestVMEventsTimeline(t *testing.T) {
	ts, _, _ := testServer(t)
	out := enroll(t, ts)

	// Two VMs so we can assert the timeline is scoped to one.
	r1 := do(t, "POST", ts.URL+"/api/v1/vms", testPAT,
		map[string]any{"host_id": out["host_id"], "name": "alpha"})
	require.Equal(t, 201, r1.StatusCode)
	var createdA map[string]string
	json.NewDecoder(r1.Body).Decode(&createdA)
	idA := createdA["id"]

	r2 := do(t, "POST", ts.URL+"/api/v1/vms", testPAT,
		map[string]any{"host_id": out["host_id"], "name": "bravo"})
	require.Equal(t, 201, r2.StatusCode)
	var createdB map[string]string
	json.NewDecoder(r2.Body).Decode(&createdB)
	idB := createdB["id"]

	require.Equal(t, 204, do(t, "DELETE", ts.URL+"/api/v1/vms/"+idA, testPAT, nil).StatusCode)

	resp := do(t, "GET", ts.URL+"/api/v1/vms/"+idA+"/events", testPAT, nil)
	require.Equal(t, 200, resp.StatusCode)
	var events []struct {
		Action string          `json:"action"`
		Detail json.RawMessage `json:"detail"`
	}
	require.NoError(t, json.NewDecoder(resp.Body).Decode(&events))

	var actions []string
	for _, e := range events {
		actions = append(actions, e.Action)
		assert.Contains(t, string(e.Detail), idA)
		assert.NotContains(t, string(e.Detail), idB, "must not leak a sibling VM's events")
	}
	assert.Contains(t, actions, "vm.create")
	assert.Contains(t, actions, "vm.delete")
}

// --- C1: input-validation tests ---

func TestCreateVMNameValidation(t *testing.T) {
	ts, _, _ := testServer(t)
	out := enroll(t, ts)

	tests := []struct {
		name       string
		vmName     string
		wantStatus int
	}{
		{"yaml injection via newline", "evil\nruncmd:", 400},
		{"name with spaces", "Has Spaces", 400},
		{"name too long (64 chars)", "a123456789012345678901234567890123456789012345678901234567890123", 400},
		{"valid name", "my-vm-2", 201},
		{"single char", "a", 201},
		{"starts with digit", "3vm", 201},
	}
	for _, tc := range tests {
		t.Run(tc.name, func(t *testing.T) {
			resp := do(t, "POST", ts.URL+"/api/v1/vms", testPAT,
				map[string]any{"host_id": out["host_id"], "name": tc.vmName})
			assert.Equal(t, tc.wantStatus, resp.StatusCode)
		})
	}
}

// TestCreateVMResourceValidation pins the resource floors: post-defaults,
// vcpus/mem_mb/disk_gb must each be >= 1. Zero means "use the default"; a
// negative value is never meaningful (a tiny disk_gb would also truncate the
// base image on the agent — the agent-side never-shrink guard is the backstop,
// this rejects nonsense at create time).
func TestCreateVMResourceValidation(t *testing.T) {
	ts, _, _ := testServer(t)
	out := enroll(t, ts)

	tests := []struct {
		name       string
		body       map[string]any
		wantStatus int
	}{
		{"negative disk_gb", map[string]any{"disk_gb": -1}, 400},
		{"negative vcpus", map[string]any{"vcpus": -2}, 400},
		{"negative mem_mb", map[string]any{"mem_mb": -512}, 400},
		{"zero values take defaults", map[string]any{"disk_gb": 0, "vcpus": 0, "mem_mb": 0}, 201},
		{"minimal explicit values", map[string]any{"disk_gb": 1, "vcpus": 1, "mem_mb": 1}, 201},
	}
	for _, tc := range tests {
		t.Run(tc.name, func(t *testing.T) {
			body := map[string]any{"host_id": out["host_id"]}
			maps.Copy(body, tc.body)
			resp := do(t, "POST", ts.URL+"/api/v1/vms", testPAT, body)
			assert.Equal(t, tc.wantStatus, resp.StatusCode)
		})
	}
}

func TestCreateVMSSHKeyValidation(t *testing.T) {
	ts, _, _ := testServer(t)
	out := enroll(t, ts)

	t.Run("ssh key with newline is rejected", func(t *testing.T) {
		resp := do(t, "POST", ts.URL+"/api/v1/vms", testPAT,
			map[string]any{
				"host_id":            out["host_id"],
				"name":               "safe-vm",
				"ssh_authorized_key": "ssh-ed25519 AAAA\ninjected: yaml",
			})
		assert.Equal(t, 400, resp.StatusCode)
	})

	t.Run("ssh key with carriage return is rejected", func(t *testing.T) {
		resp := do(t, "POST", ts.URL+"/api/v1/vms", testPAT,
			map[string]any{
				"host_id":            out["host_id"],
				"name":               "safe-vm-2",
				"ssh_authorized_key": "ssh-ed25519 AAAA\rinjected",
			})
		assert.Equal(t, 400, resp.StatusCode)
	})

	t.Run("valid single-line ssh key is accepted", func(t *testing.T) {
		resp := do(t, "POST", ts.URL+"/api/v1/vms", testPAT,
			map[string]any{
				"host_id":            out["host_id"],
				"name":               "valid-vm",
				"ssh_authorized_key": "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI test@host",
			})
		assert.Equal(t, 201, resp.StatusCode)
	})
}

// TestCreateVMImageSHAAdmission pins that image_url and image_sha256 must be
// provided together and that sha256 must be 64 lowercase hex characters.
func TestCreateVMImageSHAAdmission(t *testing.T) {
	ts, _, _ := testServer(t)
	out := enroll(t, ts)
	validSHA := strings.Repeat("b", 64)
	validURL := "https://example.com/custom.img"

	t.Run("custom url without sha is rejected", func(t *testing.T) {
		resp := do(t, "POST", ts.URL+"/api/v1/vms", testPAT,
			map[string]any{
				"host_id":   out["host_id"],
				"name":      "bad-url-no-sha",
				"image_url": validURL,
			})
		assert.Equal(t, 400, resp.StatusCode)
	})

	t.Run("sha without url is rejected", func(t *testing.T) {
		resp := do(t, "POST", ts.URL+"/api/v1/vms", testPAT,
			map[string]any{
				"host_id":      out["host_id"],
				"name":         "bad-sha-no-url",
				"image_sha256": validSHA,
			})
		assert.Equal(t, 400, resp.StatusCode)
	})

	t.Run("bad-format sha is rejected", func(t *testing.T) {
		resp := do(t, "POST", ts.URL+"/api/v1/vms", testPAT,
			map[string]any{
				"host_id":      out["host_id"],
				"name":         "bad-sha-format",
				"image_url":    validURL,
				"image_sha256": "notahexstring",
			})
		assert.Equal(t, 400, resp.StatusCode)
	})

	t.Run("both custom url and valid sha are accepted", func(t *testing.T) {
		resp := do(t, "POST", ts.URL+"/api/v1/vms", testPAT,
			map[string]any{
				"host_id":      out["host_id"],
				"name":         "good-custom-image",
				"image_url":    validURL,
				"image_sha256": validSHA,
			})
		assert.Equal(t, 201, resp.StatusCode)
	})
}

func TestMintEnrollTokenReturnsJoinBlob(t *testing.T) {
	ts, _, _ := testServer(t)
	resp := do(t, "POST", ts.URL+"/api/v1/enroll-tokens", testPAT, nil)
	if resp.StatusCode != http.StatusCreated {
		t.Fatalf("mint status = %d", resp.StatusCode)
	}
	var out struct {
		Token string `json:"token"`
		Join  string `json:"join"`
	}
	require.NoError(t, json.NewDecoder(resp.Body).Decode(&out))
	if out.Token == "" || out.Join == "" {
		t.Fatalf("expected token and join, got %+v", out)
	}
	f, err := joinblob.Decode(out.Join)
	if err != nil {
		t.Fatalf("join blob decode: %v", err)
	}
	if f.Token != out.Token {
		t.Errorf("join token %q != response token %q", f.Token, out.Token)
	}
	// The blob must carry back exactly the configured advertise addresses and
	// cert fingerprint (see testServer's api.Config) — not merely non-empty.
	assert.Equal(t, "http://127.0.0.1:8080", f.HTTPURL)
	assert.Equal(t, "127.0.0.1:8443", f.QUICAddr)
	assert.Equal(t, strings.Repeat("c", 64), f.CertFP)
}

// TestEnrollmentIsAudited pins the audit trail: minting a token, a successful
// enroll, and a denied enroll each leave a durable audit row (secrets appear
// only as hash prefixes, never verbatim).
func TestEnrollmentIsAudited(t *testing.T) {
	ts, st, _ := testServer(t)

	resp := do(t, "POST", ts.URL+"/api/v1/enroll-tokens", testPAT, nil)
	require.Equal(t, 201, resp.StatusCode)
	var tok map[string]string
	json.NewDecoder(resp.Body).Decode(&tok)

	resp = do(t, "POST", ts.URL+"/api/v1/enroll", "", map[string]string{
		"token": tok["token"], "name": "host-a", "os": "linux", "arch": "amd64", "provisioner": "cloudhv"})
	require.Equal(t, 201, resp.StatusCode)

	resp = do(t, "POST", ts.URL+"/api/v1/enroll", "", map[string]string{
		"token": "bogus", "name": "evil", "os": "linux", "arch": "amd64", "provisioner": "cloudhv"})
	require.Equal(t, 403, resp.StatusCode)

	rows, err := st.ListAudit(testTenant, 10)
	require.NoError(t, err)
	require.Len(t, rows, 2)
	assert.Equal(t, "host.enroll", rows[0].Action)
	assert.Contains(t, rows[0].Detail, "host-a")
	assert.Equal(t, "enroll-token.mint", rows[1].Action)
	for _, r := range rows {
		assert.NotContains(t, r.Detail, tok["token"], "raw token must never reach the audit log")
	}

	// The denied attempt had no resolvable tenant, so it is filed under the
	// system audit scope — durable, but invisible to any tenant's audit read.
	sys, err := st.ListAudit(store.SystemTenant, 10)
	require.NoError(t, err)
	require.Len(t, sys, 1)
	assert.Equal(t, "host.enroll.denied", sys[0].Action)
	assert.NotContains(t, sys[0].Detail, tok["token"], "raw token must never reach the audit log")
}

// TestEnrollRateLimited pins the per-IP limiter on the unauthenticated enroll
// endpoint: a burst beyond the limit returns 429 without touching the store.
func TestEnrollRateLimited(t *testing.T) {
	ts, _, _ := testServer(t)

	var got429 bool
	for range enrollBurst + 3 {
		resp := do(t, "POST", ts.URL+"/api/v1/enroll", "", map[string]string{
			"token": "bogus", "name": "x", "os": "linux", "arch": "amd64", "provisioner": "cloudhv"})
		if resp.StatusCode == http.StatusTooManyRequests {
			got429 = true
			break
		}
		require.Equal(t, 403, resp.StatusCode, "pre-limit attempts fail auth, not rate limit")
	}
	assert.True(t, got429, "burst beyond enrollBurst must yield 429")
}

// TestRevokeCredentialEndpoint pins per-host revocation: POST
// /api/v1/hosts/{id}/revoke-credential bumps the generation (204), leaves an
// audit row, requires admin auth, and 404s unknown hosts.
func TestRevokeCredentialEndpoint(t *testing.T) {
	ts, st, _ := testServer(t)
	out := enroll(t, ts)

	resp := do(t, "POST", ts.URL+"/api/v1/hosts/"+out["host_id"]+"/revoke-credential", testPAT, nil)
	require.Equal(t, 204, resp.StatusCode)

	h, err := st.GetHost(out["host_id"])
	require.NoError(t, err)
	assert.Equal(t, int64(2), h.CredGeneration)

	rows, err := st.ListAudit(testTenant, 3)
	require.NoError(t, err)
	require.NotEmpty(t, rows)
	assert.Equal(t, "host.credential.revoke", rows[0].Action)
	assert.Contains(t, rows[0].Detail, out["host_id"])

	resp = do(t, "POST", ts.URL+"/api/v1/hosts/deadbeef/revoke-credential", testPAT, nil)
	assert.Equal(t, 404, resp.StatusCode)

	resp = do(t, "POST", ts.URL+"/api/v1/hosts/"+out["host_id"]+"/revoke-credential", "wrong", nil)
	assert.Equal(t, 401, resp.StatusCode)
}

// TestEnrollMintsGenerationCredential pins that the enroll response carries a
// v2 credential bound to the host's current generation.
func TestEnrollMintsGenerationCredential(t *testing.T) {
	ts, _, _ := testServer(t)
	out := enroll(t, ts)
	parts := strings.Split(out["credential"], ".")
	require.Len(t, parts, 4, "v2 credential is host_id.gen.issued.hmac")
	assert.Equal(t, out["host_id"], parts[0])
	assert.Equal(t, "1", parts[1], "fresh enrollment mints generation 1")
}

// TestAuditDetailKeysArePinned nails down the KEY SET of every audit detail an
// action in api.go, volumes.go, events.go, tokens.go, and usercas.go emits.
// AuditEvent.Detail is a
// json.RawMessage, so the wire golden marshals it as opaque bytes and cannot
// see inside — a rename like host_id→hostId would sail through every other
// test. Here each action is driven for real and its emitted detail decoded, so
// a changed key name (or an added/dropped one) fails against the pinned set.
func TestAuditDetailKeysArePinned(t *testing.T) {
	ts, st, _ := testServer(t)

	// enroll() already emits enroll-token.mint (api.go).
	out := enroll(t, ts)
	hostID := out["host_id"]

	// api.go: the VM lifecycle audits.
	resp := do(t, "POST", ts.URL+"/api/v1/vms", testPAT,
		map[string]any{"host_id": hostID, "name": "audited-vm"})
	require.Equal(t, 201, resp.StatusCode)
	var created map[string]string
	require.NoError(t, json.NewDecoder(resp.Body).Decode(&created))
	vmID := created["id"]

	require.Equal(t, 204, do(t, "PATCH", ts.URL+"/api/v1/vms/"+vmID, testPAT,
		map[string]any{"power_state": "stopped"}).StatusCode)
	require.Equal(t, 204, do(t, "DELETE", ts.URL+"/api/v1/vms/"+vmID, testPAT, nil).StatusCode)
	require.Equal(t, 204, do(t, "POST", ts.URL+"/api/v1/vms/"+vmID+"/restore", testPAT, nil).StatusCode)

	// volumes.go: claim storage and give it back. The claim is never attached,
	// so the delete is the ordinary path rather than the 409.
	resp = do(t, "POST", ts.URL+"/api/v1/volume-claims", testPAT,
		map[string]any{"name": "audited-claim", "size_gb": 5})
	require.Equal(t, 201, resp.StatusCode)
	var claim map[string]any
	require.NoError(t, json.NewDecoder(resp.Body).Decode(&claim))
	require.Equal(t, 204, do(t, "DELETE", ts.URL+"/api/v1/volume-claims/"+claim["id"].(string), testPAT, nil).StatusCode)

	// tokens.go: mint then revoke a PAT.
	resp = do(t, "POST", ts.URL+"/api/v1/tokens", testPAT,
		map[string]any{"name": "audited-token", "ttl_seconds": 3600})
	require.Equal(t, 201, resp.StatusCode)
	var mintedTok map[string]string
	require.NoError(t, json.NewDecoder(resp.Body).Decode(&mintedTok))
	require.Equal(t, 204, do(t, "DELETE", ts.URL+"/api/v1/tokens/"+mintedTok["id"], testPAT, nil).StatusCode)

	// usercas.go: register a CA for the caller's own tenant (a valid ed25519
	// line distinct from the seed).
	require.Equal(t, 201, do(t, "POST", ts.URL+"/api/v1/user-cas", testPAT, map[string]any{
		"public_key": "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIPZK1zVJTG0Opn0BktxOpCYhRXRPMFhZDwoT1PVCM1Sq audit-ca",
		"label":      "audit-ca",
	}).StatusCode)

	// events.go: the forced decommission, LAST because it destroys the host
	// everything above ran on. Only the force shape is pinned — it is the one
	// that reports what was lost, and the graceful path's row shares the action
	// name, so newest-row-per-action can only hold one of the two.
	require.Equal(t, 200, do(t, "DELETE", ts.URL+"/api/v1/hosts/"+hostID+"?force=true", testPAT, nil).StatusCode)

	rows, err := st.ListAudit(testTenant, 100)
	require.NoError(t, err)
	// Newest row per action wins; the detail shape is one per action.
	got := map[string]map[string]bool{}
	for _, row := range rows {
		if _, seen := got[row.Action]; seen {
			continue
		}
		var detail map[string]any
		require.NoError(t, json.Unmarshal([]byte(row.Detail), &detail),
			"audit detail for %s is not a JSON object", row.Action)
		keys := map[string]bool{}
		for k := range detail {
			keys[k] = true
		}
		got[row.Action] = keys
	}

	want := map[string][]string{
		// volume_claims is on every vm.create, empty when the VM asked for
		// none: "this guest was given no volumes" is a fact worth recording,
		// and a key that comes and goes is a shape no reader can rely on.
		"vm.create":           {"vm_id", "name", "host_id", "volume_claims"},
		"vm.power":            {"vm_id", "name", "power"},
		"vm.delete":           {"vm_id", "name"},
		"vm.restore":          {"vm_id", "name"},
		"volume_claim.create": {"claim_id", "name", "size_gb"},
		"volume_claim.delete": {"claim_id", "name", "volume_id"},
		// Force is the one path that destroys data on purpose: its row is the
		// only surviving record of which volumes went with the hardware, so
		// every count and the id list are part of the pinned shape.
		"host.decommission": {"host_id", "remote", "force", "vms_purged",
			"volumes_destroyed", "claims_unbound", "volume_ids"},
		"enroll-token.mint": {"remote", "token_hash_prefix"},
		"api-token.mint":    {"token_id", "name"},
		"api-token.revoke":  {"token_id"},
		"user-ca.upload":    {"tenant", "fingerprint"},
	}
	for action, keys := range want {
		gotKeys, ok := got[action]
		require.True(t, ok, "no audit row emitted for %s", action)
		wantKeys := map[string]bool{}
		for _, k := range keys {
			wantKeys[k] = true
		}
		assert.Equal(t, wantKeys, gotKeys,
			"audit detail keys for %s drifted — a renamed/added/dropped key the wire golden cannot see", action)
	}
}

// TestAuditEndpoint pins the forensic read API: GET /api/v1/audit returns
// newest-first rows (detail as embedded JSON), honors ?limit, requires admin.
func TestAuditEndpoint(t *testing.T) {
	ts, _, _ := testServer(t)
	enroll(t, ts) // produces mint + enroll audit rows

	resp := do(t, "GET", ts.URL+"/api/v1/audit", testPAT, nil)
	require.Equal(t, 200, resp.StatusCode)
	var rows []struct {
		At     time.Time       `json:"at"`
		Action string          `json:"action"`
		Detail json.RawMessage `json:"detail"`
	}
	require.NoError(t, json.NewDecoder(resp.Body).Decode(&rows))
	require.Len(t, rows, 2)
	assert.Equal(t, "host.enroll", rows[0].Action, "newest first")
	assert.Equal(t, "enroll-token.mint", rows[1].Action)
	assert.False(t, rows[0].At.IsZero())
	assert.Contains(t, string(rows[0].Detail), "host-a")

	resp = do(t, "GET", ts.URL+"/api/v1/audit?limit=1", testPAT, nil)
	require.Equal(t, 200, resp.StatusCode)
	rows = nil
	require.NoError(t, json.NewDecoder(resp.Body).Decode(&rows))
	assert.Len(t, rows, 1)

	resp = do(t, "GET", ts.URL+"/api/v1/audit", "", nil)
	assert.Equal(t, 401, resp.StatusCode)
}

// TestAuditEndpointLimitValidation pins the explicit reject-not-clamp
// contract for ?limit.
func TestAuditEndpointLimitValidation(t *testing.T) {
	ts, _, _ := testServer(t)
	for _, bad := range []string{"0", "1001", "-3", "abc"} {
		resp := do(t, "GET", ts.URL+"/api/v1/audit?limit="+bad, testPAT, nil)
		assert.Equal(t, 400, resp.StatusCode, "limit=%s must be rejected", bad)
	}
}

// TestCreateVMMergesSSHKeyIntoCloudInit pins that supplying BOTH a cloud_init
// and an ssh_authorized_key folds the key into the stored user-data (rather
// than the old silent drop), and clears the now-redundant separate field.
func TestCreateVMMergesSSHKeyIntoCloudInit(t *testing.T) {
	ts, st, _ := testServer(t)
	out := enroll(t, ts)

	const key = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIExampleKey user@host"
	resp := do(t, "POST", ts.URL+"/api/v1/vms", testPAT, map[string]any{
		"host_id":            out["host_id"],
		"name":               "web",
		"cloud_init":         "#cloud-config\npackages:\n  - htop\n",
		"ssh_authorized_key": key,
	})
	require.Equal(t, 201, resp.StatusCode)

	vms, err := st.ListVMs()
	require.NoError(t, err)
	require.Len(t, vms, 1)
	assert.Contains(t, vms[0].CloudInit, key, "the key must be folded into stored user-data")
	assert.Contains(t, vms[0].CloudInit, "packages", "the user's cloud-init content survives the merge")
	assert.True(t, strings.HasPrefix(vms[0].CloudInit, "#cloud-config"), "header preserved")
	assert.Empty(t, vms[0].SSHAuthorizedKey, "the key is folded in, not also carried separately")
}

// TestCreateVMWrapsShellScriptUserDataWithKey pins that an ssh_authorized_key
// alongside a NON-cloud-config user-data (shell script) is not dropped and not
// rejected — it's wrapped into a multipart archive so both apply.
func TestCreateVMWrapsShellScriptUserDataWithKey(t *testing.T) {
	ts, st, _ := testServer(t)
	out := enroll(t, ts)

	const key = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIExampleKey user@host"
	resp := do(t, "POST", ts.URL+"/api/v1/vms", testPAT, map[string]any{
		"host_id":            out["host_id"],
		"name":               "web",
		"cloud_init":         "#!/bin/bash\necho hi\n",
		"ssh_authorized_key": key,
	})
	require.Equal(t, 201, resp.StatusCode)
	vms, err := st.ListVMs()
	require.NoError(t, err)
	require.Len(t, vms, 1)
	assert.Contains(t, vms[0].CloudInit, "multipart/mixed", "script user-data is wrapped in a MIME archive")
	assert.Contains(t, vms[0].CloudInit, "echo hi", "the user's script survives")
	assert.Contains(t, vms[0].CloudInit, key, "the key rides a cloud-config part")
	assert.Empty(t, vms[0].SSHAuthorizedKey, "installed into cloud-init, not carried separately")
}

// TestCreateVMRejectsUnhandleableCloudInit pins that an ssh_authorized_key
// alongside user-data we cannot safely edit or wrap (a jinja template) is a 400
// (not a silent no-op), before any mint/persist.
func TestCreateVMRejectsUnhandleableCloudInit(t *testing.T) {
	ts, st, _ := testServer(t)
	out := enroll(t, ts)

	resp := do(t, "POST", ts.URL+"/api/v1/vms", testPAT, map[string]any{
		"host_id":            out["host_id"],
		"name":               "web",
		"cloud_init":         "## template: jinja\n#cloud-config\nhostname: {{ v1.local_hostname }}\n",
		"ssh_authorized_key": "ssh-ed25519 AAAAKey user@host",
	})
	assert.Equal(t, 400, resp.StatusCode)
	vms, err := st.ListVMs()
	require.NoError(t, err)
	assert.Empty(t, vms, "reject before persist")
}

// TestDeriveLifecycle pins the server-side rollup of the orthogonal state axes
// into one coarse word. It must stay in lockstep with vmStatus() in
// web/src/lib/fleet.svelte.ts — the two folds cannot disagree.
func TestDeriveLifecycle(t *testing.T) {
	deleted := time.Unix(0, 0)
	cases := []struct {
		name        string
		vm          store.VM
		actualPower string
		phase       string
		hostOnline  bool
		want        string
	}{
		{"tombstone wins over everything",
			store.VM{Status: "ready", PowerState: "running", DeletedAt: &deleted}, "running", "ready", true, "deleting"},
		{"failed phase",
			store.VM{Status: "ready", PowerState: "running"}, "stopped", "failed", true, "failed"},
		{"still creating (live phase)",
			store.VM{Status: "ready", PowerState: "running"}, "", "creating", true, "creating"},
		{"empty phase falls back to status=creating",
			store.VM{Status: "creating", PowerState: "running"}, "", "", true, "creating"},
		{"ready phase but not running -> stopped",
			store.VM{Status: "ready", PowerState: "stopped"}, "stopped", "ready", true, "stopped"},
		{"desired running but agent reports stopped -> stopped",
			store.VM{Status: "ready", PowerState: "running"}, "stopped", "ready", true, "stopped"},
		{"running + ready -> ready",
			store.VM{Status: "ready", PowerState: "running"}, "running", "ready", true, "ready"},
		{"no live phase, desired running falls back to power_state",
			store.VM{Status: "ready", PowerState: "running"}, "", "ready", true, "ready"},

		// A dark host observes nothing. Every word below the tombstone is an
		// observation the agent would have made, and the last one it made is
		// what the durable row still holds — so serving it would be reporting
		// a guess as a fact. The VM row is untouched; only the read says so.
		{"a running VM on a dark host is not known to be running",
			store.VM{Status: "ready", PowerState: "running"}, "", "", false, "unreachable"},
		{"a stopped VM on a dark host is not known to be stopped",
			store.VM{Status: "ready", PowerState: "stopped"}, "", "", false, "unreachable"},
		{"a create on a dark host is not known to be progressing",
			store.VM{Status: "creating", PowerState: "running"}, "", "", false, "unreachable"},
		{"a failed VM on a dark host is not re-confirmed failed",
			store.VM{Status: "failed", PowerState: "stopped"}, "", "", false, "unreachable"},
		// The tombstone is the plane's OWN intent, not something the host told
		// it, so it outranks unreachability: the console still owes the
		// operator the restore affordance while the host is away.
		{"a tombstone still reads as deleting on a dark host",
			store.VM{Status: "ready", PowerState: "running", DeletedAt: &deleted}, "", "", false, "deleting"},
	}
	for _, tc := range cases {
		t.Run(tc.name, func(t *testing.T) {
			assert.Equal(t, tc.want, deriveLifecycle(tc.vm, tc.actualPower, tc.phase, tc.hostOnline))
		})
	}
}

// TestRestoreVMUnTombstonesWithinGrace pins the undo/cancel path: a deleted
// (tombstoned) VM that is still within the grace window can be restored, which
// clears deleted_at so the agent re-adopts it. A vm.restore event lands on the
// timeline.
func TestRestoreVMUnTombstonesWithinGrace(t *testing.T) {
	ts, st, _ := testServer(t)
	out := enroll(t, ts)
	resp := do(t, "POST", ts.URL+"/api/v1/vms", testPAT,
		map[string]any{"host_id": out["host_id"], "name": "web"})
	require.Equal(t, 201, resp.StatusCode)

	vms, err := st.ListVMs()
	require.NoError(t, err)
	require.Len(t, vms, 1)
	id := vms[0].ID

	// Delete → tombstoned: deleted=true.
	require.Equal(t, 204, do(t, "DELETE", ts.URL+"/api/v1/vms/"+id, testPAT, nil).StatusCode)
	items := decodeJSONKeys(t, do(t, "GET", ts.URL+"/api/v1/vms", testPAT, nil))
	require.Len(t, items, 1)
	assert.Equal(t, true, items[0]["deleted"])

	// Restore → un-tombstoned: deleted=false.
	require.Equal(t, 204, do(t, "POST", ts.URL+"/api/v1/vms/"+id+"/restore", testPAT, nil).StatusCode)
	items = decodeJSONKeys(t, do(t, "GET", ts.URL+"/api/v1/vms", testPAT, nil))
	require.Len(t, items, 1)
	assert.Equal(t, false, items[0]["deleted"])

	// The restore lands on the per-VM timeline.
	resp = do(t, "GET", ts.URL+"/api/v1/vms/"+id+"/events", testPAT, nil)
	require.Equal(t, 200, resp.StatusCode)
	var events []struct {
		Action string `json:"action"`
	}
	require.NoError(t, json.NewDecoder(resp.Body).Decode(&events))
	var actions []string
	for _, e := range events {
		actions = append(actions, e.Action)
	}
	assert.Contains(t, actions, "vm.restore")
}

// TestRestoreVMNotRestorableIs409 pins that restoring a VM that was never
// deleted, or an id that does not exist, is rejected with 409 (no raw SQL
// leaks) — RestoreVM only matches tombstoned rows.
func TestRestoreVMNotRestorableIs409(t *testing.T) {
	ts, _, _ := testServer(t)
	out := enroll(t, ts)
	resp := do(t, "POST", ts.URL+"/api/v1/vms", testPAT,
		map[string]any{"host_id": out["host_id"], "name": "web"})
	require.Equal(t, 201, resp.StatusCode)
	var created map[string]string
	json.NewDecoder(resp.Body).Decode(&created)

	// Never deleted → not restorable.
	resp = do(t, "POST", ts.URL+"/api/v1/vms/"+created["id"]+"/restore", testPAT, nil)
	assert.Equal(t, 409, resp.StatusCode)

	// Non-existent id → same "not restorable".
	resp = do(t, "POST", ts.URL+"/api/v1/vms/does-not-exist/restore", testPAT, nil)
	assert.Equal(t, 409, resp.StatusCode)
}

// TestVMResponseSurfacesTeardownDestroyDeadline pins that a quarantined (deleted
// + guest-stopped) VM surfaces the agent's hard destroy deadline as destroy_at,
// while a VM that is not scheduled for destruction reports destroy_at == 0.
func TestVMResponseSurfacesTeardownDestroyDeadline(t *testing.T) {
	ts, st, _, reg, _ := newServer(t)
	out := enroll(t, ts)
	hostID := out["host_id"]

	// Two VMs on the host: one will be quarantined for teardown, one stays live.
	for _, name := range []string{"doomed", "healthy"} {
		resp := do(t, "POST", ts.URL+"/api/v1/vms", testPAT,
			map[string]any{"host_id": hostID, "name": name})
		require.Equal(t, 201, resp.StatusCode)
	}
	vms, err := st.ListVMs()
	require.NoError(t, err)
	require.Len(t, vms, 2)
	var doomedID, healthyID string
	for _, vm := range vms {
		switch vm.Name {
		case "doomed":
			doomedID = vm.ID
		case "healthy":
			healthyID = vm.ID
		}
	}
	require.NotEmpty(t, doomedID)
	require.NotEmpty(t, healthyID)

	// Agent reports the doomed VM quarantined with a hard destroy deadline.
	const deadline = int64(1234567890)
	reg.UpdateReport(hostID, registry.Report{
		Quarantined: []registry.QuarantinedVM{{VMID: doomedID, DestroyAtUnix: deadline}},
	})

	resp := do(t, "GET", ts.URL+"/api/v1/vms", testPAT, nil)
	require.Equal(t, 200, resp.StatusCode)
	items := decodeJSONKeys(t, resp)
	require.Len(t, items, 2)

	byID := map[string]map[string]any{}
	for _, v := range items {
		byID[v["id"].(string)] = v
	}
	// JSON numbers decode as float64; deadline is exactly representable.
	assert.Equal(t, float64(deadline), byID[doomedID]["destroy_at"],
		"quarantined VM must surface its destroy deadline")
	assert.Equal(t, float64(0), byID[healthyID]["destroy_at"],
		"a VM not scheduled for destruction reports destroy_at == 0")
}

// TestVMReportsTheKeyEitriInjected pins that the console can answer "which key
// did you put in this VM". The subtle case is the second one: when a create
// supplies BOTH cloud_init and a key, the key is merged into the cloud-init
// document and ssh_authorized_key is cleared — so the column alone forgets a
// key eitri definitely installed.
//
// It also pins the boundary of the claim. eitri reports what IT injected; a key
// a user buries in their own cloud_init is theirs, and is deliberately invisible
// here rather than half-tracked.
func TestVMReportsTheKeyEitriInjected(t *testing.T) {
	const key = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIM0uJyEWzAFdAelGXHwoFgSRL+py8ZMonqWw+M4wj6HG alex@laptop"
	ts, _, _ := testServer(t)
	out := enroll(t, ts)

	// vmInjectedKey creates a VM and returns its injected_key from the API.
	vmInjectedKey := func(t *testing.T, name string, body map[string]any) map[string]any {
		t.Helper()
		body["host_id"], body["name"] = out["host_id"], name
		resp := do(t, "POST", ts.URL+"/api/v1/vms", testPAT, body)
		require.Equal(t, 201, resp.StatusCode)
		var created map[string]string
		json.NewDecoder(resp.Body).Decode(&created)

		resp = do(t, "GET", ts.URL+"/api/v1/vms", testPAT, nil)
		require.Equal(t, 200, resp.StatusCode)
		for _, v := range decodeJSONKeys(t, resp) {
			if v["id"] == created["id"] {
				require.Contains(t, v, "injected_key", "the VM response must carry injected_key")
				if v["injected_key"] == nil {
					return nil
				}
				return v["injected_key"].(map[string]any)
			}
		}
		t.Fatal("created VM missing from the list")
		return nil
	}

	t.Run("key alone", func(t *testing.T) {
		got := vmInjectedKey(t, "keyed", map[string]any{"ssh_authorized_key": key})
		require.NotNil(t, got)
		assert.Equal(t, "ssh-ed25519", got["type"])
		assert.Equal(t, "alex@laptop", got["comment"])
		assert.Contains(t, got["fingerprint"], "SHA256:")
	})

	// The merge path: ssh_authorized_key is cleared once folded into cloud-init,
	// so this is exactly where a naive read of the column reports nothing.
	t.Run("key merged into cloud-init is still reported", func(t *testing.T) {
		got := vmInjectedKey(t, "keyed-with-ci", map[string]any{
			"ssh_authorized_key": key,
			"cloud_init":         "#cloud-config\npackages:\n  - htop\n",
		})
		require.NotNil(t, got, "a key merged into cloud-init was still injected by eitri")
		assert.Equal(t, "alex@laptop", got["comment"])
	})

	t.Run("no key injected reports nothing", func(t *testing.T) {
		assert.Nil(t, vmInjectedKey(t, "bare", map[string]any{}))
	})

	// A key eitri never installed is not eitri's to report.
	t.Run("a key hidden in the user's own cloud-init is not claimed", func(t *testing.T) {
		assert.Nil(t, vmInjectedKey(t, "byo", map[string]any{
			"cloud_init": "#cloud-config\nusers:\n  - name: me\n    ssh_authorized_keys:\n      - " + key + "\n",
		}))
	})

	// The description is derived and readable; the key itself stays write-only.
	t.Run("the raw key is still never echoed", func(t *testing.T) {
		resp := do(t, "GET", ts.URL+"/api/v1/vms", testPAT, nil)
		for _, v := range decodeJSONKeys(t, resp) {
			assert.NotContains(t, v, "ssh_authorized_key")
		}
	})
}