Architecture review small fixes: FK check, web toggles, doc drift !496

merged merged by cmc on 2026-09-28 22:09 UTC · krz/gitbay:web-audit-fixes into main

10 files changed, +323 −99

Layout: unified · split

.gitbay/wiki/API.org +3 −2
@@ -59,8 +59,9 @@ the command wrote diagnostics:
59 59
60HTTP status maps the exit code: 0→200, 2→400, 3→404, 4→403, else 500. 60HTTP status maps the exit code: 0→200, 2→400, 3→404, 4→403, else 500.
61Commands that emit raw text rather than an envelope (=help=, =mr diff=) 61Commands that emit raw text rather than an envelope (=help=, =mr diff=)
62come wrapped as ={"output": "..."}=. Git transport commands and the 62come wrapped as ={"output": "..."}=. Git transport commands are refused
63token commands are refused by name. 63by name; the token commands are not — a full-scope token can mint,
64list and revoke tokens the same way it can run anything else.
64 65
65#+begin_src sh 66#+begin_src sh
66curl -s -H "Authorization: Bearer $TOKEN" \ 67curl -s -H "Authorization: Bearer $TOKEN" \
.gitbay/wiki/Parity.org +12 −7
@@ -80,9 +80,13 @@ does not appear in anyone's review queue. Draft is a flag rather than a
80fifth state, so every =state = 'open'= rule still means what it did. 80fifth state, so every =state = 'open'= rule still means what it did.
81=mr revisions= lists the heads a merge request has had; the web page 81=mr revisions= lists the heads a merge request has had; the web page
82lists them beside the reviews they staled, and the iOS client has a 82lists them beside the reviews they staled, and the iOS client has a
83Revisions section. =mr range-diff= compares two heads: the iOS client 83Revisions section. =mr range-diff= compares two heads: the iOS client shows it from a
84shows it from a revision to the one before, as text; the web has no 84revision to the one before, as text; the web has no view yet
85view. Batched review is not built. 85(krz/gitbay#269). Batched review — draft diff comments held with
86=mr comment --pending= and sent together with =--comment=/=--discard=
87or a verdict — is built and the web uses it: composing review
88comments before publishing them is the same round trip as the CLI's
89=--pending= flag.
86 90
87=mr review request --add <user>= asks a *particular* person, who then 91=mr review request --add <user>= asks a *particular* person, who then
88carries the merge request in their queue and is notified; =--remove= 92carries the merge request in their queue and is notified; =--remove=
@@ -187,7 +191,7 @@ rather than the one the web page shows.
187| bookmark list | yes | yes | yes | 191| bookmark list | yes | yes | yes |
188| bookmarks on your profile | n/a | yes | no | 192| bookmarks on your profile | n/a | yes | no |
189| watch, unwatch | yes | yes | yes | 193| watch, unwatch | yes | yes | yes |
190| mute | yes | no | yes | 194| mute | yes | yes | yes |
191| settings, protection | yes | yes | yes | 195| settings, protection | yes | yes | yes |
192| default branch | yes | yes | yes | 196| default branch | yes | yes | yes |
193| merge requests only | yes | yes | yes | 197| merge requests only | yes | yes | yes |
@@ -247,9 +251,10 @@ repository — and a repository bookmarked while public and since made
247private drops out of the listing rather than leaking that it exists 251private drops out of the listing rather than leaking that it exists
248(krz/gitbay#146). 252(krz/gitbay#146).
249 253
250The web's watch and pin controls write the store directly instead of 254The web's watch and pin controls dispatch =repo pin=/=repo unpin= and
251dispatching =repo watch= and =repo pin=. That is why the web cannot 255=repo watch=/=repo mute=/=repo unwatch=, the same commands the CLI runs
252mute: its toggle knows watching and default only. 256(krz/gitbay#261). The single watch button cycles default, watching and
257muted.
253 258
254Dependency checks are off until a repository's admin turns them on: the 259Dependency checks are off until a repository's admin turns them on: the
255check tells a public registry what the repository depends on. =repo deps 260check tells a public registry what the repository depends on. =repo deps
.gitbay/wiki/Threat-Model.org +9 −4
@@ -17,10 +17,15 @@ matrix and the open gaps are in the [[file:Architecture/00-Overview.org][Archite
17- *Serve repository HTML on its own origin as active content.* Raw file 17- *Serve repository HTML on its own origin as active content.* Raw file
18 serving is =text/plain= with =nosniff=. Rendered markdown/org is 18 serving is =text/plain= with =nosniff=. Rendered markdown/org is
19 sanitized (bluemonday) and served under a CSP that forbids scripts. 19 sanitized (bluemonday) and served under a CSP that forbids scripts.
20- *Put secrets in argv, URLs, or logs.* Import and mirror credentials, 20- *Put secrets in argv, URLs, or logs, with one documented exception.*
21 registration invites, and API tokens travel on stdin or in request 21 Import and mirror credentials, registration invites, and API tokens
22 bodies, never as command arguments (visible in =/proc=) or query 22 travel on stdin or in request bodies, never as command arguments
23 strings. Tokens are stored only as SHA-256 hashes. 23 (visible in =/proc=) or query strings. The one exception is the
24 emailed login link, =/login?token=...=: single-use, 15-minute expiry,
25 and the response that consumes it carries =Cache-Control: no-store= so
26 no intermediary keeps a copy. An operator running gitbay behind a
27 reverse proxy should configure that proxy to strip the query string
28 from its own access logs. Tokens are stored only as SHA-256 hashes.
24- *Name a mail recipient in the log.* A queued mail is logged by its 29- *Name a mail recipient in the log.* A queued mail is logged by its
25 queue row id, never by address, and the relay's own error is redacted 30 queue row id, never by address, and the relay's own error is redacted
26 before it is logged because a rejection usually quotes the address it 31 before it is logged because a rejection usually quotes the address it
CHANGELOG.org +15
@@ -98,6 +98,21 @@ for the eighteen commands whose CLI path differs from the registry's
98 picked the same way the web page picks one (#268). 98 picked the same way the web page picks one (#268).
99- =repo show='s mirror table truncates =LAST SYNC= to the second, like 99- =repo show='s mirror table truncates =LAST SYNC= to the second, like
100 every other timestamp in a view (#268). 100 every other timestamp in a view (#268).
101- A =-- foreign_keys: off= migration's =foreign_key_check= now runs
102 inside the migration's own transaction, before commit, so a
103 violation rolls the migration back instead of leaving the bad
104 schema and =user_version= already persisted (#261).
105- The web pin and watch buttons dispatch through =repo pin=/=unpin=
106 and =repo watch=/=mute=/=unwatch= instead of writing the store
107 directly, so a refusal reaches the viewer as a message instead of
108 being dropped. The watch button now cycles three states — default,
109 watching, muted — instead of two (#261).
110- The response that consumes a login link's =?token== sends
111 =Cache-Control: no-store=, so no intermediary keeps a copy of the
112 single-use URL (#261).
113- Wiki documentation fixes: API.org clarifies token commands work on the
114 API, Parity.org documents batched review and web watch/pin dispatch,
115 Threat-Model.org documents the login-link URL exception (#261).
101 116
102* v1.36.0 — 2026-09-23 117* v1.36.0 — 2026-09-23
103 118
internal/httpd/account_test.go +109
@@ -175,3 +175,112 @@ func TestAccountPageMasksAShortDeviceToken(t *testing.T) {
175 t.Fatalf("the device table has no id column:\n%s", body) 175 t.Fatalf("the device table has no id column:\n%s", body)
176 } 176 }
177} 177}
178
179// assertAudited fails the test unless an audit row with the given action
180// prefix exists — proof a handler dispatched through the control
181// registry rather than writing the store directly, since only Dispatch
182// itself calls Store.Audit.
183func assertAudited(t *testing.T, st *store.Store, prefix string) {
184 t.Helper()
185 entries, err := st.AuditEntries(store.AuditFilter{ActionPrefix: prefix, Limit: 10})
186 if err != nil {
187 t.Fatal(err)
188 }
189 if len(entries) == 0 {
190 t.Fatalf("no audit row with action prefix %q", prefix)
191 }
192}
193
194// Pinning writes through the repo pin command, not the store directly,
195// so it carries the same audit trail and write budget as every other
196// mutating command (#261).
197func TestPinToggleDispatchesRepoPin(t *testing.T) {
198 st, err := store.Open(":memory:")
199 if err != nil {
200 t.Fatal(err)
201 }
202 defer st.Close()
203 if err := st.MigrateUp(); err != nil {
204 t.Fatal(err)
205 }
206 uid, err := st.CreateUser("alice", false)
207 if err != nil {
208 t.Fatal(err)
209 }
210 u := store.User{ID: uid, Username: "alice"}
211 if _, err := st.CreateRepo("user", uid, "app", "public"); err != nil {
212 t.Fatal(err)
213 }
214
215 s := New(config.Default(), st)
216 req := httptest.NewRequest("POST", "/alice/app/pin", nil)
217 req.SetPathValue("owner", "alice")
218 req.SetPathValue("repo", "app")
219 rr := httptest.NewRecorder()
220 s.pinToggle(rr, req, u)
221
222 repo, err := st.RepoByPath("alice/app")
223 if err != nil {
224 t.Fatal(err)
225 }
226 if !st.IsPinned(uid, repo.ID) {
227 t.Fatal("pin did not take effect")
228 }
229 assertAudited(t, st, "cmd repo pin")
230
231 rr2 := httptest.NewRecorder()
232 s.pinToggle(rr2, req, u)
233 if st.IsPinned(uid, repo.ID) {
234 t.Fatal("second toggle should have unpinned")
235 }
236 assertAudited(t, st, "cmd repo unpin")
237}
238
239// The watch button cycles default, watching, muted — the three states
240// repo watch/repo mute/repo unwatch already support — rather than the
241// two the store-writing version offered (#261, #271).
242func TestWatchToggleCyclesThroughMuted(t *testing.T) {
243 st, err := store.Open(":memory:")
244 if err != nil {
245 t.Fatal(err)
246 }
247 defer st.Close()
248 if err := st.MigrateUp(); err != nil {
249 t.Fatal(err)
250 }
251 uid, err := st.CreateUser("alice", false)
252 if err != nil {
253 t.Fatal(err)
254 }
255 u := store.User{ID: uid, Username: "alice"}
256 if _, err := st.CreateRepo("user", uid, "app", "public"); err != nil {
257 t.Fatal(err)
258 }
259 repo, err := st.RepoByPath("alice/app")
260 if err != nil {
261 t.Fatal(err)
262 }
263
264 s := New(config.Default(), st)
265 req := httptest.NewRequest("POST", "/alice/app/watch", nil)
266 req.SetPathValue("owner", "alice")
267 req.SetPathValue("repo", "app")
268
269 click := func() string {
270 rr := httptest.NewRecorder()
271 s.watchToggle(rr, req, u)
272 return st.RepoWatchState(repo.ID, uid)
273 }
274 if got := click(); got != "watching" {
275 t.Fatalf("first click: got %q, want watching", got)
276 }
277 assertAudited(t, st, "cmd repo watch")
278 if got := click(); got != "muted" {
279 t.Fatalf("second click: got %q, want muted", got)
280 }
281 assertAudited(t, st, "cmd repo mute")
282 if got := click(); got != "" {
283 t.Fatalf("third click: got %q, want default (unwatched)", got)
284 }
285 assertAudited(t, st, "cmd repo unwatch")
286}
internal/httpd/accounts.go +12 −4
@@ -121,6 +121,10 @@ func (s *Server) loginSubmit(w http.ResponseWriter, r *http.Request) {
121} 121}
122 122
123func (s *Server) login(w http.ResponseWriter, r *http.Request) { 123func (s *Server) login(w http.ResponseWriter, r *http.Request) {
124 // token, when present, is a single-use secret in the query string —
125 // the documented exception to "never in a URL" (Threat-Model). No
126 // cache may keep a copy of this response.
127 w.Header().Set("Cache-Control", "no-store")
124 token := r.URL.Query().Get("token") 128 token := r.URL.Query().Get("token")
125 if token == "" { 129 if token == "" {
126 s.renderLogin(w, "", false, s.peekNext(r)) 130 s.renderLogin(w, "", false, s.peekNext(r))
@@ -240,16 +244,20 @@ func (s *Server) newSubmit(w http.ResponseWriter, r *http.Request, u store.User)
240 http.Redirect(w, r, "/"+owner+"/"+name, http.StatusSeeOther) 244 http.Redirect(w, r, "/"+owner+"/"+name, http.StatusSeeOther)
241} 245}
242 246
243// pinToggle pins or unpins the repo for the logged-in viewer. 247// pinToggle pins or unpins the repo for the logged-in viewer, through
248// repo pin/repo unpin — the same commands the CLI runs — rather than
249// writing the store directly (#261).
244func (s *Server) pinToggle(w http.ResponseWriter, r *http.Request, u store.User) { 250func (s *Server) pinToggle(w http.ResponseWriter, r *http.Request, u store.User) {
245 repo, ok := s.repoForUser(w, r, u, policy.CanRead) 251 repo, ok := s.repoForUser(w, r, u, policy.CanRead)
246 if !ok { 252 if !ok {
247 return 253 return
248 } 254 }
255 verb := "pin"
249 if s.st.IsPinned(u.ID, repo.ID) { 256 if s.st.IsPinned(u.ID, repo.ID) {
250 s.st.UnpinRepo(u.ID, repo.ID) 257 verb = "unpin"
251 } else { 258 }
252 s.st.PinRepo(u.ID, repo.ID) 259 if _, msg, ok := s.runControl(u, []string{"repo", verb, repo.Path()}); !ok {
260 s.setFlash(w, msg)
253 } 261 }
254 http.Redirect(w, r, "/"+repo.Path(), http.StatusSeeOther) 262 http.Redirect(w, r, "/"+repo.Path(), http.StatusSeeOther)
255} 263}
internal/httpd/logincookie_test.go +24
@@ -2,9 +2,11 @@ package httpd
2 2
3import ( 3import (
4 "net/http" 4 "net/http"
5 "net/http/httptest"
5 "testing" 6 "testing"
6 7
7 "gitbay.org/gitbay/internal/config" 8 "gitbay.org/gitbay/internal/config"
9 "gitbay.org/gitbay/internal/store"
8) 10)
9 11
10// The session cookie must be Lax, not Strict. A login link clicked in a mail 12// The session cookie must be Lax, not Strict. A login link clicked in a mail
@@ -36,3 +38,25 @@ func TestSessionCookieAttributes(t *testing.T) {
36 } 38 }
37 } 39 }
38} 40}
41
42// The login link's token rides in the query string — the one
43// documented exception to "never in a URL" — so the response that
44// consumes it must never be cached by an intermediary that might log
45// or replay the URL (#261).
46func TestLoginNoStoreHeader(t *testing.T) {
47 st, err := store.Open(":memory:")
48 if err != nil {
49 t.Fatal(err)
50 }
51 defer st.Close()
52 if err := st.MigrateUp(); err != nil {
53 t.Fatal(err)
54 }
55 s := New(config.Default(), st)
56 rr := httptest.NewRecorder()
57 req := httptest.NewRequest("GET", "/login?token=bogus", nil)
58 s.login(rr, req)
59 if got := rr.Header().Get("Cache-Control"); got != "no-store" {
60 t.Errorf("Cache-Control = %q, want no-store", got)
61 }
62}
internal/httpd/notifyweb.go +7 −6
@@ -56,17 +56,18 @@ func (s *Server) notificationsRead(w http.ResponseWriter, r *http.Request, u sto
56 http.Redirect(w, r, "/notifications", http.StatusSeeOther) 56 http.Redirect(w, r, "/notifications", http.StatusSeeOther)
57} 57}
58 58
59// watchToggle turns watching a repository on and off from its header, 59// watchToggle cycles the viewer's watch state on a repository: default,
60// the way the pin button does. 60// watching, muted, back to default — through repo watch/repo mute/repo
61// unwatch, the same commands the CLI runs (#261, #271).
61func (s *Server) watchToggle(w http.ResponseWriter, r *http.Request, u store.User) { 62func (s *Server) watchToggle(w http.ResponseWriter, r *http.Request, u store.User) {
62 repo, ok := s.repoForUser(w, r, u, policy.CanRead) 63 repo, ok := s.repoForUser(w, r, u, policy.CanRead)
63 if !ok { 64 if !ok {
64 return 65 return
65 } 66 }
66 if s.st.RepoWatchState(repo.ID, u.ID) == "watching" { 67 next := map[string]string{"": "watch", "watching": "mute", "muted": "unwatch"}
67 s.st.ClearRepoWatch(repo.ID, u.ID) 68 verb := next[s.st.RepoWatchState(repo.ID, u.ID)]
68 } else { 69 if _, msg, ok := s.runControl(u, []string{"repo", verb, repo.Path()}); !ok {
69 s.st.SetRepoWatch(repo.ID, u.ID, "watching") 70 s.setFlash(w, msg)
70 } 71 }
71 http.Redirect(w, r, "/"+repo.Path(), http.StatusSeeOther) 72 http.Redirect(w, r, "/"+repo.Path(), http.StatusSeeOther)
72} 73}
internal/store/store.go +80 −76
@@ -189,51 +189,26 @@ func (s *Store) migrateTo(target int) error {
189 if err != nil { 189 if err != nil {
190 return err 190 return err
191 } 191 }
192 step := func(sqlText string, newVersion int, fkOff bool) (retErr error) { 192 for cur < target {
193 if !fkOff { 193 m := ms[cur]
194 tx, err := s.DB.Begin() 194 if err := s.migrateStep(m.up, m.version, m.upFKOff); err != nil {
195 if err != nil { 195 return fmt.Errorf("migration %d up: %w", m.version, err)
196 return err
197 }
198 defer tx.Rollback()
199 if _, err := tx.Exec(sqlText); err != nil {
200 return err
201 }
202 if _, err := tx.Exec(fmt.Sprintf("PRAGMA user_version = %d", newVersion)); err != nil {
203 return err
204 }
205 return tx.Commit()
206 }
207
208 // A script whose first line is "-- foreign_keys: off" rebuilds a
209 // table that other tables reference (labels, milestones): with
210 // foreign keys on, the rebuild-by-rename loses the children's
211 // rows. PRAGMA foreign_keys is a no-op inside a transaction, and
212 // the pool gives no guarantee that a pragma set on one connection
213 // is seen by the connection Begin() draws next, so the whole step
214 // — pragma off, transaction, pragma on, foreign_key_check — runs
215 // on a single pinned connection.
216 ctx := context.Background()
217 conn, err := s.DB.Conn(ctx)
218 if err != nil {
219 return err
220 }
221 defer conn.Close()
222 if _, err := conn.ExecContext(ctx, "PRAGMA foreign_keys = OFF"); err != nil {
223 return err
224 } 196 }
225 // The connection goes back to the pool when this returns, so every 197 cur = m.version
226 // path out of here has to put foreign keys back on first. 198 }
227 restoreFK := func() error { 199 for cur > target {
228 _, err := conn.ExecContext(ctx, "PRAGMA foreign_keys = ON") 200 m := ms[cur-1]
229 return err 201 if err := s.migrateStep(m.down, m.version-1, m.downFKOff); err != nil {
202 return fmt.Errorf("migration %d down: %w", m.version, err)
230 } 203 }
231 defer func() { 204 cur = m.version - 1
232 if err := restoreFK(); err != nil && retErr == nil { 205 }
233 retErr = err 206 return nil
234 } 207}
235 }() 208
236 tx, err := conn.BeginTx(ctx, nil) 209func (s *Store) migrateStep(sqlText string, newVersion int, fkOff bool) (retErr error) {
210 if !fkOff {
211 tx, err := s.DB.Begin()
237 if err != nil { 212 if err != nil {
238 return err 213 return err
239 } 214 }
@@ -244,44 +219,73 @@ func (s *Store) migrateTo(target int) error {
244 if _, err := tx.Exec(fmt.Sprintf("PRAGMA user_version = %d", newVersion)); err != nil { 219 if _, err := tx.Exec(fmt.Sprintf("PRAGMA user_version = %d", newVersion)); err != nil {
245 return err 220 return err
246 } 221 }
247 if err := tx.Commit(); err != nil { 222 return tx.Commit()
248 return err
249 }
250 if err := restoreFK(); err != nil {
251 return err
252 }
253 rows, err := conn.QueryContext(ctx, "PRAGMA foreign_key_check")
254 if err != nil {
255 return err
256 }
257 defer rows.Close()
258 if rows.Next() {
259 var table string
260 var rowid sql.NullInt64
261 var referredTable string
262 var fkid int
263 if err := rows.Scan(&table, &rowid, &referredTable, &fkid); err != nil {
264 return err
265 }
266 return fmt.Errorf("foreign_key_check failed after migration: %s", table)
267 }
268 return rows.Err()
269 } 223 }
270 for cur < target { 224
271 m := ms[cur] 225 // A script whose first line is "-- foreign_keys: off" rebuilds a
272 if err := step(m.up, m.version, m.upFKOff); err != nil { 226 // table that other tables reference (labels, milestones): with
273 return fmt.Errorf("migration %d up: %w", m.version, err) 227 // foreign keys on, the rebuild-by-rename loses the children's
228 // rows. PRAGMA foreign_keys is a no-op inside a transaction, and
229 // the pool gives no guarantee that a pragma set on one connection
230 // is seen by the connection Begin() draws next, so the whole step
231 // — pragma off, transaction, foreign_key_check, commit, pragma on —
232 // runs on a single pinned connection. The check runs before commit:
233 // checking after would report a violation once the bad schema and
234 // user_version were already persisted.
235 ctx := context.Background()
236 conn, err := s.DB.Conn(ctx)
237 if err != nil {
238 return err
239 }
240 defer conn.Close()
241 if _, err := conn.ExecContext(ctx, "PRAGMA foreign_keys = OFF"); err != nil {
242 return err
243 }
244 // The connection goes back to the pool when this returns, so every
245 // path out of here has to put foreign keys back on first.
246 defer func() {
247 if _, err := conn.ExecContext(ctx, "PRAGMA foreign_keys = ON"); err != nil && retErr == nil {
248 retErr = err
274 } 249 }
275 cur = m.version 250 }()
251 tx, err := conn.BeginTx(ctx, nil)
252 if err != nil {
253 return err
276 } 254 }
277 for cur > target { 255 defer tx.Rollback()
278 m := ms[cur-1] 256 if _, err := tx.Exec(sqlText); err != nil {
279 if err := step(m.down, m.version-1, m.downFKOff); err != nil { 257 return err
280 return fmt.Errorf("migration %d down: %w", m.version, err) 258 }
259 if _, err := tx.Exec(fmt.Sprintf("PRAGMA user_version = %d", newVersion)); err != nil {
260 return err
261 }
262 // foreign_key_check works with enforcement off: it inspects the data
263 // directly rather than consulting the pragma. Running it here, inside
264 // the transaction, means a violation rolls back the whole rebuild
265 // (the deferred tx.Rollback fires) instead of leaving the bad schema
266 // and version committed.
267 rows, err := tx.QueryContext(ctx, "PRAGMA foreign_key_check")
268 if err != nil {
269 return err
270 }
271 if rows.Next() {
272 var table string
273 var rowid sql.NullInt64
274 var referredTable string
275 var fkid int
276 if err := rows.Scan(&table, &rowid, &referredTable, &fkid); err != nil {
277 rows.Close()
278 return err
281 } 279 }
282 cur = m.version - 1 280 rows.Close()
281 return fmt.Errorf("foreign_key_check failed after migration: %s row %v", table, rowid)
283 } 282 }
284 return nil 283 if err := rows.Err(); err != nil {
284 rows.Close()
285 return err
286 }
287 rows.Close()
288 return tx.Commit()
285} 289}
286 290
287// IsInternal reports whether err is the database or the I/O beneath it 291// IsInternal reports whether err is the database or the I/O beneath it
internal/store/store_test.go +52
@@ -255,3 +255,55 @@ func TestMigrationForeignKeysDirective(t *testing.T) {
255 t.Fatalf("foreign_keys after MigrateUp: %d, want 1", fk) 255 t.Fatalf("foreign_keys after MigrateUp: %d, want 1", fk)
256 } 256 }
257} 257}
258
259// A migration marked "-- foreign_keys: off" must have its
260// foreign_key_check run before the transaction commits, not after —
261// otherwise a violation is reported once the bad schema and
262// user_version are already persisted (#261).
263func TestFKOffMigrationChecksBeforeCommit(t *testing.T) {
264 s := open(t)
265 if err := s.MigrateUp(); err != nil {
266 t.Fatal(err)
267 }
268 versionBefore, err := s.Version()
269 if err != nil {
270 t.Fatal(err)
271 }
272
273 // Insert a row a fkOff rebuild would have to preserve or complain
274 // about: a milestone with no matching repo_id (the deliberately
275 // impossible case a corrupt migration would produce).
276 if _, err := s.DB.Exec("PRAGMA foreign_keys = OFF"); err != nil {
277 t.Fatal(err)
278 }
279 if _, err := s.DB.Exec(
280 "INSERT INTO milestones (repo_id, title, state, created_at) VALUES (99999, 'orphan', 'open', datetime('now'))"); err != nil {
281 t.Fatal(err)
282 }
283 if _, err := s.DB.Exec("PRAGMA foreign_keys = ON"); err != nil {
284 t.Fatal(err)
285 }
286
287 // A no-op fkOff step (rewriting milestones to itself) must now
288 // refuse — before it commits, not after — because the orphan row
289 // fails foreign_key_check.
290 err = s.migrateStep(
291 "UPDATE sqlite_master SET name = name WHERE 0", versionBefore+1, true)
292 if err == nil {
293 t.Fatal("expected foreign_key_check to refuse the orphaned row")
294 }
295 after, err := s.Version()
296 if err != nil {
297 t.Fatal(err)
298 }
299 if after != versionBefore {
300 t.Fatalf("user_version changed to %d despite the refused check (should stay %d)", after, versionBefore)
301 }
302 var fk int
303 if err := s.DB.QueryRow("PRAGMA foreign_keys").Scan(&fk); err != nil {
304 t.Fatal(err)
305 }
306 if fk != 1 {
307 t.Fatalf("foreign_keys after refused fkOff step: %d, want 1", fk)
308 }
309}