internal/store/idreuse_test.go

main
gitbay/internal/store/idreuse_test.go history · blame · raw

485 lines · 20111 bytes

13 symbols in this file
  1package store
  2
  3import (
  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.
 11func 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
 26var autoincTables = []string{"users", "orgs", "repos", "api_tokens", "ssh_keys", "webhook_deliveries", "push_queue"}
 27
 28func 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
 38func 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
 47func 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.
 65func 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	// Raw deletes: DeleteRepo and DeleteUser now take the deploy key and
 85	// the grant with them, and these orphans stand for ones left earlier.
 86	mustExec(t, s, "DELETE FROM repos WHERE id = ?", goneRepo)
 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	mustExec(t, s, "DELETE FROM users WHERE id = ?", goneUser)
 92	return goneRepo, goneUser
 93}
 94
 95func itoa(n int64) string { return strconv.FormatInt(n, 10) }
 96
 97func TestIDMigrationKeepsRowsAndForeignKeys(t *testing.T) {
 98	v := idMigration(t)
 99	s := open(t)
100	if err := s.MigrateTo(v - 1); err != nil {
101		t.Fatal(err)
102	}
103	goneRepo, goneUser := seedForIDs(t, s)
104	before := map[string]int{}
105	for _, tbl := range append(autoincTables, "org_members", "webhooks", "events", "push_devices", "repo_access") {
106		before[tbl] = count(t, s, tbl)
107	}
108	if err := s.MigrateTo(v); err != nil {
109		t.Fatal(err)
110	}
111	for tbl, n := range before {
112		if got := count(t, s, tbl); got != n {
113			t.Errorf("%s: %d rows after the migration, want %d", tbl, got, n)
114		}
115	}
116	for _, tbl := range autoincTables {
117		var sql string
118		if err := s.DB.QueryRow("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = ?", tbl).Scan(&sql); err != nil {
119			t.Fatal(err)
120		}
121		if !strings.Contains(sql, "AUTOINCREMENT") {
122			t.Errorf("%s is not AUTOINCREMENT", tbl)
123		}
124	}
125	fkClean(t, s)
126
127	// Children still name the rebuilt parents, not the *_old tables.
128	var n int
129	if err := s.DB.QueryRow(`SELECT COUNT(*) FROM sqlite_master m, pragma_foreign_key_list(m.name) f
130		WHERE m.type = 'table' AND f."table" LIKE '%_old'`).Scan(&n); err != nil {
131		t.Fatal(err)
132	}
133	if n != 0 {
134		t.Fatalf("%d foreign keys name a *_old table", n)
135	}
136	for _, c := range []struct{ child, parent string }{
137		{"ssh_keys", "users"}, {"ssh_keys", "api_tokens"}, {"api_tokens", "api_tokens"},
138		{"repos", "repos"}, {"issues", "repos"}, {"teams", "orgs"}, {"mr_merge_queue", "ssh_keys"},
139		{"push_queue", "push_devices"}, {"webhook_deliveries", "webhooks"},
140	} {
141		if err := s.DB.QueryRow(`SELECT COUNT(*) FROM pragma_foreign_key_list(?) WHERE "table" = ?`, c.child, c.parent).Scan(&n); err != nil || n == 0 {
142			t.Errorf("%s has no foreign key to %s (%v)", c.child, c.parent, err)
143		}
144	}
145	for _, idx := range []string{"ssh_keys_user", "webhook_deliveries_due", "push_queue_due"} {
146		if err := s.DB.QueryRow("SELECT COUNT(*) FROM sqlite_master WHERE type = 'index' AND name = ?", idx).Scan(&n); err != nil || n != 1 {
147			t.Errorf("index %s missing", idx)
148		}
149	}
150
151	// The triggers still fire.
152	// Read by column: the schema here is 0074's, older than User's loader.
153	var alice User
154	if err := s.DB.QueryRow("SELECT id FROM users WHERE username = 'alice'").Scan(&alice.ID); err != nil {
155		t.Fatal(err)
156	}
157	if _, err := s.DB.Exec("DELETE FROM users WHERE id = ?", alice.ID); err == nil || !strings.Contains(err.Error(), "still owns repositories") {
158		t.Fatalf("deleting a repository owner: %v", err)
159	}
160	if _, err := s.DB.Exec("DELETE FROM orgs WHERE name = 'acme'"); err == nil || !strings.Contains(err.Error(), "still owns repositories") {
161		t.Fatalf("deleting an owning org: %v", err)
162	}
163	// Cascades still reach the rebuilt tables' children.
164	mustExec(t, s, "DELETE FROM push_devices WHERE token = 'devtok'")
165	if got := count(t, s, "push_queue"); got != 0 {
166		t.Fatalf("push_queue after its device went: %d rows", got)
167	}
168
169	// The sequences start above the ids a deploy key and a grant still
170	// name, though neither row survived.
171	var seq int64
172	s.DB.QueryRow("SELECT seq FROM sqlite_sequence WHERE name = 'repos'").Scan(&seq)
173	if seq < goneRepo {
174		t.Fatalf("repos sequence %d, want at least %d", seq, goneRepo)
175	}
176	r, err := s.CreateRepo("user", alice.ID, "new", "public")
177	if err != nil {
178		t.Fatal(err)
179	}
180	if r <= goneRepo {
181		t.Fatalf("new repository took id %d; a deploy key still names %d", r, goneRepo)
182	}
183	u, err := s.CreateUser("dave", false)
184	if err != nil {
185		t.Fatal(err)
186	}
187	if u <= goneUser {
188		t.Fatalf("new account took id %d; a grant still names %d", u, goneUser)
189	}
190
191	// Down and up again keep the rows.
192	if err := s.MigrateTo(v - 1); err != nil {
193		t.Fatal(err)
194	}
195	if got := count(t, s, "users"); got != before["users"]+1 {
196		t.Fatalf("users after down: %d", got)
197	}
198	if err := s.DB.QueryRow("SELECT COUNT(*) FROM sqlite_sequence WHERE name = 'users'").Scan(&n); err != nil || n != 0 {
199		t.Fatalf("sqlite_sequence keeps users after down: %d, %v", n, err)
200	}
201	fkClean(t, s)
202	if err := s.MigrateUp(); err != nil {
203		t.Fatal(err)
204	}
205	fkClean(t, s)
206}
207
208// A parked profile about text names its owner by kind and id with no
209// foreign key; the sequences start above the ids it names (#306).
210func TestIDMigrationSeedsFromAboutBackfill(t *testing.T) {
211	v := idMigration(t)
212	s := open(t)
213	if err := s.MigrateTo(v - 1); err != nil {
214		t.Fatal(err)
215	}
216	mustExec(t, s, "INSERT INTO users (username) VALUES ('alice')")
217	mustExec(t, s, "INSERT INTO orgs (name) VALUES ('acme')")
218	mustExec(t, s, `INSERT INTO profile_about_backfill (owner_kind, owner_id, about, about_format)
219		VALUES ('user', 50, 'a', 'md'), ('org', 40, 'b', 'md')`)
220	if err := s.MigrateTo(v); err != nil {
221		t.Fatal(err)
222	}
223	for table, want := range map[string]int64{"users": 50, "orgs": 40} {
224		var seq int64
225		if err := s.DB.QueryRow("SELECT seq FROM sqlite_sequence WHERE name = ?", table).Scan(&seq); err != nil {
226			t.Fatal(err)
227		}
228		if seq < want {
229			t.Errorf("%s sequence %d, want at least %d", table, seq, want)
230		}
231	}
232}
233
234// Deleting the row with the highest id does not free that id, in any of
235// the rebuilt tables.
236func TestIDsNotReusedAfterDelete(t *testing.T) {
237	s := open(t)
238	if err := s.MigrateUp(); err != nil {
239		t.Fatal(err)
240	}
241	u1 := mustExec(t, s, "INSERT INTO users (username) VALUES ('u1')")
242	u2 := mustExec(t, s, "INSERT INTO users (username) VALUES ('u2')")
243	repo := mustExec(t, s, "INSERT INTO repos (owner_kind, owner_id, name, visibility) VALUES ('user', ?, 'r', 'public')", u1)
244	hook := mustExec(t, s, "INSERT INTO webhooks (repo_id, url) VALUES (?, 'https://example.org/h')", repo)
245	ev := mustExec(t, s, "INSERT INTO events (repo_id, kind) VALUES (?, 'push')", repo)
246	dev := mustExec(t, s, "INSERT INTO push_devices (user_id, token) VALUES (?, 'devtok')", u1)
247
248	cases := []struct {
249		table, insert string
250		args          func(i int) []any
251	}{
252		{"users", "INSERT INTO users (username) VALUES (?)", func(i int) []any { return []any{"x" + itoa(int64(i))} }},
253		{"orgs", "INSERT INTO orgs (name) VALUES (?)", func(i int) []any { return []any{"o" + itoa(int64(i))} }},
254		{"repos", "INSERT INTO repos (owner_kind, owner_id, name, visibility) VALUES ('user', ?, ?, 'public')",
255			func(i int) []any { return []any{u1, "n" + itoa(int64(i))} }},
256		{"api_tokens", "INSERT INTO api_tokens (user_id, name, token_hash) VALUES (?, ?, ?)",
257			func(i int) []any { return []any{u2, "t" + itoa(int64(i)), "h" + itoa(int64(i))} }},
258		{"ssh_keys", "INSERT INTO ssh_keys (user_id, fingerprint, algo, blob) VALUES (?, ?, 'ssh-ed25519', x'00')",
259			func(i int) []any { return []any{u2, "SHA256:" + itoa(int64(i))} }},
260		{"webhook_deliveries", "INSERT INTO webhook_deliveries (webhook_id, event_id) VALUES (?, ?)",
261			func(int) []any { return []any{hook, ev} }},
262		{"push_queue", "INSERT INTO push_queue (device_id, title, body, path) VALUES (?, 't', 'b', 'p')",
263			func(int) []any { return []any{dev} }},
264	}
265	for _, c := range cases {
266		first := mustExec(t, s, c.insert, c.args(1)...)
267		mustExec(t, s, "DELETE FROM "+c.table+" WHERE id = ?", first)
268		second := mustExec(t, s, c.insert, c.args(2)...)
269		if second <= first {
270			t.Errorf("%s: id %d handed out again after its row was deleted (got %d)", c.table, first, second)
271		}
272	}
273}
274
275// A sender marks a webhook delivery or a push by id after its request
276// returns. If the hook or device was removed meanwhile, the cascade took
277// the row, and the next row must not take its id and its mark (#306).
278func TestInFlightMarksAfterCascade(t *testing.T) {
279	s := open(t)
280	if err := s.MigrateUp(); err != nil {
281		t.Fatal(err)
282	}
283	u := mustExec(t, s, "INSERT INTO users (username) VALUES ('u')")
284	repo := mustExec(t, s, "INSERT INTO repos (owner_kind, owner_id, name, visibility) VALUES ('user', ?, 'r', 'public')", u)
285	ev := mustExec(t, s, "INSERT INTO events (repo_id, kind) VALUES (?, 'push')", repo)
286	h1 := mustExec(t, s, "INSERT INTO webhooks (repo_id, url) VALUES (?, 'https://example.org/1')", repo)
287	h2 := mustExec(t, s, "INSERT INTO webhooks (repo_id, url) VALUES (?, 'https://example.org/2')", repo)
288	inFlight := mustExec(t, s, "INSERT INTO webhook_deliveries (webhook_id, event_id) VALUES (?, ?)", h1, ev)
289	if err := s.RemoveWebhook(repo, h1); err != nil {
290		t.Fatal(err)
291	}
292	next := mustExec(t, s, "INSERT INTO webhook_deliveries (webhook_id, event_id) VALUES (?, ?)", h2, ev)
293	if err := s.MarkDelivered(inFlight, 200); err != nil {
294		t.Fatal(err)
295	}
296	var delivered *string
297	if err := s.DB.QueryRow("SELECT delivered_at FROM webhook_deliveries WHERE id = ?", next).Scan(&delivered); err != nil {
298		t.Fatal(err)
299	}
300	if delivered != nil {
301		t.Fatal("the removed hook's delivery was marked on the next hook's")
302	}
303
304	d1 := mustExec(t, s, "INSERT INTO push_devices (user_id, token) VALUES (?, 'd1')", u)
305	d2 := mustExec(t, s, "INSERT INTO push_devices (user_id, token) VALUES (?, 'd2')", u)
306	pushing := mustExec(t, s, "INSERT INTO push_queue (device_id, title, body, path) VALUES (?, 't', 'b', 'p')", d1)
307	if err := s.RemovePushDevice(u, d1); err != nil {
308		t.Fatal(err)
309	}
310	queued := mustExec(t, s, "INSERT INTO push_queue (device_id, title, body, path) VALUES (?, 't', 'b', 'p')", d2)
311	if err := s.MarkPushSent(pushing); err != nil {
312		t.Fatal(err)
313	}
314	var sent *string
315	if err := s.DB.QueryRow("SELECT sent_at FROM push_queue WHERE id = ?", queued).Scan(&sent); err != nil {
316		t.Fatal(err)
317	}
318	if sent != nil {
319		t.Fatal("the removed device's push was marked on the next device's")
320	}
321}
322
323// Deleting a repository takes the deploy keys scoped to it, and only
324// those; deleting an account or an org takes its grants (#306).
325func TestDeletesTakeGrantsAndDeployKeys(t *testing.T) {
326	s := open(t)
327	if err := s.MigrateUp(); err != nil {
328		t.Fatal(err)
329	}
330	var revoked []Revoked
331	s.OnRevoke(func(r Revoked) { revoked = append(revoked, r) })
332	alice := mustExec(t, s, "INSERT INTO users (username) VALUES ('alice')")
333	var repos []int64
334	for i := range 12 {
335		repos = append(repos, mustExec(t, s,
336			"INSERT INTO repos (owner_kind, owner_id, name, visibility) VALUES ('user', ?, ?, 'public')", alice, "r"+itoa(int64(i))))
337	}
338	// repos[0] is 2 and repos[10] is 12: a prefix of one id must not
339	// match the other.
340	gone, other := repos[0], repos[len(repos)-2]
341	if !strings.HasPrefix(itoa(other), itoa(gone)) {
342		t.Fatalf("ids %d and %d do not share a prefix", gone, other)
343	}
344	goneKey := mustExec(t, s, "INSERT INTO ssh_keys (user_id, fingerprint, algo, blob, scope) VALUES (?, 'SHA256:g', 'a', x'00', ?)",
345		alice, "deploy:"+itoa(gone)+":rw")
346	mustExec(t, s, "INSERT INTO ssh_keys (user_id, fingerprint, algo, blob, scope) VALUES (?, 'SHA256:o', 'a', x'00', ?)",
347		alice, "deploy:"+itoa(other)+":ro")
348	if err := s.DeleteRepo(gone); err != nil {
349		t.Fatal(err)
350	}
351	var n int
352	s.DB.QueryRow("SELECT COUNT(*) FROM ssh_keys WHERE fingerprint = 'SHA256:g'").Scan(&n)
353	if n != 0 {
354		t.Fatal("the deleted repository's deploy key survived")
355	}
356	s.DB.QueryRow("SELECT COUNT(*) FROM ssh_keys WHERE fingerprint = 'SHA256:o'").Scan(&n)
357	if n != 1 {
358		t.Fatal("another repository's deploy key went with it")
359	}
360	if len(revoked) != 1 || len(revoked[0].KeyIDs) != 1 || revoked[0].KeyIDs[0] != goneKey {
361		t.Fatalf("revocations announced: %+v", revoked)
362	}
363	if err := s.DeleteRepo(gone); err != ErrNotFound {
364		t.Fatalf("deleting it again: %v", err)
365	}
366
367	bob := mustExec(t, s, "INSERT INTO users (username) VALUES ('bob')")
368	org := mustExec(t, s, "INSERT INTO orgs (name) VALUES ('acme')")
369	if err := s.GrantAccess(other, bob, "write"); err != nil {
370		t.Fatal(err)
371	}
372	mustExec(t, s, "INSERT INTO repo_access (repo_id, subject_kind, subject_id, role) VALUES (?, 'org', ?, 'read')", other, org)
373	// An org with the same id as bob's keeps its grant when bob goes.
374	mustExec(t, s, "INSERT INTO repo_access (repo_id, subject_kind, subject_id, role) VALUES (?, 'org', ?, 'read')", repos[1], bob)
375	mustExec(t, s, `INSERT INTO profile_about_backfill (owner_kind, owner_id, about, about_format)
376		VALUES ('user', ?, 'a', 'md'), ('org', ?, 'b', 'md'), ('org', ?, 'c', 'md')`, bob, org, bob)
377	if err := s.DeleteUser(bob); err != nil {
378		t.Fatal(err)
379	}
380	s.DB.QueryRow("SELECT COUNT(*) FROM repo_access WHERE subject_kind = 'user' AND subject_id = ?", bob).Scan(&n)
381	if n != 0 {
382		t.Fatal("the deleted account's grant survived")
383	}
384	s.DB.QueryRow("SELECT COUNT(*) FROM profile_about_backfill WHERE owner_kind = 'user' AND owner_id = ?", bob).Scan(&n)
385	if n != 0 {
386		t.Fatal("the deleted account's about text survived")
387	}
388	s.DB.QueryRow("SELECT COUNT(*) FROM repo_access WHERE subject_kind = 'org' AND subject_id = ?", bob).Scan(&n)
389	if n != 1 {
390		t.Fatal("an org grant went with the account of the same id")
391	}
392	if err := s.DeleteOrg(org); err != nil {
393		t.Fatal(err)
394	}
395	s.DB.QueryRow("SELECT COUNT(*) FROM repo_access WHERE subject_kind = 'org' AND subject_id = ?", org).Scan(&n)
396	if n != 0 {
397		t.Fatal("the deleted org's grant survived")
398	}
399	s.DB.QueryRow("SELECT COUNT(*) FROM profile_about_backfill WHERE owner_kind = 'org'").Scan(&n)
400	if n != 1 {
401		t.Fatalf("org about texts after deleting acme: %d, want only the one with bob's id", n)
402	}
403}
404
405// The cleanup migration removes grants and deploy keys left by earlier
406// deletes, keeps live ones, and leaves a note with the counts.
407func TestOrphanCleanupMigration(t *testing.T) {
408	ms, err := loadMigrations()
409	if err != nil {
410		t.Fatal(err)
411	}
412	v := 0
413	for _, m := range ms {
414		if m.name == "orphan_grants_deploy_keys" {
415			v = m.version
416		}
417	}
418	if v == 0 {
419		t.Fatal("no orphan_grants_deploy_keys migration")
420	}
421	s := open(t)
422	if err := s.MigrateTo(v - 1); err != nil {
423		t.Fatal(err)
424	}
425	alice := mustExec(t, s, "INSERT INTO users (username) VALUES ('alice')")
426	repo := mustExec(t, s, "INSERT INTO repos (owner_kind, owner_id, name, visibility) VALUES ('user', ?, 'r', 'public')", alice)
427	mustExec(t, s, "INSERT INTO repo_access (repo_id, subject_kind, subject_id, role) VALUES (?, 'user', ?, 'read')", repo, alice)
428	mustExec(t, s, "INSERT INTO repo_access (repo_id, subject_kind, subject_id, role) VALUES (?, 'user', 999, 'write')", repo)
429	mustExec(t, s, "INSERT INTO repo_access (repo_id, subject_kind, subject_id, role) VALUES (?, 'org', 998, 'read')", repo)
430	mustExec(t, s, "INSERT INTO ssh_keys (user_id, fingerprint, algo, blob, scope) VALUES (?, 'SHA256:live', 'a', x'00', ?)",
431		alice, "deploy:"+itoa(repo)+":rw")
432	mustExec(t, s, "INSERT INTO ssh_keys (user_id, fingerprint, algo, blob, scope) VALUES (?, 'SHA256:dead', 'a', x'00', 'deploy:997:ro')", alice)
433	mustExec(t, s, "INSERT INTO ssh_keys (user_id, fingerprint, algo, blob) VALUES (?, 'SHA256:user', 'a', x'00')", alice)
434	mustExec(t, s, `INSERT INTO profile_about_backfill (owner_kind, owner_id, about, about_format)
435		VALUES ('user', ?, 'live', 'md'), ('user', 996, 'dead', 'md'), ('org', 995, 'dead', 'md')`, alice)
436	var epoch int
437	s.DB.QueryRow("SELECT value FROM settings WHERE key = 'key_epoch'").Scan(&epoch)
438	if err := s.MigrateTo(v); err != nil {
439		t.Fatal(err)
440	}
441	if got := count(t, s, "repo_access"); got != 1 {
442		t.Fatalf("repo_access: %d rows, want the live grant", got)
443	}
444	var about string
445	if err := s.DB.QueryRow("SELECT group_concat(about) FROM profile_about_backfill").Scan(&about); err != nil || about != "live" {
446		t.Fatalf("about texts after cleanup: %q, %v", about, err)
447	}
448	var fps []string
449	rows, err := s.DB.Query("SELECT fingerprint FROM ssh_keys ORDER BY fingerprint")
450	if err != nil {
451		t.Fatal(err)
452	}
453	for rows.Next() {
454		var fp string
455		rows.Scan(&fp)
456		fps = append(fps, fp)
457	}
458	rows.Close()
459	if strings.Join(fps, " ") != "SHA256:live SHA256:user" {
460		t.Fatalf("keys after cleanup: %v", fps)
461	}
462	var after int
463	s.DB.QueryRow("SELECT value FROM settings WHERE key = 'key_epoch'").Scan(&after)
464	if after != epoch+1 {
465		t.Fatalf("key_epoch %d, want %d", after, epoch+1)
466	}
467	note, err := s.TakeMigrationNote()
468	if err != nil || note != "removed grants of deleted accounts or organizations: 2; deploy keys of deleted repositories: 1; profile about texts of deleted accounts or organizations: 2" {
469		t.Fatalf("note %q, %v", note, err)
470	}
471	if note, _ := s.TakeMigrationNote(); note != "" {
472		t.Fatalf("note not cleared: %q", note)
473	}
474
475	// Nothing to remove, no note.
476	if err := s.MigrateTo(v - 1); err != nil {
477		t.Fatal(err)
478	}
479	if err := s.MigrateTo(v); err != nil {
480		t.Fatal(err)
481	}
482	if note, _ := s.TakeMigrationNote(); note != "" {
483		t.Fatalf("note with nothing removed: %q", note)
484	}
485}