1use std::str::FromStr;
2use std::time::Duration;
3
4use sqlx::migrate::Migrator;
5use sqlx::sqlite::{SqliteConnectOptions, SqliteJournalMode, SqlitePoolOptions};
6use sqlx::{Error, Pool, Sqlite, SqlitePool, migrate::MigrateDatabase};
7use tracing::{error, info};
8
9static MIGRATOR: Migrator = sqlx::migrate!(); pub struct Database {
12 pub pool: Pool<Sqlite>,
13}
14
15impl Database {
16 pub async fn connect(url: &str) -> Result<Database, Error> {
19 if !Sqlite::database_exists(url).await.unwrap_or(false) {
20 info!(event = "db_creation_started", outcome = "progress", database_url = %url);
21 Sqlite::create_database(url).await?;
22 info!(event = "db_creation_completed", outcome = "success", database_url = %url);
23 }
24
25 let options = SqliteConnectOptions::from_str(url)?
26 .foreign_keys(true)
30 .journal_mode(SqliteJournalMode::Wal)
34 .busy_timeout(Duration::from_secs(5));
35
36 let pool = SqlitePool::connect_with(options).await?;
37
38 run_migrations(&pool).await?;
39
40 Ok(Database { pool })
41 }
42
43 pub async fn connect_in_memory() -> Result<Database, Error> {
47 let pool = SqlitePoolOptions::new()
48 .max_connections(1)
49 .connect_with(SqliteConnectOptions::from_str("sqlite::memory:")?.foreign_keys(true))
50 .await?;
51
52 run_migrations(&pool).await?;
53
54 Ok(Database { pool })
55 }
56}
57
58async fn run_migrations(pool: &Pool<Sqlite>) -> Result<(), Error> {
59 MIGRATOR.run(pool).await.map_err(|error| {
60 error!(event = "db_migration_failed", outcome = "failure", error = %error);
63 Error::Migrate(Box::new(error))
64 })?;
65 info!(event = "db_migration_completed", outcome = "success");
66 Ok(())
67}
68
69#[cfg(test)]
70mod tests {
71 use super::*;
72
73 use crate::random::random_token;
74
75 #[tokio::test]
76 async fn connect_creates_file_and_runs_migrations() {
77 let file =
80 std::env::temp_dir().join(format!("acme-proxy-test-{}.db", uuid::Uuid::now_v7()));
81 let url = format!("sqlite://{}", file.display());
82
83 let database = Database::connect(&url).await.unwrap();
84
85 let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM nonces;")
87 .fetch_one(&database.pool)
88 .await
89 .unwrap();
90 assert_eq!(count, 0);
91
92 let journal: String = sqlx::query_scalar("PRAGMA journal_mode;")
95 .fetch_one(&database.pool)
96 .await
97 .unwrap();
98 assert_eq!(journal.to_lowercase(), "wal");
99 let foreign_keys: i64 = sqlx::query_scalar("PRAGMA foreign_keys;")
100 .fetch_one(&database.pool)
101 .await
102 .unwrap();
103 assert_eq!(foreign_keys, 1);
104
105 database.pool.close().await;
106 for suffix in ["", "-wal", "-shm"] {
108 let _ = std::fs::remove_file(format!("{}{suffix}", file.display()));
109 }
110 }
111
112 #[tokio::test]
115 async fn foreign_keys_and_the_nonce_sweep_are_indexed() {
116 let database = Database::connect_in_memory().await.unwrap();
117 let names: Vec<String> =
118 sqlx::query_scalar("SELECT name FROM sqlite_master WHERE type = 'index';")
119 .fetch_all(&database.pool)
120 .await
121 .unwrap();
122
123 for expected in [
124 "idx_orders_account_id",
125 "idx_authorizations_order",
126 "idx_challenges_authz",
127 "idx_nonces_created_at",
128 "idx_orders_cert_serial",
129 "idx_orders_replaces_claim",
130 ] {
131 assert!(
132 names.iter().any(|name| name == expected),
133 "missing index {expected}; have {names:?}"
134 );
135 }
136 }
137
138 #[tokio::test]
148 async fn declared_token_widths_match_random_token() {
149 let database = Database::connect_in_memory().await.unwrap();
150 let expected = format!("VARCHAR({})", random_token().len());
151
152 for (table, column) in [("nonces", "value"), ("challenges", "token")] {
153 assert_eq!(
154 declared_type(&database, table, column).await,
155 expected,
156 "{table}.{column} declares a width the value no longer has"
157 );
158 }
159 }
160
161 #[tokio::test]
176 async fn every_id_column_is_declared_a_blob() {
177 let database = Database::connect_in_memory().await.unwrap();
178
179 let minted = crate::sqlite::id::mint();
180 assert_eq!(
181 minted.get_version_num(),
182 7,
183 "ids are UUID v7 (RFC 9562 §5.7)"
184 );
185 assert_eq!(minted.as_bytes().len(), 16, "which is what a column holds");
186
187 for (table, column) in [
188 ("accounts", "id"),
189 ("accounts", "eab_kid"),
190 ("orders", "id"),
191 ("orders", "account_id"),
192 ("authorizations", "id"),
193 ("authorizations", "order_id"),
194 ("challenges", "id"),
195 ("challenges", "authz_id"),
196 ("eab_keys", "kid"),
197 ("upstream_orders", "order_id"),
198 ("admin_users", "id"),
199 ("admin_sessions", "user_id"),
200 ("admin_recovery_codes", "id"),
201 ("admin_recovery_codes", "user_id"),
202 ("jobs", "id"),
203 ] {
204 assert_eq!(
205 declared_type(&database, table, column).await,
206 "BLOB",
207 "{table}.{column} holds a row id"
208 );
209 }
210
211 for column in ["account_id", "order_id"] {
216 assert_eq!(
217 declared_type(&database, "audit_log", column).await,
218 "VARCHAR(36)",
219 "audit_log.{column} is deliberately still text"
220 );
221 }
222 }
223
224 const BLOB_IDS: i64 = 20_260_827_120_000;
228
229 #[tokio::test]
247 async fn the_blob_migration_preserves_every_row() {
248 let pool = SqlitePoolOptions::new()
249 .max_connections(1)
250 .connect_with(
251 SqliteConnectOptions::from_str("sqlite::memory:")
252 .unwrap()
253 .foreign_keys(true),
254 )
255 .await
256 .unwrap();
257
258 let mut converted = None;
259 for migration in MIGRATOR.iter() {
260 if migration.version == BLOB_IDS {
261 converted = Some(migration);
262 break;
263 }
264 sqlx::raw_sql(migration.sql.clone())
265 .execute(&pool)
266 .await
267 .unwrap();
268 }
269 let converted = converted.expect("the id migration is in the embedded set");
270
271 sqlx::raw_sql(SEED_V4_ROWS).execute(&pool).await.unwrap();
272 sqlx::raw_sql(converted.sql.clone())
273 .execute(&pool)
274 .await
275 .unwrap();
276
277 let account: (Vec<u8>, Option<Vec<u8>>) =
281 sqlx::query_as("SELECT id, eab_kid FROM accounts;")
282 .fetch_one(&pool)
283 .await
284 .unwrap();
285 assert_eq!(
286 uuid::Uuid::from_slice(&account.0).unwrap().to_string(),
287 "11111111-1111-4111-8111-111111111111"
288 );
289 assert_eq!(
290 uuid::Uuid::from_slice(&account.1.expect("eab_kid survived"))
291 .unwrap()
292 .to_string(),
293 "99999999-9999-4999-8999-999999999999"
294 );
295
296 for table in [
297 "orders",
298 "authorizations",
299 "challenges",
300 "upstream_orders",
301 "eab_keys",
302 "admin_users",
303 "admin_sessions",
304 "admin_recovery_codes",
305 "jobs",
306 "audit_log",
307 ] {
308 let rows: i64 = sqlx::query_scalar(sqlx::AssertSqlSafe(format!(
312 "SELECT COUNT(*) FROM {table};"
313 )))
314 .fetch_one(&pool)
315 .await
316 .unwrap();
317 assert_eq!(rows, 1, "{table} lost its row");
318 }
319
320 let joined: i64 = sqlx::query_scalar(
324 "SELECT COUNT(*) FROM challenges c \
325 JOIN authorizations a ON a.id = c.authz_id \
326 JOIN orders o ON o.id = a.order_id \
327 JOIN accounts acct ON acct.id = o.account_id;",
328 )
329 .fetch_one(&pool)
330 .await
331 .unwrap();
332 assert_eq!(joined, 1, "the account → challenge chain no longer joins");
333
334 let violations: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM pragma_foreign_key_check;")
335 .fetch_one(&pool)
336 .await
337 .unwrap();
338 assert_eq!(violations, 0);
339
340 let replaces: String = sqlx::query_scalar("SELECT replaces FROM orders;")
342 .fetch_one(&pool)
343 .await
344 .unwrap();
345 assert_eq!(replaces, "aaa.bbb", "an ARI certID is not one of our ids");
346 let audited: String = sqlx::query_scalar("SELECT account_id FROM audit_log;")
347 .fetch_one(&pool)
348 .await
349 .unwrap();
350 assert_eq!(
351 audited, "11111111-1111-4111-8111-111111111111",
352 "audit_log names a row that may be gone, and stays text"
353 );
354
355 sqlx::raw_sql("DELETE FROM accounts;")
358 .execute(&pool)
359 .await
360 .unwrap();
361 for table in ["orders", "authorizations", "challenges"] {
362 let rows: i64 = sqlx::query_scalar(sqlx::AssertSqlSafe(format!(
366 "SELECT COUNT(*) FROM {table};"
367 )))
368 .fetch_one(&pool)
369 .await
370 .unwrap();
371 assert_eq!(rows, 0, "deleting the account did not cascade into {table}");
372 }
373 }
374
375 const SEED_V4_ROWS: &str = "\
378INSERT INTO accounts (id, profile, pubkey, contact, status, created_at, eab_kid) VALUES
379 ('11111111-1111-4111-8111-111111111111', 'default', X'AA', '[]', 'valid', 100,
380 '99999999-9999-4999-8999-999999999999');
381INSERT INTO eab_keys (kid, secret, label, profile, status, created_at) VALUES
382 ('99999999-9999-4999-8999-999999999999', X'CC', 'lab', NULL, 'active', 99);
383INSERT INTO orders (id, profile, account_id, status, identifiers, expires, created_at, replaces)
384VALUES
385 ('33333333-3333-4333-8333-333333333333', 'default',
386 '11111111-1111-4111-8111-111111111111', 'pending', '[]', 200, 102, 'aaa.bbb');
387INSERT INTO authorizations (id, order_id, identifier, status, expires, created_at) VALUES
388 ('44444444-4444-4444-8444-444444444444', '33333333-3333-4333-8333-333333333333',
389 '{\"type\":\"dns\",\"value\":\"a.example\"}', 'pending', 200, 103);
390INSERT INTO challenges (id, authz_id, type, token, status, created_at, error) VALUES
391 ('55555555-5555-4555-8555-555555555555', '44444444-4444-4444-8444-444444444444',
392 'http-01', 'tok', 'pending', 104, '{\"e\":1}');
393INSERT INTO upstream_orders (order_id, upstream_order_url, csr_der, status, created_at,
394 updated_at, request_id) VALUES
395 ('33333333-3333-4333-8333-333333333333', 'https://up/o', X'DD', 'processing', 105, 105,
396 'req-abc');
397INSERT INTO admin_users (id, username, password_hash, status, created_at, updated_at) VALUES
398 ('66666666-6666-4666-8666-666666666666', 'root', 'h', 'active', 106, 106);
399INSERT INTO admin_sessions (token_hash, user_id, csrf_token, state, created_at, expires_at,
400 last_seen_at) VALUES
401 ('deadbeef', '66666666-6666-4666-8666-666666666666', 'csrf', 'active', 107, 999, 107);
402INSERT INTO admin_recovery_codes (id, user_id, code_hash, created_at) VALUES
403 ('77777777-7777-4777-8777-777777777777', '66666666-6666-4666-8666-666666666666', 'ch', 108);
404INSERT INTO jobs (id, kind, dedup_key, payload, status, run_at, max_attempts, created_at,
405 updated_at, lease_owner) VALUES
406 ('88888888-8888-4888-8888-888888888888', 'k', 'dk', '{}', 'ready', 109, 5, 109, 109,
407 'runner-1');
408INSERT INTO audit_log (created_at, event, outcome, profile, actor_kind, account_id, order_id)
409VALUES
410 (110, 'certificate_issued', 'success', 'default', 'acme',
411 '11111111-1111-4111-8111-111111111111', '33333333-3333-4333-8333-333333333333');
412";
413
414 async fn declared_type(database: &Database, table: &str, column: &str) -> String {
416 let columns: Vec<(String, String)> =
417 sqlx::query_as("SELECT name, type FROM pragma_table_info(?);")
418 .bind(table)
419 .fetch_all(&database.pool)
420 .await
421 .unwrap();
422
423 columns
424 .into_iter()
425 .find(|(name, _)| name == column)
426 .map(|(_, declared)| declared)
427 .unwrap_or_else(|| panic!("no column {table}.{column}"))
428 }
429
430 #[tokio::test]
438 async fn one_predecessor_can_only_be_claimed_by_one_live_order() {
439 let database = Database::connect_in_memory().await.unwrap();
440 let cert_id = "aYhba4dGQEHhs3uEe6CuLN4ByNQ.AIdlQyE";
441
442 sqlx::query(
443 "INSERT INTO accounts (id, profile, pubkey, contact, status, created_at) \
444 VALUES ('acct', 'default', X'00', '[]', 'valid', 0);",
445 )
446 .execute(&database.pool)
447 .await
448 .unwrap();
449
450 let insert = |id: &'static str, status: &'static str| {
451 let pool = database.pool.clone();
452 async move {
453 sqlx::query(
454 "INSERT INTO orders (id, profile, account_id, status, identifiers, expires, \
455 replaces, created_at) VALUES (?, 'default', 'acct', ?, '[]', 0, ?, 0);",
456 )
457 .bind(id)
458 .bind(status)
459 .bind(cert_id)
460 .execute(&pool)
461 .await
462 }
463 };
464
465 insert("first", "pending").await.unwrap();
466
467 let error = insert("second", "pending").await.unwrap_err();
474 match &error {
475 sqlx::Error::Database(db) => {
476 assert!(db.is_unique_violation(), "got {error}");
477 assert!(
478 db.message().contains("orders.replaces"),
479 "the violation must name the column, got {:?}",
480 db.message()
481 );
482 }
483 other => panic!("expected a database error, got {other}"),
484 }
485
486 insert("third", "invalid").await.unwrap();
489
490 sqlx::query("UPDATE orders SET status = 'invalid' WHERE id = 'first';")
492 .execute(&database.pool)
493 .await
494 .unwrap();
495 insert("fourth", "pending").await.unwrap();
496 }
497
498 #[tokio::test]
502 async fn status_columns_reject_values_outside_the_state_machine() {
503 let database = Database::connect_in_memory().await.unwrap();
504
505 let result = sqlx::query(
506 "INSERT INTO accounts (id, profile, pubkey, contact, status, created_at) \
507 VALUES ('a', 'default', X'00', '[]', 'definitely-not-a-status', 0);",
508 )
509 .execute(&database.pool)
510 .await;
511 assert!(result.is_err(), "an unknown account status must be refused");
512
513 sqlx::query(
515 "INSERT INTO accounts (id, profile, pubkey, contact, status, created_at) \
516 VALUES ('a', 'default', X'00', '[]', 'valid', 0);",
517 )
518 .execute(&database.pool)
519 .await
520 .unwrap();
521 }
522
523 #[tokio::test]
527 async fn deleting_an_account_cascades_to_its_orders() {
528 let database = Database::connect_in_memory().await.unwrap();
529
530 sqlx::query(
531 "INSERT INTO accounts (id, profile, pubkey, contact, status, created_at) \
532 VALUES ('acct', 'default', X'00', '[]', 'valid', 0);",
533 )
534 .execute(&database.pool)
535 .await
536 .unwrap();
537 sqlx::query(
538 "INSERT INTO orders (id, profile, account_id, status, identifiers, expires, created_at) \
539 VALUES ('ord', 'default', 'acct', 'pending', '[]', 0, 0);",
540 )
541 .execute(&database.pool)
542 .await
543 .unwrap();
544 sqlx::query(
545 "INSERT INTO authorizations (id, order_id, identifier, status, expires, created_at) \
546 VALUES ('az', 'ord', '{}', 'pending', 0, 0);",
547 )
548 .execute(&database.pool)
549 .await
550 .unwrap();
551 sqlx::query(
552 "INSERT INTO challenges (id, authz_id, type, token, status, created_at) \
553 VALUES ('ch', 'az', 'http-01', 't', 'pending', 0);",
554 )
555 .execute(&database.pool)
556 .await
557 .unwrap();
558
559 sqlx::query("DELETE FROM accounts WHERE id = 'acct';")
560 .execute(&database.pool)
561 .await
562 .unwrap();
563
564 for (table, query) in [
565 ("orders", "SELECT COUNT(*) FROM orders;"),
566 ("authorizations", "SELECT COUNT(*) FROM authorizations;"),
567 ("challenges", "SELECT COUNT(*) FROM challenges;"),
568 ] {
569 let count: i64 = sqlx::query_scalar(query)
570 .fetch_one(&database.pool)
571 .await
572 .unwrap();
573 assert_eq!(count, 0, "{table} should have been cascaded away");
574 }
575 }
576
577 #[tokio::test]
579 async fn an_order_cannot_have_duplicate_authorizations_for_one_identifier() {
580 let database = Database::connect_in_memory().await.unwrap();
581 sqlx::query(
582 "INSERT INTO accounts (id, profile, pubkey, contact, status, created_at) \
583 VALUES ('acct', 'default', X'00', '[]', 'valid', 0);",
584 )
585 .execute(&database.pool)
586 .await
587 .unwrap();
588 sqlx::query(
589 "INSERT INTO orders (id, profile, account_id, status, identifiers, expires, created_at) \
590 VALUES ('ord', 'default', 'acct', 'pending', '[]', 0, 0);",
591 )
592 .execute(&database.pool)
593 .await
594 .unwrap();
595
596 let insert = |id: &'static str| {
597 sqlx::query(
598 "INSERT INTO authorizations (id, order_id, identifier, status, expires, created_at) \
599 VALUES (?, 'ord', '{\"type\":\"dns\",\"value\":\"example.com\"}', 'pending', 0, 0);",
600 )
601 .bind(id)
602 .execute(&database.pool)
603 };
604
605 insert("az1").await.unwrap();
606 assert!(
607 insert("az2").await.is_err(),
608 "a second authorization for the same identifier must be refused"
609 );
610 }
611}