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.
This commit is contained in:
2026-08-26 15:54:28 -04:00
parent c197e08af0
commit 82d05dc67a
15 changed files with 2206 additions and 5 deletions
+2
View File
@@ -12,6 +12,7 @@ axum = { workspace = true }
chrono = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
sha2 = { workspace = true }
sqlx = { workspace = true }
thiserror = { workspace = true }
tokio = { workspace = true }
@@ -23,6 +24,7 @@ uuid = { workspace = true }
[dev-dependencies]
reqwest = { workspace = true }
pretty_assertions = { workspace = true }
[lints]
workspace = true
@@ -0,0 +1,145 @@
-- Runway's schema.
--
-- Two things went wrong last time and both are structural rather than
-- incidental, so they are addressed here rather than in the code above.
--
-- First, per-calendar settings lived in a single `calendar_colors TEXT` column
-- holding JSON: colours, visibility and a custom palette all in one blob. There
-- was no way to change one calendar's colour without rewriting every user's
-- whole preference document, no way to query it, and no way for the database to
-- reject nonsense. Those are three tables now.
--
-- Second, `external_calendars.user_id` was declared INTEGER while `users.id` is
-- a TEXT uuid. SQLite does not enforce foreign key types, so the mismatch was
-- silent and the constraint could never match. Every key here is TEXT, and
-- foreign keys are enforced (see `PRAGMA foreign_keys` at connection time).
--
-- Timestamps are RFC 3339 strings in UTC. SQLite has no date type; picking one
-- representation and using it everywhere beats v1's mix of TEXT and DATETIME.
CREATE TABLE users (
id TEXT PRIMARY KEY,
-- The CalDAV identity. A person is defined by the account they log into,
-- so the same username on two servers is two users.
username TEXT NOT NULL,
server_url TEXT NOT NULL,
display_name TEXT,
created_at TEXT NOT NULL,
last_login_at TEXT,
UNIQUE (username, server_url)
);
-- The CalDAV password, encrypted at rest.
--
-- v1 kept this in the browser's localStorage in cleartext and re-sent it on
-- every request in an `X-CalDAV-Password` header, read from eight different
-- call sites. The server has to be able to replay it to the CalDAV server, so
-- it cannot be hashed -- but it can be encrypted, and it can live in exactly
-- one place with one way in and one way out.
CREATE TABLE credentials (
user_id TEXT PRIMARY KEY REFERENCES users (id) ON DELETE CASCADE,
-- Ciphertext and nonce as bytes, not base64: the encoding is the storage
-- layer's business and nothing else needs to know about it.
ciphertext BLOB NOT NULL,
nonce BLOB NOT NULL,
-- Named so the scheme can be changed later without guessing what old rows
-- were encrypted with.
algorithm TEXT NOT NULL,
updated_at TEXT NOT NULL
);
-- Sessions, keyed on a hash of the token rather than the token.
--
-- The cookie holds a random secret; this table holds only its SHA-256. A copy
-- of the database therefore cannot be used to impersonate anybody, which was
-- not true of v1's `sessions.token`.
CREATE TABLE sessions (
token_hash TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users (id) ON DELETE CASCADE,
created_at TEXT NOT NULL,
expires_at TEXT NOT NULL,
last_seen_at TEXT NOT NULL,
-- Enough to show someone a list of their signed-in devices later.
user_agent TEXT
);
CREATE INDEX idx_sessions_user ON sessions (user_id);
CREATE INDEX idx_sessions_expires ON sessions (expires_at);
-- Preferences that are single values, one column each.
CREATE TABLE preferences (
user_id TEXT PRIMARY KEY REFERENCES users (id) ON DELETE CASCADE,
-- The two token axes: colour and shape/density.
theme TEXT NOT NULL DEFAULT 'default',
style TEXT NOT NULL DEFAULT 'default',
-- 'month' | 'week' | 'day' | 'agenda' | 'year'
view TEXT NOT NULL DEFAULT 'week',
-- Grid granularity in minutes.
time_increment INTEGER NOT NULL DEFAULT 30,
-- 0 = Sunday, matching chrono's Weekday::num_days_from_sunday.
week_starts_on INTEGER NOT NULL DEFAULT 0,
-- The IANA zone to render in. Never an offset: an offset cannot tell
-- January from July, which is what made v1's recurring events drift.
-- NULL means "use whatever the browser reports", which is the right
-- default for someone who moves.
display_timezone TEXT,
-- Where a new event goes by default.
default_calendar TEXT,
updated_at TEXT NOT NULL,
CHECK (time_increment IN (5, 10, 15, 20, 30, 60)),
CHECK (week_starts_on BETWEEN 0 AND 6),
CHECK (view IN ('month', 'week', 'day', 'agenda', 'year'))
);
-- Per-calendar settings: one row per calendar, not one JSON blob per user.
--
-- `color` is nullable on purpose. NULL means "whatever the server says", which
-- is a different statement from any particular colour -- v1 could not express
-- it, so it hashed the calendar's path to invent one and disagreed with every
-- other client about what colour a calendar was.
CREATE TABLE calendar_settings (
user_id TEXT NOT NULL REFERENCES users (id) ON DELETE CASCADE,
calendar_href TEXT NOT NULL,
color TEXT,
visible INTEGER NOT NULL DEFAULT 1,
position INTEGER NOT NULL DEFAULT 0,
updated_at TEXT NOT NULL,
PRIMARY KEY (user_id, calendar_href),
CHECK (visible IN (0, 1))
);
-- A subscribed read-only ICS feed.
CREATE TABLE feeds (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users (id) ON DELETE CASCADE,
name TEXT NOT NULL,
url TEXT NOT NULL,
color TEXT,
visible INTEGER NOT NULL DEFAULT 1,
position INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
UNIQUE (user_id, url),
CHECK (visible IN (0, 1))
);
CREATE INDEX idx_feeds_user ON feeds (user_id);
-- What a feed returned last time, and what to say when asking again.
--
-- `etag` and `last_modified` are both nullable because a real published Outlook
-- calendar sends neither. `content_hash` is the fallback that makes staleness
-- detectable anyway: fetch, hash, compare, and skip the reparse when it matches.
CREATE TABLE feed_cache (
feed_id TEXT PRIMARY KEY REFERENCES feeds (id) ON DELETE CASCADE,
body TEXT NOT NULL,
content_hash TEXT NOT NULL,
etag TEXT,
last_modified TEXT,
fetched_at TEXT NOT NULL,
-- From Cache-Control or Expires, when the server offers one.
expires_at TEXT
);
+161
View File
@@ -0,0 +1,161 @@
//! Per-calendar settings: colour, visibility, order.
//!
//! This is the JSON blob unpacked. v1 stored all of it — every calendar's
//! colour, every visibility toggle, and a custom 16-colour palette — inside a
//! single `calendar_colors TEXT` column, so hiding one calendar rewrote the
//! whole document, nothing could be queried, and nothing could be validated.
//!
//! One row per calendar per user, with the database enforcing that `visible` is
//! a boolean and that a user cannot have two rows for the same calendar.
use super::{Database, DbError, UserId, parse_stamp, stamp};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct CalendarSetting {
pub calendar_href: String,
/// `None` means "whatever the server says".
///
/// A distinct state from any particular colour, and one v1 could not
/// express: it hashed the calendar's path to invent one, so Runway
/// disagreed with every other client about what colour a calendar was.
pub color: Option<String>,
pub visible: bool,
pub position: i64,
pub updated_at: DateTime<Utc>,
}
#[derive(sqlx::FromRow)]
struct SettingRow {
calendar_href: String,
color: Option<String>,
visible: i64,
position: i64,
updated_at: String,
}
impl TryFrom<SettingRow> for CalendarSetting {
type Error = DbError;
fn try_from(row: SettingRow) -> Result<Self, Self::Error> {
Ok(Self {
calendar_href: row.calendar_href,
color: row.color,
visible: row.visible != 0,
position: row.position,
updated_at: parse_stamp(&row.updated_at)?,
})
}
}
pub struct CalendarSettings<'a> {
db: &'a Database,
}
impl<'a> CalendarSettings<'a> {
pub(crate) fn new(db: &'a Database) -> Self {
Self { db }
}
/// Every setting a user has recorded, in display order.
///
/// Calendars the user has never touched simply have no row; the caller
/// merges these onto what the CalDAV server reports rather than expecting
/// this to be a complete list.
pub async fn list(&self, user_id: &UserId) -> Result<Vec<CalendarSetting>, DbError> {
let rows: Vec<SettingRow> = sqlx::query_as(
"SELECT calendar_href, color, visible, position, updated_at
FROM calendar_settings WHERE user_id = ?
ORDER BY position, calendar_href",
)
.bind(user_id.as_str())
.fetch_all(self.db.pool())
.await?;
rows.into_iter().map(CalendarSetting::try_from).collect()
}
pub async fn get(
&self,
user_id: &UserId,
calendar_href: &str,
) -> Result<Option<CalendarSetting>, DbError> {
let row: Option<SettingRow> = sqlx::query_as(
"SELECT calendar_href, color, visible, position, updated_at
FROM calendar_settings WHERE user_id = ? AND calendar_href = ?",
)
.bind(user_id.as_str())
.bind(calendar_href)
.fetch_optional(self.db.pool())
.await?;
row.map(CalendarSetting::try_from).transpose()
}
/// Records settings for one calendar.
pub async fn set(
&self,
user_id: &UserId,
calendar_href: &str,
color: Option<&str>,
visible: bool,
position: i64,
) -> Result<(), DbError> {
sqlx::query(
"INSERT INTO calendar_settings
(user_id, calendar_href, color, visible, position, updated_at)
VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT (user_id, calendar_href) DO UPDATE SET
color = excluded.color,
visible = excluded.visible,
position = excluded.position,
updated_at = excluded.updated_at",
)
.bind(user_id.as_str())
.bind(calendar_href)
.bind(color)
.bind(i64::from(visible))
.bind(position)
.bind(stamp(Utc::now()))
.execute(self.db.pool())
.await?;
Ok(())
}
/// Toggles one calendar without disturbing its colour or position.
///
/// The operation the sidebar actually performs. Doing it as a read, a
/// modify and a write of the whole document is how v1 produced
/// "Fix calendar visibility preservation during event updates".
pub async fn set_visible(
&self,
user_id: &UserId,
calendar_href: &str,
visible: bool,
) -> Result<(), DbError> {
sqlx::query(
"INSERT INTO calendar_settings
(user_id, calendar_href, visible, position, updated_at)
VALUES (?, ?, ?, 0, ?)
ON CONFLICT (user_id, calendar_href) DO UPDATE SET
visible = excluded.visible,
updated_at = excluded.updated_at",
)
.bind(user_id.as_str())
.bind(calendar_href)
.bind(i64::from(visible))
.bind(stamp(Utc::now()))
.execute(self.db.pool())
.await?;
Ok(())
}
pub async fn remove(&self, user_id: &UserId, calendar_href: &str) -> Result<bool, DbError> {
let result =
sqlx::query("DELETE FROM calendar_settings WHERE user_id = ? AND calendar_href = ?")
.bind(user_id.as_str())
.bind(calendar_href)
.execute(self.db.pool())
.await?;
Ok(result.rows_affected() > 0)
}
}
+114
View File
@@ -0,0 +1,114 @@
//! The CalDAV password, at rest.
//!
//! This is the one secret the server cannot hash, because it has to be replayed
//! to the CalDAV server on every request made on the user's behalf. So it is
//! encrypted instead, and this module is the only way in or out.
//!
//! v1 kept it in the browser's localStorage in cleartext and read it from eight
//! separate call sites in `app.rs` alone, resending it in an `X-CalDAV-Password`
//! header. The encryption itself lands with authentication; the storage shape
//! is here, and it names the algorithm so a future change does not have to
//! guess what old rows were encrypted with.
use super::{Database, DbError, UserId, parse_stamp, stamp};
use chrono::{DateTime, Utc};
/// An encrypted credential exactly as stored.
///
/// Deliberately opaque: nothing here can read the password, and `Debug` shows
/// lengths rather than bytes so a stray log line cannot leak ciphertext.
#[derive(Clone, PartialEq, Eq)]
pub struct StoredCredential {
pub ciphertext: Vec<u8>,
pub nonce: Vec<u8>,
pub algorithm: String,
pub updated_at: DateTime<Utc>,
}
impl std::fmt::Debug for StoredCredential {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("StoredCredential")
.field("ciphertext", &format!("<{} bytes>", self.ciphertext.len()))
.field("nonce", &format!("<{} bytes>", self.nonce.len()))
.field("algorithm", &self.algorithm)
.field("updated_at", &self.updated_at)
.finish()
}
}
#[derive(sqlx::FromRow)]
struct CredentialRow {
ciphertext: Vec<u8>,
nonce: Vec<u8>,
algorithm: String,
updated_at: String,
}
impl TryFrom<CredentialRow> for StoredCredential {
type Error = DbError;
fn try_from(row: CredentialRow) -> Result<Self, Self::Error> {
Ok(Self {
ciphertext: row.ciphertext,
nonce: row.nonce,
algorithm: row.algorithm,
updated_at: parse_stamp(&row.updated_at)?,
})
}
}
pub struct Credentials<'a> {
db: &'a Database,
}
impl<'a> Credentials<'a> {
pub(crate) fn new(db: &'a Database) -> Self {
Self { db }
}
/// Stores or replaces a user's credential.
pub async fn store(
&self,
user_id: &UserId,
ciphertext: &[u8],
nonce: &[u8],
algorithm: &str,
) -> Result<(), DbError> {
sqlx::query(
"INSERT INTO credentials (user_id, ciphertext, nonce, algorithm, updated_at)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT (user_id) DO UPDATE SET
ciphertext = excluded.ciphertext,
nonce = excluded.nonce,
algorithm = excluded.algorithm,
updated_at = excluded.updated_at",
)
.bind(user_id.as_str())
.bind(ciphertext)
.bind(nonce)
.bind(algorithm)
.bind(stamp(Utc::now()))
.execute(self.db.pool())
.await?;
Ok(())
}
pub async fn load(&self, user_id: &UserId) -> Result<Option<StoredCredential>, DbError> {
let row: Option<CredentialRow> = sqlx::query_as(
"SELECT ciphertext, nonce, algorithm, updated_at
FROM credentials WHERE user_id = ?",
)
.bind(user_id.as_str())
.fetch_optional(self.db.pool())
.await?;
row.map(StoredCredential::try_from).transpose()
}
pub async fn delete(&self, user_id: &UserId) -> Result<bool, DbError> {
let result = sqlx::query("DELETE FROM credentials WHERE user_id = ?")
.bind(user_id.as_str())
.execute(self.db.pool())
.await?;
Ok(result.rows_affected() > 0)
}
}
+25
View File
@@ -0,0 +1,25 @@
//! Storage errors.
use thiserror::Error;
#[derive(Debug, Error)]
pub enum DbError {
/// The statement failed, or the database is unreachable.
#[error("database error: {0}")]
Sql(#[from] sqlx::Error),
/// The schema could not be brought up to date.
#[error("migration failed: {0}")]
Migration(#[from] sqlx::migrate::MigrateError),
/// A row held something the schema should have prevented.
///
/// Separate from `Sql` on purpose: this means the data is wrong, not that
/// the query was, and the two want different responses.
#[error("stored data is not readable: {0}")]
Corrupt(String),
/// A row that had to exist did not.
#[error("no {entity} with id {id}")]
NotFound { entity: &'static str, id: String },
}
+267
View File
@@ -0,0 +1,267 @@
//! Subscribed ICS feeds, and what they last returned.
use super::{Database, DbError, FeedId, UserId, parse_stamp, stamp};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Feed {
pub id: FeedId,
pub user_id: UserId,
pub name: String,
pub url: String,
pub color: Option<String>,
pub visible: bool,
pub position: i64,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
/// What is needed to subscribe to a feed.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct NewFeed {
pub name: String,
pub url: String,
pub color: Option<String>,
}
#[derive(sqlx::FromRow)]
struct FeedRow {
id: String,
user_id: String,
name: String,
url: String,
color: Option<String>,
visible: i64,
position: i64,
created_at: String,
updated_at: String,
}
impl TryFrom<FeedRow> for Feed {
type Error = DbError;
fn try_from(row: FeedRow) -> Result<Self, Self::Error> {
Ok(Self {
id: FeedId(row.id),
user_id: UserId(row.user_id),
name: row.name,
url: row.url,
color: row.color,
visible: row.visible != 0,
position: row.position,
created_at: parse_stamp(&row.created_at)?,
updated_at: parse_stamp(&row.updated_at)?,
})
}
}
pub struct Feeds<'a> {
db: &'a Database,
}
impl<'a> Feeds<'a> {
pub(crate) fn new(db: &'a Database) -> Self {
Self { db }
}
pub async fn create(&self, user_id: &UserId, feed: &NewFeed) -> Result<Feed, DbError> {
let now = Utc::now();
let row: FeedRow = sqlx::query_as(
"INSERT INTO feeds
(id, user_id, name, url, color, visible, position, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, 1,
(SELECT COALESCE(MAX(position) + 1, 0) FROM feeds WHERE user_id = ?),
?, ?)
RETURNING *",
)
.bind(FeedId::new().as_str())
.bind(user_id.as_str())
.bind(&feed.name)
.bind(&feed.url)
.bind(feed.color.as_deref())
.bind(user_id.as_str())
.bind(stamp(now))
.bind(stamp(now))
.fetch_one(self.db.pool())
.await?;
row.try_into()
}
pub async fn list(&self, user_id: &UserId) -> Result<Vec<Feed>, DbError> {
let rows: Vec<FeedRow> =
sqlx::query_as("SELECT * FROM feeds WHERE user_id = ? ORDER BY position, name")
.bind(user_id.as_str())
.fetch_all(self.db.pool())
.await?;
rows.into_iter().map(Feed::try_from).collect()
}
pub async fn find(&self, id: &FeedId) -> Result<Option<Feed>, DbError> {
let row: Option<FeedRow> = sqlx::query_as("SELECT * FROM feeds WHERE id = ?")
.bind(id.as_str())
.fetch_optional(self.db.pool())
.await?;
row.map(Feed::try_from).transpose()
}
/// Updates the parts of a feed a person can edit.
pub async fn update(
&self,
id: &FeedId,
name: &str,
color: Option<&str>,
visible: bool,
) -> Result<Feed, DbError> {
let row: Option<FeedRow> = sqlx::query_as(
"UPDATE feeds SET name = ?, color = ?, visible = ?, updated_at = ?
WHERE id = ? RETURNING *",
)
.bind(name)
.bind(color)
.bind(i64::from(visible))
.bind(stamp(Utc::now()))
.bind(id.as_str())
.fetch_optional(self.db.pool())
.await?;
row.ok_or_else(|| DbError::NotFound {
entity: "feed",
id: id.to_string(),
})?
.try_into()
}
/// Removes a feed and its cached body.
pub async fn delete(&self, id: &FeedId) -> Result<bool, DbError> {
let result = sqlx::query("DELETE FROM feeds WHERE id = ?")
.bind(id.as_str())
.execute(self.db.pool())
.await?;
Ok(result.rows_affected() > 0)
}
}
/// A feed's last response.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CachedFeed {
pub body: String,
/// SHA-256 of the body.
///
/// The fallback that makes staleness detectable when a server offers no
/// validators — a published Outlook calendar sends neither `ETag` nor
/// `Last-Modified`, so without this every poll would be a full reparse.
pub content_hash: String,
pub etag: Option<String>,
pub last_modified: Option<String>,
pub fetched_at: DateTime<Utc>,
pub expires_at: Option<DateTime<Utc>>,
}
#[derive(sqlx::FromRow)]
struct CacheRow {
body: String,
content_hash: String,
etag: Option<String>,
last_modified: Option<String>,
fetched_at: String,
expires_at: Option<String>,
}
impl TryFrom<CacheRow> for CachedFeed {
type Error = DbError;
fn try_from(row: CacheRow) -> Result<Self, Self::Error> {
Ok(Self {
body: row.body,
content_hash: row.content_hash,
etag: row.etag,
last_modified: row.last_modified,
fetched_at: parse_stamp(&row.fetched_at)?,
expires_at: row.expires_at.as_deref().map(parse_stamp).transpose()?,
})
}
}
pub struct FeedCache<'a> {
db: &'a Database,
}
impl<'a> FeedCache<'a> {
pub(crate) fn new(db: &'a Database) -> Self {
Self { db }
}
pub async fn store(&self, feed_id: &FeedId, cached: &CachedFeed) -> Result<(), DbError> {
sqlx::query(
"INSERT INTO feed_cache
(feed_id, body, content_hash, etag, last_modified, fetched_at, expires_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
ON CONFLICT (feed_id) DO UPDATE SET
body = excluded.body,
content_hash = excluded.content_hash,
etag = excluded.etag,
last_modified = excluded.last_modified,
fetched_at = excluded.fetched_at,
expires_at = excluded.expires_at",
)
.bind(feed_id.as_str())
.bind(&cached.body)
.bind(&cached.content_hash)
.bind(cached.etag.as_deref())
.bind(cached.last_modified.as_deref())
.bind(stamp(cached.fetched_at))
.bind(cached.expires_at.map(stamp))
.execute(self.db.pool())
.await?;
Ok(())
}
pub async fn load(&self, feed_id: &FeedId) -> Result<Option<CachedFeed>, DbError> {
let row: Option<CacheRow> = sqlx::query_as(
"SELECT body, content_hash, etag, last_modified, fetched_at, expires_at
FROM feed_cache WHERE feed_id = ?",
)
.bind(feed_id.as_str())
.fetch_optional(self.db.pool())
.await?;
row.map(CachedFeed::try_from).transpose()
}
/// Records that a conditional request came back "not modified".
///
/// The body is untouched; only the freshness moves. Rewriting the body here
/// would be harmless but wasteful, and the distinction is worth keeping
/// visible.
pub async fn touch(
&self,
feed_id: &FeedId,
fetched_at: DateTime<Utc>,
expires_at: Option<DateTime<Utc>>,
) -> Result<(), DbError> {
sqlx::query("UPDATE feed_cache SET fetched_at = ?, expires_at = ? WHERE feed_id = ?")
.bind(stamp(fetched_at))
.bind(expires_at.map(stamp))
.bind(feed_id.as_str())
.execute(self.db.pool())
.await?;
Ok(())
}
pub async fn clear(&self, feed_id: &FeedId) -> Result<bool, DbError> {
let result = sqlx::query("DELETE FROM feed_cache WHERE feed_id = ?")
.bind(feed_id.as_str())
.execute(self.db.pool())
.await?;
Ok(result.rows_affected() > 0)
}
}
/// SHA-256 of a feed body, as stored in `content_hash`.
pub fn hash_body(body: &str) -> String {
use sha2::{Digest, Sha256};
Sha256::digest(body.as_bytes())
.iter()
.map(|byte| format!("{byte:02x}"))
.collect()
}
+60
View File
@@ -0,0 +1,60 @@
//! Identifiers.
//!
//! Newtypes rather than bare `String`s, so a feed id cannot be passed where a
//! user id belongs. They are stored as TEXT: v1 declared
//! `external_calendars.user_id INTEGER` against a TEXT `users.id`, and because
//! SQLite does not enforce foreign key types the constraint silently could
//! never match.
use serde::{Deserialize, Serialize};
use std::fmt;
macro_rules! id_type {
($name:ident, $entity:literal) => {
#[doc = concat!("The identity of a ", $entity, ".")]
#[derive(
Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize, sqlx::Type,
)]
#[serde(transparent)]
#[sqlx(transparent)]
pub struct $name(pub String);
impl $name {
/// A fresh random identifier.
pub fn new() -> Self {
Self(uuid::Uuid::new_v4().to_string())
}
pub fn as_str(&self) -> &str {
&self.0
}
}
impl Default for $name {
fn default() -> Self {
Self::new()
}
}
impl fmt::Display for $name {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.0)
}
}
impl From<String> for $name {
fn from(value: String) -> Self {
Self(value)
}
}
impl From<&str> for $name {
fn from(value: &str) -> Self {
Self(value.to_owned())
}
}
};
}
id_type!(UserId, "user");
id_type!(FeedId, "subscribed feed");
+144
View File
@@ -0,0 +1,144 @@
//! Storage.
//!
//! A thin repository over SQLite: one module per table group, every query
//! exercised by a test against a real database rather than a mock. There is no
//! ORM and no query builder — the statements are short enough to read, and
//! reading them is the point.
//!
//! Everything here takes and returns typed values. The previous iteration
//! stored per-calendar colours, visibility and a custom palette together in one
//! `calendar_colors TEXT` column of JSON, so changing one calendar's colour
//! meant rewriting the whole document and nothing could be queried or checked.
mod calendars;
mod credentials;
mod error;
mod feeds;
mod ids;
mod preferences;
mod sessions;
mod users;
pub use calendars::{CalendarSetting, CalendarSettings};
pub use credentials::{Credentials, StoredCredential};
pub use error::DbError;
pub use feeds::{CachedFeed, Feed, FeedCache, Feeds, NewFeed, hash_body};
pub use ids::{FeedId, UserId};
pub use preferences::{Preferences, PreferencesRepo, View};
pub use sessions::{Session, Sessions};
pub use users::{User, Users};
use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions};
use sqlx::{Pool, Sqlite};
use std::str::FromStr;
use std::time::Duration;
/// A connection pool with the schema applied.
#[derive(Debug, Clone)]
pub struct Database {
pool: Pool<Sqlite>,
}
impl Database {
/// Opens (creating if needed) a database at a URL such as
/// `sqlite:runway.db`, and brings the schema up to date.
pub async fn connect(url: &str) -> Result<Self, DbError> {
let options = SqliteConnectOptions::from_str(url)
.map_err(DbError::from)?
.create_if_missing(true)
// Off by default in SQLite, which is how v1's mismatched
// `user_id INTEGER` referencing a TEXT primary key went unnoticed.
.foreign_keys(true)
// Concurrent readers alongside a writer, which a web server wants.
.journal_mode(sqlx::sqlite::SqliteJournalMode::Wal)
.busy_timeout(Duration::from_secs(5));
let pool = SqlitePoolOptions::new()
.max_connections(8)
.connect_with(options)
.await?;
let database = Self { pool };
database.migrate().await?;
Ok(database)
}
/// A private database that exists only for the life of the process.
///
/// Used by tests, so they run against real SQLite — the same engine, the
/// same constraints, the same type affinities — without a file to clean up.
pub async fn in_memory() -> Result<Self, DbError> {
Self::connect("sqlite::memory:").await
}
/// Applies any migrations the database has not seen.
pub async fn migrate(&self) -> Result<(), DbError> {
sqlx::migrate!("./migrations").run(&self.pool).await?;
Ok(())
}
pub(crate) fn pool(&self) -> &Pool<Sqlite> {
&self.pool
}
/// Raw pool access, for tests that need to assert on what is *actually*
/// stored rather than on what the repository hands back.
///
/// Two things are only checkable this way: that a session token never
/// reaches the disk, and that the schema's CHECK constraints reject bad
/// values by whatever route they arrive. Not for ordinary use — going
/// around the repository is how a codebase ends up with the same query
/// written four slightly different ways.
#[doc(hidden)]
pub fn pool_for_tests(&self) -> &Pool<Sqlite> {
&self.pool
}
pub fn users(&self) -> Users<'_> {
Users::new(self)
}
pub fn credentials(&self) -> Credentials<'_> {
Credentials::new(self)
}
pub fn sessions(&self) -> Sessions<'_> {
Sessions::new(self)
}
pub fn preferences(&self) -> PreferencesRepo<'_> {
PreferencesRepo::new(self)
}
pub fn calendar_settings(&self) -> CalendarSettings<'_> {
CalendarSettings::new(self)
}
pub fn feeds(&self) -> Feeds<'_> {
Feeds::new(self)
}
pub fn feed_cache(&self) -> FeedCache<'_> {
FeedCache::new(self)
}
/// Closes the pool, waiting for in-flight statements.
pub async fn close(&self) {
self.pool.close().await;
}
}
/// The one timestamp format the schema uses.
///
/// SQLite has no date type. v1 wrote some columns as TEXT and others as
/// DATETIME and compared them against each other; picking one representation
/// and never deviating is most of what makes that class of bug impossible.
pub(crate) fn stamp(at: chrono::DateTime<chrono::Utc>) -> String {
at.to_rfc3339_opts(chrono::SecondsFormat::Micros, true)
}
pub(crate) fn parse_stamp(text: &str) -> Result<chrono::DateTime<chrono::Utc>, DbError> {
chrono::DateTime::parse_from_rfc3339(text)
.map(|dt| dt.with_timezone(&chrono::Utc))
.map_err(|e| DbError::Corrupt(format!("bad timestamp {text:?}: {e}")))
}
+186
View File
@@ -0,0 +1,186 @@
//! Single-valued preferences: one column each, not one JSON document.
//!
//! v1 kept these in localStorage *and* in a `user_preferences` table with no
//! defined source of truth — `calendar_view_mode`, `calendar_theme`,
//! `calendar_style`, `calendar_selected_date`, `calendar_time_increment`,
//! `calendar_colors` and a `user_preferences` blob containing the others, all
//! coexisting, some written to both places and some to one.
//!
//! There is one home for each of these now, and the database rejects values
//! that make no sense rather than trusting whatever the client sent.
use super::{Database, DbError, UserId, parse_stamp, stamp};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
/// Which view the calendar opens in.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum View {
Month,
#[default]
Week,
Day,
Agenda,
Year,
}
impl View {
pub fn as_str(self) -> &'static str {
match self {
Self::Month => "month",
Self::Week => "week",
Self::Day => "day",
Self::Agenda => "agenda",
Self::Year => "year",
}
}
fn parse(text: &str) -> Result<Self, DbError> {
match text {
"month" => Ok(Self::Month),
"week" => Ok(Self::Week),
"day" => Ok(Self::Day),
"agenda" => Ok(Self::Agenda),
"year" => Ok(Self::Year),
other => Err(DbError::Corrupt(format!("unknown view {other:?}"))),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Preferences {
pub theme: String,
pub style: String,
pub view: View,
/// Grid granularity in minutes.
pub time_increment: u16,
/// 0 = Sunday, matching `chrono::Weekday::num_days_from_sunday`.
pub week_starts_on: u8,
/// The IANA zone to render in.
///
/// `None` means "follow the browser", which is the right default for
/// somebody who moves — and the reason this is a zone name and never an
/// offset.
pub display_timezone: Option<String>,
/// Where a new event goes unless told otherwise.
pub default_calendar: Option<String>,
}
impl Default for Preferences {
fn default() -> Self {
Self {
theme: "default".to_owned(),
style: "default".to_owned(),
view: View::Week,
time_increment: 30,
week_starts_on: 0,
display_timezone: None,
default_calendar: None,
}
}
}
#[derive(sqlx::FromRow)]
struct PreferencesRow {
theme: String,
style: String,
view: String,
time_increment: i64,
week_starts_on: i64,
display_timezone: Option<String>,
default_calendar: Option<String>,
updated_at: String,
}
impl PreferencesRow {
fn into_preferences(self) -> Result<(Preferences, DateTime<Utc>), DbError> {
let updated_at = parse_stamp(&self.updated_at)?;
Ok((
Preferences {
theme: self.theme,
style: self.style,
view: View::parse(&self.view)?,
time_increment: u16::try_from(self.time_increment)
.map_err(|_| DbError::Corrupt("time_increment out of range".to_owned()))?,
week_starts_on: u8::try_from(self.week_starts_on)
.map_err(|_| DbError::Corrupt("week_starts_on out of range".to_owned()))?,
display_timezone: self.display_timezone,
default_calendar: self.default_calendar,
},
updated_at,
))
}
}
pub struct PreferencesRepo<'a> {
db: &'a Database,
}
impl<'a> PreferencesRepo<'a> {
pub(crate) fn new(db: &'a Database) -> Self {
Self { db }
}
/// A user's preferences, or the defaults if they have never saved any.
///
/// Never `None`: "no row yet" and "the defaults" are the same thing to
/// every caller, and collapsing them here means no caller has to remember.
pub async fn load(&self, user_id: &UserId) -> Result<Preferences, DbError> {
let row: Option<PreferencesRow> =
sqlx::query_as("SELECT * FROM preferences WHERE user_id = ?")
.bind(user_id.as_str())
.fetch_optional(self.db.pool())
.await?;
match row {
Some(row) => Ok(row.into_preferences()?.0),
None => Ok(Preferences::default()),
}
}
/// When these were last written, if ever.
pub async fn updated_at(&self, user_id: &UserId) -> Result<Option<DateTime<Utc>>, DbError> {
let row: Option<PreferencesRow> =
sqlx::query_as("SELECT * FROM preferences WHERE user_id = ?")
.bind(user_id.as_str())
.fetch_optional(self.db.pool())
.await?;
row.map(|r| r.into_preferences().map(|(_, at)| at))
.transpose()
}
/// Writes a user's preferences.
///
/// The whole set at once, because that is what the client holds and a
/// partial update would need a way to say "leave this alone" that JSON
/// nulls cannot express unambiguously.
pub async fn save(&self, user_id: &UserId, preferences: &Preferences) -> Result<(), DbError> {
sqlx::query(
"INSERT INTO preferences
(user_id, theme, style, view, time_increment, week_starts_on,
display_timezone, default_calendar, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT (user_id) DO UPDATE SET
theme = excluded.theme,
style = excluded.style,
view = excluded.view,
time_increment = excluded.time_increment,
week_starts_on = excluded.week_starts_on,
display_timezone = excluded.display_timezone,
default_calendar = excluded.default_calendar,
updated_at = excluded.updated_at",
)
.bind(user_id.as_str())
.bind(&preferences.theme)
.bind(&preferences.style)
.bind(preferences.view.as_str())
.bind(i64::from(preferences.time_increment))
.bind(i64::from(preferences.week_starts_on))
.bind(preferences.display_timezone.as_deref())
.bind(preferences.default_calendar.as_deref())
.bind(stamp(Utc::now()))
.execute(self.db.pool())
.await?;
Ok(())
}
}
+172
View File
@@ -0,0 +1,172 @@
//! Sessions.
//!
//! The cookie carries a random secret. This table stores only its SHA-256, so
//! a leaked copy of the database cannot be used to impersonate anyone — v1
//! stored the token itself, which meant read access to the file was read
//! access to every account.
//!
//! Hashing is enough here, unlike for a password: the token is long and random,
//! so there is nothing to guess and no need for a slow KDF.
use super::{Database, DbError, UserId, parse_stamp, stamp};
use chrono::{DateTime, TimeDelta, Utc};
use sha2::{Digest, Sha256};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Session {
pub user_id: UserId,
pub created_at: DateTime<Utc>,
pub expires_at: DateTime<Utc>,
pub last_seen_at: DateTime<Utc>,
pub user_agent: Option<String>,
}
#[derive(sqlx::FromRow)]
struct SessionRow {
user_id: String,
created_at: String,
expires_at: String,
last_seen_at: String,
user_agent: Option<String>,
}
impl TryFrom<SessionRow> for Session {
type Error = DbError;
fn try_from(row: SessionRow) -> Result<Self, Self::Error> {
Ok(Self {
user_id: UserId(row.user_id),
created_at: parse_stamp(&row.created_at)?,
expires_at: parse_stamp(&row.expires_at)?,
last_seen_at: parse_stamp(&row.last_seen_at)?,
user_agent: row.user_agent,
})
}
}
/// The stored form of a session token.
///
/// The only way a token becomes a database key, so there is no path that
/// accidentally stores the raw value.
pub(crate) fn hash_token(token: &str) -> String {
let digest = Sha256::digest(token.as_bytes());
digest.iter().map(|byte| format!("{byte:02x}")).collect()
}
pub struct Sessions<'a> {
db: &'a Database,
}
impl<'a> Sessions<'a> {
pub(crate) fn new(db: &'a Database) -> Self {
Self { db }
}
/// Starts a session for a token the caller generated.
///
/// The token is not returned, because this never had it in a form worth
/// keeping — the caller owns it and puts it in the cookie.
pub async fn create(
&self,
user_id: &UserId,
token: &str,
lifetime: TimeDelta,
user_agent: Option<&str>,
) -> Result<Session, DbError> {
let now = Utc::now();
let expires = now + lifetime;
// RETURNING rather than reconstructing the row in Rust. Stored
// timestamps are truncated to microseconds, so a hand-built return
// value would carry sub-microsecond digits the database does not have
// and would not compare equal to the same session read back.
let row: SessionRow = sqlx::query_as(
"INSERT INTO sessions
(token_hash, user_id, created_at, expires_at, last_seen_at, user_agent)
VALUES (?, ?, ?, ?, ?, ?)
RETURNING *",
)
.bind(hash_token(token))
.bind(user_id.as_str())
.bind(stamp(now))
.bind(stamp(expires))
.bind(stamp(now))
.bind(user_agent)
.fetch_one(self.db.pool())
.await?;
row.try_into()
}
/// Looks a token up, touching `last_seen_at` if it is still valid.
///
/// An expired session reads as absent rather than as an error: for the
/// caller there is no difference between a session that ran out and one
/// that never existed, and treating them alike removes a branch where a
/// mistake would let an expired session through.
pub async fn touch(&self, token: &str) -> Result<Option<Session>, DbError> {
let now = Utc::now();
let row: Option<SessionRow> = sqlx::query_as(
"UPDATE sessions SET last_seen_at = ?
WHERE token_hash = ? AND expires_at > ?
RETURNING *",
)
.bind(stamp(now))
.bind(hash_token(token))
.bind(stamp(now))
.fetch_optional(self.db.pool())
.await?;
row.map(Session::try_from).transpose()
}
/// Reads a session without touching it.
pub async fn peek(&self, token: &str) -> Result<Option<Session>, DbError> {
let row: Option<SessionRow> =
sqlx::query_as("SELECT * FROM sessions WHERE token_hash = ? AND expires_at > ?")
.bind(hash_token(token))
.bind(stamp(Utc::now()))
.fetch_optional(self.db.pool())
.await?;
row.map(Session::try_from).transpose()
}
/// Ends one session — a logout.
pub async fn delete(&self, token: &str) -> Result<bool, DbError> {
let result = sqlx::query("DELETE FROM sessions WHERE token_hash = ?")
.bind(hash_token(token))
.execute(self.db.pool())
.await?;
Ok(result.rows_affected() > 0)
}
/// Ends every session for a user — a password change, or "sign out
/// everywhere".
pub async fn delete_for_user(&self, user_id: &UserId) -> Result<u64, DbError> {
let result = sqlx::query("DELETE FROM sessions WHERE user_id = ?")
.bind(user_id.as_str())
.execute(self.db.pool())
.await?;
Ok(result.rows_affected())
}
/// Removes expired rows. Expiry is enforced on lookup regardless; this is
/// housekeeping, not a security boundary.
pub async fn purge_expired(&self) -> Result<u64, DbError> {
let result = sqlx::query("DELETE FROM sessions WHERE expires_at <= ?")
.bind(stamp(Utc::now()))
.execute(self.db.pool())
.await?;
Ok(result.rows_affected())
}
/// Every live session for a user, newest first.
pub async fn list_for_user(&self, user_id: &UserId) -> Result<Vec<Session>, DbError> {
let rows: Vec<SessionRow> = sqlx::query_as(
"SELECT * FROM sessions WHERE user_id = ? AND expires_at > ?
ORDER BY created_at DESC",
)
.bind(user_id.as_str())
.bind(stamp(Utc::now()))
.fetch_all(self.db.pool())
.await?;
rows.into_iter().map(Session::try_from).collect()
}
}
+125
View File
@@ -0,0 +1,125 @@
//! Accounts.
//!
//! A user is a CalDAV identity: a username on a particular server. The same
//! username on two servers is two people as far as this is concerned, which is
//! what the unique constraint says.
use super::{Database, DbError, UserId, parse_stamp, stamp};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct User {
pub id: UserId,
pub username: String,
pub server_url: String,
pub display_name: Option<String>,
pub created_at: DateTime<Utc>,
pub last_login_at: Option<DateTime<Utc>>,
}
#[derive(sqlx::FromRow)]
struct UserRow {
id: String,
username: String,
server_url: String,
display_name: Option<String>,
created_at: String,
last_login_at: Option<String>,
}
impl TryFrom<UserRow> for User {
type Error = DbError;
fn try_from(row: UserRow) -> Result<Self, Self::Error> {
Ok(Self {
id: UserId(row.id),
username: row.username,
server_url: row.server_url,
display_name: row.display_name,
created_at: parse_stamp(&row.created_at)?,
last_login_at: row.last_login_at.as_deref().map(parse_stamp).transpose()?,
})
}
}
pub struct Users<'a> {
db: &'a Database,
}
impl<'a> Users<'a> {
pub(crate) fn new(db: &'a Database) -> Self {
Self { db }
}
/// Records a successful login, creating the account if this is the first.
///
/// One statement rather than a read followed by a conditional write: two
/// logins arriving together would otherwise race, and the loser would hit
/// the unique constraint.
pub async fn record_login(
&self,
username: &str,
server_url: &str,
display_name: Option<&str>,
) -> Result<User, DbError> {
let now = Utc::now();
let row: UserRow = sqlx::query_as(
"INSERT INTO users (id, username, server_url, display_name, created_at, last_login_at)
VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT (username, server_url) DO UPDATE SET
last_login_at = excluded.last_login_at,
-- Only overwrite the display name when a new one was offered.
display_name = COALESCE(excluded.display_name, users.display_name)
RETURNING *",
)
.bind(UserId::new().as_str())
.bind(username)
.bind(server_url)
.bind(display_name)
.bind(stamp(now))
.bind(stamp(now))
.fetch_one(self.db.pool())
.await?;
row.try_into()
}
pub async fn find(&self, id: &UserId) -> Result<Option<User>, DbError> {
let row: Option<UserRow> = sqlx::query_as("SELECT * FROM users WHERE id = ?")
.bind(id.as_str())
.fetch_optional(self.db.pool())
.await?;
row.map(User::try_from).transpose()
}
pub async fn find_by_login(
&self,
username: &str,
server_url: &str,
) -> Result<Option<User>, DbError> {
let row: Option<UserRow> =
sqlx::query_as("SELECT * FROM users WHERE username = ? AND server_url = ?")
.bind(username)
.bind(server_url)
.fetch_optional(self.db.pool())
.await?;
row.map(User::try_from).transpose()
}
pub async fn all(&self) -> Result<Vec<User>, DbError> {
let rows: Vec<UserRow> =
sqlx::query_as("SELECT * FROM users ORDER BY username, server_url")
.fetch_all(self.db.pool())
.await?;
rows.into_iter().map(User::try_from).collect()
}
/// Removes an account and, by cascade, everything belonging to it.
pub async fn delete(&self, id: &UserId) -> Result<bool, DbError> {
let result = sqlx::query("DELETE FROM users WHERE id = ?")
.bind(id.as_str())
.execute(self.db.pool())
.await?;
Ok(result.rows_affected() > 0)
}
}
+2
View File
@@ -1 +1,3 @@
//! Runway backend: a CalDAV proxy with sessions, preferences and ICS feeds.
pub mod db;
+793
View File
@@ -0,0 +1,793 @@
//! 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");
}