a73x

internal/server/api/client/client_test.go

Ref:   Size: 23.9 KiB   History

package client_test

import (
	"context"
	"encoding/json"
	"errors"
	"io"
	"net/http"
	"net/http/httptest"
	"strings"
	"testing"
	"time"

	"golang.org/x/crypto/ssh"

	"github.com/a73x/eitri/internal/gateclient"
	"github.com/a73x/eitri/internal/server/api/client"
	"github.com/a73x/eitri/internal/server/api/types"
)

// The client must satisfy gateclient's CertAuthority so GateAuth can use it
// directly (FetchSSHCA + UploadUserCA).
var _ gateclient.CertAuthority = (*client.Client)(nil)

// testCALine is a valid ed25519 authorized_keys line WITH a trailing comment;
// FetchSSHCALine must return it verbatim (the cli's pin file keeps the comment).
const testCALine = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIPZK1zVJTG0Opn0BktxOpCYhRXRPMFhZDwoT1PVCM1Sq eitri-host-ca"

// capture records what the handler saw so tests can assert on the request.
type capture struct {
	method string
	path   string // escaped path, so %2F survives inspection
	auth   string
	ctype  string
	body   []byte
}

// serve starts an httptest server that records each request into *capture and
// responds with status and body.
func serve(t *testing.T, cap *capture, status int, body string) *httptest.Server {
	t.Helper()
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		cap.method = r.Method
		cap.path = r.URL.EscapedPath()
		cap.auth = r.Header.Get("Authorization")
		cap.ctype = r.Header.Get("Content-Type")
		cap.body, _ = io.ReadAll(r.Body)
		w.WriteHeader(status)
		io.WriteString(w, body)
	}))
	t.Cleanup(srv.Close)
	return srv
}

func TestListHosts(t *testing.T) {
	var cap capture
	srv := serve(t, &cap, http.StatusOK, `[{"id":"h1","name":"mewtwo","online":true}]`)
	c := &client.Client{BaseURL: srv.URL, Token: "tok"}

	hosts, err := c.ListHosts(context.Background())
	if err != nil {
		t.Fatalf("ListHosts: %v", err)
	}
	if cap.method != http.MethodGet || cap.path != "/api/v1/hosts" {
		t.Errorf("request = %s %s, want GET /api/v1/hosts", cap.method, cap.path)
	}
	if cap.auth != "Bearer tok" {
		t.Errorf("Authorization = %q, want %q", cap.auth, "Bearer tok")
	}
	if len(hosts) != 1 || hosts[0].ID != "h1" || hosts[0].Name != "mewtwo" || !hosts[0].Online {
		t.Errorf("hosts = %+v, want one host h1/mewtwo/online", hosts)
	}
}

func TestListVMs(t *testing.T) {
	var cap capture
	srv := serve(t, &cap, http.StatusOK, `[{"id":"v1","name":"dev","lifecycle":"ready","assigned_ip":"10.77.1.2"}]`)
	c := &client.Client{BaseURL: srv.URL, Token: "tok"}

	vms, err := c.ListVMs(context.Background())
	if err != nil {
		t.Fatalf("ListVMs: %v", err)
	}
	if cap.method != http.MethodGet || cap.path != "/api/v1/vms" {
		t.Errorf("request = %s %s, want GET /api/v1/vms", cap.method, cap.path)
	}
	if cap.auth != "Bearer tok" {
		t.Errorf("Authorization = %q, want %q", cap.auth, "Bearer tok")
	}
	if len(vms) != 1 || vms[0].ID != "v1" || vms[0].Lifecycle != "ready" || vms[0].AssignedIP != "10.77.1.2" {
		t.Errorf("vms = %+v, want one ready VM v1 at 10.77.1.2", vms)
	}
}

