package api import ( "bytes" "encoding/json" "net/http" "net/http/httptest" "strings" "testing" "gitea.dooplex.hu/admin/felhom-hub/internal/store" ) // R-21 slice C — the appliance register + one-shot poll delivery. The load-bearing contracts: // - register is idempotent by (uuid, mac_set); a DIFFERENT mac-set is a distinct appliance. // - a bound appliance's credentials are delivered EXACTLY ONCE; every later poll → 410. // - an unknown / discarded token → 404, indistinguishable (no enumeration oracle). func registerAppliance(t *testing.T, h *Handler, uuid string, macs []string) string { t.Helper() body, _ := json.Marshal(applianceRegisterReq{UUID: uuid, MACs: macs, SSHHostPubkeys: []string{"ssh-ed25519 AAAAKEY host"}}) req := httptest.NewRequest("POST", "/api/v1/appliance/register", bytes.NewReader(body)) rr := httptest.NewRecorder() h.ServeHTTP(rr, req) if rr.Code != 200 { t.Fatalf("register = %d (%s), want 200", rr.Code, rr.Body.String()) } var resp struct { Token string `json:"appliance_token"` Poll int `json:"poll_interval_sec"` } if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil { t.Fatal(err) } if len(resp.Token) < 32 || resp.Poll != 30 { t.Fatalf("bad register response: token_len=%d poll=%d", len(resp.Token), resp.Poll) } return resp.Token } func poll(t *testing.T, h *Handler, token string) *httptest.ResponseRecorder { t.Helper() req := httptest.NewRequest("GET", "/api/v1/appliance/poll", nil) req.Header.Set("Authorization", "Bearer "+token) rr := httptest.NewRecorder() h.ServeHTTP(rr, req) return rr } func countAppliances(t *testing.T, st *store.Store) int { t.Helper() list, err := st.ListUnclaimedAppliances() if err != nil { t.Fatal(err) } return len(list) } // Scenario A: register is idempotent by (uuid, mac_set); a different mac-set is a distinct appliance. // (Red-proof: making RegisterAppliance always-INSERT — dropping the (uuid,mac_set) upsert — makes the // re-register assertion see 2 rows → FAIL. Verified run-fail-revert.) func TestApplianceRegister_Idempotent(t *testing.T) { h, st, _ := newTestHandler(t) macs := []string{"bc:24:11:98:10:0e", "bc:24:11:98:10:0f"} registerAppliance(t, h, "uuid-A", macs) registerAppliance(t, h, "uuid-A", macs) // same box, re-register if n := countAppliances(t, st); n != 1 { t.Fatalf("re-register duplicated the appliance: %d rows, want 1", n) } // MAC order must not matter (normalized/sorted). registerAppliance(t, h, "uuid-A", []string{macs[1], macs[0]}) if n := countAppliances(t, st); n != 1 { t.Fatalf("MAC reorder duplicated the appliance: %d rows, want 1", n) } // Same UUID, DIFFERENT mac-set = a distinct appliance (cheap-board duplicate-UUID tiebreaker). registerAppliance(t, h, "uuid-A", []string{"aa:aa:aa:aa:aa:aa"}) if n := countAppliances(t, st); n != 2 { t.Fatalf("different mac-set should be a distinct appliance: %d rows, want 2", n) } } // Scenario C: one-shot delivery + 410; and 404-no-oracle. func TestAppliancePoll_OneShotAnd410(t *testing.T) { h, st, _ := newTestHandler(t) if err := st.SaveCustomerConfig(&store.CustomerConfig{CustomerID: "acme", APIKey: "k", RetrievalPassword: "the-passphrase"}); err != nil { t.Fatal(err) } token := registerAppliance(t, h, "uuid-C", []string{"bc:24:11:98:10:0e"}) // Unbound → 204 (keep polling), no credentials. if rr := poll(t, h, token); rr.Code != http.StatusNoContent { t.Fatalf("unbound poll = %d, want 204", rr.Code) } // Operator binds it. list, _ := st.ListUnclaimedAppliances() if err := st.BindAppliance(list[0].ID, "acme", "appliance", "--cores 4"); err != nil { t.Fatal(err) } // First poll after bind: ONE delivery with the credentials. rr := poll(t, h, token) if rr.Code != 200 { t.Fatalf("bound poll = %d (%s), want 200", rr.Code, rr.Body.String()) } var creds map[string]string json.Unmarshal(rr.Body.Bytes(), &creds) if creds["customer_id"] != "acme" || creds["retrieval_passphrase"] != "the-passphrase" || creds["mode"] != "appliance" || creds["extra_args"] != "--cores 4" { t.Fatalf("delivery payload wrong: %+v", creds) } // Red-proof target: every subsequent poll → 410, NEVER re-delivers the passphrase. // (Defeating MarkApplianceDelivered's status flip makes this re-receive 200+passphrase → FAIL.) rr2 := poll(t, h, token) if rr2.Code != http.StatusGone { t.Fatalf("second poll = %d, want 410 (one-shot)", rr2.Code) } if strings.Contains(rr2.Body.String(), "the-passphrase") { t.Fatal("the passphrase was re-delivered on the second poll — one-shot broken") } } func TestAppliancePoll_NoOracle(t *testing.T) { h, st, _ := newTestHandler(t) // Unknown token → 404. if rr := poll(t, h, "totally-unknown-token"); rr.Code != http.StatusNotFound { t.Fatalf("unknown token = %d, want 404", rr.Code) } // Registered-then-discarded → 404, indistinguishable from unknown. token := registerAppliance(t, h, "uuid-D", []string{"bc:24:11:98:10:0e"}) list, _ := st.ListUnclaimedAppliances() if err := st.DiscardAppliance(list[0].ID); err != nil { t.Fatal(err) } rr := poll(t, h, token) if rr.Code != http.StatusNotFound { t.Fatalf("discarded token poll = %d, want 404 (no oracle)", rr.Code) } // Discard is sticky across a re-register: the box gets a token but its poll stays 404. token2 := registerAppliance(t, h, "uuid-D", []string{"bc:24:11:98:10:0e"}) if rr := poll(t, h, token2); rr.Code != http.StatusNotFound { t.Fatalf("re-registered-after-discard poll = %d, want 404 (sticky discard)", rr.Code) } if n := countAppliances(t, st); n != 0 { t.Fatalf("discarded appliance still listed as unclaimed: %d", n) } }