| 1 | package console |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "errors" |
| 6 | "fmt" |
| 7 | "net/http" |
| 8 | "net/url" |
| 9 | "strings" |
| 10 | "testing" |
| 11 | "time" |
| 12 | |
| 13 | "github.com/carlosframework/platform/internal/provision" |
| 14 | "github.com/carlosframework/platform/internal/release" |
| 15 | ) |
| 16 | |
| 17 | // trashedTemp seeds tmp-a, expired, in the Trash with its 3-day window, the |
| 18 | // state the expiry pass leaves. |
| 19 | func trashedTemp(t *testing.T, fx *consoleFixture, app string, at time.Time) { |
| 20 | t.Helper() |
| 21 | tempApp(t, fx, app, at) |
| 22 | if err := release.TrashApp(context.Background(), fx.rel, fx.sqid, app, "expiry", at); err != nil { |
| 23 | t.Fatal(err) |
| 24 | } |
| 25 | if ok, err := fx.st.ExpireAppRow(OpsAccountID, app, at); err != nil || !ok { |
| 26 | t.Fatal(ok, err) |
| 27 | } |
| 28 | } |
| 29 | |
| 30 | // Spec §6 and §9: rewrites expiry before deleting the tombstone, and clears |
| 31 | // the deadline only after. The order is asserted at the moment of the delete. |
| 32 | func TestRestoreRewritesExpiryBeforeTheMarkerGoes(t *testing.T) { |
| 33 | fx := newConsoleFixture(t) |
| 34 | now := time.Now().Truncate(time.Second) |
| 35 | trashedTemp(t, fx, "tmp-a", now.Add(-time.Hour)) |
| 36 | h := &hookStore{Store: fx.rel} |
| 37 | marker := release.AppTombstoneKey(fx.sqid, "tmp-a") |
| 38 | var atDelete *AppLife |
| 39 | h.onDelete = func(key string) { |
| 40 | if key == marker { |
| 41 | atDelete = lifeOf(t, fx, "tmp-a") |
| 42 | } |
| 43 | } |
| 44 | fx.srv.cfg.Releases = h |
| 45 | if err := fx.srv.restoreApp(context.Background(), opsAccount(t, fx), "tmp-a", now); err != nil { |
| 46 | t.Fatal(err) |
| 47 | } |
| 48 | if atDelete == nil { |
| 49 | t.Fatal("the marker was never deleted") |
| 50 | } |
| 51 | if atDelete.ExpiresAt != now.Add(temporaryTTLDefault).Unix() || !atDelete.Restoring || atDelete.PurgeAfter == 0 { |
| 52 | t.Fatalf("row at the marker delete = %+v, want expiry now+7d, restoring, deadline still set", atDelete) |
| 53 | } |
| 54 | if l := lifeOf(t, fx, "tmp-a"); l.Trashed() || l.Restoring || l.PurgeAfter != 0 || l.PurgeFor != 0 { |
| 55 | t.Fatalf("row after = %+v, want live, no deadline", l) |
| 56 | } |
| 57 | } |
| 58 | |
| 59 | // Spec §9: a failed tombstone delete keeps the deadline and releases restoring. |
| 60 | func TestRestoreWhoseMarkerDeleteFailsKeepsTheDeadline(t *testing.T) { |
| 61 | fx := newConsoleFixture(t) |
| 62 | now := time.Now().Truncate(time.Second) |
| 63 | trashedTemp(t, fx, "tmp-a", now.Add(-time.Hour)) |
| 64 | before := *lifeOf(t, fx, "tmp-a") |
| 65 | fx.srv.cfg.Releases = &hookStore{Store: fx.rel, failDelete: map[string]int{release.AppTombstoneKey(fx.sqid, "tmp-a"): 1}} |
| 66 | err := fx.srv.restoreApp(context.Background(), opsAccount(t, fx), "tmp-a", now) |
| 67 | var rf *restoreFailure |
| 68 | if !errors.As(err, &rf) || !rf.Marker { |
| 69 | t.Fatalf("restoreApp = %v, want a marker-half restoreFailure", err) |
| 70 | } |
| 71 | l := lifeOf(t, fx, "tmp-a") |
| 72 | if l.Restoring || !l.Trashed() || l.PurgeAfter != before.PurgeAfter || l.PurgeFor != before.PurgeFor { |
| 73 | t.Fatalf("row = %+v, want trashed with the same deadline and restoring released", l) |
| 74 | } |
| 75 | } |
| 76 | |
| 77 | // Spec §9: a failed step 5 holds the cap slot until settled. |
| 78 | func TestRestoreThatStopsBeforeStepFiveHoldsItsSlot(t *testing.T) { |
| 79 | fx := newConsoleFixture(t) |
| 80 | now := time.Now().Truncate(time.Second) |
| 81 | for i := 0; i < temporaryAppCap-1; i++ { |
| 82 | tempApp(t, fx, fmt.Sprintf("tmp-live%02d", i), now.Add(time.Hour)) |
| 83 | } |
| 84 | trashedTemp(t, fx, "tmp-a", now.Add(-time.Hour)) |
| 85 | // Steps 3 and 4 done, step 5 never ran. |
| 86 | fx.st.SetRestoring(OpsAccountID, "tmp-a", true) |
| 87 | release.RestoreApp(context.Background(), fx.rel, fx.sqid, "tmp-a") |
| 88 | if err := fx.srv.temporaryCapCheck(OpsAccountID); err == nil { |
| 89 | t.Fatal("the half-restored app's slot was free for a new app to take") |
| 90 | } |
| 91 | if err := fx.srv.settleApp(context.Background(), OpsAccountID, fx.sqid, "tmp-a", now, always(true)); err != nil { |
| 92 | t.Fatal(err) |
| 93 | } |
| 94 | if l := lifeOf(t, fx, "tmp-a"); l.Trashed() || l.Restoring { |
| 95 | t.Fatalf("settled row = %+v, want live", l) |
| 96 | } |
| 97 | } |
| 98 | |
| 99 | // Spec §9: the cap is enforced on every console restore door. |
| 100 | func TestRestoreDoorsEnforceTheCap(t *testing.T) { |
| 101 | fx := newConsoleFixture(t) |
| 102 | now := time.Now().Truncate(time.Second) |
| 103 | for i := 0; i < temporaryAppCap; i++ { |
| 104 | tempApp(t, fx, fmt.Sprintf("tmp-live%02d", i), now.Add(time.Hour)) |
| 105 | } |
| 106 | trashedTemp(t, fx, "tmp-a", now.Add(-time.Hour)) |
| 107 | token := cliToken(t, fx, "paul@keymail.dev", "code-restore-cap") |
| 108 | status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/"+fx.sqid+"/tmp-a/restore", map[string]string{}, token) |
| 109 | if status != http.StatusConflict || out["code"] != "cap" { |
| 110 | t.Fatalf("API restore over the cap = %d %v, want 409 cap", status, out) |
| 111 | } |
| 112 | c, csrf := signInAs(t, fx, "paul@keymail.dev", "code-restore-cap-web") |
| 113 | st, loc, _ := postForm(t, c, fx.u("/trash/restore"), url.Values{"csrf": {csrf}, "app": {"tmp-a"}}) |
| 114 | if _, _, page := get(t, c, fx.ts.URL+loc); st != http.StatusSeeOther || !strings.Contains(page, "tmp-live00") { |
| 115 | t.Fatalf("browser restore over the cap = %d, want the refusal listing the live apps", st) |
| 116 | } |
| 117 | if markerOf(t, fx, "tmp-a") == nil { |
| 118 | t.Fatal("a refused restore removed the marker") |
| 119 | } |
| 120 | } |
| 121 | |
| 122 | // Spec §5 "Trash by hand": the doors set the window for a temporary row only. |
| 123 | func TestTrashDoorsSetTheWindowForTemporaryRows(t *testing.T) { |
| 124 | fx := newConsoleFixture(t) |
| 125 | tempApp(t, fx, "tmp-a", time.Now().Add(30*24*time.Hour)) |
| 126 | token := cliToken(t, fx, "paul@keymail.dev", "code-trash-window") |
| 127 | for _, app := range []string{"tmp-a", "kass"} { |
| 128 | if status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/"+fx.sqid+"/"+app+"/delete", map[string]string{}, token); status != http.StatusOK { |
| 129 | t.Fatalf("delete %s = %d %v", app, status, out) |
| 130 | } |
| 131 | } |
| 132 | tmp, kass := lifeOf(t, fx, "tmp-a"), lifeOf(t, fx, "kass") |
| 133 | if tmp.PurgeFor != tmp.DeletedAt || tmp.PurgeAfter != tmp.DeletedAt+72*3600 { |
| 134 | t.Fatalf("temporary trash = %+v, want the 3-day window", tmp) |
| 135 | } |
| 136 | if kass.PurgeAfter != 0 || kass.PurgeFor != 0 { |
| 137 | t.Fatalf("permanent trash = %+v, want none", kass) |
| 138 | } |
| 139 | } |
| 140 | |
| 141 | // waitForWaiter returns once a second holder is queued on key: the request |
| 142 | // under test has passed every check it makes before the lock and is blocked |
| 143 | // on it. Deterministic: it waits for the queue, not for a clock. |
| 144 | func waitForWaiter(t *testing.T, l *keyedLocks, key string) { |
| 145 | t.Helper() |
| 146 | deadline := time.Now().Add(10 * time.Second) |
| 147 | for time.Now().Before(deadline) { |
| 148 | l.mu.Lock() |
| 149 | n := 0 |
| 150 | if e := l.m[key]; e != nil { |
| 151 | n = e.refs |
| 152 | } |
| 153 | l.mu.Unlock() |
| 154 | if n >= 2 { |
| 155 | return |
| 156 | } |
| 157 | time.Sleep(time.Millisecond) |
| 158 | } |
| 159 | t.Fatalf("no request queued on %s", key) |
| 160 | } |
| 161 | |
| 162 | // Astra plan R1 finding 1: a trash door wrote the marker and failed its row |
| 163 | // update, so the row is live. The manual purge must settle first: without it |
| 164 | // PurgeApp sweeps the objects and PurgeAppRow (guarded deleted_at != 0) |
| 165 | // silently keeps a live row that nothing ever repairs. |
| 166 | func TestManualPurgeAfterAFailedTrashRowUpdate(t *testing.T) { |
| 167 | fx := newConsoleFixture(t) |
| 168 | if err := release.TrashApp(context.Background(), fx.rel, fx.sqid, "kass", "paul@keymail.dev", time.Now()); err != nil { |
| 169 | t.Fatal(err) |
| 170 | } |
| 171 | if l := lifeOf(t, fx, "kass"); l.Trashed() { |
| 172 | t.Fatal("setup: the row must still be live") |
| 173 | } |
| 174 | c, csrf := signInAs(t, fx, "paul@keymail.dev", "code-purge-unsettled") |
| 175 | postGated(t, fx, c, fx.u("/trash/delete"), url.Values{"csrf": {csrf}, "app": {"kass"}, "confirm": {"kass"}}) |
| 176 | if keys := appKeys(t, fx, "kass"); len(keys) != 0 { |
| 177 | t.Fatalf("purge left objects: %v", keys) |
| 178 | } |
| 179 | if l := lifeOf(t, fx, "kass"); l != nil { |
| 180 | t.Fatalf("row = %+v after a purge, want it gone with the objects", l) |
| 181 | } |
| 182 | } |
| 183 | |
| 184 | // Astra plan round 4: restoring a provisioned temporary static app must not |
| 185 | // send anyone to carlos add, which refuses every tmp- name. Its instance |
| 186 | // record survives trash and restore, and with the marker gone provision's |
| 187 | // reconcile mints the route again (box side: TestReconcileDeclinesTrashedApp, |
| 188 | // run in Step 4). Both restore doors are checked. |
| 189 | func TestRestoreOfAProvisionedTemporaryStaticApp(t *testing.T) { |
| 190 | fx := newConsoleFixture(t) |
| 191 | fx.srv.cfg.AppsDomain = "carlosapp.test" |
| 192 | tempApp(t, fx, "tmp-site", time.Now().Add(24*time.Hour)) |
| 193 | token := cliToken(t, fx, "paul@keymail.dev", "code-restore-provisioned") |
| 194 | base := fx.ts.URL + "/api/cli/apps/" + fx.sqid + "/tmp-site" |
| 195 | if st, out := putJSON(t, base+"/instances-manifest", map[string]any{}, token); st != http.StatusOK { |
| 196 | t.Fatalf("enable = %d %v", st, out) |
| 197 | } |
| 198 | apex := "tmp-site." + fx.sqid + ".carlosapp.test" |
| 199 | if st, _, out := rawReq(t, http.MethodPost, base+"/instances", token, `{"host":"`+apex+`","kind":"static","channel":"edge"}`); st != http.StatusCreated { |
| 200 | t.Fatalf("instance = %d %s", st, out) |
| 201 | } |
| 202 | recordKept := func(when string) { |
| 203 | t.Helper() |
| 204 | rec, err := provision.GetRecord(context.Background(), fx.rel, fx.sqid, "tmp-site", apex) |
| 205 | if err != nil || rec == nil || rec.Kind != "static" || rec.Host != apex { |
| 206 | t.Fatalf("%s: instance record = %+v %v, want the static record at %s kept", when, rec, err, apex) |
| 207 | } |
| 208 | } |
| 209 | |
| 210 | // API: delete, then restore. |
| 211 | if st, out := postJSON(t, base+"/delete", map[string]string{}, token); st != http.StatusOK { |
| 212 | t.Fatalf("delete = %d %v", st, out) |
| 213 | } |
| 214 | recordKept("after trash") |
| 215 | st, out := postJSON(t, base+"/restore", map[string]string{}, token) |
| 216 | if st != http.StatusOK { |
| 217 | t.Fatalf("restore = %d %v", st, out) |
| 218 | } |
| 219 | if note := str(t, out, "note"); strings.Contains(note, "carlos add") || !strings.Contains(note, "tmp-site") { |
| 220 | t.Fatalf("restore note = %q, want the instance-record guidance, never carlos add", note) |
| 221 | } |
| 222 | recordKept("after API restore") |
| 223 | if markerOf(t, fx, "tmp-site") != nil || lifeOf(t, fx, "tmp-site").Trashed() { |
| 224 | t.Fatal("after restore the marker must be gone and the row live, so reconcile mints the route again") |
| 225 | } |
| 226 | |
| 227 | // Browser: delete through the API again, restore from the Trash page. |
| 228 | if st, out := postJSON(t, base+"/delete", map[string]string{}, token); st != http.StatusOK { |
| 229 | t.Fatalf("second delete = %d %v", st, out) |
| 230 | } |
| 231 | c, csrf := signInAs(t, fx, "paul@keymail.dev", "code-restore-provisioned-web") |
| 232 | st, loc, _ := postForm(t, c, fx.u("/trash/restore"), url.Values{"csrf": {csrf}, "app": {"tmp-site"}}) |
| 233 | if st != http.StatusSeeOther { |
| 234 | t.Fatalf("browser restore = %d", st) |
| 235 | } |
| 236 | if _, _, page := get(t, c, fx.ts.URL+loc); strings.Contains(page, "carlos add") || !strings.Contains(page, "tmp-site") { |
| 237 | t.Fatal("the browser restore flash sends a temporary app to carlos add, or does not name it") |
| 238 | } |
| 239 | recordKept("after browser restore") |
| 240 | } |
| 241 | |
| 242 | // Astra plan round 2: settling under the lock must not turn a trash retry into |
| 243 | // a refusal. A marker already in the bucket (a headless trash) and a live row: |
| 244 | // the API trash settles the row onto the marker's clock and then answers 200 |
| 245 | // through its ordinary path, with the marker untouched, the existing deadline, |
| 246 | // and a serial bump. |
| 247 | func TestTrashAfterAHeadlessTrashIsIdempotent(t *testing.T) { |
| 248 | fx := newConsoleFixture(t) |
| 249 | ctx := context.Background() |
| 250 | old := time.Now().Add(-5 * 24 * time.Hour).Truncate(time.Second) |
| 251 | if err := release.TrashApp(ctx, fx.rel, fx.sqid, "kass", "headless", old); err != nil { |
| 252 | t.Fatal(err) |
| 253 | } |
| 254 | serialBefore, _, _ := fx.rel.Get(ctx, release.SerialKey) |
| 255 | token := cliToken(t, fx, "paul@keymail.dev", "code-trash-idempotent") |
| 256 | status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/"+fx.sqid+"/kass/delete", map[string]string{}, token) |
| 257 | if status != http.StatusOK { |
| 258 | t.Fatalf("delete after a headless trash = %d %v, want 200", status, out) |
| 259 | } |
| 260 | until, err := time.Parse(time.RFC3339, str(t, out, "restorable_until")) |
| 261 | if err != nil || !until.Equal(old.Add(release.TrashCooloff)) { |
| 262 | t.Fatalf("restorable_until = %v %v, want the headless marker's %v", until, err, old.Add(release.TrashCooloff)) |
| 263 | } |
| 264 | if tomb := markerOf(t, fx, "kass"); tomb == nil || !tomb.DeletedAt.Equal(old) || tomb.Actor != "headless" { |
| 265 | t.Fatalf("marker = %+v, want the headless one, untouched", tomb) |
| 266 | } |
| 267 | if serialAfter, _, _ := fx.rel.Get(ctx, release.SerialKey); string(serialAfter) == string(serialBefore) { |
| 268 | t.Fatal("the idempotent success skipped the serial bump") |
| 269 | } |
| 270 | } |
| 271 | |
| 272 | // Astra plan R1 finding 2 (spec §2: a check made before the lock is a hint). |
| 273 | // Each case holds the app's lock, lets the destructive request queue on it, |
| 274 | // changes a precondition, and releases. The door must refuse on what is true |
| 275 | // once it holds the lock. |
| 276 | func TestDestructiveDoorsRecheckAfterTheLock(t *testing.T) { |
| 277 | run := func(t *testing.T, fx *consoleFixture, app string, request func() int, change func()) int { |
| 278 | t.Helper() |
| 279 | unlock := fx.srv.lockApp(fx.sqid, app) |
| 280 | done := make(chan int, 1) |
| 281 | go func() { done <- request() }() |
| 282 | waitForWaiter(t, &fx.srv.appLocks, fx.sqid+"/"+app) |
| 283 | change() |
| 284 | unlock() |
| 285 | return <-done |
| 286 | } |
| 287 | |
| 288 | t.Run("API trash: marked control plane while waiting", func(t *testing.T) { |
| 289 | fx := newConsoleFixture(t) |
| 290 | token := cliToken(t, fx, "paul@keymail.dev", "code-recheck-api-cp") |
| 291 | status := run(t, fx, "kass", func() int { |
| 292 | st, _ := postRaw(t, fx.ts.URL+"/api/cli/apps/"+fx.sqid+"/kass/delete", map[string]string{}, token) |
| 293 | return st |
| 294 | }, func() { |
| 295 | if err := fx.st.SetAppControlPlane(OpsAccountID, "kass", true); err != nil { |
| 296 | t.Error(err) |
| 297 | } |
| 298 | }) |
| 299 | if status != http.StatusForbidden || markerOf(t, fx, "kass") != nil || lifeOf(t, fx, "kass").Trashed() { |
| 300 | t.Fatalf("status %d: the control plane was trashed by a request that checked the flag before waiting", status) |
| 301 | } |
| 302 | }) |
| 303 | |
| 304 | t.Run("API trash: row purged while waiting", func(t *testing.T) { |
| 305 | fx := newConsoleFixture(t) |
| 306 | token := cliToken(t, fx, "paul@keymail.dev", "code-recheck-api-gone") |
| 307 | status := run(t, fx, "kass", func() int { |
| 308 | st, _ := postRaw(t, fx.ts.URL+"/api/cli/apps/"+fx.sqid+"/kass/delete", map[string]string{}, token) |
| 309 | return st |
| 310 | }, func() { |
| 311 | fx.st.TrashAppRow(OpsAccountID, "kass", time.Now()) |
| 312 | fx.st.PurgeAppRow(OpsAccountID, "kass") |
| 313 | }) |
| 314 | if status != http.StatusNotFound || markerOf(t, fx, "kass") != nil { |
| 315 | t.Fatalf("status %d: a delayed trash wrote a marker for a row that no longer exists", status) |
| 316 | } |
| 317 | }) |
| 318 | |
| 319 | t.Run("browser delete: marked control plane while waiting", func(t *testing.T) { |
| 320 | fx := newConsoleFixture(t) |
| 321 | c, csrf := signInAs(t, fx, "paul@keymail.dev", "code-recheck-web-cp") |
| 322 | run(t, fx, "kass", func() int { |
| 323 | st, _, _ := postGated(t, fx, c, fx.u("/apps/kass/delete"), url.Values{"csrf": {csrf}, "confirm": {"kass"}}) |
| 324 | return st |
| 325 | }, func() { |
| 326 | if err := fx.st.SetAppControlPlane(OpsAccountID, "kass", true); err != nil { |
| 327 | t.Error(err) |
| 328 | } |
| 329 | }) |
| 330 | if markerOf(t, fx, "kass") != nil || lifeOf(t, fx, "kass").Trashed() { |
| 331 | t.Fatal("the browser delete trashed the control plane after waiting on the lock") |
| 332 | } |
| 333 | }) |
| 334 | |
| 335 | t.Run("purge door: marked control plane while waiting", func(t *testing.T) { |
| 336 | fx := newConsoleFixture(t) |
| 337 | now := time.Now() |
| 338 | if err := release.TrashApp(context.Background(), fx.rel, fx.sqid, "kass", "paul@keymail.dev", now); err != nil { |
| 339 | t.Fatal(err) |
| 340 | } |
| 341 | fx.st.TrashAppRow(OpsAccountID, "kass", now) |
| 342 | before := len(appKeys(t, fx, "kass")) |
| 343 | c, csrf := signInAs(t, fx, "paul@keymail.dev", "code-recheck-purge-cp") |
| 344 | run(t, fx, "kass", func() int { |
| 345 | st, _, _ := postGated(t, fx, c, fx.u("/trash/delete"), url.Values{"csrf": {csrf}, "app": {"kass"}, "confirm": {"kass"}}) |
| 346 | return st |
| 347 | }, func() { |
| 348 | if err := fx.st.SetAppControlPlane(OpsAccountID, "kass", true); err != nil { |
| 349 | t.Error(err) |
| 350 | } |
| 351 | }) |
| 352 | if got := len(appKeys(t, fx, "kass")); got != before || lifeOf(t, fx, "kass") == nil { |
| 353 | t.Fatalf("the purge door destroyed the control plane after waiting on the lock (%d of %d keys left)", got, before) |
| 354 | } |
| 355 | }) |
| 356 | } |
| 357 | |
| 358 | // Task 7 review, fix round 1: a restore whose step 5 failed has already |
| 359 | // removed the marker. The retry must answer as a restore, with the audit row |
| 360 | // and the serial bump, not "not in the trash". The failed attempt bumps the |
| 361 | // serial too, because the bucket has already changed. Both doors. |
| 362 | func TestRestoreRetryAfterAFailedRowUpdate(t *testing.T) { |
| 363 | failStepFive := func(t *testing.T, fx *consoleFixture) func() { |
| 364 | t.Helper() |
| 365 | if _, err := fx.st.db.Exec(`CREATE TRIGGER fail_step_five BEFORE UPDATE ON apps |
| 366 | WHEN OLD.deleted_at != 0 AND NEW.deleted_at = 0 |
| 367 | BEGIN SELECT RAISE(ABORT, 'test: step 5 refused'); END`); err != nil { |
| 368 | t.Fatal(err) |
| 369 | } |
| 370 | return func() { |
| 371 | t.Helper() |
| 372 | if _, err := fx.st.db.Exec(`DROP TRIGGER fail_step_five`); err != nil { |
| 373 | t.Fatal(err) |
| 374 | } |
| 375 | } |
| 376 | } |
| 377 | serial := func(t *testing.T, fx *consoleFixture) string { |
| 378 | t.Helper() |
| 379 | b, _, _ := fx.rel.Get(context.Background(), release.SerialKey) |
| 380 | return string(b) |
| 381 | } |
| 382 | |
| 383 | t.Run("API", func(t *testing.T) { |
| 384 | fx := newConsoleFixture(t) |
| 385 | token := cliToken(t, fx, "paul@keymail.dev", "code-restore-retry-api") |
| 386 | base := fx.ts.URL + "/api/cli/apps/" + fx.sqid + "/kass" |
| 387 | if st, out := postJSON(t, base+"/delete", map[string]string{}, token); st != http.StatusOK { |
| 388 | t.Fatalf("delete = %d %v", st, out) |
| 389 | } |
| 390 | restoreRow := failStepFive(t, fx) |
| 391 | before := serial(t, fx) |
| 392 | if st, out := postJSON(t, base+"/restore", map[string]string{}, token); st != http.StatusInternalServerError { |
| 393 | t.Fatalf("restore with step 5 failing = %d %v, want 500", st, out) |
| 394 | } |
| 395 | if markerOf(t, fx, "kass") != nil || !lifeOf(t, fx, "kass").Trashed() { |
| 396 | t.Fatal("setup: want the marker gone and the row still trashed") |
| 397 | } |
| 398 | if serial(t, fx) == before { |
| 399 | t.Fatal("the failed step 5 skipped the serial bump, though the marker is gone") |
| 400 | } |
| 401 | restoreRow() |
| 402 | before = serial(t, fx) |
| 403 | if st, out := postJSON(t, base+"/restore", map[string]string{}, token); st != http.StatusOK || out["result"] != "restored" { |
| 404 | t.Fatalf("retry = %d %v, want 200 restored", st, out) |
| 405 | } |
| 406 | if l := lifeOf(t, fx, "kass"); l.Trashed() || l.Restoring { |
| 407 | t.Fatalf("row after the retry = %+v, want live", l) |
| 408 | } |
| 409 | auditedBy(t, fx, "kass", "restore", "paul@keymail.dev", "kass") |
| 410 | if serial(t, fx) == before { |
| 411 | t.Fatal("the retry skipped the serial bump") |
| 412 | } |
| 413 | }) |
| 414 | |
| 415 | t.Run("browser", func(t *testing.T) { |
| 416 | fx := newConsoleFixture(t) |
| 417 | token := cliToken(t, fx, "paul@keymail.dev", "code-restore-retry-web") |
| 418 | if st, out := postJSON(t, fx.ts.URL+"/api/cli/apps/"+fx.sqid+"/kass/delete", map[string]string{}, token); st != http.StatusOK { |
| 419 | t.Fatalf("delete = %d %v", st, out) |
| 420 | } |
| 421 | c, csrf := signInAs(t, fx, "paul@keymail.dev", "code-restore-retry-web-in") |
| 422 | restoreRow := failStepFive(t, fx) |
| 423 | before := serial(t, fx) |
| 424 | postForm(t, c, fx.u("/trash/restore"), url.Values{"csrf": {csrf}, "app": {"kass"}}) |
| 425 | if markerOf(t, fx, "kass") != nil || !lifeOf(t, fx, "kass").Trashed() { |
| 426 | t.Fatal("setup: want the marker gone and the row still trashed") |
| 427 | } |
| 428 | if serial(t, fx) == before { |
| 429 | t.Fatal("the failed step 5 skipped the serial bump, though the marker is gone") |
| 430 | } |
| 431 | restoreRow() |
| 432 | before = serial(t, fx) |
| 433 | st, loc, _ := postForm(t, c, fx.u("/trash/restore"), url.Values{"csrf": {csrf}, "app": {"kass"}}) |
| 434 | if _, _, page := get(t, c, fx.ts.URL+loc); st != http.StatusSeeOther || strings.Contains(page, "nothing called") { |
| 435 | t.Fatalf("retry = %d, want the restored flash, not the not-in-trash refusal", st) |
| 436 | } |
| 437 | if l := lifeOf(t, fx, "kass"); l.Trashed() || l.Restoring { |
| 438 | t.Fatalf("row after the retry = %+v, want live", l) |
| 439 | } |
| 440 | auditedBy(t, fx, "kass", "restore", "paul@keymail.dev", "kass") |
| 441 | if serial(t, fx) == before { |
| 442 | t.Fatal("the retry skipped the serial bump") |
| 443 | } |
| 444 | }) |
| 445 | } |
| 446 | |