package localapi import ( "context" "errors" "net/http" "strings" "testing" "time" ) // R-978 — starting the escrow ceremony again must not make the customer's written-down recovery code // useless. The hub overwrites the current package when the sealed secrets are unchanged, so the NEW // package may reach the hub only when its code is claimed. These tests ask the customer's question of // the hub's current package — „does my code still open it?" — not whether some function ran. const ( firstR = "first-ceremony-code-alpha" secondR = "second-ceremony-code-bravo" ) // sequenceRunner mints the given codes in order, one per ceremony. func sequenceRunner(codes ...string) ceremonyRunner { i := 0 return func(context.Context) ([]byte, []byte, int, error) { r := codes[i] if i < len(codes)-1 { i++ } return []byte(cannedCeremonyJSON(r)), nil, 0, nil } } func claimCode(t *testing.T, h http.Handler) (int, string) { t.Helper() w := do(t, h, "POST", "/escrow/ceremony/claim", "A", "") return w.Code, w.Body.String() } // THE R-978 CASE. Ceremony 1 is claimed (the customer writes code 1 down). Ceremony 2 is started and // abandoned — three ways: its code expires unclaimed, a third start supersedes it, the agent restarts. // In every case the hub's current package must still open with code 1, and nothing was uploaded for 2. // // COMPANION RED-PROOF (observed 2026-10-18): restore the pre-R-978 shape — the package uploaded when the // ceremony finishes (s.escrowCeremony.Upload(base, out.PendingUpload) in the job goroutine, as the root // subprocess did with `--upload`) and nothing left for the claim (finishEscrowCeremony(job, R, nil)) → // all three subtests FAIL with "the abandoned second ceremony replaced the package: code 1 no longer // opens it (uploads=2)" (uploads=3 for the superseding start). Restored. func TestEscrowCeremony_R978_AbandonedSecondCeremonyKeepsFirstCode(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) if code, body := claimCode(t, h); code != http.StatusOK || !strings.Contains(body, firstR) { t.Fatalf("claim of ceremony 1: %d %s", code, body) } if !hub.opensWith(firstR) || hub.count() != 1 { t.Fatalf("after the first claim the hub must hold code 1's package (uploads=%d)", hub.count()) } startAndWait(t, srv, h) // ceremony 2 — about to be abandoned return srv, hub, h, &cur } check := func(t *testing.T, hub *fakeEscrowHub) { t.Helper() if !hub.opensWith(firstR) { t.Fatalf("the abandoned second ceremony 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-claimed code 2 reached the hub (uploads=%d)", hub.count()) } } t.Run("code 2 expires unclaimed", func(t *testing.T) { _, hub, h, cur := setup(t) *cur = cur.Add(escrowClaimTTL + time.Minute) if code, _ := claimCode(t, h); code != http.StatusGone { t.Fatalf("claim after TTL: got %d, want 410", code) } check(t, hub) }) t.Run("a third start supersedes code 2", func(t *testing.T) { srv, hub, h, _ := setup(t) startAndWait(t, srv, h) // ceremony 3, also left unclaimed check(t, hub) }) t.Run("the agent restarts before code 2 is claimed", func(t *testing.T) { _, hub, _, _ := setup(t) // A restart is a new process with an empty slot; only the hub's record survives it. srv2, _ := newEscrowTestServerHub(t, sequenceRunner("unused"), hub) if code, _ := claimCode(t, srv2.Handler()); code != http.StatusNotFound { t.Fatalf("claim after a restart: got %d, want 404", code) } check(t, hub) }) } // The positive control: CLAIMING code 2 is what replaces the package — the code handed out opens the // package the hub now holds, and the claim answers only after the hub took it. func TestEscrowCeremony_R978_ClaimStoresThePackageBeforeHandingOutTheCode(t *testing.T) { srv, hub := newEscrowTestServerHub(t, sequenceRunner(firstR, secondR), &fakeEscrowHub{}) h := srv.Handler() startAndWait(t, srv, h) if hub.count() != 0 { t.Fatalf("the package was uploaded before any claim (uploads=%d)", hub.count()) } claimCode(t, h) startAndWait(t, srv, h) code, body := claimCode(t, h) if code != http.StatusOK || !strings.Contains(body, secondR) { t.Fatalf("claim of ceremony 2: %d %s", code, body) } if !hub.opensWith(secondR) || hub.count() != 2 { t.Fatalf("after claiming code 2 the hub must hold its package (uploads=%d, opens with 2: %v)", hub.count(), hub.opensWith(secondR)) } } // A failed upload hands out NO code, keeps the code claimable and the previous package current; a retry // that reaches the hub succeeds. func TestEscrowCeremony_R978_UploadFailureKeepsTheCodeClaimable(t *testing.T) { hub := &fakeEscrowHub{err: errors.New("hub returned HTTP 502")} srv, _ := newEscrowTestServerHub(t, sequenceRunner(firstR), hub) h := srv.Handler() startAndWait(t, srv, h) code, body := claimCode(t, h) if code != http.StatusBadGateway { t.Fatalf("claim with the hub failing: got %d, want 502 (%s)", code, body) } assertNoRIn(t, "failed-upload claim payload", body, firstR) if st := do(t, h, "GET", "/escrow/ceremony/status", "A", "").Body.String(); !strings.Contains(st, `"claimable":true`) || !strings.Contains(st, `"uploaded":false`) { t.Fatalf("after a failed upload the code must stay claimable and un-uploaded: %s", st) } hub.mu.Lock() hub.err = nil hub.mu.Unlock() code, body = claimCode(t, h) if code != http.StatusOK || !strings.Contains(body, firstR) || !hub.opensWith(firstR) { t.Fatalf("retry after the hub recovered: %d %s (opens: %v)", code, body, hub.opensWith(firstR)) } } // While the claim's upload is in flight, neither the TTL nor a new start may take the code away — else // the hub would hold a package whose code nobody was given (the R-978 failure, reached the other way). func TestEscrowCeremony_R978_TTLAndRestartWaitForAnInFlightClaim(t *testing.T) { hub := &fakeEscrowHub{gate: make(chan struct{}), entered: make(chan struct{})} entered := hub.entered srv, _ := newEscrowTestServerHub(t, sequenceRunner(firstR, secondR), hub) var mu = make(chan struct{}, 1) mu <- struct{}{} cur := testNow srv.now = func() time.Time { <-mu; defer func() { mu <- struct{}{} }(); return cur } h := srv.Handler() startAndWait(t, srv, h) type res struct { code int body string } done := make(chan res, 1) go func() { c, b := claimCode(t, h); done <- res{c, b} }() <-entered <-mu cur = cur.Add(escrowClaimTTL + time.Minute) // the TTL passes mid-upload mu <- struct{}{} if st := do(t, h, "GET", "/escrow/ceremony/status", "A", "").Body.String(); strings.Contains(st, `"phase":"unclaimed_void"`) { t.Fatalf("the TTL voided the code while its package was uploading: %s", st) } if w := do(t, h, "POST", "/escrow/ceremony", "A", ""); w.Code != http.StatusConflict { t.Fatalf("a new start during the claim's upload: got %d, want 409", w.Code) } if code, _ := claimCode(t, h); code != http.StatusConflict { t.Fatalf("a second claim during the upload: got %d, want 409", code) } close(hub.gate) r := <-done if r.code != http.StatusOK || !strings.Contains(r.body, firstR) || !hub.opensWith(firstR) { t.Fatalf("the in-flight claim must deliver the code whose package it stored: %d %s", r.code, r.body) } } // No upload configured → the claim refuses rather than hand out a code for a package the hub lacks. func TestEscrowCeremony_R978_NoUploaderRefusesTheClaim(t *testing.T) { srv, _ := newEscrowTestServerHub(t, sequenceRunner(firstR), &fakeEscrowHub{}) srv.escrowCeremony.Upload = nil h := srv.Handler() startAndWait(t, srv, h) code, body := claimCode(t, h) if code != http.StatusServiceUnavailable { t.Fatalf("claim with no uploader: got %d, want 503 (%s)", code, body) } assertNoRIn(t, "no-uploader claim payload", body, firstR) } // An output that uploaded by itself (the pre-R-978 shape) or carries no package fails the ceremony. func TestEscrowCeremony_R978_OutputWithoutPendingPackageFails(t *testing.T) { srv, _ := newEscrowTestServerHub(t, func(context.Context) ([]byte, []byte, int, error) { return []byte(`{"version":2,"recovery_code":"` + firstR + `","uploaded":true}`), nil, 0, nil }, &fakeEscrowHub{}) h := srv.Handler() startAndWait(t, srv, h) st := do(t, h, "GET", "/escrow/ceremony/status", "A", "").Body.String() if !strings.Contains(st, `"phase":"failed"`) || !strings.Contains(st, "no pending package") { t.Fatalf("want failed: %s", st) } if code, _ := claimCode(t, h); code != http.StatusConflict { t.Fatalf("claim on a failed ceremony: got %d, want 409", code) } } func assertNoRIn(t *testing.T, where, body, r string) { t.Helper() if strings.Contains(body, r) { t.Fatalf("%s carries the recovery code", where) } }