Skip to main content

acme_proxy/sqlite/
db.rs

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!(); // defaults to "./migrations"
10
11pub struct Database {
12    pub pool: Pool<Sqlite>,
13}
14
15impl Database {
16    /// Connects to the `SQLite` database at `url`, creating the file if it does
17    /// not exist yet, then runs the embedded migrations.
18    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            // The schema's `ON DELETE CASCADE` rules only bite when foreign keys
27            // are enforced. sqlx enables them by default, but the schema depends
28            // on it, so state it here rather than inherit it.
29            .foreign_keys(true)
30            // Every response writes a nonce row. Under the default rollback
31            // journal a write takes an exclusive lock on the whole database, so
32            // the pool serializes; WAL lets readers continue during a write.
33            .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    /// Builds a throwaway in-memory database with migrations applied. Pinned to
44    /// a single connection so the whole test shares one in-memory database
45    /// (each `SQLite` connection otherwise gets its own).
46    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        // Startup-only, and the caller exits on error — but a `Result`-returning
61        // function should not decide that on its own by panicking.
62        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        // A unique temp path so the "database does not exist → create it" branch
78        // runs (the in-memory helper never exercises it).
79        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        // Migrations applied: the `nonces` table exists and is queryable.
86        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        // WAL and foreign-key enforcement are on: the schema's CASCADE rules
93        // depend on the latter.
94        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        // WAL leaves sidecar files behind.
107        for suffix in ["", "-wal", "-shm"] {
108            let _ = std::fs::remove_file(format!("{}{suffix}", file.display()));
109        }
110    }
111
112    /// Every foreign key is indexed. Without these, each child lookup is a full
113    /// table scan — `Authorization::find_by_order` runs on every order read.
114    #[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    /// The declared width of every column holding a [`random_token`] value must
139    /// match what that function actually produces.
140    ///
141    /// `nonces.value` was declared `VARCHAR(36)` — accurate for the UUID v4 it
142    /// held until the nonce moved to the CSPRNG, and false from that moment on.
143    /// It stayed false because SQLite gives the column TEXT affinity and
144    /// enforces no length, so nothing anywhere could notice. This is what
145    /// notices: change `TOKEN_BYTES` and the failure lands here, beside the
146    /// migration that has to be written.
147    #[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    /// The declared type of every column holding a row id.
162    ///
163    /// The [`random_token`] twin above, for the other family of values the
164    /// schema declares a type for. Ids are the 16 bytes of a UUID
165    /// ([`crate::sqlite::id`]), stored as a BLOB rather than as the 36
166    /// characters of its rendering, and this is what notices a column that went
167    /// back to text — or a new table added with a `VARCHAR(36)` id out of
168    /// habit.
169    ///
170    /// It matters for the reason the widths do: SQLite gives a declared type an
171    /// affinity and enforces nothing, where the PostgreSQL set these
172    /// declarations will be transcribed into (`TODO.md`) has a native `uuid`
173    /// and does enforce it. `nonces.value` is what a stale declaration looks
174    /// like once nothing can notice it.
175    #[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        // Asserted by name so neither reads as an oversight later. `audit_log`
212        // has no foreign keys on purpose — its rows outlive their subjects, so
213        // these two name a row that may be gone rather than pointing at one,
214        // and they sit beside `actor_id` and `request_id`, which are free-form.
215        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    /// Version of `20260827120000_uuid_ids_as_blobs.sql`, the migration that
225    /// converted every id column from its 36-character rendering to the 16
226    /// bytes behind it.
227    const BLOB_IDS: i64 = 20_260_827_120_000;
228
229    /// Every row survives the conversion to BLOB ids, with its id intact and
230    /// its foreign keys still resolving.
231    ///
232    /// This is the only thing standing between a mistyped column list and
233    /// silent data loss, and there are two ways to lose a row there. An
234    /// `INSERT ... SELECT` drops any column it does not name, quietly. And
235    /// `DROP TABLE` under `foreign_keys = ON` fires `ON DELETE CASCADE` into
236    /// every child, so a rebuild that drops a parent while a rebuilt child
237    /// already references it empties the child — with no error, and nothing
238    /// else in this suite would notice, since a fresh database has no rows to
239    /// lose.
240    ///
241    /// So the fixture is a database at the migration *before* that one, seeded
242    /// through raw SQL with a v4 id in every column that was about to move,
243    /// including the nullable `accounts.eab_kid` (where an unconvertible value
244    /// would become `NULL` rather than failing a `NOT NULL`) and the columns
245    /// deliberately left as text beside them.
246    #[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        // Every table kept its row, and every id is now the 16 bytes of the v4
278        // it held. `accounts.eab_kid` is the nullable one, and carries a value
279        // here for exactly that reason.
280        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            // `AssertSqlSafe` for `Job::claim_next`'s reason: sqlx refuses a
309            // query string that is not `'static`, and the table name here comes
310            // from the literal list above rather than from any input.
311            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        // The foreign keys resolve across the conversion — a join is what
321        // proves both sides were converted the same way, where two counts
322        // would pass even if they had not been.
323        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        // The columns that deliberately did not move still hold their text.
341        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        // And the CASCADE the staging detour exists to protect is still wired:
356        // it must survive the rebuild, not merely be absent during it.
357        sqlx::raw_sql("DELETE FROM accounts;")
358            .execute(&pool)
359            .await
360            .unwrap();
361        for table in ["orders", "authorizations", "challenges"] {
362            // `AssertSqlSafe` for `Job::claim_next`'s reason: sqlx refuses a
363            // query string that is not `'static`, and the table name here comes
364            // from the literal list above rather than from any input.
365            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    /// One row per table, each carrying a UUID v4 in every id column — the
376    /// shape a database written before `sqlite::id` existed holds.
377    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    /// The `pragma_table_info` lookup both declaration guards above run.
415    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    /// RFC 9773 §5's "not already been marked as replaced" holds even when two
431    /// newOrder requests race: `check_replaces` reads in one transaction and the
432    /// order is inserted in another, so the database is what actually decides.
433    ///
434    /// The partial predicate matters as much as the uniqueness — an order that
435    /// falls to `invalid` has to free its predecessor, or a failed replacement
436    /// would block every retry for good.
437    #[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        // A second live claim on the same predecessor is refused, and the error
468        // names the offending column — which is what `is_replaces_conflict`
469        // matches on to tell this apart from the authorization and challenge
470        // constraints inserted in the same transaction. SQLite reports the
471        // columns of a partial unique index, never the index's own name, so
472        // this assertion is what keeps that matcher honest.
473        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        // An `invalid` order is outside the index, so a retry after a failed
487        // replacement is accepted.
488        insert("third", "invalid").await.unwrap();
489
490        // And once the first claim goes invalid, the predecessor is free again.
491        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    /// The status columns are pinned to their state machines, so a typo in one
499    /// of the raw-string transitions scattered across the models fails loudly
500    /// rather than parking a row in an unreachable state.
501    #[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        // And a legitimate one is accepted.
514        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    /// Deleting a parent takes its children with it. Before this the constraints
524    /// had no referential action at all, so an account could never be deleted —
525    /// which blocked any retention work.
526    #[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    /// An order cannot carry two authorizations for the same identifier.
578    #[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}