//! The storage layer, against real SQLite. //! //! In-memory rather than mocked: the same engine, the same constraints, the //! same type affinities, and the same `PRAGMA foreign_keys` behaviour the //! deployed database has. A mock would agree with whatever the code believes, //! which is not the question worth asking. #![allow(clippy::unwrap_used, clippy::expect_used)] use chrono::{TimeDelta, Utc}; use pretty_assertions::assert_eq; use runway_server::db::*; async fn database() -> Database { Database::in_memory().await.expect("schema applies cleanly") } async fn a_user(db: &Database) -> User { db.users() .record_login("alex", "https://dav.example.org/", Some("Alex")) .await .unwrap() } // -------------------------------------------------------------------- schema -- #[tokio::test] async fn migrations_apply_to_an_empty_database() { let db = database().await; // Running them twice must be a no-op, which is what makes an ordinary // restart safe. db.migrate().await.unwrap(); assert!(db.users().all().await.unwrap().is_empty()); } #[tokio::test] async fn foreign_keys_are_enforced() { // v1 declared `external_calendars.user_id INTEGER` against a TEXT // `users.id`. SQLite does not enforce foreign key types, and does not // enforce foreign keys at all unless asked, so the constraint was decorative. let db = database().await; let orphan = UserId::from("nobody"); let result = db .feeds() .create( &orphan, &NewFeed { name: "Work".to_owned(), url: "https://example.org/work.ics".to_owned(), color: None, }, ) .await; assert!( result.is_err(), "a feed belonging to a user who does not exist must be refused", ); } #[tokio::test] async fn deleting_a_user_takes_everything_with_it() { let db = database().await; let user = a_user(&db).await; db.credentials() .store(&user.id, b"cipher", b"nonce", "test") .await .unwrap(); db.sessions() .create(&user.id, "token", TimeDelta::hours(1), None) .await .unwrap(); db.preferences() .save(&user.id, &Preferences::default()) .await .unwrap(); db.calendar_settings() .set(&user.id, "/cal/personal/", Some("#123456"), true, 0) .await .unwrap(); let feed = db .feeds() .create( &user.id, &NewFeed { name: "Work".to_owned(), url: "https://example.org/work.ics".to_owned(), color: None, }, ) .await .unwrap(); assert!(db.users().delete(&user.id).await.unwrap()); assert!(db.credentials().load(&user.id).await.unwrap().is_none()); assert!(db.sessions().peek("token").await.unwrap().is_none()); assert!( db.calendar_settings() .list(&user.id) .await .unwrap() .is_empty() ); assert!(db.feeds().find(&feed.id).await.unwrap().is_none()); assert!( db.feed_cache().load(&feed.id).await.unwrap().is_none(), "the cascade has to reach the cache too, or a deleted account leaves \ its calendar data on disk", ); } // --------------------------------------------------------------------- users -- #[tokio::test] async fn logging_in_twice_does_not_create_a_second_account() { let db = database().await; let first = a_user(&db).await; let second = a_user(&db).await; assert_eq!(first.id, second.id); assert_eq!(db.users().all().await.unwrap().len(), 1); assert!( second.last_login_at >= first.last_login_at, "and the login is recorded", ); } #[tokio::test] async fn the_same_username_on_two_servers_is_two_people() { let db = database().await; let here = db .users() .record_login("alex", "https://dav.example.org/", None) .await .unwrap(); let there = db .users() .record_login("alex", "https://other.example.net/", None) .await .unwrap(); assert_ne!(here.id, there.id); assert_eq!(db.users().all().await.unwrap().len(), 2); } #[tokio::test] async fn a_login_without_a_display_name_does_not_erase_the_stored_one() { let db = database().await; a_user(&db).await; let later = db .users() .record_login("alex", "https://dav.example.org/", None) .await .unwrap(); assert_eq!(later.display_name.as_deref(), Some("Alex")); } // ---------------------------------------------------------------- credentials -- #[tokio::test] async fn a_credential_round_trips_as_bytes() { let db = database().await; let user = a_user(&db).await; db.credentials() .store( &user.id, &[0xde, 0xad, 0xbe, 0xef], &[1, 2, 3], "xchacha20poly1305", ) .await .unwrap(); let stored = db.credentials().load(&user.id).await.unwrap().unwrap(); assert_eq!(stored.ciphertext, vec![0xde, 0xad, 0xbe, 0xef]); assert_eq!(stored.nonce, vec![1, 2, 3]); assert_eq!( stored.algorithm, "xchacha20poly1305", "the scheme is recorded so a later change does not have to guess what \ old rows were encrypted with", ); } #[tokio::test] async fn storing_a_credential_again_replaces_it() { let db = database().await; let user = a_user(&db).await; db.credentials() .store(&user.id, b"old", b"n1", "v1") .await .unwrap(); db.credentials() .store(&user.id, b"new", b"n2", "v2") .await .unwrap(); let stored = db.credentials().load(&user.id).await.unwrap().unwrap(); assert_eq!(stored.ciphertext, b"new".to_vec()); assert_eq!(stored.algorithm, "v2"); } #[tokio::test] async fn a_credential_does_not_print_itself() { let db = database().await; let user = a_user(&db).await; db.credentials() .store(&user.id, b"super-secret-ciphertext", b"nonce", "test") .await .unwrap(); let stored = db.credentials().load(&user.id).await.unwrap().unwrap(); let rendered = format!("{stored:?}"); assert!( !rendered.contains("115"), // a byte of the ciphertext, were it printed "{rendered}", ); assert!( rendered.contains("<23 bytes>"), "Debug should show the shape, not the contents: {rendered}", ); } // ------------------------------------------------------------------ sessions -- #[tokio::test] async fn a_session_token_is_not_stored() { let db = database().await; let user = a_user(&db).await; let token = "a-long-random-secret-from-the-cookie"; db.sessions() .create(&user.id, token, TimeDelta::hours(24), Some("Firefox")) .await .unwrap(); // Read the raw column: nothing anywhere may equal the token itself. let stored: Vec<(String,)> = sqlx::query_as("SELECT token_hash FROM sessions") .fetch_all(db.pool_for_tests()) .await .unwrap(); assert_eq!(stored.len(), 1); assert_ne!( stored[0].0, token, "a copy of the database must not be usable to impersonate anybody; v1 \ stored the token itself", ); assert_eq!(stored[0].0.len(), 64, "a hex SHA-256"); assert!(db.sessions().peek(token).await.unwrap().is_some()); } #[tokio::test] async fn an_expired_session_reads_as_absent() { let db = database().await; let user = a_user(&db).await; db.sessions() .create(&user.id, "stale", TimeDelta::seconds(-1), None) .await .unwrap(); assert!(db.sessions().peek("stale").await.unwrap().is_none()); assert!( db.sessions().touch("stale").await.unwrap().is_none(), "and touching it must not revive it", ); } #[tokio::test] async fn touching_a_session_moves_last_seen_but_not_expiry() { let db = database().await; let user = a_user(&db).await; let created = db .sessions() .create(&user.id, "live", TimeDelta::hours(24), None) .await .unwrap(); tokio::time::sleep(std::time::Duration::from_millis(5)).await; let touched = db.sessions().touch("live").await.unwrap().unwrap(); assert!(touched.last_seen_at > created.last_seen_at); assert_eq!( touched.expires_at, created.expires_at, "a session has a fixed lifetime; sliding it on every request means it \ never ends", ); } #[tokio::test] async fn a_wrong_token_matches_nothing() { let db = database().await; let user = a_user(&db).await; db.sessions() .create(&user.id, "the-real-token", TimeDelta::hours(1), None) .await .unwrap(); assert!(db.sessions().peek("the-real-toke").await.unwrap().is_none()); assert!(db.sessions().peek("").await.unwrap().is_none()); } #[tokio::test] async fn sessions_can_be_ended_one_at_a_time_or_all_at_once() { let db = database().await; let user = a_user(&db).await; for token in ["laptop", "phone", "tablet"] { db.sessions() .create(&user.id, token, TimeDelta::hours(1), Some(token)) .await .unwrap(); } assert!(db.sessions().delete("phone").await.unwrap()); assert_eq!( db.sessions().list_for_user(&user.id).await.unwrap().len(), 2 ); assert_eq!(db.sessions().delete_for_user(&user.id).await.unwrap(), 2); assert!( db.sessions() .list_for_user(&user.id) .await .unwrap() .is_empty() ); } #[tokio::test] async fn purging_removes_only_expired_sessions() { let db = database().await; let user = a_user(&db).await; db.sessions() .create(&user.id, "live", TimeDelta::hours(1), None) .await .unwrap(); db.sessions() .create(&user.id, "dead", TimeDelta::seconds(-1), None) .await .unwrap(); assert_eq!(db.sessions().purge_expired().await.unwrap(), 1); assert!(db.sessions().peek("live").await.unwrap().is_some()); } // --------------------------------------------------------------- preferences -- #[tokio::test] async fn a_user_who_has_saved_nothing_gets_the_defaults() { let db = database().await; let user = a_user(&db).await; let prefs = db.preferences().load(&user.id).await.unwrap(); assert_eq!(prefs, Preferences::default()); assert_eq!(prefs.view, View::Week); assert_eq!( prefs.display_timezone, None, "no zone means follow the browser, which is the right default for \ somebody who moves", ); assert!( db.preferences() .updated_at(&user.id) .await .unwrap() .is_none() ); } #[tokio::test] async fn preferences_round_trip() { let db = database().await; let user = a_user(&db).await; let saved = Preferences { theme: "nord".to_owned(), style: "compact".to_owned(), view: View::Month, time_increment: 15, week_starts_on: 1, display_timezone: Some("America/Louisville".to_owned()), default_calendar: Some("/cal/personal/".to_owned()), }; db.preferences().save(&user.id, &saved).await.unwrap(); assert_eq!(db.preferences().load(&user.id).await.unwrap(), saved); assert!( db.preferences() .updated_at(&user.id) .await .unwrap() .is_some() ); } #[tokio::test] async fn the_database_refuses_nonsense_preferences() { // The checks are in the schema, so a bad value cannot arrive by any route — // including a future handler that forgets to validate. let db = database().await; let user = a_user(&db).await; db.preferences() .save(&user.id, &Preferences::default()) .await .unwrap(); // Written out rather than generated: sqlx 0.9 will not accept a query built // by format!, which is a good rule and not one worth working around. let refused = [ ( "a time increment that is not on the list", "UPDATE preferences SET time_increment = 7 WHERE user_id = ?", ), ( "a day of the week that does not exist", "UPDATE preferences SET week_starts_on = 9 WHERE user_id = ?", ), ( "a view nobody implemented", "UPDATE preferences SET view = 'sideways' WHERE user_id = ?", ), ]; for (what, statement) in refused { let result = sqlx::query(statement) .bind(user.id.as_str()) .execute(db.pool_for_tests()) .await; assert!(result.is_err(), "{what} should have been refused"); } } // --------------------------------------------------------- calendar settings -- #[tokio::test] async fn calendar_settings_are_rows_not_a_json_blob() { let db = database().await; let user = a_user(&db).await; db.calendar_settings() .set(&user.id, "/cal/personal/", Some("#0CCE6B"), true, 0) .await .unwrap(); db.calendar_settings() .set(&user.id, "/cal/work/", Some("#DC2626"), false, 1) .await .unwrap(); let all = db.calendar_settings().list(&user.id).await.unwrap(); assert_eq!(all.len(), 2); assert_eq!(all[0].calendar_href, "/cal/personal/"); assert_eq!(all[0].color.as_deref(), Some("#0CCE6B")); assert!(!all[1].visible); } #[tokio::test] async fn hiding_one_calendar_leaves_the_others_alone() { // v1 read the whole JSON document, changed one field and wrote it back, // which is where "Fix calendar visibility preservation during event // updates" came from. let db = database().await; let user = a_user(&db).await; db.calendar_settings() .set(&user.id, "/cal/personal/", Some("#0CCE6B"), true, 0) .await .unwrap(); db.calendar_settings() .set(&user.id, "/cal/work/", Some("#DC2626"), true, 1) .await .unwrap(); db.calendar_settings() .set_visible(&user.id, "/cal/work/", false) .await .unwrap(); let personal = db .calendar_settings() .get(&user.id, "/cal/personal/") .await .unwrap() .unwrap(); let work = db .calendar_settings() .get(&user.id, "/cal/work/") .await .unwrap() .unwrap(); assert!(personal.visible, "the other calendar must be untouched"); assert!(!work.visible); assert_eq!( work.color.as_deref(), Some("#DC2626"), "and toggling visibility must not discard the colour", ); assert_eq!(work.position, 1, "or the ordering"); } #[tokio::test] async fn a_calendar_with_no_stored_colour_is_distinguishable_from_one_set_to_black() { let db = database().await; let user = a_user(&db).await; db.calendar_settings() .set(&user.id, "/cal/server-says/", None, true, 0) .await .unwrap(); db.calendar_settings() .set(&user.id, "/cal/black/", Some("#000000"), true, 1) .await .unwrap(); let settings = db.calendar_settings().list(&user.id).await.unwrap(); let unset = settings .iter() .find(|s| s.calendar_href == "/cal/server-says/") .unwrap(); let black = settings .iter() .find(|s| s.calendar_href == "/cal/black/") .unwrap(); assert_eq!( unset.color, None, "\"defer to the server\" is a real state; v1 could not express it and \ hashed the path to invent a colour instead", ); assert_eq!(black.color.as_deref(), Some("#000000")); } #[tokio::test] async fn two_users_keep_separate_settings_for_the_same_calendar() { let db = database().await; let alex = a_user(&db).await; let sam = db .users() .record_login("sam", "https://dav.example.org/", None) .await .unwrap(); db.calendar_settings() .set(&alex.id, "/cal/shared/", Some("#111111"), true, 0) .await .unwrap(); db.calendar_settings() .set(&sam.id, "/cal/shared/", Some("#222222"), false, 0) .await .unwrap(); assert_eq!( db.calendar_settings() .get(&alex.id, "/cal/shared/") .await .unwrap() .unwrap() .color .as_deref(), Some("#111111"), ); assert_eq!( db.calendar_settings() .get(&sam.id, "/cal/shared/") .await .unwrap() .unwrap() .color .as_deref(), Some("#222222"), ); } // --------------------------------------------------------------------- feeds -- #[tokio::test] async fn feeds_are_created_in_order_and_listed_that_way() { let db = database().await; let user = a_user(&db).await; for (name, url) in [ ("Work", "https://example.org/work.ics"), ("Holidays", "https://example.org/holidays.ics"), ] { db.feeds() .create( &user.id, &NewFeed { name: name.to_owned(), url: url.to_owned(), color: None, }, ) .await .unwrap(); } let feeds = db.feeds().list(&user.id).await.unwrap(); assert_eq!( feeds.iter().map(|f| f.name.as_str()).collect::>(), vec!["Work", "Holidays"], "ordered by position, not alphabetically -- the person chose this order", ); assert!(feeds.iter().all(|f| f.visible)); } #[tokio::test] async fn the_same_feed_cannot_be_subscribed_twice() { let db = database().await; let user = a_user(&db).await; let feed = NewFeed { name: "Work".to_owned(), url: "https://example.org/work.ics".to_owned(), color: None, }; db.feeds().create(&user.id, &feed).await.unwrap(); assert!(db.feeds().create(&user.id, &feed).await.is_err()); } #[tokio::test] async fn updating_a_missing_feed_says_which_one() { let db = database().await; let result = db .feeds() .update(&FeedId::from("nope"), "New name", None, true) .await; match result { Err(DbError::NotFound { entity, id }) => { assert_eq!(entity, "feed"); assert_eq!(id, "nope"); } other => panic!("expected a typed NotFound, got {other:?}"), } } // ---------------------------------------------------------------- feed cache -- #[tokio::test] async fn a_cached_feed_round_trips() { let db = database().await; let user = a_user(&db).await; let feed = db .feeds() .create( &user.id, &NewFeed { name: "Work".to_owned(), url: "https://example.org/work.ics".to_owned(), color: None, }, ) .await .unwrap(); let body = "BEGIN:VCALENDAR\r\nEND:VCALENDAR\r\n"; let cached = CachedFeed { body: body.to_owned(), content_hash: hash_body(body), etag: None, last_modified: None, fetched_at: Utc::now(), expires_at: None, }; db.feed_cache().store(&feed.id, &cached).await.unwrap(); let loaded = db.feed_cache().load(&feed.id).await.unwrap().unwrap(); assert_eq!(loaded.body, body); assert_eq!( loaded.content_hash, hash_body(body), "a published Outlook feed sends no ETag and no Last-Modified, so the \ hash is the only way to notice nothing changed", ); } #[tokio::test] async fn a_content_hash_changes_only_when_the_body_does() { let one = "BEGIN:VCALENDAR\r\nX-WR-CALNAME:Work\r\nEND:VCALENDAR\r\n"; let same = "BEGIN:VCALENDAR\r\nX-WR-CALNAME:Work\r\nEND:VCALENDAR\r\n"; let other = "BEGIN:VCALENDAR\r\nX-WR-CALNAME:Home\r\nEND:VCALENDAR\r\n"; assert_eq!(hash_body(one), hash_body(same)); assert_ne!(hash_body(one), hash_body(other)); } #[tokio::test] async fn a_not_modified_response_moves_freshness_without_touching_the_body() { let db = database().await; let user = a_user(&db).await; let feed = db .feeds() .create( &user.id, &NewFeed { name: "Work".to_owned(), url: "https://example.org/work.ics".to_owned(), color: None, }, ) .await .unwrap(); let body = "BEGIN:VCALENDAR\r\nEND:VCALENDAR\r\n"; let first = Utc::now() - TimeDelta::hours(2); db.feed_cache() .store( &feed.id, &CachedFeed { body: body.to_owned(), content_hash: hash_body(body), etag: Some("\"abc\"".to_owned()), last_modified: None, fetched_at: first, expires_at: None, }, ) .await .unwrap(); let now = Utc::now(); db.feed_cache().touch(&feed.id, now, None).await.unwrap(); let loaded = db.feed_cache().load(&feed.id).await.unwrap().unwrap(); assert_eq!(loaded.body, body); assert_eq!(loaded.etag.as_deref(), Some("\"abc\"")); assert!(loaded.fetched_at > first); } #[tokio::test] async fn removing_a_feed_removes_its_cache() { let db = database().await; let user = a_user(&db).await; let feed = db .feeds() .create( &user.id, &NewFeed { name: "Work".to_owned(), url: "https://example.org/work.ics".to_owned(), color: None, }, ) .await .unwrap(); let body = "BEGIN:VCALENDAR\r\nEND:VCALENDAR\r\n"; db.feed_cache() .store( &feed.id, &CachedFeed { body: body.to_owned(), content_hash: hash_body(body), etag: None, last_modified: None, fetched_at: Utc::now(), expires_at: None, }, ) .await .unwrap(); assert!(db.feeds().delete(&feed.id).await.unwrap()); assert!(db.feed_cache().load(&feed.id).await.unwrap().is_none()); } // ----------------------------------------------------------------------- ids -- #[tokio::test] async fn identifiers_are_distinct_per_type() { // The compiler enforces the rest; this just pins that they are not // interchangeable strings by accident. let user = UserId::new(); let feed = FeedId::new(); assert_ne!(user.as_str(), feed.as_str()); assert_eq!(user.as_str().len(), 36, "a uuid, stored as text"); }