4009401f46
One operator action returns a customer to pre-first-install: all operational state dies (offsite repo, PBS namespace+backups, DR recipe, one-time secret, claim state, retained escrow custody); identity + basic config + provenance + events survive. Sits between host delete and customer Delete. - store/customer_reset.go: customer_resets journal, live inventory, ack-gated purge (never touches identity/provenance/events), DeleteClaim. - claim.ResetToUnclaimed: delete claim row -> fresh code next onboarding. - offsite.Deprovision (idempotent) + OffsiteIdentifier + ClearProvisionedDescriptor. - tenantsync.Deprovision + felhom-tenantsync.sh deprovision op (destroys ns + backup groups + token; shared user untouched; idempotent). - web/customer_reset.go: GET reset -> inventory JSON; POST -> orchestration (external teardown FIRST, DB purge LAST; refuse-while-hosts; typed-id + separate escrow ack). Amber RESET card distinct from red Danger-zone Delete. - Red-proofs: ack-gate + partial-failure resumability (both proven red); store ack-gating + journal round-trip; offsite idempotency + descriptor clear; RESET-card render. Green: build + vet + test.
116 lines
3.8 KiB
Go
116 lines
3.8 KiB
Go
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")
|
|
}
|
|
}
|