Commit 7af5f9d750
Verified · cmc
Layout: unified · split
internal/control/idreuse_test.go added +95
| @@ -0,0 +1,95 @@ | ||
| 1 | package control | |
| 2 | ||
| 3 | import ( | |
| 4 | "fmt" | |
| 5 | "testing" | |
| 6 | "time" | |
| 7 | ||
| 8 | "gitbay.org/gitbay/internal/policy" | |
| 9 | ) | |
| 10 | ||
| 11 | // A deploy key names its repository by id in its scope, and deleting | |
| 12 | // the repository leaves the key. The next repository does not take the | |
| 13 | // id, so the key opens nothing (#306). | |
| 14 | func TestDeployKeyOfDeletedRepository(t *testing.T) { | |
| 15 | st, _, uid := newQueueTestRepo(t) | |
| 16 | goneID, err := st.CreateRepo("user", uid, "gone", "private") | |
| 17 | if err != nil { | |
| 18 | t.Fatal(err) | |
| 19 | } | |
| 20 | scope := fmt.Sprintf("deploy:%d:rw", goneID) | |
| 21 | if err := st.AddSSHKey(uid, "SHA256:deploy", "ssh-ed25519", []byte("d"), scope, ""); err != nil { | |
| 22 | t.Fatal(err) | |
| 23 | } | |
| 24 | if err := st.DeleteRepo(goneID); err != nil { | |
| 25 | t.Fatal(err) | |
| 26 | } | |
| 27 | nextID, err := st.CreateRepo("user", uid, "next", "private") | |
| 28 | if err != nil { | |
| 29 | t.Fatal(err) | |
| 30 | } | |
| 31 | if policy.DeployScopeAllows(scope, nextID, false) { | |
| 32 | t.Fatalf("the deleted repository's deploy key reads repository %d", nextID) | |
| 33 | } | |
| 34 | } | |
| 35 | ||
| 36 | // A grant names its account by id and outlives the account. The next | |
| 37 | // account does not take the id, so it does not inherit the grant (#306). | |
| 38 | func TestGrantOfDeletedAccount(t *testing.T) { | |
| 39 | st, repo, _ := newQueueTestRepo(t) | |
| 40 | carol, err := st.CreateUser("carol", false) | |
| 41 | if err != nil { | |
| 42 | t.Fatal(err) | |
| 43 | } | |
| 44 | if err := st.GrantAccess(repo.ID, carol, "admin"); err != nil { | |
| 45 | t.Fatal(err) | |
| 46 | } | |
| 47 | if err := st.DeleteUser(carol); err != nil { | |
| 48 | t.Fatal(err) | |
| 49 | } | |
| 50 | dave, err := st.CreateUser("dave", false) | |
| 51 | if err != nil { | |
| 52 | t.Fatal(err) | |
| 53 | } | |
| 54 | if role, err := st.AccessRole(repo.ID, dave); err != nil || role != "" { | |
| 55 | t.Fatalf("new account's role on the repository: %q, %v", role, err) | |
| 56 | } | |
| 57 | } | |
| 58 | ||
| 59 | // A merge queued with a key that is then removed stays refused when a | |
| 60 | // new key is added: key_id is set to NULL on removal and the new key has | |
| 61 | // an id of its own (#289, #306). | |
| 62 | func TestQueuedMergeKeyRemovedThenNewKey(t *testing.T) { | |
| 63 | st, repo, uid := newQueueTestRepo(t) | |
| 64 | if err := st.AddSSHKey(uid, "SHA256:k1", "ssh-ed25519", []byte("k1"), "full", ""); err != nil { | |
| 65 | t.Fatal(err) | |
| 66 | } | |
| 67 | k1, err := st.SSHKeyByFingerprint("SHA256:k1") | |
| 68 | if err != nil { | |
| 69 | t.Fatal(err) | |
| 70 | } | |
| 71 | if _, err := st.CreateMR(repo.ID, uid, repo.ID, "feature", "main", "t", "", "abc", "md", false); err != nil { | |
| 72 | t.Fatal(err) | |
| 73 | } | |
| 74 | mr, err := st.MRByNumber(repo.ID, 1) | |
| 75 | if err != nil { | |
| 76 | t.Fatal(err) | |
| 77 | } | |
| 78 | mrID := mr.ID | |
| 79 | if err := st.QueueMerge(mrID, uid, "", k1.ID, 0); err != nil { | |
| 80 | t.Fatal(err) | |
| 81 | } | |
| 82 | if err := st.RemoveSSHKey(uid, "SHA256:k1"); err != nil { | |
| 83 | t.Fatal(err) | |
| 84 | } | |
| 85 | if err := st.AddSSHKey(uid, "SHA256:k2", "ssh-ed25519", []byte("k2"), "full", ""); err != nil { | |
| 86 | t.Fatal(err) | |
| 87 | } | |
| 88 | if k2, _ := st.SSHKeyByFingerprint("SHA256:k2"); k2.ID == k1.ID { | |
| 89 | t.Fatalf("new key took the removed key's id %d", k1.ID) | |
| 90 | } | |
| 91 | reason, err := queueCredentialLapsed(st, mrID, time.Now()) | |
| 92 | if err != nil || reason != "the key it was queued with was removed" { | |
| 93 | t.Fatalf("lapsed = %q, %v", reason, err) | |
| 94 | } | |
| 95 | } | |
internal/httpd/lfsauth_test.go +38 −7
| @@ -253,9 +253,10 @@ func TestLFSUploadTokenRefusedOnceArchived(t *testing.T) { | ||
| 253 | 253 | } |
| 254 | 254 | } |
| 255 | 255 | |
| 256 | // SQLite gives a new key the id of the highest deleted one. A token | |
| 257 | // minted for the deleted key is refused when presented against the new | |
| 258 | // key; the new key's own token works (#303). | |
| 256 | // A removed key's id is not handed to the next key (#306), so its token | |
| 257 | // names no live key. An id freed and taken before ids stopped being | |
| 258 | // reused is still refused by the fingerprint pin; the new key's own token | |
| 259 | // works (#303). | |
| 259 | 260 | func TestLFSTokenRefusedOnReusedKeyID(t *testing.T) { |
| 260 | 261 | s, st, u := newTokenTestServer(t) |
| 261 | 262 | repo := lfsTestRepo(t, st, u.ID, "app", "private") |
| @@ -269,14 +270,44 @@ func TestLFSTokenRefusedOnReusedKeyID(t *testing.T) { | ||
| 269 | 270 | if err := st.RemoveSSHKey(u.ID, "SHA256:old"); err != nil { |
| 270 | 271 | t.Fatal(err) |
| 271 | 272 | } |
| 272 | newID := lfsTestKey(t, st, u.ID, "SHA256:new", "full") | |
| 273 | if newID != oldID { | |
| 274 | t.Fatalf("new key got id %d, want the reused %d", newID, oldID) | |
| 273 | nextID := lfsTestKey(t, st, u.ID, "SHA256:next", "full") | |
| 274 | if nextID == oldID { | |
| 275 | t.Fatalf("new key took the removed key's id %d", oldID) | |
| 276 | } | |
| 277 | if op, _ := s.lfsAuth(lfsRequest(old), repo); op != "" { | |
| 278 | t.Errorf("removed key's token: %q", op) | |
| 279 | } | |
| 280 | if _, err := st.DB.Exec("INSERT INTO ssh_keys (id, user_id, fingerprint, algo, blob) VALUES (?, ?, 'SHA256:new', 'ssh-ed25519', x'00')", | |
| 281 | oldID, u.ID); err != nil { | |
| 282 | t.Fatal(err) | |
| 275 | 283 | } |
| 276 | 284 | if op, _ := s.lfsAuth(lfsRequest(old), repo); op != "" { |
| 277 | 285 | t.Errorf("old key's token on the reused id: %q", op) |
| 278 | 286 | } |
| 279 | if op, _ := s.lfsAuth(lfsRequest(lfs.Sign(secret, repo.ID, newID, "SHA256:new", "upload", now)), repo); op != "upload" { | |
| 287 | if op, _ := s.lfsAuth(lfsRequest(lfs.Sign(secret, repo.ID, oldID, "SHA256:new", "upload", now)), repo); op != "upload" { | |
| 280 | 288 | t.Errorf("new key's own token: %q", op) |
| 281 | 289 | } |
| 282 | 290 | } |
| 291 | ||
| 292 | // A token names its repository by id. Deleting the repository does not | |
| 293 | // let the next one take that id and the token with it (#306). | |
| 294 | func TestLFSTokenForDeletedRepository(t *testing.T) { | |
| 295 | s, st, u := newTokenTestServer(t) | |
| 296 | gone := lfsTestRepo(t, st, u.ID, "gone", "private") | |
| 297 | secret, err := s.lfsSecret() | |
| 298 | if err != nil { | |
| 299 | t.Fatal(err) | |
| 300 | } | |
| 301 | key := lfsTestKey(t, st, u.ID, "SHA256:owner", "full") | |
| 302 | tok := lfs.Sign(secret, gone.ID, key, "SHA256:owner", "upload", time.Now()) | |
| 303 | if err := st.DeleteRepo(gone.ID); err != nil { | |
| 304 | t.Fatal(err) | |
| 305 | } | |
| 306 | next := lfsTestRepo(t, st, u.ID, "next", "private") | |
| 307 | if next.ID == gone.ID { | |
| 308 | t.Fatalf("new repository took the deleted one's id %d", gone.ID) | |
| 309 | } | |
| 310 | if op, _ := s.lfsAuth(lfsRequest(tok), next); op != "" { | |
| 311 | t.Errorf("deleted repository's token on the next repository: %q", op) | |
| 312 | } | |
| 313 | } | |
internal/lfs/lfs.go +4 −3
| @@ -130,9 +130,10 @@ const TokenTTL = time.Hour | ||
| 130 | 130 | // Sign mints a token for op ("download" or "upload") on repoID, bound |
| 131 | 131 | // to keyID: the SSH key, user or deploy, that asked for it, or 0 for an |
| 132 | 132 | // anonymous download of a public repository. fingerprint is that key's |
| 133 | // fingerprint, "" for key 0. SQLite reuses the id of a deleted key, so | |
| 134 | // the token carries a hash of the fingerprint as well and a new key | |
| 135 | // given the old id does not inherit the old key's tokens (#303). | |
| 133 | // fingerprint, "" for key 0. Key ids freed before they stopped being | |
| 134 | // reused (#306) may belong to a later key, so the token carries a hash | |
| 135 | // of the fingerprint as well and a new key given the old id does not | |
| 136 | // inherit the old key's tokens (#303). | |
| 136 | 137 | func Sign(secret []byte, repoID, keyID int64, fingerprint, op string, now time.Time) string { |
| 137 | 138 | payload := fmt.Sprintf("%d:%d:%s:%s:%d", repoID, keyID, KeyPin(fingerprint), op, now.Add(TokenTTL).Unix()) |
| 138 | 139 | mac := hmac.New(sha256.New, secret) |
internal/mailin/mailin.go +3 −2
| @@ -187,8 +187,9 @@ func (p *Processor) Handle(raw []byte) Result { | ||
| 187 | 187 | case u.Pending: |
| 188 | 188 | return p.refuse(u.ID, msgID, "account not active") |
| 189 | 189 | } |
| 190 | // Ids are reused after a hard delete: an account created after the | |
| 191 | // token was minted is not the one it named. | |
| 190 | // An id freed before ids stopped being reused (#306) may have been | |
| 191 | // taken: an account created after the token was minted is not the | |
| 192 | // one it named. | |
| 192 | 193 | if code := p.createdAfter("users", u.ID, target, msgID, "account"); code != nil { |
| 193 | 194 | return *code |
| 194 | 195 | } |
internal/mailin/mailin_test.go +25 −4
| @@ -336,7 +336,26 @@ func TestDrainRetriesTransientFailure(t *testing.T) { | ||
| 336 | 336 | } |
| 337 | 337 | } |
| 338 | 338 | |
| 339 | // A repository id freed by a delete and taken by a later repository does | |
| 339 | // A deleted repository's id is not handed to the next repository | |
| 340 | // (#306), so a reply meant for it finds no repository. | |
| 341 | func TestDeletedRepositoryID(t *testing.T) { | |
| 342 | f := setup(t) | |
| 343 | m := []byte(f.message(t, "bob@example.test", "hi")) | |
| 344 | if err := f.st.DeleteRepo(f.repo.ID); err != nil { | |
| 345 | t.Fatal(err) | |
| 346 | } | |
| 347 | alice, _ := f.st.UserByUsername("alice") | |
| 348 | id, err := f.st.CreateRepo("user", alice.ID, "other", "public") | |
| 349 | if err != nil || id == f.repo.ID { | |
| 350 | t.Fatalf("new repository has id %d (%v), the deleted one's", id, err) | |
| 351 | } | |
| 352 | res := f.p.Handle(m) | |
| 353 | if res.Posted || !strings.Contains(res.Reason, "repository no longer exists") { | |
| 354 | t.Fatalf("result %+v", res) | |
| 355 | } | |
| 356 | } | |
| 357 | ||
| 358 | // A repository id freed and taken before ids stopped being reused does | |
| 340 | 359 | // not accept replies meant for the old one. |
| 341 | 360 | func TestReusedRepositoryID(t *testing.T) { |
| 342 | 361 | f := setup(t) |
| @@ -345,9 +364,11 @@ func TestReusedRepositoryID(t *testing.T) { | ||
| 345 | 364 | t.Fatal(err) |
| 346 | 365 | } |
| 347 | 366 | alice, _ := f.st.UserByUsername("alice") |
| 348 | id, err := f.st.CreateRepo("user", alice.ID, "other", "public") | |
| 349 | if err != nil || id != f.repo.ID { | |
| 350 | t.Fatalf("new repository has id %d (%v), want the freed %d", id, err, f.repo.ID) | |
| 367 | id := f.repo.ID | |
| 368 | if _, err := f.st.DB.Exec( | |
| 369 | "INSERT INTO repos (id, owner_kind, owner_id, name, visibility) VALUES (?, 'user', ?, 'other', 'public')", | |
| 370 | id, alice.ID); err != nil { | |
| 371 | t.Fatal(err) | |
| 351 | 372 | } |
| 352 | 373 | f.st.CreateIssue(id, alice.ID, "t", "", "md") |
| 353 | 374 | // Created after the token, as it would be outside a fast test. |
internal/store/idreuse_test.go added +322
| @@ -0,0 +1,322 @@ | ||
| 1 | package store | |
| 2 | ||
| 3 | import ( | |
| 4 | "strconv" | |
| 5 | "strings" | |
| 6 | "testing" | |
| 7 | ) | |
| 8 | ||
| 9 | // idMigration is the version of the migration that makes ids | |
| 10 | // AUTOINCREMENT (#306), found by name so the test survives renumbering. | |
| 11 | func idMigration(t *testing.T) int { | |
| 12 | t.Helper() | |
| 13 | ms, err := loadMigrations() | |
| 14 | if err != nil { | |
| 15 | t.Fatal(err) | |
| 16 | } | |
| 17 | for _, m := range ms { | |
| 18 | if m.name == "id_autoincrement" { | |
| 19 | return m.version | |
| 20 | } | |
| 21 | } | |
| 22 | t.Fatal("no id_autoincrement migration") | |
| 23 | return 0 | |
| 24 | } | |
| 25 | ||
| 26 | var autoincTables = []string{"users", "orgs", "repos", "api_tokens", "ssh_keys", "webhook_deliveries", "push_queue"} | |
| 27 | ||
| 28 | func mustExec(t *testing.T, s *Store, q string, args ...any) int64 { | |
| 29 | t.Helper() | |
| 30 | res, err := s.DB.Exec(q, args...) | |
| 31 | if err != nil { | |
| 32 | t.Fatalf("%s: %v", q, err) | |
| 33 | } | |
| 34 | id, _ := res.LastInsertId() | |
| 35 | return id | |
| 36 | } | |
| 37 | ||
| 38 | func count(t *testing.T, s *Store, table string) int { | |
| 39 | t.Helper() | |
| 40 | var n int | |
| 41 | if err := s.DB.QueryRow("SELECT COUNT(*) FROM " + table).Scan(&n); err != nil { | |
| 42 | t.Fatal(err) | |
| 43 | } | |
| 44 | return n | |
| 45 | } | |
| 46 | ||
| 47 | func fkClean(t *testing.T, s *Store) { | |
| 48 | t.Helper() | |
| 49 | rows, err := s.DB.Query("PRAGMA foreign_key_check") | |
| 50 | if err != nil { | |
| 51 | t.Fatal(err) | |
| 52 | } | |
| 53 | defer rows.Close() | |
| 54 | if rows.Next() { | |
| 55 | var table, parent string | |
| 56 | var rowid, fk any | |
| 57 | rows.Scan(&table, &rowid, &parent, &fk) | |
| 58 | t.Fatalf("foreign_key_check: %s row %v -> %s", table, rowid, parent) | |
| 59 | } | |
| 60 | } | |
| 61 | ||
| 62 | // seedForIDs fills every rebuilt table and the rows that name their ids, | |
| 63 | // then deletes the newest repository and the newest account while a | |
| 64 | // deploy key and a grant still name them. | |
| 65 | func seedForIDs(t *testing.T, s *Store) (goneRepo, goneUser int64) { | |
| 66 | t.Helper() | |
| 67 | alice := mustExec(t, s, "INSERT INTO users (username) VALUES ('alice')") | |
| 68 | org := mustExec(t, s, "INSERT INTO orgs (name) VALUES ('acme')") | |
| 69 | repo := mustExec(t, s, "INSERT INTO repos (owner_kind, owner_id, name, visibility) VALUES ('user', ?, 'app', 'public')", alice) | |
| 70 | mustExec(t, s, "INSERT INTO repos (owner_kind, owner_id, name, visibility, fork_of) VALUES ('org', ?, 'fork', 'private', ?)", org, repo) | |
| 71 | tok := mustExec(t, s, "INSERT INTO api_tokens (user_id, name, token_hash) VALUES (?, 't1', 'h1')", alice) | |
| 72 | mustExec(t, s, "INSERT INTO api_tokens (user_id, name, token_hash, created_by_token) VALUES (?, 't2', 'h2', ?)", alice, tok) | |
| 73 | mustExec(t, s, "INSERT INTO ssh_keys (user_id, fingerprint, algo, blob, created_by_token) VALUES (?, 'SHA256:a', 'ssh-ed25519', x'00', ?)", alice, tok) | |
| 74 | hook := mustExec(t, s, "INSERT INTO webhooks (repo_id, url) VALUES (?, 'https://example.org/h')", repo) | |
| 75 | ev := mustExec(t, s, "INSERT INTO events (repo_id, actor_id, kind) VALUES (?, ?, 'push')", repo, alice) | |
| 76 | mustExec(t, s, "INSERT INTO webhook_deliveries (webhook_id, event_id) VALUES (?, ?)", hook, ev) | |
| 77 | dev := mustExec(t, s, "INSERT INTO push_devices (user_id, token) VALUES (?, 'devtok')", alice) | |
| 78 | mustExec(t, s, "INSERT INTO push_queue (device_id, title, body, path) VALUES (?, 't', 'b', 'p')", dev) | |
| 79 | mustExec(t, s, "INSERT INTO org_members (org_id, user_id, role) VALUES (?, ?, 'admin')", org, alice) | |
| 80 | ||
| 81 | goneRepo = mustExec(t, s, "INSERT INTO repos (owner_kind, owner_id, name, visibility) VALUES ('user', ?, 'gone', 'private')", alice) | |
| 82 | mustExec(t, s, "INSERT INTO ssh_keys (user_id, fingerprint, algo, blob, scope) VALUES (?, 'SHA256:d', 'ssh-ed25519', x'00', ?)", | |
| 83 | alice, "deploy:"+itoa(goneRepo)+":rw") | |
| 84 | if err := s.DeleteRepo(goneRepo); err != nil { | |
| 85 | t.Fatal(err) | |
| 86 | } | |
| 87 | goneUser = mustExec(t, s, "INSERT INTO users (username) VALUES ('carol')") | |
| 88 | if err := s.GrantAccess(repo, goneUser, "write"); err != nil { | |
| 89 | t.Fatal(err) | |
| 90 | } | |
| 91 | if err := s.DeleteUser(goneUser); err != nil { | |
| 92 | t.Fatal(err) | |
| 93 | } | |
| 94 | return goneRepo, goneUser | |
| 95 | } | |
| 96 | ||
| 97 | func itoa(n int64) string { return strconv.FormatInt(n, 10) } | |
| 98 | ||
| 99 | func TestIDMigrationKeepsRowsAndForeignKeys(t *testing.T) { | |
| 100 | v := idMigration(t) | |
| 101 | s := open(t) | |
| 102 | if err := s.MigrateTo(v - 1); err != nil { | |
| 103 | t.Fatal(err) | |
| 104 | } | |
| 105 | goneRepo, goneUser := seedForIDs(t, s) | |
| 106 | before := map[string]int{} | |
| 107 | for _, tbl := range append(autoincTables, "org_members", "webhooks", "events", "push_devices", "repo_access") { | |
| 108 | before[tbl] = count(t, s, tbl) | |
| 109 | } | |
| 110 | if err := s.MigrateTo(v); err != nil { | |
| 111 | t.Fatal(err) | |
| 112 | } | |
| 113 | for tbl, n := range before { | |
| 114 | if got := count(t, s, tbl); got != n { | |
| 115 | t.Errorf("%s: %d rows after the migration, want %d", tbl, got, n) | |
| 116 | } | |
| 117 | } | |
| 118 | for _, tbl := range autoincTables { | |
| 119 | var sql string | |
| 120 | if err := s.DB.QueryRow("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = ?", tbl).Scan(&sql); err != nil { | |
| 121 | t.Fatal(err) | |
| 122 | } | |
| 123 | if !strings.Contains(sql, "AUTOINCREMENT") { | |
| 124 | t.Errorf("%s is not AUTOINCREMENT", tbl) | |
| 125 | } | |
| 126 | } | |
| 127 | fkClean(t, s) | |
| 128 | ||
| 129 | // Children still name the rebuilt parents, not the *_old tables. | |
| 130 | var n int | |
| 131 | if err := s.DB.QueryRow(`SELECT COUNT(*) FROM sqlite_master m, pragma_foreign_key_list(m.name) f | |
| 132 | WHERE m.type = 'table' AND f."table" LIKE '%_old'`).Scan(&n); err != nil { | |
| 133 | t.Fatal(err) | |
| 134 | } | |
| 135 | if n != 0 { | |
| 136 | t.Fatalf("%d foreign keys name a *_old table", n) | |
| 137 | } | |
| 138 | for _, c := range []struct{ child, parent string }{ | |
| 139 | {"ssh_keys", "users"}, {"ssh_keys", "api_tokens"}, {"api_tokens", "api_tokens"}, | |
| 140 | {"repos", "repos"}, {"issues", "repos"}, {"teams", "orgs"}, {"mr_merge_queue", "ssh_keys"}, | |
| 141 | {"push_queue", "push_devices"}, {"webhook_deliveries", "webhooks"}, | |
| 142 | } { | |
| 143 | if err := s.DB.QueryRow(`SELECT COUNT(*) FROM pragma_foreign_key_list(?) WHERE "table" = ?`, c.child, c.parent).Scan(&n); err != nil || n == 0 { | |
| 144 | t.Errorf("%s has no foreign key to %s (%v)", c.child, c.parent, err) | |
| 145 | } | |
| 146 | } | |
| 147 | for _, idx := range []string{"ssh_keys_user", "webhook_deliveries_due", "push_queue_due"} { | |
| 148 | if err := s.DB.QueryRow("SELECT COUNT(*) FROM sqlite_master WHERE type = 'index' AND name = ?", idx).Scan(&n); err != nil || n != 1 { | |
| 149 | t.Errorf("index %s missing", idx) | |
| 150 | } | |
| 151 | } | |
| 152 | ||
| 153 | // The triggers still fire. | |
| 154 | alice, err := s.UserByUsername("alice") | |
| 155 | if err != nil { | |
| 156 | t.Fatal(err) | |
| 157 | } | |
| 158 | if _, err := s.DB.Exec("DELETE FROM users WHERE id = ?", alice.ID); err == nil || !strings.Contains(err.Error(), "still owns repositories") { | |
| 159 | t.Fatalf("deleting a repository owner: %v", err) | |
| 160 | } | |
| 161 | if _, err := s.DB.Exec("DELETE FROM orgs WHERE name = 'acme'"); err == nil || !strings.Contains(err.Error(), "still owns repositories") { | |
| 162 | t.Fatalf("deleting an owning org: %v", err) | |
| 163 | } | |
| 164 | // Cascades still reach the rebuilt tables' children. | |
| 165 | mustExec(t, s, "DELETE FROM push_devices WHERE token = 'devtok'") | |
| 166 | if got := count(t, s, "push_queue"); got != 0 { | |
| 167 | t.Fatalf("push_queue after its device went: %d rows", got) | |
| 168 | } | |
| 169 | ||
| 170 | // The sequences start above the ids a deploy key and a grant still | |
| 171 | // name, though neither row survived. | |
| 172 | var seq int64 | |
| 173 | s.DB.QueryRow("SELECT seq FROM sqlite_sequence WHERE name = 'repos'").Scan(&seq) | |
| 174 | if seq < goneRepo { | |
| 175 | t.Fatalf("repos sequence %d, want at least %d", seq, goneRepo) | |
| 176 | } | |
| 177 | r, err := s.CreateRepo("user", alice.ID, "new", "public") | |
| 178 | if err != nil { | |
| 179 | t.Fatal(err) | |
| 180 | } | |
| 181 | if r <= goneRepo { | |
| 182 | t.Fatalf("new repository took id %d; a deploy key still names %d", r, goneRepo) | |
| 183 | } | |
| 184 | u, err := s.CreateUser("dave", false) | |
| 185 | if err != nil { | |
| 186 | t.Fatal(err) | |
| 187 | } | |
| 188 | if u <= goneUser { | |
| 189 | t.Fatalf("new account took id %d; a grant still names %d", u, goneUser) | |
| 190 | } | |
| 191 | ||
| 192 | // Down and up again keep the rows. | |
| 193 | if err := s.MigrateTo(v - 1); err != nil { | |
| 194 | t.Fatal(err) | |
| 195 | } | |
| 196 | if got := count(t, s, "users"); got != before["users"]+1 { | |
| 197 | t.Fatalf("users after down: %d", got) | |
| 198 | } | |
| 199 | if err := s.DB.QueryRow("SELECT COUNT(*) FROM sqlite_sequence WHERE name = 'users'").Scan(&n); err != nil || n != 0 { | |
| 200 | t.Fatalf("sqlite_sequence keeps users after down: %d, %v", n, err) | |
| 201 | } | |
| 202 | fkClean(t, s) | |
| 203 | if err := s.MigrateUp(); err != nil { | |
| 204 | t.Fatal(err) | |
| 205 | } | |
| 206 | fkClean(t, s) | |
| 207 | } | |
| 208 | ||
| 209 | // A parked profile about text names its owner by kind and id with no | |
| 210 | // foreign key; the sequences start above the ids it names (#306). | |
| 211 | func TestIDMigrationSeedsFromAboutBackfill(t *testing.T) { | |
| 212 | v := idMigration(t) | |
| 213 | s := open(t) | |
| 214 | if err := s.MigrateTo(v - 1); err != nil { | |
| 215 | t.Fatal(err) | |
| 216 | } | |
| 217 | mustExec(t, s, "INSERT INTO users (username) VALUES ('alice')") | |
| 218 | mustExec(t, s, "INSERT INTO orgs (name) VALUES ('acme')") | |
| 219 | mustExec(t, s, `INSERT INTO profile_about_backfill (owner_kind, owner_id, about, about_format) | |
| 220 | VALUES ('user', 50, 'a', 'md'), ('org', 40, 'b', 'md')`) | |
| 221 | if err := s.MigrateTo(v); err != nil { | |
| 222 | t.Fatal(err) | |
| 223 | } | |
| 224 | for table, want := range map[string]int64{"users": 50, "orgs": 40} { | |
| 225 | var seq int64 | |
| 226 | if err := s.DB.QueryRow("SELECT seq FROM sqlite_sequence WHERE name = ?", table).Scan(&seq); err != nil { | |
| 227 | t.Fatal(err) | |
| 228 | } | |
| 229 | if seq < want { | |
| 230 | t.Errorf("%s sequence %d, want at least %d", table, seq, want) | |
| 231 | } | |
| 232 | } | |
| 233 | } | |
| 234 | ||
| 235 | // Deleting the row with the highest id does not free that id, in any of | |
| 236 | // the rebuilt tables. | |
| 237 | func TestIDsNotReusedAfterDelete(t *testing.T) { | |
| 238 | s := open(t) | |
| 239 | if err := s.MigrateUp(); err != nil { | |
| 240 | t.Fatal(err) | |
| 241 | } | |
| 242 | u1 := mustExec(t, s, "INSERT INTO users (username) VALUES ('u1')") | |
| 243 | u2 := mustExec(t, s, "INSERT INTO users (username) VALUES ('u2')") | |
| 244 | repo := mustExec(t, s, "INSERT INTO repos (owner_kind, owner_id, name, visibility) VALUES ('user', ?, 'r', 'public')", u1) | |
| 245 | hook := mustExec(t, s, "INSERT INTO webhooks (repo_id, url) VALUES (?, 'https://example.org/h')", repo) | |
| 246 | ev := mustExec(t, s, "INSERT INTO events (repo_id, kind) VALUES (?, 'push')", repo) | |
| 247 | dev := mustExec(t, s, "INSERT INTO push_devices (user_id, token) VALUES (?, 'devtok')", u1) | |
| 248 | ||
| 249 | cases := []struct { | |
| 250 | table, insert string | |
| 251 | args func(i int) []any | |
| 252 | }{ | |
| 253 | {"users", "INSERT INTO users (username) VALUES (?)", func(i int) []any { return []any{"x" + itoa(int64(i))} }}, | |
| 254 | {"orgs", "INSERT INTO orgs (name) VALUES (?)", func(i int) []any { return []any{"o" + itoa(int64(i))} }}, | |
| 255 | {"repos", "INSERT INTO repos (owner_kind, owner_id, name, visibility) VALUES ('user', ?, ?, 'public')", | |
| 256 | func(i int) []any { return []any{u1, "n" + itoa(int64(i))} }}, | |
| 257 | {"api_tokens", "INSERT INTO api_tokens (user_id, name, token_hash) VALUES (?, ?, ?)", | |
| 258 | func(i int) []any { return []any{u2, "t" + itoa(int64(i)), "h" + itoa(int64(i))} }}, | |
| 259 | {"ssh_keys", "INSERT INTO ssh_keys (user_id, fingerprint, algo, blob) VALUES (?, ?, 'ssh-ed25519', x'00')", | |
| 260 | func(i int) []any { return []any{u2, "SHA256:" + itoa(int64(i))} }}, | |
| 261 | {"webhook_deliveries", "INSERT INTO webhook_deliveries (webhook_id, event_id) VALUES (?, ?)", | |
| 262 | func(int) []any { return []any{hook, ev} }}, | |
| 263 | {"push_queue", "INSERT INTO push_queue (device_id, title, body, path) VALUES (?, 't', 'b', 'p')", | |
| 264 | func(int) []any { return []any{dev} }}, | |
| 265 | } | |
| 266 | for _, c := range cases { | |
| 267 | first := mustExec(t, s, c.insert, c.args(1)...) | |
| 268 | mustExec(t, s, "DELETE FROM "+c.table+" WHERE id = ?", first) | |
| 269 | second := mustExec(t, s, c.insert, c.args(2)...) | |
| 270 | if second <= first { | |
| 271 | t.Errorf("%s: id %d handed out again after its row was deleted (got %d)", c.table, first, second) | |
| 272 | } | |
| 273 | } | |
| 274 | } | |
| 275 | ||
| 276 | // A sender marks a webhook delivery or a push by id after its request | |
| 277 | // returns. If the hook or device was removed meanwhile, the cascade took | |
| 278 | // the row, and the next row must not take its id and its mark (#306). | |
| 279 | func TestInFlightMarksAfterCascade(t *testing.T) { | |
| 280 | s := open(t) | |
| 281 | if err := s.MigrateUp(); err != nil { | |
| 282 | t.Fatal(err) | |
| 283 | } | |
| 284 | u := mustExec(t, s, "INSERT INTO users (username) VALUES ('u')") | |
| 285 | repo := mustExec(t, s, "INSERT INTO repos (owner_kind, owner_id, name, visibility) VALUES ('user', ?, 'r', 'public')", u) | |
| 286 | ev := mustExec(t, s, "INSERT INTO events (repo_id, kind) VALUES (?, 'push')", repo) | |
| 287 | h1 := mustExec(t, s, "INSERT INTO webhooks (repo_id, url) VALUES (?, 'https://example.org/1')", repo) | |
| 288 | h2 := mustExec(t, s, "INSERT INTO webhooks (repo_id, url) VALUES (?, 'https://example.org/2')", repo) | |
| 289 | inFlight := mustExec(t, s, "INSERT INTO webhook_deliveries (webhook_id, event_id) VALUES (?, ?)", h1, ev) | |
| 290 | if err := s.RemoveWebhook(repo, h1); err != nil { | |
| 291 | t.Fatal(err) | |
| 292 | } | |
| 293 | next := mustExec(t, s, "INSERT INTO webhook_deliveries (webhook_id, event_id) VALUES (?, ?)", h2, ev) | |
| 294 | if err := s.MarkDelivered(inFlight, 200); err != nil { | |
| 295 | t.Fatal(err) | |
| 296 | } | |
| 297 | var delivered *string | |
| 298 | if err := s.DB.QueryRow("SELECT delivered_at FROM webhook_deliveries WHERE id = ?", next).Scan(&delivered); err != nil { | |
| 299 | t.Fatal(err) | |
| 300 | } | |
| 301 | if delivered != nil { | |
| 302 | t.Fatal("the removed hook's delivery was marked on the next hook's") | |
| 303 | } | |
| 304 | ||
| 305 | d1 := mustExec(t, s, "INSERT INTO push_devices (user_id, token) VALUES (?, 'd1')", u) | |
| 306 | d2 := mustExec(t, s, "INSERT INTO push_devices (user_id, token) VALUES (?, 'd2')", u) | |
| 307 | pushing := mustExec(t, s, "INSERT INTO push_queue (device_id, title, body, path) VALUES (?, 't', 'b', 'p')", d1) | |
| 308 | if err := s.RemovePushDevice(u, d1); err != nil { | |
| 309 | t.Fatal(err) | |
| 310 | } | |
| 311 | queued := mustExec(t, s, "INSERT INTO push_queue (device_id, title, body, path) VALUES (?, 't', 'b', 'p')", d2) | |
| 312 | if err := s.MarkPushSent(pushing); err != nil { | |
| 313 | t.Fatal(err) | |
| 314 | } | |
| 315 | var sent *string | |
| 316 | if err := s.DB.QueryRow("SELECT sent_at FROM push_queue WHERE id = ?", queued).Scan(&sent); err != nil { | |
| 317 | t.Fatal(err) | |
| 318 | } | |
| 319 | if sent != nil { | |
| 320 | t.Fatal("the removed device's push was marked on the next device's") | |
| 321 | } | |
| 322 | } | |
internal/store/migrations/0074_id_autoincrement.down.sql added +175
| @@ -0,0 +1,175 @@ | ||
| 1 | -- foreign_keys: off | |
| 2 | -- Back to ids without AUTOINCREMENT: the same rebuild with the keyword | |
| 3 | -- dropped, and the tables' sqlite_sequence rows removed. | |
| 4 | PRAGMA legacy_alter_table = ON; | |
| 5 | ||
| 6 | DROP TRIGGER users_owning_repos; | |
| 7 | DROP TRIGGER orgs_owning_repos; | |
| 8 | ||
| 9 | ALTER TABLE users RENAME TO users_old; | |
| 10 | CREATE TABLE users ( | |
| 11 | id INTEGER PRIMARY KEY, | |
| 12 | username TEXT NOT NULL UNIQUE, | |
| 13 | is_admin INTEGER NOT NULL DEFAULT 0, | |
| 14 | created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')), | |
| 15 | pending INTEGER NOT NULL DEFAULT 0, | |
| 16 | description TEXT NOT NULL DEFAULT '', | |
| 17 | website TEXT NOT NULL DEFAULT '', | |
| 18 | disabled INTEGER NOT NULL DEFAULT 0, | |
| 19 | links TEXT NOT NULL DEFAULT '', | |
| 20 | repo_limit INTEGER, | |
| 21 | byte_limit INTEGER, | |
| 22 | notify_mail INTEGER NOT NULL DEFAULT 1, | |
| 23 | notify_watch INTEGER NOT NULL DEFAULT 0, | |
| 24 | theme TEXT NOT NULL DEFAULT 'system', | |
| 25 | notify_push INTEGER NOT NULL DEFAULT 1, | |
| 26 | diff_layout TEXT NOT NULL DEFAULT 'unified', | |
| 27 | notify_reply INTEGER NOT NULL DEFAULT 0 | |
| 28 | ); | |
| 29 | INSERT INTO users (id, username, is_admin, created_at, pending, description, website, disabled, | |
| 30 | links, repo_limit, byte_limit, notify_mail, notify_watch, theme, notify_push, diff_layout, notify_reply) | |
| 31 | SELECT id, username, is_admin, created_at, pending, description, website, disabled, | |
| 32 | links, repo_limit, byte_limit, notify_mail, notify_watch, theme, notify_push, diff_layout, notify_reply | |
| 33 | FROM users_old; | |
| 34 | DROP TABLE users_old; | |
| 35 | ||
| 36 | ALTER TABLE orgs RENAME TO orgs_old; | |
| 37 | CREATE TABLE orgs ( | |
| 38 | id INTEGER PRIMARY KEY, | |
| 39 | name TEXT NOT NULL UNIQUE, | |
| 40 | created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')), | |
| 41 | description TEXT NOT NULL DEFAULT '', | |
| 42 | website TEXT NOT NULL DEFAULT '', | |
| 43 | members_role TEXT NOT NULL DEFAULT 'write' | |
| 44 | CHECK (members_role IN ('write', 'read', 'none')), | |
| 45 | links TEXT NOT NULL DEFAULT '' | |
| 46 | ); | |
| 47 | INSERT INTO orgs (id, name, created_at, description, website, members_role, links) | |
| 48 | SELECT id, name, created_at, description, website, members_role, links FROM orgs_old; | |
| 49 | DROP TABLE orgs_old; | |
| 50 | ||
| 51 | ALTER TABLE repos RENAME TO repos_old; | |
| 52 | CREATE TABLE repos ( | |
| 53 | id INTEGER PRIMARY KEY, | |
| 54 | owner_kind TEXT NOT NULL CHECK (owner_kind IN ('user','org')), | |
| 55 | owner_id INTEGER NOT NULL, | |
| 56 | name TEXT NOT NULL, | |
| 57 | visibility TEXT NOT NULL CHECK (visibility IN ('public','private')), | |
| 58 | default_branch TEXT NOT NULL DEFAULT 'main', | |
| 59 | fork_of INTEGER REFERENCES repos(id) ON DELETE SET NULL, | |
| 60 | issue_counter INTEGER NOT NULL DEFAULT 0, | |
| 61 | mr_counter INTEGER NOT NULL DEFAULT 0, | |
| 62 | settings_json TEXT NOT NULL DEFAULT '{}', | |
| 63 | created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')), | |
| 64 | build_counter INTEGER NOT NULL DEFAULT 0, | |
| 65 | UNIQUE (owner_kind, owner_id, name) | |
| 66 | ); | |
| 67 | INSERT INTO repos (id, owner_kind, owner_id, name, visibility, default_branch, fork_of, | |
| 68 | issue_counter, mr_counter, settings_json, created_at, build_counter) | |
| 69 | SELECT id, owner_kind, owner_id, name, visibility, default_branch, fork_of, | |
| 70 | issue_counter, mr_counter, settings_json, created_at, build_counter | |
| 71 | FROM repos_old; | |
| 72 | DROP TABLE repos_old; | |
| 73 | ||
| 74 | ALTER TABLE api_tokens RENAME TO api_tokens_old; | |
| 75 | CREATE TABLE api_tokens ( | |
| 76 | id INTEGER PRIMARY KEY, | |
| 77 | user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, | |
| 78 | name TEXT NOT NULL, | |
| 79 | token_hash TEXT NOT NULL UNIQUE, | |
| 80 | scope TEXT NOT NULL DEFAULT 'full' CHECK (scope IN ('full','read')), | |
| 81 | created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')), | |
| 82 | expires_at TEXT, | |
| 83 | last_used_at TEXT, | |
| 84 | created_by_token INTEGER REFERENCES api_tokens(id) ON DELETE SET NULL, | |
| 85 | UNIQUE (user_id, name) | |
| 86 | ); | |
| 87 | INSERT INTO api_tokens (id, user_id, name, token_hash, scope, created_at, expires_at, | |
| 88 | last_used_at, created_by_token) | |
| 89 | SELECT id, user_id, name, token_hash, scope, created_at, expires_at, | |
| 90 | last_used_at, created_by_token | |
| 91 | FROM api_tokens_old; | |
| 92 | DROP TABLE api_tokens_old; | |
| 93 | ||
| 94 | ALTER TABLE ssh_keys RENAME TO ssh_keys_old; | |
| 95 | CREATE TABLE ssh_keys ( | |
| 96 | id INTEGER PRIMARY KEY, | |
| 97 | user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, | |
| 98 | fingerprint TEXT NOT NULL UNIQUE, | |
| 99 | algo TEXT NOT NULL, | |
| 100 | blob BLOB NOT NULL, | |
| 101 | scope TEXT NOT NULL DEFAULT 'full', | |
| 102 | created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')), | |
| 103 | last_used_at TEXT, | |
| 104 | label TEXT NOT NULL DEFAULT '', | |
| 105 | created_by_token INTEGER REFERENCES api_tokens(id) ON DELETE SET NULL, | |
| 106 | expires_at TEXT | |
| 107 | ); | |
| 108 | INSERT INTO ssh_keys (id, user_id, fingerprint, algo, blob, scope, created_at, last_used_at, | |
| 109 | label, created_by_token, expires_at) | |
| 110 | SELECT id, user_id, fingerprint, algo, blob, scope, created_at, last_used_at, | |
| 111 | label, created_by_token, expires_at | |
| 112 | FROM ssh_keys_old; | |
| 113 | DROP TABLE ssh_keys_old; | |
| 114 | CREATE INDEX ssh_keys_user ON ssh_keys(user_id); | |
| 115 | ||
| 116 | ALTER TABLE webhook_deliveries RENAME TO webhook_deliveries_old; | |
| 117 | CREATE TABLE webhook_deliveries ( | |
| 118 | id INTEGER PRIMARY KEY, | |
| 119 | webhook_id INTEGER NOT NULL REFERENCES webhooks(id) ON DELETE CASCADE, | |
| 120 | event_id INTEGER NOT NULL REFERENCES events(id) ON DELETE CASCADE, | |
| 121 | attempts INTEGER NOT NULL DEFAULT 0, | |
| 122 | next_attempt_at TEXT, | |
| 123 | delivered_at TEXT, | |
| 124 | failed_at TEXT, | |
| 125 | last_status INTEGER, | |
| 126 | last_error TEXT, | |
| 127 | created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) | |
| 128 | ); | |
| 129 | INSERT INTO webhook_deliveries (id, webhook_id, event_id, attempts, next_attempt_at, | |
| 130 | delivered_at, failed_at, last_status, last_error, created_at) | |
| 131 | SELECT id, webhook_id, event_id, attempts, next_attempt_at, | |
| 132 | delivered_at, failed_at, last_status, last_error, created_at | |
| 133 | FROM webhook_deliveries_old; | |
| 134 | DROP TABLE webhook_deliveries_old; | |
| 135 | CREATE INDEX webhook_deliveries_due ON webhook_deliveries(next_attempt_at) | |
| 136 | WHERE delivered_at IS NULL AND failed_at IS NULL; | |
| 137 | ||
| 138 | ALTER TABLE push_queue RENAME TO push_queue_old; | |
| 139 | CREATE TABLE push_queue ( | |
| 140 | id INTEGER PRIMARY KEY, | |
| 141 | device_id INTEGER NOT NULL REFERENCES push_devices(id) ON DELETE CASCADE, | |
| 142 | title TEXT NOT NULL, | |
| 143 | body TEXT NOT NULL, | |
| 144 | path TEXT NOT NULL, | |
| 145 | attempts INTEGER NOT NULL DEFAULT 0, | |
| 146 | next_attempt_at TEXT, | |
| 147 | sent_at TEXT, | |
| 148 | failed_at TEXT, | |
| 149 | last_error TEXT, | |
| 150 | created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) | |
| 151 | ); | |
| 152 | INSERT INTO push_queue (id, device_id, title, body, path, attempts, next_attempt_at, | |
| 153 | sent_at, failed_at, last_error, created_at) | |
| 154 | SELECT id, device_id, title, body, path, attempts, next_attempt_at, | |
| 155 | sent_at, failed_at, last_error, created_at | |
| 156 | FROM push_queue_old; | |
| 157 | DROP TABLE push_queue_old; | |
| 158 | CREATE INDEX push_queue_due ON push_queue(next_attempt_at) | |
| 159 | WHERE sent_at IS NULL AND failed_at IS NULL; | |
| 160 | ||
| 161 | CREATE TRIGGER users_owning_repos BEFORE DELETE ON users | |
| 162 | WHEN EXISTS (SELECT 1 FROM repos WHERE owner_kind = 'user' AND owner_id = OLD.id) | |
| 163 | BEGIN | |
| 164 | SELECT RAISE(ABORT, 'user still owns repositories'); | |
| 165 | END; | |
| 166 | CREATE TRIGGER orgs_owning_repos BEFORE DELETE ON orgs | |
| 167 | WHEN EXISTS (SELECT 1 FROM repos WHERE owner_kind = 'org' AND owner_id = OLD.id) | |
| 168 | BEGIN | |
| 169 | SELECT RAISE(ABORT, 'organization still owns repositories'); | |
| 170 | END; | |
| 171 | ||
| 172 | PRAGMA legacy_alter_table = OFF; | |
| 173 | ||
| 174 | DELETE FROM sqlite_sequence WHERE name IN | |
| 175 | ('users', 'orgs', 'repos', 'api_tokens', 'ssh_keys', 'webhook_deliveries', 'push_queue'); | |
internal/store/migrations/0074_id_autoincrement.up.sql added +211
| @@ -0,0 +1,211 @@ | ||
| 1 | -- foreign_keys: off | |
| 2 | -- Ids that are named after their row is gone are never handed out again | |
| 3 | -- (#306). Without AUTOINCREMENT SQLite gives a new row MAX(id)+1, so | |
| 4 | -- deleting the newest account, organization, repository, key or token | |
| 5 | -- let the next one take its id, and with it whatever still named that | |
| 6 | -- id: a deploy key's scope, a repo_access grant, a signed LFS or | |
| 7 | -- reply-by-mail token, a hook's environment. webhook_deliveries and | |
| 8 | -- push_queue rows are named by id by a sender that is mid-request when | |
| 9 | -- a cascade can remove them. | |
| 10 | -- | |
| 11 | -- Each table is rebuilt the way 0052 rebuilds labels: foreign keys off | |
| 12 | -- for the step, legacy_alter_table so the children keep naming the | |
| 13 | -- table through the rename and bind to the new one, and the runner's | |
| 14 | -- foreign_key_check before commit. Rows keep their ids; indexes and | |
| 15 | -- triggers are recreated. sqlite_sequence starts at the highest id in | |
| 16 | -- the table or named anywhere else, so an id already freed and still | |
| 17 | -- named (a deploy key for a deleted repository, a grant, an audit row or | |
| 18 | -- a parked profile about text for a deleted owner) is not handed out | |
| 19 | -- either. | |
| 20 | PRAGMA legacy_alter_table = ON; | |
| 21 | ||
| 22 | DROP TRIGGER users_owning_repos; | |
| 23 | DROP TRIGGER orgs_owning_repos; | |
| 24 | ||
| 25 | ALTER TABLE users RENAME TO users_old; | |
| 26 | CREATE TABLE users ( | |
| 27 | id INTEGER PRIMARY KEY AUTOINCREMENT, | |
| 28 | username TEXT NOT NULL UNIQUE, | |
| 29 | is_admin INTEGER NOT NULL DEFAULT 0, | |
| 30 | created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')), | |
| 31 | pending INTEGER NOT NULL DEFAULT 0, | |
| 32 | description TEXT NOT NULL DEFAULT '', | |
| 33 | website TEXT NOT NULL DEFAULT '', | |
| 34 | disabled INTEGER NOT NULL DEFAULT 0, | |
| 35 | links TEXT NOT NULL DEFAULT '', | |
| 36 | repo_limit INTEGER, | |
| 37 | byte_limit INTEGER, | |
| 38 | notify_mail INTEGER NOT NULL DEFAULT 1, | |
| 39 | notify_watch INTEGER NOT NULL DEFAULT 0, | |
| 40 | theme TEXT NOT NULL DEFAULT 'system', | |
| 41 | notify_push INTEGER NOT NULL DEFAULT 1, | |
| 42 | diff_layout TEXT NOT NULL DEFAULT 'unified', | |
| 43 | notify_reply INTEGER NOT NULL DEFAULT 0 | |
| 44 | ); | |
| 45 | INSERT INTO users (id, username, is_admin, created_at, pending, description, website, disabled, | |
| 46 | links, repo_limit, byte_limit, notify_mail, notify_watch, theme, notify_push, diff_layout, notify_reply) | |
| 47 | SELECT id, username, is_admin, created_at, pending, description, website, disabled, | |
| 48 | links, repo_limit, byte_limit, notify_mail, notify_watch, theme, notify_push, diff_layout, notify_reply | |
| 49 | FROM users_old; | |
| 50 | DROP TABLE users_old; | |
| 51 | ||
| 52 | ALTER TABLE orgs RENAME TO orgs_old; | |
| 53 | CREATE TABLE orgs ( | |
| 54 | id INTEGER PRIMARY KEY AUTOINCREMENT, | |
| 55 | name TEXT NOT NULL UNIQUE, | |
| 56 | created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')), | |
| 57 | description TEXT NOT NULL DEFAULT '', | |
| 58 | website TEXT NOT NULL DEFAULT '', | |
| 59 | members_role TEXT NOT NULL DEFAULT 'write' | |
| 60 | CHECK (members_role IN ('write', 'read', 'none')), | |
| 61 | links TEXT NOT NULL DEFAULT '' | |
| 62 | ); | |
| 63 | INSERT INTO orgs (id, name, created_at, description, website, members_role, links) | |
| 64 | SELECT id, name, created_at, description, website, members_role, links FROM orgs_old; | |
| 65 | DROP TABLE orgs_old; | |
| 66 | ||
| 67 | ALTER TABLE repos RENAME TO repos_old; | |
| 68 | CREATE TABLE repos ( | |
| 69 | id INTEGER PRIMARY KEY AUTOINCREMENT, | |
| 70 | owner_kind TEXT NOT NULL CHECK (owner_kind IN ('user','org')), | |
| 71 | owner_id INTEGER NOT NULL, | |
| 72 | name TEXT NOT NULL, | |
| 73 | visibility TEXT NOT NULL CHECK (visibility IN ('public','private')), | |
| 74 | default_branch TEXT NOT NULL DEFAULT 'main', | |
| 75 | fork_of INTEGER REFERENCES repos(id) ON DELETE SET NULL, | |
| 76 | issue_counter INTEGER NOT NULL DEFAULT 0, | |
| 77 | mr_counter INTEGER NOT NULL DEFAULT 0, | |
| 78 | settings_json TEXT NOT NULL DEFAULT '{}', | |
| 79 | created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')), | |
| 80 | build_counter INTEGER NOT NULL DEFAULT 0, | |
| 81 | UNIQUE (owner_kind, owner_id, name) | |
| 82 | ); | |
| 83 | INSERT INTO repos (id, owner_kind, owner_id, name, visibility, default_branch, fork_of, | |
| 84 | issue_counter, mr_counter, settings_json, created_at, build_counter) | |
| 85 | SELECT id, owner_kind, owner_id, name, visibility, default_branch, fork_of, | |
| 86 | issue_counter, mr_counter, settings_json, created_at, build_counter | |
| 87 | FROM repos_old; | |
| 88 | DROP TABLE repos_old; | |
| 89 | ||
| 90 | ALTER TABLE api_tokens RENAME TO api_tokens_old; | |
| 91 | CREATE TABLE api_tokens ( | |
| 92 | id INTEGER PRIMARY KEY AUTOINCREMENT, | |
| 93 | user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, | |
| 94 | name TEXT NOT NULL, | |
| 95 | token_hash TEXT NOT NULL UNIQUE, | |
| 96 | scope TEXT NOT NULL DEFAULT 'full' CHECK (scope IN ('full','read')), | |
| 97 | created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')), | |
| 98 | expires_at TEXT, | |
| 99 | last_used_at TEXT, | |
| 100 | created_by_token INTEGER REFERENCES api_tokens(id) ON DELETE SET NULL, | |
| 101 | UNIQUE (user_id, name) | |
| 102 | ); | |
| 103 | INSERT INTO api_tokens (id, user_id, name, token_hash, scope, created_at, expires_at, | |
| 104 | last_used_at, created_by_token) | |
| 105 | SELECT id, user_id, name, token_hash, scope, created_at, expires_at, | |
| 106 | last_used_at, created_by_token | |
| 107 | FROM api_tokens_old; | |
| 108 | DROP TABLE api_tokens_old; | |
| 109 | ||
| 110 | ALTER TABLE ssh_keys RENAME TO ssh_keys_old; | |
| 111 | CREATE TABLE ssh_keys ( | |
| 112 | id INTEGER PRIMARY KEY AUTOINCREMENT, | |
| 113 | user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, | |
| 114 | fingerprint TEXT NOT NULL UNIQUE, | |
| 115 | algo TEXT NOT NULL, | |
| 116 | blob BLOB NOT NULL, | |
| 117 | scope TEXT NOT NULL DEFAULT 'full', | |
| 118 | created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')), | |
| 119 | last_used_at TEXT, | |
| 120 | label TEXT NOT NULL DEFAULT '', | |
| 121 | created_by_token INTEGER REFERENCES api_tokens(id) ON DELETE SET NULL, | |
| 122 | expires_at TEXT | |
| 123 | ); | |
| 124 | INSERT INTO ssh_keys (id, user_id, fingerprint, algo, blob, scope, created_at, last_used_at, | |
| 125 | label, created_by_token, expires_at) | |
| 126 | SELECT id, user_id, fingerprint, algo, blob, scope, created_at, last_used_at, | |
| 127 | label, created_by_token, expires_at | |
| 128 | FROM ssh_keys_old; | |
| 129 | DROP TABLE ssh_keys_old; | |
| 130 | CREATE INDEX ssh_keys_user ON ssh_keys(user_id); | |
| 131 | ||
| 132 | ALTER TABLE webhook_deliveries RENAME TO webhook_deliveries_old; | |
| 133 | CREATE TABLE webhook_deliveries ( | |
| 134 | id INTEGER PRIMARY KEY AUTOINCREMENT, | |
| 135 | webhook_id INTEGER NOT NULL REFERENCES webhooks(id) ON DELETE CASCADE, | |
| 136 | event_id INTEGER NOT NULL REFERENCES events(id) ON DELETE CASCADE, | |
| 137 | attempts INTEGER NOT NULL DEFAULT 0, | |
| 138 | next_attempt_at TEXT, | |
| 139 | delivered_at TEXT, | |
| 140 | failed_at TEXT, | |
| 141 | last_status INTEGER, | |
| 142 | last_error TEXT, | |
| 143 | created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) | |
| 144 | ); | |
| 145 | INSERT INTO webhook_deliveries (id, webhook_id, event_id, attempts, next_attempt_at, | |
| 146 | delivered_at, failed_at, last_status, last_error, created_at) | |
| 147 | SELECT id, webhook_id, event_id, attempts, next_attempt_at, | |
| 148 | delivered_at, failed_at, last_status, last_error, created_at | |
| 149 | FROM webhook_deliveries_old; | |
| 150 | DROP TABLE webhook_deliveries_old; | |
| 151 | CREATE INDEX webhook_deliveries_due ON webhook_deliveries(next_attempt_at) | |
| 152 | WHERE delivered_at IS NULL AND failed_at IS NULL; | |
| 153 | ||
| 154 | ALTER TABLE push_queue RENAME TO push_queue_old; | |
| 155 | CREATE TABLE push_queue ( | |
| 156 | id INTEGER PRIMARY KEY AUTOINCREMENT, | |
| 157 | device_id INTEGER NOT NULL REFERENCES push_devices(id) ON DELETE CASCADE, | |
| 158 | title TEXT NOT NULL, | |
| 159 | body TEXT NOT NULL, | |
| 160 | path TEXT NOT NULL, | |
| 161 | attempts INTEGER NOT NULL DEFAULT 0, | |
| 162 | next_attempt_at TEXT, | |
| 163 | sent_at TEXT, | |
| 164 | failed_at TEXT, | |
| 165 | last_error TEXT, | |
| 166 | created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) | |
| 167 | ); | |
| 168 | INSERT INTO push_queue (id, device_id, title, body, path, attempts, next_attempt_at, | |
| 169 | sent_at, failed_at, last_error, created_at) | |
| 170 | SELECT id, device_id, title, body, path, attempts, next_attempt_at, | |
| 171 | sent_at, failed_at, last_error, created_at | |
| 172 | FROM push_queue_old; | |
| 173 | DROP TABLE push_queue_old; | |
| 174 | CREATE INDEX push_queue_due ON push_queue(next_attempt_at) | |
| 175 | WHERE sent_at IS NULL AND failed_at IS NULL; | |
| 176 | ||
| 177 | CREATE TRIGGER users_owning_repos BEFORE DELETE ON users | |
| 178 | WHEN EXISTS (SELECT 1 FROM repos WHERE owner_kind = 'user' AND owner_id = OLD.id) | |
| 179 | BEGIN | |
| 180 | SELECT RAISE(ABORT, 'user still owns repositories'); | |
| 181 | END; | |
| 182 | CREATE TRIGGER orgs_owning_repos BEFORE DELETE ON orgs | |
| 183 | WHEN EXISTS (SELECT 1 FROM repos WHERE owner_kind = 'org' AND owner_id = OLD.id) | |
| 184 | BEGIN | |
| 185 | SELECT RAISE(ABORT, 'organization still owns repositories'); | |
| 186 | END; | |
| 187 | ||
| 188 | PRAGMA legacy_alter_table = OFF; | |
| 189 | ||
| 190 | -- The copies above set each sequence to the table's own highest id; an | |
| 191 | -- empty table has no row yet. | |
| 192 | INSERT INTO sqlite_sequence (name, seq) | |
| 193 | SELECT t.name, 0 FROM (SELECT 'users' AS name UNION ALL SELECT 'orgs' UNION ALL SELECT 'repos' | |
| 194 | UNION ALL SELECT 'api_tokens' UNION ALL SELECT 'ssh_keys' | |
| 195 | UNION ALL SELECT 'webhook_deliveries' UNION ALL SELECT 'push_queue') t | |
| 196 | WHERE NOT EXISTS (SELECT 1 FROM sqlite_sequence s WHERE s.name = t.name); | |
| 197 | ||
| 198 | UPDATE sqlite_sequence SET seq = MAX(seq, | |
| 199 | (SELECT COALESCE(MAX(subject_id), 0) FROM repo_access WHERE subject_kind = 'user'), | |
| 200 | (SELECT COALESCE(MAX(actor_ref), 0) FROM audit_log), | |
| 201 | (SELECT COALESCE(MAX(user_id), 0) FROM page_domains), | |
| 202 | (SELECT COALESCE(MAX(owner_id), 0) FROM profile_about_backfill WHERE owner_kind = 'user')) | |
| 203 | WHERE name = 'users'; | |
| 204 | UPDATE sqlite_sequence SET seq = MAX(seq, | |
| 205 | (SELECT COALESCE(MAX(subject_id), 0) FROM repo_access WHERE subject_kind = 'org'), | |
| 206 | (SELECT COALESCE(MAX(owner_id), 0) FROM profile_about_backfill WHERE owner_kind = 'org')) | |
| 207 | WHERE name = 'orgs'; | |
| 208 | UPDATE sqlite_sequence SET seq = MAX(seq, | |
| 209 | (SELECT COALESCE(MAX(CAST(substr(scope, 8, instr(substr(scope, 8), ':') - 1) AS INTEGER)), 0) | |
| 210 | FROM ssh_keys WHERE scope LIKE 'deploy:%')) | |
| 211 | WHERE name = 'repos'; | |