| 1 | package console |
| 2 | |
| 3 | import ( |
| 4 | "database/sql" |
| 5 | "path/filepath" |
| 6 | "testing" |
| 7 | "time" |
| 8 | |
| 9 | "github.com/carlosframework/platform/internal/release" |
| 10 | _ "modernc.org/sqlite" |
| 11 | ) |
| 12 | |
| 13 | // tempStoreAccount gives a bare store an account row, which AppRows and the |
| 14 | // candidate queries join against. |
| 15 | func tempStoreAccount(t *testing.T, s *Store) int64 { |
| 16 | t.Helper() |
| 17 | id := testAccountID() |
| 18 | if _, err := s.EnsureAccount(id, "tst", "Test", time.Unix(1700000000, 0)); err != nil { |
| 19 | t.Fatal(err) |
| 20 | } |
| 21 | return id |
| 22 | } |
| 23 | |
| 24 | func mustLife(t *testing.T, s *Store, acct int64, app string) *AppLife { |
| 25 | t.Helper() |
| 26 | l, err := s.AppLife(acct, app) |
| 27 | if err != nil || l == nil { |
| 28 | t.Fatalf("AppLife(%s) = %v, %v", app, l, err) |
| 29 | } |
| 30 | return l |
| 31 | } |
| 32 | |
| 33 | // The pre-accounts table is rebuilt by migrateAppsKey on the FIRST open, after |
| 34 | // the ALTER list has added the new columns to the old table. The rebuild must |
| 35 | // carry them, or the first boot after upgrade runs without them. |
| 36 | func TestTemporaryColumnsSurviveTheAppsRebuild(t *testing.T) { |
| 37 | path := filepath.Join(t.TempDir(), "console.db") |
| 38 | raw, err := sql.Open("sqlite", path) |
| 39 | if err != nil { |
| 40 | t.Fatal(err) |
| 41 | } |
| 42 | if _, err := raw.Exec(`CREATE TABLE apps (app TEXT PRIMARY KEY, created_by TEXT NOT NULL, created_at INTEGER NOT NULL);`); err != nil { |
| 43 | t.Fatal(err) |
| 44 | } |
| 45 | if _, err := raw.Exec(`INSERT INTO apps (app, created_by, created_at) VALUES ('kass', 'paul@keymail.dev', 1700000000)`); err != nil { |
| 46 | t.Fatal(err) |
| 47 | } |
| 48 | raw.Close() |
| 49 | st, err := Open(path) |
| 50 | if err != nil { |
| 51 | t.Fatal(err) |
| 52 | } |
| 53 | defer st.Close() |
| 54 | var exp, restoring, after, forAt int64 |
| 55 | if err := st.db.QueryRow(`SELECT expires_at, restoring, purge_after, purge_for FROM apps WHERE app = 'kass'`). |
| 56 | Scan(&exp, &restoring, &after, &forAt); err != nil { |
| 57 | t.Fatalf("new columns after the first open: %v", err) |
| 58 | } |
| 59 | if exp != 0 || restoring != 0 || after != 0 || forAt != 0 { |
| 60 | t.Fatalf("defaults = %d %d %d %d, want all 0 (permanent, not restoring, no early purge)", exp, restoring, after, forAt) |
| 61 | } |
| 62 | } |
| 63 | |
| 64 | func TestCreateTemporaryAppInClaimsWithTheExpiry(t *testing.T) { |
| 65 | s := openTestStore(t) |
| 66 | acct := tempStoreAccount(t, s) |
| 67 | now := time.Unix(1700000000, 0) |
| 68 | exp := now.Add(7 * 24 * time.Hour) |
| 69 | if ok, err := s.CreateTemporaryAppIn(acct, "tmp-docs", "paul@keymail.dev", now, exp); err != nil || !ok { |
| 70 | t.Fatalf("CreateTemporaryAppIn = %v %v", ok, err) |
| 71 | } |
| 72 | if l := mustLife(t, s, acct, "tmp-docs"); l.ExpiresAt != exp.Unix() || !l.Temporary() || l.Trashed() { |
| 73 | t.Fatalf("row = %+v, want expires_at %d, live", l, exp.Unix()) |
| 74 | } |
| 75 | // A racing permanent create of the same name loses (ON CONFLICT DO NOTHING). |
| 76 | if ok, err := s.CreateAppIn(acct, "tmp-docs", "eve@keymail.dev", now); err != nil || ok { |
| 77 | t.Fatalf("permanent create over a temporary row = %v %v, want false", ok, err) |
| 78 | } |
| 79 | if ok, _ := s.CreateTemporaryAppIn(acct, "tmp-docs", "eve@keymail.dev", now, exp); ok { |
| 80 | t.Fatal("a second temporary claim of the same name succeeded") |
| 81 | } |
| 82 | if _, err := s.CreateTemporaryAppIn(acct, "tmp-zero", "paul@keymail.dev", now, time.Time{}); err == nil { |
| 83 | t.Fatal("a temporary claim with no expiry was accepted") |
| 84 | } |
| 85 | } |
| 86 | |
| 87 | func TestTemporaryConditionalUpdatesLoseCleanly(t *testing.T) { |
| 88 | s := openTestStore(t) |
| 89 | acct := tempStoreAccount(t, s) |
| 90 | now := time.Unix(1700000000, 0) |
| 91 | if _, err := s.CreateAppIn(acct, "perm", "paul@keymail.dev", now); err != nil { |
| 92 | t.Fatal(err) |
| 93 | } |
| 94 | if _, err := s.CreateTemporaryAppIn(acct, "tmp-a", "paul@keymail.dev", now, now.Add(time.Hour)); err != nil { |
| 95 | t.Fatal(err) |
| 96 | } |
| 97 | for name, fn := range map[string]func() (bool, error){ |
| 98 | "keep permanent": func() (bool, error) { return s.KeepApp(acct, "perm") }, |
| 99 | "ttl permanent": func() (bool, error) { return s.SetAppExpiry(acct, "perm", now.Add(2*time.Hour)) }, |
| 100 | "expire permanent": func() (bool, error) { return s.ExpireAppRow(acct, "perm", now) }, |
| 101 | "bump live": func() (bool, error) { return s.BumpTrashedExpiry(acct, "tmp-a", now) }, |
| 102 | "restoring on live": func() (bool, error) { return s.SetRestoring(acct, "tmp-a", true) }, |
| 103 | } { |
| 104 | if ok, err := fn(); err != nil || ok { |
| 105 | t.Errorf("%s = %v %v, want a lost update (false, nil)", name, ok, err) |
| 106 | } |
| 107 | } |
| 108 | // Expire wins once, and sets the 3-day window from the given clock. |
| 109 | at := now.Add(90 * time.Minute) |
| 110 | if ok, err := s.ExpireAppRow(acct, "tmp-a", at); err != nil || !ok { |
| 111 | t.Fatalf("ExpireAppRow = %v %v", ok, err) |
| 112 | } |
| 113 | l := mustLife(t, s, acct, "tmp-a") |
| 114 | if l.DeletedAt != at.Unix() || l.PurgeFor != at.Unix() || l.PurgeAfter != at.Unix()+72*3600 { |
| 115 | t.Fatalf("expired row = %+v, want deleted_at = purge_for = %d, purge_after = +72h", l, at.Unix()) |
| 116 | } |
| 117 | for name, fn := range map[string]func() (bool, error){ |
| 118 | "expire twice": func() (bool, error) { return s.ExpireAppRow(acct, "tmp-a", at.Add(time.Hour)) }, |
| 119 | "keep trashed": func() (bool, error) { return s.KeepApp(acct, "tmp-a") }, |
| 120 | "ttl on trashed": func() (bool, error) { return s.SetAppExpiry(acct, "tmp-a", now.Add(9*time.Hour)) }, |
| 121 | } { |
| 122 | if ok, err := fn(); err != nil || ok { |
| 123 | t.Errorf("%s = %v %v, want a lost update", name, ok, err) |
| 124 | } |
| 125 | } |
| 126 | } |
| 127 | |
| 128 | func TestTrashAppRowSetsTheWindowOnlyForTemporaryRows(t *testing.T) { |
| 129 | s := openTestStore(t) |
| 130 | acct := tempStoreAccount(t, s) |
| 131 | now := time.Unix(1700000000, 0) |
| 132 | s.CreateAppIn(acct, "perm", "paul@keymail.dev", now) |
| 133 | s.CreateTemporaryAppIn(acct, "tmp-a", "paul@keymail.dev", now, now.Add(30*24*time.Hour)) |
| 134 | at := now.Add(time.Minute) |
| 135 | for _, app := range []string{"perm", "tmp-a"} { |
| 136 | if err := s.TrashAppRow(acct, app, at); err != nil { |
| 137 | t.Fatal(err) |
| 138 | } |
| 139 | } |
| 140 | if l := mustLife(t, s, acct, "perm"); l.PurgeAfter != 0 || l.PurgeFor != 0 || l.DeletedAt != at.Unix() { |
| 141 | t.Fatalf("permanent trash = %+v, want no early purge", l) |
| 142 | } |
| 143 | if l := mustLife(t, s, acct, "tmp-a"); l.PurgeFor != at.Unix() || l.PurgeAfter != at.Unix()+72*3600 { |
| 144 | t.Fatalf("temporary trash = %+v, want the 3-day window from %d", l, at.Unix()) |
| 145 | } |
| 146 | } |
| 147 | |
| 148 | func TestLiveTemporaryCountsARestoringRow(t *testing.T) { |
| 149 | s := openTestStore(t) |
| 150 | acct := tempStoreAccount(t, s) |
| 151 | now := time.Unix(1700000000, 0) |
| 152 | for _, app := range []string{"tmp-a", "tmp-b", "tmp-c"} { |
| 153 | s.CreateTemporaryAppIn(acct, app, "paul@keymail.dev", now, now.Add(time.Hour)) |
| 154 | } |
| 155 | s.CreateAppIn(acct, "perm", "paul@keymail.dev", now) |
| 156 | s.TrashAppRow(acct, "tmp-b", now) |
| 157 | s.TrashAppRow(acct, "tmp-c", now) |
| 158 | if ok, err := s.SetRestoring(acct, "tmp-c", true); err != nil || !ok { |
| 159 | t.Fatalf("SetRestoring = %v %v", ok, err) |
| 160 | } |
| 161 | live, err := s.LiveTemporaryApps(acct) |
| 162 | if err != nil { |
| 163 | t.Fatal(err) |
| 164 | } |
| 165 | var names []string |
| 166 | for _, l := range live { |
| 167 | names = append(names, l.App) |
| 168 | } |
| 169 | if len(names) != 2 || names[0] != "tmp-a" || names[1] != "tmp-c" { |
| 170 | t.Fatalf("LiveTemporaryApps = %v, want [tmp-a tmp-c]: live, plus the one mid-restore", names) |
| 171 | } |
| 172 | } |
| 173 | |
| 174 | func TestRestoreStepsOnTheRow(t *testing.T) { |
| 175 | s := openTestStore(t) |
| 176 | acct := tempStoreAccount(t, s) |
| 177 | now := time.Unix(1700000000, 0) |
| 178 | s.CreateTemporaryAppIn(acct, "tmp-a", "paul@keymail.dev", now.Add(-10*24*time.Hour), now.Add(-time.Minute)) |
| 179 | s.TrashAppRow(acct, "tmp-a", now.Add(-time.Minute)) |
| 180 | // Step 2: only while trashed, only when the expiry is under an hour away. |
| 181 | if ok, err := s.BumpTrashedExpiry(acct, "tmp-a", now); err != nil || !ok { |
| 182 | t.Fatalf("BumpTrashedExpiry = %v %v", ok, err) |
| 183 | } |
| 184 | if l := mustLife(t, s, acct, "tmp-a"); l.ExpiresAt != now.Add(temporaryTTLDefault).Unix() || !l.Trashed() || l.PurgeAfter == 0 { |
| 185 | t.Fatalf("after step 2 = %+v, want expiry now+7d, still trashed, deadline kept", l) |
| 186 | } |
| 187 | if ok, _ := s.BumpTrashedExpiry(acct, "tmp-a", now); ok { |
| 188 | t.Fatal("step 2 fired again on an expiry a week out") |
| 189 | } |
| 190 | // Step 5. |
| 191 | if err := s.FinishRestoreRow(acct, "tmp-a", now); err != nil { |
| 192 | t.Fatal(err) |
| 193 | } |
| 194 | if l := mustLife(t, s, acct, "tmp-a"); l.Trashed() || l.Restoring || l.PurgeAfter != 0 || l.PurgeFor != 0 { |
| 195 | t.Fatalf("after step 5 = %+v, want live with no deadline", l) |
| 196 | } |
| 197 | } |
| 198 | |
| 199 | func TestEffectivePurgeAt(t *testing.T) { |
| 200 | del := int64(1700000000) |
| 201 | if got, want := effectivePurgeAt(del, 0), time.Unix(del, 0).UTC().Add(release.TrashCooloff); !got.Equal(want) { |
| 202 | t.Errorf("permanent = %v, want %v", got, want) |
| 203 | } |
| 204 | if got, want := effectivePurgeAt(del, del+72*3600), time.Unix(del+72*3600, 0).UTC(); !got.Equal(want) { |
| 205 | t.Errorf("temporary = %v, want %v", got, want) |
| 206 | } |
| 207 | } |
| 208 | |
| 209 | func TestPurgeDueAndTemporaryRows(t *testing.T) { |
| 210 | s := openTestStore(t) |
| 211 | acct := tempStoreAccount(t, s) |
| 212 | now := time.Unix(1700000000, 0) |
| 213 | s.CreateAppIn(acct, "perm", "paul@keymail.dev", now) |
| 214 | s.CreateTemporaryAppIn(acct, "tmp-due", "paul@keymail.dev", now, now.Add(time.Hour)) |
| 215 | s.CreateTemporaryAppIn(acct, "tmp-later", "paul@keymail.dev", now, now.Add(time.Hour)) |
| 216 | s.TrashAppRow(acct, "perm", now.Add(-100*24*time.Hour)) |
| 217 | s.TrashAppRow(acct, "tmp-due", now.Add(-72*time.Hour)) |
| 218 | s.TrashAppRow(acct, "tmp-later", now.Add(-71*time.Hour)) |
| 219 | due, err := s.PurgeDueApps(now) |
| 220 | if err != nil { |
| 221 | t.Fatal(err) |
| 222 | } |
| 223 | if len(due) != 1 || due[0].App != "tmp-due" || due[0].Sqid != "tst" || due[0].PurgeFor != now.Add(-72*time.Hour).Unix() { |
| 224 | t.Fatalf("PurgeDueApps = %+v, want only tmp-due (a permanent row never has purge_after)", due) |
| 225 | } |
| 226 | rows, err := s.TemporaryAppRows() |
| 227 | if err != nil { |
| 228 | t.Fatal(err) |
| 229 | } |
| 230 | if len(rows) != 2 { |
| 231 | t.Fatalf("TemporaryAppRows = %+v, want the two temporary rows", rows) |
| 232 | } |
| 233 | } |
| 234 | |