test(domains): a trash marker never crosses accounts — same name, different tenant, untouched
App names are unique within an account and never globally: two tenants legitimately both run a "derp". The removal key is one RouteAccount(rt) away from being a name-only match, and that one word is the difference between deleting an app and deleting a stranger's. Nothing pinned it. Two accounts, one name, one marker, two passes: A's routes go, B's custom route and apex survive with their stamp intact and byte-identical across the second pass.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 file changed,
+63
−0
internal/domains/reconcile_test.go+63 −0
diff --git a/internal/domains/reconcile_test.go b/internal/domains/reconcile_test.go| index 21cb9c3b6..370ecb56c 100644 |
| --- a/internal/domains/reconcile_test.go |
| +++ b/internal/domains/reconcile_test.go |
| @@ -15,6 +15,7 @@ import ( |
| "os" |
| "path/filepath" |
| "reflect" |
| + "slices" |
| "testing" |
| "time" |
| @@ -724,6 +725,68 @@ func TestReconcileTrashedAppWithUnstampedRouteStillDrops(t *testing.T) { |
| } |
| } |
| +// THE safety property of the whole trash mechanism: app names are unique |
| +// within an account and never globally, so two tenants legitimately both run a |
| +// "derp". The removal key is (account, app) — one `release.RouteAccount(rt)` |
| +// away from being a name-only match, and that one word is the difference |
| +// between deleting an app and deleting a stranger's. Account A trashing its |
| +// derp must leave B's derp exactly as it found it. |
| +func TestReconcileTrashMarkerNeverCrossesAccounts(t *testing.T) { |
| + a, b := domains.Sqid(793481920157), domains.Sqid(555000111222) |
| + s, reg := dirStore(t), regWith(t, |
| + registry.Route{Host: "derp.a.custom", Kind: "static", App: "derp", |
| + Status: "ready", Channel: "stable", Account: a}, |
| + registry.Route{Host: "derp." + a + ".oncarlos.test", Kind: "static", App: "derp", |
| + Status: "ready", Channel: "stable", Platform: true, Account: a}, |
| + registry.Route{Host: "derp.b.custom", Kind: "static", App: "derp", |
| + Status: "ready", Channel: "stable", Account: b}, |
| + registry.Route{Host: "derp." + b + ".oncarlos.test", Kind: "static", App: "derp", |
| + Status: "ready", Channel: "stable", Platform: true, Account: b}, |
| + ) |
| + if got := writeAccountManifest(t, s, 793481920157, "Ayy"); got != a { |
| + t.Fatalf("account A sqid = %q, want %q", got, a) |
| + } |
| + if got := writeAccountManifest(t, s, 555000111222, "Bee"); got != b { |
| + t.Fatalf("account B sqid = %q, want %q", got, b) |
| + } |
| + putKey(t, s, "apps/"+a+"/derp/channels/stable.json") |
| + putKey(t, s, "apps/"+b+"/derp/channels/stable.json") |
| + // Only A trashes its derp. |
| + if err := release.TrashApp(ctx, s, a, "derp", "paul@keymail.dev", time.Unix(1753900000, 0)); err != nil { |
| + t.Fatal(err) |
| + } |
| + |
| + wantHosts := []string{"derp.b.custom", "derp." + b + ".oncarlos.test"} |
| + slices.Sort(wantHosts) // reg.List returns hosts in sorted order |
| + var first []registry.Route |
| + for pass := 1; pass <= 2; pass++ { |
| + if err := domains.Reconcile(ctx, s, reg, cfg(t)); err != nil { |
| + t.Fatalf("pass %d: %v", pass, err) |
| + } |
| + got := routesFor(t, reg, "derp") |
| + var hosts []string |
| + for _, rt := range got { |
| + hosts = append(hosts, rt.Host) |
| + if rt.Account != b { |
| + t.Errorf("pass %d: surviving route %s stamped %q, want B (%s)", pass, rt.Host, rt.Account, b) |
| + } |
| + } |
| + if !reflect.DeepEqual(hosts, wantHosts) { |
| + t.Fatalf("pass %d: derp routes = %v, want exactly B's %v — A's marker must not reach B", |
| + pass, hosts, wantHosts) |
| + } |
| + if pass == 1 { |
| + first = got |
| + continue |
| + } |
| + // Byte-identical across the second pass too: no re-stamp, no re-mint, |
| + // no field quietly rewritten under B. |
| + if !reflect.DeepEqual(first, got) { |
| + t.Errorf("second pass changed B's routes:\nfirst %+v\nsecond %+v", first, got) |
| + } |
| + } |
| +} |
| + |
| // A marker we cannot fully believe must not destroy routes: ReadAppTombstone |
| // errors, reconcile logs it and treats the app as live, every pass, until |
| // someone fixes or removes the object. |