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