func TestCreateVM(t *testing.T) {
	var cap capture
	srv := serve(t, &cap, http.StatusCreated, `{"id":"v9","name":"smoke"}`)
	c := &client.Client{BaseURL: srv.URL, Token: "tok"}

	out, err := c.CreateVM(context.Background(), client.CreateVMRequest{HostID: "h1", Name: "smoke", VCPUs: 2})
	if err != nil {
		t.Fatalf("CreateVM: %v", err)
	}
	if cap.method != http.MethodPost || cap.path != "/api/v1/vms" {
		t.Errorf("request = %s %s, want POST /api/v1/vms", cap.method, cap.path)
	}
	if cap.ctype != "application/json" {
		t.Errorf("Content-Type = %q, want application/json", cap.ctype)
	}
	var req types.CreateVMRequest
	if err := json.Unmarshal(cap.body, &req); err != nil {
		t.Fatalf("request body did not decode as CreateVMRequest: %v", err)
	}
	if req.HostID != "h1" || req.Name != "smoke" || req.VCPUs != 2 {
		t.Errorf("request body = %+v, want host h1 / name smoke / 2 vcpus", req)
	}
	if out.ID != "v9" || out.Name != "smoke" {
		t.Errorf("response = %+v, want id v9 name smoke", out)
	}
}

func TestDeleteVM(t *testing.T) {
	var cap capture
	srv := serve(t, &cap, http.StatusNoContent, "")
	c := &client.Client{BaseURL: srv.URL, Token: "tok"}

	if err := c.DeleteVM(context.Background(), "vm-123"); err != nil {
		t.Fatalf("DeleteVM: %v", err)
	}
	if cap.method != http.MethodDelete || cap.path != "/api/v1/vms/vm-123" {
		t.Errorf("request = %s %s, want DELETE /api/v1/vms/vm-123", cap.method, cap.path)
	}
}

func TestDeleteVMEscapesID(t *testing.T) {
	var cap capture
	srv := serve(t, &cap, http.StatusNoContent, "")
	c := &client.Client{BaseURL: srv.URL}

	if err := c.DeleteVM(context.Background(), "a b/c"); err != nil {
		t.Fatalf("DeleteVM: %v", err)
	}
	if want := "/api/v1/vms/a%20b%2Fc"; cap.path != want {
		t.Errorf("path = %q, want %q", cap.path, want)
	}
}

func TestPatchVM(t *testing.T) {
	var cap capture
	srv := serve(t, &cap, http.StatusOK, "{}")
	c := &client.Client{BaseURL: srv.URL, Token: "tok"}

	if err := c.PatchVM(context.Background(), "vm-123", "stopped"); err != nil {
		t.Fatalf("PatchVM: %v", err)
	}
	if cap.method != http.MethodPatch || cap.path != "/api/v1/vms/vm-123" {
		t.Errorf("request = %s %s, want PATCH /api/v1/vms/vm-123", cap.method, cap.path)
	}
	if !strings.Contains(string(cap.body), `"power_state":"stopped"`) {
		t.Errorf("body = %s, want power_state stopped", cap.body)
	}
}

func TestNoAuthHeaderWhenTokenEmpty(t *testing.T) {
	var cap capture
	srv := serve(t, &cap, http.StatusOK, `[]`)
	c := &client.Client{BaseURL: srv.URL}

	if _, err := c.ListHosts(context.Background()); err != nil {
		t.Fatalf("ListHosts: %v", err)
	}
	if cap.auth != "" {
		t.Errorf("Authorization = %q, want unset when Token is empty", cap.auth)
	}
}

func TestBaseURLTrailingSlash(t *testing.T) {
	var cap capture
	srv := serve(t, &cap, http.StatusOK, `[]`)
	c := &client.Client{BaseURL: srv.URL + "/"}

	if _, err := c.ListVMs(context.Background()); err != nil {
		t.Fatalf("ListVMs: %v", err)
	}
	if cap.path != "/api/v1/vms" {
		t.Errorf("path = %q, want /api/v1/vms (trailing BaseURL slash trimmed)", cap.path)
	}
}

