6b40eb8619
Closes DRILL-day0-vm F-4 hub-side: per-customer claim state (customer_claims,
bcrypt-only custody), the claim engine (issue at real config retrieve = Day-0
bake; first-report issue for live boxes; resend rotates generation; reset
rate-limited 3/day), three Hungarian emails via the dispatcher, report-ACK
claim object {code_hash, generation, issued_at} + set-only claimed ingest,
web.claim_code_* baked into generated controller.yaml, Setup-tab status chip
+ resend button, POST /api/v1/claim/reset-request (self-scoped), claim_lockout
event allowlisted. 13 new tests; full repo green.
Claude-Session: https://claude.ai/code/session_01NptTCFtu7dz2Ru89qHRagN
120 lines
3.9 KiB
Go
120 lines
3.9 KiB
Go
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?!")
|
|
}
|
|
}
|