Files
felhom.eu/hub/internal/api/host_enroll_test.go
T
admin 8098237ce1 hub v0.14.0: passphrase-authed host enrollment (Day-0 option C)
New POST /api/v1/host-enroll (handleHostEnroll): X-Retrieval-Password authed,
body {customer_id} -> {host_id, api_key}. Mint-once-reuse (201 first, 200
reuse) so re-running the host-bootstrap never orphans a running agent's key;
auth checked before any mint. Backed by new Store.GetHostByCustomer
(ORDER BY updated_at DESC LIMIT 1, idx_hosts_customer).

GET /config/{id} and global-key POST /admin/hosts left untouched. Exact-match
route (path == "/host-enroll") to avoid the /hosts/ prefix collision.

Tests: host_enroll_test.go (mint/reuse/401-no-mint/404/400) + GetHostByCustomer
store test; companion red-proof verified always-mint fails the reuse assertion.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TtXesNa2LGbMmE4DNL6SE7
2026-06-26 15:35:24 +02:00

150 lines
4.7 KiB
Go

package api
import (
"encoding/json"
"net/http"
"net/http/httptest"
"regexp"
"strings"
"testing"
"gitea.dooplex.hu/admin/felhom-hub/internal/store"
)
// doEnroll POSTs /host-enroll with the passphrase header (the do() helper only sets Bearer).
func doEnroll(h *Handler, customerID, pw string) *httptest.ResponseRecorder {
body := `{"customer_id":"` + customerID + `"}`
req := httptest.NewRequest(http.MethodPost, "/api/v1/host-enroll", strings.NewReader(body))
if pw != "" {
req.Header.Set("X-Retrieval-Password", pw)
}
rr := httptest.NewRecorder()
h.ServeHTTP(rr, req)
return rr
}
type enrollResp struct {
HostID string `json:"host_id"`
APIKey string `json:"api_key"`
}
func countHostsForCustomer(t *testing.T, st *store.Store, customerID string) int {
t.Helper()
hosts, err := st.ListHosts()
if err != nil {
t.Fatalf("ListHosts: %v", err)
}
n := 0
for _, h := range hosts {
if h.CustomerID == customerID {
n++
}
}
return n
}
var hostIDSuffix = regexp.MustCompile(`^c1-[0-9a-f]{6}$`)
var hex64 = regexp.MustCompile(`^[0-9a-f]{64}$`)
// Scenario A — first enroll mints.
func TestHostEnroll_FirstMints(t *testing.T) {
h, st, _ := newTestHandler(t)
st.SaveCustomerConfig(&store.CustomerConfig{CustomerID: "c1", APIKey: "ckey", RetrievalPassword: "pass-phrase"})
rr := doEnroll(h, "c1", "pass-phrase")
if rr.Code != http.StatusCreated {
t.Fatalf("status = %d, body=%s", rr.Code, rr.Body.String())
}
var got enrollResp
if err := json.Unmarshal(rr.Body.Bytes(), &got); err != nil {
t.Fatalf("decode: %v", err)
}
if !hostIDSuffix.MatchString(got.HostID) {
t.Errorf("host_id = %q, want c1-<6hex>", got.HostID)
}
if !hex64.MatchString(got.APIKey) {
t.Errorf("api_key = %q, want 64 hex", got.APIKey)
}
if n := countHostsForCustomer(t, st, "c1"); n != 1 {
t.Errorf("host rows for c1 = %d, want 1", n)
}
byKey, err := st.GetHostByAPIKey(got.APIKey)
if err != nil || byKey == nil || byKey.HostID != got.HostID {
t.Errorf("GetHostByAPIKey(minted) = %+v / %v", byKey, err)
}
}
// Scenario B — second enroll REUSES (idempotent). The load-bearing case.
func TestHostEnroll_SecondReuses(t *testing.T) {
h, st, _ := newTestHandler(t)
st.SaveCustomerConfig(&store.CustomerConfig{CustomerID: "c1", APIKey: "ckey", RetrievalPassword: "pass-phrase"})
rr1 := doEnroll(h, "c1", "pass-phrase")
if rr1.Code != http.StatusCreated {
t.Fatalf("first status = %d", rr1.Code)
}
var first enrollResp
json.Unmarshal(rr1.Body.Bytes(), &first)
rr2 := doEnroll(h, "c1", "pass-phrase")
if rr2.Code != http.StatusOK {
t.Fatalf("second status = %d, want 200 (reuse), body=%s", rr2.Code, rr2.Body.String())
}
var second enrollResp
json.Unmarshal(rr2.Body.Bytes(), &second)
if second.HostID != first.HostID || second.APIKey != first.APIKey {
t.Errorf("reuse returned different creds: first=%+v second=%+v", first, second)
}
if n := countHostsForCustomer(t, st, "c1"); n != 1 {
t.Errorf("after 2nd enroll host rows for c1 = %d, want 1 (no orphan/dup)", n)
}
}
// Scenario C — wrong passphrase refused, NO mint (auth before mint).
func TestHostEnroll_WrongPassphrase_NoMint(t *testing.T) {
h, st, _ := newTestHandler(t)
st.SaveCustomerConfig(&store.CustomerConfig{CustomerID: "c1", APIKey: "ckey", RetrievalPassword: "pass-phrase"})
rr := doEnroll(h, "c1", "wrong")
if rr.Code != http.StatusUnauthorized {
t.Fatalf("status = %d, want 401", rr.Code)
}
if n := countHostsForCustomer(t, st, "c1"); n != 0 {
t.Errorf("host rows for c1 = %d after bad auth, want 0 (no mint on bad auth)", n)
}
}
// Scenario D — unknown customer → 404, no mint.
func TestHostEnroll_UnknownCustomer(t *testing.T) {
h, st, _ := newTestHandler(t)
rr := doEnroll(h, "ghost", "anything")
if rr.Code != http.StatusNotFound {
t.Fatalf("status = %d, want 404", rr.Code)
}
if n := countHostsForCustomer(t, st, "ghost"); n != 0 {
t.Errorf("host rows for ghost = %d, want 0", n)
}
}
// Scenario E — missing header → 401 ; missing customer_id → 400.
func TestHostEnroll_MissingHeaderAndCustomerID(t *testing.T) {
h, st, _ := newTestHandler(t)
st.SaveCustomerConfig(&store.CustomerConfig{CustomerID: "c1", APIKey: "ckey", RetrievalPassword: "pass-phrase"})
// missing X-Retrieval-Password → 401
rr := doEnroll(h, "c1", "")
if rr.Code != http.StatusUnauthorized {
t.Errorf("missing header status = %d, want 401", rr.Code)
}
// empty/absent customer_id → 400 (checked before auth: payload validation first)
req := httptest.NewRequest(http.MethodPost, "/api/v1/host-enroll", strings.NewReader(`{}`))
req.Header.Set("X-Retrieval-Password", "pass-phrase")
rr2 := httptest.NewRecorder()
h.ServeHTTP(rr2, req)
if rr2.Code != http.StatusBadRequest {
t.Errorf("missing customer_id status = %d, want 400", rr2.Code)
}
}