func TestNon2xxReturnsTypedError(t *testing.T) {
	var cap capture
	srv := serve(t, &cap, http.StatusConflict, "insufficient host capacity")
	c := &client.Client{BaseURL: srv.URL, Token: "tok"}

	_, err := c.CreateVM(context.Background(), client.CreateVMRequest{HostID: "h1"})
	if err == nil {
		t.Fatal("CreateVM on 409: got nil error")
	}
	var apiErr *client.Error
	if !errors.As(err, &apiErr) {
		t.Fatalf("errors.As found no *client.Error in %v", err)
	}
	if apiErr.Status != http.StatusConflict {
		t.Errorf("Status = %d, want 409", apiErr.Status)
	}
	if apiErr.Method != http.MethodPost || apiErr.Path != "/api/v1/vms" {
		t.Errorf("Method/Path = %s %s, want POST /api/v1/vms", apiErr.Method, apiErr.Path)
	}
	if !strings.Contains(err.Error(), "409") || !strings.Contains(err.Error(), "insufficient host capacity") {
		t.Errorf("error %q should contain the status code and the response body", err)
	}
}

func TestContextCancellationAborts(t *testing.T) {
	release := make(chan struct{})
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		<-release
	}))
	t.Cleanup(func() { close(release); srv.Close() })
	c := &client.Client{BaseURL: srv.URL}

	ctx, cancel := context.WithCancel(context.Background())
	cancel()
	if _, err := c.ListHosts(ctx); !errors.Is(err, context.Canceled) {
		t.Fatalf("ListHosts with canceled ctx: err = %v, want context.Canceled", err)
	}
}

func TestMe(t *testing.T) {
	var cap capture
	srv := serve(t, &cap, http.StatusOK, `{"email":"alex@emery.xyz","tenant":"alex"}`)
	c := &client.Client{BaseURL: srv.URL, Token: "tok"}

	me, err := c.Me()
	if err != nil {
		t.Fatalf("Me: %v", err)
	}
	if cap.method != http.MethodGet || cap.path != "/api/v1/me" {
		t.Errorf("request = %s %s, want GET /api/v1/me", cap.method, cap.path)
	}
	if cap.auth != "Bearer tok" {
		t.Errorf("Authorization = %q, want %q", cap.auth, "Bearer tok")
	}
	if me.Email != "alex@emery.xyz" || me.Tenant != "alex" {
		t.Errorf("me = %+v, want alex@emery.xyz / alex", me)
	}
}

func TestCreateAPIToken(t *testing.T) {
	var cap capture
	srv := serve(t, &cap, http.StatusCreated,
		`{"expires_at":"2026-07-27T13:00:00Z","id":"tok-1","name":"boot-gate","token":"eitri_pat_secret"}`)
	c := &client.Client{BaseURL: srv.URL, Token: "tok"}

	out, err := c.CreateAPIToken("boot-gate", time.Hour)
	if err != nil {
		t.Fatalf("CreateAPIToken: %v", err)
	}
	if cap.method != http.MethodPost || cap.path != "/api/v1/tokens" {
		t.Errorf("request = %s %s, want POST /api/v1/tokens", cap.method, cap.path)
	}
	if cap.ctype != "application/json" {
		t.Errorf("Content-Type = %q, want application/json", cap.ctype)
	}
	var req types.CreateAPITokenRequest
	if err := json.Unmarshal(cap.body, &req); err != nil {
		t.Fatalf("request body did not decode as CreateAPITokenRequest: %v", err)
	}
	if req.Name != "boot-gate" || req.TTLSeconds != 3600 {
		t.Errorf("request body = %+v, want name boot-gate / 3600s", req)
	}
	if out.Token != "eitri_pat_secret" || out.ID != "tok-1" || out.ExpiresAt != "2026-07-27T13:00:00Z" {
		t.Errorf("response = %+v, want the minted secret/id/expiry", out)
	}
}

