Files
runway/crates/runway-server/tests/db.rs
T
connor 82d05dc67a Add the database schema and repository layer
The per-calendar JSON blob is gone. v1 kept every calendar's colour, every
visibility toggle and a custom palette inside one calendar_colors TEXT
column, so hiding one calendar rewrote the whole document -- which is where
"Fix calendar visibility preservation during event updates" came from.
Those are rows now, and set_visible touches visibility alone.

Foreign keys are switched on. v1 declared external_calendars.user_id as
INTEGER against a TEXT users.id; SQLite enforces neither the type nor the
constraint unless asked, so it was decorative and could never match.

Session tokens are stored as SHA-256, never in the clear, so a copy of the
database cannot be used to impersonate anyone. The CalDAV password is the
one secret that cannot be hashed -- it has to be replayed to the server --
so it gets an encrypted column with the algorithm recorded alongside, and
one way in and one way out instead of v1's eight localStorage reads.

Preferences are one column each with CHECK constraints, so a bad view or a
nonsense time increment is refused whatever route it arrives by. A NULL
display timezone means "follow the browser", which is a state v1 could not
express -- as with a NULL calendar colour meaning "defer to the server",
which is why it hashed paths to invent one.

Feed caching stores a content hash beside the ETag, because a published
Outlook feed sends neither ETag nor Last-Modified and staleness has to be
detectable anyway.

Thirty tests against real in-memory SQLite, not mocks. One caught that
create() returned nanosecond timestamps while the column stores
microseconds, so a session never compared equal to itself read back.
2026-08-26 15:54:28 -04:00

794 lines
22 KiB
Rust

//! 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<_>>(),
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");
}