Files
felhom.eu/hub/internal/store/customer_reset_test.go
T
admin 4009401f46 hub v0.61.0 + felhom-tenantsync v1.1.0: Customer RESET (middle lifecycle tier)
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.
2026-07-17 13:09:04 +02:00

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")
}
}