func TestCreateAPITokenNonExpiring(t *testing.T) {
	var cap capture
	srv := serve(t, &cap, http.StatusCreated, `{"id":"tok-2","name":"perm","token":"eitri_pat_x"}`)
	c := &client.Client{BaseURL: srv.URL, Token: "tok"}

	if _, err := c.CreateAPIToken("perm", 0); err != nil {
		t.Fatalf("CreateAPIToken: %v", err)
	}
	var req types.CreateAPITokenRequest
	if err := json.Unmarshal(cap.body, &req); err != nil {
		t.Fatalf("decode request: %v", err)
	}
	if req.TTLSeconds != 0 {
		t.Errorf("ttl_seconds = %d, want 0 (non-expiring)", req.TTLSeconds)
	}
}

func TestFetchSSHCALineVerbatim(t *testing.T) {
	var cap capture
	body, _ := json.Marshal(map[string]string{"ca": testCALine})
	srv := serve(t, &cap, http.StatusOK, string(body))
	c := &client.Client{BaseURL: srv.URL, Token: "tok"}

	line, err := c.FetchSSHCALine(context.Background())
	if err != nil {
		t.Fatalf("FetchSSHCALine: %v", err)
	}
	if cap.method != http.MethodGet || cap.path != "/api/v1/ssh-ca" {
		t.Errorf("request = %s %s, want GET /api/v1/ssh-ca", cap.method, cap.path)
	}
	if cap.auth != "Bearer tok" {
		t.Errorf("Authorization = %q, want %q", cap.auth, "Bearer tok")
	}
	if line != testCALine {
		t.Errorf("line = %q, want the verbatim server line %q (comment preserved)", line, testCALine)
	}
}

func TestFetchSSHCAParsesKey(t *testing.T) {
	var cap capture
	body, _ := json.Marshal(map[string]string{"ca": testCALine})
	srv := serve(t, &cap, http.StatusOK, string(body))
	c := &client.Client{BaseURL: srv.URL}

	pub, err := c.FetchSSHCA(context.Background())
	if err != nil {
		t.Fatalf("FetchSSHCA: %v", err)
	}
	want, _, _, _, err := ssh.ParseAuthorizedKey([]byte(testCALine))
	if err != nil {
		t.Fatalf("parsing test fixture: %v", err)
	}
	if string(pub.Marshal()) != string(want.Marshal()) {
		t.Error("FetchSSHCA returned a different key than the server sent")
	}
}

func TestFetchSSHCA404IsGateOff(t *testing.T) {
	for _, tc := range []struct {
		name string
		call func(c *client.Client) error
	}{
		{"FetchSSHCALine", func(c *client.Client) error { _, err := c.FetchSSHCALine(context.Background()); return err }},
		{"FetchSSHCA", func(c *client.Client) error { _, err := c.FetchSSHCA(context.Background()); return err }},
	} {
		t.Run(tc.name, func(t *testing.T) {
			var cap capture
			srv := serve(t, &cap, http.StatusNotFound, "not found")
			err := tc.call(&client.Client{BaseURL: srv.URL})
			if err == nil {
				t.Fatal("404: got nil error")
			}
			// The full wording is pinned: mcpserver's tests and human eyes
			// both read this message, so a rewording must be deliberate.
			if got, want := err.Error(), "ssh-ca gate is not enabled on this eitri server"; got != want {
				t.Errorf("gate-off message = %q, want %q", got, want)
			}
			var apiErr *client.Error
			if !errors.As(err, &apiErr) || apiErr.Status != http.StatusNotFound {
				t.Errorf("errors.As should still find the underlying *client.Error with Status 404, got %v", err)
			}
		})
	}
}

