internal/server/api/usercas_test.go
Ref: Size: 8.0 KiB History
package api
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/a73x/eitri/internal/server/api/types"
"github.com/a73x/eitri/internal/server/sshca"
"github.com/a73x/eitri/internal/server/store"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// TestUploadUserCAStoresCanonicalLine posts a BYO user-CA public key in the
// shape a real upload arrives in — copied out of a file, so indented, carrying
// the operator's comment and a trailing newline — and asserts eitri stores the
// CANONICAL line. Lookup is by exact line match (TenantForUserCA), so storing
// what was posted would mean a CA that resolves for nobody: every guest signed
// by it is unreachable, and re-uploading the same key from a different tool
// mints a second row instead of finding the first. The pubkey is registered,
// never a signing key.
func TestUploadUserCAStoresCanonicalLine(t *testing.T) {
ts, st, _, _, _ := newServer(t)
// A throwaway CA: eitri only ever sees the public key.
_, signer, err := sshca.GenerateHostKey()
require.NoError(t, err)
canonical := sshca.AuthorizedKeyLine(signer.PublicKey())
posted := " " + canonical + " alex@laptop\n"
require.NotEqual(t, canonical, posted, "the posted line must differ from the canonical one or this test proves nothing")
resp := do(t, "POST", ts.URL+"/api/v1/tenants/"+testTenant+"/user-cas", testPAT,
map[string]any{"public_key": posted, "label": "yubikey-ca"})
require.Equal(t, http.StatusCreated, resp.StatusCode)
var out map[string]string
require.NoError(t, json.NewDecoder(resp.Body).Decode(&out))
assert.NotEmpty(t, out["fingerprint"], "upload must echo the CA fingerprint")
tenant, ok, err := st.TenantForUserCA(canonical)
require.NoError(t, err)
require.True(t, ok, "the uploaded CA must resolve back to a tenant by its canonical line — the only form a lookup ever presents")
assert.Equal(t, testTenant, tenant)
assert.True(t, tenantlessListHas(t, ts.URL, testPAT, canonical),
"the stored line must be the canonical one, comment and whitespace stripped")
}
// TestUploadUserCATenantlessLandsOnCallerTenant posts to the tenant-less
// /api/v1/user-cas and asserts the CA registers under the CALLER's own tenant
// (the credential names it), while a second tenant neither lands there nor sees
// it through its own tenant-less list.
func TestUploadUserCATenantlessLandsOnCallerTenant(t *testing.T) {
w := newTwoTenant(t)
_, signer, err := sshca.GenerateHostKey()
require.NoError(t, err)
line := sshca.AuthorizedKeyLine(signer.PublicKey())
// default's PAT uploads via the tenant-less endpoint.
resp := do(t, "POST", w.ts.URL+"/api/v1/user-cas", testPAT,
map[string]any{"public_key": line, "label": "mine"})
require.Equal(t, http.StatusCreated, resp.StatusCode)
// It resolves back to default — the caller's own tenant, not beta.
tenant, ok, err := w.st.TenantForUserCA(line)
require.NoError(t, err)
require.True(t, ok)
assert.Equal(t, testTenant, tenant)
// default's own tenant-less list includes it.
require.True(t, tenantlessListHas(t, w.ts.URL, testPAT, line),
"default's tenant-less list must include its own CA")
// beta's tenant-less list (its own tenant) must not.
require.False(t, tenantlessListHas(t, w.ts.URL, w.betaPAT, line),
"beta must not see default's CA through its own tenant-less list")
}
// tenantlessListHas reports whether GET /api/v1/user-cas as pat returns a CA
// whose pubkey line equals want.
func tenantlessListHas(t *testing.T, baseURL, pat, want string) bool {
t.Helper()
resp := do(t, "GET", baseURL+"/api/v1/user-cas", pat, nil)
require.Equal(t, http.StatusOK, resp.StatusCode)
var cas []types.UserCA
require.NoError(t, json.NewDecoder(resp.Body).Decode(&cas))
for _, c := range cas {
if c.PubKey == want {
return true
}
}
return false
}
func TestUploadUserCAGarbageKeyIs400(t *testing.T) {
ts, _, _, _, _ := newServer(t)
resp := do(t, "POST", ts.URL+"/api/v1/tenants/"+testTenant+"/user-cas", testPAT,
map[string]any{"public_key": "not-a-key"})
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
}
// caLessTenant provisions a tenant that has a PAT and an enrolled, reporting
// host but has registered no user CA — the state every tenant is in between
// signing up and uploading, and the one the create refusal is about. It returns
// that tenant's PAT and host id.
func caLessTenant(t *testing.T, ts *httptest.Server, st *store.Store, sub string) (pat, hostID string) {
t.Helper()
tn, err := st.CreateTenantForIdentity("https://issuer.example", sub, sub+"@example.com")
require.NoError(t, err)
pat, _, err = st.CreateAPIToken(tn.ID, sub, 0)
require.NoError(t, err)
return pat, enrollWith(t, ts, pat, sub+"-host")
}
// TestCreateVMRefusesTenantWithNoUserCA is the tenant-side half of the
// pre-CSR refusal: a guest bakes its tenant's CA set into its sshd trust at
// create and nothing updates it afterwards, so a tenant with no CA would be
// creating a guest nothing can ever reach — a dead end whose only remedy is to
// delete it and start again. The request is well-formed and the host is fine;
// it is the tenant that is not ready, which is what makes this a 409.
//
// The message is asserted, not just the status, because it is the feature: a
// caller must be able to fix this from wherever it is standing without reading
// the docs, and the caller may be a browser, a shell, a model, or a program.
func TestCreateVMRefusesTenantWithNoUserCA(t *testing.T) {
ts, st, _, _, _ := newServer(t)
pat, hostID := caLessTenant(t, ts, st, "no-ca")
resp := do(t, "POST", ts.URL+"/api/v1/vms", pat, map[string]any{"host_id": hostID})
require.Equal(t, http.StatusConflict, resp.StatusCode)
body := bodyText(t, resp)
assert.Contains(t, body, "no registered SSH user CA", "the refusal must name what is missing")
assert.Contains(t, body, "Settings", "a human at the console must be told where to go")
assert.Contains(t, body, "eitri ca upload", "a human at a shell must be given the command")
assert.Contains(t, body, "ca_upload", "a model holding a PAT must be given the tool")
assert.Contains(t, body, "/user-cas", "a program with only the API must be given the endpoint")
}
// TestCreateVMProceedsOnceACAIsRegistered walks the remedy the refusal names
// and asserts it is sufficient: the same tenant, host and request that were
// refused go through once a CA is registered. Without this the refusal could
// be standing in front of some other failure and nobody would know.
func TestCreateVMProceedsOnceACAIsRegistered(t *testing.T) {
ts, st, _, _, _ := newServer(t)
pat, hostID := caLessTenant(t, ts, st, "late-ca")
resp := do(t, "POST", ts.URL+"/api/v1/vms", pat, map[string]any{"host_id": hostID})
require.Equal(t, http.StatusConflict, resp.StatusCode)
// Register through the endpoint the refusal names — the same one the
// console, the CLI and the MCP ca_upload tool all post to.
_, signer, err := sshca.GenerateHostKey()
require.NoError(t, err)
resp = do(t, "POST", ts.URL+"/api/v1/user-cas", pat,
map[string]any{"public_key": sshca.AuthorizedKeyLine(signer.PublicKey())})
require.Equal(t, http.StatusCreated, resp.StatusCode)
resp = do(t, "POST", ts.URL+"/api/v1/vms", pat, map[string]any{"host_id": hostID})
assert.Equal(t, http.StatusCreated, resp.StatusCode, "a registered CA is the whole precondition")
}
// TestCreateVMNotUnlockedByAnotherTenantsCA pins the scope of the check. A
// guest trusts ITS OWN tenant's CA set, so a CA registered anywhere else says
// nothing about whether this tenant could reach the guest it is asking for.
// A fleet-wide reading of "is there a CA" would let the first tenant to upload
// one silently unblock every other tenant into the same dead end.
func TestCreateVMNotUnlockedByAnotherTenantsCA(t *testing.T) {
ts, st, _, _, _ := newServer(t)
// testTenant already has a seeded CA; this one has none.
pat, hostID := caLessTenant(t, ts, st, "borrower")
resp := do(t, "POST", ts.URL+"/api/v1/vms", pat, map[string]any{"host_id": hostID})
assert.Equal(t, http.StatusConflict, resp.StatusCode,
"another tenant's CA must not satisfy this tenant's precondition")
}