| index cda333a2c..0534b16a5 100644 |
| --- a/internal/console/apicli_test.go |
| +++ b/internal/console/apicli_test.go |
| @@ -1277,6 +1277,17 @@ func TestAPITrashAndRestoreApp(t *testing.T) { |
| if has, err := fx.st.HasTrashedAppIn(OpsAccountID, "titogo"); err != nil || !has { |
| t.Fatalf("HasTrashedAppIn after trash = %v %v, want true", has, err) |
| } |
| + // One clock, and it is the MARKER's: the auto-purge pass measures the 30 |
| + // days from there, so a row stamped with anything else would give the |
| + // Trash page a "days remaining" nothing honours. |
| + trashed, err := fx.st.TrashedAppsIn(OpsAccountID) |
| + if err != nil || len(trashed) != 1 || trashed[0].App != "titogo" { |
| + t.Fatalf("TrashedAppsIn = %+v %v, want exactly titogo", trashed, err) |
| + } |
| + if trashed[0].DeletedAt.Unix() != tomb.DeletedAt.Unix() { |
| + t.Fatalf("row deleted_at = %s, marker deleted_at = %s — they must be the same clock", |
| + trashed[0].DeletedAt, tomb.DeletedAt) |
| + } |
| |
| // Every other door resolves through the same live-apps-only lookup, so a |
| // trashed app is simply not there any more. |
| @@ -1367,6 +1378,84 @@ func TestAPITrashTwiceKeepsTheClock(t *testing.T) { |
| } |
| } |
| |
| +// blindMarkerStore writes the trash marker happily and then refuses to read |
| +// it back — the shape a half-broken bucket (or a permissions split) takes, |
| +// and the one case where this handler has written something it cannot |
| +// describe. |
| +type blindMarkerStore struct{ release.Store } |
| + |
| +func (b blindMarkerStore) Get(ctx context.Context, key string) ([]byte, string, error) { |
| + if strings.HasSuffix(key, "/deleted.json") { |
| + return nil, "", errors.New("AccessDenied: s3://carlos-prod-secret/deleted.json request id ABC123") |
| + } |
| + return b.Store.Get(ctx, key) |
| +} |
| + |
| +// TestAPITrashUnconfirmedWindowIsRefused: when the marker cannot be re-read, |
| +// the handler must not invent a deadline. Auto-purge destroys at the |
| +// MARKER's clock, and the row's own clock never self-heals (materialize |
| +// short-circuits on an already-trashed row, TrashAppRow is guarded by |
| +// deleted_at = 0) — so a guess here is a wrong "days remaining" forever. |
| +// 502, nothing written, and the retry produces the truth. |
| +func TestAPITrashUnconfirmedWindowIsRefused(t *testing.T) { |
| + fx, token := trashFixture(t, "code-trash-unconfirmed") |
| + // The app was already trashed headless — the marker exists with its own |
| + // (old) clock while this console's row is still live, so the API call |
| + // below takes TrashApp's idempotent path. |
| + old := time.Now().Add(-9 * 24 * time.Hour) |
| + if err := release.TrashApp(context.Background(), fx.rel, release.OpsAccount, "titogo", "paul@keymail.dev", old); err != nil { |
| + t.Fatal(err) |
| + } |
| + fx.srv.cfg.Releases = blindMarkerStore{fx.rel} |
| + |
| + status, body := postBody(t, fx.ts.URL+"/api/cli/apps/bab/titogo/delete", token, "") |
| + if status != http.StatusBadGateway { |
| + t.Fatalf("delete with an unreadable marker = %d %s, want 502", status, body) |
| + } |
| + if !strings.Contains(string(body), "try again") { |
| + t.Fatalf("refusal = %s, want it to invite the retry that produces the true window", body) |
| + } |
| + if strings.Contains(string(body), "AccessDenied") || strings.Contains(string(body), "s3://") { |
| + t.Fatalf("refusal leaks the store's own error text: %s", body) |
| + } |
| + // The row must be untouched: a row stamped now would outlive the marker's |
| + // real deadline and could never be corrected. |
| + if has, err := fx.st.HasTrashedAppIn(OpsAccountID, "titogo"); err != nil || has { |
| + t.Fatalf("HasTrashedAppIn after an unconfirmed trash = %v %v, want false", has, err) |
| + } |
| + if has, err := fx.st.HasAppIn(OpsAccountID, "titogo"); err != nil || !has { |
| + t.Fatalf("HasAppIn after an unconfirmed trash = %v %v, want the row still live", has, err) |
| + } |
| + |
| + // The retry, against a bucket that answers: the SAME idempotent path, |
| + // now reporting the marker's original window and stamping the row with it. |
| + fx.srv.cfg.Releases = fx.rel |
| + status, out := postBody(t, fx.ts.URL+"/api/cli/apps/bab/titogo/delete", token, "") |
| + if status != http.StatusOK { |
| + t.Fatalf("retry delete = %d %s, want 200", status, out) |
| + } |
| + var res struct { |
| + RestorableUntil string `json:"restorable_until"` |
| + } |
| + if err := json.Unmarshal(out, &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 ORIGINAL marker's window ~%s", until, want) |
| + } |
| + trashed, err := fx.st.TrashedAppsIn(OpsAccountID) |
| + if err != nil || len(trashed) != 1 { |
| + t.Fatalf("TrashedAppsIn = %+v %v, want exactly titogo", trashed, err) |
| + } |
| + if trashed[0].DeletedAt.Unix() != old.UTC().Unix() { |
| + t.Fatalf("row deleted_at = %s, want the marker's own clock %s", trashed[0].DeletedAt, old.UTC()) |
| + } |
| +} |
| + |
| // 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 |