use sqlx::{Executor, Row};
use super::dialect::{RememberDb, RememberPool, sql};
use super::error::RememberTokenError;
type Conn = <RememberDb as sqlx::Database>::Connection;
const STATEMENT_SEPARATOR: &str = "--;;";
const MIGRATIONS: &[(&str, &str)] = &[("0001_remember_tokens", sql::SCHEMA)];
pub(super) async fn apply(pool: &RememberPool) -> Result<(), RememberTokenError> {
let mut conn = pool.acquire().await?;
apply_on(&mut conn).await
}
async fn apply_on(conn: &mut Conn) -> Result<(), RememberTokenError> {
conn.execute(sql::CREATE_HISTORY).await?;
let Some(lock) = sql::LOCK else {
return apply_pending(conn).await;
};
sqlx::query(lock).execute(&mut *conn).await?;
let result = apply_pending(&mut *conn).await;
if let Some(unlock) = sql::UNLOCK {
let _ = sqlx::query(unlock).execute(&mut *conn).await;
}
result
}
async fn apply_pending(conn: &mut Conn) -> Result<(), RememberTokenError> {
for &(version, migration) in MIGRATIONS {
let applied: i64 = sqlx::query(sql::COUNT_APPLIED)
.bind(version)
.fetch_one(&mut *conn)
.await?
.try_get::<i64, _>(0)?;
if applied > 0 {
continue;
}
for statement in statements(migration) {
conn.execute(statement).await?;
}
sqlx::query(sql::RECORD_APPLIED)
.bind(version)
.execute(&mut *conn)
.await?;
}
Ok(())
}
fn statements(migration: &str) -> impl Iterator<Item = &str> {
let mut pieces = Vec::new();
let mut start = 0;
let mut offset = 0;
for line in migration.split_inclusive('\n') {
if line.trim() == STATEMENT_SEPARATOR {
pieces.push(&migration[start..offset]);
start = offset + line.len();
}
offset += line.len();
}
pieces.push(&migration[start..]);
pieces
.into_iter()
.map(str::trim)
.filter(|statement| !statement.is_empty())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_bundled_migration_splits_into_at_least_one_statement() {
for &(version, migration) in MIGRATIONS {
let count = statements(migration).count();
assert!(count > 0, "{version} produced no statements");
}
}
#[test]
fn a_comment_that_mentions_the_separator_does_not_split_the_file() {
let sql = "-- Statements are separated by a line reading `--;;`.\n\
CREATE TABLE a (i INT)";
let split: Vec<_> = statements(sql).collect();
assert_eq!(split.len(), 1, "split into {split:?}");
assert!(split[0].starts_with("-- Statements"));
}
#[test]
fn the_bundled_migration_creates_the_remember_tokens_table() {
for &(version, migration) in MIGRATIONS {
assert!(
statements(migration).any(|statement| statement
.contains("CREATE TABLE IF NOT EXISTS arcature_remember_tokens")),
"{version} does not create arcature_remember_tokens"
);
}
}
#[test]
fn the_bundled_migration_never_stores_a_plaintext_secret() {
for &(version, migration) in MIGRATIONS {
assert!(
migration.contains("secret_digest"),
"{version} has no secret_digest column"
);
assert!(
!migration.contains("secret_plaintext") && !migration.contains("token TEXT"),
"{version} appears to store a token in the clear"
);
}
}
#[test]
fn the_expiry_column_has_no_null_state() {
for &(version, migration) in MIGRATIONS {
let expiry_line = migration
.lines()
.find(|line| line.trim_start().starts_with("expires_at"))
.unwrap_or_else(|| panic!("{version} declares no expires_at column"));
assert!(
expiry_line.contains("NOT NULL"),
"{version} allows a null expiry: {expiry_line}"
);
}
}
#[test]
fn the_previous_digest_is_nullable_because_a_fresh_token_has_none() {
for &(version, migration) in MIGRATIONS {
let line = migration
.lines()
.find(|line| line.trim_start().starts_with("previous_digest"))
.unwrap_or_else(|| panic!("{version} declares no previous_digest column"));
assert!(
!line.contains("NOT NULL"),
"{version} forbids a null previous digest: {line}"
);
}
}
#[test]
fn the_subject_is_indexed_so_the_theft_cascade_is_not_a_scan() {
for &(version, migration) in MIGRATIONS {
assert!(
migration.contains("arcature_remember_tokens_subject_idx"),
"{version} does not index subject"
);
}
}
}