diff --git a/controller/internal/web/claim.go b/controller/internal/web/claim.go index 78ae57c..9512d33 100644 --- a/controller/internal/web/claim.go +++ b/controller/internal/web/claim.go @@ -8,11 +8,13 @@ import ( "encoding/hex" "encoding/json" "fmt" + "io" "net/http" "os" "strings" "time" + "gitea.dooplex.hu/admin/felhom-controller/internal/report" "golang.org/x/crypto/bcrypt" ) @@ -369,6 +371,10 @@ func (s *Server) handleClaimRequestNewCode(w http.ResponseWriter, r *http.Reques } // requestHubResetCode calls POST /api/v1/claim/reset-request with the box's own report key. +// v0.123.0 (take-two F-15): a hub ≥0.52.0 returns the freshly rotated code state in the response; +// it is applied through the SAME generation-guarded consumer as the report ACK (ClaimSync), so the +// emailed code works the moment it lands instead of after the next ACK (~15 min). An old hub's +// bare {"status":"ok"} response is a clean no-op (no claim object → Reconcile skips). func (s *Server) requestHubResetCode() { if s.cfg.Hub.URL == "" || s.cfg.Hub.APIKey == "" { s.logger.Printf("[WARN] [web] claim: cannot request a new code — hub URL/key not configured") @@ -388,12 +394,23 @@ func (s *Server) requestHubResetCode() { s.logger.Printf("[ERROR] [web] claim: reset-request to hub failed: %v", err) return } - resp.Body.Close() + defer resp.Body.Close() if resp.StatusCode < 200 || resp.StatusCode >= 300 { s.logger.Printf("[WARN] [web] claim: hub reset-request returned HTTP %d", resp.StatusCode) return } s.logger.Printf("[INFO] [web] claim: requested a fresh code from the hub for %s", s.cfg.Customer.ID) + var payload struct { + Claim *report.ClaimStatus `json:"claim"` + } + if err := json.NewDecoder(io.LimitReader(resp.Body, 64<<10)).Decode(&payload); err != nil { + s.logger.Printf("[WARN] [web] claim: parsing reset-request response failed (code arrives via the next ACK): %v", err) + return + } + if payload.Claim != nil && s.settings != nil { + sync := &report.ClaimSync{Settings: s.settings, Logger: s.logger} + sync.Reconcile(payload.Claim) + } } // PrintLocalResetCode is the root escape hatch (v0.122.0, --print-reset-code): generate a fresh diff --git a/controller/internal/web/claim_reset_response_test.go b/controller/internal/web/claim_reset_response_test.go new file mode 100644 index 0000000..448162e --- /dev/null +++ b/controller/internal/web/claim_reset_response_test.go @@ -0,0 +1,137 @@ +package web + +import ( + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + "time" + + "golang.org/x/crypto/bcrypt" +) + +// take-two F-15 (v0.123.0) — the reset-request response path. The hub returns the freshly rotated +// code state in the reset-request response; the controller applies it through the SAME +// generation-guarded consumer as the report ACK (report.ClaimSync), so the emailed code is +// accepted IMMEDIATELY — no ~15-minute wait for the next ACK. + +// fixtureHub serves a canned reset-request response and records that it was hit. +func fixtureHub(t *testing.T, respBody string) (*httptest.Server, *int) { + t.Helper() + hits := 0 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/v1/claim/reset-request" { + t.Errorf("unexpected hub path %s", r.URL.Path) + } + if r.Header.Get("Authorization") != "Bearer test-key" { + t.Errorf("reset-request missing the box's own report key") + } + hits++ + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, respBody) + })) + t.Cleanup(srv.Close) + return srv, &hits +} + +func (s *Server) postClaim(code, pw string) *httptest.ResponseRecorder { + form := url.Values{"_csrf": {s.claimCSRFToken()}, "code": {code}, "new_password": {pw}, "confirm_password": {pw}} + req := httptest.NewRequest(http.MethodPost, "/claim", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.AddCookie(&http.Cookie{Name: claimCSRFCookie, Value: s.claimCSRFToken()}) + rr := httptest.NewRecorder() + s.handleClaimSubmit(rr, req) + return rr +} + +// The response's rotated hash is applied in the same request cycle: the NEW code claims the box +// immediately, and the OLD (pre-rotation) code is dead. Red-proof: drop the response handling in +// requestHubResetCode → the immediate acceptance fails ("Hibás vagy lejárt kód"). +func TestResetRequest_ResponseHashAppliedImmediately(t *testing.T) { + s, oldCode, sett := claimTestServer(t) + + newCode := "friss-teszt-kod" + newHash, _ := bcrypt.GenerateFromPassword([]byte(newCode), 10) + resp, _ := json.Marshal(map[string]interface{}{ + "status": "ok", + "claim": map[string]interface{}{ + "code_hash": string(newHash), + "generation": 2, + "issued_at": time.Now().UTC().Format(time.RFC3339), + }, + }) + hub, hits := fixtureHub(t, string(resp)) + s.cfg.Hub.URL = hub.URL + s.cfg.Hub.APIKey = "test-key" + + s.requestHubResetCode() // synchronous here; the handler fires it in a goroutine + if *hits != 1 { + t.Fatalf("hub reset-request hits = %d, want 1", *hits) + } + + // The effect: settings now hold the rotated state (the ACK consumer's exact write). + gotHash, gotGen, _ := sett.GetClaimCode() + if gotGen != 2 || gotHash != string(newHash) { + t.Fatalf("settings after reset-request: gen=%d hash-is-new=%v, want gen 2 with the response hash", gotGen, gotHash == string(newHash)) + } + + // End-to-end: the emailed code works NOW — and the pre-rotation code is dead. + if rr := s.postClaim(oldCode, "hosszu-jelszo-123"); rr.Code == http.StatusFound { + t.Fatal("the OLD code still claims the box after rotation") + } + if rr := s.postClaim(newCode, "hosszu-jelszo-123"); rr.Code != http.StatusFound { + t.Fatalf("the NEW code must be accepted immediately, got %d (body: %.200s)", rr.Code, rr.Body.String()) + } + if !sett.GetClaimed() { + t.Fatal("box not marked claimed after the immediate-code claim") + } +} + +// Generation guard (reuse, not reimplementation): a replayed/older-generation response must NOT +// downgrade the active hash — the exact non-effect is asserted. +func TestResetRequest_OlderGenerationResponseIsNoOp(t *testing.T) { + s, _, sett := claimTestServer(t) + origHash, origGen, origIssued := sett.GetClaimCode() // gen 1 from the harness + + staleHash, _ := bcrypt.GenerateFromPassword([]byte("regi-lejart-kod"), 10) + resp, _ := json.Marshal(map[string]interface{}{ + "status": "ok", + "claim": map[string]interface{}{ + "code_hash": string(staleHash), + "generation": origGen, // same generation = replay; <= is refused by ClaimSync + "issued_at": time.Now().UTC().Format(time.RFC3339), + }, + }) + hub, _ := fixtureHub(t, string(resp)) + s.cfg.Hub.URL = hub.URL + s.cfg.Hub.APIKey = "test-key" + + s.requestHubResetCode() + + gotHash, gotGen, gotIssued := sett.GetClaimCode() + if gotHash != origHash || gotGen != origGen || gotIssued != origIssued { + t.Fatalf("stale response mutated the active code state: gen %d→%d hash-changed=%v", origGen, gotGen, gotHash != origHash) + } +} + +// An old hub's bare {"status":"ok"} (no claim object) is a clean no-op — mixed-fleet safety. +func TestResetRequest_LegacyHubResponseIsNoOp(t *testing.T) { + s, _, sett := claimTestServer(t) + origHash, origGen, _ := sett.GetClaimCode() + + hub, hits := fixtureHub(t, `{"status":"ok"}`) + s.cfg.Hub.URL = hub.URL + s.cfg.Hub.APIKey = "test-key" + + s.requestHubResetCode() + if *hits != 1 { + t.Fatalf("hub hits = %d, want 1", *hits) + } + gotHash, gotGen, _ := sett.GetClaimCode() + if gotHash != origHash || gotGen != origGen { + t.Fatal("legacy hub response mutated the code state") + } +}