package store import ( "io" "log" "path/filepath" "testing" ) func newResetStore(t *testing.T) *Store { t.Helper() st, err := New(filepath.Join(t.TempDir(), "t.db"), log.New(io.Discard, "", 0)) if err != nil { t.Fatalf("store.New: %v", err) } t.Cleanup(func() { st.Close() }) return st } // seedRetainedBlob leaves a superseded escrow blob reachable only via F-14 host_deletions provenance // (host deleted, blob retained) — the customer-RESET escrow-custody target. func seedRetainedBlob(t *testing.T, st *Store, customerID string) { t.Helper() hostID := customerID + "-h1" if err := st.UpsertHost(&Host{HostID: hostID, CustomerID: customerID, APIKey: "h"}); err != nil { t.Fatalf("upsert host: %v", err) } if _, _, err := st.SaveHostEscrow(hostID, []byte("A"), "fpA", "p", "2026-01-01T00:00:00Z", "shaA"); err != nil { t.Fatalf("escrow A: %v", err) } if _, _, err := st.SaveHostEscrow(hostID, []byte("B"), "fpB", "p", "2026-01-02T00:00:00Z", "shaB"); err != nil { t.Fatalf("escrow B: %v", err) } if err := st.DeleteHost(hostID, true); err != nil { t.Fatalf("delete host: %v", err) } } // The store-level ack contract: PurgeCustomerResetDBState must NEVER destroy retained escrow custody // unless escrowAck is true — even while it clears the rest of the operational state. (The web handler // gates ack separately; this proves the store primitive is itself honest.) func TestPurgeCustomerResetDBState_AckGatesEscrow(t *testing.T) { st := newResetStore(t) if err := st.SaveCustomerConfig(&CustomerConfig{CustomerID: "acme", APIKey: "capi", RetrievalPassword: "pw"}); err != nil { t.Fatalf("seed config: %v", err) } seedRetainedBlob(t, st, "acme") if err := st.SaveOneTimeSecret("acme", "ots"); err != nil { t.Fatalf("seed ots: %v", err) } if err := st.SaveDRRecipeHostHalf("acme", "acme-h1", 1, []byte("half")); err != nil { t.Fatalf("seed dr: %v", err) } // ack=false: everything else goes, blobs SURVIVE. if err := st.PurgeCustomerResetDBState("acme", false); err != nil { t.Fatalf("purge (no ack): %v", err) } inv, err := st.CustomerResetInventory("acme") if err != nil { t.Fatal(err) } if inv.SupersededBlobs == 0 { t.Error("ack=false destroyed retained escrow custody") } if inv.OneTimeSecretPresent || inv.DRRecipePresent { t.Errorf("ack=false failed to clear the non-escrow state: %+v", inv) } // ack=true: blobs go too. if err := st.PurgeCustomerResetDBState("acme", true); err != nil { t.Fatalf("purge (ack): %v", err) } if inv, _ := st.CustomerResetInventory("acme"); inv.SupersededBlobs != 0 { t.Errorf("ack=true failed to destroy retained custody: %d", inv.SupersededBlobs) } // Identity SURVIVES the purge. if cfg, _ := st.GetCustomerConfig("acme"); cfg == nil { t.Error("purge destroyed customer_config — identity must survive") } } // The journal round-trips: open → per-leg status → finish, and Latest reflects it. func TestCustomerResetJournal_RoundTrip(t *testing.T) { st := newResetStore(t) id, err := st.StartCustomerReset("acme", true) if err != nil { t.Fatalf("start: %v", err) } if err := st.UpdateResetLeg(id, "hetzner", "ok"); err != nil { t.Fatalf("leg: %v", err) } if err := st.UpdateResetLeg(id, "pbs", "failed"); err != nil { t.Fatalf("leg: %v", err) } if err := st.UpdateResetLeg(id, "pbs", "ok"); err != nil { // overwrite on re-run t.Fatalf("leg: %v", err) } cr, err := st.LatestCustomerReset("acme") if err != nil || cr == nil { t.Fatalf("latest: %v / %v", cr, err) } if cr.CompletedAt != nil { t.Error("not finished yet — CompletedAt should be nil") } if !cr.EscrowAcked || cr.Legs["hetzner"] != "ok" || cr.Legs["pbs"] != "ok" { t.Errorf("journal wrong: %+v", cr) } if err := st.FinishCustomerReset(id); err != nil { t.Fatalf("finish: %v", err) } if cr, _ := st.LatestCustomerReset("acme"); cr.CompletedAt == nil { t.Error("finish did not stamp CompletedAt") } }