Skip to main content

acme_proxy_store/
db.rs

1//! The connection, and the only holder of the pool.
2//!
3//! **Opening a database never migrates it.** [`Database::open`] connects;
4//! [`Database::migrate`] applies the embedded set and
5//! [`Database::pending_migrations`] reports what is unapplied. Applying the
6//! schema is a named act with two callers — `acme-proxy migrate`/`init`, and a
7//! `serve` running the `worker` role — rather than a side effect of opening a
8//! file, since two processes starting together would otherwise race
9//! `MIGRATOR.run` with no lock between them. `tests/layering.rs`
10//! (`only_the_schema_owners_apply_migrations`) keeps it at two callers.
11//!
12//! **The pool is private to this crate.** A caller elsewhere opens a [`Tx`]
13//! through [`Database::transaction`] or reads [`Database::pool_stats`];
14//! [`Database::raw_pool`] exists for test fixtures only, and `tests/layering.rs`
15//! fails when production code calls it. [`Database::close`] is how the failure
16//! suites simulate an outage.
17//!
18//! Two pragmas are pinned on every connection: `foreign_keys` (the schema's
19//! `ON DELETE CASCADE` depends on it) and `journal_mode = WAL` (every ACME
20//! response writes a nonce row, and the rollback journal takes a database-wide
21//! lock per write).
22//!
23//! The tests at the bottom of this file are the migration guards: every
24//! rebuild's row preservation, and every declared width pinned to the constant
25//! it follows.
26
27use std::str::FromStr;
28use std::time::Duration;
29
30use sqlx::migrate::Migrator;
31use sqlx::postgres::{PgPoolOptions, Postgres};
32use sqlx::sqlite::{SqliteConnectOptions, SqliteJournalMode, SqlitePoolOptions};
33use sqlx::{Error, Pool, Sqlite, SqlitePool, migrate::MigrateDatabase};
34use tracing::{error, info};
35
36use crate::sql::{Dialect, Exec};
37
38/// The SQLite set, frozen and append-only since 0.1.0.
39static MIGRATOR: Migrator = sqlx::migrate!("./migrations");
40
41/// The PostgreSQL set. A schema of its own rather than a transcription: the
42/// SQLite files carry table rebuilds that exist only because SQLite cannot add
43/// a `CHECK`, and replaying those here would be archaeology rather than a
44/// schema. Append-only from its own first release, on the same rule.
45static PG_MIGRATOR: Migrator = sqlx::migrate!("./migrations-postgres");
46
47/// The connection pool, and the only way to reach it.
48///
49/// The pool is private to this crate: everything else goes through a table
50/// module, [`Database::transaction`] or [`Database::pool_stats`]. That is what
51/// keeps SQL — and the dialect it is written in — in one crate.
52pub enum Database {
53    Sqlite(Pool<Sqlite>),
54    Postgres(Pool<Postgres>),
55}
56
57/// One database transaction, handed out by [`Database::transaction`].
58///
59/// A wrapper rather than `sqlx::Transaction` itself, so the pool it is drawn
60/// from stays this crate's business: a caller outside it can open a
61/// transaction without being able to reach the pool. It derefs to the
62/// connection, so `tx.conn()` is what every table method taking an executor or a
63/// `&mut SqliteConnection` is handed. Dropped without [`Tx::commit`], it rolls
64/// back — `sqlx`'s own rule, unchanged.
65pub enum Tx {
66    Sqlite(sqlx::Transaction<'static, Sqlite>),
67    Postgres(sqlx::Transaction<'static, Postgres>),
68}
69
70impl Tx {
71    /// Commits the transaction.
72    pub async fn commit(self) -> Result<(), Error> {
73        match self {
74            Tx::Sqlite(tx) => tx.commit().await,
75            Tx::Postgres(tx) => tx.commit().await,
76        }
77    }
78
79    /// The connection underneath, for a statement to run on.
80    ///
81    /// Replaces the `Deref` this used to carry: the target was
82    /// `SqliteConnection`, which is exactly the dialect this enum exists to
83    /// stop naming. `tx.conn()` is what `tx.conn()` used to be.
84    pub fn conn(&mut self) -> Exec<'_> {
85        match self {
86            Tx::Sqlite(tx) => Exec::SqliteConn(tx),
87            Tx::Postgres(tx) => Exec::PgConn(tx),
88        }
89    }
90}
91
92/// The pool's occupancy at one instant, for the metrics gauge.
93#[derive(Clone, Copy, Debug, PartialEq, Eq)]
94pub struct PoolStats {
95    /// Every connection the pool currently holds.
96    pub size: u32,
97    /// Those of them not checked out.
98    pub idle: usize,
99}
100
101impl Database {
102    /// Which dialect this database speaks.
103    #[must_use]
104    pub fn dialect(&self) -> Dialect {
105        match self {
106            Database::Sqlite(_) => Dialect::Sqlite,
107            Database::Postgres(_) => Dialect::Postgres,
108        }
109    }
110
111    /// This database as a target for one statement.
112    #[must_use]
113    pub fn exec(&self) -> Exec<'_> {
114        match self {
115            Database::Sqlite(pool) => Exec::SqlitePool(pool),
116            Database::Postgres(pool) => Exec::PgPool(pool),
117        }
118    }
119
120    /// Begins a transaction.
121    pub async fn transaction(&self) -> Result<Tx, Error> {
122        Ok(match self {
123            Database::Sqlite(pool) => Tx::Sqlite(pool.begin().await?),
124            Database::Postgres(pool) => Tx::Postgres(pool.begin().await?),
125        })
126    }
127
128    /// Begins a transaction that holds the **write lock from its first
129    /// statement** (`BEGIN IMMEDIATE`), for one that reads and then writes on
130    /// what it read while another process may be writing.
131    ///
132    /// A plain [`transaction`](Self::transaction) is deferred: it takes a read
133    /// snapshot at its first `SELECT` and asks for the write lock only at its
134    /// first write. In WAL mode, if another connection committed in between,
135    /// that upgrade fails at once with `SQLITE_BUSY_SNAPSHOT` — `busy_timeout`
136    /// cannot help, since waiting would not make the snapshot current. Taking
137    /// the lock up front makes the transaction wait its turn under
138    /// `busy_timeout` instead, and then read what it writes against.
139    pub async fn write_transaction(&self) -> Result<Tx, Error> {
140        Ok(match self {
141            Database::Sqlite(pool) => Tx::Sqlite(pool.begin_with("BEGIN IMMEDIATE").await?),
142            // PostgreSQL needs nothing here. The hazard this exists for is a
143            // WAL snapshot going stale between a read and the write that rests
144            // on it, which `SQLITE_BUSY_SNAPSHOT` reports and `busy_timeout`
145            // cannot help. Row-level locking means the same read-then-write
146            // either blocks or re-evaluates against what it locked.
147            Database::Postgres(pool) => Tx::Postgres(pool.begin().await?),
148        })
149    }
150
151    /// The pool's size and idle count, read now rather than tracked.
152    #[must_use]
153    pub fn pool_stats(&self) -> PoolStats {
154        match self {
155            Database::Sqlite(pool) => PoolStats {
156                size: pool.size(),
157                idle: pool.num_idle(),
158            },
159            Database::Postgres(pool) => PoolStats {
160                size: pool.size(),
161                idle: pool.num_idle(),
162            },
163        }
164    }
165
166    /// The pool itself, **for test fixtures only**: raw SQL that sets up or
167    /// inspects state no table module writes or reads (a back-dated row, a
168    /// forced constraint violation).
169    ///
170    /// Public because the integration tests under `tests/` are another crate.
171    /// Production code must not call it — `tests/layering.rs` fails the build
172    /// when it appears outside `crates/store/` and outside a `#[cfg(test)]`
173    /// module.
174    #[doc(hidden)]
175    #[must_use]
176    pub fn raw_pool(&self) -> &Pool<Sqlite> {
177        match self {
178            Database::Sqlite(pool) => pool,
179            Database::Postgres(_) => {
180                panic!("raw_pool() is a SQLite test fixture; this database is PostgreSQL")
181            }
182        }
183    }
184
185    /// Closes the pool: every later query fails with `PoolClosed`.
186    ///
187    /// Waits for checked-out connections to be returned. Also how a test
188    /// simulates the database going away underneath a running server.
189    pub async fn close(&self) {
190        match self {
191            Database::Sqlite(pool) => pool.close().await,
192            Database::Postgres(pool) => pool.close().await,
193        }
194    }
195
196    /// Opens the database at `url`, whose **scheme picks the backend**.
197    /// **Does not migrate.**
198    ///
199    /// `sqlite:` creates the file if it is not there yet, and pins the two
200    /// pragmas the schema depends on. `postgres:`/`postgresql:` expects the
201    /// database to exist — creating one is a privileged act an operator
202    /// performs, not something a server does to a cluster it was pointed at.
203    /// Any other scheme is refused by name here, rather than as a driver error
204    /// several frames down.
205    ///
206    /// Applying the schema is a separate, named act: [`migrate`](Self::migrate),
207    /// `acme-proxy migrate`, or the `worker` role at startup. It used to happen
208    /// here, which meant every subcommand — `audit list`, `completions`, a
209    /// health check — silently upgraded the schema of whatever database it was
210    /// pointed at, and two processes starting together raced `MIGRATOR::run`
211    /// with no lock between them (`SQLite` gives `sqlx` none; PostgreSQL does,
212    /// an advisory lock, so there the one-owner rule is belt and braces).
213    ///
214    /// A caller that needs the schema present asks
215    /// [`pending_migrations`](Self::pending_migrations) and refuses by name, or
216    /// uses [`connect_and_migrate`](Self::connect_and_migrate).
217    pub async fn open(url: &str) -> Result<Database, Error> {
218        match scheme_of(url) {
219            Some("sqlite") => Self::open_sqlite(url).await,
220            Some("postgres" | "postgresql") => Self::open_postgres(url).await,
221            _ => Err(Error::Configuration(
222                format!(
223                    "unsupported database URL scheme in `{}`: expected one of \
224                     sqlite://, postgres:// or postgresql://",
225                    acme_proxy_core::logfields::redact_url(url)
226                )
227                .into(),
228            )),
229        }
230    }
231
232    async fn open_sqlite(url: &str) -> Result<Database, Error> {
233        if !Sqlite::database_exists(url).await.unwrap_or(false) {
234            info!(event = "db_creation_started", outcome = "progress", database_url = %url);
235            Sqlite::create_database(url).await?;
236            info!(event = "db_creation_completed", outcome = "success", database_url = %url);
237        }
238
239        let options = SqliteConnectOptions::from_str(url)?
240            // The schema's `ON DELETE CASCADE` rules only bite when foreign keys
241            // are enforced. sqlx enables them by default, but the schema depends
242            // on it, so state it here rather than inherit it.
243            .foreign_keys(true)
244            // Every response writes a nonce row. Under the default rollback
245            // journal a write takes an exclusive lock on the whole database, so
246            // the pool serializes; WAL lets readers continue during a write.
247            .journal_mode(SqliteJournalMode::Wal)
248            .busy_timeout(Duration::from_secs(5));
249
250        Ok(Database::Sqlite(SqlitePool::connect_with(options).await?))
251    }
252
253    async fn open_postgres(url: &str) -> Result<Database, Error> {
254        // Neither pragma has an analogue: foreign keys are always enforced and
255        // there is no journal mode to choose. The pool is bounded because,
256        // unlike a file, a cluster has a global connection limit that several
257        // role processes share.
258        let pool = PgPoolOptions::new()
259            .max_connections(10)
260            .acquire_timeout(Duration::from_secs(5))
261            .connect(url)
262            .await?;
263
264        Ok(Database::Postgres(pool))
265    }
266
267    /// [`open`](Self::open) followed by [`migrate`](Self::migrate).
268    ///
269    /// For the two callers that own the schema — `acme-proxy migrate` and
270    /// `acme-proxy init` — and for tests over a file-backed database, which
271    /// want the same thing in one step.
272    pub async fn connect_and_migrate(url: &str) -> Result<Database, Error> {
273        let database = Self::open(url).await?;
274        database.migrate().await?;
275        Ok(database)
276    }
277
278    /// Applies every embedded migration that has not run yet.
279    ///
280    /// Idempotent: `sqlx` tracks each file by version and checksum, so running
281    /// this against an up-to-date database does nothing.
282    pub async fn migrate(&self) -> Result<(), Error> {
283        match self {
284            Database::Sqlite(pool) => run_migrations(&MIGRATOR, pool).await,
285            Database::Postgres(pool) => run_migrations(&PG_MIGRATOR, pool).await,
286        }
287    }
288
289    /// The migration set this database is measured against.
290    fn migrator(&self) -> &'static Migrator {
291        match self {
292            Database::Sqlite(_) => &MIGRATOR,
293            Database::Postgres(_) => &PG_MIGRATOR,
294        }
295    }
296
297    /// The versions of the embedded migrations this database has not applied.
298    ///
299    /// Empty means the schema is current. What the roles that must **not**
300    /// migrate check before serving, so an unmigrated database stops them by
301    /// name rather than failing later as a missing table.
302    ///
303    /// A database with no `_sqlx_migrations` table has applied nothing — that
304    /// is a freshly created file, not an error.
305    pub async fn pending_migrations(&self) -> Result<Vec<i64>, Error> {
306        let applied: std::collections::HashSet<i64> = if self.migrations_table_exists().await? {
307            crate::sql::query("SELECT version FROM _sqlx_migrations;")
308                .fetch_all(self)
309                .await?
310                .iter()
311                .map(|row| row.try_get::<i64>(0usize))
312                .collect::<Result<_, _>>()?
313        } else {
314            std::collections::HashSet::new()
315        };
316
317        Ok(self
318            .migrator()
319            .iter()
320            .filter(|migration| !migration.migration_type.is_down_migration())
321            .map(|migration| migration.version)
322            .filter(|version| !applied.contains(version))
323            .collect())
324    }
325
326    /// Builds a throwaway in-memory database with migrations applied. Pinned to
327    /// a single connection so the whole test shares one in-memory database
328    /// (each `SQLite` connection otherwise gets its own).
329    pub async fn connect_in_memory() -> Result<Database, Error> {
330        let pool = SqlitePoolOptions::new()
331            .max_connections(1)
332            .connect_with(SqliteConnectOptions::from_str("sqlite::memory:")?.foreign_keys(true))
333            .await?;
334
335        run_migrations(&MIGRATOR, &pool).await?;
336
337        Ok(Database::Sqlite(pool))
338    }
339
340    /// The database a test should run against: **PostgreSQL when
341    /// `TEST_POSTGRES_URL` names one**, and an in-memory SQLite otherwise.
342    ///
343    /// This is what nearly every test in this crate calls, so one CI job runs
344    /// the whole suite on each backend. [`connect_in_memory`](Self::connect_in_memory)
345    /// stays, and calling it *means* SQLite — which makes the constructor a
346    /// test picks its own declaration of what it is testing. The seven tests
347    /// that read `pragma_table_info`, `sqlite_master` or replay the embedded
348    /// SQLite migration set say so by calling the other one, and need no
349    /// separate opt-out.
350    ///
351    /// Each call gets a schema of its own; see
352    /// [`crate::testutil::postgres_database`].
353    ///
354    /// # Panics
355    ///
356    /// When `ACME_PROXY_REQUIRE_POSTGRES` is set and no server can be reached
357    /// — a skip is the failure there, or a service that never started takes a
358    /// whole job green.
359    #[cfg(any(test, feature = "test-util"))]
360    pub async fn connect_for_test() -> Result<Database, Error> {
361        match crate::testutil::postgres_database().await {
362            Some(database) => Ok(database),
363            None => Self::connect_in_memory().await,
364        }
365    }
366}
367
368/// Is there a `_sqlx_migrations` table to read?
369///
370/// Asked before reading it rather than by reading it and swallowing the error.
371/// On SQLite a missing table is a clean `Err(Database(_))` the caller can
372/// treat as "nothing applied"; on PostgreSQL the failed statement aborts the
373/// surrounding transaction, so the next query in the same connection fails too
374/// and the cause is three frames away from the mistake.
375impl Database {
376    async fn migrations_table_exists(&self) -> Result<bool, Error> {
377        let sql = match self {
378            Database::Sqlite(_) => {
379                "SELECT COUNT(*) FROM sqlite_master \
380                 WHERE type = 'table' AND name = '_sqlx_migrations';"
381            }
382            // `to_regclass` resolves through `search_path`, which is what
383            // makes this the *current* schema's table rather than any schema's.
384            // `information_schema.tables` is not scoped, so a second schema in
385            // the same database — a test schema, a staging copy — answered yes
386            // for a database that had never been migrated, and the read that
387            // followed failed with `relation "_sqlx_migrations" does not exist`.
388            Database::Postgres(_) => {
389                "SELECT CASE WHEN to_regclass('_sqlx_migrations') IS NULL \
390                        THEN 0 ELSE 1 END::bigint;"
391            }
392        };
393        let count: i64 = crate::sql::query(sql)
394            .fetch_one(self)
395            .await?
396            .try_get(0usize)?;
397        Ok(count > 0)
398    }
399}
400
401/// The scheme of a URL, lower-cased, or `None` if it has none.
402fn scheme_of(url: &str) -> Option<&str> {
403    let scheme = url.split("://").next()?;
404    (scheme != url).then_some(scheme)
405}
406
407async fn run_migrations<DB>(migrator: &Migrator, pool: &Pool<DB>) -> Result<(), Error>
408where
409    DB: sqlx::Database,
410    DB::Connection: sqlx::migrate::Migrate,
411{
412    migrator.run(pool).await.map_err(|error| {
413        // Startup-only, and the caller exits on error — but a `Result`-returning
414        // function should not decide that on its own by panicking.
415        error!(event = "db_migration_failed", outcome = "failure", error = %error);
416        Error::Migrate(Box::new(error))
417    })?;
418    info!(event = "db_migration_completed", outcome = "success");
419    Ok(())
420}
421
422#[cfg(test)]
423mod tests {
424    use super::*;
425
426    use acme_proxy_core::random::random_token;
427
428    #[tokio::test]
429    async fn connect_creates_file_and_runs_migrations() {
430        // A unique temp path so the "database does not exist → create it" branch
431        // runs (the in-memory helper never exercises it).
432        let file =
433            std::env::temp_dir().join(format!("acme-proxy-test-{}.db", uuid::Uuid::now_v7()));
434        let url = format!("sqlite://{}", file.display());
435
436        let database = Database::connect_and_migrate(&url).await.unwrap();
437
438        // Migrations applied: the `nonces` table exists and is queryable.
439        let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM nonces;")
440            .fetch_one(database.raw_pool())
441            .await
442            .unwrap();
443        assert_eq!(count, 0);
444
445        // WAL and foreign-key enforcement are on: the schema's CASCADE rules
446        // depend on the latter.
447        let journal: String = sqlx::query_scalar("PRAGMA journal_mode;")
448            .fetch_one(database.raw_pool())
449            .await
450            .unwrap();
451        assert_eq!(journal.to_lowercase(), "wal");
452        let foreign_keys: i64 = sqlx::query_scalar("PRAGMA foreign_keys;")
453            .fetch_one(database.raw_pool())
454            .await
455            .unwrap();
456        assert_eq!(foreign_keys, 1);
457
458        database.close().await;
459        // WAL leaves sidecar files behind.
460        for suffix in ["", "-wal", "-shm"] {
461            let _ = std::fs::remove_file(format!("{}{suffix}", file.display()));
462        }
463    }
464
465    /// A `Tx` keeps `sqlx`'s transaction semantics through the wrapper: a
466    /// commit lands, a drop rolls back, and the pool reports its occupancy.
467    #[tokio::test]
468    async fn a_transaction_commits_or_rolls_back_through_the_wrapper() {
469        let database = Database::connect_in_memory().await.unwrap();
470
471        let mut tx = database.transaction().await.unwrap();
472        crate::sql::query("INSERT INTO nonces VALUES ('dropped', 0);")
473            .execute(tx.conn())
474            .await
475            .unwrap();
476        drop(tx);
477        assert_eq!(count(&database).await, 0, "a dropped Tx rolls back");
478
479        let mut tx = database.transaction().await.unwrap();
480        crate::sql::query("INSERT INTO nonces VALUES ('kept', 0);")
481            .execute(tx.conn())
482            .await
483            .unwrap();
484        tx.commit().await.unwrap();
485        assert_eq!(count(&database).await, 1, "a committed Tx lands");
486
487        // `connect_in_memory` pins the pool to one connection. Whether it reads
488        // idle yet is a race with `sqlx` handing it back, so only the bound is
489        // asserted.
490        let stats = database.pool_stats();
491        assert_eq!(stats.size, 1);
492        assert!(stats.idle <= 1, "{stats:?}");
493
494        database.close().await;
495        let refused = crate::sql::query("SELECT 1;")
496            .execute(database.raw_pool())
497            .await;
498        assert!(refused.is_err(), "a closed pool refuses work");
499    }
500
501    async fn count(database: &Database) -> i64 {
502        sqlx::query_scalar("SELECT COUNT(*) FROM nonces;")
503            .fetch_one(database.raw_pool())
504            .await
505            .unwrap()
506    }
507
508    /// Every foreign key is indexed. Without these, each child lookup is a full
509    /// table scan — `Authorization::find_by_order` runs on every order read.
510    #[tokio::test]
511    async fn foreign_keys_and_the_nonce_sweep_are_indexed() {
512        let database = Database::connect_in_memory().await.unwrap();
513        let names: Vec<String> =
514            sqlx::query_scalar("SELECT name FROM sqlite_master WHERE type = 'index';")
515                .fetch_all(database.raw_pool())
516                .await
517                .unwrap();
518
519        for expected in [
520            "idx_orders_account_id",
521            "idx_authorizations_order",
522            "idx_challenges_authz",
523            "idx_nonces_created_at",
524            "idx_orders_cert_serial",
525            "idx_orders_replaces_claim",
526            // Not a foreign key, but `eab delete` and `account list --eab-kid`
527            // look accounts up by it.
528            "idx_accounts_eab_kid",
529        ] {
530            assert!(
531                names.iter().any(|name| name == expected),
532                "missing index {expected}; have {names:?}"
533            );
534        }
535    }
536
537    /// The declared width of every column holding a [`random_token`] value must
538    /// match what that function actually produces.
539    ///
540    /// `nonces.value` was declared `VARCHAR(36)` — accurate for the UUID v4 it
541    /// held until the nonce moved to the CSPRNG, and false from that moment on.
542    /// It stayed false because SQLite gives the column TEXT affinity and
543    /// enforces no length, so nothing anywhere could notice. This is what
544    /// notices: change `TOKEN_BYTES` and the failure lands here, beside the
545    /// migration that has to be written.
546    #[tokio::test]
547    async fn declared_token_widths_match_random_token() {
548        let database = Database::connect_in_memory().await.unwrap();
549        let expected = format!("VARCHAR({})", random_token().len());
550
551        for (table, column) in [("nonces", "value"), ("challenges", "token")] {
552            assert_eq!(
553                declared_type(&database, table, column).await,
554                expected,
555                "{table}.{column} declares a width the value no longer has"
556            );
557        }
558    }
559
560    /// The width `revocations.issuer` and `crls.issuer` declare is the length of
561    /// [`acme_proxy_core::cert::issuer_id`], the `declared_token_widths_match_random_token`
562    /// rule applied to the other derived value this schema stores.
563    #[tokio::test]
564    async fn declared_issuer_widths_match_the_issuer_id() {
565        let database = Database::connect_in_memory().await.unwrap();
566        let expected = format!(
567            "VARCHAR({})",
568            acme_proxy_core::cert::issuer_id(b"any key").len()
569        );
570
571        for (table, column) in [("revocations", "issuer"), ("crls", "issuer")] {
572            assert_eq!(
573                declared_type(&database, table, column).await,
574                expected,
575                "{table}.{column} declares a width the issuer id does not have"
576            );
577        }
578    }
579
580    /// The declared type of every column holding a row id.
581    ///
582    /// The [`random_token`] twin above, for the other family of values the
583    /// schema declares a type for. Ids are the 16 bytes of a UUID
584    /// ([`crate::id`]), stored as a BLOB rather than as the 36
585    /// characters of its rendering, and this is what notices a column that went
586    /// back to text — or a new table added with a `VARCHAR(36)` id out of
587    /// habit.
588    ///
589    /// It matters for the reason the widths do: SQLite gives a declared type an
590    /// affinity and enforces nothing, where the PostgreSQL set these
591    /// declarations will be transcribed into (issue #4) has a native `uuid`
592    /// and does enforce it. `nonces.value` is what a stale declaration looks
593    /// like once nothing can notice it.
594    #[tokio::test]
595    async fn every_id_column_is_declared_a_blob() {
596        let database = Database::connect_in_memory().await.unwrap();
597
598        let minted = crate::id::mint();
599        assert_eq!(
600            minted.get_version_num(),
601            7,
602            "ids are UUID v7 (RFC 9562 §5.7)"
603        );
604        assert_eq!(minted.as_bytes().len(), 16, "which is what a column holds");
605
606        for (table, column) in [
607            ("accounts", "id"),
608            ("accounts", "eab_kid"),
609            ("orders", "id"),
610            ("orders", "account_id"),
611            ("authorizations", "id"),
612            ("authorizations", "order_id"),
613            ("challenges", "id"),
614            ("challenges", "authz_id"),
615            ("eab_keys", "kid"),
616            ("upstream_orders", "order_id"),
617            ("admin_users", "id"),
618            ("admin_sessions", "user_id"),
619            ("admin_recovery_codes", "id"),
620            ("admin_recovery_codes", "user_id"),
621            ("jobs", "id"),
622        ] {
623            assert_eq!(
624                declared_type(&database, table, column).await,
625                "BLOB",
626                "{table}.{column} holds a row id"
627            );
628        }
629
630        // Asserted by name so neither reads as an oversight later. `audit_log`
631        // has no foreign keys on purpose — its rows outlive their subjects, so
632        // these two name a row that may be gone rather than pointing at one,
633        // and they sit beside `actor_id` and `request_id`, which are free-form.
634        for column in ["account_id", "order_id"] {
635            assert_eq!(
636                declared_type(&database, "audit_log", column).await,
637                "VARCHAR(36)",
638                "audit_log.{column} is deliberately still text"
639            );
640        }
641    }
642
643    /// Version of `20260827120000_uuid_ids_as_blobs.sql`, the migration that
644    /// converted every id column from its 36-character rendering to the 16
645    /// bytes behind it.
646    const BLOB_IDS: i64 = 20_260_827_120_000;
647
648    /// Every row survives the conversion to BLOB ids, with its id intact and
649    /// its foreign keys still resolving.
650    ///
651    /// This is the only thing standing between a mistyped column list and
652    /// silent data loss, and there are two ways to lose a row there. An
653    /// `INSERT ... SELECT` drops any column it does not name, quietly. And
654    /// `DROP TABLE` under `foreign_keys = ON` fires `ON DELETE CASCADE` into
655    /// every child, so a rebuild that drops a parent while a rebuilt child
656    /// already references it empties the child — with no error, and nothing
657    /// else in this suite would notice, since a fresh database has no rows to
658    /// lose.
659    ///
660    /// So the fixture is a database at the migration *before* that one, seeded
661    /// through raw SQL with a v4 id in every column that was about to move,
662    /// including the nullable `accounts.eab_kid` (where an unconvertible value
663    /// would become `NULL` rather than failing a `NOT NULL`) and the columns
664    /// deliberately left as text beside them.
665    #[tokio::test]
666    async fn the_blob_migration_preserves_every_row() {
667        let pool = SqlitePoolOptions::new()
668            .max_connections(1)
669            .connect_with(
670                SqliteConnectOptions::from_str("sqlite::memory:")
671                    .unwrap()
672                    .foreign_keys(true),
673            )
674            .await
675            .unwrap();
676
677        let mut converted = None;
678        for migration in MIGRATOR.iter() {
679            if migration.version == BLOB_IDS {
680                converted = Some(migration);
681                break;
682            }
683            sqlx::raw_sql(migration.sql.clone())
684                .execute(&pool)
685                .await
686                .unwrap();
687        }
688        let converted = converted.expect("the id migration is in the embedded set");
689
690        sqlx::raw_sql(SEED_V4_ROWS).execute(&pool).await.unwrap();
691        sqlx::raw_sql(converted.sql.clone())
692            .execute(&pool)
693            .await
694            .unwrap();
695
696        // Every table kept its row, and every id is now the 16 bytes of the v4
697        // it held. `accounts.eab_kid` is the nullable one, and carries a value
698        // here for exactly that reason.
699        let account: (Vec<u8>, Option<Vec<u8>>) =
700            sqlx::query_as("SELECT id, eab_kid FROM accounts;")
701                .fetch_one(&pool)
702                .await
703                .unwrap();
704        assert_eq!(
705            uuid::Uuid::from_slice(&account.0).unwrap().to_string(),
706            "11111111-1111-4111-8111-111111111111"
707        );
708        assert_eq!(
709            uuid::Uuid::from_slice(&account.1.expect("eab_kid survived"))
710                .unwrap()
711                .to_string(),
712            "99999999-9999-4999-8999-999999999999"
713        );
714
715        for table in [
716            "orders",
717            "authorizations",
718            "challenges",
719            "upstream_orders",
720            "eab_keys",
721            "admin_users",
722            "admin_sessions",
723            "admin_recovery_codes",
724            "jobs",
725            "audit_log",
726        ] {
727            // `AssertSqlSafe` for `Job::claim_next`'s reason: sqlx refuses a
728            // query string that is not `'static`, and the table name here comes
729            // from the literal list above rather than from any input.
730            let rows: i64 = sqlx::query_scalar(sqlx::AssertSqlSafe(format!(
731                "SELECT COUNT(*) FROM {table};"
732            )))
733            .fetch_one(&pool)
734            .await
735            .unwrap();
736            assert_eq!(rows, 1, "{table} lost its row");
737        }
738
739        // The foreign keys resolve across the conversion — a join is what
740        // proves both sides were converted the same way, where two counts
741        // would pass even if they had not been.
742        let joined: i64 = sqlx::query_scalar(
743            "SELECT COUNT(*) FROM challenges c \
744             JOIN authorizations a ON a.id = c.authz_id \
745             JOIN orders o ON o.id = a.order_id \
746             JOIN accounts acct ON acct.id = o.account_id;",
747        )
748        .fetch_one(&pool)
749        .await
750        .unwrap();
751        assert_eq!(joined, 1, "the account → challenge chain no longer joins");
752
753        let violations: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM pragma_foreign_key_check;")
754            .fetch_one(&pool)
755            .await
756            .unwrap();
757        assert_eq!(violations, 0);
758
759        // The columns that deliberately did not move still hold their text.
760        let replaces: String = sqlx::query_scalar("SELECT replaces FROM orders;")
761            .fetch_one(&pool)
762            .await
763            .unwrap();
764        assert_eq!(replaces, "aaa.bbb", "an ARI certID is not one of our ids");
765        let audited: String = sqlx::query_scalar("SELECT account_id FROM audit_log;")
766            .fetch_one(&pool)
767            .await
768            .unwrap();
769        assert_eq!(
770            audited, "11111111-1111-4111-8111-111111111111",
771            "audit_log names a row that may be gone, and stays text"
772        );
773
774        // And the CASCADE the staging detour exists to protect is still wired:
775        // it must survive the rebuild, not merely be absent during it.
776        sqlx::raw_sql("DELETE FROM accounts;")
777            .execute(&pool)
778            .await
779            .unwrap();
780        for table in ["orders", "authorizations", "challenges"] {
781            // `AssertSqlSafe` for `Job::claim_next`'s reason: sqlx refuses a
782            // query string that is not `'static`, and the table name here comes
783            // from the literal list above rather than from any input.
784            let rows: i64 = sqlx::query_scalar(sqlx::AssertSqlSafe(format!(
785                "SELECT COUNT(*) FROM {table};"
786            )))
787            .fetch_one(&pool)
788            .await
789            .unwrap();
790            assert_eq!(rows, 0, "deleting the account did not cascade into {table}");
791        }
792    }
793
794    const AUDIT_LOG_ADMIN_ACTIONS: i64 = 20_260_909_120_000;
795
796    /// `20260909120000` rebuilds `audit_log` to drop the `event` `CHECK` (the
797    /// Rust `AuditEvent` enum is the authority now). A rebuild is where a
798    /// mistyped column list loses a row silently — `audit_log` has no foreign
799    /// keys, so this is the simple case, but the guard is the same: seed a row
800    /// with every column populated, apply the migration, and assert the row
801    /// came back whole, that an event name no enum variant spells now inserts,
802    /// and that the three indexes the `DROP` took were re-created.
803    #[tokio::test]
804    async fn the_audit_log_rebuild_keeps_every_row_and_relaxes_the_event_check() {
805        let pool = SqlitePoolOptions::new()
806            .max_connections(1)
807            .connect_with(
808                SqliteConnectOptions::from_str("sqlite::memory:")
809                    .unwrap()
810                    .foreign_keys(true),
811            )
812            .await
813            .unwrap();
814
815        let mut converted = None;
816        for migration in MIGRATOR.iter() {
817            if migration.version == AUDIT_LOG_ADMIN_ACTIONS {
818                converted = Some(migration);
819                break;
820            }
821            sqlx::raw_sql(migration.sql.clone())
822                .execute(&pool)
823                .await
824                .unwrap();
825        }
826        let converted = converted.expect("the audit-log rebuild is in the embedded set");
827
828        sqlx::raw_sql(
829            "INSERT INTO audit_log \
830             (id, created_at, event, outcome, profile, actor_kind, actor_id, account_id, \
831              order_id, cert_serial, identifiers, client_ip, client_ptr, user_agent, \
832              request_id, reason, detail) VALUES \
833             (41812, 1700, 'certificate_revoked', 'success', 'le', 'admin', 'root', \
834              'acct-1', 'order-1', '0a0b', '[\"a.example\"]', '203.0.113.7', 'host.example', \
835              'certbot', 'req-9', '1', 'by operator');",
836        )
837        .execute(&pool)
838        .await
839        .unwrap();
840
841        sqlx::raw_sql(converted.sql.clone())
842            .execute(&pool)
843            .await
844            .unwrap();
845
846        let row = crate::sql::query(
847            "SELECT id, event, actor_id, account_id, order_id, cert_serial, identifiers, \
848             client_ip, user_agent, reason, detail FROM audit_log;",
849        )
850        .fetch_one(&pool)
851        .await
852        .unwrap();
853        assert_eq!(
854            row.try_get::<i64>("id").unwrap(),
855            41812,
856            "the id has to survive, `audit show <id>` uses it"
857        );
858        assert_eq!(
859            row.try_get::<String>("event").unwrap(),
860            "certificate_revoked"
861        );
862        assert_eq!(
863            row.try_get::<Option<String>>("actor_id")
864                .unwrap()
865                .as_deref(),
866            Some("root")
867        );
868        assert_eq!(
869            row.try_get::<Option<String>>("account_id")
870                .unwrap()
871                .as_deref(),
872            Some("acct-1")
873        );
874        assert_eq!(
875            row.try_get::<Option<String>>("order_id")
876                .unwrap()
877                .as_deref(),
878            Some("order-1")
879        );
880        assert_eq!(
881            row.try_get::<String>("identifiers").unwrap(),
882            "[\"a.example\"]"
883        );
884        assert_eq!(
885            row.try_get::<Option<String>>("cert_serial")
886                .unwrap()
887                .as_deref(),
888            Some("0a0b")
889        );
890        assert_eq!(
891            row.try_get::<Option<String>>("client_ip")
892                .unwrap()
893                .as_deref(),
894            Some("203.0.113.7")
895        );
896        assert_eq!(
897            row.try_get::<Option<String>>("detail").unwrap().as_deref(),
898            Some("by operator")
899        );
900
901        // `account_id` / `order_id` stay text — no FK, they name a row that may
902        // be gone (`every_id_column_is_declared_a_blob` also asserts this).
903        assert_eq!(
904            declared_type_on(&pool, "audit_log", "account_id").await,
905            "VARCHAR(36)"
906        );
907
908        // The dropped `event` CHECK: a value no `AuditEvent` variant spells now
909        // inserts. `outcome` and `actor_kind` keep theirs.
910        sqlx::raw_sql(
911            "INSERT INTO audit_log (created_at, event, outcome, profile, actor_kind) \
912             VALUES (1701, 'operator_teleported', 'success', '', 'admin');",
913        )
914        .execute(&pool)
915        .await
916        .unwrap();
917        let bad_actor = sqlx::raw_sql(
918            "INSERT INTO audit_log (created_at, event, outcome, profile, actor_kind) \
919             VALUES (1702, 'account_deleted', 'success', '', 'robot');",
920        )
921        .execute(&pool)
922        .await;
923        assert!(
924            bad_actor.is_err(),
925            "the actor_kind CHECK still guards the column"
926        );
927
928        // The three indexes the DROP took, re-created by the migration.
929        let indexes: Vec<String> =
930            sqlx::query_scalar("SELECT name FROM sqlite_master WHERE type = 'index';")
931                .fetch_all(&pool)
932                .await
933                .unwrap();
934        for expected in [
935            "idx_audit_log_created_at",
936            "idx_audit_log_account_id",
937            "idx_audit_log_cert_serial",
938        ] {
939            assert!(
940                indexes.iter().any(|name| name == expected),
941                "{expected} was not re-created"
942            );
943        }
944    }
945
946    /// [`declared_type`] against a bare pool rather than a [`Database`].
947    async fn declared_type_on(pool: &sqlx::SqlitePool, table: &str, column: &str) -> String {
948        let columns: Vec<(String, String)> =
949            sqlx::query_as("SELECT name, type FROM pragma_table_info(?);")
950                .bind(table)
951                .fetch_all(pool)
952                .await
953                .unwrap();
954        columns
955            .into_iter()
956            .find(|(name, _)| name == column)
957            .map(|(_, declared)| declared)
958            .unwrap_or_else(|| panic!("no column {table}.{column}"))
959    }
960
961    /// One row per table, each carrying a UUID v4 in every id column — the
962    /// shape a database written before `crate::id` existed holds.
963    const SEED_V4_ROWS: &str = "\
964INSERT INTO accounts (id, profile, pubkey, contact, status, created_at, eab_kid) VALUES
965  ('11111111-1111-4111-8111-111111111111', 'default', X'AA', '[]', 'valid', 100,
966   '99999999-9999-4999-8999-999999999999');
967INSERT INTO eab_keys (kid, secret, label, profile, status, created_at) VALUES
968  ('99999999-9999-4999-8999-999999999999', X'CC', 'lab', NULL, 'active', 99);
969INSERT INTO orders (id, profile, account_id, status, identifiers, expires, created_at, replaces)
970VALUES
971  ('33333333-3333-4333-8333-333333333333', 'default',
972   '11111111-1111-4111-8111-111111111111', 'pending', '[]', 200, 102, 'aaa.bbb');
973INSERT INTO authorizations (id, order_id, identifier, status, expires, created_at) VALUES
974  ('44444444-4444-4444-8444-444444444444', '33333333-3333-4333-8333-333333333333',
975   '{\"type\":\"dns\",\"value\":\"a.example\"}', 'pending', 200, 103);
976INSERT INTO challenges (id, authz_id, type, token, status, created_at, error) VALUES
977  ('55555555-5555-4555-8555-555555555555', '44444444-4444-4444-8444-444444444444',
978   'http-01', 'tok', 'pending', 104, '{\"e\":1}');
979INSERT INTO upstream_orders (order_id, upstream_order_url, csr_der, status, created_at,
980                             updated_at, request_id) VALUES
981  ('33333333-3333-4333-8333-333333333333', 'https://up/o', X'DD', 'processing', 105, 105,
982   'req-abc');
983INSERT INTO admin_users (id, username, password_hash, status, created_at, updated_at) VALUES
984  ('66666666-6666-4666-8666-666666666666', 'root', 'h', 'active', 106, 106);
985INSERT INTO admin_sessions (token_hash, user_id, csrf_token, state, created_at, expires_at,
986                            last_seen_at) VALUES
987  ('deadbeef', '66666666-6666-4666-8666-666666666666', 'csrf', 'active', 107, 999, 107);
988INSERT INTO admin_recovery_codes (id, user_id, code_hash, created_at) VALUES
989  ('77777777-7777-4777-8777-777777777777', '66666666-6666-4666-8666-666666666666', 'ch', 108);
990INSERT INTO jobs (id, kind, dedup_key, payload, status, run_at, max_attempts, created_at,
991                  updated_at, lease_owner) VALUES
992  ('88888888-8888-4888-8888-888888888888', 'k', 'dk', '{}', 'ready', 109, 5, 109, 109,
993   'runner-1');
994INSERT INTO audit_log (created_at, event, outcome, profile, actor_kind, account_id, order_id)
995VALUES
996  (110, 'certificate_issued', 'success', 'default', 'acme',
997   '11111111-1111-4111-8111-111111111111', '33333333-3333-4333-8333-333333333333');
998";
999
1000    /// The `pragma_table_info` lookup both declaration guards above run.
1001    async fn declared_type(database: &Database, table: &str, column: &str) -> String {
1002        let columns: Vec<(String, String)> =
1003            sqlx::query_as("SELECT name, type FROM pragma_table_info(?);")
1004                .bind(table)
1005                .fetch_all(database.raw_pool())
1006                .await
1007                .unwrap();
1008
1009        columns
1010            .into_iter()
1011            .find(|(name, _)| name == column)
1012            .map(|(_, declared)| declared)
1013            .unwrap_or_else(|| panic!("no column {table}.{column}"))
1014    }
1015
1016    /// RFC 9773 §5's "not already been marked as replaced" holds even when two
1017    /// newOrder requests race: `check_replaces` reads in one transaction and the
1018    /// order is inserted in another, so the database is what actually decides.
1019    ///
1020    /// The partial predicate matters as much as the uniqueness — an order that
1021    /// falls to `invalid` has to free its predecessor, or a failed replacement
1022    /// would block every retry for good.
1023    #[tokio::test]
1024    async fn one_predecessor_can_only_be_claimed_by_one_live_order() {
1025        let database = Database::connect_in_memory().await.unwrap();
1026        let cert_id = "aYhba4dGQEHhs3uEe6CuLN4ByNQ.AIdlQyE";
1027
1028        crate::sql::query(
1029            "INSERT INTO accounts (id, profile, pubkey, contact, status, created_at) \
1030             VALUES ('acct', 'default', X'00', '[]', 'valid', 0);",
1031        )
1032        .execute(database.raw_pool())
1033        .await
1034        .unwrap();
1035
1036        let insert = |id: &'static str, status: &'static str| {
1037            let pool = database.raw_pool().clone();
1038            async move {
1039                crate::sql::query(
1040                    "INSERT INTO orders (id, profile, account_id, status, identifiers, expires, \
1041                     replaces, created_at) VALUES (?, 'default', 'acct', ?, '[]', 0, ?, 0);",
1042                )
1043                .bind(id)
1044                .bind(status)
1045                .bind(cert_id)
1046                .execute(&pool)
1047                .await
1048            }
1049        };
1050
1051        insert("first", "pending").await.unwrap();
1052
1053        // A second live claim on the same predecessor is refused, and the error
1054        // names the offending column — which is what `is_replaces_conflict`
1055        // matches on to tell this apart from the authorization and challenge
1056        // constraints inserted in the same transaction. SQLite reports the
1057        // columns of a partial unique index, never the index's own name, so
1058        // this assertion is what keeps that matcher honest.
1059        let error = insert("second", "pending").await.unwrap_err();
1060        match &error {
1061            sqlx::Error::Database(db) => {
1062                assert!(db.is_unique_violation(), "got {error}");
1063                assert!(
1064                    db.message().contains("orders.replaces"),
1065                    "the violation must name the column, got {:?}",
1066                    db.message()
1067                );
1068            }
1069            other => panic!("expected a database error, got {other}"),
1070        }
1071
1072        // An `invalid` order is outside the index, so a retry after a failed
1073        // replacement is accepted.
1074        insert("third", "invalid").await.unwrap();
1075
1076        // And once the first claim goes invalid, the predecessor is free again.
1077        crate::sql::query("UPDATE orders SET status = 'invalid' WHERE id = 'first';")
1078            .execute(database.raw_pool())
1079            .await
1080            .unwrap();
1081        insert("fourth", "pending").await.unwrap();
1082    }
1083
1084    /// The status columns are pinned to their state machines, so a typo in one
1085    /// of the raw-string transitions scattered across the models fails loudly
1086    /// rather than parking a row in an unreachable state.
1087    #[tokio::test]
1088    async fn status_columns_reject_values_outside_the_state_machine() {
1089        let database = Database::connect_in_memory().await.unwrap();
1090
1091        let result = crate::sql::query(
1092            "INSERT INTO accounts (id, profile, pubkey, contact, status, created_at) \
1093             VALUES ('a', 'default', X'00', '[]', 'definitely-not-a-status', 0);",
1094        )
1095        .execute(database.raw_pool())
1096        .await;
1097        assert!(result.is_err(), "an unknown account status must be refused");
1098
1099        // And a legitimate one is accepted.
1100        crate::sql::query(
1101            "INSERT INTO accounts (id, profile, pubkey, contact, status, created_at) \
1102             VALUES ('a', 'default', X'00', '[]', 'valid', 0);",
1103        )
1104        .execute(database.raw_pool())
1105        .await
1106        .unwrap();
1107    }
1108
1109    /// Deleting a parent takes its children with it. Before this the constraints
1110    /// had no referential action at all, so an account could never be deleted —
1111    /// which blocked any retention work.
1112    #[tokio::test]
1113    async fn deleting_an_account_cascades_to_its_orders() {
1114        let database = Database::connect_in_memory().await.unwrap();
1115
1116        crate::sql::query(
1117            "INSERT INTO accounts (id, profile, pubkey, contact, status, created_at) \
1118             VALUES ('acct', 'default', X'00', '[]', 'valid', 0);",
1119        )
1120        .execute(database.raw_pool())
1121        .await
1122        .unwrap();
1123        crate::sql::query(
1124            "INSERT INTO orders (id, profile, account_id, status, identifiers, expires, created_at) \
1125             VALUES ('ord', 'default', 'acct', 'pending', '[]', 0, 0);",
1126        )
1127        .execute(database.raw_pool())
1128        .await
1129        .unwrap();
1130        crate::sql::query(
1131            "INSERT INTO authorizations (id, order_id, identifier, status, expires, created_at) \
1132             VALUES ('az', 'ord', '{}', 'pending', 0, 0);",
1133        )
1134        .execute(database.raw_pool())
1135        .await
1136        .unwrap();
1137        crate::sql::query(
1138            "INSERT INTO challenges (id, authz_id, type, token, status, created_at) \
1139             VALUES ('ch', 'az', 'http-01', 't', 'pending', 0);",
1140        )
1141        .execute(database.raw_pool())
1142        .await
1143        .unwrap();
1144
1145        crate::sql::query("DELETE FROM accounts WHERE id = 'acct';")
1146            .execute(database.raw_pool())
1147            .await
1148            .unwrap();
1149
1150        for (table, query) in [
1151            ("orders", "SELECT COUNT(*) FROM orders;"),
1152            ("authorizations", "SELECT COUNT(*) FROM authorizations;"),
1153            ("challenges", "SELECT COUNT(*) FROM challenges;"),
1154        ] {
1155            let count: i64 = sqlx::query_scalar(query)
1156                .fetch_one(database.raw_pool())
1157                .await
1158                .unwrap();
1159            assert_eq!(count, 0, "{table} should have been cascaded away");
1160        }
1161    }
1162
1163    /// An order cannot carry two authorizations for the same identifier.
1164    #[tokio::test]
1165    async fn an_order_cannot_have_duplicate_authorizations_for_one_identifier() {
1166        let database = Database::connect_in_memory().await.unwrap();
1167        crate::sql::query(
1168            "INSERT INTO accounts (id, profile, pubkey, contact, status, created_at) \
1169             VALUES ('acct', 'default', X'00', '[]', 'valid', 0);",
1170        )
1171        .execute(database.raw_pool())
1172        .await
1173        .unwrap();
1174        crate::sql::query(
1175            "INSERT INTO orders (id, profile, account_id, status, identifiers, expires, created_at) \
1176             VALUES ('ord', 'default', 'acct', 'pending', '[]', 0, 0);",
1177        )
1178        .execute(database.raw_pool())
1179        .await
1180        .unwrap();
1181
1182        let insert = |id: &'static str| {
1183            crate::sql::query(
1184                "INSERT INTO authorizations (id, order_id, identifier, status, expires, created_at) \
1185                 VALUES (?, 'ord', '{\"type\":\"dns\",\"value\":\"example.com\"}', 'pending', 0, 0);",
1186            )
1187            .bind(id)
1188            .execute(database.raw_pool())
1189        };
1190
1191        insert("az1").await.unwrap();
1192        assert!(
1193            insert("az2").await.is_err(),
1194            "a second authorization for the same identifier must be refused"
1195        );
1196    }
1197}