| index 464f0c472..cda333a2c 100644 |
| --- a/internal/console/apicli_test.go |
| +++ b/internal/console/apicli_test.go |
| @@ -15,6 +15,7 @@ import ( |
| "crypto/sha256" |
| "encoding/hex" |
| "encoding/json" |
| + "errors" |
| "fmt" |
| "io" |
| "mime/multipart" |
| @@ -1204,3 +1205,314 @@ func TestAPIShipConcurrencyLimit(t *testing.T) { |
| } |
| } |
| } |
| + |
| +// ---- trash and restore ------------------------------------------------------ |
| + |
| +// trashFixture is a console with titogo claimed in the ops account and a |
| +// MEMBER (not owner) token for it: trashing is a member action on purpose — |
| +// the 30-day window is what makes the reversible verb cheap. |
| +func trashFixture(t *testing.T, code string) (*consoleFixture, string) { |
| + t.Helper() |
| + fx := newConsoleFixture(t) |
| + now := time.Now() |
| + if _, err := fx.st.CreateAppIn(OpsAccountID, "titogo", "paul@keymail.dev", now); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := fx.st.AddMember(OpsAccountID, "alice@keymail.dev", RoleMember, now); err != nil { |
| + t.Fatal(err) |
| + } |
| + return fx, cliToken(t, fx, "alice@keymail.dev", code) |
| +} |
| + |
| +// tombstone reads the trash marker straight from the fixture's own bucket — |
| +// never through s.cfg.Releases, which some tests below deliberately break. |
| +func tombstone(t *testing.T, fx *consoleFixture, app string) *release.AppTombstone { |
| + t.Helper() |
| + tomb, err := release.ReadAppTombstone(context.Background(), fx.rel, release.OpsAccount, app) |
| + if err != nil { |
| + t.Fatalf("read trash marker for %s: %v", app, err) |
| + } |
| + return tomb |
| +} |
| + |
| +// TestAPITrashAndRestoreApp is the whole round trip: a member trashes an app, |
| +// the marker lands in the BUCKET (which is where trash state actually |
| +// lives), the row follows, every other CLI door stops answering for that |
| +// app — and restore puts it all back. |
| +func TestAPITrashAndRestoreApp(t *testing.T) { |
| + fx, token := trashFixture(t, "code-trash-roundtrip") |
| + |
| + status, out := postBody(t, fx.ts.URL+"/api/cli/apps/bab/titogo/delete", token, "") |
| + if status != http.StatusOK { |
| + t.Fatalf("delete = %d %s, want 200", status, out) |
| + } |
| + var res struct { |
| + Result string `json:"result"` |
| + RestorableUntil string `json:"restorable_until"` |
| + } |
| + if err := json.Unmarshal(out, &res); err != nil { |
| + t.Fatalf("delete body %s: %v", out, err) |
| + } |
| + if res.Result != "trashed" { |
| + t.Fatalf("delete result = %q, want trashed", res.Result) |
| + } |
| + until, err := time.Parse(time.RFC3339, res.RestorableUntil) |
| + if err != nil { |
| + t.Fatalf("restorable_until %q: %v", res.RestorableUntil, err) |
| + } |
| + if want := time.Now().Add(release.TrashCooloff); until.Sub(want) > time.Minute || want.Sub(until) > time.Minute { |
| + t.Fatalf("restorable_until = %s, want ~%s (now + the cooloff)", until, want) |
| + } |
| + |
| + tomb := tombstone(t, fx, "titogo") |
| + if tomb == nil { |
| + t.Fatal("no trash marker in the bucket after a 200 delete") |
| + } |
| + if tomb.Actor != "alice@keymail.dev" { |
| + t.Fatalf("marker actor = %q, want the token's own identity", tomb.Actor) |
| + } |
| + if has, err := fx.st.HasAppIn(OpsAccountID, "titogo"); err != nil || has { |
| + t.Fatalf("HasAppIn after trash = %v %v, want false", has, err) |
| + } |
| + if has, err := fx.st.HasTrashedAppIn(OpsAccountID, "titogo"); err != nil || !has { |
| + t.Fatalf("HasTrashedAppIn after trash = %v %v, want true", has, err) |
| + } |
| + |
| + // Every other door resolves through the same live-apps-only lookup, so a |
| + // trashed app is simply not there any more. |
| + if status, body := getRaw(t, fx.ts.URL+"/api/cli/apps/bab/titogo/channels", token); status != http.StatusNotFound { |
| + t.Fatalf("channels on a trashed app = %d %s, want 404", status, body) |
| + } |
| + if status, body := postBody(t, fx.ts.URL+"/api/cli/apps/bab/titogo/promote", token, |
| + `{"version":"v1","channel":"canary/eu"}`); status != http.StatusNotFound { |
| + t.Fatalf("promote to a trashed app = %d %s, want 404", status, body) |
| + } |
| + if status, body := postBody(t, fx.ts.URL+"/api/cli/apps/bab/titogo/releases", token, ""); status != http.StatusNotFound { |
| + t.Fatalf("ship to a trashed app = %d %s, want 404", status, body) |
| + } |
| + |
| + status, out = postBody(t, fx.ts.URL+"/api/cli/apps/bab/titogo/restore", token, "") |
| + if status != http.StatusOK { |
| + t.Fatalf("restore = %d %s, want 200", status, out) |
| + } |
| + var back struct { |
| + Result string `json:"result"` |
| + Note string `json:"note"` |
| + } |
| + if err := json.Unmarshal(out, &back); err != nil { |
| + t.Fatalf("restore body %s: %v", out, err) |
| + } |
| + if back.Result != "restored" { |
| + t.Fatalf("restore result = %q, want restored", back.Result) |
| + } |
| + // Restore resurrects NO routes (spec §3, the anti-ghost rule) — the |
| + // answer has to say so, because nothing else will. |
| + if !strings.Contains(back.Note, "carlos add") { |
| + t.Fatalf("restore note = %q, want it to name `carlos add`", back.Note) |
| + } |
| + if tomb := tombstone(t, fx, "titogo"); tomb != nil { |
| + t.Fatalf("trash marker survived a restore: %+v", tomb) |
| + } |
| + if has, err := fx.st.HasAppIn(OpsAccountID, "titogo"); err != nil || !has { |
| + t.Fatalf("HasAppIn after restore = %v %v, want true", has, err) |
| + } |
| + if has, err := fx.st.HasTrashedAppIn(OpsAccountID, "titogo"); err != nil || has { |
| + t.Fatalf("HasTrashedAppIn after restore = %v %v, want false", has, err) |
| + } |
| + if status, body := getRaw(t, fx.ts.URL+"/api/cli/apps/bab/titogo/channels", token); status != http.StatusOK { |
| + t.Fatalf("channels after restore = %d %s, want 200", status, body) |
| + } |
| +} |
| + |
| +// TestAPITrashTwiceKeepsTheClock: the second trash must not buy another 30 |
| +// days. TrashApp is create-only, so the answer has to come from the marker |
| +// that is actually there, not from this request's own clock. |
| +func TestAPITrashTwiceKeepsTheClock(t *testing.T) { |
| + fx, token := trashFixture(t, "code-trash-twice") |
| + |
| + status, first := postBody(t, fx.ts.URL+"/api/cli/apps/bab/titogo/delete", token, "") |
| + if status != http.StatusOK { |
| + t.Fatalf("first delete = %d %s", status, first) |
| + } |
| + // The row is trashed now, so the second call resolves through the trashed |
| + // door… which delete does NOT use: a re-delete of a trashed app is a 404, |
| + // exactly like every other verb. Restore it first, trash it again, and |
| + // assert the marker's original clock is what comes back — the marker is |
| + // deliberately not re-written by a second trash. |
| + if status, out := postBody(t, fx.ts.URL+"/api/cli/apps/bab/titogo/restore", token, ""); status != http.StatusOK { |
| + t.Fatalf("restore = %d %s", status, out) |
| + } |
| + // Write a marker by hand with an OLD timestamp, then trash through the |
| + // API: the API must report the existing marker's window, not a fresh one. |
| + old := time.Now().Add(-10 * 24 * time.Hour) |
| + if err := release.TrashApp(context.Background(), fx.rel, release.OpsAccount, "titogo", "paul@keymail.dev", old); err != nil { |
| + t.Fatal(err) |
| + } |
| + status, second := postBody(t, fx.ts.URL+"/api/cli/apps/bab/titogo/delete", token, "") |
| + if status != http.StatusOK { |
| + t.Fatalf("second delete = %d %s, want 200 (idempotent)", status, second) |
| + } |
| + var res struct { |
| + RestorableUntil string `json:"restorable_until"` |
| + } |
| + if err := json.Unmarshal(second, &res); err != nil { |
| + t.Fatal(err) |
| + } |
| + until, err := time.Parse(time.RFC3339, res.RestorableUntil) |
| + if err != nil { |
| + t.Fatalf("restorable_until %q: %v", res.RestorableUntil, err) |
| + } |
| + if want := old.Add(release.TrashCooloff); until.Sub(want) > time.Minute || want.Sub(until) > time.Minute { |
| + t.Fatalf("restorable_until = %s, want the EXISTING marker's window ~%s", until, want) |
| + } |
| +} |
| + |
| +// TestAPITrashNonMemberIs404: a token holder who is not a member of the |
| +// account learns nothing at all — not that the account exists, not that the |
| +// app does, and not that either has a trash. Byte-identical to the 404 a |
| +// member gets for a name that does not exist. |
| +func TestAPITrashNonMemberIs404(t *testing.T) { |
| + fx, member := trashFixture(t, "code-trash-member") |
| + stranger := cliToken(t, fx, "bob@keymail.dev", "code-trash-stranger") |
| + |
| + mStatus, mBody := postBody(t, fx.ts.URL+"/api/cli/apps/bab/nosuchapp/delete", member, "") |
| + sStatus, sBody := postBody(t, fx.ts.URL+"/api/cli/apps/bab/titogo/delete", stranger, "") |
| + if mStatus != http.StatusNotFound || sStatus != http.StatusNotFound { |
| + t.Fatalf("delete: member/unknown-app = %d, stranger/real-app = %d, want 404 both", mStatus, sStatus) |
| + } |
| + if string(mBody) != string(sBody) { |
| + t.Fatalf("delete 404s differ:\n member: %q\n stranger: %q", mBody, sBody) |
| + } |
| + // The restore door answers the same way, through its own resolver. |
| + mStatus, mBody = postBody(t, fx.ts.URL+"/api/cli/apps/bab/nosuchapp/restore", member, "") |
| + sStatus, sBody = postBody(t, fx.ts.URL+"/api/cli/apps/bab/titogo/restore", stranger, "") |
| + if mStatus != http.StatusNotFound || sStatus != http.StatusNotFound { |
| + t.Fatalf("restore: member/unknown-app = %d, stranger/real-app = %d, want 404 both", mStatus, sStatus) |
| + } |
| + if string(mBody) != string(sBody) { |
| + t.Fatalf("restore 404s differ:\n member: %q\n stranger: %q", mBody, sBody) |
| + } |
| + if tomb := tombstone(t, fx, "titogo"); tomb != nil { |
| + t.Fatalf("a stranger's delete wrote a marker: %+v", tomb) |
| + } |
| + if has, _ := fx.st.HasAppIn(OpsAccountID, "titogo"); !has { |
| + t.Fatal("a stranger's delete trashed the row") |
| + } |
| +} |
| + |
| +// TestAPIRestoreLiveAppIs404: restore answers only for apps that are |
| +// actually in the trash — a live app is not restorable, and saying so with |
| +// anything other than the standard 404 would make this door an oracle for |
| +// trash state. |
| +func TestAPIRestoreLiveAppIs404(t *testing.T) { |
| + fx, token := trashFixture(t, "code-restore-live") |
| + |
| + status, body := postBody(t, fx.ts.URL+"/api/cli/apps/bab/titogo/restore", token, "") |
| + if status != http.StatusNotFound { |
| + t.Fatalf("restore of a live app = %d %s, want 404", status, body) |
| + } |
| + if _, unknown := postBody(t, fx.ts.URL+"/api/cli/apps/bab/nosuchapp/restore", token, ""); string(unknown) != string(body) { |
| + t.Fatalf("restore 404s differ:\n live: %q\n unknown: %q", body, unknown) |
| + } |
| +} |
| + |
| +// TestAPITrashProductionRefused: the production flag IS the deletion |
| +// protection (spec §5) — no checkbox, no break-glass. The refusal has to |
| +// name the off-switch, because "no" without a way forward is a support |
| +// ticket. |
| +func TestAPITrashProductionRefused(t *testing.T) { |
| + fx, token := trashFixture(t, "code-trash-production") |
| + if err := release.SetProduction(context.Background(), fx.rel, fx.priv, |
| + release.OpsAccount, "titogo", true, "paul@keymail.dev"); err != nil { |
| + t.Fatal(err) |
| + } |
| + |
| + status, body := postBody(t, fx.ts.URL+"/api/cli/apps/bab/titogo/delete", token, "") |
| + if status != http.StatusConflict { |
| + t.Fatalf("delete of a production app = %d %s, want 409", status, body) |
| + } |
| + if !strings.Contains(string(body), "production") { |
| + t.Fatalf("refusal = %s, want it to name the production off-switch", body) |
| + } |
| + if tomb := tombstone(t, fx, "titogo"); tomb != nil { |
| + t.Fatalf("a refused delete wrote a marker: %+v", tomb) |
| + } |
| + if has, _ := fx.st.HasAppIn(OpsAccountID, "titogo"); !has { |
| + t.Fatal("a refused delete trashed the row") |
| + } |
| +} |
| + |
| +// unreadableProductionStore breaks exactly one read — the production flag's |
| +// object — which is the state anyone with bucket write but no signing key |
| +// can create at will. |
| +type unreadableProductionStore struct{ release.Store } |
| + |
| +func (u unreadableProductionStore) Get(ctx context.Context, key string) ([]byte, string, error) { |
| + if strings.HasSuffix(key, "/production.json") { |
| + return nil, "", errors.New("AccessDenied: s3://carlos-prod-secret/production.json request id ABC123") |
| + } |
| + return u.Store.Get(ctx, key) |
| +} |
| + |
| +// TestAPITrashProductionUnreadableRefused: when the bucket cannot say "not |
| +// production", the platform acts as if it had said "production" (spec §5) — |
| +// and NOTHING is trashed on the way out. |
| +func TestAPITrashProductionUnreadableRefused(t *testing.T) { |
| + fx, token := trashFixture(t, "code-trash-unreadable") |
| + fx.srv.cfg.Releases = unreadableProductionStore{fx.rel} |
| + |
| + status, body := postBody(t, fx.ts.URL+"/api/cli/apps/bab/titogo/delete", token, "") |
| + if status != http.StatusConflict { |
| + t.Fatalf("delete with an unreadable flag = %d %s, want 409", status, body) |
| + } |
| + if !strings.Contains(string(body), "production") { |
| + t.Fatalf("refusal = %s, want it to name the production flag", body) |
| + } |
| + if strings.Contains(string(body), "AccessDenied") || strings.Contains(string(body), "s3://") { |
| + t.Fatalf("refusal leaks the store's own error text: %s", body) |
| + } |
| + if tomb := tombstone(t, fx, "titogo"); tomb != nil { |
| + t.Fatalf("a refused delete wrote a marker: %+v", tomb) |
| + } |
| + if has, err := fx.st.HasAppIn(OpsAccountID, "titogo"); err != nil || !has { |
| + t.Fatalf("HasAppIn after a refused delete = %v %v, want the row still live", has, err) |
| + } |
| + if has, _ := fx.st.HasTrashedAppIn(OpsAccountID, "titogo"); has { |
| + t.Fatal("a refused delete trashed the row") |
| + } |
| +} |
| + |
| +// TestAPITrashWithoutAReleaseStore: a console with no bucket cannot trash at |
| +// all. A row-only trash would un-hide itself at the next materialize pass — |
| +// the bucket is where trash state lives. |
| +func TestAPITrashWithoutAReleaseStore(t *testing.T) { |
| + fx, token := trashFixture(t, "code-trash-nobucket") |
| + fx.srv.cfg.Releases = nil |
| + |
| + status, body := postBody(t, fx.ts.URL+"/api/cli/apps/bab/titogo/delete", token, "") |
| + if status != http.StatusServiceUnavailable { |
| + t.Fatalf("delete with no release store = %d %s, want 503", status, body) |
| + } |
| + if has, _ := fx.st.HasAppIn(OpsAccountID, "titogo"); !has { |
| + t.Fatal("a 503 delete trashed the row anyway") |
| + } |
| +} |
| + |
| +// TestAPITrashNoToken: both doors answer an anonymous caller with cliUser's |
| +// one 401 — before any name in the path is resolved. |
| +func TestAPITrashNoToken(t *testing.T) { |
| + fx, _ := trashFixture(t, "code-trash-anon") |
| + |
| + for _, verb := range []string{"delete", "restore"} { |
| + status, body := postBody(t, fx.ts.URL+"/api/cli/apps/bab/titogo/"+verb, "", "") |
| + if status != http.StatusUnauthorized { |
| + t.Fatalf("%s with no token = %d %s, want 401", verb, status, body) |
| + } |
| + if !strings.Contains(string(body), "carlos auth login") { |
| + t.Fatalf("%s with no token = %s, want cliUser's own wording", verb, body) |
| + } |
| + } |
| + if tomb := tombstone(t, fx, "titogo"); tomb != nil { |
| + t.Fatalf("an anonymous delete wrote a marker: %+v", tomb) |
| + } |
| +} |