mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-07-09 16:00:59 +00:00
Record iOS mobile readiness proof
This commit is contained in:
parent
1e164f35f2
commit
8e0fdd090c
8 changed files with 439 additions and 7 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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": [],
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue