internal/store/idreuse_test.go

v1.41.0
gitbay/internal/store/idreuse_test.go history · blame · raw

484 lines · 19976 bytes

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