func TestFetchSSHCAGarbage(t *testing.T) {
	for _, tc := range []struct {
		name string
		call func(c *client.Client) error
	}{
		{"FetchSSHCALine", func(c *client.Client) error { _, err := c.FetchSSHCALine(context.Background()); return err }},
		{"FetchSSHCA", func(c *client.Client) error { _, err := c.FetchSSHCA(context.Background()); return err }},
	} {
		t.Run(tc.name, func(t *testing.T) {
			var cap capture
			srv := serve(t, &cap, http.StatusOK, `{"ca":"not an ssh key"}`)
			if err := tc.call(&client.Client{BaseURL: srv.URL}); err == nil {
				t.Fatal("garbage CA: got nil error")
			}
		})
	}
}

func TestUploadUserCA(t *testing.T) {
	for _, tc := range []struct {
		name  string
		label string
	}{
		{"with label", "laptop"},
		{"empty label", ""},
	} {
		t.Run(tc.name, func(t *testing.T) {
			var cap capture
			srv := serve(t, &cap, http.StatusCreated, `{"fingerprint":"SHA256:abc"}`)
			c := &client.Client{BaseURL: srv.URL, Token: "tok", UserCALabel: tc.label}

			if err := c.UploadUserCA(context.Background(), "default", testCALine); err != nil {
				t.Fatalf("UploadUserCA: %v", err)
			}
			if cap.method != http.MethodPost || cap.path != "/api/v1/tenants/default/user-cas" {
				t.Errorf("request = %s %s, want POST /api/v1/tenants/default/user-cas", cap.method, cap.path)
			}
			var req types.UserCARequest
			if err := json.Unmarshal(cap.body, &req); err != nil {
				t.Fatalf("request body did not decode as UserCARequest: %v", err)
			}
			if req.PublicKey != testCALine {
				t.Errorf("public_key = %q, want the CA line", req.PublicKey)
			}
			if req.Label != tc.label {
				t.Errorf("label = %q, want %q", req.Label, tc.label)
			}
		})
	}
}

// An empty tenant targets the tenant-less endpoint, which registers on the
// caller's own tenant (the credential names it).
func TestUploadUserCAEmptyTenantHitsOwnEndpoint(t *testing.T) {
	var cap capture
	srv := serve(t, &cap, http.StatusCreated, `{"fingerprint":"SHA256:abc"}`)
	c := &client.Client{BaseURL: srv.URL, Token: "tok"}

	if err := c.UploadUserCA(context.Background(), "", testCALine); err != nil {
		t.Fatalf("UploadUserCA: %v", err)
	}
	if cap.method != http.MethodPost || cap.path != "/api/v1/user-cas" {
		t.Errorf("request = %s %s, want POST /api/v1/user-cas", cap.method, cap.path)
	}
}

func TestListUserCAs(t *testing.T) {
	var cap capture
	srv := serve(t, &cap, http.StatusOK, `[{"fingerprint":"SHA256:abc","label":"laptop","pubkey":"`+testCALine+`"}]`)
	c := &client.Client{BaseURL: srv.URL, Token: "tok"}

	cas, err := c.ListUserCAs(context.Background(), "default")
	if err != nil {
		t.Fatalf("ListUserCAs: %v", err)
	}
	if cap.method != http.MethodGet || cap.path != "/api/v1/tenants/default/user-cas" {
		t.Errorf("request = %s %s, want GET /api/v1/tenants/default/user-cas", cap.method, cap.path)
	}
	if len(cas) != 1 || cas[0].Fingerprint != "SHA256:abc" || cas[0].Label != "laptop" {
		t.Errorf("cas = %+v, want one entry SHA256:abc/laptop", cas)
	}
}

