package web import ( "net/http" "net/http/httptest" "net/url" "strings" "testing" "gitea.dooplex.hu/admin/felhom-hub/internal/store" ) // R-21 slice C — the operator unclaimed-appliance surface: render + bind/discard. func seedAppliance(t *testing.T, st *store.Store, uuid, macSet string) int64 { t.Helper() // a real ed25519 host key line so the fingerprint helper has something to parse sshKey := "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIHVBv+9slP74+1/vNhiI0OJDrXQ2nvb8iwmIxMfUZn36 host" hw := `{"product":"Intel N100 mini","cpu":"Intel(R) N100","mem_kb":16150372}` if _, _, err := st.RegisterAppliance(uuid, macSet, sshKey, hw, "hash-"+uuid, "ABCDEF"); err != nil { t.Fatalf("register appliance: %v", err) } list, err := st.ListUnclaimedAppliances() if err != nil { t.Fatal(err) } for _, a := range list { if a.UUID == uuid && a.MACSet == macSet { return a.ID } } t.Fatal("seeded appliance not found") return 0 } func renderHosts(t *testing.T, s *Server) string { t.Helper() rr := httptest.NewRecorder() s.handleHostsList(rr, httptest.NewRequest("GET", "/hosts", nil)) if rr.Code != 200 { t.Fatalf("hosts page = %d", rr.Code) } return rr.Body.String() } func TestAppliances_UnclaimedSectionRenders(t *testing.T) { s, st := newTestServer(t) if err := st.SaveCustomerConfig(&store.CustomerConfig{CustomerID: "acme", CustomerName: "Acme Kft", APIKey: "k", RetrievalPassword: "pw"}); err != nil { t.Fatal(err) } seedAppliance(t, st, "uuid-vis", "bc:24:11:98:10:0e,bc:24:11:98:10:0f") html := renderHosts(t, s) for _, want := range []string{ "Unclaimed appliances", "uuid-vis", "bc:24:11:98:10:0e", "Intel N100 mini", "SHA256:", // hw + a computed SSH fingerprint `action="/appliances/`, "/bind", "/discard", `Acme Kft (0 hosts)`, // the picker shows host counts (display only) } { if !strings.Contains(html, want) { t.Errorf("unclaimed section missing %q", want) } } } func TestAppliances_BindStagesDelivery(t *testing.T) { s, st := newTestServer(t) if err := st.SaveCustomerConfig(&store.CustomerConfig{CustomerID: "acme", CustomerName: "Acme", APIKey: "k", RetrievalPassword: "pw"}); err != nil { t.Fatal(err) } id := seedAppliance(t, st, "uuid-bind", "bc:24:11:98:10:0e") form := url.Values{"customer_id": {"acme"}, "mode": {"appliance"}, "extra_args": {"--cores 4"}} req := httptest.NewRequest("POST", "/appliances/x/bind", strings.NewReader(form.Encode())) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") rr := httptest.NewRecorder() s.handleApplianceBind(rr, req, id) if rr.Code != http.StatusSeeOther { t.Fatalf("bind = %d (%s), want 303", rr.Code, rr.Body.String()) } // The appliance is now bound with the staged delivery. a, _ := st.GetAppliance(id) if a.Status != store.ApplianceBound || a.CustomerID != "acme" || a.InstallMode != "appliance" || a.ExtraArgs != "--cores 4" { t.Fatalf("bind did not stage the delivery: %+v", a) } // Audit event recorded (a customer scopes it now). if ev, _ := st.GetLatestEventByType("acme", "appliance_bound"); ev == nil { t.Error("no appliance_bound event recorded") } // It leaves the unclaimed section as a bound row (still shown until delivered). if !strings.Contains(renderHosts(t, s), "bound → Acme") { t.Error("bound appliance not shown as bound in the UI") } } // Bind must NOT gate on the customer's host count (a post-RESET / drill customer is hostless). func TestAppliances_BindDoesNotGateOnHostCount(t *testing.T) { s, st := newTestServer(t) if err := st.SaveCustomerConfig(&store.CustomerConfig{CustomerID: "hostless", APIKey: "k", RetrievalPassword: "pw"}); err != nil { t.Fatal(err) } id := seedAppliance(t, st, "uuid-h", "bc:24:11:98:10:0e") form := url.Values{"customer_id": {"hostless"}} req := httptest.NewRequest("POST", "/appliances/x/bind", strings.NewReader(form.Encode())) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") rr := httptest.NewRecorder() s.handleApplianceBind(rr, req, id) if rr.Code != http.StatusSeeOther { t.Fatalf("bind to hostless customer = %d, want 303 (host count is display-only)", rr.Code) } } func TestAppliances_BindUnknownCustomerRejected(t *testing.T) { s, st := newTestServer(t) id := seedAppliance(t, st, "uuid-u", "bc:24:11:98:10:0e") form := url.Values{"customer_id": {"ghost"}} req := httptest.NewRequest("POST", "/appliances/x/bind", strings.NewReader(form.Encode())) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") rr := httptest.NewRecorder() s.handleApplianceBind(rr, req, id) if rr.Code != http.StatusBadRequest { t.Fatalf("bind to unknown customer = %d, want 400", rr.Code) } if a, _ := st.GetAppliance(id); a.Status != store.ApplianceRegistered { t.Error("a rejected bind still mutated the appliance") } } func TestAppliances_Discard(t *testing.T) { s, st := newTestServer(t) id := seedAppliance(t, st, "uuid-d", "bc:24:11:98:10:0e") req := httptest.NewRequest("POST", "/appliances/x/discard", nil) rr := httptest.NewRecorder() s.handleApplianceDiscard(rr, req, id) if rr.Code != http.StatusSeeOther { t.Fatalf("discard = %d, want 303", rr.Code) } a, _ := st.GetAppliance(id) if a.Status != store.ApplianceDiscarded { t.Fatalf("discard did not set status: %+v", a) } // No longer in the unclaimed list. list, _ := st.ListUnclaimedAppliances() if len(list) != 0 { t.Errorf("discarded appliance still unclaimed: %d", len(list)) } }