package console import ( "database/sql" "path/filepath" "testing" "time" "github.com/carlosframework/platform/internal/release" _ "modernc.org/sqlite" ) // tempStoreAccount gives a bare store an account row, which AppRows and the // candidate queries join against. func tempStoreAccount(t *testing.T, s *Store) int64 { t.Helper() id := testAccountID() if _, err := s.EnsureAccount(id, "tst", "Test", time.Unix(1700000000, 0)); err != nil { t.Fatal(err) } return id } func mustLife(t *testing.T, s *Store, acct int64, app string) *AppLife { t.Helper() l, err := s.AppLife(acct, app) if err != nil || l == nil { t.Fatalf("AppLife(%s) = %v, %v", app, l, err) } return l } // The pre-accounts table is rebuilt by migrateAppsKey on the FIRST open, after // the ALTER list has added the new columns to the old table. The rebuild must // carry them, or the first boot after upgrade runs without them. func TestTemporaryColumnsSurviveTheAppsRebuild(t *testing.T) { path := filepath.Join(t.TempDir(), "console.db") raw, err := sql.Open("sqlite", path) if err != nil { t.Fatal(err) } if _, err := raw.Exec(`CREATE TABLE apps (app TEXT PRIMARY KEY, created_by TEXT NOT NULL, created_at INTEGER NOT NULL);`); err != nil { t.Fatal(err) } if _, err := raw.Exec(`INSERT INTO apps (app, created_by, created_at) VALUES ('kass', 'paul@keymail.dev', 1700000000)`); err != nil { t.Fatal(err) } raw.Close() st, err := Open(path) if err != nil { t.Fatal(err) } defer st.Close() var exp, restoring, after, forAt int64 if err := st.db.QueryRow(`SELECT expires_at, restoring, purge_after, purge_for FROM apps WHERE app = 'kass'`). Scan(&exp, &restoring, &after, &forAt); err != nil { t.Fatalf("new columns after the first open: %v", err) } if exp != 0 || restoring != 0 || after != 0 || forAt != 0 { t.Fatalf("defaults = %d %d %d %d, want all 0 (permanent, not restoring, no early purge)", exp, restoring, after, forAt) } } func TestCreateTemporaryAppInClaimsWithTheExpiry(t *testing.T) { s := openTestStore(t) acct := tempStoreAccount(t, s) now := time.Unix(1700000000, 0) exp := now.Add(7 * 24 * time.Hour) if ok, err := s.CreateTemporaryAppIn(acct, "tmp-docs", "paul@keymail.dev", now, exp); err != nil || !ok { t.Fatalf("CreateTemporaryAppIn = %v %v", ok, err) } if l := mustLife(t, s, acct, "tmp-docs"); l.ExpiresAt != exp.Unix() || !l.Temporary() || l.Trashed() { t.Fatalf("row = %+v, want expires_at %d, live", l, exp.Unix()) } // A racing permanent create of the same name loses (ON CONFLICT DO NOTHING). if ok, err := s.CreateAppIn(acct, "tmp-docs", "eve@keymail.dev", now); err != nil || ok { t.Fatalf("permanent create over a temporary row = %v %v, want false", ok, err) } if ok, _ := s.CreateTemporaryAppIn(acct, "tmp-docs", "eve@keymail.dev", now, exp); ok { t.Fatal("a second temporary claim of the same name succeeded") } if _, err := s.CreateTemporaryAppIn(acct, "tmp-zero", "paul@keymail.dev", now, time.Time{}); err == nil { t.Fatal("a temporary claim with no expiry was accepted") } } func TestTemporaryConditionalUpdatesLoseCleanly(t *testing.T) { s := openTestStore(t) acct := tempStoreAccount(t, s) now := time.Unix(1700000000, 0) if _, err := s.CreateAppIn(acct, "perm", "paul@keymail.dev", now); err != nil { t.Fatal(err) } if _, err := s.CreateTemporaryAppIn(acct, "tmp-a", "paul@keymail.dev", now, now.Add(time.Hour)); err != nil { t.Fatal(err) } for name, fn := range map[string]func() (bool, error){ "keep permanent": func() (bool, error) { return s.KeepApp(acct, "perm") }, "ttl permanent": func() (bool, error) { return s.SetAppExpiry(acct, "perm", now.Add(2*time.Hour)) }, "expire permanent": func() (bool, error) { return s.ExpireAppRow(acct, "perm", now) }, "bump live": func() (bool, error) { return s.BumpTrashedExpiry(acct, "tmp-a", now) }, "restoring on live": func() (bool, error) { return s.SetRestoring(acct, "tmp-a", true) }, } { if ok, err := fn(); err != nil || ok { t.Errorf("%s = %v %v, want a lost update (false, nil)", name, ok, err) } } // Expire wins once, and sets the 3-day window from the given clock. at := now.Add(90 * time.Minute) if ok, err := s.ExpireAppRow(acct, "tmp-a", at); err != nil || !ok { t.Fatalf("ExpireAppRow = %v %v", ok, err) } l := mustLife(t, s, acct, "tmp-a") if l.DeletedAt != at.Unix() || l.PurgeFor != at.Unix() || l.PurgeAfter != at.Unix()+72*3600 { t.Fatalf("expired row = %+v, want deleted_at = purge_for = %d, purge_after = +72h", l, at.Unix()) } for name, fn := range map[string]func() (bool, error){ "expire twice": func() (bool, error) { return s.ExpireAppRow(acct, "tmp-a", at.Add(time.Hour)) }, "keep trashed": func() (bool, error) { return s.KeepApp(acct, "tmp-a") }, "ttl on trashed": func() (bool, error) { return s.SetAppExpiry(acct, "tmp-a", now.Add(9*time.Hour)) }, } { if ok, err := fn(); err != nil || ok { t.Errorf("%s = %v %v, want a lost update", name, ok, err) } } } func TestTrashAppRowSetsTheWindowOnlyForTemporaryRows(t *testing.T) { s := openTestStore(t) acct := tempStoreAccount(t, s) now := time.Unix(1700000000, 0) s.CreateAppIn(acct, "perm", "paul@keymail.dev", now) s.CreateTemporaryAppIn(acct, "tmp-a", "paul@keymail.dev", now, now.Add(30*24*time.Hour)) at := now.Add(time.Minute) for _, app := range []string{"perm", "tmp-a"} { if err := s.TrashAppRow(acct, app, at); err != nil { t.Fatal(err) } } if l := mustLife(t, s, acct, "perm"); l.PurgeAfter != 0 || l.PurgeFor != 0 || l.DeletedAt != at.Unix() { t.Fatalf("permanent trash = %+v, want no early purge", l) } if l := mustLife(t, s, acct, "tmp-a"); l.PurgeFor != at.Unix() || l.PurgeAfter != at.Unix()+72*3600 { t.Fatalf("temporary trash = %+v, want the 3-day window from %d", l, at.Unix()) } } func TestLiveTemporaryCountsARestoringRow(t *testing.T) { s := openTestStore(t) acct := tempStoreAccount(t, s) now := time.Unix(1700000000, 0) for _, app := range []string{"tmp-a", "tmp-b", "tmp-c"} { s.CreateTemporaryAppIn(acct, app, "paul@keymail.dev", now, now.Add(time.Hour)) } s.CreateAppIn(acct, "perm", "paul@keymail.dev", now) s.TrashAppRow(acct, "tmp-b", now) s.TrashAppRow(acct, "tmp-c", now) if ok, err := s.SetRestoring(acct, "tmp-c", true); err != nil || !ok { t.Fatalf("SetRestoring = %v %v", ok, err) } live, err := s.LiveTemporaryApps(acct) if err != nil { t.Fatal(err) } var names []string for _, l := range live { names = append(names, l.App) } if len(names) != 2 || names[0] != "tmp-a" || names[1] != "tmp-c" { t.Fatalf("LiveTemporaryApps = %v, want [tmp-a tmp-c]: live, plus the one mid-restore", names) } } func TestRestoreStepsOnTheRow(t *testing.T) { s := openTestStore(t) acct := tempStoreAccount(t, s) now := time.Unix(1700000000, 0) s.CreateTemporaryAppIn(acct, "tmp-a", "paul@keymail.dev", now.Add(-10*24*time.Hour), now.Add(-time.Minute)) s.TrashAppRow(acct, "tmp-a", now.Add(-time.Minute)) // Step 2: only while trashed, only when the expiry is under an hour away. if ok, err := s.BumpTrashedExpiry(acct, "tmp-a", now); err != nil || !ok { t.Fatalf("BumpTrashedExpiry = %v %v", ok, err) } if l := mustLife(t, s, acct, "tmp-a"); l.ExpiresAt != now.Add(temporaryTTLDefault).Unix() || !l.Trashed() || l.PurgeAfter == 0 { t.Fatalf("after step 2 = %+v, want expiry now+7d, still trashed, deadline kept", l) } if ok, _ := s.BumpTrashedExpiry(acct, "tmp-a", now); ok { t.Fatal("step 2 fired again on an expiry a week out") } // Step 5. if err := s.FinishRestoreRow(acct, "tmp-a", now); err != nil { t.Fatal(err) } if l := mustLife(t, s, acct, "tmp-a"); l.Trashed() || l.Restoring || l.PurgeAfter != 0 || l.PurgeFor != 0 { t.Fatalf("after step 5 = %+v, want live with no deadline", l) } } func TestEffectivePurgeAt(t *testing.T) { del := int64(1700000000) if got, want := effectivePurgeAt(del, 0), time.Unix(del, 0).UTC().Add(release.TrashCooloff); !got.Equal(want) { t.Errorf("permanent = %v, want %v", got, want) } if got, want := effectivePurgeAt(del, del+72*3600), time.Unix(del+72*3600, 0).UTC(); !got.Equal(want) { t.Errorf("temporary = %v, want %v", got, want) } } func TestPurgeDueAndTemporaryRows(t *testing.T) { s := openTestStore(t) acct := tempStoreAccount(t, s) now := time.Unix(1700000000, 0) s.CreateAppIn(acct, "perm", "paul@keymail.dev", now) s.CreateTemporaryAppIn(acct, "tmp-due", "paul@keymail.dev", now, now.Add(time.Hour)) s.CreateTemporaryAppIn(acct, "tmp-later", "paul@keymail.dev", now, now.Add(time.Hour)) s.TrashAppRow(acct, "perm", now.Add(-100*24*time.Hour)) s.TrashAppRow(acct, "tmp-due", now.Add(-72*time.Hour)) s.TrashAppRow(acct, "tmp-later", now.Add(-71*time.Hour)) due, err := s.PurgeDueApps(now) if err != nil { t.Fatal(err) } if len(due) != 1 || due[0].App != "tmp-due" || due[0].Sqid != "tst" || due[0].PurgeFor != now.Add(-72*time.Hour).Unix() { t.Fatalf("PurgeDueApps = %+v, want only tmp-due (a permanent row never has purge_after)", due) } rows, err := s.TemporaryAppRows() if err != nil { t.Fatal(err) } if len(rows) != 2 { t.Fatalf("TemporaryAppRows = %+v, want the two temporary rows", rows) } }