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") } }