steid

@jamesgill /

steid/src/infrastructure/database_compat_tests.rs
Rust19.2 KBCode·Blame·History·Raw
1//! The expand/deploy/contract rule, enforced.
2//!
3//! The rule and its reasons are in `plans/architecture.md` ("Migrations"): a migration
4//! lands the instant a new build boots, while the previous build may still be serving
5//! from the same file, so no release may drop, rename or tighten what the previous one
6//! reads.
7//!
8//! Two tests, and only one of them is the real thing:
9//!
10//! - `no_migration_contracts_the_schema` is a **lexical guard**. It reads the real
11//! `migrations/` directory at test time, so a migration written after this file was
12//! is covered without anyone remembering to add it to a list. Being a grep, it is
13//! fooled by a table rebuilt through `create`/`insert`/`drop` — the prose rule and
14//! review cover that.
15//! - `the_current_reads_work_against_the_last_released_schema` replays the 0.4.0
16//! migration set and runs today's repository reads against it. That is the *reverse*
17//! of the direction the rule is about: proving the **old** binary reads the **new**
18//! schema would need the old binary linked in, and it is not. It is kept because it
19//! is separately true and separately needed — `steid backup` opens the pool without
20//! migrating (see `database::open`), so a new binary really does read an old schema.
21
22use std::{fs, path::PathBuf};
23
24use sqlx::{AssertSqlSafe, SqlitePool};
25
26use crate::domain::{
27 IssueNumber, IssueState, OrgId, OrgName, PullRequestState, PushEventId, RepoId, RepoName,
28 UserId, WebhookId,
29 repository::{
30 IssueRepository, LabelRepository, MembershipRepository, OrgRepository,
31 PullRequestRepository, PushEventRepository, RepoRepository, TokenRepository,
32 UserRepository, WebhookDeliveryRepository, WebhookRepository,
33 },
34};
35use crate::infrastructure::repository::{
36 SqliteIssueRepo, SqliteLabelRepo, SqliteMembershipRepo, SqliteOrgRepo, SqlitePullRequestRepo,
37 SqlitePushEventRepo, SqliteRepoRepo, SqliteTokenRepo, SqliteUserRepo,
38 SqliteWebhookDeliveryRepo, SqliteWebhookRepo,
39};
40
41/// The cumulative migration set of the newest released version, 0.4.0 (`219c396`).
42///
43/// This is the authoritative copy — `plans/releases.md` is the human record and is
44/// checked against it below. When a release is cut, rename this and replace it with the
45/// full list at that tag.
46const RELEASED_0_5_0: &[&str] = &[
47 "20260804093030_create_orgs.sql",
48 "20260804093031_create_users.sql",
49 "20260804093032_create_memberships.sql",
50 "20260804093033_create_sessions.sql",
51 "20260804093034_add_org_bio.sql",
52 "20260804093035_create_repositories.sql",
53 "20260828120000_create_tokens.sql",
54 "20260829090000_add_repository_recency.sql",
55 "20260906120100_add_repository_import.sql",
56 "20260906120200_create_issues.sql",
57 "20260906120201_create_issue_comments.sql",
58 "20260906120300_create_push_events.sql",
59 "20260906120301_create_webhooks.sql",
60 "20260906120302_create_webhook_deliveries.sql",
61 "20260906120400_add_repository_archived.sql",
62 "20260906140100_add_token_lifecycle.sql",
63 "20260906140200_create_labels.sql",
64 "20260906140201_create_issue_labels.sql",
65 "20260906180100_create_pull_requests.sql",
66 "20260906180101_create_pull_request_comments.sql",
67 "20260906190000_create_pull_request_labels.sql",
68 "20260908090500_create_password_resets.sql",
69];
70
71/// SHA-256 over the released migration files' bytes, in order, at 0.5.0.
72///
73/// `sqlx::migrate!` checksums each file — comments included — and refuses to boot when
74/// an applied migration's file has changed. So a shipped migration is immutable down to
75/// its prose: a one-word comment edit in a shipped file (which happened between 0.4.0
76/// and 0.5.0, and was reverted) would have stopped every existing instance from
77/// booting the new binary. Bump this only in a release commit, never to make it pass.
78const RELEASED_0_5_0_DIGEST: &str =
79 "e94827d74e0e715897f5f66e2d7a9709860dfcdc7b1741db2e059fcf59612b4e";
80
81#[test]
82fn a_shipped_migration_is_never_edited() {
83 use sha2::{Digest, Sha256};
84 let mut hasher = Sha256::new();
85 for name in RELEASED_0_5_0 {
86 hasher.update(std::fs::read(migrations_dir().join(name)).expect("a released migration"));
87 }
88 let digest = format!("{:x}", hasher.finalize());
89 assert_eq!(
90 digest, RELEASED_0_5_0_DIGEST,
91 "a migration that already shipped has been edited; sqlx would refuse to boot on \
92 every instance that applied it. Revert the edit — a new migration is the only \
93 way to change a shipped schema."
94 );
95}
96
97/// Migrations written before the rule existed and grandfathered past the guard.
98///
99/// Empty, and it should stay that way: every migration to date already complies, so the
100/// baseline is the beginning of history rather than a line drawn to make a test pass. An
101/// entry here is a promise that a released instance survived it, not a way to land a
102/// contracting migration.
103const GRANDFATHERED: &[&str] = &[];
104
105fn migrations_dir() -> PathBuf {
106 PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("migrations")
107}
108
109/// Every `*.sql` in `migrations/`, in the order sqlx would apply them.
110fn migration_files() -> Vec<(String, String)> {
111 let mut files: Vec<_> = fs::read_dir(migrations_dir())
112 .expect("migrations/ should be readable")
113 .map(|entry| entry.expect("a directory entry").path())
114 .filter(|path| path.extension().is_some_and(|ext| ext == "sql"))
115 .map(|path| {
116 let name = path
117 .file_name()
118 .expect("a file name")
119 .to_string_lossy()
120 .into_owned();
121 let body = fs::read_to_string(&path).expect("a migration should be readable");
122 (name, body)
123 })
124 .collect();
125
126 // The timestamp prefix is fixed-width, so lexical order is application order — the
127 // same thing sqlx does with the version it parses out of the name.
128 files.sort_by(|(a, _), (b, _)| a.cmp(b));
129 files
130}
131
132/// A migration with its `--` comments removed, and nothing else touched.
133///
134/// Comments have to go before anything else looks at the SQL: every migration in this
135/// tree opens with several paragraphs explaining itself, and half of them use the word
136/// "drop" while doing nothing of the kind. Matching on the prose would make the guard
137/// unusable within a week — `prose_about_dropping_things_is_not_a_dropped_column` is
138/// the test of that.
139fn without_comments(body: &str) -> String {
140 let mut out = String::with_capacity(body.len());
141 let mut chars = body.chars().peekable();
142 let mut in_string = false;
143
144 while let Some(c) = chars.next() {
145 if in_string {
146 out.push(c);
147 if c == '\'' {
148 in_string = false;
149 }
150 continue;
151 }
152 match c {
153 '\'' => {
154 in_string = true;
155 out.push(c);
156 }
157 '-' if chars.peek() == Some(&'-') => {
158 for skipped in chars.by_ref() {
159 if skipped == '\n' {
160 break;
161 }
162 }
163 out.push(' ');
164 }
165 _ => out.push(c),
166 }
167 }
168
169 out
170}
171
172/// A migration reduced to what the guard reads: no comments, no string literals,
173/// lowercase, whitespace collapsed.
174///
175/// String literals go too, so a migration that inserts the text "drop column" into a
176/// row is not mistaken for one that runs it. Collapsing whitespace is what lets
177/// `drop\n column` be found by searching for a phrase rather than a regex.
178fn statements_only(body: &str) -> String {
179 let mut out = String::with_capacity(body.len());
180 let stripped = without_comments(body);
181 let mut in_string = false;
182
183 for c in stripped.chars() {
184 if in_string {
185 if c == '\'' {
186 in_string = false;
187 }
188 continue;
189 }
190 if c == '\'' {
191 in_string = true;
192 } else {
193 out.push(c.to_ascii_lowercase());
194 }
195 }
196
197 out.split_whitespace().collect::<Vec<_>>().join(" ")
198}
199
200#[test]
201fn no_migration_contracts_the_schema() {
202 for (name, body) in migration_files() {
203 if GRANDFATHERED.contains(&name.as_str()) {
204 continue;
205 }
206 let sql = statements_only(&body);
207
208 for forbidden in ["drop column", "drop table", "rename to", "rename column"] {
209 assert!(
210 !sql.contains(forbidden),
211 "{name} contains `{forbidden}`, which the previous release's binary \
212 cannot survive. Contracting waits a release — see plans/architecture.md."
213 );
214 }
215
216 // `add column … not null` needs a `default`, and the check has to be per
217 // statement: a migration adding three columns must not be excused because one
218 // of the others carries a default.
219 for statement in sql.split(';') {
220 let statement = statement.trim();
221 if !statement.contains("add column") || !statement.contains("not null") {
222 continue;
223 }
224 assert!(
225 statement.contains("default"),
226 "{name} adds a `not null` column without a `default`:\n {statement}\n\
227 SQLite refuses that on a non-empty table, and the previous release's \
228 inserts would omit the column. Give it a default — see \
229 plans/architecture.md."
230 );
231 }
232 }
233}
234
235#[test]
236fn the_released_set_is_a_prefix_of_the_migrations_on_disk() {
237 let on_disk: Vec<String> = migration_files()
238 .into_iter()
239 .map(|(name, _)| name)
240 .collect();
241
242 assert!(
243 on_disk.len() >= RELEASED_0_5_0.len(),
244 "there are fewer migrations on disk than 0.5.0 shipped — one has been deleted"
245 );
246 assert_eq!(
247 &on_disk[..RELEASED_0_5_0.len()],
248 RELEASED_0_5_0,
249 "RELEASED_0_5_0 no longer matches the start of migrations/. Either a name is \
250 mistyped, or a new migration has been back-dated in front of a released one — \
251 which would make it run on an instance that has already recorded a later \
252 version. See plans/releases.md."
253 );
254}
255
256/// Applies exactly the 0.4.0 set to a fresh database, without sqlx's migrator.
257///
258/// The migrator refuses to stop part-way, and the point is to stand at an older
259/// version, so the files are applied by hand — statement by statement, split on `;`,
260/// which every migration in this tree is written to tolerate (the same split the
261/// repository column-migration test uses).
262async fn pool_at_release_0_4_0() -> SqlitePool {
263 let pool = SqlitePool::connect("sqlite::memory:")
264 .await
265 .expect("in-memory database should open");
266
267 let dir = migrations_dir();
268 for name in RELEASED_0_5_0 {
269 let body = fs::read_to_string(dir.join(name)).expect("a released migration should exist");
270 let sql = without_comments(&body);
271 for statement in sql
272 .split(';')
273 .filter(|statement| !statement.trim().is_empty())
274 {
275 // `AssertSqlSafe` because the string is read from this repository's own
276 // `migrations/` at test time, not from anything a user can reach.
277 sqlx::query(AssertSqlSafe(statement.to_owned()))
278 .execute(&pool)
279 .await
280 .unwrap_or_else(|error| panic!("{name} should apply: {error}\n {statement}"));
281 }
282 }
283
284 pool
285}
286
287/// Today's reads, against the schema 0.4.0 left behind.
288///
289/// One read per table the released schema has — a `find`, a `list` or a `count`, so a
290/// column added since and selected by name would fail here. The reads are run for their
291/// errors, not their answers: the database is empty, and what is being proved is that
292/// every query still *compiles against the old columns*.
293///
294/// `password_resets` is deliberately absent: its table arrived after 0.4.0, so at this
295/// schema it does not exist, and a binary reading it against a 0.4.0 database is exactly
296/// what expand-deploy-contract forbids anyone relying on. It joins the list when 0.5.0
297/// becomes the baseline.
298#[tokio::test]
299async fn the_current_reads_work_against_the_last_released_schema() {
300 let pool = pool_at_release_0_4_0().await;
301
302 let org_id = OrgId::from_trusted("o1");
303 let user_id = UserId::from_trusted("u1");
304 let repo_id = RepoId::from_trusted("r1");
305
306 // orgs, users, memberships, sessions
307 let orgs = SqliteOrgRepo::new(pool.clone());
308 orgs.find_by_id(&org_id).await.expect("orgs.find_by_id");
309 orgs.find_by_name(&OrgName::new("james").expect("valid name"))
310 .await
311 .expect("orgs.find_by_name");
312 assert_eq!(orgs.count().await.expect("orgs.count"), 0);
313
314 let users = SqliteUserRepo::new(pool.clone());
315 users.find_by_id(&user_id).await.expect("users.find_by_id");
316 users
317 .find_by_personal_org(&org_id)
318 .await
319 .expect("users.find_by_personal_org");
320 assert_eq!(users.count().await.expect("users.count"), 0);
321 assert!(!users.any_exist().await.expect("users.any_exist"));
322 assert!(users.sole_user().await.expect("users.sole_user").is_none());
323
324 let memberships = SqliteMembershipRepo::new(pool.clone());
325 memberships
326 .find(&org_id, &user_id)
327 .await
328 .expect("memberships.find");
329 memberships
330 .list_for_user(&user_id)
331 .await
332 .expect("memberships.list_for_user");
333
334 // Sessions are read through a `SessionTokenHash`, which is built by hashing rather
335 // than from a string, so the useful read here is the sweep: it touches the same
336 // columns and needs no value the domain will not hand out.
337 crate::domain::repository::SessionRepository::delete_expired(
338 &crate::infrastructure::repository::SqliteSessionRepo::new(pool.clone()),
339 std::time::SystemTime::UNIX_EPOCH,
340 )
341 .await
342 .expect("sessions.delete_expired");
343
344 // repositories, including the three columns 0.4.0 added
345 let repos = SqliteRepoRepo::new(pool.clone());
346 repos.find_by_id(&repo_id).await.expect("repos.find_by_id");
347 repos
348 .find_by_org_and_name(&org_id, &RepoName::new("steid").expect("valid name"))
349 .await
350 .expect("repos.find_by_org_and_name");
351 repos.list_by_org(&org_id).await.expect("repos.list_by_org");
352 assert_eq!(repos.count().await.expect("repos.count"), 0);
353
354 // tokens, with the lifecycle columns 0.4.0 added
355 let tokens = SqliteTokenRepo::new(pool.clone());
356 tokens
357 .list_by_user(&user_id)
358 .await
359 .expect("tokens.list_by_user");
360
361 // issues and issue comments
362 let issues = SqliteIssueRepo::new(pool.clone());
363 issues
364 .find_by_number(&repo_id, IssueNumber::new(1).expect("valid number"))
365 .await
366 .expect("issues.find_by_number");
367 issues
368 .list_by_repo(&repo_id, IssueState::Open, 0, 10)
369 .await
370 .expect("issues.list_by_repo");
371 issues.counts(&repo_id).await.expect("issues.counts");
372 issues
373 .comment_counts(&repo_id)
374 .await
375 .expect("issues.comment_counts");
376
377 // pull requests and their comments
378 let pulls = SqlitePullRequestRepo::new(pool.clone());
379 pulls
380 .find_by_number(&repo_id, IssueNumber::new(1).expect("valid number"))
381 .await
382 .expect("pulls.find_by_number");
383 pulls
384 .list_by_repo(&repo_id, PullRequestState::Open, 0, 10)
385 .await
386 .expect("pulls.list_by_repo");
387 pulls.counts(&repo_id).await.expect("pulls.counts");
388 pulls
389 .comment_counts(&repo_id)
390 .await
391 .expect("pulls.comment_counts");
392
393 // push events
394 let pushes = SqlitePushEventRepo::new(pool.clone());
395 pushes
396 .find(&PushEventId::from_trusted("p1"))
397 .await
398 .expect("pushes.find");
399 pushes
400 .list_by_repo(&repo_id, 10)
401 .await
402 .expect("pushes.list_by_repo");
403 pushes
404 .list_recent(std::slice::from_ref(&repo_id), 10)
405 .await
406 .expect("pushes.list_recent");
407
408 // webhooks and deliveries
409 let webhook_id = WebhookId::from_trusted("w1");
410 let webhooks = SqliteWebhookRepo::new(pool.clone());
411 webhooks.find(&webhook_id).await.expect("webhooks.find");
412 webhooks
413 .list_by_repo(&repo_id)
414 .await
415 .expect("webhooks.list_by_repo");
416
417 let deliveries = SqliteWebhookDeliveryRepo::new(pool.clone());
418 deliveries
419 .list_by_webhook(&webhook_id, 0, 10)
420 .await
421 .expect("deliveries.list_by_webhook");
422 assert_eq!(
423 deliveries
424 .count_by_webhook(&webhook_id)
425 .await
426 .expect("deliveries.count_by_webhook"),
427 0
428 );
429 deliveries
430 .latest_by_webhook(&webhook_id)
431 .await
432 .expect("deliveries.latest_by_webhook");
433
434 // labels, issue_labels, pull_request_labels
435 let labels = SqliteLabelRepo::new(pool.clone());
436 labels
437 .list_by_repo(&repo_id)
438 .await
439 .expect("labels.list_by_repo");
440 labels
441 .find_by_name(&repo_id, "bug")
442 .await
443 .expect("labels.find_by_name");
444 labels
445 .labels_for_repo(&repo_id)
446 .await
447 .expect("labels.labels_for_repo");
448 labels
449 .labels_for_repo_pulls(&repo_id)
450 .await
451 .expect("labels.labels_for_repo_pulls");
452 labels
453 .issue_counts(&repo_id)
454 .await
455 .expect("labels.issue_counts");
456 labels
457 .pull_counts(&repo_id)
458 .await
459 .expect("labels.pull_counts");
460}
461
462#[test]
463fn the_guard_catches_a_contracting_migration() {
464 // The guard's own test: the four statements it exists to refuse, in the shape a
465 // real migration would write them, checked without touching migrations/. Without
466 // this, a guard that silently stopped matching would still pass every run.
467 for contracting in [
468 "alter table repositories drop column description;",
469 "drop table webhooks;",
470 "alter table orgs rename to organisations;",
471 "alter table users rename column email to address;",
472 ] {
473 let sql = statements_only(contracting);
474 assert!(
475 ["drop column", "drop table", "rename to", "rename column"]
476 .iter()
477 .any(|forbidden| sql.contains(forbidden)),
478 "the guard no longer recognises `{contracting}`"
479 );
480 }
481
482 let tightening = statements_only("alter table repositories add column stars integer not null;");
483 assert!(tightening.contains("add column") && tightening.contains("not null"));
484 assert!(
485 !tightening.contains("default"),
486 "the guard no longer recognises a `not null` column with no default"
487 );
488}
489
490#[test]
491fn prose_about_dropping_things_is_not_a_dropped_column() {
492 // Every migration here opens with paragraphs of reasoning, and they talk about
493 // dropping and renaming constantly. A guard that read the comments would have to be
494 // switched off within a week.
495 let sql = statements_only(
496 "-- We could DROP COLUMN `bio` here, or RENAME TO something else, but\n\
497 -- contracting waits a release. See architecture.md.\n\
498 alter table orgs add column tagline text;\n",
499 );
500
501 assert_eq!(sql, "alter table orgs add column tagline text;");
502
503 // And a literal is not a statement either.
504 let sql = statements_only("insert into notes (body) values ('drop table orgs');");
505 assert_eq!(sql, "insert into notes (body) values ();");
506}