hub: customer-claim password arc parts 1+2 — code engine, emails, ACK, configgen bake, UI (v0.50.0)
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
This commit is contained in:
@@ -0,0 +1,163 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-hub/internal/claim"
|
||||
"gitea.dooplex.hu/admin/felhom-hub/internal/configgen"
|
||||
"gitea.dooplex.hu/admin/felhom-hub/internal/store"
|
||||
)
|
||||
|
||||
type nullMailer struct{ sends int }
|
||||
|
||||
func (n *nullMailer) SendClaimEmail(kind, customerID, email, domain, code string) error {
|
||||
n.sends++
|
||||
return nil
|
||||
}
|
||||
|
||||
func withClaimEngine(t *testing.T, h *Handler, st *store.Store) *nullMailer {
|
||||
t.Helper()
|
||||
m := &nullMailer{}
|
||||
h.SetClaimEngine(&claim.Engine{Store: st, Mailer: m, Logger: log.New(io.Discard, "", 0)})
|
||||
return m
|
||||
}
|
||||
|
||||
// The report ACK of a managed customer carries the claim object (hash + generation + issued_at),
|
||||
// and the first report ISSUES the code for a live box that has none yet.
|
||||
func TestReportACK_ClaimServedAndIssuedOnFirstReport(t *testing.T) {
|
||||
h, st, _ := newTestHandler(t)
|
||||
m := withClaimEngine(t, h, st)
|
||||
if err := st.SaveCustomerConfig(&store.CustomerConfig{
|
||||
CustomerID: "c", RetrievalPassword: "pw", APIKey: "k", ConfigJSON: "{}",
|
||||
Email: "owner@example.hu", Domain: "example.hu",
|
||||
}); err != nil {
|
||||
t.Fatalf("SaveCustomerConfig: %v", err)
|
||||
}
|
||||
|
||||
rr := do(h, http.MethodPost, "/report", globalKey, `{"customer_id":"c"}`)
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("report = %d (%s)", rr.Code, rr.Body.String())
|
||||
}
|
||||
var ack struct {
|
||||
Claim *struct {
|
||||
CodeHash string `json:"code_hash"`
|
||||
Generation int `json:"generation"`
|
||||
IssuedAt string `json:"issued_at"`
|
||||
} `json:"claim"`
|
||||
}
|
||||
if err := json.Unmarshal(rr.Body.Bytes(), &ack); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if ack.Claim == nil || ack.Claim.Generation != 1 || !strings.HasPrefix(ack.Claim.CodeHash, "$2") {
|
||||
t.Fatalf("ACK claim = %+v, want gen 1 with a bcrypt hash", ack.Claim)
|
||||
}
|
||||
if m.sends != 1 {
|
||||
t.Fatalf("first report should have emailed the code once, sends=%d", m.sends)
|
||||
}
|
||||
|
||||
// Second report: same generation, no re-send (idempotent), claim still served.
|
||||
rr = do(h, http.MethodPost, "/report", globalKey, `{"customer_id":"c"}`)
|
||||
var ack2 struct {
|
||||
Claim *struct {
|
||||
Generation int `json:"generation"`
|
||||
} `json:"claim"`
|
||||
}
|
||||
json.Unmarshal(rr.Body.Bytes(), &ack2)
|
||||
if ack2.Claim == nil || ack2.Claim.Generation != 1 || m.sends != 1 {
|
||||
t.Fatalf("second report: claim=%+v sends=%d, want gen 1 / 1 send", ack2.Claim, m.sends)
|
||||
}
|
||||
}
|
||||
|
||||
// A reported claimed:true flips the store row (set-only) and a later claimed:false report can
|
||||
// never un-claim it (wiped-settings DR case).
|
||||
func TestReport_ClaimedIngestIsSetOnly(t *testing.T) {
|
||||
h, st, _ := newTestHandler(t)
|
||||
withClaimEngine(t, h, st)
|
||||
st.SaveCustomerConfig(&store.CustomerConfig{
|
||||
CustomerID: "c", RetrievalPassword: "pw", APIKey: "k", ConfigJSON: "{}",
|
||||
Email: "owner@example.hu",
|
||||
})
|
||||
|
||||
do(h, http.MethodPost, "/report", globalKey, `{"customer_id":"c","claimed":true}`)
|
||||
cs, _ := st.GetClaim("c")
|
||||
if !cs.Claimed() {
|
||||
t.Fatal("claimed:true report did not mark the customer claimed")
|
||||
}
|
||||
claimedAt := *cs.ClaimedAt
|
||||
|
||||
do(h, http.MethodPost, "/report", globalKey, `{"customer_id":"c","claimed":false}`)
|
||||
cs, _ = st.GetClaim("c")
|
||||
if !cs.Claimed() || !cs.ClaimedAt.Equal(claimedAt) {
|
||||
t.Fatal("claimed:false report altered the claimed state — must be set-only")
|
||||
}
|
||||
}
|
||||
|
||||
// The reset-request endpoint is self-scoped: a customer key may only request its own reset.
|
||||
func TestClaimResetRequest_SelfScoped(t *testing.T) {
|
||||
h, st, _ := newTestHandler(t)
|
||||
m := withClaimEngine(t, h, st)
|
||||
st.SaveCustomerConfig(&store.CustomerConfig{
|
||||
CustomerID: "c1", RetrievalPassword: "pw", APIKey: "KEY1", ConfigJSON: "{}", Email: "a@b.hu",
|
||||
})
|
||||
st.SaveCustomerConfig(&store.CustomerConfig{
|
||||
CustomerID: "c2", RetrievalPassword: "pw", APIKey: "KEY2", ConfigJSON: "{}", Email: "c@d.hu",
|
||||
})
|
||||
|
||||
rr := do(h, http.MethodPost, "/claim/reset-request", "KEY1", `{"customer_id":"c2"}`)
|
||||
if rr.Code != http.StatusForbidden {
|
||||
t.Fatalf("cross-customer reset request = %d, want 403", rr.Code)
|
||||
}
|
||||
rr = do(h, http.MethodPost, "/claim/reset-request", "KEY1", `{"customer_id":"c1"}`)
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("own reset request = %d, want 200 (%s)", rr.Code, rr.Body.String())
|
||||
}
|
||||
if m.sends == 0 {
|
||||
t.Fatal("reset request sent no email")
|
||||
}
|
||||
rr = do(h, http.MethodPost, "/claim/reset-request", "", `{"customer_id":"c1"}`)
|
||||
if rr.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("unauthenticated reset request = %d, want 401", rr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// The generated controller.yaml bakes the claim hash (Day-0 gate-from-first-boot) and NEVER a
|
||||
// plaintext code; without a claim state the web section is unchanged.
|
||||
func TestConfiggen_BakesClaimHashOnly(t *testing.T) {
|
||||
tmpl := "web:\n listen: :8080\n password_hash: \"\"\n"
|
||||
cfg := &store.CustomerConfig{CustomerID: "c", ConfigJSON: "{}"}
|
||||
|
||||
out, err := configgen.Generate(tmpl, cfg, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("generate (nil claim): %v", err)
|
||||
}
|
||||
if strings.Contains(out, "claim_code_hash") {
|
||||
t.Fatal("nil claim state must not emit claim_code_hash")
|
||||
}
|
||||
|
||||
h, st, _ := newTestHandler(t)
|
||||
m := withClaimEngine(t, h, st)
|
||||
_ = m
|
||||
st.SaveCustomerConfig(&store.CustomerConfig{
|
||||
CustomerID: "c", RetrievalPassword: "pw", APIKey: "k", ConfigJSON: "{}", Email: "a@b.hu",
|
||||
})
|
||||
do(h, http.MethodPost, "/report", globalKey, `{"customer_id":"c"}`) // issues gen 1
|
||||
cs, _ := st.GetClaim("c")
|
||||
|
||||
out, err = configgen.Generate(tmpl, cfg, cs)
|
||||
if err != nil {
|
||||
t.Fatalf("generate (with claim): %v", err)
|
||||
}
|
||||
if !strings.Contains(out, "claim_code_hash: "+cs.CodeHash) &&
|
||||
!strings.Contains(out, "claim_code_hash: '"+cs.CodeHash+"'") &&
|
||||
!strings.Contains(out, "claim_code_hash: \""+cs.CodeHash+"\"") {
|
||||
t.Fatalf("generated yaml missing the claim hash:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, "claim_code_generation: 1") {
|
||||
t.Fatalf("generated yaml missing the generation:\n%s", out)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user