use std::time::Duration;
use chrono::Utc;
use sha2::{Digest, Sha256};
use tokio::sync::{Mutex, MutexGuard};
use super::dialect::{ResetPool, sql, stored_time};
use super::store::PasswordResets;
use super::token::{ID_BYTES, SECRET_BYTES, format_plaintext, parse_plaintext};
static TABLE: Mutex<()> = Mutex::const_new(());
const CONNECTIONS: u32 = 4;
const HOUR: Duration = Duration::from_secs(60 * 60);
#[cfg(feature = "db-postgres")]
const SET_EXPIRY: &str = "UPDATE arcature_password_resets SET expires_at = $1 WHERE id = $2";
#[cfg(not(feature = "db-postgres"))]
const SET_EXPIRY: &str = "UPDATE arcature_password_resets SET expires_at = ? WHERE id = ?";
struct Fixture {
store: PasswordResets,
_exclusive: MutexGuard<'static, ()>,
}
impl Fixture {
fn store(&self) -> &PasswordResets {
&self.store
}
fn pool(&self) -> &ResetPool {
self.store.pool()
}
async fn ids(&self) -> Vec<Vec<u8>> {
sqlx::query_scalar::<_, Vec<u8>>("SELECT id FROM arcature_password_resets")
.fetch_all(self.pool())
.await
.expect("read arcature_password_resets")
}
async fn rows(&self) -> usize {
self.ids().await.len()
}
async fn set_expiry(&self, plaintext: &str, at: chrono::DateTime<Utc>) {
let (id, _) = parse_plaintext(plaintext).expect("a link this crate minted");
let affected = sqlx::query(SET_EXPIRY)
.bind(stored_time(at))
.bind(id.as_bytes().to_vec())
.execute(self.pool())
.await
.expect("move the deadline")
.rows_affected();
assert_eq!(affected, 1, "the link to re-date was not there");
}
async fn insert_extra(&self, id: [u8; 16], subject: &str) -> u64 {
let expires_at = Utc::now() + chrono::TimeDelta::seconds(3600);
sqlx::query(sql::INSERT_NEW)
.bind(id.to_vec())
.bind(vec![0u8; 32])
.bind(subject)
.bind(stored_time(expires_at))
.bind(stored_time(Utc::now()))
.execute(self.pool())
.await
.expect("insert a second link by hand")
.rows_affected()
}
}
async fn resets() -> Option<Fixture> {
use crate::test_kit::database::{
REQUIRE_TEST_DB_VAR, TEST_DB_URL_VAR, TestDatabaseError, test_database_required,
test_database_url,
};
let url = match test_database_url() {
Ok(url) => url,
Err(TestDatabaseError::NotConfigured) => {
assert!(
!test_database_required(),
"{REQUIRE_TEST_DB_VAR} is set, so {TEST_DB_URL_VAR} has to be too"
);
return None;
}
Err(error) => panic!("{error}"),
};
let exclusive = TABLE.lock().await;
let pool = sqlx::pool::PoolOptions::<crate::database::Driver>::new()
.max_connections(CONNECTIONS)
.acquire_timeout(Duration::from_secs(30))
.connect(&url)
.await
.unwrap_or_else(|error| panic!("connect to the test database: {error}"));
let store = PasswordResets::new(pool);
store
.migrate()
.await
.unwrap_or_else(|error| panic!("migrate arcature_password_resets: {error}"));
sqlx::query("DELETE FROM arcature_password_resets")
.execute(store.pool())
.await
.unwrap_or_else(|error| panic!("empty arcature_password_resets: {error}"));
Some(Fixture {
store,
_exclusive: exclusive,
})
}
macro_rules! with_resets {
(|$fixture:ident| $body:block) => {
let Some($fixture) = resets().await else {
return;
};
$body
};
}
fn with_foreign_secret(id_from: &str, secret_from: &str) -> String {
let (id_half, _) = id_from.split_once('.').expect("a link this crate minted");
let (_, secret_half) = secret_from
.split_once('.')
.expect("a link this crate minted");
format!("{id_half}.{secret_half}")
}
#[tokio::test]
async fn a_freshly_issued_link_redeems_once_and_then_never_again() {
with_resets!(|fixture| {
let issued = fixture
.store()
.issue("ada@example.test", HOUR)
.await
.expect("issue");
assert_eq!(issued.subject(), "ada@example.test");
let link = issued.plaintext().expose().to_owned();
assert_eq!(fixture.rows().await, 1);
let subject = fixture
.store()
.consume(&link)
.await
.expect("consume")
.expect("a link just issued redeems");
assert_eq!(subject, "ada@example.test");
assert_eq!(fixture.rows().await, 0, "a spent link leaves no row");
assert!(
fixture
.store()
.consume(&link)
.await
.expect("consume")
.is_none(),
"a spent link must be refused, not merely different"
);
});
}
#[tokio::test]
async fn issuing_again_clears_the_link_the_subject_already_had() {
with_resets!(|fixture| {
let first = fixture
.store()
.issue("ada@example.test", HOUR)
.await
.expect("issue")
.plaintext()
.expose()
.to_owned();
let second = fixture
.store()
.issue("ada@example.test", HOUR)
.await
.expect("issue")
.plaintext()
.expose()
.to_owned();
assert_ne!(first, second);
assert_eq!(fixture.rows().await, 1, "one subject, one live link");
assert!(
fixture
.store()
.consume(&first)
.await
.expect("consume")
.is_none(),
"the superseded link must be dead"
);
assert_eq!(
fixture
.store()
.consume(&second)
.await
.expect("consume")
.as_deref(),
Some("ada@example.test")
);
});
}
#[tokio::test]
async fn a_lapsed_link_is_refused_before_any_sweep_runs() {
with_resets!(|fixture| {
let link = fixture
.store()
.issue("ada@example.test", HOUR)
.await
.expect("issue")
.plaintext()
.expose()
.to_owned();
fixture
.set_expiry(&link, Utc::now() - chrono::TimeDelta::seconds(1))
.await;
assert!(
fixture
.store()
.consume(&link)
.await
.expect("consume")
.is_none(),
"a lapsed link must not redeem"
);
assert_eq!(fixture.rows().await, 1);
});
}
#[tokio::test]
async fn a_wrong_secret_is_refused_and_does_not_spend_the_link() {
with_resets!(|fixture| {
let real = fixture
.store()
.issue("ada@example.test", HOUR)
.await
.expect("issue")
.plaintext()
.expose()
.to_owned();
let other = fixture
.store()
.issue("grace@example.test", HOUR)
.await
.expect("issue")
.plaintext()
.expose()
.to_owned();
let forged = with_foreign_secret(&real, &other);
assert_ne!(forged, real, "the splice did not change anything");
assert!(
fixture
.store()
.consume(&forged)
.await
.expect("consume")
.is_none(),
"a wrong secret must not redeem"
);
assert_eq!(fixture.rows().await, 2, "a wrong guess deleted a row");
assert_eq!(
fixture
.store()
.consume(&real)
.await
.expect("consume")
.as_deref(),
Some("ada@example.test")
);
});
}
#[tokio::test]
async fn an_id_nobody_ever_issued_reaches_no_row() {
with_resets!(|fixture| {
let real = fixture
.store()
.issue("ada@example.test", HOUR)
.await
.expect("issue")
.plaintext()
.expose()
.to_owned();
let unknown = format_plaintext(&[0u8; ID_BYTES], &[0u8; SECRET_BYTES]);
assert!(
parse_plaintext(&unknown).is_some(),
"the fixture link has to be well formed, or nothing is queried"
);
assert!(
fixture
.store()
.consume(&unknown)
.await
.expect("consume")
.is_none(),
"an id with no row must be refused, not error"
);
assert_eq!(fixture.rows().await, 1);
assert_eq!(
fixture
.store()
.consume(&real)
.await
.expect("consume")
.as_deref(),
Some("ada@example.test")
);
});
}
#[tokio::test]
async fn revoking_clears_every_link_the_subject_has_and_nobody_elses() {
with_resets!(|fixture| {
fixture
.store()
.issue("ada@example.test", HOUR)
.await
.expect("issue");
assert_eq!(fixture.insert_extra([1u8; 16], "ada@example.test").await, 1);
assert_eq!(fixture.insert_extra([2u8; 16], "ada@example.test").await, 1);
let grace = fixture
.store()
.issue("grace@example.test", HOUR)
.await
.expect("issue")
.plaintext()
.expose()
.to_owned();
assert_eq!(fixture.rows().await, 4);
let revoked = fixture
.store()
.revoke_all_for("ada@example.test")
.await
.expect("revoke all");
assert_eq!(revoked, 3);
assert_eq!(fixture.rows().await, 1);
assert_eq!(
fixture
.store()
.consume(&grace)
.await
.expect("consume")
.as_deref(),
Some("grace@example.test"),
"the bystander's link should be untouched"
);
});
}
#[tokio::test]
async fn the_sweep_reclaims_lapsed_links_and_leaves_live_ones() {
with_resets!(|fixture| {
let live = fixture
.store()
.issue("ada@example.test", HOUR)
.await
.expect("issue")
.plaintext()
.expose()
.to_owned();
let dead = fixture
.store()
.issue("grace@example.test", HOUR)
.await
.expect("issue")
.plaintext()
.expose()
.to_owned();
fixture
.set_expiry(&dead, Utc::now() - chrono::TimeDelta::seconds(1))
.await;
let swept = fixture.store().sweep_expired().await.expect("sweep");
assert_eq!(swept, 1);
assert_eq!(fixture.rows().await, 1);
assert_eq!(
fixture
.store()
.consume(&live)
.await
.expect("consume")
.as_deref(),
Some("ada@example.test")
);
});
}
#[tokio::test]
async fn two_requests_carrying_one_link_spend_it_exactly_once() {
with_resets!(|fixture| {
let link = fixture
.store()
.issue("ada@example.test", HOUR)
.await
.expect("issue")
.plaintext()
.expose()
.to_owned();
let (left, right) = tokio::join!(
fixture.store().consume(&link),
fixture.store().consume(&link),
);
let left = left.expect("consume");
let right = right.expect("consume");
let redeemed = usize::from(left.is_some()) + usize::from(right.is_some());
assert_eq!(
redeemed, 1,
"exactly one of two concurrent redemptions may spend the link"
);
for outcome in [left, right].into_iter().flatten() {
assert_eq!(outcome, "ada@example.test");
}
assert_eq!(fixture.rows().await, 0);
});
}
#[tokio::test]
async fn an_id_that_is_already_taken_is_reported_as_zero_rows_rather_than_an_error() {
with_resets!(|fixture| {
assert_eq!(fixture.insert_extra([7u8; 16], "ada@example.test").await, 1);
assert_eq!(
fixture.insert_extra([7u8; 16], "grace@example.test").await,
0,
"a taken id must be reported as zero rows, not raised as an error"
);
assert_eq!(fixture.rows().await, 1);
assert_eq!(
fixture
.store()
.revoke_all_for("ada@example.test")
.await
.expect("revoke all"),
1
);
});
}
#[tokio::test]
async fn the_database_never_holds_the_link() {
with_resets!(|fixture| {
let link = fixture
.store()
.issue("ada@example.test", HOUR)
.await
.expect("issue")
.plaintext()
.expose()
.to_owned();
let rows = sqlx::query_as::<_, (Vec<u8>, Vec<u8>, String)>(
"SELECT id, secret_digest, subject FROM arcature_password_resets",
)
.fetch_all(fixture.pool())
.await
.expect("read the row");
assert_eq!(rows.len(), 1);
let (id_column, digest_column, subject_column) = &rows[0];
let (id, secret) = parse_plaintext(&link).expect("a link this crate minted");
assert_eq!(id_column.as_slice(), id.as_bytes(), "the id is the key");
assert_eq!(subject_column, "ada@example.test");
assert_eq!(digest_column.len(), 32);
assert_ne!(digest_column.as_slice(), &secret[..]);
let expected: [u8; 32] = Sha256::digest(secret).into();
assert_eq!(digest_column.as_slice(), &expected[..]);
});
}
#[tokio::test]
async fn migrating_twice_is_a_no_op() {
with_resets!(|fixture| {
fixture.store().migrate().await.expect("migrate again");
let link = fixture
.store()
.issue("ada@example.test", HOUR)
.await
.expect("issue")
.plaintext()
.expose()
.to_owned();
assert_eq!(fixture.rows().await, 1);
assert_eq!(
fixture
.store()
.consume(&link)
.await
.expect("consume")
.as_deref(),
Some("ada@example.test")
);
});
}