package web // Customer-claim arc (v0.50.0) — the Setup-tab claim card + the operator resend route. import ( "io" "log" "net/http/httptest" "strings" "testing" "gitea.dooplex.hu/admin/felhom-hub/internal/claim" "gitea.dooplex.hu/admin/felhom-hub/internal/store" "golang.org/x/crypto/bcrypt" ) type uiFakeMailer struct{ sends []string } func (f *uiFakeMailer) SendClaimEmail(kind, customerID, email, domain, code string) error { f.sends = append(f.sends, kind) return nil } func withUIClaim(t *testing.T, s *Server, st *store.Store) (*claim.Engine, *uiFakeMailer) { t.Helper() m := &uiFakeMailer{} e := &claim.Engine{Store: st, Mailer: m, Logger: log.New(io.Discard, "", 0)} s.SetClaimEngine(e) return e, m } // The Setup tab renders the claim card in each state, and never any plaintext code (there is // none to render — the assertion pins that no template regression invents one). func TestCustomerPage_ClaimCardStates(t *testing.T) { s, st := newTestServer(t) e, _ := withUIClaim(t, s, st) cc := &store.CustomerConfig{ CustomerID: "acme", CustomerName: "Acme", Domain: "acme.hu", RetrievalPassword: "pw", APIKey: "k", Status: "active", Email: "owner@acme.hu", } if err := st.SaveCustomerConfig(cc); err != nil { t.Fatal(err) } // No claim row yet → neutral chip, no resend form. html := renderCustomerPage(t, s, "acme") if !strings.Contains(html, "no code issued yet") { t.Error("missing the no-code-issued chip") } if strings.Contains(html, "/configs/acme/claim-resend") { t.Error("resend form must not render before a code exists") } // Issued (unclaimed) → Nyitott chip + resend form; the bcrypt hash must NOT leak into HTML. cs, err := e.EnsureIssued(cc) if err != nil { t.Fatalf("EnsureIssued: %v", err) } html = renderCustomerPage(t, s, "acme") if !strings.Contains(html, "Nyitott — kód kiküldve") { t.Error("missing the unclaimed-emailed chip") } if !strings.Contains(html, "/configs/acme/claim-resend") || !strings.Contains(html, "Kód újraküldése") { t.Error("missing the resend form/button") } if strings.Contains(html, cs.CodeHash) { t.Error("the code HASH leaked into the page (nothing claim-secret may render)") } // Claimed → Claimed chip + the reset-flavored button label. if err := e.MarkClaimed(cc); err != nil { t.Fatalf("MarkClaimed: %v", err) } html = renderCustomerPage(t, s, "acme") if !strings.Contains(html, ">Claimed ") { t.Error("missing the Claimed chip") } if !strings.Contains(html, "Visszaállító kód küldése") { t.Error("claimed state should offer the reset-code button") } } // The resend route rotates the code (generation++) and redirects with a flash. func TestClaimResendRoute_RotatesAndRedirects(t *testing.T) { s, st := newTestServer(t) e, m := withUIClaim(t, s, st) cc := &store.CustomerConfig{ CustomerID: "acme", CustomerName: "Acme", Domain: "acme.hu", RetrievalPassword: "pw", APIKey: "k", Status: "active", Email: "owner@acme.hu", } st.SaveCustomerConfig(cc) if _, err := e.EnsureIssued(cc); err != nil { t.Fatalf("EnsureIssued: %v", err) } before, _ := st.GetClaim("acme") rr := httptest.NewRecorder() s.handleClaimResend(rr, httptest.NewRequest("POST", "/configs/acme/claim-resend", nil), "acme") if rr.Code != 303 { t.Fatalf("resend status = %d, want 303", rr.Code) } if loc := rr.Header().Get("Location"); !strings.Contains(loc, "flash=claim-resent") { t.Fatalf("resend redirect = %q", loc) } after, _ := st.GetClaim("acme") if after.Generation != before.Generation+1 { t.Fatalf("generation %d → %d, want +1", before.Generation, after.Generation) } if after.CodeHash == before.CodeHash { t.Fatal("resend did not rotate the hash") } if len(m.sends) != 2 { t.Fatalf("sends = %v, want issue+resend", m.sends) } // Sanity: the stored value is a bcrypt hash (never a plaintext code). if bcrypt.CompareHashAndPassword([]byte(after.CodeHash), []byte("definitely-wrong")) == nil { t.Fatal("stored hash verified a wrong code?!") } }