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