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")
}
})
}