controller: F-15 instant reset codes — reset-request response hash applied via the ACK's generation-guarded ClaimSync (emailed code works immediately; old-hub bare response = no-op)
Claude-Session: https://claude.ai/code/session_01GzammAMzsJTgpQHqxwM2bC
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user