// An empty tenant targets the tenant-less endpoint, which lists the caller's own
// tenant (the credential names it) — mirroring UploadUserCA's routing so a caller
// can check-then-upload against its own tenant with an empty tenant throughout.
func TestListUserCAsEmptyTenantHitsOwnEndpoint(t *testing.T) {
	var cap capture
	srv := serve(t, &cap, http.StatusOK, `[]`)
	c := &client.Client{BaseURL: srv.URL, Token: "tok"}

	if _, err := c.ListUserCAs(context.Background(), ""); err != nil {
		t.Fatalf("ListUserCAs: %v", err)
	}
	if cap.method != http.MethodGet || cap.path != "/api/v1/user-cas" {
		t.Errorf("request = %s %s, want GET /api/v1/user-cas", cap.method, cap.path)
	}
}

func TestUploadUserCAEscapesTenant(t *testing.T) {
	var cap capture
	srv := serve(t, &cap, http.StatusCreated, `{}`)
	c := &client.Client{BaseURL: srv.URL}

	if err := c.UploadUserCA(context.Background(), "a/b", testCALine); err != nil {
		t.Fatalf("UploadUserCA: %v", err)
	}
	if want := "/api/v1/tenants/a%2Fb/user-cas"; cap.path != want {
		t.Errorf("path = %q, want %q", cap.path, want)
	}
}

func TestCreateExposure(t *testing.T) {
	var cap capture
	srv := serve(t, &cap, http.StatusCreated,
		`{"id":"x-1","vm_id":"v-1","host_id":"h-1","guest_port":8080,"host_port":30080,`+
			`"host_addr":"192.168.0.190","protocol":"tcp","scope":"lan","state":"pending","reason":"",`+
			`"created_at":"2026-08-05T12:00:00Z"}`)
	c := &client.Client{BaseURL: srv.URL, Token: "eitri_pat_x"}

	got, err := c.CreateExposure(context.Background(), "v-1", 8080, 0, "udp")
	if err != nil {
		t.Fatalf("CreateExposure: %v", err)
	}
	if cap.method != http.MethodPost || cap.path != "/api/v1/vms/v-1/exposures" {
		t.Errorf("request = %s %s, want POST /api/v1/vms/v-1/exposures", cap.method, cap.path)
	}
	var sent types.CreateExposureRequest
	if err := json.Unmarshal(cap.body, &sent); err != nil {
		t.Fatalf("decode sent body: %v", err)
	}
	if sent.GuestPort != 8080 || sent.HostPort != 0 || sent.Protocol != "udp" {
		t.Errorf("sent = %+v, want guest 8080 / host 0 / udp", sent)
	}
	if got.ID != "x-1" || got.HostPort != 30080 {
		t.Errorf("got = %+v, want the allocated exposure back", got)
	}
}

func TestListExposures(t *testing.T) {
	var cap capture
	srv := serve(t, &cap, http.StatusOK, `[{"id":"x-1","host_port":30080,"state":"active"}]`)
	c := &client.Client{BaseURL: srv.URL, Token: "eitri_pat_x"}

	got, err := c.ListExposures(context.Background(), "v-1")
	if err != nil {
		t.Fatalf("ListExposures: %v", err)
	}
	if cap.method != http.MethodGet || cap.path != "/api/v1/vms/v-1/exposures" {
		t.Errorf("request = %s %s, want GET /api/v1/vms/v-1/exposures", cap.method, cap.path)
	}
	if len(got) != 1 || got[0].State != "active" {
		t.Errorf("got = %+v, want one active exposure", got)
	}
}

func TestDeleteExposure(t *testing.T) {
	var cap capture
	srv := serve(t, &cap, http.StatusNoContent, "")
	c := &client.Client{BaseURL: srv.URL, Token: "eitri_pat_x"}

	if err := c.DeleteExposure(context.Background(), "x-1"); err != nil {
		t.Fatalf("DeleteExposure: %v", err)
	}
	if cap.method != http.MethodDelete || cap.path != "/api/v1/exposures/x-1" {
		t.Errorf("request = %s %s, want DELETE /api/v1/exposures/x-1", cap.method, cap.path)
	}
}

