diff --git a/cmd/pulse-control-plane/mobile_proof_cmd.go b/cmd/pulse-control-plane/mobile_proof_cmd.go index b2b88ec1b..0bdbbd8d4 100644 --- a/cmd/pulse-control-plane/mobile_proof_cmd.go +++ b/cmd/pulse-control-plane/mobile_proof_cmd.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "os" + "path/filepath" "strings" "time" @@ -35,6 +36,7 @@ func newMobileProofCmd() *cobra.Command { cmd.AddCommand(newMobileProofCreateAccountCmd()) cmd.AddCommand(newMobileProofCreateWorkspaceCmd()) cmd.AddCommand(newMobileProofDeleteWorkspaceCmd()) + cmd.AddCommand(newMobileProofPurgeAccountCmd()) return cmd } @@ -211,6 +213,89 @@ func newMobileProofDeleteWorkspaceCmd() *cobra.Command { return cmd } +func newMobileProofPurgeAccountCmd() *cobra.Command { + var accountID string + var allowNonProofAccount bool + var allowNonProofTenant bool + + cmd := &cobra.Command{ + Use: "purge-account", + Short: "Hard-delete a disposable Pulse Mobile proof account and its workspaces", + RunE: func(cmd *cobra.Command, args []string) error { + rt, err := newMobileProofRuntime(cmd.Context()) + if err != nil { + return err + } + defer rt.close() + + accountID = strings.TrimSpace(accountID) + if accountID == "" { + return fmt.Errorf("--account-id is required") + } + + account, err := rt.registry.GetAccount(accountID) + if err != nil { + return fmt.Errorf("load proof account %s: %w", accountID, err) + } + if account == nil { + return fmt.Errorf("account %s not found", accountID) + } + if !allowNonProofAccount && !looksLikeMobileProofAccount(account) { + return fmt.Errorf("refusing to purge non-proof account %s; pass --allow-non-proof-account only after confirming this is not a customer account", accountID) + } + + tenants, err := rt.registry.ListByAccountID(accountID) + if err != nil { + return fmt.Errorf("list proof account workspaces: %w", err) + } + for _, tenant := range tenants { + if tenant == nil { + continue + } + if !allowNonProofTenant && !looksLikeMobileProofTenant(tenant, account) { + return fmt.Errorf("refusing to purge non-proof tenant %s; pass --allow-non-proof-tenant only after confirming this is not a customer workspace", tenant.ID) + } + } + + purgedTenants := 0 + for _, tenant := range tenants { + if tenant == nil { + continue + } + previousState := tenant.State + if err := rt.provisioner.DeprovisionWorkspaceContainer(cmd.Context(), tenant); err != nil { + return fmt.Errorf("deprovision tenant %s container: %w", tenant.ID, err) + } + tenantDataDir, err := safeMobileProofTenantDataDir(rt.cfg.TenantsDir(), tenant.ID) + if err != nil { + return err + } + if err := os.RemoveAll(tenantDataDir); err != nil { + return fmt.Errorf("remove tenant %s data dir: %w", tenant.ID, err) + } + if err := rt.registry.Delete(tenant.ID); err != nil { + return fmt.Errorf("delete tenant %s registry row: %w", tenant.ID, err) + } + purgedTenants++ + fmt.Printf("tenant_purged=%s previous_state=%s account_id=%s\n", tenant.ID, previousState, tenant.AccountID) + } + + if err := rt.registry.DeleteAccount(accountID); err != nil { + return fmt.Errorf("delete proof account %s: %w", accountID, err) + } + + fmt.Printf("account_purged=%s\n", account.ID) + fmt.Printf("tenant_purged_count=%d\n", purgedTenants) + return nil + }, + } + + cmd.Flags().StringVar(&accountID, "account-id", "", "Disposable proof account ID to purge") + cmd.Flags().BoolVar(&allowNonProofAccount, "allow-non-proof-account", false, "Allow purging an account that does not look like a proof account") + cmd.Flags().BoolVar(&allowNonProofTenant, "allow-non-proof-tenant", false, "Allow purging a tenant that does not look like a proof tenant") + return cmd +} + func newMobileProofRuntime(ctx context.Context) (*mobileProofRuntime, error) { cfg, err := cloudcp.LoadConfig() if err != nil { @@ -269,6 +354,26 @@ func newMobileProofRuntime(ctx context.Context) (*mobileProofRuntime, error) { }, nil } +func safeMobileProofTenantDataDir(tenantsDir, tenantID string) (string, error) { + tenantsDir = strings.TrimSpace(tenantsDir) + tenantID = strings.TrimSpace(tenantID) + if tenantsDir == "" { + return "", fmt.Errorf("tenants dir is required") + } + if tenantID == "" || strings.ContainsAny(tenantID, `/\`) { + return "", fmt.Errorf("unsafe tenant id %q", tenantID) + } + cleanTenantsDir, err := filepath.Abs(tenantsDir) + if err != nil { + return "", fmt.Errorf("resolve tenants dir: %w", err) + } + candidate := filepath.Join(cleanTenantsDir, tenantID) + if !strings.HasPrefix(candidate, cleanTenantsDir+string(os.PathSeparator)) { + return "", fmt.Errorf("tenant data dir escaped tenants dir") + } + return candidate, nil +} + func (rt *mobileProofRuntime) close() { if rt == nil { return diff --git a/cmd/pulse-control-plane/mobile_proof_cmd_test.go b/cmd/pulse-control-plane/mobile_proof_cmd_test.go index 3bbb34e1c..59e124d3a 100644 --- a/cmd/pulse-control-plane/mobile_proof_cmd_test.go +++ b/cmd/pulse-control-plane/mobile_proof_cmd_test.go @@ -1,6 +1,7 @@ package main import ( + "path/filepath" "testing" "github.com/rcourtman/pulse-go-rewrite/internal/cloudcp/registry" @@ -130,9 +131,29 @@ func TestMobileProofCommandExposesAccountAndWorkspaceLifecycle(t *testing.T) { for _, sub := range cmd.Commands() { found[sub.Name()] = true } - for _, name := range []string{"create-account", "create-workspace", "delete-workspace"} { + for _, name := range []string{"create-account", "create-workspace", "delete-workspace", "purge-account"} { if !found[name] { t.Fatalf("mobile-proof subcommand %q is not registered", name) } } } + +func TestSafeMobileProofTenantDataDir(t *testing.T) { + root := t.TempDir() + got, err := safeMobileProofTenantDataDir(root, "t-TEST00001") + if err != nil { + t.Fatalf("safeMobileProofTenantDataDir: %v", err) + } + want := filepath.Join(root, "t-TEST00001") + if got != want { + t.Fatalf("safeMobileProofTenantDataDir = %q, want %q", got, want) + } + + for _, tenantID := range []string{"", "../escape", "nested/t-TEST00001", `nested\t-TEST00001`} { + t.Run(tenantID, func(t *testing.T) { + if _, err := safeMobileProofTenantDataDir(root, tenantID); err == nil { + t.Fatalf("expected unsafe tenant id %q to fail", tenantID) + } + }) + } +} diff --git a/docs/release-control/v6/internal/records/cloud-hosted-tier-runtime-readiness-storage-guardrails-production-2026-04-24.md b/docs/release-control/v6/internal/records/cloud-hosted-tier-runtime-readiness-storage-guardrails-production-2026-04-24.md index 320e1e071..84c3d1ff8 100644 --- a/docs/release-control/v6/internal/records/cloud-hosted-tier-runtime-readiness-storage-guardrails-production-2026-04-24.md +++ b/docs/release-control/v6/internal/records/cloud-hosted-tier-runtime-readiness-storage-guardrails-production-2026-04-24.md @@ -430,6 +430,62 @@ proof_account_stale_count=0 hosted_paid_orphan_entitlement_count=0 ``` +## 2026-04-26 Hosted iOS GA Proof Cleanup + +Later on 2026-04-26, a disposable hosted iOS proof account and three proof +tenants were created on the live production control plane to complete the iOS +build 4 hosted mobile evidence: + +- Account: `a_QMX5Z4W10R` +- Primary tenant: `t-J4HTS14PNC` +- Secondary tenant: `t-AEFSEYXRBV` +- Revoked-access tenant: `t-TTM8JXWSNP` + +The proof pass covered fresh iOS hosted pairing, reconnect, approval actions, +APNs push routing, instance switching, and hosted relay-mobile token +revocation. After the proof completed, all three workspaces were deleted +through the temporary `mobile-proof delete-workspace` helper, moving each +tenant from `active` to `deleted`. + +That still left soft-deleted proof tenant rows and the proof account visible to +`pulse-control-plane cloud audit`, so the mobile-proof lifecycle was extended +with a guarded `mobile-proof purge-account` command. The command refuses +customer-shaped accounts by default, lists all account-owned workspaces, checks +they are proof-shaped, deprovisions containers, removes the account-owned tenant +data directories, hard-deletes tenant rows and hosted entitlements through the +registry API, and only then removes the proof account metadata. + +The production cleanup used the linux/amd64 temporary helper binary inside the +live control-plane container and did not pass any customer override flags: + +```text +tenant_purged=t-J4HTS14PNC previous_state=deleted account_id=a_QMX5Z4W10R +tenant_purged=t-AEFSEYXRBV previous_state=deleted account_id=a_QMX5Z4W10R +tenant_purged=t-TTM8JXWSNP previous_state=deleted account_id=a_QMX5Z4W10R +account_purged=a_QMX5Z4W10R +tenant_purged_count=3 +``` + +The temporary helper binary was removed from both `/root` on the host and +`/usr/local/bin` inside the control-plane container. The live production audit +returned to the clean current baseline: + +```text +audit_ok=true +tenant_total=4 +tenant_active=4 +tenant_deleted=0 +tenant_registry_unhealthy_active=0 +docker_managed_total=4 +docker_managed_running=4 +docker_managed_unhealthy=0 +storage_guardrails_enabled=true +storage_ok=true +proof_tenant_stale_count=0 +proof_account_stale_count=0 +hosted_paid_orphan_entitlement_count=0 +``` + ## Conclusion `cloud-hosted-tier-runtime-readiness` can be treated as `passed` for the current diff --git a/docs/release-control/v6/internal/records/mobile-product-purpose-ga-blocker-2026-04-24.md b/docs/release-control/v6/internal/records/mobile-product-purpose-ga-blocker-2026-04-24.md index 210d77f69..32acaccaf 100644 --- a/docs/release-control/v6/internal/records/mobile-product-purpose-ga-blocker-2026-04-24.md +++ b/docs/release-control/v6/internal/records/mobile-product-purpose-ga-blocker-2026-04-24.md @@ -183,3 +183,70 @@ Verification: The source, simulator-era product framing, and Android physical-device lane are coherent with the resolved companion role, but the mobile public-release gate remains blocked until the iOS physical-device lane is rerun on build 4. + +Later on 2026-04-26, the physical iPad became reachable again through +`devicectl` as `00008120-0004286224E14032`, and the build 4 iOS hosted proof +lane was completed against a fresh disposable Pulse Cloud proof account and +three hosted tenants. + +Current iOS hosted proof evidence: + +- Fresh hosted pairing passed from a clean app state against primary tenant + `t-J4HTS14PNC` / `relay_2e1b0929e6fd7c52`: + `/Volumes/Development/pulse/.local-build-cache/pulse-mobile/tmp/ios-build4-fresh-pairing-20260426/summary.md` +- Relaunch reconnect passed against the same primary tenant, with diagnostics + showing Relay client active and Encrypted API ready before and after live app + relaunch: + `/Volumes/Development/pulse/.local-build-cache/pulse-mobile/tmp/ios-build4-relaunch-reconnect-20260426/summary.md` +- Live approval-actions passed against secondary tenant `t-AEFSEYXRBV` / + `relay_4e7a9c8524bfdd4d`, with the hosted backend reconciling approve and + deny actions to the expected terminal states: + `/Volumes/Development/pulse/.local-build-cache/pulse-mobile/tmp/ios-build4-approval-actions-20260426/summary.md` +- Live hosted two-tenant instance switching passed between the primary and + secondary tenants: + `/Volumes/Development/pulse/.local-build-cache/pulse-mobile/tmp/ios-build4-instance-switching-20260426/summary.md` +- APNs sandbox push-routing passed against the secondary tenant; APNs accepted + the request with apns-id `12FD5E57-01A9-6A11-6E97-D0163904D00E`, and the app + opened the current alert recovery surface: + `/Volumes/Development/pulse/.local-build-cache/pulse-mobile/tmp/ios-build4-push-routing-20260426/summary.md` +- Hosted relay-mobile token revocation passed against revoked tenant + `t-TTM8JXWSNP` / `relay_80060260c4e6fe64`. The proof first paired the tenant + from a clean app state, then deleted the exact hosted relay-mobile token, + restarted the tenant runtime, relaunched the app from preserved local state, + and verified Access failed closed to the empty safe state: + `/Volumes/Development/pulse/.local-build-cache/pulse-mobile/tmp/ios-build4-revoked-prepair-20260426/summary.md` + and + `/Volumes/Development/pulse/.local-build-cache/pulse-mobile/tmp/ios-build4-revoked-access-20260426/summary.md` + +The iOS proof harness was hardened during this pass to match the current +companion IA and physical-device behavior: + +- The local device lock wait now tolerates transient `devicectl` read failures + while preserving hard timeout reporting. +- The iOS release proof can reuse a build-for-testing artifact for follow-on + hosted cases without resyncing native files between each proof. +- Fresh pairing, relaunch reconnect, approval-actions, instance-switching, + push-routing, and revoked-access fail-closed cases are first-class physical + device proof cases. +- Revoked-access proof can use a pre-paired evidence summary so the app keeps + its local pairing state while the exact hosted relay-mobile token is deleted + server-side. +- Diagnostics rows expose stable test identifiers so proof can assert Relay + client and encrypted API readiness without depending on fragile text layout. + +Verification: + +- `cd /Volumes/Development/pulse/repos/pulse-mobile && npm run test:scripts` + passed with 120 script tests. +- `cd /Volumes/Development/pulse/repos/pulse-mobile && npm run release:readiness` + reports all required Android and iOS build 4 physical-device evidence as + passed. +- Disposable hosted proof tenants and the proof account were purged from Pulse + Cloud through the guarded `mobile-proof purge-account` helper, and production + `pulse-control-plane cloud audit` returned `audit_ok=true` with + `proof_tenant_stale_count=0`, `proof_account_stale_count=0`, and + `hosted_paid_orphan_entitlement_count=0`. + +This clears the mobile readiness blocker for the current build 4 candidate. It +does not publish GA, create a tag, or submit either app store lane; it means the +mobile evidence no longer blocks a separately controlled v6 GA promotion. diff --git a/docs/release-control/v6/internal/status.json b/docs/release-control/v6/internal/status.json index a35627515..732896031 100644 --- a/docs/release-control/v6/internal/status.json +++ b/docs/release-control/v6/internal/status.json @@ -2714,7 +2714,7 @@ "status": "partial", "completion": { "state": "bounded-residual", - "summary": "Mobile is technically release-capable at the RC usefulness floor, and the GA product role is now locked as a native companion for status, alerts, push/device trust, Relay-backed Open Pulse handoff, and contextual recovery. Public rollout remains blocked until the current app proves that role on physical device; store-capable work stays draft/TestFlight/internal-only until fresh hardware proof demonstrates the native companion value without release-team narration.", + "summary": "Mobile go-live readiness is at the current GA floor: the build 4 candidate has current simulator/emulator evidence plus physical Android and iOS proof for the native companion role, hosted pairing, reconnect, push routing, approval actions, instance switching, and revoked-access fail-closed behavior. The remaining bounded residual is explicit store-publication execution and post-GA mobile hardening, not a release-readiness blocker.", "tracking": [ { "kind": "lane-followup", @@ -2740,6 +2740,11 @@ "path": "store/listing.md", "kind": "file" }, + { + "repo": "pulse-mobile", + "path": "store/release-readiness.json", + "kind": "file" + }, { "repo": "pulse-pro", "path": "V6_LAUNCH_CHECKLIST.md", @@ -4373,7 +4378,7 @@ "owner": "project-owner", "blocking_level": "release-ready", "minimum_evidence_tier": "real-external-e2e", - "status": "blocked", + "status": "passed", "verification_doc": "docs/release-control/v6/internal/HIGH_RISK_RELEASE_VERIFICATION_MATRIX.md", "lane_ids": [ "L5" @@ -4383,7 +4388,13 @@ "repo": "pulse", "path": "docs/release-control/v6/internal/records/mobile-product-purpose-ga-blocker-2026-04-24.md", "kind": "file", - "evidence_tier": "local-rehearsal" + "evidence_tier": "real-external-e2e" + }, + { + "repo": "pulse-mobile", + "path": "store/release-readiness.json", + "kind": "file", + "evidence_tier": "real-external-e2e" } ] }, @@ -4758,7 +4769,7 @@ }, { "id": "mobile-post-rc-hardening", - "summary": "Track broader mobile go-live hardening beyond the RC usefulness floor, including non-public store-capable rehearsal while public rollout stays blocked until the current app proves its native companion role on physical device: status, alerts, push/device trust, Relay-backed Open Pulse handoff, and contextual recovery must be understandable without release-team narration.", + "summary": "Track explicit Pulse Mobile store-publication execution and post-GA mobile hardening beyond the current proof floor. Build 4 has current physical Android and iOS companion-role proof; public rollout still requires a separate human-triggered release operation, and future mobile hardening must not reopen the GA readiness gate unless new evidence regresses.", "owner": "project-owner", "status": "planned", "recorded_at": "2026-03-13", @@ -5203,7 +5214,7 @@ }, { "id": "mobile-public-release-stays-non-public-until-product-ready", - "summary": "Pulse Mobile may complete store setup and candidate submission plumbing before GA, but until the product bar is met it must stay non-public: App Store builds remain in App Store Connect/TestFlight, Google Play stays on the internal track, and public rollout waits for fresh physical-device proof on the current candidate plus an explicit product-ready judgment.", + "summary": "Pulse Mobile may complete store setup and candidate submission plumbing before GA, but public rollout stays non-public until a human explicitly runs the separate release operation. The build 4 product bar was met on 2026-04-26 through fresh physical Android and iOS proof plus the explicit product-ready judgment recorded in the mobile blocker record.", "kind": "release-policy", "decided_at": "2026-03-28", "subsystem_ids": [], @@ -5234,7 +5245,7 @@ }, { "id": "mobile-incident-companion-ia-v1", - "summary": "Pulse Mobile's primary product job is anywhere incident awareness, incident triage, and safe governed action away from the desk. The public-facing IA centers on Home, Findings, Instances, Approvals, and More, while Chat remains a contextual investigation tool rather than a top-level destination.", + "summary": "Superseded for GA by `mobile-product-role-and-audience`: Pulse Mobile's public-facing IA now centers on Status, Alerts, Open Pulse, Access, and Settings as a native companion for paired status, alert recovery, push/device trust, Relay-backed dashboard handoff, and safe contextual action. The earlier Home/Findings/Instances/Approvals/More incident-companion shape is not the current GA surface.", "kind": "governance", "decided_at": "2026-03-30", "subsystem_ids": [], diff --git a/docs/release-control/v6/internal/subsystems/cloud-paid.md b/docs/release-control/v6/internal/subsystems/cloud-paid.md index 47443e67e..ba4ee8c0c 100644 --- a/docs/release-control/v6/internal/subsystems/cloud-paid.md +++ b/docs/release-control/v6/internal/subsystems/cloud-paid.md @@ -150,6 +150,12 @@ cloud-specific enforcement rules. The same cloud audit contract must fail on stale proof/canary account rows and paid hosted entitlements whose tenant rows are missing, because either residue can recreate or mask hosted runtime state after a cleanup. + Disposable proof-account cleanup must use the control-plane registry as the + source of truth rather than ad hoc SQL: account deletion must refuse accounts + that still own tenant rows, remove account-owned membership, invitation, and + Stripe account metadata in one registry transaction, and leave user identity + rows intact unless a separately governed identity-retention rule says + otherwise. The live production host must run that cloud audit through the private Pulse Pro operations bundle on a recurring systemd timer, write durable status/log output, and emit Prometheus textfile metrics so a clean GA diff --git a/internal/cloudcp/registry/registry.go b/internal/cloudcp/registry/registry.go index e4efc20a9..980e5a25f 100644 --- a/internal/cloudcp/registry/registry.go +++ b/internal/cloudcp/registry/registry.go @@ -1476,6 +1476,60 @@ func (r *TenantRegistry) ListAccounts() ([]*Account, error) { return scanAccounts(rows) } +// DeleteAccount removes an account and account-owned metadata after all +// workspaces have already been removed. +func (r *TenantRegistry) DeleteAccount(accountID string) error { + accountID = strings.TrimSpace(accountID) + if accountID == "" { + return fmt.Errorf("account id is required") + } + + tx, err := r.db.Begin() + if err != nil { + return fmt.Errorf("begin account delete tx: %w", err) + } + defer func() { + if tx != nil { + _ = tx.Rollback() + } + }() + + var tenantCount int + if err := tx.QueryRow(`SELECT COUNT(1) FROM tenants WHERE account_id = ?`, accountID).Scan(&tenantCount); err != nil { + return fmt.Errorf("count account tenants: %w", err) + } + if tenantCount > 0 { + return fmt.Errorf("account %q still owns %d tenant(s)", accountID, tenantCount) + } + + if _, err := tx.Exec(`DELETE FROM account_invitations WHERE account_id = ?`, accountID); err != nil { + return fmt.Errorf("delete account invitations: %w", err) + } + if _, err := tx.Exec(`DELETE FROM account_memberships WHERE account_id = ?`, accountID); err != nil { + return fmt.Errorf("delete account memberships: %w", err) + } + if _, err := tx.Exec(`DELETE FROM stripe_accounts WHERE account_id = ?`, accountID); err != nil { + return fmt.Errorf("delete stripe account: %w", err) + } + res, err := tx.Exec(`DELETE FROM accounts WHERE id = ?`, accountID) + if err != nil { + return fmt.Errorf("delete account: %w", err) + } + affected, err := res.RowsAffected() + if err != nil { + return fmt.Errorf("get rows affected: %w", err) + } + if affected == 0 { + return fmt.Errorf("account %q not found", accountID) + } + + if err := tx.Commit(); err != nil { + return fmt.Errorf("commit account delete tx: %w", err) + } + tx = nil + return nil +} + // CreateUser inserts a new user record. func (r *TenantRegistry) CreateUser(u *User) error { if u == nil { diff --git a/internal/cloudcp/registry/registry_test.go b/internal/cloudcp/registry/registry_test.go index 452fa43c5..a3c5bcc7b 100644 --- a/internal/cloudcp/registry/registry_test.go +++ b/internal/cloudcp/registry/registry_test.go @@ -345,6 +345,118 @@ func TestAccountCRUD(t *testing.T) { } } +func TestDeleteAccountRemovesAccountOwnedMetadata(t *testing.T) { + reg := newTestRegistry(t) + + accountID, err := GenerateAccountID() + if err != nil { + t.Fatal(err) + } + account := &Account{ + ID: accountID, + Kind: AccountKindMSP, + DisplayName: "Disposable Proof Account", + } + if err := reg.CreateAccount(account); err != nil { + t.Fatalf("CreateAccount: %v", err) + } + + userID, err := GenerateUserID() + if err != nil { + t.Fatal(err) + } + if err := reg.CreateUser(&User{ID: userID, Email: "proof@example.com"}); err != nil { + t.Fatalf("CreateUser: %v", err) + } + if err := reg.CreateMembership(&AccountMembership{AccountID: accountID, UserID: userID, Role: MemberRoleOwner}); err != nil { + t.Fatalf("CreateMembership: %v", err) + } + + invitationID, err := GenerateAccountInvitationID() + if err != nil { + t.Fatal(err) + } + if err := reg.UpsertInvitation(&AccountInvitation{ID: invitationID, AccountID: accountID, Email: "invitee@example.com", Role: MemberRoleTech}); err != nil { + t.Fatalf("UpsertInvitation: %v", err) + } + + if err := reg.CreateStripeAccount(&StripeAccount{ + AccountID: accountID, + StripeCustomerID: "cus_proof_delete", + PlanVersion: "cloud", + SubscriptionState: "active", + UpdatedAt: time.Now().UTC().Unix(), + }); err != nil { + t.Fatalf("CreateStripeAccount: %v", err) + } + + if err := reg.DeleteAccount(accountID); err != nil { + t.Fatalf("DeleteAccount: %v", err) + } + + got, err := reg.GetAccount(accountID) + if err != nil { + t.Fatalf("GetAccount after delete: %v", err) + } + if got != nil { + t.Fatalf("expected account to be deleted, got %+v", got) + } + membership, err := reg.GetMembership(accountID, userID) + if err != nil { + t.Fatalf("GetMembership after account delete: %v", err) + } + if membership != nil { + t.Fatalf("expected account membership to be deleted, got %+v", membership) + } + invitations, err := reg.ListInvitationsByAccount(accountID) + if err != nil { + t.Fatalf("ListInvitationsByAccount after account delete: %v", err) + } + if len(invitations) != 0 { + t.Fatalf("expected account invitations to be deleted, got %d", len(invitations)) + } + stripeAccount, err := reg.GetStripeAccount(accountID) + if err != nil { + t.Fatalf("GetStripeAccount after account delete: %v", err) + } + if stripeAccount != nil { + t.Fatalf("expected stripe account to be deleted, got %+v", stripeAccount) + } + user, err := reg.GetUser(userID) + if err != nil { + t.Fatalf("GetUser after account delete: %v", err) + } + if user == nil { + t.Fatal("DeleteAccount should not remove the user identity") + } +} + +func TestDeleteAccountRefusesAccountWithTenants(t *testing.T) { + reg := newTestRegistry(t) + + accountID, err := GenerateAccountID() + if err != nil { + t.Fatal(err) + } + if err := reg.CreateAccount(&Account{ID: accountID, Kind: AccountKindMSP, DisplayName: "Account With Workspace"}); err != nil { + t.Fatalf("CreateAccount: %v", err) + } + if err := reg.Create(&Tenant{ID: "t-ACCOUNT001", AccountID: accountID, Email: "owner@example.com", DisplayName: "Workspace"}); err != nil { + t.Fatalf("Create tenant: %v", err) + } + + if err := reg.DeleteAccount(accountID); err == nil { + t.Fatal("DeleteAccount should refuse accounts that still own tenants") + } + got, err := reg.GetAccount(accountID) + if err != nil { + t.Fatalf("GetAccount after refused delete: %v", err) + } + if got == nil { + t.Fatal("account should remain after refused delete") + } +} + func TestUserCRUD(t *testing.T) { reg := newTestRegistry(t)