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) } } // v0.52.0 (take-two F-15): the reset-request response carries the ACTIVE code state — the freshly // ROTATED hash + generation matching the stored row — so the box accepts the emailed code // immediately instead of waiting for the next report ACK. Never a plaintext code. func TestClaimResetRequest_ResponseCarriesRotatedHash(t *testing.T) { h, st, _ := newTestHandler(t) withClaimEngine(t, h, st) st.SaveCustomerConfig(&store.CustomerConfig{ CustomerID: "c1", RetrievalPassword: "pw", APIKey: "KEY1", ConfigJSON: "{}", Email: "a@b.hu", }) do(h, http.MethodPost, "/report", globalKey, `{"customer_id":"c1"}`) // issues gen 1 before, _ := st.GetClaim("c1") rr := do(h, http.MethodPost, "/claim/reset-request", "KEY1", `{"customer_id":"c1"}`) if rr.Code != http.StatusOK { t.Fatalf("reset request = %d (%s)", rr.Code, rr.Body.String()) } var resp 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(), &resp); err != nil { t.Fatalf("decode: %v", err) } after, _ := st.GetClaim("c1") if resp.Claim == nil { t.Fatalf("reset-request response carries no claim object: %s", rr.Body.String()) } if resp.Claim.Generation != before.Generation+1 || resp.Claim.Generation != after.Generation { t.Fatalf("response generation = %d, want the rotated one (stored %d, pre-rotation %d)", resp.Claim.Generation, after.Generation, before.Generation) } if resp.Claim.CodeHash != after.CodeHash || resp.Claim.CodeHash == before.CodeHash { t.Fatalf("response hash must be the freshly stored one (matches-stored=%v, still-old=%v)", resp.Claim.CodeHash == after.CodeHash, resp.Claim.CodeHash == before.CodeHash) } if !strings.HasPrefix(resp.Claim.CodeHash, "$2") || resp.Claim.IssuedAt == "" { t.Fatalf("response claim malformed: %+v (want bcrypt hash + issued_at)", resp.Claim) } } // 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) } }