func TestCreateVolumeClaim(t *testing.T) {
	var cap capture
	srv := serve(t, &cap, http.StatusCreated,
		`{"id":"c-1","name":"project-data","size_gb":50,"status":"pending",`+
			`"host_id":"","vm_id":"","present":null,"created_at":"2026-08-22T12:00:00Z"}`)
	c := &client.Client{BaseURL: srv.URL, Token: "eitri_pat_x"}

	got, err := c.CreateVolumeClaim(context.Background(), "project-data", 50)
	if err != nil {
		t.Fatalf("CreateVolumeClaim: %v", err)
	}
	if cap.method != http.MethodPost || cap.path != "/api/v1/volume-claims" {
		t.Errorf("request = %s %s, want POST /api/v1/volume-claims", cap.method, cap.path)
	}
	var sent types.CreateVolumeClaimRequest
	if err := json.Unmarshal(cap.body, &sent); err != nil {
		t.Fatalf("decode sent body: %v", err)
	}
	if sent.Name != "project-data" || sent.SizeGB != 50 {
		t.Errorf("sent = %+v, want project-data / 50GB", sent)
	}
	if got.ID != "c-1" || got.Status != "pending" {
		t.Errorf("got = %+v, want the pending claim back", got)
	}
	// present is null on the wire and must decode to nil, not false: a client
	// that flattens the two reports a missing disk for one nobody has looked at.
	if got.Present != nil {
		t.Errorf("present = %v, want nil for a claim no host has reported on", *got.Present)
	}
}

func TestListVolumeClaims(t *testing.T) {
	var cap capture
	srv := serve(t, &cap, http.StatusOK,
		`[{"id":"c-1","name":"data","size_gb":5,"status":"bound","host_id":"h-1","vm_id":"v-1","present":true}]`)
	c := &client.Client{BaseURL: srv.URL, Token: "eitri_pat_x"}

	got, err := c.ListVolumeClaims(context.Background())
	if err != nil {
		t.Fatalf("ListVolumeClaims: %v", err)
	}
	if cap.method != http.MethodGet || cap.path != "/api/v1/volume-claims" {
		t.Errorf("request = %s %s, want GET /api/v1/volume-claims", cap.method, cap.path)
	}
	if len(got) != 1 || got[0].Status != "bound" || got[0].HostID != "h-1" {
		t.Errorf("got = %+v, want one bound claim on h-1", got)
	}
	if got[0].Present == nil || !*got[0].Present {
		t.Errorf("present = %v, want true", got[0].Present)
	}
}

func TestGetVolumeClaimEscapesID(t *testing.T) {
	var cap capture
	srv := serve(t, &cap, http.StatusOK, `{"id":"c-1","name":"data","status":"pending"}`)
	c := &client.Client{BaseURL: srv.URL, Token: "eitri_pat_x"}

	got, err := c.GetVolumeClaim(context.Background(), "a b/c")
	if err != nil {
		t.Fatalf("GetVolumeClaim: %v", err)
	}
	if want := "/api/v1/volume-claims/a%20b%2Fc"; cap.method != http.MethodGet || cap.path != want {
		t.Errorf("request = %s %s, want GET %s", cap.method, cap.path, want)
	}
	if got.ID != "c-1" {
		t.Errorf("got = %+v, want claim c-1", got)
	}
}

func TestDeleteVolumeClaim(t *testing.T) {
	var cap capture
	srv := serve(t, &cap, http.StatusNoContent, "")
	c := &client.Client{BaseURL: srv.URL, Token: "eitri_pat_x"}

	if err := c.DeleteVolumeClaim(context.Background(), "c-1"); err != nil {
		t.Fatalf("DeleteVolumeClaim: %v", err)
	}
	if cap.method != http.MethodDelete || cap.path != "/api/v1/volume-claims/c-1" {
		t.Errorf("request = %s %s, want DELETE /api/v1/volume-claims/c-1", cap.method, cap.path)
	}
}

