use std::collections::HashMap;
use std::time::Duration as StdDuration;
use time::{Duration, OffsetDateTime};
use tokio::sync::{Mutex, MutexGuard};
use tower_sessions::session::{Id, Record};
use tower_sessions::session_store::{ExpiredDeletion, SessionStore};
use super::DbSessionStore;
use super::dialect::SessionPool;
static TABLE: Mutex<()> = Mutex::const_new(());
const CONNECTIONS: u32 = 4;
struct Fixture {
store: DbSessionStore,
_exclusive: MutexGuard<'static, ()>,
}
impl Fixture {
fn store(&self) -> &DbSessionStore {
&self.store
}
fn pool(&self) -> &SessionPool {
self.store.pool()
}
async fn keys(&self) -> Vec<Vec<u8>> {
sqlx::query_scalar::<_, Vec<u8>>("SELECT id FROM arcature_sessions")
.fetch_all(self.pool())
.await
.expect("read arcature_sessions")
}
async fn rows(&self) -> usize {
self.keys().await.len()
}
}
async fn sessions() -> 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(StdDuration::from_secs(30))
.connect(&url)
.await
.unwrap_or_else(|error| panic!("connect to the test database: {error}"));
let store = DbSessionStore::new(pool);
store
.migrate()
.await
.unwrap_or_else(|error| panic!("migrate arcature_sessions: {error}"));
sqlx::query("DELETE FROM arcature_sessions")
.execute(store.pool())
.await
.unwrap_or_else(|error| panic!("empty arcature_sessions: {error}"));
Some(Fixture {
store,
_exclusive: exclusive,
})
}
fn record(in_: Duration) -> Record {
let mut data = HashMap::new();
data.insert("user_id".to_owned(), serde_json::json!(7));
data.insert("flash".to_owned(), serde_json::json!("saved"));
Record {
id: Id::default(),
data,
expiry_date: OffsetDateTime::now_utc() + in_,
}
}
#[tokio::test]
async fn a_saved_session_comes_back_with_its_data() {
let Some(fixture) = sessions().await else {
return;
};
let record = record(Duration::hours(1));
fixture.store().save(&record).await.expect("save");
let loaded = fixture
.store()
.load(&record.id)
.await
.expect("load")
.expect("the session was saved a moment ago");
assert_eq!(loaded.id, record.id);
assert_eq!(loaded.data, record.data);
let drift = loaded.expiry_date - record.expiry_date;
assert!(
drift.abs() < Duration::milliseconds(2),
"expiry came back as {} rather than {}",
loaded.expiry_date,
record.expiry_date
);
}
#[tokio::test]
async fn create_moves_off_an_id_that_is_already_taken() {
let Some(fixture) = sessions().await else {
return;
};
let mut first = record(Duration::hours(1));
fixture.store().create(&mut first).await.expect("create");
let taken = first.id;
let mut second = record(Duration::hours(1));
second.id = taken;
second
.data
.insert("user_id".to_owned(), serde_json::json!(9));
fixture.store().create(&mut second).await.expect("create");
assert_ne!(second.id, taken, "create reused a taken id");
assert_eq!(
fixture.rows().await,
2,
"one of the two sessions is missing"
);
let survivor = fixture
.store()
.load(&taken)
.await
.expect("load")
.expect("the first session must still be there");
assert_eq!(survivor.data, first.data, "create overwrote the first row");
}
#[tokio::test]
async fn saving_the_same_id_twice_updates_the_one_row() {
let Some(fixture) = sessions().await else {
return;
};
let mut record = record(Duration::hours(1));
fixture.store().save(&record).await.expect("first save");
record
.data
.insert("user_id".to_owned(), serde_json::json!(11));
fixture.store().save(&record).await.expect("second save");
assert_eq!(fixture.rows().await, 1, "the upsert inserted a second row");
let loaded = fixture
.store()
.load(&record.id)
.await
.expect("load")
.expect("present");
assert_eq!(loaded.data, record.data);
}
#[tokio::test]
async fn deleting_a_session_makes_it_unloadable() {
let Some(fixture) = sessions().await else {
return;
};
let record = record(Duration::hours(1));
fixture.store().save(&record).await.expect("save");
fixture.store().delete(&record.id).await.expect("delete");
assert_eq!(fixture.rows().await, 0, "the row survived the delete");
assert!(
fixture
.store()
.load(&record.id)
.await
.expect("load")
.is_none(),
"a deleted session still loads"
);
fixture
.store()
.delete(&record.id)
.await
.expect("deleting twice");
}
#[tokio::test]
async fn an_expired_session_does_not_load_even_though_its_row_is_still_there() {
let Some(fixture) = sessions().await else {
return;
};
let record = record(Duration::minutes(-5));
fixture.store().save(&record).await.expect("save");
assert_eq!(fixture.rows().await, 1, "the row should still be on disk");
assert!(
fixture
.store()
.load(&record.id)
.await
.expect("load")
.is_none(),
"an expired session loaded"
);
}
#[tokio::test]
async fn the_sweep_deletes_expired_rows_and_leaves_live_ones() {
let Some(fixture) = sessions().await else {
return;
};
let expired = record(Duration::minutes(-5));
let live = record(Duration::hours(1));
fixture.store().save(&expired).await.expect("save expired");
fixture.store().save(&live).await.expect("save live");
let deleted = fixture.store().sweep_expired().await.expect("sweep");
assert_eq!(deleted, 1, "the sweep took the wrong number of rows");
assert_eq!(fixture.rows().await, 1);
assert!(
fixture
.store()
.load(&live.id)
.await
.expect("load")
.is_some(),
"the sweep took a live session"
);
fixture
.store()
.delete_expired()
.await
.expect("delete_expired");
assert_eq!(fixture.rows().await, 1);
}
#[tokio::test]
async fn the_row_key_is_a_digest_rather_than_the_session_id() {
let Some(fixture) = sessions().await else {
return;
};
let record = record(Duration::hours(1));
fixture.store().save(&record).await.expect("save");
let keys = fixture.keys().await;
assert_eq!(keys.len(), 1);
let stored = &keys[0];
assert_eq!(stored.len(), 32, "the stored key is not a SHA-256 digest");
assert_ne!(
stored.as_slice(),
&record.id.0.to_le_bytes()[..],
"the session id itself reached the database"
);
assert_ne!(stored.len(), record.id.0.to_le_bytes().len());
}