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 | 256 | // A removed key's id is not handed to the next key (#306), so its token |
| 257 | // minted for the deleted key is refused when presented against the new | 257 | // names no live key. An id freed and taken before ids stopped being |
| 258 | // key; the new key's own token works (#303). | 258 | // reused is still refused by the fingerprint pin; the new key's own token |
| 259 | // works (#303). | ||
| 259 | func TestLFSTokenRefusedOnReusedKeyID(t *testing.T) { | 260 | func TestLFSTokenRefusedOnReusedKeyID(t *testing.T) { |
| 260 | s, st, u := newTokenTestServer(t) | 261 | s, st, u := newTokenTestServer(t) |
| 261 | repo := lfsTestRepo(t, st, u.ID, "app", "private") | 262 | repo := lfsTestRepo(t, st, u.ID, "app", "private") |
| @@ -269,14 +270,44 @@ func TestLFSTokenRefusedOnReusedKeyID(t *testing.T) { | |||
| 269 | if err := st.RemoveSSHKey(u.ID, "SHA256:old"); err != nil { | 270 | if err := st.RemoveSSHKey(u.ID, "SHA256:old"); err != nil { |
| 270 | t.Fatal(err) | 271 | t.Fatal(err) |
| 271 | } | 272 | } |
| 272 | newID := lfsTestKey(t, st, u.ID, "SHA256:new", "full") | 273 | nextID := lfsTestKey(t, st, u.ID, "SHA256:next", "full") |
| 273 | if newID != oldID { | 274 | if nextID == oldID { |
| 274 | t.Fatalf("new key got id %d, want the reused %d", newID, 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 | if op, _ := s.lfsAuth(lfsRequest(old), repo); op != "" { | 284 | if op, _ := s.lfsAuth(lfsRequest(old), repo); op != "" { |
| 277 | t.Errorf("old key's token on the reused id: %q", op) | 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 | t.Errorf("new key's own token: %q", op) | 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 | // Sign mints a token for op ("download" or "upload") on repoID, bound | 130 | // Sign mints a token for op ("download" or "upload") on repoID, bound |
| 131 | // to keyID: the SSH key, user or deploy, that asked for it, or 0 for an | 131 | // to keyID: the SSH key, user or deploy, that asked for it, or 0 for an |
| 132 | // anonymous download of a public repository. fingerprint is that key's | 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 | 133 | // fingerprint, "" for key 0. Key ids freed before they stopped being |
| 134 | // the token carries a hash of the fingerprint as well and a new key | 134 | // reused (#306) may belong to a later key, so the token carries a hash |
| 135 | // given the old id does not inherit the old key's tokens (#303). | 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 | func Sign(secret []byte, repoID, keyID int64, fingerprint, op string, now time.Time) string { | 137 | func Sign(secret []byte, repoID, keyID int64, fingerprint, op string, now time.Time) string { |
| 137 | payload := fmt.Sprintf("%d:%d:%s:%s:%d", repoID, keyID, KeyPin(fingerprint), op, now.Add(TokenTTL).Unix()) | 138 | payload := fmt.Sprintf("%d:%d:%s:%s:%d", repoID, keyID, KeyPin(fingerprint), op, now.Add(TokenTTL).Unix()) |
| 138 | mac := hmac.New(sha256.New, secret) | 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 | case u.Pending: | 187 | case u.Pending: |
| 188 | return p.refuse(u.ID, msgID, "account not active") | 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 | 190 | // An id freed before ids stopped being reused (#306) may have been |
| 191 | // token was minted is not the one it named. | 191 | // taken: an account created after the token was minted is not the |
| 192 | // one it named. | ||
| 192 | if code := p.createdAfter("users", u.ID, target, msgID, "account"); code != nil { | 193 | if code := p.createdAfter("users", u.ID, target, msgID, "account"); code != nil { |
| 193 | return *code | 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 | // not accept replies meant for the old one. | 359 | // not accept replies meant for the old one. |
| 341 | func TestReusedRepositoryID(t *testing.T) { | 360 | func TestReusedRepositoryID(t *testing.T) { |
| 342 | f := setup(t) | 361 | f := setup(t) |
| @@ -345,9 +364,11 @@ func TestReusedRepositoryID(t *testing.T) { | |||
| 345 | t.Fatal(err) | 364 | t.Fatal(err) |
| 346 | } | 365 | } |
| 347 | alice, _ := f.st.UserByUsername("alice") | 366 | alice, _ := f.st.UserByUsername("alice") |
| 348 | id, err := f.st.CreateRepo("user", alice.ID, "other", "public") | 367 | id := f.repo.ID |
| 349 | if err != nil || id != f.repo.ID { | 368 | if _, err := f.st.DB.Exec( |
| 350 | t.Fatalf("new repository has id %d (%v), want the freed %d", id, err, f.repo.ID) | 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 | f.st.CreateIssue(id, alice.ID, "t", "", "md") | 373 | f.st.CreateIssue(id, alice.ID, "t", "", "md") |
| 353 | // Created after the token, as it would be outside a fast test. | 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'; | ||