// The four delegation calls are one endpoint distinguished by method, so the
// method is the thing worth pinning: getting it wrong would silently start or
// end a delegation instead of reading one.
func TestDelegationCalls(t *testing.T) {
	const delegationJSON = `{"public_key":"ssh-ed25519 AAAA eitri","ca_fingerprint":"SHA256:abc",` +
		`"key_id":"eitri-delegation","serial":"7","principals":["ubuntu"],"expires_at":"2026-08-08T12:00:00Z"}`

	t.Run("begin", func(t *testing.T) {
		var cap capture
		srv := serve(t, &cap, http.StatusOK,
			`{"public_key":"ssh-ed25519 AAAA eitri","principal":"ubuntu","instructions":"ssh-keygen -s ..."}`)
		c := &client.Client{BaseURL: srv.URL, Token: "tok"}

		got, err := c.BeginDelegation(context.Background())
		if err != nil {
			t.Fatalf("BeginDelegation: %v", err)
		}
		if cap.method != http.MethodPost || cap.path != "/api/v1/delegations" {
			t.Errorf("request = %s %s, want POST /api/v1/delegations", cap.method, cap.path)
		}
		if got.PublicKey != "ssh-ed25519 AAAA eitri" || got.Principal != "ubuntu" {
			t.Errorf("challenge = %+v", got)
		}
		if got.Instructions == "" {
			t.Error("the caller needs the command, not just the key")
		}
	})

	t.Run("complete", func(t *testing.T) {
		var cap capture
		srv := serve(t, &cap, http.StatusOK, delegationJSON)
		c := &client.Client{BaseURL: srv.URL, Token: "tok"}

		const cert = "ssh-ed25519-cert-v01@openssh.com AAAAcert alex@laptop"
		got, err := c.CompleteDelegation(context.Background(), cert)
		if err != nil {
			t.Fatalf("CompleteDelegation: %v", err)
		}
		if cap.method != http.MethodPut || cap.path != "/api/v1/delegations" {
			t.Errorf("request = %s %s, want PUT /api/v1/delegations", cap.method, cap.path)
		}
		var req types.DelegationRequest
		if err := json.Unmarshal(cap.body, &req); err != nil {
			t.Fatalf("request body did not decode as DelegationRequest: %v", err)
		}
		if req.Certificate != cert {
			t.Errorf("certificate = %q, want it passed through unchanged", req.Certificate)
		}
		if got.CAFingerprint != "SHA256:abc" || got.ExpiresAt != "2026-08-08T12:00:00Z" {
			t.Errorf("delegation = %+v", got)
		}
	})

	t.Run("read", func(t *testing.T) {
		var cap capture
		srv := serve(t, &cap, http.StatusOK, delegationJSON)
		c := &client.Client{BaseURL: srv.URL, Token: "tok"}

		got, err := c.Delegation(context.Background())
		if err != nil {
			t.Fatalf("Delegation: %v", err)
		}
		if cap.method != http.MethodGet || cap.path != "/api/v1/delegations" {
			t.Errorf("request = %s %s, want GET /api/v1/delegations", cap.method, cap.path)
		}
		if got.Serial != "7" {
			t.Errorf("serial = %q, want the string form", got.Serial)
		}
	})

	t.Run("revoke", func(t *testing.T) {
		var cap capture
		srv := serve(t, &cap, http.StatusNoContent, "")
		c := &client.Client{BaseURL: srv.URL, Token: "tok"}

		if err := c.RevokeDelegation(context.Background()); err != nil {
			t.Fatalf("RevokeDelegation: %v", err)
		}
		if cap.method != http.MethodDelete || cap.path != "/api/v1/delegations" {
			t.Errorf("request = %s %s, want DELETE /api/v1/delegations", cap.method, cap.path)
		}
	})
}