package localapi import ( "encoding/json" "errors" "net/http" "strings" "testing" "time" ) // R-1151 — the new recovery code replaces the hub's package only after the household TYPED IT BACK. Before, the // claim uploaded it (R-978), so a household that opened the new code and closed the page without writing it down // had already replaced the package their old code opens. These tests ask the hub's current package the household's // question — „does my written-down code still open it?". type deferredClaim struct { code int r string positions []int required bool body string } func claimDeferred(t *testing.T, h http.Handler) deferredClaim { t.Helper() w := do(t, h, "POST", "/escrow/ceremony/claim", "A", `{"defer_upload":true}`) var env struct { Data struct { RecoveryCode string `json:"recovery_code"` ConfirmRequired bool `json:"confirm_required"` VerifyPositions []int `json:"verify_positions"` } `json:"data"` } _ = json.Unmarshal(w.Body.Bytes(), &env) return deferredClaim{w.Code, env.Data.RecoveryCode, env.Data.VerifyPositions, env.Data.ConfirmRequired, w.Body.String()} } // typedBack is what a household that wrote the code down types for the claim's two positions. func (c deferredClaim) typedBack(t *testing.T) []string { t.Helper() words := strings.Split(c.r, "-") if len(c.positions) != 2 { t.Fatalf("the claim named %d positions, want 2: %s", len(c.positions), c.body) } return []string{words[c.positions[0]-1], words[c.positions[1]-1]} } func confirm(t *testing.T, h http.Handler, words []string) int { t.Helper() b, _ := json.Marshal(map[string]any{"words": words}) return do(t, h, "POST", "/escrow/ceremony/confirm", "A", string(b)).Code } // THE R-1151 CASE. Ceremony 1 is claimed and typed back (the household's code 1). Ceremony 2 is started and its code // SHOWN (claimed) — and then the page is closed: the words never come back. Three ways it ends: the confirm window // runs out, a new start supersedes it, the agent restarts. In every case code 1 must still open the hub's package. // // COMPANION RED-PROOF (observed 2026-10-19): ignore the claim's defer_upload (`if false && req.DeferUpload …`, so // every claim takes the R-978 order and uploads at once) → all three subtests FAIL with "a code that was shown but // never typed back replaced the package: code 1 no longer opens it (uploads=2)". Restored. func TestEscrowCeremony_R1151_ShownButNotTypedBackKeepsFirstCode(t *testing.T) { setup := func(t *testing.T) (*Server, *fakeEscrowHub, http.Handler, *time.Time) { srv, hub := newEscrowTestServerHub(t, sequenceRunner(firstR, secondR, "third-code-charlie"), &fakeEscrowHub{}) cur := testNow srv.now = func() time.Time { return cur } h := srv.Handler() startAndWait(t, srv, h) c1 := claimDeferred(t, h) if c1.code != http.StatusOK || c1.r != firstR { t.Fatalf("deferred claim of ceremony 1: %d %s", c1.code, c1.body) } if c1.required { // the household types code 1 back if code := confirm(t, h, c1.typedBack(t)); code != http.StatusOK { t.Fatalf("typing code 1 back: %d", code) } } if !hub.opensWith(firstR) || hub.count() != 1 { t.Fatalf("after ceremony 1 the hub must hold code 1's package (uploads=%d)", hub.count()) } startAndWait(t, srv, h) if c2 := claimDeferred(t, h); c2.code != http.StatusOK || c2.r != secondR { t.Fatalf("deferred claim of ceremony 2: %d %s", c2.code, c2.body) } // …shown, and the page is closed return srv, hub, h, &cur } check := func(t *testing.T, hub *fakeEscrowHub) { t.Helper() if !hub.opensWith(firstR) { t.Fatalf("a code that was shown but never typed back replaced the package: code 1 no longer opens it (uploads=%d)", hub.count()) } if hub.opensWith(secondR) || hub.count() != 1 { t.Fatalf("a package for the never-confirmed code 2 reached the hub (uploads=%d)", hub.count()) } } t.Run("the confirm window runs out", func(t *testing.T) { _, hub, h, cur := setup(t) *cur = cur.Add(escrowConfirmTTL + time.Minute) check(t, hub) st := do(t, h, "GET", "/escrow/ceremony/status", "A", "").Body.String() if !strings.Contains(st, `"phase":"unconfirmed_void"`) || !strings.Contains(st, `"awaiting_confirm":false`) { t.Fatalf("after the window the job must read unconfirmed_void: %s", st) } if code := confirm(t, h, []string{"second", "bravo"}); code != http.StatusGone { t.Fatalf("a confirm after the window: got %d, want 410", code) } }) t.Run("a new start supersedes it", func(t *testing.T) { srv, hub, h, _ := setup(t) startAndWait(t, srv, h) if code := confirm(t, h, []string{"second", "bravo"}); code != http.StatusNotFound { t.Fatalf("a confirm for the superseded code: got %d, want 404", code) } check(t, hub) }) t.Run("the agent restarts", func(t *testing.T) { _, hub, _, _ := setup(t) srv2, _ := newEscrowTestServerHub(t, sequenceRunner("unused"), hub) if code := confirm(t, srv2.Handler(), []string{"second", "bravo"}); code != http.StatusNotFound { t.Fatalf("a confirm after a restart: got %d, want 404", code) } check(t, hub) }) } // The positive control and the typing: wrong words store nothing and the wait goes on; the right words (any case, // spaces around) store the package, once. // // COMPANION RED-PROOF (observed 2026-10-19): skip the word check (`if match != 1 && false`) → this fails with "one // wrong word: got 200 (uploads=1), want 422 and nothing stored". Restored. func TestEscrowCeremony_R1151_TypedBackStoresThePackage(t *testing.T) { srv, hub := newEscrowTestServerHub(t, sequenceRunner(secondR), &fakeEscrowHub{}) h := srv.Handler() startAndWait(t, srv, h) c := claimDeferred(t, h) if c.code != http.StatusOK || !c.required || hub.count() != 0 { t.Fatalf("a deferred claim must hand out the code and store nothing yet: %d required=%v uploads=%d", c.code, c.required, hub.count()) } right := c.typedBack(t) if code := confirm(t, h, []string{right[0], "wrong"}); code != http.StatusUnprocessableEntity || hub.count() != 0 { t.Fatalf("one wrong word: got %d (uploads=%d), want 422 and nothing stored", code, hub.count()) } if code := confirm(t, h, []string{right[0]}); code != http.StatusBadRequest { t.Fatalf("one word only: got %d, want 400", code) } st := do(t, h, "GET", "/escrow/ceremony/status", "A", "").Body.String() if !strings.Contains(st, `"awaiting_confirm":true`) || !strings.Contains(st, `"uploaded":false`) { t.Fatalf("a wrong try must leave the wait open: %s", st) } assertNoRIn(t, "status while awaiting the words", st, secondR) for _, w := range right { assertNoRIn(t, "status while awaiting the words", st, `"`+w+`"`) } if code := confirm(t, h, []string{" " + strings.ToUpper(right[0]), right[1] + " "}); code != http.StatusOK { t.Fatalf("the right words: got %d, want 200", code) } if !hub.opensWith(secondR) || hub.count() != 1 { t.Fatalf("after the words came back the hub must hold code 2's package (uploads=%d)", hub.count()) } if code := confirm(t, h, right); code != http.StatusNotFound || hub.count() != 1 { t.Fatalf("a second confirm: got %d (uploads=%d), want 404 and no second upload", code, hub.count()) } if st := do(t, h, "GET", "/escrow/ceremony/status", "A", "").Body.String(); !strings.Contains(st, `"uploaded":true`) { t.Fatalf("status after the confirm: %s", st) } } // A controller that has not updated (0.331.0 and older) claims with `{}` and never confirms: it keeps the R-978 // order — the package is stored at the claim — so its household's ceremony still completes. func TestEscrowCeremony_R1151_OldControllerKeepsTheClaimOrder(t *testing.T) { for _, body := range []string{"", "{}"} { srv, hub := newEscrowTestServerHub(t, sequenceRunner(firstR), &fakeEscrowHub{}) h := srv.Handler() startAndWait(t, srv, h) w := do(t, h, "POST", "/escrow/ceremony/claim", "A", body) if w.Code != http.StatusOK || !strings.Contains(w.Body.String(), firstR) || !strings.Contains(w.Body.String(), `"confirm_required":false`) { t.Fatalf("old-shape claim %q: %d %s", body, w.Code, w.Body.String()) } if !hub.opensWith(firstR) || hub.count() != 1 { t.Fatalf("old-shape claim %q must store the package at once (uploads=%d)", body, hub.count()) } if code := confirm(t, h, []string{"first", "alpha"}); code != http.StatusNotFound { t.Fatalf("nothing waits after an old-shape claim: got %d, want 404", code) } } } // The hub failing at the confirm keeps the wait open and the previous package current; a retry succeeds. func TestEscrowCeremony_R1151_UploadFailureAtConfirmKeepsWaiting(t *testing.T) { hub := &fakeEscrowHub{err: errors.New("hub returned HTTP 502")} srv, _ := newEscrowTestServerHub(t, sequenceRunner(secondR), hub) h := srv.Handler() startAndWait(t, srv, h) c := claimDeferred(t, h) if code := confirm(t, h, c.typedBack(t)); code != http.StatusBadGateway { t.Fatalf("confirm with the hub failing: got %d, want 502", code) } hub.mu.Lock() hub.err = nil hub.mu.Unlock() if code := confirm(t, h, c.typedBack(t)); code != http.StatusOK || !hub.opensWith(secondR) { t.Fatalf("retry after the hub recovered: %d (opens: %v)", code, hub.opensWith(secondR)) } } // The two positions are distinct, in range, smaller first — over many draws every position appears. func TestPickConfirmPositions(t *testing.T) { seen := map[int]bool{} for i := 0; i < 2000; i++ { p, err := pickConfirmPositions(10) if err != nil { t.Fatal(err) } if p[0] < 1 || p[1] > 10 || p[0] >= p[1] { t.Fatalf("bad positions %v", p) } seen[p[0]], seen[p[1]] = true, true } if len(seen) != 10 { t.Fatalf("only positions %v were ever drawn", seen) } }