From 23c2f6fbc07f25a11826892d783bddcc93550d25 Mon Sep 17 00:00:00 2001 From: Owen Jacobson Date: Sun, 1 Jun 2025 13:57:02 -0400 Subject: Fix stray reference to a nonexistent file. I've replaced it with something more general, which will be applicable no matter how we restructure the routing. --- src/broadcast.rs | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/broadcast.rs b/src/broadcast.rs index 174016e..41a5e23 100644 --- a/src/broadcast.rs +++ b/src/broadcast.rs @@ -58,10 +58,9 @@ where #[allow(clippy::manual_ok_err)] future::ready(match r { Ok(event) => Some(event), - // Stop the stream here. This will disconnect SSE clients - // (see `routes.rs`), who will then resume from - // `Last-Event-ID`, allowing them to catch up by reading - // the skipped messages from the database. + // Stop the stream here. This will disconnect SSE clients (see the `/api/events` + // endpoint), who will then resume from `Last-Event-ID`, allowing them to catch up + // by reading the skipped messages from the database. // // See also: // -- cgit v1.2.3 From 5ed96f8e8b9d9f19ee249f5c73a5a21ef6bca09f Mon Sep 17 00:00:00 2001 From: Owen Jacobson Date: Tue, 17 Jun 2025 02:03:28 -0400 Subject: Reorganize and consolidate HTTP routes. HTTP routes are now defined in a single, unified module, pulling them out of the topical modules they were formerly part of. This is intended to improve the navigability of the codebase. Previously, finding the handler corresponding to a specific endpoint required prior familiarity, though in practice you could usually guess from topic area. Now, all routes are defined in `crate::routes`. Other than changing visibility, I've avoided making changes to the handlers at the ends of those routes. --- src/boot/mod.rs | 10 +++--- src/boot/routes/mod.rs | 10 +----- src/channel/mod.rs | 4 +-- src/channel/routes/mod.rs | 18 ++-------- src/cli.rs | 39 ++-------------------- src/event/mod.rs | 10 +++--- src/event/routes/mod.rs | 10 +----- src/invite/mod.rs | 8 ++--- src/invite/routes/mod.rs | 18 ++-------- src/lib.rs | 1 + src/message/mod.rs | 6 ++-- src/message/routes/mod.rs | 10 +----- src/routes.rs | 83 +++++++++++++++++++++++++++++++++++++++++++++++ src/setup/mod.rs | 4 +-- src/setup/routes/mod.rs | 10 +----- src/ui/mod.rs | 4 +-- src/ui/routes/mod.rs | 35 ++++---------------- src/user/mod.rs | 6 ++-- src/user/routes/mod.rs | 17 ++-------- 19 files changed, 127 insertions(+), 176 deletions(-) create mode 100644 src/routes.rs diff --git a/src/boot/mod.rs b/src/boot/mod.rs index 122bd53..2b74de1 100644 --- a/src/boot/mod.rs +++ b/src/boot/mod.rs @@ -1,11 +1,11 @@ -use crate::{channel::Channel, event::Sequence, message::Message, user::User}; -use serde::Serialize; use std::time::Duration; -pub mod app; -mod routes; +use serde::Serialize; -pub use self::routes::router; +use crate::{channel::Channel, event::Sequence, message::Message, user::User}; + +pub mod app; +pub mod routes; #[derive(serde::Serialize)] pub struct Snapshot { diff --git a/src/boot/routes/mod.rs b/src/boot/routes/mod.rs index 8fd99d3..60ad5d8 100644 --- a/src/boot/routes/mod.rs +++ b/src/boot/routes/mod.rs @@ -1,11 +1,3 @@ -use axum::{Router, routing::get}; - -use crate::app::App; - -mod get; +pub mod get; #[cfg(test)] mod test; - -pub fn router() -> Router { - Router::new().route("/api/boot", get(get::handler)) -} diff --git a/src/channel/mod.rs b/src/channel/mod.rs index d5ba828..feb00a9 100644 --- a/src/channel/mod.rs +++ b/src/channel/mod.rs @@ -3,8 +3,8 @@ pub mod event; mod history; mod id; pub mod repo; -mod routes; +pub mod routes; mod snapshot; mod validate; -pub use self::{event::Event, history::History, id::Id, routes::router, snapshot::Channel}; +pub use self::{event::Event, history::History, id::Id, snapshot::Channel}; diff --git a/src/channel/routes/mod.rs b/src/channel/routes/mod.rs index c917348..bd90721 100644 --- a/src/channel/routes/mod.rs +++ b/src/channel/routes/mod.rs @@ -1,19 +1,5 @@ -use axum::{ - Router, - routing::{delete, post}, -}; - -use crate::app::App; - -mod channel; -mod post; +pub mod channel; +pub mod post; #[cfg(test)] mod test; - -pub fn router() -> Router { - Router::new() - .route("/api/channels", post(post::handler)) - .route("/api/channels/{channel}", post(channel::post::handler)) - .route("/api/channels/{channel}", delete(channel::delete::handler)) -} diff --git a/src/cli.rs b/src/cli.rs index 7bfdbc0..28c2ec8 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -6,7 +6,6 @@ use std::{future, io}; use axum::{ - Router, http::header, middleware, response::{IntoResponse, Response}, @@ -15,7 +14,7 @@ use clap::{CommandFactory, Parser}; use sqlx::sqlite::SqlitePool; use tokio::net; -use crate::{app::App, boot, channel, clock, db, event, expire, invite, message, setup, ui, user}; +use crate::{app::App, clock, db, routes}; /// Command-line entry point for running the `pilcrow` server. /// @@ -82,7 +81,7 @@ impl Args { let pool = self.pool().await?; let app = App::from(pool); - let app = routers(&app) + let app = routes::routes(&app) .route_layer(middleware::from_fn(clock::middleware)) .route_layer(middleware::map_response(Self::server_info())) .with_state(app); @@ -123,40 +122,6 @@ impl Args { } } -fn routers(app: &App) -> Router { - [ - [ - // API endpoints that require setup to function - boot::router(), - channel::router(), - event::router(), - invite::router(), - user::router(), - message::router(), - ] - .into_iter() - .fold(Router::default(), Router::merge) - // Run expiry whenever someone accesses the API. This was previously a blanket middleware - // affecting the whole service, but loading the client makes a several requests before the - // client can completely load, each of which was triggering expiry. There is absolutely no - // upside to re-checking expiry tens of times back-to-back like that; the API is accessed - // more regularly and with less of a traffic rush. - // - // This should, probably, be moved to a background job at some point. - .route_layer(middleware::from_fn_with_state( - app.clone(), - expire::middleware, - )) - .route_layer(setup::Required(app.clone())), - // API endpoints that handle setup - setup::router(), - // The UI (handles setup state itself) - ui::router(app), - ] - .into_iter() - .fold(Router::default(), Router::merge) -} - fn started_msg(listener: &net::TcpListener) -> io::Result { let local_addr = listener.local_addr()?; Ok(format!("listening on http://{local_addr}/")) diff --git a/src/event/mod.rs b/src/event/mod.rs index 1f2ec42..ff30dc7 100644 --- a/src/event/mod.rs +++ b/src/event/mod.rs @@ -1,18 +1,18 @@ -use crate::{channel, message, user}; -use axum::response::sse; -use axum::response::sse::KeepAlive; use std::time::Duration; +use axum::response::sse::{self, KeepAlive}; + +use crate::{channel, message, user}; + pub mod app; mod broadcaster; mod extract; pub mod repo; -mod routes; +pub mod routes; mod sequence; pub use self::{ broadcaster::Broadcaster, - routes::router, sequence::{Instant, Sequence, Sequenced}, }; diff --git a/src/event/routes/mod.rs b/src/event/routes/mod.rs index 742d397..60ad5d8 100644 --- a/src/event/routes/mod.rs +++ b/src/event/routes/mod.rs @@ -1,11 +1,3 @@ -use axum::{Router, routing::get}; - -use crate::app::App; - -mod get; +pub mod get; #[cfg(test)] mod test; - -pub fn router() -> Router { - Router::new().route("/api/events", get(get::handler)) -} diff --git a/src/invite/mod.rs b/src/invite/mod.rs index 2d32fda..3932eea 100644 --- a/src/invite/mod.rs +++ b/src/invite/mod.rs @@ -1,11 +1,11 @@ +use crate::{clock::DateTime, normalize::nfc, user}; + pub mod app; mod id; mod repo; -mod routes; - -use crate::{clock::DateTime, normalize::nfc, user}; +pub mod routes; -pub use self::{id::Id, routes::router}; +pub use self::id::Id; #[derive(Debug, serde::Serialize)] pub struct Invite { diff --git a/src/invite/routes/mod.rs b/src/invite/routes/mod.rs index d83efc6..8747a4e 100644 --- a/src/invite/routes/mod.rs +++ b/src/invite/routes/mod.rs @@ -1,18 +1,4 @@ -use axum::{ - Router, - routing::{get, post}, -}; - -use crate::app::App; - -mod invite; -mod post; +pub mod invite; +pub mod post; #[cfg(test)] mod test; - -pub fn router() -> Router { - Router::new() - .route("/api/invite", post(post::handler)) - .route("/api/invite/{invite}", get(invite::get::handler)) - .route("/api/invite/{invite}", post(invite::post::handler)) -} diff --git a/src/lib.rs b/src/lib.rs index 4cce63b..d2fa390 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -17,6 +17,7 @@ mod invite; mod message; mod name; mod normalize; +mod routes; mod setup; #[cfg(test)] mod test; diff --git a/src/message/mod.rs b/src/message/mod.rs index c2687bc..fbaa4a3 100644 --- a/src/message/mod.rs +++ b/src/message/mod.rs @@ -4,9 +4,7 @@ pub mod event; mod history; mod id; pub mod repo; -mod routes; +pub mod routes; mod snapshot; -pub use self::{ - body::Body, event::Event, history::History, id::Id, routes::router, snapshot::Message, -}; +pub use self::{body::Body, event::Event, history::History, id::Id, snapshot::Message}; diff --git a/src/message/routes/mod.rs b/src/message/routes/mod.rs index 00b2b1a..e216a50 100644 --- a/src/message/routes/mod.rs +++ b/src/message/routes/mod.rs @@ -1,9 +1 @@ -use axum::{Router, routing::delete}; - -use crate::app::App; - -mod message; - -pub fn router() -> Router { - Router::new().route("/api/messages/{message}", delete(message::delete::handler)) -} +pub mod message; diff --git a/src/routes.rs b/src/routes.rs new file mode 100644 index 0000000..5bb5f91 --- /dev/null +++ b/src/routes.rs @@ -0,0 +1,83 @@ +use axum::{ + Router, middleware, + response::Redirect, + routing::{delete, get, post}, +}; + +use crate::{app::App, boot, channel, event, expire, invite, message, setup, ui, user}; + +pub fn routes(app: &App) -> Router { + // UI routes that can be accessed before the administrator completes setup. + let ui_bootstrap = Router::new() + .route("/{*path}", get(ui::routes::path::get::handler)) + .route("/setup", get(ui::routes::setup::get::handler)); + + // UI routes that require the administrator to complete setup first. + let ui_setup_required = Router::new() + .route("/", get(ui::routes::get::handler)) + .route("/ch/{channel}", get(ui::routes::ch::channel::get::handler)) + .route( + "/invite/{invite}", + get(ui::routes::invite::invite::get::handler), + ) + .route("/login", get(ui::routes::login::get::handler)) + .route("/me", get(ui::routes::me::get::handler)) + .route_layer(crate::setup::Required(app.clone()).with_fallback(Redirect::to("/setup"))); + + // API routes that can run before the administrator completes setup. + let api_bootstrap = Router::new().route("/api/setup", post(setup::routes::post::handler)); + + // API routes that require the administrator to complete setup first. + let api_setup_required = Router::new() + .route("/api/auth/login", post(user::routes::login::post::handler)) + .route( + "/api/auth/logout", + post(user::routes::logout::post::handler), + ) + .route("/api/boot", get(boot::routes::get::handler)) + .route("/api/channels", post(channel::routes::post::handler)) + .route( + "/api/channels/{channel}", + post(channel::routes::channel::post::handler), + ) + .route( + "/api/channels/{channel}", + delete(channel::routes::channel::delete::handler), + ) + .route("/api/events", get(event::routes::get::handler)) + .route("/api/invite", post(invite::routes::post::handler)) + .route( + "/api/invite/{invite}", + get(invite::routes::invite::get::handler), + ) + .route( + "/api/invite/{invite}", + post(invite::routes::invite::post::handler), + ) + .route( + "/api/messages/{message}", + delete(message::routes::message::delete::handler), + ) + .route("/api/password", post(user::routes::password::post::handler)) + // Run expiry whenever someone accesses the API. This was previously a blanket middleware + // affecting the whole service, but loading the client makes a several requests before the + // client can completely load, each of which was triggering expiry. There is absolutely no + // upside to re-checking expiry tens of times back-to-back like that; the API is accessed + // more regularly and with less of a traffic rush. + // + // This should, probably, be moved to a background job at some point. + .route_layer(middleware::from_fn_with_state( + app.clone(), + expire::middleware, + )) + .route_layer(setup::Required(app.clone())); + + [ + ui_bootstrap, + ui_setup_required, + api_bootstrap, + api_setup_required, + ] + .into_iter() + .fold(Router::default(), Router::merge) +} diff --git a/src/setup/mod.rs b/src/setup/mod.rs index a4b821c..e741a60 100644 --- a/src/setup/mod.rs +++ b/src/setup/mod.rs @@ -1,6 +1,6 @@ pub mod app; pub mod repo; mod required; -mod routes; +pub mod routes; -pub use self::{required::Required, routes::router}; +pub use self::required::Required; diff --git a/src/setup/routes/mod.rs b/src/setup/routes/mod.rs index 977a790..e94a249 100644 --- a/src/setup/routes/mod.rs +++ b/src/setup/routes/mod.rs @@ -1,11 +1,3 @@ -use axum::{Router, routing::post}; - -use crate::app::App; - -mod post; +pub mod post; #[cfg(test)] mod test; - -pub fn router() -> Router { - Router::new().route("/api/setup", post(post::handler)) -} diff --git a/src/ui/mod.rs b/src/ui/mod.rs index e834bba..eeaf27a 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -1,6 +1,4 @@ mod assets; mod error; mod mime; -mod routes; - -pub use self::routes::router; +pub mod routes; diff --git a/src/ui/routes/mod.rs b/src/ui/routes/mod.rs index dc94773..2390802 100644 --- a/src/ui/routes/mod.rs +++ b/src/ui/routes/mod.rs @@ -1,28 +1,7 @@ -use axum::{Router, response::Redirect, routing::get}; - -use crate::app::App; - -mod ch; -mod get; -mod invite; -mod login; -mod me; -mod path; -mod setup; - -pub fn router(app: &App) -> Router { - [ - Router::new() - .route("/{*path}", get(path::get::handler)) - .route("/setup", get(setup::get::handler)), - Router::new() - .route("/", get(get::handler)) - .route("/me", get(me::get::handler)) - .route("/login", get(login::get::handler)) - .route("/ch/{channel}", get(ch::channel::get::handler)) - .route("/invite/{invite}", get(invite::invite::get::handler)) - .route_layer(crate::setup::Required(app.clone()).with_fallback(Redirect::to("/setup"))), - ] - .into_iter() - .fold(Router::default(), Router::merge) -} +pub mod ch; +pub mod get; +pub mod invite; +pub mod login; +pub mod me; +pub mod path; +pub mod setup; diff --git a/src/user/mod.rs b/src/user/mod.rs index f4c66ab..7ea3d26 100644 --- a/src/user/mod.rs +++ b/src/user/mod.rs @@ -6,10 +6,8 @@ mod history; mod id; pub mod password; pub mod repo; -mod routes; +pub mod routes; mod snapshot; mod validate; -pub use self::{ - event::Event, history::History, id::Id, password::Password, routes::router, snapshot::User, -}; +pub use self::{event::Event, history::History, id::Id, password::Password, snapshot::User}; diff --git a/src/user/routes/mod.rs b/src/user/routes/mod.rs index ade96cb..f9bbed7 100644 --- a/src/user/routes/mod.rs +++ b/src/user/routes/mod.rs @@ -1,14 +1,3 @@ -use axum::{Router, routing::post}; - -use crate::app::App; - -mod login; -mod logout; -mod password; - -pub fn router() -> Router { - Router::new() - .route("/api/password", post(password::post::handler)) - .route("/api/auth/login", post(login::post::handler)) - .route("/api/auth/logout", post(logout::post::handler)) -} +pub mod login; +pub mod logout; +pub mod password; -- cgit v1.2.3 From 4e3d5ccac99b24934c972e088cd7eb02bb95df06 Mon Sep 17 00:00:00 2001 From: Owen Jacobson Date: Tue, 17 Jun 2025 02:11:45 -0400 Subject: Handlers are _named operations_, which can be exposed via routes. Each domain module that exposes handlers does so through a `handlers` child module, ideally as a top-level symbol that can be plugged directly into Axum's `MethodRouter`. Modules could make exceptions to this - kill the doctrinaire inside yourself, after all - but none of the API modules that actually exist need such exceptions, and consistency is useful. The related details of request types, URL types, response types, errors, &c &c are then organized into modules under `handlers`, along with their respective tests. --- src/boot/handlers/boot/mod.rs | 30 +++ src/boot/handlers/boot/test.rs | 133 ++++++++++ src/boot/handlers/mod.rs | 3 + src/boot/mod.rs | 2 +- src/boot/routes/get.rs | 27 -- src/boot/routes/mod.rs | 3 - src/boot/routes/test.rs | 134 ---------- src/channel/handlers/create/mod.rs | 67 +++++ src/channel/handlers/create/test.rs | 242 ++++++++++++++++++ src/channel/handlers/delete/mod.rs | 59 +++++ src/channel/handlers/delete/test.rs | 174 +++++++++++++ src/channel/handlers/mod.rs | 9 + src/channel/handlers/send/mod.rs | 61 +++++ src/channel/handlers/send/test.rs | 131 ++++++++++ src/channel/mod.rs | 2 +- src/channel/routes/channel/delete.rs | 56 ----- src/channel/routes/channel/mod.rs | 9 - src/channel/routes/channel/post.rs | 57 ----- src/channel/routes/channel/test/delete.rs | 177 ------------- src/channel/routes/channel/test/mod.rs | 2 - src/channel/routes/channel/test/post.rs | 131 ---------- src/channel/routes/mod.rs | 5 - src/channel/routes/post.rs | 64 ----- src/channel/routes/test.rs | 239 ------------------ src/event/handlers/mod.rs | 3 + src/event/handlers/stream/mod.rs | 85 +++++++ src/event/handlers/stream/test/channel.rs | 273 ++++++++++++++++++++ src/event/handlers/stream/test/invite.rs | 87 +++++++ src/event/handlers/stream/test/message.rs | 393 +++++++++++++++++++++++++++++ src/event/handlers/stream/test/mod.rs | 8 + src/event/handlers/stream/test/resume.rs | 227 +++++++++++++++++ src/event/handlers/stream/test/setup.rs | 47 ++++ src/event/handlers/stream/test/token.rs | 148 +++++++++++ src/event/mod.rs | 2 +- src/event/routes/get.rs | 82 ------- src/event/routes/mod.rs | 3 - src/event/routes/test/channel.rs | 276 --------------------- src/event/routes/test/invite.rs | 90 ------- src/event/routes/test/message.rs | 396 ------------------------------ src/event/routes/test/mod.rs | 6 - src/event/routes/test/resume.rs | 227 ----------------- src/event/routes/test/setup.rs | 50 ---- src/event/routes/test/token.rs | 151 ------------ src/invite/handlers/accept/mod.rs | 60 +++++ src/invite/handlers/accept/test.rs | 236 ++++++++++++++++++ src/invite/handlers/get/mod.rs | 41 ++++ src/invite/handlers/get/test.rs | 65 +++++ src/invite/handlers/issue/mod.rs | 22 ++ src/invite/handlers/issue/test.rs | 27 ++ src/invite/handlers/mod.rs | 9 + src/invite/mod.rs | 2 +- src/invite/routes/invite/get.rs | 38 --- src/invite/routes/invite/mod.rs | 6 - src/invite/routes/invite/post.rs | 57 ----- src/invite/routes/invite/test/get.rs | 65 ----- src/invite/routes/invite/test/mod.rs | 2 - src/invite/routes/invite/test/post.rs | 240 ------------------ src/invite/routes/mod.rs | 4 - src/invite/routes/post.rs | 19 -- src/invite/routes/test.rs | 28 --- src/message/handlers/delete/mod.rs | 55 +++++ src/message/handlers/delete/test.rs | 183 ++++++++++++++ src/message/handlers/mod.rs | 3 + src/message/mod.rs | 2 +- src/message/routes/message/mod.rs | 61 ----- src/message/routes/message/test.rs | 184 -------------- src/message/routes/mod.rs | 1 - src/routes.rs | 61 ++--- src/setup/handlers/mod.rs | 3 + src/setup/handlers/setup/mod.rs | 55 +++++ src/setup/handlers/setup/test.rs | 93 +++++++ src/setup/mod.rs | 2 +- src/setup/routes/mod.rs | 3 - src/setup/routes/post.rs | 52 ---- src/setup/routes/test.rs | 94 ------- src/ui/handlers/asset.rs | 7 + src/ui/handlers/channel.rs | 58 +++++ src/ui/handlers/index.rs | 30 +++ src/ui/handlers/invite.rs | 53 ++++ src/ui/handlers/login.rs | 8 + src/ui/handlers/me.rs | 30 +++ src/ui/handlers/mod.rs | 15 ++ src/ui/handlers/setup.rs | 41 ++++ src/ui/mod.rs | 2 +- src/ui/routes/ch/channel.rs | 60 ----- src/ui/routes/ch/mod.rs | 1 - src/ui/routes/get.rs | 30 --- src/ui/routes/invite/invite.rs | 55 ----- src/ui/routes/invite/mod.rs | 4 - src/ui/routes/login.rs | 10 - src/ui/routes/me.rs | 32 --- src/ui/routes/mod.rs | 7 - src/ui/routes/path.rs | 9 - src/ui/routes/setup.rs | 43 ---- src/user/handlers/login/mod.rs | 55 +++++ src/user/handlers/login/test.rs | 127 ++++++++++ src/user/handlers/logout/mod.rs | 53 ++++ src/user/handlers/logout/test.rs | 79 ++++++ src/user/handlers/mod.rs | 7 + src/user/handlers/password/mod.rs | 57 +++++ src/user/handlers/password/test.rs | 67 +++++ src/user/mod.rs | 2 +- src/user/routes/login/mod.rs | 4 - src/user/routes/login/post.rs | 52 ---- src/user/routes/login/test.rs | 128 ---------- src/user/routes/logout/mod.rs | 4 - src/user/routes/logout/post.rs | 50 ---- src/user/routes/logout/test.rs | 79 ------ src/user/routes/mod.rs | 3 - src/user/routes/password/mod.rs | 4 - src/user/routes/password/post.rs | 54 ---- src/user/routes/password/test.rs | 68 ----- 112 files changed, 3747 insertions(+), 3785 deletions(-) create mode 100644 src/boot/handlers/boot/mod.rs create mode 100644 src/boot/handlers/boot/test.rs create mode 100644 src/boot/handlers/mod.rs delete mode 100644 src/boot/routes/get.rs delete mode 100644 src/boot/routes/mod.rs delete mode 100644 src/boot/routes/test.rs create mode 100644 src/channel/handlers/create/mod.rs create mode 100644 src/channel/handlers/create/test.rs create mode 100644 src/channel/handlers/delete/mod.rs create mode 100644 src/channel/handlers/delete/test.rs create mode 100644 src/channel/handlers/mod.rs create mode 100644 src/channel/handlers/send/mod.rs create mode 100644 src/channel/handlers/send/test.rs delete mode 100644 src/channel/routes/channel/delete.rs delete mode 100644 src/channel/routes/channel/mod.rs delete mode 100644 src/channel/routes/channel/post.rs delete mode 100644 src/channel/routes/channel/test/delete.rs delete mode 100644 src/channel/routes/channel/test/mod.rs delete mode 100644 src/channel/routes/channel/test/post.rs delete mode 100644 src/channel/routes/mod.rs delete mode 100644 src/channel/routes/post.rs delete mode 100644 src/channel/routes/test.rs create mode 100644 src/event/handlers/mod.rs create mode 100644 src/event/handlers/stream/mod.rs create mode 100644 src/event/handlers/stream/test/channel.rs create mode 100644 src/event/handlers/stream/test/invite.rs create mode 100644 src/event/handlers/stream/test/message.rs create mode 100644 src/event/handlers/stream/test/mod.rs create mode 100644 src/event/handlers/stream/test/resume.rs create mode 100644 src/event/handlers/stream/test/setup.rs create mode 100644 src/event/handlers/stream/test/token.rs delete mode 100644 src/event/routes/get.rs delete mode 100644 src/event/routes/mod.rs delete mode 100644 src/event/routes/test/channel.rs delete mode 100644 src/event/routes/test/invite.rs delete mode 100644 src/event/routes/test/message.rs delete mode 100644 src/event/routes/test/mod.rs delete mode 100644 src/event/routes/test/resume.rs delete mode 100644 src/event/routes/test/setup.rs delete mode 100644 src/event/routes/test/token.rs create mode 100644 src/invite/handlers/accept/mod.rs create mode 100644 src/invite/handlers/accept/test.rs create mode 100644 src/invite/handlers/get/mod.rs create mode 100644 src/invite/handlers/get/test.rs create mode 100644 src/invite/handlers/issue/mod.rs create mode 100644 src/invite/handlers/issue/test.rs create mode 100644 src/invite/handlers/mod.rs delete mode 100644 src/invite/routes/invite/get.rs delete mode 100644 src/invite/routes/invite/mod.rs delete mode 100644 src/invite/routes/invite/post.rs delete mode 100644 src/invite/routes/invite/test/get.rs delete mode 100644 src/invite/routes/invite/test/mod.rs delete mode 100644 src/invite/routes/invite/test/post.rs delete mode 100644 src/invite/routes/mod.rs delete mode 100644 src/invite/routes/post.rs delete mode 100644 src/invite/routes/test.rs create mode 100644 src/message/handlers/delete/mod.rs create mode 100644 src/message/handlers/delete/test.rs create mode 100644 src/message/handlers/mod.rs delete mode 100644 src/message/routes/message/mod.rs delete mode 100644 src/message/routes/message/test.rs delete mode 100644 src/message/routes/mod.rs create mode 100644 src/setup/handlers/mod.rs create mode 100644 src/setup/handlers/setup/mod.rs create mode 100644 src/setup/handlers/setup/test.rs delete mode 100644 src/setup/routes/mod.rs delete mode 100644 src/setup/routes/post.rs delete mode 100644 src/setup/routes/test.rs create mode 100644 src/ui/handlers/asset.rs create mode 100644 src/ui/handlers/channel.rs create mode 100644 src/ui/handlers/index.rs create mode 100644 src/ui/handlers/invite.rs create mode 100644 src/ui/handlers/login.rs create mode 100644 src/ui/handlers/me.rs create mode 100644 src/ui/handlers/mod.rs create mode 100644 src/ui/handlers/setup.rs delete mode 100644 src/ui/routes/ch/channel.rs delete mode 100644 src/ui/routes/ch/mod.rs delete mode 100644 src/ui/routes/get.rs delete mode 100644 src/ui/routes/invite/invite.rs delete mode 100644 src/ui/routes/invite/mod.rs delete mode 100644 src/ui/routes/login.rs delete mode 100644 src/ui/routes/me.rs delete mode 100644 src/ui/routes/mod.rs delete mode 100644 src/ui/routes/path.rs delete mode 100644 src/ui/routes/setup.rs create mode 100644 src/user/handlers/login/mod.rs create mode 100644 src/user/handlers/login/test.rs create mode 100644 src/user/handlers/logout/mod.rs create mode 100644 src/user/handlers/logout/test.rs create mode 100644 src/user/handlers/mod.rs create mode 100644 src/user/handlers/password/mod.rs create mode 100644 src/user/handlers/password/test.rs delete mode 100644 src/user/routes/login/mod.rs delete mode 100644 src/user/routes/login/post.rs delete mode 100644 src/user/routes/login/test.rs delete mode 100644 src/user/routes/logout/mod.rs delete mode 100644 src/user/routes/logout/post.rs delete mode 100644 src/user/routes/logout/test.rs delete mode 100644 src/user/routes/mod.rs delete mode 100644 src/user/routes/password/mod.rs delete mode 100644 src/user/routes/password/post.rs delete mode 100644 src/user/routes/password/test.rs diff --git a/src/boot/handlers/boot/mod.rs b/src/boot/handlers/boot/mod.rs new file mode 100644 index 0000000..010f57b --- /dev/null +++ b/src/boot/handlers/boot/mod.rs @@ -0,0 +1,30 @@ +use axum::{ + extract::{Json, State}, + response::{self, IntoResponse}, +}; + +use crate::{app::App, boot::Snapshot, error::Internal, token::extract::Identity, user::User}; + +#[cfg(test)] +mod test; + +pub async fn handler(State(app): State, identity: Identity) -> Result { + let snapshot = app.boot().snapshot().await?; + Ok(Response { + user: identity.user, + snapshot, + }) +} + +#[derive(serde::Serialize)] +pub struct Response { + pub user: User, + #[serde(flatten)] + pub snapshot: Snapshot, +} + +impl IntoResponse for Response { + fn into_response(self) -> response::Response { + Json(self).into_response() + } +} diff --git a/src/boot/handlers/boot/test.rs b/src/boot/handlers/boot/test.rs new file mode 100644 index 0000000..0a7622b --- /dev/null +++ b/src/boot/handlers/boot/test.rs @@ -0,0 +1,133 @@ +use axum::extract::State; + +use crate::test::fixtures; + +#[tokio::test] +async fn returns_identity() { + let app = fixtures::scratch_app().await; + + let viewer = fixtures::identity::fictitious(); + let response = super::handler(State(app), viewer.clone()) + .await + .expect("boot always succeeds"); + + assert_eq!(viewer.user, response.user); +} + +#[tokio::test] +async fn includes_logins() { + let app = fixtures::scratch_app().await; + let spectator = fixtures::user::create(&app, &fixtures::now()).await; + + let viewer = fixtures::identity::fictitious(); + let response = super::handler(State(app), viewer) + .await + .expect("boot always succeeds"); + + assert!(response.snapshot.users.contains(&spectator)); +} + +#[tokio::test] +async fn includes_channels() { + let app = fixtures::scratch_app().await; + let channel = fixtures::channel::create(&app, &fixtures::now()).await; + + let viewer = fixtures::identity::fictitious(); + let response = super::handler(State(app), viewer) + .await + .expect("boot always succeeds"); + + assert!(response.snapshot.channels.contains(&channel)); +} + +#[tokio::test] +async fn includes_messages() { + let app = fixtures::scratch_app().await; + let sender = fixtures::user::create(&app, &fixtures::now()).await; + let channel = fixtures::channel::create(&app, &fixtures::now()).await; + let message = fixtures::message::send(&app, &channel, &sender, &fixtures::now()).await; + + let viewer = fixtures::identity::fictitious(); + let response = super::handler(State(app), viewer) + .await + .expect("boot always succeeds"); + + assert!(response.snapshot.messages.contains(&message)); +} + +#[tokio::test] +async fn excludes_expired_messages() { + let app = fixtures::scratch_app().await; + let sender = fixtures::user::create(&app, &fixtures::ancient()).await; + let channel = fixtures::channel::create(&app, &fixtures::ancient()).await; + let expired_message = + fixtures::message::send(&app, &channel, &sender, &fixtures::ancient()).await; + + app.messages() + .expire(&fixtures::now()) + .await + .expect("expiry never fails"); + + let viewer = fixtures::identity::fictitious(); + let response = super::handler(State(app), viewer) + .await + .expect("boot always succeeds"); + + assert!(!response.snapshot.messages.contains(&expired_message)); +} + +#[tokio::test] +async fn excludes_deleted_messages() { + let app = fixtures::scratch_app().await; + let sender = fixtures::user::create(&app, &fixtures::now()).await; + let channel = fixtures::channel::create(&app, &fixtures::now()).await; + let deleted_message = fixtures::message::send(&app, &channel, &sender, &fixtures::now()).await; + + app.messages() + .delete(&sender, &deleted_message.id, &fixtures::now()) + .await + .expect("deleting valid message succeeds"); + + let viewer = fixtures::identity::fictitious(); + let response = super::handler(State(app), viewer) + .await + .expect("boot always succeeds"); + + assert!(!response.snapshot.messages.contains(&deleted_message)); +} + +#[tokio::test] +async fn excludes_expired_channels() { + let app = fixtures::scratch_app().await; + let expired_channel = fixtures::channel::create(&app, &fixtures::ancient()).await; + + app.channels() + .expire(&fixtures::now()) + .await + .expect("expiry never fails"); + + let viewer = fixtures::identity::fictitious(); + let response = super::handler(State(app), viewer) + .await + .expect("boot always succeeds"); + + assert!(!response.snapshot.channels.contains(&expired_channel)); +} + +#[tokio::test] +async fn excludes_deleted_channels() { + let app = fixtures::scratch_app().await; + let deleted_channel = fixtures::channel::create(&app, &fixtures::now()).await; + + app.channels() + .delete(&deleted_channel.id, &fixtures::now()) + .await + .expect("deleting a valid channel succeeds"); + + let viewer = fixtures::identity::fictitious(); + let response = super::handler(State(app), viewer) + .await + .expect("boot always succeeds"); + + assert!(!response.snapshot.channels.contains(&deleted_channel)); +} diff --git a/src/boot/handlers/mod.rs b/src/boot/handlers/mod.rs new file mode 100644 index 0000000..194c4a9 --- /dev/null +++ b/src/boot/handlers/mod.rs @@ -0,0 +1,3 @@ +mod boot; + +pub use boot::handler as boot; diff --git a/src/boot/mod.rs b/src/boot/mod.rs index 2b74de1..48da4f0 100644 --- a/src/boot/mod.rs +++ b/src/boot/mod.rs @@ -5,7 +5,7 @@ use serde::Serialize; use crate::{channel::Channel, event::Sequence, message::Message, user::User}; pub mod app; -pub mod routes; +pub mod handlers; #[derive(serde::Serialize)] pub struct Snapshot { diff --git a/src/boot/routes/get.rs b/src/boot/routes/get.rs deleted file mode 100644 index 4873b7a..0000000 --- a/src/boot/routes/get.rs +++ /dev/null @@ -1,27 +0,0 @@ -use axum::{ - extract::{Json, State}, - response::{self, IntoResponse}, -}; - -use crate::{app::App, boot::Snapshot, error::Internal, token::extract::Identity, user::User}; - -pub async fn handler(State(app): State, identity: Identity) -> Result { - let snapshot = app.boot().snapshot().await?; - Ok(Response { - user: identity.user, - snapshot, - }) -} - -#[derive(serde::Serialize)] -pub struct Response { - pub user: User, - #[serde(flatten)] - pub snapshot: Snapshot, -} - -impl IntoResponse for Response { - fn into_response(self) -> response::Response { - Json(self).into_response() - } -} diff --git a/src/boot/routes/mod.rs b/src/boot/routes/mod.rs deleted file mode 100644 index 60ad5d8..0000000 --- a/src/boot/routes/mod.rs +++ /dev/null @@ -1,3 +0,0 @@ -pub mod get; -#[cfg(test)] -mod test; diff --git a/src/boot/routes/test.rs b/src/boot/routes/test.rs deleted file mode 100644 index 55802fe..0000000 --- a/src/boot/routes/test.rs +++ /dev/null @@ -1,134 +0,0 @@ -use axum::extract::State; - -use super::get; -use crate::test::fixtures; - -#[tokio::test] -async fn returns_identity() { - let app = fixtures::scratch_app().await; - - let viewer = fixtures::identity::fictitious(); - let response = get::handler(State(app), viewer.clone()) - .await - .expect("boot always succeeds"); - - assert_eq!(viewer.user, response.user); -} - -#[tokio::test] -async fn includes_logins() { - let app = fixtures::scratch_app().await; - let spectator = fixtures::user::create(&app, &fixtures::now()).await; - - let viewer = fixtures::identity::fictitious(); - let response = get::handler(State(app), viewer) - .await - .expect("boot always succeeds"); - - assert!(response.snapshot.users.contains(&spectator)); -} - -#[tokio::test] -async fn includes_channels() { - let app = fixtures::scratch_app().await; - let channel = fixtures::channel::create(&app, &fixtures::now()).await; - - let viewer = fixtures::identity::fictitious(); - let response = get::handler(State(app), viewer) - .await - .expect("boot always succeeds"); - - assert!(response.snapshot.channels.contains(&channel)); -} - -#[tokio::test] -async fn includes_messages() { - let app = fixtures::scratch_app().await; - let sender = fixtures::user::create(&app, &fixtures::now()).await; - let channel = fixtures::channel::create(&app, &fixtures::now()).await; - let message = fixtures::message::send(&app, &channel, &sender, &fixtures::now()).await; - - let viewer = fixtures::identity::fictitious(); - let response = get::handler(State(app), viewer) - .await - .expect("boot always succeeds"); - - assert!(response.snapshot.messages.contains(&message)); -} - -#[tokio::test] -async fn excludes_expired_messages() { - let app = fixtures::scratch_app().await; - let sender = fixtures::user::create(&app, &fixtures::ancient()).await; - let channel = fixtures::channel::create(&app, &fixtures::ancient()).await; - let expired_message = - fixtures::message::send(&app, &channel, &sender, &fixtures::ancient()).await; - - app.messages() - .expire(&fixtures::now()) - .await - .expect("expiry never fails"); - - let viewer = fixtures::identity::fictitious(); - let response = get::handler(State(app), viewer) - .await - .expect("boot always succeeds"); - - assert!(!response.snapshot.messages.contains(&expired_message)); -} - -#[tokio::test] -async fn excludes_deleted_messages() { - let app = fixtures::scratch_app().await; - let sender = fixtures::user::create(&app, &fixtures::now()).await; - let channel = fixtures::channel::create(&app, &fixtures::now()).await; - let deleted_message = fixtures::message::send(&app, &channel, &sender, &fixtures::now()).await; - - app.messages() - .delete(&sender, &deleted_message.id, &fixtures::now()) - .await - .expect("deleting valid message succeeds"); - - let viewer = fixtures::identity::fictitious(); - let response = get::handler(State(app), viewer) - .await - .expect("boot always succeeds"); - - assert!(!response.snapshot.messages.contains(&deleted_message)); -} - -#[tokio::test] -async fn excludes_expired_channels() { - let app = fixtures::scratch_app().await; - let expired_channel = fixtures::channel::create(&app, &fixtures::ancient()).await; - - app.channels() - .expire(&fixtures::now()) - .await - .expect("expiry never fails"); - - let viewer = fixtures::identity::fictitious(); - let response = get::handler(State(app), viewer) - .await - .expect("boot always succeeds"); - - assert!(!response.snapshot.channels.contains(&expired_channel)); -} - -#[tokio::test] -async fn excludes_deleted_channels() { - let app = fixtures::scratch_app().await; - let deleted_channel = fixtures::channel::create(&app, &fixtures::now()).await; - - app.channels() - .delete(&deleted_channel.id, &fixtures::now()) - .await - .expect("deleting a valid channel succeeds"); - - let viewer = fixtures::identity::fictitious(); - let response = get::handler(State(app), viewer) - .await - .expect("boot always succeeds"); - - assert!(!response.snapshot.channels.contains(&deleted_channel)); -} diff --git a/src/channel/handlers/create/mod.rs b/src/channel/handlers/create/mod.rs new file mode 100644 index 0000000..2c860fc --- /dev/null +++ b/src/channel/handlers/create/mod.rs @@ -0,0 +1,67 @@ +use axum::{ + extract::{Json, State}, + http::StatusCode, + response::{self, IntoResponse}, +}; + +use crate::{ + app::App, + channel::{Channel, app}, + clock::RequestedAt, + error::Internal, + name::Name, + token::extract::Identity, +}; + +#[cfg(test)] +mod test; + +pub async fn handler( + State(app): State, + _: Identity, // requires auth, but doesn't actually care who you are + RequestedAt(created_at): RequestedAt, + Json(request): Json, +) -> Result { + let channel = app + .channels() + .create(&request.name, &created_at) + .await + .map_err(Error)?; + + Ok(Response(channel)) +} + +#[derive(serde::Deserialize)] +pub struct Request { + pub name: Name, +} + +#[derive(Debug)] +pub struct Response(pub Channel); + +impl IntoResponse for Response { + fn into_response(self) -> response::Response { + let Self(channel) = self; + (StatusCode::ACCEPTED, Json(channel)).into_response() + } +} + +#[derive(Debug)] +pub struct Error(pub app::CreateError); + +impl IntoResponse for Error { + fn into_response(self) -> response::Response { + let Self(error) = self; + match error { + app::CreateError::DuplicateName(_) => { + (StatusCode::CONFLICT, error.to_string()).into_response() + } + app::CreateError::InvalidName(_) => { + (StatusCode::BAD_REQUEST, error.to_string()).into_response() + } + app::CreateError::Name(_) | app::CreateError::Database(_) => { + Internal::from(error).into_response() + } + } + } +} diff --git a/src/channel/handlers/create/test.rs b/src/channel/handlers/create/test.rs new file mode 100644 index 0000000..3c770cf --- /dev/null +++ b/src/channel/handlers/create/test.rs @@ -0,0 +1,242 @@ +use std::future; + +use axum::extract::{Json, State}; +use futures::stream::StreamExt as _; + +use crate::{ + channel::app, + name::Name, + test::fixtures::{self, future::Expect as _}, +}; + +#[tokio::test] +async fn new_channel() { + // Set up the environment + + let app = fixtures::scratch_app().await; + let creator = fixtures::identity::create(&app, &fixtures::now()).await; + let resume_point = fixtures::boot::resume_point(&app).await; + + // Call the endpoint + + let name = fixtures::channel::propose(); + let request = super::Request { name: name.clone() }; + let super::Response(response) = + super::handler(State(app.clone()), creator, fixtures::now(), Json(request)) + .await + .expect("creating a channel in an empty app succeeds"); + + // Verify the structure of the response + + assert_eq!(name, response.name); + + // Verify the semantics + + let snapshot = app.boot().snapshot().await.expect("boot always succeeds"); + assert!(snapshot.channels.iter().any(|channel| channel == &response)); + + let channel = app + .channels() + .get(&response.id) + .await + .expect("the newly-created channel exists"); + assert_eq!(response, channel); + + let mut events = app + .events() + .subscribe(resume_point) + .await + .expect("subscribing never fails") + .filter_map(fixtures::event::channel) + .filter_map(fixtures::event::channel::created) + .filter(|event| future::ready(event.channel == response)); + + let event = events.next().expect_some("creation event published").await; + + assert_eq!(event.channel, response); +} + +#[tokio::test] +async fn duplicate_name() { + // Set up the environment + + let app = fixtures::scratch_app().await; + let creator = fixtures::identity::create(&app, &fixtures::now()).await; + let channel = fixtures::channel::create(&app, &fixtures::now()).await; + + // Call the endpoint + + let request = super::Request { + name: channel.name.clone(), + }; + let super::Error(error) = + super::handler(State(app.clone()), creator, fixtures::now(), Json(request)) + .await + .expect_err("duplicate channel name should fail the request"); + + // Verify the structure of the response + + assert!(matches!( + error, + app::CreateError::DuplicateName(name) if channel.name == name + )); +} + +#[tokio::test] +async fn conflicting_canonical_name() { + // Set up the environment + + let app = fixtures::scratch_app().await; + let creator = fixtures::identity::create(&app, &fixtures::now()).await; + + let existing_name = Name::from("rijksmuseum"); + app.channels() + .create(&existing_name, &fixtures::now()) + .await + .expect("creating a channel in an empty environment succeeds"); + + let conflicting_name = Name::from("r\u{0133}ksmuseum"); + + // Call the endpoint + + let request = super::Request { + name: conflicting_name.clone(), + }; + let super::Error(error) = + super::handler(State(app.clone()), creator, fixtures::now(), Json(request)) + .await + .expect_err("duplicate channel name should fail the request"); + + // Verify the structure of the response + + assert!(matches!( + error, + app::CreateError::DuplicateName(name) if conflicting_name == name + )); +} + +#[tokio::test] +async fn invalid_name() { + // Set up the environment + + let app = fixtures::scratch_app().await; + let creator = fixtures::identity::create(&app, &fixtures::now()).await; + + // Call the endpoint + + let name = fixtures::channel::propose_invalid_name(); + let request = super::Request { name: name.clone() }; + let super::Error(error) = crate::channel::handlers::create::handler( + State(app.clone()), + creator, + fixtures::now(), + Json(request), + ) + .await + .expect_err("invalid channel name should fail the request"); + + // Verify the structure of the response + + assert!(matches!( + error, + app::CreateError::InvalidName(error_name) if name == error_name + )); +} + +#[tokio::test] +async fn name_reusable_after_delete() { + // Set up the environment + + let app = fixtures::scratch_app().await; + let creator = fixtures::identity::create(&app, &fixtures::now()).await; + let name = fixtures::channel::propose(); + + // Call the endpoint (first time) + + let request = super::Request { name: name.clone() }; + let super::Response(response) = super::handler( + State(app.clone()), + creator.clone(), + fixtures::now(), + Json(request), + ) + .await + .expect("new channel in an empty app"); + + // Delete the channel + + app.channels() + .delete(&response.id, &fixtures::now()) + .await + .expect("deleting a newly-created channel succeeds"); + + // Call the endpoint (second time) + + let request = super::Request { name: name.clone() }; + let super::Response(response) = + super::handler(State(app.clone()), creator, fixtures::now(), Json(request)) + .await + .expect("creation succeeds after original channel deleted"); + + // Verify the structure of the response + + assert_eq!(name, response.name); + + // Verify the semantics + + let channel = app + .channels() + .get(&response.id) + .await + .expect("the newly-created channel exists"); + assert_eq!(response, channel); +} + +#[tokio::test] +async fn name_reusable_after_expiry() { + // Set up the environment + + let app = fixtures::scratch_app().await; + let creator = fixtures::identity::create(&app, &fixtures::ancient()).await; + let name = fixtures::channel::propose(); + + // Call the endpoint (first time) + + let request = super::Request { name: name.clone() }; + let super::Response(_) = super::handler( + State(app.clone()), + creator.clone(), + fixtures::ancient(), + Json(request), + ) + .await + .expect("new channel in an empty app"); + + // Delete the channel + + app.channels() + .expire(&fixtures::now()) + .await + .expect("expiry always succeeds"); + + // Call the endpoint (second time) + + let request = super::Request { name: name.clone() }; + let super::Response(response) = + super::handler(State(app.clone()), creator, fixtures::now(), Json(request)) + .await + .expect("creation succeeds after original channel expired"); + + // Verify the structure of the response + + assert_eq!(name, response.name); + + // Verify the semantics + + let channel = app + .channels() + .get(&response.id) + .await + .expect("the newly-created channel exists"); + assert_eq!(response, channel); +} diff --git a/src/channel/handlers/delete/mod.rs b/src/channel/handlers/delete/mod.rs new file mode 100644 index 0000000..b986bec --- /dev/null +++ b/src/channel/handlers/delete/mod.rs @@ -0,0 +1,59 @@ +use axum::{ + extract::{Json, Path, State}, + http::StatusCode, + response::{self, IntoResponse}, +}; + +use crate::{ + app::App, + channel::{self, app, handlers::PathInfo}, + clock::RequestedAt, + error::{Internal, NotFound}, + token::extract::Identity, +}; + +#[cfg(test)] +mod test; + +pub async fn handler( + State(app): State, + Path(channel): Path, + RequestedAt(deleted_at): RequestedAt, + _: Identity, +) -> Result { + app.channels().delete(&channel, &deleted_at).await?; + + Ok(Response { id: channel }) +} + +#[derive(Debug, serde::Serialize)] +pub struct Response { + pub id: channel::Id, +} + +impl IntoResponse for Response { + fn into_response(self) -> response::Response { + (StatusCode::ACCEPTED, Json(self)).into_response() + } +} + +#[derive(Debug, thiserror::Error)] +#[error(transparent)] +pub struct Error(#[from] pub app::DeleteError); + +impl IntoResponse for Error { + fn into_response(self) -> response::Response { + let Self(error) = self; + match error { + app::DeleteError::NotFound(_) | app::DeleteError::Deleted(_) => { + NotFound(error).into_response() + } + app::DeleteError::NotEmpty(_) => { + (StatusCode::CONFLICT, error.to_string()).into_response() + } + app::DeleteError::Name(_) | app::DeleteError::Database(_) => { + Internal::from(error).into_response() + } + } + } +} diff --git a/src/channel/handlers/delete/test.rs b/src/channel/handlers/delete/test.rs new file mode 100644 index 0000000..b1e42ea --- /dev/null +++ b/src/channel/handlers/delete/test.rs @@ -0,0 +1,174 @@ +use axum::extract::{Path, State}; + +use crate::{channel::app, test::fixtures}; + +#[tokio::test] +pub async fn valid_channel() { + // Set up the environment + + let app = fixtures::scratch_app().await; + let channel = fixtures::channel::create(&app, &fixtures::now()).await; + + // Send the request + + let deleter = fixtures::identity::create(&app, &fixtures::now()).await; + let response = super::handler( + State(app.clone()), + Path(channel.id.clone()), + fixtures::now(), + deleter, + ) + .await + .expect("deleting a valid channel succeeds"); + + // Verify the response + + assert_eq!(channel.id, response.id); + + // Verify the semantics + + let snapshot = app.boot().snapshot().await.expect("boot always succeeds"); + assert!(!snapshot.channels.contains(&channel)); +} + +#[tokio::test] +pub async fn invalid_channel_id() { + // Set up the environment + + let app = fixtures::scratch_app().await; + + // Send the request + + let deleter = fixtures::identity::create(&app, &fixtures::now()).await; + let channel = fixtures::channel::fictitious(); + let super::Error(error) = super::handler( + State(app.clone()), + Path(channel.clone()), + fixtures::now(), + deleter, + ) + .await + .expect_err("deleting a nonexistent channel fails"); + + // Verify the response + + assert!(matches!(error, app::DeleteError::NotFound(id) if id == channel)); +} + +#[tokio::test] +pub async fn channel_deleted() { + // Set up the environment + + let app = fixtures::scratch_app().await; + let channel = fixtures::channel::create(&app, &fixtures::now()).await; + + app.channels() + .delete(&channel.id, &fixtures::now()) + .await + .expect("deleting a recently-sent channel succeeds"); + + // Send the request + + let deleter = fixtures::identity::create(&app, &fixtures::now()).await; + let super::Error(error) = super::handler( + State(app.clone()), + Path(channel.id.clone()), + fixtures::now(), + deleter, + ) + .await + .expect_err("deleting a deleted channel fails"); + + // Verify the response + + assert!(matches!(error, app::DeleteError::Deleted(id) if id == channel.id)); +} + +#[tokio::test] +pub async fn channel_expired() { + // Set up the environment + + let app = fixtures::scratch_app().await; + let channel = fixtures::channel::create(&app, &fixtures::ancient()).await; + + app.channels() + .expire(&fixtures::now()) + .await + .expect("expiring channels always succeeds"); + + // Send the request + + let deleter = fixtures::identity::create(&app, &fixtures::now()).await; + let super::Error(error) = super::handler( + State(app.clone()), + Path(channel.id.clone()), + fixtures::now(), + deleter, + ) + .await + .expect_err("deleting an expired channel fails"); + + // Verify the response + + assert!(matches!(error, app::DeleteError::Deleted(id) if id == channel.id)); +} + +#[tokio::test] +pub async fn channel_purged() { + // Set up the environment + + let app = fixtures::scratch_app().await; + let channel = fixtures::channel::create(&app, &fixtures::ancient()).await; + + app.channels() + .expire(&fixtures::old()) + .await + .expect("expiring channels always succeeds"); + + app.channels() + .purge(&fixtures::now()) + .await + .expect("purging channels always succeeds"); + + // Send the request + + let deleter = fixtures::identity::create(&app, &fixtures::now()).await; + let super::Error(error) = super::handler( + State(app.clone()), + Path(channel.id.clone()), + fixtures::now(), + deleter, + ) + .await + .expect_err("deleting a purged channel fails"); + + // Verify the response + + assert!(matches!(error, app::DeleteError::NotFound(id) if id == channel.id)); +} + +#[tokio::test] +pub async fn channel_not_empty() { + // Set up the environment + + let app = fixtures::scratch_app().await; + let channel = fixtures::channel::create(&app, &fixtures::now()).await; + let sender = fixtures::user::create(&app, &fixtures::now()).await; + fixtures::message::send(&app, &channel, &sender, &fixtures::now()).await; + + // Send the request + + let deleter = fixtures::identity::create(&app, &fixtures::now()).await; + let super::Error(error) = super::handler( + State(app.clone()), + Path(channel.id.clone()), + fixtures::now(), + deleter, + ) + .await + .expect_err("deleting a channel with messages fails"); + + // Verify the response + + assert!(matches!(error, app::DeleteError::NotEmpty(id) if id == channel.id)); +} diff --git a/src/channel/handlers/mod.rs b/src/channel/handlers/mod.rs new file mode 100644 index 0000000..f2ffd0d --- /dev/null +++ b/src/channel/handlers/mod.rs @@ -0,0 +1,9 @@ +mod create; +mod delete; +mod send; + +pub use create::handler as create; +pub use delete::handler as delete; +pub use send::handler as send; + +type PathInfo = crate::channel::Id; diff --git a/src/channel/handlers/send/mod.rs b/src/channel/handlers/send/mod.rs new file mode 100644 index 0000000..aa241e2 --- /dev/null +++ b/src/channel/handlers/send/mod.rs @@ -0,0 +1,61 @@ +use axum::{ + extract::{Json, Path, State}, + http::StatusCode, + response::{self, IntoResponse}, +}; + +use crate::channel::handlers::PathInfo; +use crate::{ + app::App, + clock::RequestedAt, + error::{Internal, NotFound}, + message::{Body, Message, app::SendError}, + token::extract::Identity, +}; + +#[cfg(test)] +mod test; + +pub async fn handler( + State(app): State, + Path(channel): Path, + RequestedAt(sent_at): RequestedAt, + identity: Identity, + Json(request): Json, +) -> Result { + let message = app + .messages() + .send(&channel, &identity.user, &sent_at, &request.body) + .await?; + + Ok(Response(message)) +} + +#[derive(serde::Deserialize)] +pub struct Request { + pub body: Body, +} + +#[derive(Debug)] +pub struct Response(pub Message); + +impl IntoResponse for Response { + fn into_response(self) -> response::Response { + let Self(message) = self; + (StatusCode::ACCEPTED, Json(message)).into_response() + } +} + +#[derive(Debug, thiserror::Error)] +#[error(transparent)] +pub struct Error(#[from] pub SendError); + +impl IntoResponse for Error { + fn into_response(self) -> response::Response { + let Self(error) = self; + match error { + SendError::ChannelNotFound(_) => NotFound(error).into_response(), + SendError::Name(_) | SendError::Database(_) => Internal::from(error).into_response(), + } + } +} diff --git a/src/channel/handlers/send/test.rs b/src/channel/handlers/send/test.rs new file mode 100644 index 0000000..f43f901 --- /dev/null +++ b/src/channel/handlers/send/test.rs @@ -0,0 +1,131 @@ +use axum::extract::{Json, Path, State}; +use futures::stream::{self, StreamExt as _}; + +use crate::{ + channel, + event::Sequenced, + message::app::SendError, + test::fixtures::{self, future::Expect as _}, +}; + +#[tokio::test] +async fn messages_in_order() { + // Set up the environment + + let app = fixtures::scratch_app().await; + let sender = fixtures::identity::create(&app, &fixtures::now()).await; + let channel = fixtures::channel::create(&app, &fixtures::now()).await; + let resume_point = fixtures::boot::resume_point(&app).await; + + // Call the endpoint (twice) + + let requests = vec![ + (fixtures::now(), fixtures::message::propose()), + (fixtures::now(), fixtures::message::propose()), + ]; + + for (sent_at, body) in &requests { + let request = super::Request { body: body.clone() }; + + let _ = super::handler( + State(app.clone()), + Path(channel.id.clone()), + sent_at.clone(), + sender.clone(), + Json(request), + ) + .await + .expect("sending to a valid channel succeeds"); + } + + // Verify the semantics + + let mut events = app + .events() + .subscribe(resume_point) + .await + .expect("subscribing to a valid channel succeeds") + .filter_map(fixtures::event::message) + .filter_map(fixtures::event::message::sent) + .zip(stream::iter(requests)); + + while let Some((event, (sent_at, body))) = events + .next() + .expect_ready("an event should be ready for each message") + .await + { + assert_eq!(*sent_at, event.at()); + assert_eq!(sender.user.id, event.message.sender); + assert_eq!(body, event.message.body); + } +} + +#[tokio::test] +async fn nonexistent_channel() { + // Set up the environment + + let app = fixtures::scratch_app().await; + let sender = fixtures::identity::create(&app, &fixtures::now()).await; + + // Call the endpoint + + let sent_at = fixtures::now(); + let channel = channel::Id::generate(); + let request = super::Request { + body: fixtures::message::propose(), + }; + let super::Error(error) = super::handler( + State(app), + Path(channel.clone()), + sent_at, + sender, + Json(request), + ) + .await + .expect_err("sending to a nonexistent channel fails"); + + // Verify the structure of the response + + assert!(matches!( + error, + SendError::ChannelNotFound(error_channel) if channel == error_channel + )); +} + +#[tokio::test] +async fn deleted_channel() { + // Set up the environment + + let app = fixtures::scratch_app().await; + let sender = fixtures::identity::create(&app, &fixtures::now()).await; + let channel = fixtures::channel::create(&app, &fixtures::now()).await; + + app.channels() + .delete(&channel.id, &fixtures::now()) + .await + .expect("deleting a new channel succeeds"); + + // Call the endpoint + + let sent_at = fixtures::now(); + let channel = channel::Id::generate(); + let request = super::Request { + body: fixtures::message::propose(), + }; + let super::Error(error) = super::handler( + State(app), + Path(channel.clone()), + sent_at, + sender, + Json(request), + ) + .await + .expect_err("sending to a deleted channel fails"); + + // Verify the structure of the response + + assert!(matches!( + error, + SendError::ChannelNotFound(error_channel) if channel == error_channel + )); +} diff --git a/src/channel/mod.rs b/src/channel/mod.rs index feb00a9..bbaf33e 100644 --- a/src/channel/mod.rs +++ b/src/channel/mod.rs @@ -1,9 +1,9 @@ pub mod app; pub mod event; +pub mod handlers; mod history; mod id; pub mod repo; -pub mod routes; mod snapshot; mod validate; diff --git a/src/channel/routes/channel/delete.rs b/src/channel/routes/channel/delete.rs deleted file mode 100644 index 3db7772..0000000 --- a/src/channel/routes/channel/delete.rs +++ /dev/null @@ -1,56 +0,0 @@ -use axum::{ - extract::{Json, Path, State}, - http::StatusCode, - response::{self, IntoResponse}, -}; - -use crate::{ - app::App, - channel::{self, app}, - clock::RequestedAt, - error::{Internal, NotFound}, - token::extract::Identity, -}; - -pub async fn handler( - State(app): State, - Path(channel): Path, - RequestedAt(deleted_at): RequestedAt, - _: Identity, -) -> Result { - app.channels().delete(&channel, &deleted_at).await?; - - Ok(Response { id: channel }) -} - -#[derive(Debug, serde::Serialize)] -pub struct Response { - pub id: channel::Id, -} - -impl IntoResponse for Response { - fn into_response(self) -> response::Response { - (StatusCode::ACCEPTED, Json(self)).into_response() - } -} - -#[derive(Debug, thiserror::Error)] -#[error(transparent)] -pub struct Error(#[from] pub app::DeleteError); - -impl IntoResponse for Error { - fn into_response(self) -> response::Response { - let Self(error) = self; - match error { - app::DeleteError::NotFound(_) | app::DeleteError::Deleted(_) => { - NotFound(error).into_response() - } - app::DeleteError::NotEmpty(_) => { - (StatusCode::CONFLICT, error.to_string()).into_response() - } - app::DeleteError::Name(_) | app::DeleteError::Database(_) => { - Internal::from(error).into_response() - } - } - } -} diff --git a/src/channel/routes/channel/mod.rs b/src/channel/routes/channel/mod.rs deleted file mode 100644 index 31a9142..0000000 --- a/src/channel/routes/channel/mod.rs +++ /dev/null @@ -1,9 +0,0 @@ -use crate::channel::Id; - -pub mod delete; -pub mod post; - -#[cfg(test)] -mod test; - -type PathInfo = Id; diff --git a/src/channel/routes/channel/post.rs b/src/channel/routes/channel/post.rs deleted file mode 100644 index 2547122..0000000 --- a/src/channel/routes/channel/post.rs +++ /dev/null @@ -1,57 +0,0 @@ -use axum::{ - extract::{Json, Path, State}, - http::StatusCode, - response::{self, IntoResponse}, -}; - -use crate::{ - app::App, - clock::RequestedAt, - error::{Internal, NotFound}, - message::{Body, Message, app::SendError}, - token::extract::Identity, -}; - -pub async fn handler( - State(app): State, - Path(channel): Path, - RequestedAt(sent_at): RequestedAt, - identity: Identity, - Json(request): Json, -) -> Result { - let message = app - .messages() - .send(&channel, &identity.user, &sent_at, &request.body) - .await?; - - Ok(Response(message)) -} - -#[derive(serde::Deserialize)] -pub struct Request { - pub body: Body, -} - -#[derive(Debug)] -pub struct Response(pub Message); - -impl IntoResponse for Response { - fn into_response(self) -> response::Response { - let Self(message) = self; - (StatusCode::ACCEPTED, Json(message)).into_response() - } -} - -#[derive(Debug, thiserror::Error)] -#[error(transparent)] -pub struct Error(#[from] pub SendError); - -impl IntoResponse for Error { - fn into_response(self) -> response::Response { - let Self(error) = self; - match error { - SendError::ChannelNotFound(_) => NotFound(error).into_response(), - SendError::Name(_) | SendError::Database(_) => Internal::from(error).into_response(), - } - } -} diff --git a/src/channel/routes/channel/test/delete.rs b/src/channel/routes/channel/test/delete.rs deleted file mode 100644 index bd9261d..0000000 --- a/src/channel/routes/channel/test/delete.rs +++ /dev/null @@ -1,177 +0,0 @@ -use axum::extract::{Path, State}; - -use crate::{ - channel::{app, routes::channel::delete}, - test::fixtures, -}; - -#[tokio::test] -pub async fn valid_channel() { - // Set up the environment - - let app = fixtures::scratch_app().await; - let channel = fixtures::channel::create(&app, &fixtures::now()).await; - - // Send the request - - let deleter = fixtures::identity::create(&app, &fixtures::now()).await; - let response = delete::handler( - State(app.clone()), - Path(channel.id.clone()), - fixtures::now(), - deleter, - ) - .await - .expect("deleting a valid channel succeeds"); - - // Verify the response - - assert_eq!(channel.id, response.id); - - // Verify the semantics - - let snapshot = app.boot().snapshot().await.expect("boot always succeeds"); - assert!(!snapshot.channels.contains(&channel)); -} - -#[tokio::test] -pub async fn invalid_channel_id() { - // Set up the environment - - let app = fixtures::scratch_app().await; - - // Send the request - - let deleter = fixtures::identity::create(&app, &fixtures::now()).await; - let channel = fixtures::channel::fictitious(); - let delete::Error(error) = delete::handler( - State(app.clone()), - Path(channel.clone()), - fixtures::now(), - deleter, - ) - .await - .expect_err("deleting a nonexistent channel fails"); - - // Verify the response - - assert!(matches!(error, app::DeleteError::NotFound(id) if id == channel)); -} - -#[tokio::test] -pub async fn channel_deleted() { - // Set up the environment - - let app = fixtures::scratch_app().await; - let channel = fixtures::channel::create(&app, &fixtures::now()).await; - - app.channels() - .delete(&channel.id, &fixtures::now()) - .await - .expect("deleting a recently-sent channel succeeds"); - - // Send the request - - let deleter = fixtures::identity::create(&app, &fixtures::now()).await; - let delete::Error(error) = delete::handler( - State(app.clone()), - Path(channel.id.clone()), - fixtures::now(), - deleter, - ) - .await - .expect_err("deleting a deleted channel fails"); - - // Verify the response - - assert!(matches!(error, app::DeleteError::Deleted(id) if id == channel.id)); -} - -#[tokio::test] -pub async fn channel_expired() { - // Set up the environment - - let app = fixtures::scratch_app().await; - let channel = fixtures::channel::create(&app, &fixtures::ancient()).await; - - app.channels() - .expire(&fixtures::now()) - .await - .expect("expiring channels always succeeds"); - - // Send the request - - let deleter = fixtures::identity::create(&app, &fixtures::now()).await; - let delete::Error(error) = delete::handler( - State(app.clone()), - Path(channel.id.clone()), - fixtures::now(), - deleter, - ) - .await - .expect_err("deleting an expired channel fails"); - - // Verify the response - - assert!(matches!(error, app::DeleteError::Deleted(id) if id == channel.id)); -} - -#[tokio::test] -pub async fn channel_purged() { - // Set up the environment - - let app = fixtures::scratch_app().await; - let channel = fixtures::channel::create(&app, &fixtures::ancient()).await; - - app.channels() - .expire(&fixtures::old()) - .await - .expect("expiring channels always succeeds"); - - app.channels() - .purge(&fixtures::now()) - .await - .expect("purging channels always succeeds"); - - // Send the request - - let deleter = fixtures::identity::create(&app, &fixtures::now()).await; - let delete::Error(error) = delete::handler( - State(app.clone()), - Path(channel.id.clone()), - fixtures::now(), - deleter, - ) - .await - .expect_err("deleting a purged channel fails"); - - // Verify the response - - assert!(matches!(error, app::DeleteError::NotFound(id) if id == channel.id)); -} - -#[tokio::test] -pub async fn channel_not_empty() { - // Set up the environment - - let app = fixtures::scratch_app().await; - let channel = fixtures::channel::create(&app, &fixtures::now()).await; - let sender = fixtures::user::create(&app, &fixtures::now()).await; - fixtures::message::send(&app, &channel, &sender, &fixtures::now()).await; - - // Send the request - - let deleter = fixtures::identity::create(&app, &fixtures::now()).await; - let delete::Error(error) = delete::handler( - State(app.clone()), - Path(channel.id.clone()), - fixtures::now(), - deleter, - ) - .await - .expect_err("deleting a channel with messages fails"); - - // Verify the response - - assert!(matches!(error, app::DeleteError::NotEmpty(id) if id == channel.id)); -} diff --git a/src/channel/routes/channel/test/mod.rs b/src/channel/routes/channel/test/mod.rs deleted file mode 100644 index 78bf86e..0000000 --- a/src/channel/routes/channel/test/mod.rs +++ /dev/null @@ -1,2 +0,0 @@ -mod delete; -mod post; diff --git a/src/channel/routes/channel/test/post.rs b/src/channel/routes/channel/test/post.rs deleted file mode 100644 index d9527ac..0000000 --- a/src/channel/routes/channel/test/post.rs +++ /dev/null @@ -1,131 +0,0 @@ -use axum::extract::{Json, Path, State}; -use futures::stream::{self, StreamExt as _}; - -use crate::{ - channel::{self, routes::channel::post}, - event::Sequenced, - message::app::SendError, - test::fixtures::{self, future::Expect as _}, -}; - -#[tokio::test] -async fn messages_in_order() { - // Set up the environment - - let app = fixtures::scratch_app().await; - let sender = fixtures::identity::create(&app, &fixtures::now()).await; - let channel = fixtures::channel::create(&app, &fixtures::now()).await; - let resume_point = fixtures::boot::resume_point(&app).await; - - // Call the endpoint (twice) - - let requests = vec![ - (fixtures::now(), fixtures::message::propose()), - (fixtures::now(), fixtures::message::propose()), - ]; - - for (sent_at, body) in &requests { - let request = post::Request { body: body.clone() }; - - let _ = post::handler( - State(app.clone()), - Path(channel.id.clone()), - sent_at.clone(), - sender.clone(), - Json(request), - ) - .await - .expect("sending to a valid channel succeeds"); - } - - // Verify the semantics - - let mut events = app - .events() - .subscribe(resume_point) - .await - .expect("subscribing to a valid channel succeeds") - .filter_map(fixtures::event::message) - .filter_map(fixtures::event::message::sent) - .zip(stream::iter(requests)); - - while let Some((event, (sent_at, body))) = events - .next() - .expect_ready("an event should be ready for each message") - .await - { - assert_eq!(*sent_at, event.at()); - assert_eq!(sender.user.id, event.message.sender); - assert_eq!(body, event.message.body); - } -} - -#[tokio::test] -async fn nonexistent_channel() { - // Set up the environment - - let app = fixtures::scratch_app().await; - let sender = fixtures::identity::create(&app, &fixtures::now()).await; - - // Call the endpoint - - let sent_at = fixtures::now(); - let channel = channel::Id::generate(); - let request = post::Request { - body: fixtures::message::propose(), - }; - let post::Error(error) = post::handler( - State(app), - Path(channel.clone()), - sent_at, - sender, - Json(request), - ) - .await - .expect_err("sending to a nonexistent channel fails"); - - // Verify the structure of the response - - assert!(matches!( - error, - SendError::ChannelNotFound(error_channel) if channel == error_channel - )); -} - -#[tokio::test] -async fn deleted_channel() { - // Set up the environment - - let app = fixtures::scratch_app().await; - let sender = fixtures::identity::create(&app, &fixtures::now()).await; - let channel = fixtures::channel::create(&app, &fixtures::now()).await; - - app.channels() - .delete(&channel.id, &fixtures::now()) - .await - .expect("deleting a new channel succeeds"); - - // Call the endpoint - - let sent_at = fixtures::now(); - let channel = channel::Id::generate(); - let request = post::Request { - body: fixtures::message::propose(), - }; - let post::Error(error) = post::handler( - State(app), - Path(channel.clone()), - sent_at, - sender, - Json(request), - ) - .await - .expect_err("sending to a deleted channel fails"); - - // Verify the structure of the response - - assert!(matches!( - error, - SendError::ChannelNotFound(error_channel) if channel == error_channel - )); -} diff --git a/src/channel/routes/mod.rs b/src/channel/routes/mod.rs deleted file mode 100644 index bd90721..0000000 --- a/src/channel/routes/mod.rs +++ /dev/null @@ -1,5 +0,0 @@ -pub mod channel; -pub mod post; - -#[cfg(test)] -mod test; diff --git a/src/channel/routes/post.rs b/src/channel/routes/post.rs deleted file mode 100644 index 6ea9b61..0000000 --- a/src/channel/routes/post.rs +++ /dev/null @@ -1,64 +0,0 @@ -use axum::{ - extract::{Json, State}, - http::StatusCode, - response::{self, IntoResponse}, -}; - -use crate::{ - app::App, - channel::{Channel, app}, - clock::RequestedAt, - error::Internal, - name::Name, - token::extract::Identity, -}; - -pub async fn handler( - State(app): State, - _: Identity, // requires auth, but doesn't actually care who you are - RequestedAt(created_at): RequestedAt, - Json(request): Json, -) -> Result { - let channel = app - .channels() - .create(&request.name, &created_at) - .await - .map_err(Error)?; - - Ok(Response(channel)) -} - -#[derive(serde::Deserialize)] -pub struct Request { - pub name: Name, -} - -#[derive(Debug)] -pub struct Response(pub Channel); - -impl IntoResponse for Response { - fn into_response(self) -> response::Response { - let Self(channel) = self; - (StatusCode::ACCEPTED, Json(channel)).into_response() - } -} - -#[derive(Debug)] -pub struct Error(pub app::CreateError); - -impl IntoResponse for Error { - fn into_response(self) -> response::Response { - let Self(error) = self; - match error { - app::CreateError::DuplicateName(_) => { - (StatusCode::CONFLICT, error.to_string()).into_response() - } - app::CreateError::InvalidName(_) => { - (StatusCode::BAD_REQUEST, error.to_string()).into_response() - } - app::CreateError::Name(_) | app::CreateError::Database(_) => { - Internal::from(error).into_response() - } - } - } -} diff --git a/src/channel/routes/test.rs b/src/channel/routes/test.rs deleted file mode 100644 index cba8f2e..0000000 --- a/src/channel/routes/test.rs +++ /dev/null @@ -1,239 +0,0 @@ -use std::future; - -use axum::extract::{Json, State}; -use futures::stream::StreamExt as _; - -use super::post; -use crate::{ - channel::app, - name::Name, - test::fixtures::{self, future::Expect as _}, -}; - -#[tokio::test] -async fn new_channel() { - // Set up the environment - - let app = fixtures::scratch_app().await; - let creator = fixtures::identity::create(&app, &fixtures::now()).await; - let resume_point = fixtures::boot::resume_point(&app).await; - - // Call the endpoint - - let name = fixtures::channel::propose(); - let request = post::Request { name: name.clone() }; - let post::Response(response) = - post::handler(State(app.clone()), creator, fixtures::now(), Json(request)) - .await - .expect("creating a channel in an empty app succeeds"); - - // Verify the structure of the response - - assert_eq!(name, response.name); - - // Verify the semantics - - let snapshot = app.boot().snapshot().await.expect("boot always succeeds"); - assert!(snapshot.channels.iter().any(|channel| channel == &response)); - - let channel = app - .channels() - .get(&response.id) - .await - .expect("the newly-created channel exists"); - assert_eq!(response, channel); - - let mut events = app - .events() - .subscribe(resume_point) - .await - .expect("subscribing never fails") - .filter_map(fixtures::event::channel) - .filter_map(fixtures::event::channel::created) - .filter(|event| future::ready(event.channel == response)); - - let event = events.next().expect_some("creation event published").await; - - assert_eq!(event.channel, response); -} - -#[tokio::test] -async fn duplicate_name() { - // Set up the environment - - let app = fixtures::scratch_app().await; - let creator = fixtures::identity::create(&app, &fixtures::now()).await; - let channel = fixtures::channel::create(&app, &fixtures::now()).await; - - // Call the endpoint - - let request = post::Request { - name: channel.name.clone(), - }; - let post::Error(error) = - post::handler(State(app.clone()), creator, fixtures::now(), Json(request)) - .await - .expect_err("duplicate channel name should fail the request"); - - // Verify the structure of the response - - assert!(matches!( - error, - app::CreateError::DuplicateName(name) if channel.name == name - )); -} - -#[tokio::test] -async fn conflicting_canonical_name() { - // Set up the environment - - let app = fixtures::scratch_app().await; - let creator = fixtures::identity::create(&app, &fixtures::now()).await; - - let existing_name = Name::from("rijksmuseum"); - app.channels() - .create(&existing_name, &fixtures::now()) - .await - .expect("creating a channel in an empty environment succeeds"); - - let conflicting_name = Name::from("r\u{0133}ksmuseum"); - - // Call the endpoint - - let request = post::Request { - name: conflicting_name.clone(), - }; - let post::Error(error) = - post::handler(State(app.clone()), creator, fixtures::now(), Json(request)) - .await - .expect_err("duplicate channel name should fail the request"); - - // Verify the structure of the response - - assert!(matches!( - error, - app::CreateError::DuplicateName(name) if conflicting_name == name - )); -} - -#[tokio::test] -async fn invalid_name() { - // Set up the environment - - let app = fixtures::scratch_app().await; - let creator = fixtures::identity::create(&app, &fixtures::now()).await; - - // Call the endpoint - - let name = fixtures::channel::propose_invalid_name(); - let request = post::Request { name: name.clone() }; - let post::Error(error) = - post::handler(State(app.clone()), creator, fixtures::now(), Json(request)) - .await - .expect_err("invalid channel name should fail the request"); - - // Verify the structure of the response - - assert!(matches!( - error, - app::CreateError::InvalidName(error_name) if name == error_name - )); -} - -#[tokio::test] -async fn name_reusable_after_delete() { - // Set up the environment - - let app = fixtures::scratch_app().await; - let creator = fixtures::identity::create(&app, &fixtures::now()).await; - let name = fixtures::channel::propose(); - - // Call the endpoint (first time) - - let request = post::Request { name: name.clone() }; - let post::Response(response) = post::handler( - State(app.clone()), - creator.clone(), - fixtures::now(), - Json(request), - ) - .await - .expect("new channel in an empty app"); - - // Delete the channel - - app.channels() - .delete(&response.id, &fixtures::now()) - .await - .expect("deleting a newly-created channel succeeds"); - - // Call the endpoint (second time) - - let request = post::Request { name: name.clone() }; - let post::Response(response) = - post::handler(State(app.clone()), creator, fixtures::now(), Json(request)) - .await - .expect("creation succeeds after original channel deleted"); - - // Verify the structure of the response - - assert_eq!(name, response.name); - - // Verify the semantics - - let channel = app - .channels() - .get(&response.id) - .await - .expect("the newly-created channel exists"); - assert_eq!(response, channel); -} - -#[tokio::test] -async fn name_reusable_after_expiry() { - // Set up the environment - - let app = fixtures::scratch_app().await; - let creator = fixtures::identity::create(&app, &fixtures::ancient()).await; - let name = fixtures::channel::propose(); - - // Call the endpoint (first time) - - let request = post::Request { name: name.clone() }; - let post::Response(_) = post::handler( - State(app.clone()), - creator.clone(), - fixtures::ancient(), - Json(request), - ) - .await - .expect("new channel in an empty app"); - - // Delete the channel - - app.channels() - .expire(&fixtures::now()) - .await - .expect("expiry always succeeds"); - - // Call the endpoint (second time) - - let request = post::Request { name: name.clone() }; - let post::Response(response) = - post::handler(State(app.clone()), creator, fixtures::now(), Json(request)) - .await - .expect("creation succeeds after original channel expired"); - - // Verify the structure of the response - - assert_eq!(name, response.name); - - // Verify the semantics - - let channel = app - .channels() - .get(&response.id) - .await - .expect("the newly-created channel exists"); - assert_eq!(response, channel); -} diff --git a/src/event/handlers/mod.rs b/src/event/handlers/mod.rs new file mode 100644 index 0000000..22d988c --- /dev/null +++ b/src/event/handlers/mod.rs @@ -0,0 +1,3 @@ +mod stream; + +pub use stream::handler as stream; diff --git a/src/event/handlers/stream/mod.rs b/src/event/handlers/stream/mod.rs new file mode 100644 index 0000000..d0d3f08 --- /dev/null +++ b/src/event/handlers/stream/mod.rs @@ -0,0 +1,85 @@ +use axum::{ + extract::State, + response::{ + self, IntoResponse, + sse::{self, Sse}, + }, +}; +use axum_extra::extract::Query; +use futures::stream::{Stream, StreamExt as _}; + +use crate::{ + app::App, + error::{Internal, Unauthorized}, + event::{Event, Heartbeat::Heartbeat, Sequence, Sequenced as _, app, extract::LastEventId}, + token::{app::ValidateError, extract::Identity}, +}; + +#[cfg(test)] +mod test; + +pub async fn handler( + State(app): State, + identity: Identity, + last_event_id: Option>, + Query(query): Query, +) -> Result + std::fmt::Debug>, Error> { + let resume_at = last_event_id.map_or(query.resume_point, LastEventId::into_inner); + + let stream = app.events().subscribe(resume_at).await?; + let stream = app.tokens().limit_stream(identity.token, stream).await?; + + Ok(Response(stream)) +} + +#[derive(serde::Deserialize)] +pub struct QueryParams { + pub resume_point: Sequence, +} + +#[derive(Debug)] +pub struct Response(pub S); + +impl IntoResponse for Response +where + S: Stream + Send + 'static, +{ + fn into_response(self) -> response::Response { + let Self(stream) = self; + let stream = stream.map(sse::Event::try_from); + let heartbeat = match Heartbeat.try_into().map_err(Internal::from) { + Ok(heartbeat) => heartbeat, + Err(err) => return err.into_response(), + }; + Sse::new(stream).keep_alive(heartbeat).into_response() + } +} + +impl TryFrom for sse::Event { + type Error = serde_json::Error; + + fn try_from(event: Event) -> Result { + let id = serde_json::to_string(&event.sequence())?; + let data = serde_json::to_string_pretty(&event)?; + + let event = Self::default().id(id).data(data); + + Ok(event) + } +} + +#[derive(Debug, thiserror::Error)] +#[error(transparent)] +pub enum Error { + Subscribe(#[from] app::Error), + Validate(#[from] ValidateError), +} + +impl IntoResponse for Error { + fn into_response(self) -> response::Response { + match self { + Self::Validate(ValidateError::InvalidToken) => Unauthorized.into_response(), + other => Internal::from(other).into_response(), + } + } +} diff --git a/src/event/handlers/stream/test/channel.rs b/src/event/handlers/stream/test/channel.rs new file mode 100644 index 0000000..187c3c3 --- /dev/null +++ b/src/event/handlers/stream/test/channel.rs @@ -0,0 +1,273 @@ +use axum::extract::State; +use axum_extra::extract::Query; +use futures::{future, stream::StreamExt as _}; + +use crate::test::fixtures::{self, future::Expect as _}; + +#[tokio::test] +async fn creating() { + // Set up the environment + + let app = fixtures::scratch_app().await; + let resume_point = fixtures::boot::resume_point(&app).await; + + // Subscribe + + let subscriber = fixtures::identity::create(&app, &fixtures::now()).await; + let super::Response(events) = super::handler( + State(app.clone()), + subscriber, + None, + Query(super::QueryParams { resume_point }), + ) + .await + .expect("subscribe never fails"); + + // Create a channel + + let name = fixtures::channel::propose(); + let channel = app + .channels() + .create(&name, &fixtures::now()) + .await + .expect("creating a channel succeeds"); + + // Verify channel created event + + events + .filter_map(fixtures::event::channel) + .filter_map(fixtures::event::channel::created) + .filter(|event| future::ready(event.channel == channel)) + .next() + .expect_some("channel created event is delivered") + .await; +} + +#[tokio::test] +async fn previously_created() { + // Set up the environment + + let app = fixtures::scratch_app().await; + let resume_point = fixtures::boot::resume_point(&app).await; + + // Create a channel + + let name = fixtures::channel::propose(); + let channel = app + .channels() + .create(&name, &fixtures::now()) + .await + .expect("creating a channel succeeds"); + + // Subscribe + + let subscriber = fixtures::identity::create(&app, &fixtures::now()).await; + let super::Response(events) = super::handler( + State(app.clone()), + subscriber, + None, + Query(super::QueryParams { resume_point }), + ) + .await + .expect("subscribe never fails"); + + // Verify channel created event + + let _ = events + .filter_map(fixtures::event::channel) + .filter_map(fixtures::event::channel::created) + .filter(|event| future::ready(event.channel == channel)) + .next() + .expect_some("channel created event is delivered") + .await; +} + +#[tokio::test] +async fn expiring() { + // Set up the environment + + let app = fixtures::scratch_app().await; + let channel = fixtures::channel::create(&app, &fixtures::ancient()).await; + let resume_point = fixtures::boot::resume_point(&app).await; + + // Subscribe + + let subscriber = fixtures::identity::create(&app, &fixtures::now()).await; + let super::Response(events) = super::handler( + State(app.clone()), + subscriber, + None, + Query(super::QueryParams { resume_point }), + ) + .await + .expect("subscribe never fails"); + + // Expire channels + + app.channels() + .expire(&fixtures::now()) + .await + .expect("expiring channels always succeeds"); + + // Check for expiry event + let _ = events + .filter_map(fixtures::event::channel) + .filter_map(fixtures::event::channel::deleted) + .filter(|event| future::ready(event.id == channel.id)) + .next() + .expect_some("a deleted channel event will be delivered") + .await; +} + +#[tokio::test] +async fn previously_expired() { + // Set up the environment + + let app = fixtures::scratch_app().await; + let channel = fixtures::channel::create(&app, &fixtures::ancient()).await; + let resume_point = fixtures::boot::resume_point(&app).await; + + // Expire channels + + app.channels() + .expire(&fixtures::now()) + .await + .expect("expiring channels always succeeds"); + + // Subscribe + + let subscriber = fixtures::identity::create(&app, &fixtures::now()).await; + let super::Response(events) = super::handler( + State(app.clone()), + subscriber, + None, + Query(super::QueryParams { resume_point }), + ) + .await + .expect("subscribe never fails"); + + // Check for expiry event + let _ = events + .filter_map(fixtures::event::channel) + .filter_map(fixtures::event::channel::deleted) + .filter(|event| future::ready(event.id == channel.id)) + .next() + .expect_some("a deleted channel event will be delivered") + .await; +} + +#[tokio::test] +async fn deleting() { + // Set up the environment + + let app = fixtures::scratch_app().await; + let channel = fixtures::channel::create(&app, &fixtures::now()).await; + let resume_point = fixtures::boot::resume_point(&app).await; + + // Subscribe + + let subscriber = fixtures::identity::create(&app, &fixtures::now()).await; + let super::Response(events) = super::handler( + State(app.clone()), + subscriber, + None, + Query(super::QueryParams { resume_point }), + ) + .await + .expect("subscribe never fails"); + + // Delete the channel + + app.channels() + .delete(&channel.id, &fixtures::now()) + .await + .expect("deleting a valid channel succeeds"); + + // Check for delete event + let _ = events + .filter_map(fixtures::event::channel) + .filter_map(fixtures::event::channel::deleted) + .filter(|event| future::ready(event.id == channel.id)) + .next() + .expect_some("a deleted channel event will be delivered") + .await; +} + +#[tokio::test] +async fn previously_deleted() { + // Set up the environment + + let app = fixtures::scratch_app().await; + let channel = fixtures::channel::create(&app, &fixtures::now()).await; + let resume_point = fixtures::boot::resume_point(&app).await; + + // Delete the channel + + app.channels() + .delete(&channel.id, &fixtures::now()) + .await + .expect("deleting a valid channel succeeds"); + + // Subscribe + + let subscriber = fixtures::identity::create(&app, &fixtures::now()).await; + let super::Response(events) = super::handler( + State(app.clone()), + subscriber, + None, + Query(super::QueryParams { resume_point }), + ) + .await + .expect("subscribe never fails"); + + // Check for expiry event + let _ = events + .filter_map(fixtures::event::channel) + .filter_map(fixtures::event::channel::deleted) + .filter(|event| future::ready(event.id == channel.id)) + .next() + .expect_some("a deleted channel event will be delivered") + .await; +} + +#[tokio::test] +async fn previously_purged() { + // Set up the environment + + let app = fixtures::scratch_app().await; + let channel = fixtures::channel::create(&app, &fixtures::ancient()).await; + let resume_point = fixtures::boot::resume_point(&app).await; + + // Delete and purge the channel + + app.channels() + .delete(&channel.id, &fixtures::ancient()) + .await + .expect("deleting a valid channel succeeds"); + + app.channels() + .purge(&fixtures::now()) + .await + .expect("purging channels always succeeds"); + + // Subscribe + + let subscriber = fixtures::identity::create(&app, &fixtures::now()).await; + let super::Response(events) = super::handler( + State(app.clone()), + subscriber, + None, + Query(super::QueryParams { resume_point }), + ) + .await + .expect("subscribe never fails"); + + // Check for expiry event + events + .filter_map(fixtures::event::channel) + .filter_map(fixtures::event::channel::deleted) + .filter(|event| future::ready(event.id == channel.id)) + .next() + .expect_wait("deleted channel events not delivered") + .await; +} diff --git a/src/event/handlers/stream/test/invite.rs b/src/event/handlers/stream/test/invite.rs new file mode 100644 index 0000000..c8e12fb --- /dev/null +++ b/src/event/handlers/stream/test/invite.rs @@ -0,0 +1,87 @@ +use axum::extract::State; +use axum_extra::extract::Query; +use futures::{future, stream::StreamExt as _}; + +use crate::test::fixtures::{self, future::Expect as _}; + +#[tokio::test] +async fn accepting_invite() { + // Set up the environment + + let app = fixtures::scratch_app().await; + let issuer = fixtures::user::create(&app, &fixtures::now()).await; + let invite = fixtures::invite::issue(&app, &issuer, &fixtures::now()).await; + let resume_point = fixtures::boot::resume_point(&app).await; + + // Subscribe + + let subscriber = fixtures::identity::create(&app, &fixtures::now()).await; + let super::Response(events) = super::handler( + State(app.clone()), + subscriber, + None, + Query(super::QueryParams { resume_point }), + ) + .await + .expect("subscribe never fails"); + + // Accept the invite + + let (name, password) = fixtures::user::propose(); + let (joiner, _) = app + .invites() + .accept(&invite.id, &name, &password, &fixtures::now()) + .await + .expect("accepting an invite succeeds"); + + // Expect a login created event + + let _ = events + .filter_map(fixtures::event::user) + .filter_map(fixtures::event::user::created) + .filter(|event| future::ready(event.user == joiner)) + .next() + .expect_some("a login created event is sent") + .await; +} + +#[tokio::test] +async fn previously_accepted_invite() { + // Set up the environment + + let app = fixtures::scratch_app().await; + let issuer = fixtures::user::create(&app, &fixtures::now()).await; + let invite = fixtures::invite::issue(&app, &issuer, &fixtures::now()).await; + let resume_point = fixtures::boot::resume_point(&app).await; + + // Accept the invite + + let (name, password) = fixtures::user::propose(); + let (joiner, _) = app + .invites() + .accept(&invite.id, &name, &password, &fixtures::now()) + .await + .expect("accepting an invite succeeds"); + + // Subscribe + + let subscriber = fixtures::identity::create(&app, &fixtures::now()).await; + let super::Response(events) = super::handler( + State(app.clone()), + subscriber, + None, + Query(super::QueryParams { resume_point }), + ) + .await + .expect("subscribe never fails"); + + // Expect a login created event + + let _ = events + .filter_map(fixtures::event::user) + .filter_map(fixtures::event::user::created) + .filter(|event| future::ready(event.user == joiner)) + .next() + .expect_some("a login created event is sent") + .await; +} diff --git a/src/event/handlers/stream/test/message.rs b/src/event/handlers/stream/test/message.rs new file mode 100644 index 0000000..a80c896 --- /dev/null +++ b/src/event/handlers/stream/test/message.rs @@ -0,0 +1,393 @@ +use axum::extract::State; +use axum_extra::extract::Query; +use futures::{ + future, + stream::{self, StreamExt as _}, +}; + +use crate::test::fixtures::{self, future::Expect as _}; + +#[tokio::test] +async fn sending() { + // Set up the environment + + let app = fixtures::scratch_app().await; + let channel = fixtures::channel::create(&app, &fixtures::now()).await; + let resume_point = fixtures::boot::resume_point(&app).await; + + // Call the endpoint + + let subscriber = fixtures::identity::create(&app, &fixtures::now()).await; + let super::Response(events) = super::handler( + State(app.clone()), + subscriber, + None, + Query(super::QueryParams { resume_point }), + ) + .await + .expect("subscribe never fails"); + + // Send a message + + let sender = fixtures::user::create(&app, &fixtures::now()).await; + let message = app + .messages() + .send( + &channel.id, + &sender, + &fixtures::now(), + &fixtures::message::propose(), + ) + .await + .expect("sending a message succeeds"); + + // Verify that an event is delivered + + let _ = events + .filter_map(fixtures::event::message) + .filter_map(fixtures::event::message::sent) + .filter(|event| future::ready(event.message == message)) + .next() + .expect_some("delivered message sent event") + .await; +} + +#[tokio::test] +async fn previously_sent() { + // Set up the environment + + let app = fixtures::scratch_app().await; + let channel = fixtures::channel::create(&app, &fixtures::now()).await; + let resume_point = fixtures::boot::resume_point(&app).await; + + // Send a message + + let sender = fixtures::user::create(&app, &fixtures::now()).await; + let message = app + .messages() + .send( + &channel.id, + &sender, + &fixtures::now(), + &fixtures::message::propose(), + ) + .await + .expect("sending a message succeeds"); + + // Call the endpoint + + let subscriber = fixtures::identity::create(&app, &fixtures::now()).await; + let super::Response(events) = super::handler( + State(app.clone()), + subscriber, + None, + Query(super::QueryParams { resume_point }), + ) + .await + .expect("subscribe never fails"); + + // Verify that an event is delivered + + let _ = events + .filter_map(fixtures::event::message) + .filter_map(fixtures::event::message::sent) + .filter(|event| future::ready(event.message == message)) + .next() + .expect_some("delivered message sent event") + .await; +} + +#[tokio::test] +async fn sent_in_multiple_channels() { + // Set up the environment + + let app = fixtures::scratch_app().await; + let sender = fixtures::user::create(&app, &fixtures::now()).await; + let resume_point = fixtures::boot::resume_point(&app).await; + + let channels = [ + fixtures::channel::create(&app, &fixtures::now()).await, + fixtures::channel::create(&app, &fixtures::now()).await, + ]; + + let messages = stream::iter(channels) + .then(|channel| { + let app = app.clone(); + let sender = sender.clone(); + let channel = channel.clone(); + async move { fixtures::message::send(&app, &channel, &sender, &fixtures::now()).await } + }) + .collect::>() + .await; + + // Call the endpoint + + let subscriber = fixtures::identity::create(&app, &fixtures::now()).await; + let super::Response(events) = super::handler( + State(app), + subscriber, + None, + Query(super::QueryParams { resume_point }), + ) + .await + .expect("subscribe never fails"); + + // Verify the structure of the response. + + let events = events + .filter_map(fixtures::event::message) + .filter_map(fixtures::event::message::sent) + .take(messages.len()) + .collect::>() + .expect_ready("events ready") + .await; + + for message in &messages { + assert!(events.iter().any(|event| &event.message == message)); + } +} + +#[tokio::test] +async fn sent_sequentially() { + // Set up the environment + + let app = fixtures::scratch_app().await; + let channel = fixtures::channel::create(&app, &fixtures::now()).await; + let sender = fixtures::user::create(&app, &fixtures::now()).await; + let resume_point = fixtures::boot::resume_point(&app).await; + + let messages = vec![ + fixtures::message::send(&app, &channel, &sender, &fixtures::now()).await, + fixtures::message::send(&app, &channel, &sender, &fixtures::now()).await, + fixtures::message::send(&app, &channel, &sender, &fixtures::now()).await, + ]; + + // Subscribe + + let subscriber = fixtures::identity::create(&app, &fixtures::now()).await; + let super::Response(events) = super::handler( + State(app), + subscriber, + None, + Query(super::QueryParams { resume_point }), + ) + .await + .expect("subscribe never fails"); + + // Verify the expected events in the expected order + + let mut events = events + .filter_map(fixtures::event::message) + .filter_map(fixtures::event::message::sent) + .filter(|event| future::ready(messages.iter().any(|message| &event.message == message))); + + for message in &messages { + let event = events + .next() + .expect_some("undelivered messages remaining") + .await; + + assert_eq!(message, &event.message); + } +} + +#[tokio::test] +async fn expiring() { + // Set up the environment + + let app = fixtures::scratch_app().await; + let channel = fixtures::channel::create(&app, &fixtures::ancient()).await; + let sender = fixtures::user::create(&app, &fixtures::ancient()).await; + let message = fixtures::message::send(&app, &channel, &sender, &fixtures::ancient()).await; + let resume_point = fixtures::boot::resume_point(&app).await; + + // Subscribe + + let subscriber = fixtures::identity::create(&app, &fixtures::now()).await; + let super::Response(events) = super::handler( + State(app.clone()), + subscriber, + None, + Query(super::QueryParams { resume_point }), + ) + .await + .expect("subscribe never fails"); + + // Expire messages + + app.messages() + .expire(&fixtures::now()) + .await + .expect("expiring messages always succeeds"); + + // Check for expiry event + let _ = events + .filter_map(fixtures::event::message) + .filter_map(fixtures::event::message::deleted) + .filter(|event| future::ready(event.id == message.id)) + .next() + .expect_some("a deleted message event will be delivered") + .await; +} + +#[tokio::test] +async fn previously_expired() { + // Set up the environment + + let app = fixtures::scratch_app().await; + let channel = fixtures::channel::create(&app, &fixtures::ancient()).await; + let sender = fixtures::user::create(&app, &fixtures::ancient()).await; + let message = fixtures::message::send(&app, &channel, &sender, &fixtures::ancient()).await; + let resume_point = fixtures::boot::resume_point(&app).await; + + // Expire messages + + app.messages() + .expire(&fixtures::now()) + .await + .expect("expiring messages always succeeds"); + + // Subscribe + + let subscriber = fixtures::identity::create(&app, &fixtures::now()).await; + let super::Response(events) = super::handler( + State(app.clone()), + subscriber, + None, + Query(super::QueryParams { resume_point }), + ) + .await + .expect("subscribe never fails"); + + // Check for expiry event + let _ = events + .filter_map(fixtures::event::message) + .filter_map(fixtures::event::message::deleted) + .filter(|event| future::ready(event.id == message.id)) + .next() + .expect_some("a deleted message event will be delivered") + .await; +} + +#[tokio::test] +async fn deleting() { + // Set up the environment + + let app = fixtures::scratch_app().await; + let channel = fixtures::channel::create(&app, &fixtures::now()).await; + let sender = fixtures::user::create(&app, &fixtures::now()).await; + let message = fixtures::message::send(&app, &channel, &sender, &fixtures::now()).await; + let resume_point = fixtures::boot::resume_point(&app).await; + + // Subscribe + + let subscriber = fixtures::identity::create(&app, &fixtures::now()).await; + let super::Response(events) = super::handler( + State(app.clone()), + subscriber, + None, + Query(super::QueryParams { resume_point }), + ) + .await + .expect("subscribe never fails"); + + // Delete the message + + app.messages() + .delete(&sender, &message.id, &fixtures::now()) + .await + .expect("deleting a valid message succeeds"); + + // Check for delete event + let _ = events + .filter_map(fixtures::event::message) + .filter_map(fixtures::event::message::deleted) + .filter(|event| future::ready(event.id == message.id)) + .next() + .expect_some("a deleted message event will be delivered") + .await; +} + +#[tokio::test] +async fn previously_deleted() { + // Set up the environment + + let app = fixtures::scratch_app().await; + let channel = fixtures::channel::create(&app, &fixtures::now()).await; + let sender = fixtures::user::create(&app, &fixtures::now()).await; + let message = fixtures::message::send(&app, &channel, &sender, &fixtures::now()).await; + let resume_point = fixtures::boot::resume_point(&app).await; + + // Delete the message + + app.messages() + .delete(&sender, &message.id, &fixtures::now()) + .await + .expect("deleting a valid message succeeds"); + + // Subscribe + + let subscriber = fixtures::identity::create(&app, &fixtures::now()).await; + let super::Response(events) = super::handler( + State(app.clone()), + subscriber, + None, + Query(super::QueryParams { resume_point }), + ) + .await + .expect("subscribe never fails"); + + // Check for delete event + let _ = events + .filter_map(fixtures::event::message) + .filter_map(fixtures::event::message::deleted) + .filter(|event| future::ready(event.id == message.id)) + .next() + .expect_some("a deleted message event will be delivered") + .await; +} + +#[tokio::test] +async fn previously_purged() { + // Set up the environment + + let app = fixtures::scratch_app().await; + let channel = fixtures::channel::create(&app, &fixtures::ancient()).await; + let sender = fixtures::user::create(&app, &fixtures::ancient()).await; + let message = fixtures::message::send(&app, &channel, &sender, &fixtures::ancient()).await; + let resume_point = fixtures::boot::resume_point(&app).await; + + // Purge the message + + app.messages() + .delete(&sender, &message.id, &fixtures::ancient()) + .await + .expect("deleting a valid message succeeds"); + + app.messages() + .purge(&fixtures::now()) + .await + .expect("purge always succeeds"); + + // Subscribe + + let subscriber = fixtures::identity::create(&app, &fixtures::now()).await; + let super::Response(events) = super::handler( + State(app.clone()), + subscriber, + None, + Query(super::QueryParams { resume_point }), + ) + .await + .expect("subscribe never fails"); + + // Check for delete event + + events + .filter_map(fixtures::event::message) + .filter_map(fixtures::event::message::deleted) + .filter(|event| future::ready(event.id == message.id)) + .next() + .expect_wait("no deleted message will be delivered") + .await; +} diff --git a/src/event/handlers/stream/test/mod.rs b/src/event/handlers/stream/test/mod.rs new file mode 100644 index 0000000..df43deb --- /dev/null +++ b/src/event/handlers/stream/test/mod.rs @@ -0,0 +1,8 @@ +mod channel; +mod invite; +mod message; +mod resume; +mod setup; +mod token; + +use super::{QueryParams, Response, handler}; diff --git a/src/event/handlers/stream/test/resume.rs b/src/event/handlers/stream/test/resume.rs new file mode 100644 index 0000000..34fee4d --- /dev/null +++ b/src/event/handlers/stream/test/resume.rs @@ -0,0 +1,227 @@ +use std::future; + +use axum::extract::State; +use axum_extra::extract::Query; +use futures::stream::{self, StreamExt as _}; + +use crate::{ + event::Sequenced as _, + test::fixtures::{self, future::Expect as _}, +}; + +#[tokio::test] +async fn resume() { + // Set up the environment + + let app = fixtures::scratch_app().await; + let channel = fixtures::channel::create(&app, &fixtures::now()).await; + let sender = fixtures::user::create(&app, &fixtures::now()).await; + let resume_point = fixtures::boot::resume_point(&app).await; + + let initial_message = fixtures::message::send(&app, &channel, &sender, &fixtures::now()).await; + + let later_messages = vec![ + fixtures::message::send(&app, &channel, &sender, &fixtures::now()).await, + fixtures::message::send(&app, &channel, &sender, &fixtures::now()).await, + ]; + + // Call the endpoint + + let subscriber = fixtures::identity::create(&app, &fixtures::now()).await; + + let resume_at = { + // First subscription + let super::Response(events) = super::handler( + State(app.clone()), + subscriber.clone(), + None, + Query(super::QueryParams { resume_point }), + ) + .await + .expect("subscribe never fails"); + + let event = events + .filter_map(fixtures::event::message) + .filter_map(fixtures::event::message::sent) + .filter(|event| future::ready(event.message == initial_message)) + .next() + .expect_some("delivered event for initial message") + .await; + + event.sequence() + }; + + // Resume after disconnect + let super::Response(resumed) = super::handler( + State(app), + subscriber, + Some(resume_at.into()), + Query(super::QueryParams { resume_point }), + ) + .await + .expect("subscribe never fails"); + + // Verify final events + + let mut events = resumed + .filter_map(fixtures::event::message) + .filter_map(fixtures::event::message::sent) + .zip(stream::iter(later_messages)); + + while let Some((event, message)) = events.next().expect_ready("event ready").await { + assert_eq!(message, event.message); + } +} + +// This test verifies a real bug I hit developing the vector-of-sequences +// approach to resuming events. A small omission caused the event IDs in a +// resumed stream to _omit_ channels that were in the original stream until +// those channels also appeared in the resumed stream. +// +// Clients would see something like +// * In the original stream, Cfoo=5,Cbar=8 +// * In the resumed stream, Cfoo=6 (no Cbar sequence number) +// +// Disconnecting and reconnecting a second time, using event IDs from that +// initial period of the first resume attempt, would then cause the second +// resume attempt to restart all other channels from the beginning, and not +// from where the first disconnection happened. +// +// As we have switched to a single global event sequence number, this scenario +// can no longer arise, but this test is preserved because the actual behaviour +// _is_ a valid way for clients to behave, and should work. We might as well +// keep testing it. +#[tokio::test] +async fn serial_resume() { + // Set up the environment + + let app = fixtures::scratch_app().await; + let sender = fixtures::user::create(&app, &fixtures::now()).await; + let channel_a = fixtures::channel::create(&app, &fixtures::now()).await; + let channel_b = fixtures::channel::create(&app, &fixtures::now()).await; + let resume_point = fixtures::boot::resume_point(&app).await; + + // Call the endpoint + + let subscriber = fixtures::identity::create(&app, &fixtures::now()).await; + + let resume_at = { + let initial_messages = [ + fixtures::message::send(&app, &channel_a, &sender, &fixtures::now()).await, + fixtures::message::send(&app, &channel_b, &sender, &fixtures::now()).await, + ]; + + // First subscription + + let super::Response(events) = super::handler( + State(app.clone()), + subscriber.clone(), + None, + Query(super::QueryParams { resume_point }), + ) + .await + .expect("subscribe never fails"); + + // Check for expected events + + let events = events + .filter_map(fixtures::event::message) + .filter_map(fixtures::event::message::sent) + .zip(stream::iter(initial_messages)) + .collect::>() + .expect_ready("zipping a finite list of events is ready immediately") + .await; + + assert!( + events + .iter() + .all(|(event, message)| message == &event.message) + ); + + let (event, _) = events.last().expect("this vec is non-empty"); + + // Take the last one's resume point + + event.sequence() + }; + + // Resume after disconnect + let resume_at = { + let resume_messages = [ + // Note that channel_b does not appear here. The buggy behaviour + // would be masked if channel_b happened to send a new message + // into the resumed event stream. + fixtures::message::send(&app, &channel_a, &sender, &fixtures::now()).await, + fixtures::message::send(&app, &channel_a, &sender, &fixtures::now()).await, + ]; + + // Second subscription + let super::Response(events) = super::handler( + State(app.clone()), + subscriber.clone(), + Some(resume_at.into()), + Query(super::QueryParams { resume_point }), + ) + .await + .expect("subscribe never fails"); + + // Check for expected events + + let events = events + .filter_map(fixtures::event::message) + .filter_map(fixtures::event::message::sent) + .zip(stream::iter(resume_messages)) + .collect::>() + .expect_ready("zipping a finite list of events is ready immediately") + .await; + + assert!( + events + .iter() + .all(|(event, message)| message == &event.message) + ); + + let (event, _) = events.last().expect("this vec is non-empty"); + + // Take the last one's resume point + + event.sequence() + }; + + // Resume after disconnect a second time + { + // At this point, we can send on either channel and demonstrate the + // problem. The resume point should before both of these messages, but + // after _all_ prior messages. + let final_messages = [ + fixtures::message::send(&app, &channel_a, &sender, &fixtures::now()).await, + fixtures::message::send(&app, &channel_b, &sender, &fixtures::now()).await, + ]; + + // Third subscription + let super::Response(events) = super::handler( + State(app.clone()), + subscriber.clone(), + Some(resume_at.into()), + Query(super::QueryParams { resume_point }), + ) + .await + .expect("subscribe never fails"); + + // Check for expected events + + let events = events + .filter_map(fixtures::event::message) + .filter_map(fixtures::event::message::sent) + .zip(stream::iter(final_messages)) + .collect::>() + .expect_ready("zipping a finite list of events is ready immediately") + .await; + + assert!( + events + .iter() + .all(|(event, message)| message == &event.message) + ); + }; +} diff --git a/src/event/handlers/stream/test/setup.rs b/src/event/handlers/stream/test/setup.rs new file mode 100644 index 0000000..5335055 --- /dev/null +++ b/src/event/handlers/stream/test/setup.rs @@ -0,0 +1,47 @@ +use axum::extract::State; +use axum_extra::extract::Query; +use futures::{future, stream::StreamExt as _}; + +use crate::test::fixtures::{self, future::Expect as _}; + +// There's no test for this in subscribe-then-setup order because creating an +// identity to subscribe with also completes initial setup, preventing the +// test from running. That is also a can't-happen scenario in reality. +#[tokio::test] +async fn previously_completed() { + // Set up the environment + + let app = fixtures::scratch_app().await; + let resume_point = fixtures::boot::resume_point(&app).await; + + // Complete initial setup + + let (name, password) = fixtures::user::propose(); + let (owner, _) = app + .setup() + .initial(&name, &password, &fixtures::now()) + .await + .expect("initial setup in an empty app succeeds"); + + // Subscribe to events + + let subscriber = fixtures::identity::create(&app, &fixtures::now()).await; + let super::Response(events) = super::handler( + State(app.clone()), + subscriber, + None, + Query(super::QueryParams { resume_point }), + ) + .await + .expect("subscribe never fails"); + + // Expect a login created event + + let _ = events + .filter_map(fixtures::event::user) + .filter_map(fixtures::event::user::created) + .filter(|event| future::ready(event.user == owner)) + .next() + .expect_some("a login created event is sent") + .await; +} diff --git a/src/event/handlers/stream/test/token.rs b/src/event/handlers/stream/test/token.rs new file mode 100644 index 0000000..2008323 --- /dev/null +++ b/src/event/handlers/stream/test/token.rs @@ -0,0 +1,148 @@ +use axum::extract::State; +use axum_extra::extract::Query; +use futures::{future, stream::StreamExt as _}; + +use crate::test::fixtures::{self, future::Expect as _}; + +#[tokio::test] +async fn terminates_on_token_expiry() { + // Set up the environment + + let app = fixtures::scratch_app().await; + let channel = fixtures::channel::create(&app, &fixtures::now()).await; + let sender = fixtures::user::create(&app, &fixtures::now()).await; + let resume_point = fixtures::boot::resume_point(&app).await; + + // Subscribe via the endpoint + + let subscriber_creds = fixtures::user::create_with_password(&app, &fixtures::now()).await; + let subscriber = + fixtures::identity::logged_in(&app, &subscriber_creds, &fixtures::ancient()).await; + + let super::Response(events) = super::handler( + State(app.clone()), + subscriber, + None, + Query(super::QueryParams { resume_point }), + ) + .await + .expect("subscribe never fails"); + + // Verify the resulting stream's behaviour + + app.tokens() + .expire(&fixtures::now()) + .await + .expect("expiring tokens succeeds"); + + // These should not be delivered. + let messages = [ + fixtures::message::send(&app, &channel, &sender, &fixtures::now()).await, + fixtures::message::send(&app, &channel, &sender, &fixtures::now()).await, + fixtures::message::send(&app, &channel, &sender, &fixtures::now()).await, + ]; + + events + .filter_map(fixtures::event::message) + .filter_map(fixtures::event::message::sent) + .filter(|event| future::ready(messages.iter().any(|message| &event.message == message))) + .next() + .expect_none("end of stream") + .await; +} + +#[tokio::test] +async fn terminates_on_logout() { + // Set up the environment + + let app = fixtures::scratch_app().await; + let channel = fixtures::channel::create(&app, &fixtures::now()).await; + let sender = fixtures::user::create(&app, &fixtures::now()).await; + let resume_point = fixtures::boot::resume_point(&app).await; + + // Subscribe via the endpoint + + let subscriber = fixtures::identity::create(&app, &fixtures::now()).await; + + let super::Response(events) = super::handler( + State(app.clone()), + subscriber.clone(), + None, + Query(super::QueryParams { resume_point }), + ) + .await + .expect("subscribe never fails"); + + // Verify the resulting stream's behaviour + + app.tokens() + .logout(&subscriber.token) + .await + .expect("expiring tokens succeeds"); + + // These should not be delivered. + + let messages = [ + fixtures::message::send(&app, &channel, &sender, &fixtures::now()).await, + fixtures::message::send(&app, &channel, &sender, &fixtures::now()).await, + fixtures::message::send(&app, &channel, &sender, &fixtures::now()).await, + ]; + + events + .filter_map(fixtures::event::message) + .filter_map(fixtures::event::message::sent) + .filter(|event| future::ready(messages.iter().any(|message| &event.message == message))) + .next() + .expect_none("end of stream") + .await; +} + +#[tokio::test] +async fn terminates_on_password_change() { + // Set up the environment + + let app = fixtures::scratch_app().await; + let channel = fixtures::channel::create(&app, &fixtures::now()).await; + let sender = fixtures::user::create(&app, &fixtures::now()).await; + let resume_point = fixtures::boot::resume_point(&app).await; + + // Subscribe via the endpoint + + let creds = fixtures::user::create_with_password(&app, &fixtures::now()).await; + let cookie = fixtures::cookie::logged_in(&app, &creds, &fixtures::now()).await; + let subscriber = fixtures::identity::from_cookie(&app, &cookie, &fixtures::now()).await; + + let super::Response(events) = super::handler( + State(app.clone()), + subscriber.clone(), + None, + Query(super::QueryParams { resume_point }), + ) + .await + .expect("subscribe never fails"); + + // Verify the resulting stream's behaviour + + let (_, password) = creds; + let to = fixtures::user::propose_password(); + app.tokens() + .change_password(&subscriber.user, &password, &to, &fixtures::now()) + .await + .expect("expiring tokens succeeds"); + + // These should not be delivered. + + let messages = [ + fixtures::message::send(&app, &channel, &sender, &fixtures::now()).await, + fixtures::message::send(&app, &channel, &sender, &fixtures::now()).await, + fixtures::message::send(&app, &channel, &sender, &fixtures::now()).await, + ]; + + events + .filter_map(fixtures::event::message) + .filter_map(fixtures::event::message::sent) + .filter(|event| future::ready(messages.iter().any(|message| &event.message == message))) + .next() + .expect_none("end of stream") + .await; +} diff --git a/src/event/mod.rs b/src/event/mod.rs index ff30dc7..6657243 100644 --- a/src/event/mod.rs +++ b/src/event/mod.rs @@ -7,8 +7,8 @@ use crate::{channel, message, user}; pub mod app; mod broadcaster; mod extract; +pub mod handlers; pub mod repo; -pub mod routes; mod sequence; pub use self::{ diff --git a/src/event/routes/get.rs b/src/event/routes/get.rs deleted file mode 100644 index f6c91fa..0000000 --- a/src/event/routes/get.rs +++ /dev/null @@ -1,82 +0,0 @@ -use axum::{ - extract::State, - response::{ - self, IntoResponse, - sse::{self, Sse}, - }, -}; -use axum_extra::extract::Query; -use futures::stream::{Stream, StreamExt as _}; - -use crate::{ - app::App, - error::{Internal, Unauthorized}, - event::{Event, Heartbeat::Heartbeat, Sequence, Sequenced as _, app, extract::LastEventId}, - token::{app::ValidateError, extract::Identity}, -}; - -pub async fn handler( - State(app): State, - identity: Identity, - last_event_id: Option>, - Query(query): Query, -) -> Result + std::fmt::Debug>, Error> { - let resume_at = last_event_id.map_or(query.resume_point, LastEventId::into_inner); - - let stream = app.events().subscribe(resume_at).await?; - let stream = app.tokens().limit_stream(identity.token, stream).await?; - - Ok(Response(stream)) -} - -#[derive(serde::Deserialize)] -pub struct QueryParams { - pub resume_point: Sequence, -} - -#[derive(Debug)] -pub struct Response(pub S); - -impl IntoResponse for Response -where - S: Stream + Send + 'static, -{ - fn into_response(self) -> response::Response { - let Self(stream) = self; - let stream = stream.map(sse::Event::try_from); - let heartbeat = match Heartbeat.try_into().map_err(Internal::from) { - Ok(heartbeat) => heartbeat, - Err(err) => return err.into_response(), - }; - Sse::new(stream).keep_alive(heartbeat).into_response() - } -} - -impl TryFrom for sse::Event { - type Error = serde_json::Error; - - fn try_from(event: Event) -> Result { - let id = serde_json::to_string(&event.sequence())?; - let data = serde_json::to_string_pretty(&event)?; - - let event = Self::default().id(id).data(data); - - Ok(event) - } -} - -#[derive(Debug, thiserror::Error)] -#[error(transparent)] -pub enum Error { - Subscribe(#[from] app::Error), - Validate(#[from] ValidateError), -} - -impl IntoResponse for Error { - fn into_response(self) -> response::Response { - match self { - Self::Validate(ValidateError::InvalidToken) => Unauthorized.into_response(), - other => Internal::from(other).into_response(), - } - } -} diff --git a/src/event/routes/mod.rs b/src/event/routes/mod.rs deleted file mode 100644 index 60ad5d8..0000000 --- a/src/event/routes/mod.rs +++ /dev/null @@ -1,3 +0,0 @@ -pub mod get; -#[cfg(test)] -mod test; diff --git a/src/event/routes/test/channel.rs b/src/event/routes/test/channel.rs deleted file mode 100644 index 0695ab1..0000000 --- a/src/event/routes/test/channel.rs +++ /dev/null @@ -1,276 +0,0 @@ -use axum::extract::State; -use axum_extra::extract::Query; -use futures::{future, stream::StreamExt as _}; - -use crate::{ - event::routes::get, - test::fixtures::{self, future::Expect as _}, -}; - -#[tokio::test] -async fn creating() { - // Set up the environment - - let app = fixtures::scratch_app().await; - let resume_point = fixtures::boot::resume_point(&app).await; - - // Subscribe - - let subscriber = fixtures::identity::create(&app, &fixtures::now()).await; - let get::Response(events) = get::handler( - State(app.clone()), - subscriber, - None, - Query(get::QueryParams { resume_point }), - ) - .await - .expect("subscribe never fails"); - - // Create a channel - - let name = fixtures::channel::propose(); - let channel = app - .channels() - .create(&name, &fixtures::now()) - .await - .expect("creating a channel succeeds"); - - // Verify channel created event - - events - .filter_map(fixtures::event::channel) - .filter_map(fixtures::event::channel::created) - .filter(|event| future::ready(event.channel == channel)) - .next() - .expect_some("channel created event is delivered") - .await; -} - -#[tokio::test] -async fn previously_created() { - // Set up the environment - - let app = fixtures::scratch_app().await; - let resume_point = fixtures::boot::resume_point(&app).await; - - // Create a channel - - let name = fixtures::channel::propose(); - let channel = app - .channels() - .create(&name, &fixtures::now()) - .await - .expect("creating a channel succeeds"); - - // Subscribe - - let subscriber = fixtures::identity::create(&app, &fixtures::now()).await; - let get::Response(events) = get::handler( - State(app.clone()), - subscriber, - None, - Query(get::QueryParams { resume_point }), - ) - .await - .expect("subscribe never fails"); - - // Verify channel created event - - let _ = events - .filter_map(fixtures::event::channel) - .filter_map(fixtures::event::channel::created) - .filter(|event| future::ready(event.channel == channel)) - .next() - .expect_some("channel created event is delivered") - .await; -} - -#[tokio::test] -async fn expiring() { - // Set up the environment - - let app = fixtures::scratch_app().await; - let channel = fixtures::channel::create(&app, &fixtures::ancient()).await; - let resume_point = fixtures::boot::resume_point(&app).await; - - // Subscribe - - let subscriber = fixtures::identity::create(&app, &fixtures::now()).await; - let get::Response(events) = get::handler( - State(app.clone()), - subscriber, - None, - Query(get::QueryParams { resume_point }), - ) - .await - .expect("subscribe never fails"); - - // Expire channels - - app.channels() - .expire(&fixtures::now()) - .await - .expect("expiring channels always succeeds"); - - // Check for expiry event - let _ = events - .filter_map(fixtures::event::channel) - .filter_map(fixtures::event::channel::deleted) - .filter(|event| future::ready(event.id == channel.id)) - .next() - .expect_some("a deleted channel event will be delivered") - .await; -} - -#[tokio::test] -async fn previously_expired() { - // Set up the environment - - let app = fixtures::scratch_app().await; - let channel = fixtures::channel::create(&app, &fixtures::ancient()).await; - let resume_point = fixtures::boot::resume_point(&app).await; - - // Expire channels - - app.channels() - .expire(&fixtures::now()) - .await - .expect("expiring channels always succeeds"); - - // Subscribe - - let subscriber = fixtures::identity::create(&app, &fixtures::now()).await; - let get::Response(events) = get::handler( - State(app.clone()), - subscriber, - None, - Query(get::QueryParams { resume_point }), - ) - .await - .expect("subscribe never fails"); - - // Check for expiry event - let _ = events - .filter_map(fixtures::event::channel) - .filter_map(fixtures::event::channel::deleted) - .filter(|event| future::ready(event.id == channel.id)) - .next() - .expect_some("a deleted channel event will be delivered") - .await; -} - -#[tokio::test] -async fn deleting() { - // Set up the environment - - let app = fixtures::scratch_app().await; - let channel = fixtures::channel::create(&app, &fixtures::now()).await; - let resume_point = fixtures::boot::resume_point(&app).await; - - // Subscribe - - let subscriber = fixtures::identity::create(&app, &fixtures::now()).await; - let get::Response(events) = get::handler( - State(app.clone()), - subscriber, - None, - Query(get::QueryParams { resume_point }), - ) - .await - .expect("subscribe never fails"); - - // Delete the channel - - app.channels() - .delete(&channel.id, &fixtures::now()) - .await - .expect("deleting a valid channel succeeds"); - - // Check for delete event - let _ = events - .filter_map(fixtures::event::channel) - .filter_map(fixtures::event::channel::deleted) - .filter(|event| future::ready(event.id == channel.id)) - .next() - .expect_some("a deleted channel event will be delivered") - .await; -} - -#[tokio::test] -async fn previously_deleted() { - // Set up the environment - - let app = fixtures::scratch_app().await; - let channel = fixtures::channel::create(&app, &fixtures::now()).await; - let resume_point = fixtures::boot::resume_point(&app).await; - - // Delete the channel - - app.channels() - .delete(&channel.id, &fixtures::now()) - .await - .expect("deleting a valid channel succeeds"); - - // Subscribe - - let subscriber = fixtures::identity::create(&app, &fixtures::now()).await; - let get::Response(events) = get::handler( - State(app.clone()), - subscriber, - None, - Query(get::QueryParams { resume_point }), - ) - .await - .expect("subscribe never fails"); - - // Check for expiry event - let _ = events - .filter_map(fixtures::event::channel) - .filter_map(fixtures::event::channel::deleted) - .filter(|event| future::ready(event.id == channel.id)) - .next() - .expect_some("a deleted channel event will be delivered") - .await; -} - -#[tokio::test] -async fn previously_purged() { - // Set up the environment - - let app = fixtures::scratch_app().await; - let channel = fixtures::channel::create(&app, &fixtures::ancient()).await; - let resume_point = fixtures::boot::resume_point(&app).await; - - // Delete and purge the channel - - app.channels() - .delete(&channel.id, &fixtures::ancient()) - .await - .expect("deleting a valid channel succeeds"); - - app.channels() - .purge(&fixtures::now()) - .await - .expect("purging channels always succeeds"); - - // Subscribe - - let subscriber = fixtures::identity::create(&app, &fixtures::now()).await; - let get::Response(events) = get::handler( - State(app.clone()), - subscriber, - None, - Query(get::QueryParams { resume_point }), - ) - .await - .expect("subscribe never fails"); - - // Check for expiry event - events - .filter_map(fixtures::event::channel) - .filter_map(fixtures::event::channel::deleted) - .filter(|event| future::ready(event.id == channel.id)) - .next() - .expect_wait("deleted channel events not delivered") - .await; -} diff --git a/src/event/routes/test/invite.rs b/src/event/routes/test/invite.rs deleted file mode 100644 index 1d1bec6..0000000 --- a/src/event/routes/test/invite.rs +++ /dev/null @@ -1,90 +0,0 @@ -use axum::extract::State; -use axum_extra::extract::Query; -use futures::{future, stream::StreamExt as _}; - -use crate::{ - event::routes::get, - test::fixtures::{self, future::Expect as _}, -}; - -#[tokio::test] -async fn accepting_invite() { - // Set up the environment - - let app = fixtures::scratch_app().await; - let issuer = fixtures::user::create(&app, &fixtures::now()).await; - let invite = fixtures::invite::issue(&app, &issuer, &fixtures::now()).await; - let resume_point = fixtures::boot::resume_point(&app).await; - - // Subscribe - - let subscriber = fixtures::identity::create(&app, &fixtures::now()).await; - let get::Response(events) = get::handler( - State(app.clone()), - subscriber, - None, - Query(get::QueryParams { resume_point }), - ) - .await - .expect("subscribe never fails"); - - // Accept the invite - - let (name, password) = fixtures::user::propose(); - let (joiner, _) = app - .invites() - .accept(&invite.id, &name, &password, &fixtures::now()) - .await - .expect("accepting an invite succeeds"); - - // Expect a login created event - - let _ = events - .filter_map(fixtures::event::user) - .filter_map(fixtures::event::user::created) - .filter(|event| future::ready(event.user == joiner)) - .next() - .expect_some("a login created event is sent") - .await; -} - -#[tokio::test] -async fn previously_accepted_invite() { - // Set up the environment - - let app = fixtures::scratch_app().await; - let issuer = fixtures::user::create(&app, &fixtures::now()).await; - let invite = fixtures::invite::issue(&app, &issuer, &fixtures::now()).await; - let resume_point = fixtures::boot::resume_point(&app).await; - - // Accept the invite - - let (name, password) = fixtures::user::propose(); - let (joiner, _) = app - .invites() - .accept(&invite.id, &name, &password, &fixtures::now()) - .await - .expect("accepting an invite succeeds"); - - // Subscribe - - let subscriber = fixtures::identity::create(&app, &fixtures::now()).await; - let get::Response(events) = get::handler( - State(app.clone()), - subscriber, - None, - Query(get::QueryParams { resume_point }), - ) - .await - .expect("subscribe never fails"); - - // Expect a login created event - - let _ = events - .filter_map(fixtures::event::user) - .filter_map(fixtures::event::user::created) - .filter(|event| future::ready(event.user == joiner)) - .next() - .expect_some("a login created event is sent") - .await; -} diff --git a/src/event/routes/test/message.rs b/src/event/routes/test/message.rs deleted file mode 100644 index 84a3aec..0000000 --- a/src/event/routes/test/message.rs +++ /dev/null @@ -1,396 +0,0 @@ -use axum::extract::State; -use axum_extra::extract::Query; -use futures::{ - future, - stream::{self, StreamExt as _}, -}; - -use crate::{ - event::routes::get, - test::fixtures::{self, future::Expect as _}, -}; - -#[tokio::test] -async fn sending() { - // Set up the environment - - let app = fixtures::scratch_app().await; - let channel = fixtures::channel::create(&app, &fixtures::now()).await; - let resume_point = fixtures::boot::resume_point(&app).await; - - // Call the endpoint - - let subscriber = fixtures::identity::create(&app, &fixtures::now()).await; - let get::Response(events) = get::handler( - State(app.clone()), - subscriber, - None, - Query(get::QueryParams { resume_point }), - ) - .await - .expect("subscribe never fails"); - - // Send a message - - let sender = fixtures::user::create(&app, &fixtures::now()).await; - let message = app - .messages() - .send( - &channel.id, - &sender, - &fixtures::now(), - &fixtures::message::propose(), - ) - .await - .expect("sending a message succeeds"); - - // Verify that an event is delivered - - let _ = events - .filter_map(fixtures::event::message) - .filter_map(fixtures::event::message::sent) - .filter(|event| future::ready(event.message == message)) - .next() - .expect_some("delivered message sent event") - .await; -} - -#[tokio::test] -async fn previously_sent() { - // Set up the environment - - let app = fixtures::scratch_app().await; - let channel = fixtures::channel::create(&app, &fixtures::now()).await; - let resume_point = fixtures::boot::resume_point(&app).await; - - // Send a message - - let sender = fixtures::user::create(&app, &fixtures::now()).await; - let message = app - .messages() - .send( - &channel.id, - &sender, - &fixtures::now(), - &fixtures::message::propose(), - ) - .await - .expect("sending a message succeeds"); - - // Call the endpoint - - let subscriber = fixtures::identity::create(&app, &fixtures::now()).await; - let get::Response(events) = get::handler( - State(app.clone()), - subscriber, - None, - Query(get::QueryParams { resume_point }), - ) - .await - .expect("subscribe never fails"); - - // Verify that an event is delivered - - let _ = events - .filter_map(fixtures::event::message) - .filter_map(fixtures::event::message::sent) - .filter(|event| future::ready(event.message == message)) - .next() - .expect_some("delivered message sent event") - .await; -} - -#[tokio::test] -async fn sent_in_multiple_channels() { - // Set up the environment - - let app = fixtures::scratch_app().await; - let sender = fixtures::user::create(&app, &fixtures::now()).await; - let resume_point = fixtures::boot::resume_point(&app).await; - - let channels = [ - fixtures::channel::create(&app, &fixtures::now()).await, - fixtures::channel::create(&app, &fixtures::now()).await, - ]; - - let messages = stream::iter(channels) - .then(|channel| { - let app = app.clone(); - let sender = sender.clone(); - let channel = channel.clone(); - async move { fixtures::message::send(&app, &channel, &sender, &fixtures::now()).await } - }) - .collect::>() - .await; - - // Call the endpoint - - let subscriber = fixtures::identity::create(&app, &fixtures::now()).await; - let get::Response(events) = get::handler( - State(app), - subscriber, - None, - Query(get::QueryParams { resume_point }), - ) - .await - .expect("subscribe never fails"); - - // Verify the structure of the response. - - let events = events - .filter_map(fixtures::event::message) - .filter_map(fixtures::event::message::sent) - .take(messages.len()) - .collect::>() - .expect_ready("events ready") - .await; - - for message in &messages { - assert!(events.iter().any(|event| &event.message == message)); - } -} - -#[tokio::test] -async fn sent_sequentially() { - // Set up the environment - - let app = fixtures::scratch_app().await; - let channel = fixtures::channel::create(&app, &fixtures::now()).await; - let sender = fixtures::user::create(&app, &fixtures::now()).await; - let resume_point = fixtures::boot::resume_point(&app).await; - - let messages = vec![ - fixtures::message::send(&app, &channel, &sender, &fixtures::now()).await, - fixtures::message::send(&app, &channel, &sender, &fixtures::now()).await, - fixtures::message::send(&app, &channel, &sender, &fixtures::now()).await, - ]; - - // Subscribe - - let subscriber = fixtures::identity::create(&app, &fixtures::now()).await; - let get::Response(events) = get::handler( - State(app), - subscriber, - None, - Query(get::QueryParams { resume_point }), - ) - .await - .expect("subscribe never fails"); - - // Verify the expected events in the expected order - - let mut events = events - .filter_map(fixtures::event::message) - .filter_map(fixtures::event::message::sent) - .filter(|event| future::ready(messages.iter().any(|message| &event.message == message))); - - for message in &messages { - let event = events - .next() - .expect_some("undelivered messages remaining") - .await; - - assert_eq!(message, &event.message); - } -} - -#[tokio::test] -async fn expiring() { - // Set up the environment - - let app = fixtures::scratch_app().await; - let channel = fixtures::channel::create(&app, &fixtures::ancient()).await; - let sender = fixtures::user::create(&app, &fixtures::ancient()).await; - let message = fixtures::message::send(&app, &channel, &sender, &fixtures::ancient()).await; - let resume_point = fixtures::boot::resume_point(&app).await; - - // Subscribe - - let subscriber = fixtures::identity::create(&app, &fixtures::now()).await; - let get::Response(events) = get::handler( - State(app.clone()), - subscriber, - None, - Query(get::QueryParams { resume_point }), - ) - .await - .expect("subscribe never fails"); - - // Expire messages - - app.messages() - .expire(&fixtures::now()) - .await - .expect("expiring messages always succeeds"); - - // Check for expiry event - let _ = events - .filter_map(fixtures::event::message) - .filter_map(fixtures::event::message::deleted) - .filter(|event| future::ready(event.id == message.id)) - .next() - .expect_some("a deleted message event will be delivered") - .await; -} - -#[tokio::test] -async fn previously_expired() { - // Set up the environment - - let app = fixtures::scratch_app().await; - let channel = fixtures::channel::create(&app, &fixtures::ancient()).await; - let sender = fixtures::user::create(&app, &fixtures::ancient()).await; - let message = fixtures::message::send(&app, &channel, &sender, &fixtures::ancient()).await; - let resume_point = fixtures::boot::resume_point(&app).await; - - // Expire messages - - app.messages() - .expire(&fixtures::now()) - .await - .expect("expiring messages always succeeds"); - - // Subscribe - - let subscriber = fixtures::identity::create(&app, &fixtures::now()).await; - let get::Response(events) = get::handler( - State(app.clone()), - subscriber, - None, - Query(get::QueryParams { resume_point }), - ) - .await - .expect("subscribe never fails"); - - // Check for expiry event - let _ = events - .filter_map(fixtures::event::message) - .filter_map(fixtures::event::message::deleted) - .filter(|event| future::ready(event.id == message.id)) - .next() - .expect_some("a deleted message event will be delivered") - .await; -} - -#[tokio::test] -async fn deleting() { - // Set up the environment - - let app = fixtures::scratch_app().await; - let channel = fixtures::channel::create(&app, &fixtures::now()).await; - let sender = fixtures::user::create(&app, &fixtures::now()).await; - let message = fixtures::message::send(&app, &channel, &sender, &fixtures::now()).await; - let resume_point = fixtures::boot::resume_point(&app).await; - - // Subscribe - - let subscriber = fixtures::identity::create(&app, &fixtures::now()).await; - let get::Response(events) = get::handler( - State(app.clone()), - subscriber, - None, - Query(get::QueryParams { resume_point }), - ) - .await - .expect("subscribe never fails"); - - // Delete the message - - app.messages() - .delete(&sender, &message.id, &fixtures::now()) - .await - .expect("deleting a valid message succeeds"); - - // Check for delete event - let _ = events - .filter_map(fixtures::event::message) - .filter_map(fixtures::event::message::deleted) - .filter(|event| future::ready(event.id == message.id)) - .next() - .expect_some("a deleted message event will be delivered") - .await; -} - -#[tokio::test] -async fn previously_deleted() { - // Set up the environment - - let app = fixtures::scratch_app().await; - let channel = fixtures::channel::create(&app, &fixtures::now()).await; - let sender = fixtures::user::create(&app, &fixtures::now()).await; - let message = fixtures::message::send(&app, &channel, &sender, &fixtures::now()).await; - let resume_point = fixtures::boot::resume_point(&app).await; - - // Delete the message - - app.messages() - .delete(&sender, &message.id, &fixtures::now()) - .await - .expect("deleting a valid message succeeds"); - - // Subscribe - - let subscriber = fixtures::identity::create(&app, &fixtures::now()).await; - let get::Response(events) = get::handler( - State(app.clone()), - subscriber, - None, - Query(get::QueryParams { resume_point }), - ) - .await - .expect("subscribe never fails"); - - // Check for delete event - let _ = events - .filter_map(fixtures::event::message) - .filter_map(fixtures::event::message::deleted) - .filter(|event| future::ready(event.id == message.id)) - .next() - .expect_some("a deleted message event will be delivered") - .await; -} - -#[tokio::test] -async fn previously_purged() { - // Set up the environment - - let app = fixtures::scratch_app().await; - let channel = fixtures::channel::create(&app, &fixtures::ancient()).await; - let sender = fixtures::user::create(&app, &fixtures::ancient()).await; - let message = fixtures::message::send(&app, &channel, &sender, &fixtures::ancient()).await; - let resume_point = fixtures::boot::resume_point(&app).await; - - // Purge the message - - app.messages() - .delete(&sender, &message.id, &fixtures::ancient()) - .await - .expect("deleting a valid message succeeds"); - - app.messages() - .purge(&fixtures::now()) - .await - .expect("purge always succeeds"); - - // Subscribe - - let subscriber = fixtures::identity::create(&app, &fixtures::now()).await; - let get::Response(events) = get::handler( - State(app.clone()), - subscriber, - None, - Query(get::QueryParams { resume_point }), - ) - .await - .expect("subscribe never fails"); - - // Check for delete event - - events - .filter_map(fixtures::event::message) - .filter_map(fixtures::event::message::deleted) - .filter(|event| future::ready(event.id == message.id)) - .next() - .expect_wait("no deleted message will be delivered") - .await; -} diff --git a/src/event/routes/test/mod.rs b/src/event/routes/test/mod.rs deleted file mode 100644 index e7e35f1..0000000 --- a/src/event/routes/test/mod.rs +++ /dev/null @@ -1,6 +0,0 @@ -mod channel; -mod invite; -mod message; -mod resume; -mod setup; -mod token; diff --git a/src/event/routes/test/resume.rs b/src/event/routes/test/resume.rs deleted file mode 100644 index 633eae3..0000000 --- a/src/event/routes/test/resume.rs +++ /dev/null @@ -1,227 +0,0 @@ -use std::future; - -use axum::extract::State; -use axum_extra::extract::Query; -use futures::stream::{self, StreamExt as _}; - -use crate::{ - event::{Sequenced as _, routes::get}, - test::fixtures::{self, future::Expect as _}, -}; - -#[tokio::test] -async fn resume() { - // Set up the environment - - let app = fixtures::scratch_app().await; - let channel = fixtures::channel::create(&app, &fixtures::now()).await; - let sender = fixtures::user::create(&app, &fixtures::now()).await; - let resume_point = fixtures::boot::resume_point(&app).await; - - let initial_message = fixtures::message::send(&app, &channel, &sender, &fixtures::now()).await; - - let later_messages = vec![ - fixtures::message::send(&app, &channel, &sender, &fixtures::now()).await, - fixtures::message::send(&app, &channel, &sender, &fixtures::now()).await, - ]; - - // Call the endpoint - - let subscriber = fixtures::identity::create(&app, &fixtures::now()).await; - - let resume_at = { - // First subscription - let get::Response(events) = get::handler( - State(app.clone()), - subscriber.clone(), - None, - Query(get::QueryParams { resume_point }), - ) - .await - .expect("subscribe never fails"); - - let event = events - .filter_map(fixtures::event::message) - .filter_map(fixtures::event::message::sent) - .filter(|event| future::ready(event.message == initial_message)) - .next() - .expect_some("delivered event for initial message") - .await; - - event.sequence() - }; - - // Resume after disconnect - let get::Response(resumed) = get::handler( - State(app), - subscriber, - Some(resume_at.into()), - Query(get::QueryParams { resume_point }), - ) - .await - .expect("subscribe never fails"); - - // Verify final events - - let mut events = resumed - .filter_map(fixtures::event::message) - .filter_map(fixtures::event::message::sent) - .zip(stream::iter(later_messages)); - - while let Some((event, message)) = events.next().expect_ready("event ready").await { - assert_eq!(message, event.message); - } -} - -// This test verifies a real bug I hit developing the vector-of-sequences -// approach to resuming events. A small omission caused the event IDs in a -// resumed stream to _omit_ channels that were in the original stream until -// those channels also appeared in the resumed stream. -// -// Clients would see something like -// * In the original stream, Cfoo=5,Cbar=8 -// * In the resumed stream, Cfoo=6 (no Cbar sequence number) -// -// Disconnecting and reconnecting a second time, using event IDs from that -// initial period of the first resume attempt, would then cause the second -// resume attempt to restart all other channels from the beginning, and not -// from where the first disconnection happened. -// -// As we have switched to a single global event sequence number, this scenario -// can no longer arise, but this test is preserved because the actual behaviour -// _is_ a valid way for clients to behave, and should work. We might as well -// keep testing it. -#[tokio::test] -async fn serial_resume() { - // Set up the environment - - let app = fixtures::scratch_app().await; - let sender = fixtures::user::create(&app, &fixtures::now()).await; - let channel_a = fixtures::channel::create(&app, &fixtures::now()).await; - let channel_b = fixtures::channel::create(&app, &fixtures::now()).await; - let resume_point = fixtures::boot::resume_point(&app).await; - - // Call the endpoint - - let subscriber = fixtures::identity::create(&app, &fixtures::now()).await; - - let resume_at = { - let initial_messages = [ - fixtures::message::send(&app, &channel_a, &sender, &fixtures::now()).await, - fixtures::message::send(&app, &channel_b, &sender, &fixtures::now()).await, - ]; - - // First subscription - - let get::Response(events) = get::handler( - State(app.clone()), - subscriber.clone(), - None, - Query(get::QueryParams { resume_point }), - ) - .await - .expect("subscribe never fails"); - - // Check for expected events - - let events = events - .filter_map(fixtures::event::message) - .filter_map(fixtures::event::message::sent) - .zip(stream::iter(initial_messages)) - .collect::>() - .expect_ready("zipping a finite list of events is ready immediately") - .await; - - assert!( - events - .iter() - .all(|(event, message)| message == &event.message) - ); - - let (event, _) = events.last().expect("this vec is non-empty"); - - // Take the last one's resume point - - event.sequence() - }; - - // Resume after disconnect - let resume_at = { - let resume_messages = [ - // Note that channel_b does not appear here. The buggy behaviour - // would be masked if channel_b happened to send a new message - // into the resumed event stream. - fixtures::message::send(&app, &channel_a, &sender, &fixtures::now()).await, - fixtures::message::send(&app, &channel_a, &sender, &fixtures::now()).await, - ]; - - // Second subscription - let get::Response(events) = get::handler( - State(app.clone()), - subscriber.clone(), - Some(resume_at.into()), - Query(get::QueryParams { resume_point }), - ) - .await - .expect("subscribe never fails"); - - // Check for expected events - - let events = events - .filter_map(fixtures::event::message) - .filter_map(fixtures::event::message::sent) - .zip(stream::iter(resume_messages)) - .collect::>() - .expect_ready("zipping a finite list of events is ready immediately") - .await; - - assert!( - events - .iter() - .all(|(event, message)| message == &event.message) - ); - - let (event, _) = events.last().expect("this vec is non-empty"); - - // Take the last one's resume point - - event.sequence() - }; - - // Resume after disconnect a second time - { - // At this point, we can send on either channel and demonstrate the - // problem. The resume point should before both of these messages, but - // after _all_ prior messages. - let final_messages = [ - fixtures::message::send(&app, &channel_a, &sender, &fixtures::now()).await, - fixtures::message::send(&app, &channel_b, &sender, &fixtures::now()).await, - ]; - - // Third subscription - let get::Response(events) = get::handler( - State(app.clone()), - subscriber.clone(), - Some(resume_at.into()), - Query(get::QueryParams { resume_point }), - ) - .await - .expect("subscribe never fails"); - - // Check for expected events - - let events = events - .filter_map(fixtures::event::message) - .filter_map(fixtures::event::message::sent) - .zip(stream::iter(final_messages)) - .collect::>() - .expect_ready("zipping a finite list of events is ready immediately") - .await; - - assert!( - events - .iter() - .all(|(event, message)| message == &event.message) - ); - }; -} diff --git a/src/event/routes/test/setup.rs b/src/event/routes/test/setup.rs deleted file mode 100644 index 1170fe4..0000000 --- a/src/event/routes/test/setup.rs +++ /dev/null @@ -1,50 +0,0 @@ -use axum::extract::State; -use axum_extra::extract::Query; -use futures::{future, stream::StreamExt as _}; - -use crate::{ - event::routes::get, - test::fixtures::{self, future::Expect as _}, -}; - -// There's no test for this in subscribe-then-setup order because creating an -// identity to subscribe with also completes initial setup, preventing the -// test from running. That is also a can't-happen scenario in reality. -#[tokio::test] -async fn previously_completed() { - // Set up the environment - - let app = fixtures::scratch_app().await; - let resume_point = fixtures::boot::resume_point(&app).await; - - // Complete initial setup - - let (name, password) = fixtures::user::propose(); - let (owner, _) = app - .setup() - .initial(&name, &password, &fixtures::now()) - .await - .expect("initial setup in an empty app succeeds"); - - // Subscribe to events - - let subscriber = fixtures::identity::create(&app, &fixtures::now()).await; - let get::Response(events) = get::handler( - State(app.clone()), - subscriber, - None, - Query(get::QueryParams { resume_point }), - ) - .await - .expect("subscribe never fails"); - - // Expect a login created event - - let _ = events - .filter_map(fixtures::event::user) - .filter_map(fixtures::event::user::created) - .filter(|event| future::ready(event.user == owner)) - .next() - .expect_some("a login created event is sent") - .await; -} diff --git a/src/event/routes/test/token.rs b/src/event/routes/test/token.rs deleted file mode 100644 index a467de5..0000000 --- a/src/event/routes/test/token.rs +++ /dev/null @@ -1,151 +0,0 @@ -use axum::extract::State; -use axum_extra::extract::Query; -use futures::{future, stream::StreamExt as _}; - -use crate::{ - event::routes::get, - test::fixtures::{self, future::Expect as _}, -}; - -#[tokio::test] -async fn terminates_on_token_expiry() { - // Set up the environment - - let app = fixtures::scratch_app().await; - let channel = fixtures::channel::create(&app, &fixtures::now()).await; - let sender = fixtures::user::create(&app, &fixtures::now()).await; - let resume_point = fixtures::boot::resume_point(&app).await; - - // Subscribe via the endpoint - - let subscriber_creds = fixtures::user::create_with_password(&app, &fixtures::now()).await; - let subscriber = - fixtures::identity::logged_in(&app, &subscriber_creds, &fixtures::ancient()).await; - - let get::Response(events) = get::handler( - State(app.clone()), - subscriber, - None, - Query(get::QueryParams { resume_point }), - ) - .await - .expect("subscribe never fails"); - - // Verify the resulting stream's behaviour - - app.tokens() - .expire(&fixtures::now()) - .await - .expect("expiring tokens succeeds"); - - // These should not be delivered. - let messages = [ - fixtures::message::send(&app, &channel, &sender, &fixtures::now()).await, - fixtures::message::send(&app, &channel, &sender, &fixtures::now()).await, - fixtures::message::send(&app, &channel, &sender, &fixtures::now()).await, - ]; - - events - .filter_map(fixtures::event::message) - .filter_map(fixtures::event::message::sent) - .filter(|event| future::ready(messages.iter().any(|message| &event.message == message))) - .next() - .expect_none("end of stream") - .await; -} - -#[tokio::test] -async fn terminates_on_logout() { - // Set up the environment - - let app = fixtures::scratch_app().await; - let channel = fixtures::channel::create(&app, &fixtures::now()).await; - let sender = fixtures::user::create(&app, &fixtures::now()).await; - let resume_point = fixtures::boot::resume_point(&app).await; - - // Subscribe via the endpoint - - let subscriber = fixtures::identity::create(&app, &fixtures::now()).await; - - let get::Response(events) = get::handler( - State(app.clone()), - subscriber.clone(), - None, - Query(get::QueryParams { resume_point }), - ) - .await - .expect("subscribe never fails"); - - // Verify the resulting stream's behaviour - - app.tokens() - .logout(&subscriber.token) - .await - .expect("expiring tokens succeeds"); - - // These should not be delivered. - - let messages = [ - fixtures::message::send(&app, &channel, &sender, &fixtures::now()).await, - fixtures::message::send(&app, &channel, &sender, &fixtures::now()).await, - fixtures::message::send(&app, &channel, &sender, &fixtures::now()).await, - ]; - - events - .filter_map(fixtures::event::message) - .filter_map(fixtures::event::message::sent) - .filter(|event| future::ready(messages.iter().any(|message| &event.message == message))) - .next() - .expect_none("end of stream") - .await; -} - -#[tokio::test] -async fn terminates_on_password_change() { - // Set up the environment - - let app = fixtures::scratch_app().await; - let channel = fixtures::channel::create(&app, &fixtures::now()).await; - let sender = fixtures::user::create(&app, &fixtures::now()).await; - let resume_point = fixtures::boot::resume_point(&app).await; - - // Subscribe via the endpoint - - let creds = fixtures::user::create_with_password(&app, &fixtures::now()).await; - let cookie = fixtures::cookie::logged_in(&app, &creds, &fixtures::now()).await; - let subscriber = fixtures::identity::from_cookie(&app, &cookie, &fixtures::now()).await; - - let get::Response(events) = get::handler( - State(app.clone()), - subscriber.clone(), - None, - Query(get::QueryParams { resume_point }), - ) - .await - .expect("subscribe never fails"); - - // Verify the resulting stream's behaviour - - let (_, password) = creds; - let to = fixtures::user::propose_password(); - app.tokens() - .change_password(&subscriber.user, &password, &to, &fixtures::now()) - .await - .expect("expiring tokens succeeds"); - - // These should not be delivered. - - let messages = [ - fixtures::message::send(&app, &channel, &sender, &fixtures::now()).await, - fixtures::message::send(&app, &channel, &sender, &fixtures::now()).await, - fixtures::message::send(&app, &channel, &sender, &fixtures::now()).await, - ]; - - events - .filter_map(fixtures::event::message) - .filter_map(fixtures::event::message::sent) - .filter(|event| future::ready(messages.iter().any(|message| &event.message == message))) - .next() - .expect_none("end of stream") - .await; -} diff --git a/src/invite/handlers/accept/mod.rs b/src/invite/handlers/accept/mod.rs new file mode 100644 index 0000000..0d5f08a --- /dev/null +++ b/src/invite/handlers/accept/mod.rs @@ -0,0 +1,60 @@ +use axum::{ + extract::{Json, Path, State}, + http::StatusCode, + response::{IntoResponse, Response}, +}; + +use crate::{ + app::App, + clock::RequestedAt, + error::{Internal, NotFound}, + invite::{app, handlers::PathInfo}, + name::Name, + token::extract::IdentityCookie, + user::{Password, User}, +}; + +#[cfg(test)] +mod test; + +pub async fn handler( + State(app): State, + RequestedAt(accepted_at): RequestedAt, + identity: IdentityCookie, + Path(invite): Path, + Json(request): Json, +) -> Result<(IdentityCookie, Json), Error> { + let (login, secret) = app + .invites() + .accept(&invite, &request.name, &request.password, &accepted_at) + .await + .map_err(Error)?; + let identity = identity.set(secret); + Ok((identity, Json(login))) +} + +#[derive(serde::Deserialize)] +pub struct Request { + pub name: Name, + pub password: Password, +} + +#[derive(Debug, thiserror::Error)] +#[error(transparent)] +pub struct Error(pub app::AcceptError); + +impl IntoResponse for Error { + fn into_response(self) -> Response { + let Self(error) = self; + match error { + app::AcceptError::NotFound(_) => NotFound(error).into_response(), + app::AcceptError::InvalidName(_) => { + (StatusCode::BAD_REQUEST, error.to_string()).into_response() + } + app::AcceptError::DuplicateLogin(_) => { + (StatusCode::CONFLICT, error.to_string()).into_response() + } + other => Internal::from(other).into_response(), + } + } +} diff --git a/src/invite/handlers/accept/test.rs b/src/invite/handlers/accept/test.rs new file mode 100644 index 0000000..cb13900 --- /dev/null +++ b/src/invite/handlers/accept/test.rs @@ -0,0 +1,236 @@ +use axum::extract::{Json, Path, State}; + +use crate::{invite::app::AcceptError, name::Name, test::fixtures}; + +#[tokio::test] +async fn valid_invite() { + // Set up the environment + + let app = fixtures::scratch_app().await; + let issuer = fixtures::user::create(&app, &fixtures::now()).await; + let invite = fixtures::invite::issue(&app, &issuer, &fixtures::now()).await; + + // Call the endpoint + + let (name, password) = fixtures::user::propose(); + let identity = fixtures::cookie::not_logged_in(); + let request = super::Request { + name: name.clone(), + password: password.clone(), + }; + let (identity, Json(response)) = super::handler( + State(app.clone()), + fixtures::now(), + identity, + Path(invite.id), + Json(request), + ) + .await + .expect("accepting a valid invite succeeds"); + + // Verify the response + + assert!(identity.secret().is_some()); + assert_eq!(name, response.name); + + // Verify that the issued token is valid + + let secret = identity + .secret() + .expect("newly-issued identity has a token secret"); + let (_, login) = app + .tokens() + .validate(&secret, &fixtures::now()) + .await + .expect("newly-issued identity cookie is valid"); + assert_eq!(response, login); + + // Verify that the given credentials can log in + + let (login, _) = app + .tokens() + .login(&name, &password, &fixtures::now()) + .await + .expect("credentials given on signup are valid"); + assert_eq!(response, login); +} + +#[tokio::test] +async fn nonexistent_invite() { + // Set up the environment + + let app = fixtures::scratch_app().await; + let invite = fixtures::invite::fictitious(); + + // Call the endpoint + + let (name, password) = fixtures::user::propose(); + let identity = fixtures::cookie::not_logged_in(); + let request = super::Request { + name: name.clone(), + password: password.clone(), + }; + let super::Error(error) = super::handler( + State(app.clone()), + fixtures::now(), + identity, + Path(invite.clone()), + Json(request), + ) + .await + .expect_err("accepting a nonexistent invite fails"); + + // Verify the response + + assert!(matches!(error, AcceptError::NotFound(error_id) if error_id == invite)); +} + +#[tokio::test] +async fn expired_invite() { + // Set up the environment + + let app = fixtures::scratch_app().await; + let issuer = fixtures::user::create(&app, &fixtures::ancient()).await; + let invite = fixtures::invite::issue(&app, &issuer, &fixtures::ancient()).await; + + app.invites() + .expire(&fixtures::now()) + .await + .expect("expiring invites never fails"); + + // Call the endpoint + + let (name, password) = fixtures::user::propose(); + let identity = fixtures::cookie::not_logged_in(); + let request = super::Request { + name: name.clone(), + password: password.clone(), + }; + let super::Error(error) = super::handler( + State(app.clone()), + fixtures::now(), + identity, + Path(invite.id.clone()), + Json(request), + ) + .await + .expect_err("accepting a nonexistent invite fails"); + + // Verify the response + + assert!(matches!(error, AcceptError::NotFound(error_id) if error_id == invite.id)); +} + +#[tokio::test] +async fn accepted_invite() { + // Set up the environment + + let app = fixtures::scratch_app().await; + let issuer = fixtures::user::create(&app, &fixtures::ancient()).await; + let invite = fixtures::invite::issue(&app, &issuer, &fixtures::ancient()).await; + + let (name, password) = fixtures::user::propose(); + app.invites() + .accept(&invite.id, &name, &password, &fixtures::now()) + .await + .expect("accepting a valid invite succeeds"); + + // Call the endpoint + + let (name, password) = fixtures::user::propose(); + let identity = fixtures::cookie::not_logged_in(); + let request = super::Request { + name: name.clone(), + password: password.clone(), + }; + let super::Error(error) = super::handler( + State(app.clone()), + fixtures::now(), + identity, + Path(invite.id.clone()), + Json(request), + ) + .await + .expect_err("accepting a nonexistent invite fails"); + + // Verify the response + + assert!(matches!(error, AcceptError::NotFound(error_id) if error_id == invite.id)); +} + +#[tokio::test] +async fn conflicting_name() { + // Set up the environment + + let app = fixtures::scratch_app().await; + let issuer = fixtures::user::create(&app, &fixtures::ancient()).await; + let invite = fixtures::invite::issue(&app, &issuer, &fixtures::ancient()).await; + + let existing_name = Name::from("rijksmuseum"); + app.users() + .create( + &existing_name, + &fixtures::user::propose_password(), + &fixtures::now(), + ) + .await + .expect("creating a user in an empty environment succeeds"); + + // Call the endpoint + + let conflicting_name = Name::from("r\u{0133}ksmuseum"); + let password = fixtures::user::propose_password(); + + let identity = fixtures::cookie::not_logged_in(); + let request = super::Request { + name: conflicting_name.clone(), + password: password.clone(), + }; + let super::Error(error) = super::handler( + State(app.clone()), + fixtures::now(), + identity, + Path(invite.id.clone()), + Json(request), + ) + .await + .expect_err("accepting a nonexistent invite fails"); + + // Verify the response + + assert!( + matches!(error, AcceptError::DuplicateLogin(error_name) if error_name == conflicting_name) + ); +} + +#[tokio::test] +async fn invalid_name() { + // Set up the environment + + let app = fixtures::scratch_app().await; + let issuer = fixtures::user::create(&app, &fixtures::now()).await; + let invite = fixtures::invite::issue(&app, &issuer, &fixtures::now()).await; + + // Call the endpoint + + let name = fixtures::user::propose_invalid_name(); + let password = fixtures::user::propose_password(); + let identity = fixtures::cookie::not_logged_in(); + let request = super::Request { + name: name.clone(), + password: password.clone(), + }; + let super::Error(error) = super::handler( + State(app.clone()), + fixtures::now(), + identity, + Path(invite.id), + Json(request), + ) + .await + .expect_err("using an invalid name should fail"); + + // Verify the response + + assert!(matches!(error, AcceptError::InvalidName(error_name) if name == error_name)); +} diff --git a/src/invite/handlers/get/mod.rs b/src/invite/handlers/get/mod.rs new file mode 100644 index 0000000..bb72586 --- /dev/null +++ b/src/invite/handlers/get/mod.rs @@ -0,0 +1,41 @@ +use axum::{ + extract::{Json, Path, State}, + response::{IntoResponse, Response}, +}; + +use crate::{ + app::App, + error::{Internal, NotFound}, + invite::{Id, Summary, handlers::PathInfo}, +}; + +#[cfg(test)] +mod test; + +pub async fn handler( + State(app): State, + Path(invite): Path, +) -> Result, Error> { + app.invites() + .get(&invite) + .await? + .map(Json) + .ok_or_else(move || Error::NotFound(invite)) +} + +#[derive(Debug, thiserror::Error)] +pub enum Error { + #[error("invite not found: {0}")] + NotFound(Id), + #[error(transparent)] + Database(#[from] sqlx::Error), +} + +impl IntoResponse for Error { + fn into_response(self) -> Response { + match self { + Self::NotFound(_) => NotFound(self).into_response(), + Self::Database(_) => Internal::from(self).into_response(), + } + } +} diff --git a/src/invite/handlers/get/test.rs b/src/invite/handlers/get/test.rs new file mode 100644 index 0000000..0f2f725 --- /dev/null +++ b/src/invite/handlers/get/test.rs @@ -0,0 +1,65 @@ +use axum::extract::{Json, Path, State}; + +use crate::test::fixtures; + +#[tokio::test] +async fn valid_invite() { + // Set up the environment + + let app = fixtures::scratch_app().await; + let issuer = fixtures::user::create(&app, &fixtures::now()).await; + let invite = fixtures::invite::issue(&app, &issuer, &fixtures::now()).await; + + // Call endpoint + + let Json(response) = super::handler(State(app), Path(invite.id)) + .await + .expect("get for an existing invite succeeds"); + + // Verify response + + assert_eq!(issuer.name.display(), &response.issuer); + assert_eq!(invite.issued_at, response.issued_at); +} + +#[tokio::test] +async fn nonexistent_invite() { + // Set up the environment + + let app = fixtures::scratch_app().await; + + // Call endpoint + + let invite = fixtures::invite::fictitious(); + let error = super::handler(State(app), Path(invite.clone())) + .await + .expect_err("get for a nonexistent invite fails"); + + // Verify response + + assert!(matches!(error, super::Error::NotFound(error_id) if invite == error_id)); +} + +#[tokio::test] +async fn expired_invite() { + // Set up the environment + + let app = fixtures::scratch_app().await; + let issuer = fixtures::user::create(&app, &fixtures::ancient()).await; + let invite = fixtures::invite::issue(&app, &issuer, &fixtures::ancient()).await; + + app.invites() + .expire(&fixtures::now()) + .await + .expect("expiring invites never fails"); + + // Call endpoint + + let error = super::handler(State(app), Path(invite.id.clone())) + .await + .expect_err("get for an expired invite fails"); + + // Verify response + + assert!(matches!(error, super::Error::NotFound(error_id) if invite.id == error_id)); +} diff --git a/src/invite/handlers/issue/mod.rs b/src/invite/handlers/issue/mod.rs new file mode 100644 index 0000000..6085f7a --- /dev/null +++ b/src/invite/handlers/issue/mod.rs @@ -0,0 +1,22 @@ +use axum::extract::{Json, State}; + +use crate::{ + app::App, clock::RequestedAt, error::Internal, invite::Invite, token::extract::Identity, +}; + +#[cfg(test)] +mod test; + +pub async fn handler( + State(app): State, + RequestedAt(issued_at): RequestedAt, + identity: Identity, + _: Json, +) -> Result, Internal> { + let invite = app.invites().issue(&identity.user, &issued_at).await?; + Ok(Json(invite)) +} + +// Require `{}` as the only valid request for this endpoint. +#[derive(Default, serde::Deserialize)] +pub struct Request {} diff --git a/src/invite/handlers/issue/test.rs b/src/invite/handlers/issue/test.rs new file mode 100644 index 0000000..2bf5400 --- /dev/null +++ b/src/invite/handlers/issue/test.rs @@ -0,0 +1,27 @@ +use axum::extract::{Json, State}; + +use crate::test::fixtures; + +#[tokio::test] +async fn create_invite() { + // Set up the environment + + let app = fixtures::scratch_app().await; + let issuer = fixtures::identity::create(&app, &fixtures::now()).await; + let issued_at = fixtures::now(); + + // Call the endpoint + + let Json(invite) = super::handler( + State(app), + issued_at.clone(), + issuer.clone(), + Json(super::Request {}), + ) + .await + .expect("creating an invite always succeeds"); + + // Verify the response + assert_eq!(issuer.user.id, invite.issuer); + assert_eq!(&*issued_at, &invite.issued_at); +} diff --git a/src/invite/handlers/mod.rs b/src/invite/handlers/mod.rs new file mode 100644 index 0000000..07a2bbe --- /dev/null +++ b/src/invite/handlers/mod.rs @@ -0,0 +1,9 @@ +mod accept; +mod get; +mod issue; + +type PathInfo = crate::invite::Id; + +pub use accept::handler as accept; +pub use get::handler as get; +pub use issue::handler as issue; diff --git a/src/invite/mod.rs b/src/invite/mod.rs index 3932eea..74b9ceb 100644 --- a/src/invite/mod.rs +++ b/src/invite/mod.rs @@ -1,9 +1,9 @@ use crate::{clock::DateTime, normalize::nfc, user}; pub mod app; +pub mod handlers; mod id; mod repo; -pub mod routes; pub use self::id::Id; diff --git a/src/invite/routes/invite/get.rs b/src/invite/routes/invite/get.rs deleted file mode 100644 index f862833..0000000 --- a/src/invite/routes/invite/get.rs +++ /dev/null @@ -1,38 +0,0 @@ -use axum::{ - extract::{Json, Path, State}, - response::{IntoResponse, Response}, -}; - -use crate::{ - app::App, - error::{Internal, NotFound}, - invite::{Id, Summary}, -}; - -pub async fn handler( - State(app): State, - Path(invite): Path, -) -> Result, Error> { - app.invites() - .get(&invite) - .await? - .map(Json) - .ok_or_else(move || Error::NotFound(invite)) -} - -#[derive(Debug, thiserror::Error)] -pub enum Error { - #[error("invite not found: {0}")] - NotFound(Id), - #[error(transparent)] - Database(#[from] sqlx::Error), -} - -impl IntoResponse for Error { - fn into_response(self) -> Response { - match self { - Self::NotFound(_) => NotFound(self).into_response(), - Self::Database(_) => Internal::from(self).into_response(), - } - } -} diff --git a/src/invite/routes/invite/mod.rs b/src/invite/routes/invite/mod.rs deleted file mode 100644 index c22029a..0000000 --- a/src/invite/routes/invite/mod.rs +++ /dev/null @@ -1,6 +0,0 @@ -pub mod get; -pub mod post; -#[cfg(test)] -pub mod test; - -type PathInfo = crate::invite::Id; diff --git a/src/invite/routes/invite/post.rs b/src/invite/routes/invite/post.rs deleted file mode 100644 index 58d15c2..0000000 --- a/src/invite/routes/invite/post.rs +++ /dev/null @@ -1,57 +0,0 @@ -use axum::{ - extract::{Json, Path, State}, - http::StatusCode, - response::{IntoResponse, Response}, -}; - -use crate::{ - app::App, - clock::RequestedAt, - error::{Internal, NotFound}, - invite::app, - name::Name, - token::extract::IdentityCookie, - user::{Password, User}, -}; - -pub async fn handler( - State(app): State, - RequestedAt(accepted_at): RequestedAt, - identity: IdentityCookie, - Path(invite): Path, - Json(request): Json, -) -> Result<(IdentityCookie, Json), Error> { - let (login, secret) = app - .invites() - .accept(&invite, &request.name, &request.password, &accepted_at) - .await - .map_err(Error)?; - let identity = identity.set(secret); - Ok((identity, Json(login))) -} - -#[derive(serde::Deserialize)] -pub struct Request { - pub name: Name, - pub password: Password, -} - -#[derive(Debug, thiserror::Error)] -#[error(transparent)] -pub struct Error(pub app::AcceptError); - -impl IntoResponse for Error { - fn into_response(self) -> Response { - let Self(error) = self; - match error { - app::AcceptError::NotFound(_) => NotFound(error).into_response(), - app::AcceptError::InvalidName(_) => { - (StatusCode::BAD_REQUEST, error.to_string()).into_response() - } - app::AcceptError::DuplicateLogin(_) => { - (StatusCode::CONFLICT, error.to_string()).into_response() - } - other => Internal::from(other).into_response(), - } - } -} diff --git a/src/invite/routes/invite/test/get.rs b/src/invite/routes/invite/test/get.rs deleted file mode 100644 index 0dc8a79..0000000 --- a/src/invite/routes/invite/test/get.rs +++ /dev/null @@ -1,65 +0,0 @@ -use axum::extract::{Json, Path, State}; - -use crate::{invite::routes::invite::get, test::fixtures}; - -#[tokio::test] -async fn valid_invite() { - // Set up the environment - - let app = fixtures::scratch_app().await; - let issuer = fixtures::user::create(&app, &fixtures::now()).await; - let invite = fixtures::invite::issue(&app, &issuer, &fixtures::now()).await; - - // Call endpoint - - let Json(response) = get::handler(State(app), Path(invite.id)) - .await - .expect("get for an existing invite succeeds"); - - // Verify response - - assert_eq!(issuer.name.display(), &response.issuer); - assert_eq!(invite.issued_at, response.issued_at); -} - -#[tokio::test] -async fn nonexistent_invite() { - // Set up the environment - - let app = fixtures::scratch_app().await; - - // Call endpoint - - let invite = fixtures::invite::fictitious(); - let error = get::handler(State(app), Path(invite.clone())) - .await - .expect_err("get for a nonexistent invite fails"); - - // Verify response - - assert!(matches!(error, get::Error::NotFound(error_id) if invite == error_id)); -} - -#[tokio::test] -async fn expired_invite() { - // Set up the environment - - let app = fixtures::scratch_app().await; - let issuer = fixtures::user::create(&app, &fixtures::ancient()).await; - let invite = fixtures::invite::issue(&app, &issuer, &fixtures::ancient()).await; - - app.invites() - .expire(&fixtures::now()) - .await - .expect("expiring invites never fails"); - - // Call endpoint - - let error = get::handler(State(app), Path(invite.id.clone())) - .await - .expect_err("get for an expired invite fails"); - - // Verify response - - assert!(matches!(error, get::Error::NotFound(error_id) if invite.id == error_id)); -} diff --git a/src/invite/routes/invite/test/mod.rs b/src/invite/routes/invite/test/mod.rs deleted file mode 100644 index d6c1f06..0000000 --- a/src/invite/routes/invite/test/mod.rs +++ /dev/null @@ -1,2 +0,0 @@ -mod get; -mod post; diff --git a/src/invite/routes/invite/test/post.rs b/src/invite/routes/invite/test/post.rs deleted file mode 100644 index b204b32..0000000 --- a/src/invite/routes/invite/test/post.rs +++ /dev/null @@ -1,240 +0,0 @@ -use axum::extract::{Json, Path, State}; - -use crate::{ - invite::{app::AcceptError, routes::invite::post}, - name::Name, - test::fixtures, -}; - -#[tokio::test] -async fn valid_invite() { - // Set up the environment - - let app = fixtures::scratch_app().await; - let issuer = fixtures::user::create(&app, &fixtures::now()).await; - let invite = fixtures::invite::issue(&app, &issuer, &fixtures::now()).await; - - // Call the endpoint - - let (name, password) = fixtures::user::propose(); - let identity = fixtures::cookie::not_logged_in(); - let request = post::Request { - name: name.clone(), - password: password.clone(), - }; - let (identity, Json(response)) = post::handler( - State(app.clone()), - fixtures::now(), - identity, - Path(invite.id), - Json(request), - ) - .await - .expect("accepting a valid invite succeeds"); - - // Verify the response - - assert!(identity.secret().is_some()); - assert_eq!(name, response.name); - - // Verify that the issued token is valid - - let secret = identity - .secret() - .expect("newly-issued identity has a token secret"); - let (_, login) = app - .tokens() - .validate(&secret, &fixtures::now()) - .await - .expect("newly-issued identity cookie is valid"); - assert_eq!(response, login); - - // Verify that the given credentials can log in - - let (login, _) = app - .tokens() - .login(&name, &password, &fixtures::now()) - .await - .expect("credentials given on signup are valid"); - assert_eq!(response, login); -} - -#[tokio::test] -async fn nonexistent_invite() { - // Set up the environment - - let app = fixtures::scratch_app().await; - let invite = fixtures::invite::fictitious(); - - // Call the endpoint - - let (name, password) = fixtures::user::propose(); - let identity = fixtures::cookie::not_logged_in(); - let request = post::Request { - name: name.clone(), - password: password.clone(), - }; - let post::Error(error) = post::handler( - State(app.clone()), - fixtures::now(), - identity, - Path(invite.clone()), - Json(request), - ) - .await - .expect_err("accepting a nonexistent invite fails"); - - // Verify the response - - assert!(matches!(error, AcceptError::NotFound(error_id) if error_id == invite)); -} - -#[tokio::test] -async fn expired_invite() { - // Set up the environment - - let app = fixtures::scratch_app().await; - let issuer = fixtures::user::create(&app, &fixtures::ancient()).await; - let invite = fixtures::invite::issue(&app, &issuer, &fixtures::ancient()).await; - - app.invites() - .expire(&fixtures::now()) - .await - .expect("expiring invites never fails"); - - // Call the endpoint - - let (name, password) = fixtures::user::propose(); - let identity = fixtures::cookie::not_logged_in(); - let request = post::Request { - name: name.clone(), - password: password.clone(), - }; - let post::Error(error) = post::handler( - State(app.clone()), - fixtures::now(), - identity, - Path(invite.id.clone()), - Json(request), - ) - .await - .expect_err("accepting a nonexistent invite fails"); - - // Verify the response - - assert!(matches!(error, AcceptError::NotFound(error_id) if error_id == invite.id)); -} - -#[tokio::test] -async fn accepted_invite() { - // Set up the environment - - let app = fixtures::scratch_app().await; - let issuer = fixtures::user::create(&app, &fixtures::ancient()).await; - let invite = fixtures::invite::issue(&app, &issuer, &fixtures::ancient()).await; - - let (name, password) = fixtures::user::propose(); - app.invites() - .accept(&invite.id, &name, &password, &fixtures::now()) - .await - .expect("accepting a valid invite succeeds"); - - // Call the endpoint - - let (name, password) = fixtures::user::propose(); - let identity = fixtures::cookie::not_logged_in(); - let request = post::Request { - name: name.clone(), - password: password.clone(), - }; - let post::Error(error) = post::handler( - State(app.clone()), - fixtures::now(), - identity, - Path(invite.id.clone()), - Json(request), - ) - .await - .expect_err("accepting a nonexistent invite fails"); - - // Verify the response - - assert!(matches!(error, AcceptError::NotFound(error_id) if error_id == invite.id)); -} - -#[tokio::test] -async fn conflicting_name() { - // Set up the environment - - let app = fixtures::scratch_app().await; - let issuer = fixtures::user::create(&app, &fixtures::ancient()).await; - let invite = fixtures::invite::issue(&app, &issuer, &fixtures::ancient()).await; - - let existing_name = Name::from("rijksmuseum"); - app.users() - .create( - &existing_name, - &fixtures::user::propose_password(), - &fixtures::now(), - ) - .await - .expect("creating a user in an empty environment succeeds"); - - // Call the endpoint - - let conflicting_name = Name::from("r\u{0133}ksmuseum"); - let password = fixtures::user::propose_password(); - - let identity = fixtures::cookie::not_logged_in(); - let request = post::Request { - name: conflicting_name.clone(), - password: password.clone(), - }; - let post::Error(error) = post::handler( - State(app.clone()), - fixtures::now(), - identity, - Path(invite.id.clone()), - Json(request), - ) - .await - .expect_err("accepting a nonexistent invite fails"); - - // Verify the response - - assert!( - matches!(error, AcceptError::DuplicateLogin(error_name) if error_name == conflicting_name) - ); -} - -#[tokio::test] -async fn invalid_name() { - // Set up the environment - - let app = fixtures::scratch_app().await; - let issuer = fixtures::user::create(&app, &fixtures::now()).await; - let invite = fixtures::invite::issue(&app, &issuer, &fixtures::now()).await; - - // Call the endpoint - - let name = fixtures::user::propose_invalid_name(); - let password = fixtures::user::propose_password(); - let identity = fixtures::cookie::not_logged_in(); - let request = post::Request { - name: name.clone(), - password: password.clone(), - }; - let post::Error(error) = post::handler( - State(app.clone()), - fixtures::now(), - identity, - Path(invite.id), - Json(request), - ) - .await - .expect_err("using an invalid name should fail"); - - // Verify the response - - assert!(matches!(error, AcceptError::InvalidName(error_name) if name == error_name)); -} diff --git a/src/invite/routes/mod.rs b/src/invite/routes/mod.rs deleted file mode 100644 index 8747a4e..0000000 --- a/src/invite/routes/mod.rs +++ /dev/null @@ -1,4 +0,0 @@ -pub mod invite; -pub mod post; -#[cfg(test)] -mod test; diff --git a/src/invite/routes/post.rs b/src/invite/routes/post.rs deleted file mode 100644 index f7ca76c..0000000 --- a/src/invite/routes/post.rs +++ /dev/null @@ -1,19 +0,0 @@ -use axum::extract::{Json, State}; - -use crate::{ - app::App, clock::RequestedAt, error::Internal, invite::Invite, token::extract::Identity, -}; - -pub async fn handler( - State(app): State, - RequestedAt(issued_at): RequestedAt, - identity: Identity, - _: Json, -) -> Result, Internal> { - let invite = app.invites().issue(&identity.user, &issued_at).await?; - Ok(Json(invite)) -} - -// Require `{}` as the only valid request for this endpoint. -#[derive(Default, serde::Deserialize)] -pub struct Request {} diff --git a/src/invite/routes/test.rs b/src/invite/routes/test.rs deleted file mode 100644 index 4ea8a3d..0000000 --- a/src/invite/routes/test.rs +++ /dev/null @@ -1,28 +0,0 @@ -use axum::extract::{Json, State}; - -use super::post; -use crate::test::fixtures; - -#[tokio::test] -async fn create_invite() { - // Set up the environment - - let app = fixtures::scratch_app().await; - let issuer = fixtures::identity::create(&app, &fixtures::now()).await; - let issued_at = fixtures::now(); - - // Call the endpoint - - let Json(invite) = post::handler( - State(app), - issued_at.clone(), - issuer.clone(), - Json(post::Request {}), - ) - .await - .expect("creating an invite always succeeds"); - - // Verify the response - assert_eq!(issuer.user.id, invite.issuer); - assert_eq!(&*issued_at, &invite.issued_at); -} diff --git a/src/message/handlers/delete/mod.rs b/src/message/handlers/delete/mod.rs new file mode 100644 index 0000000..5eac4eb --- /dev/null +++ b/src/message/handlers/delete/mod.rs @@ -0,0 +1,55 @@ +use axum::{ + extract::{Json, Path, State}, + http::StatusCode, + response::{self, IntoResponse}, +}; + +use crate::{ + app::App, + clock::RequestedAt, + error::{Internal, NotFound}, + message::{self, app::DeleteError}, + token::extract::Identity, +}; + +#[cfg(test)] +mod test; + +pub async fn handler( + State(app): State, + Path(message): Path, + RequestedAt(deleted_at): RequestedAt, + identity: Identity, +) -> Result { + app.messages() + .delete(&identity.user, &message, &deleted_at) + .await?; + + Ok(Response { id: message }) +} + +#[derive(Debug, serde::Serialize)] +pub struct Response { + pub id: message::Id, +} + +impl IntoResponse for Response { + fn into_response(self) -> response::Response { + (StatusCode::ACCEPTED, Json(self)).into_response() + } +} + +#[derive(Debug, thiserror::Error)] +#[error(transparent)] +pub struct Error(#[from] pub DeleteError); + +impl IntoResponse for Error { + fn into_response(self) -> response::Response { + let Self(error) = self; + match error { + DeleteError::NotSender(_) => (StatusCode::FORBIDDEN, error.to_string()).into_response(), + DeleteError::NotFound(_) | DeleteError::Deleted(_) => NotFound(error).into_response(), + DeleteError::Database(_) => Internal::from(error).into_response(), + } + } +} diff --git a/src/message/handlers/delete/test.rs b/src/message/handlers/delete/test.rs new file mode 100644 index 0000000..15aa2c2 --- /dev/null +++ b/src/message/handlers/delete/test.rs @@ -0,0 +1,183 @@ +use axum::extract::{Path, State}; + +use crate::{message::app, test::fixtures}; + +#[tokio::test] +pub async fn delete_message() { + // Set up the environment + + let app = fixtures::scratch_app().await; + let sender = fixtures::identity::create(&app, &fixtures::now()).await; + let channel = fixtures::channel::create(&app, &fixtures::now()).await; + let message = fixtures::message::send(&app, &channel, &sender.user, &fixtures::now()).await; + + // Send the request + + let response = super::handler( + State(app.clone()), + Path(message.id.clone()), + fixtures::now(), + sender, + ) + .await + .expect("deleting a valid message succeeds"); + + // Verify the response + + assert_eq!(message.id, response.id); + + // Verify the semantics + + let snapshot = app.boot().snapshot().await.expect("boot always succeeds"); + assert!(!snapshot.messages.contains(&message)); +} + +#[tokio::test] +pub async fn delete_invalid_message_id() { + // Set up the environment + + let app = fixtures::scratch_app().await; + + // Send the request + + let deleter = fixtures::identity::create(&app, &fixtures::now()).await; + let message = fixtures::message::fictitious(); + let super::Error(error) = super::handler( + State(app.clone()), + Path(message.clone()), + fixtures::now(), + deleter, + ) + .await + .expect_err("deleting a nonexistent message fails"); + + // Verify the response + + assert!(matches!(error, app::DeleteError::NotFound(id) if id == message)); +} + +#[tokio::test] +pub async fn delete_deleted() { + // Set up the environment + + let app = fixtures::scratch_app().await; + let sender = fixtures::user::create(&app, &fixtures::now()).await; + let channel = fixtures::channel::create(&app, &fixtures::now()).await; + let message = fixtures::message::send(&app, &channel, &sender, &fixtures::now()).await; + + app.messages() + .delete(&sender, &message.id, &fixtures::now()) + .await + .expect("deleting a recently-sent message succeeds"); + + // Send the request + + let deleter = fixtures::identity::create(&app, &fixtures::now()).await; + let super::Error(error) = super::handler( + State(app.clone()), + Path(message.id.clone()), + fixtures::now(), + deleter, + ) + .await + .expect_err("deleting a deleted message fails"); + + // Verify the response + + assert!(matches!(error, app::DeleteError::Deleted(id) if id == message.id)); +} + +#[tokio::test] +pub async fn delete_expired() { + // Set up the environment + + let app = fixtures::scratch_app().await; + let sender = fixtures::user::create(&app, &fixtures::ancient()).await; + let channel = fixtures::channel::create(&app, &fixtures::ancient()).await; + let message = fixtures::message::send(&app, &channel, &sender, &fixtures::ancient()).await; + + app.messages() + .expire(&fixtures::now()) + .await + .expect("expiring messages always succeeds"); + + // Send the request + + let deleter = fixtures::identity::create(&app, &fixtures::now()).await; + let super::Error(error) = super::handler( + State(app.clone()), + Path(message.id.clone()), + fixtures::now(), + deleter, + ) + .await + .expect_err("deleting an expired message fails"); + + // Verify the response + + assert!(matches!(error, app::DeleteError::Deleted(id) if id == message.id)); +} + +#[tokio::test] +pub async fn delete_purged() { + // Set up the environment + + let app = fixtures::scratch_app().await; + let sender = fixtures::user::create(&app, &fixtures::ancient()).await; + let channel = fixtures::channel::create(&app, &fixtures::ancient()).await; + let message = fixtures::message::send(&app, &channel, &sender, &fixtures::ancient()).await; + + app.messages() + .expire(&fixtures::old()) + .await + .expect("expiring messages always succeeds"); + + app.messages() + .purge(&fixtures::now()) + .await + .expect("purging messages always succeeds"); + + // Send the request + + let deleter = fixtures::identity::create(&app, &fixtures::now()).await; + let super::Error(error) = super::handler( + State(app.clone()), + Path(message.id.clone()), + fixtures::now(), + deleter, + ) + .await + .expect_err("deleting a purged message fails"); + + // Verify the response + + assert!(matches!(error, app::DeleteError::NotFound(id) if id == message.id)); +} + +#[tokio::test] +pub async fn delete_not_sender() { + // Set up the environment + + let app = fixtures::scratch_app().await; + let sender = fixtures::user::create(&app, &fixtures::now()).await; + let channel = fixtures::channel::create(&app, &fixtures::now()).await; + let message = fixtures::message::send(&app, &channel, &sender, &fixtures::now()).await; + + // Send the request + + let deleter = fixtures::identity::create(&app, &fixtures::now()).await; + let super::Error(error) = super::handler( + State(app.clone()), + Path(message.id.clone()), + fixtures::now(), + deleter.clone(), + ) + .await + .expect_err("deleting a message someone else sent fails"); + + // Verify the response + + assert!( + matches!(error, app::DeleteError::NotSender(error_sender) if deleter.user == error_sender) + ); +} diff --git a/src/message/handlers/mod.rs b/src/message/handlers/mod.rs new file mode 100644 index 0000000..7e78475 --- /dev/null +++ b/src/message/handlers/mod.rs @@ -0,0 +1,3 @@ +mod delete; + +pub use delete::handler as delete; diff --git a/src/message/mod.rs b/src/message/mod.rs index fbaa4a3..e1643e6 100644 --- a/src/message/mod.rs +++ b/src/message/mod.rs @@ -1,10 +1,10 @@ pub mod app; mod body; pub mod event; +pub mod handlers; mod history; mod id; pub mod repo; -pub mod routes; mod snapshot; pub use self::{body::Body, event::Event, history::History, id::Id, snapshot::Message}; diff --git a/src/message/routes/message/mod.rs b/src/message/routes/message/mod.rs deleted file mode 100644 index a05d344..0000000 --- a/src/message/routes/message/mod.rs +++ /dev/null @@ -1,61 +0,0 @@ -#[cfg(test)] -mod test; - -pub mod delete { - use axum::{ - extract::{Json, Path, State}, - http::StatusCode, - response::{self, IntoResponse}, - }; - - use crate::{ - app::App, - clock::RequestedAt, - error::{Internal, NotFound}, - message::{self, app::DeleteError}, - token::extract::Identity, - }; - - pub async fn handler( - State(app): State, - Path(message): Path, - RequestedAt(deleted_at): RequestedAt, - identity: Identity, - ) -> Result { - app.messages() - .delete(&identity.user, &message, &deleted_at) - .await?; - - Ok(Response { id: message }) - } - - #[derive(Debug, serde::Serialize)] - pub struct Response { - pub id: message::Id, - } - - impl IntoResponse for Response { - fn into_response(self) -> response::Response { - (StatusCode::ACCEPTED, Json(self)).into_response() - } - } - - #[derive(Debug, thiserror::Error)] - #[error(transparent)] - pub struct Error(#[from] pub DeleteError); - - impl IntoResponse for Error { - fn into_response(self) -> response::Response { - let Self(error) = self; - match error { - DeleteError::NotSender(_) => { - (StatusCode::FORBIDDEN, error.to_string()).into_response() - } - DeleteError::NotFound(_) | DeleteError::Deleted(_) => { - NotFound(error).into_response() - } - DeleteError::Database(_) => Internal::from(error).into_response(), - } - } - } -} diff --git a/src/message/routes/message/test.rs b/src/message/routes/message/test.rs deleted file mode 100644 index 1888be7..0000000 --- a/src/message/routes/message/test.rs +++ /dev/null @@ -1,184 +0,0 @@ -use axum::extract::{Path, State}; - -use super::delete; -use crate::{message::app, test::fixtures}; - -#[tokio::test] -pub async fn delete_message() { - // Set up the environment - - let app = fixtures::scratch_app().await; - let sender = fixtures::identity::create(&app, &fixtures::now()).await; - let channel = fixtures::channel::create(&app, &fixtures::now()).await; - let message = fixtures::message::send(&app, &channel, &sender.user, &fixtures::now()).await; - - // Send the request - - let response = delete::handler( - State(app.clone()), - Path(message.id.clone()), - fixtures::now(), - sender, - ) - .await - .expect("deleting a valid message succeeds"); - - // Verify the response - - assert_eq!(message.id, response.id); - - // Verify the semantics - - let snapshot = app.boot().snapshot().await.expect("boot always succeeds"); - assert!(!snapshot.messages.contains(&message)); -} - -#[tokio::test] -pub async fn delete_invalid_message_id() { - // Set up the environment - - let app = fixtures::scratch_app().await; - - // Send the request - - let deleter = fixtures::identity::create(&app, &fixtures::now()).await; - let message = fixtures::message::fictitious(); - let delete::Error(error) = delete::handler( - State(app.clone()), - Path(message.clone()), - fixtures::now(), - deleter, - ) - .await - .expect_err("deleting a nonexistent message fails"); - - // Verify the response - - assert!(matches!(error, app::DeleteError::NotFound(id) if id == message)); -} - -#[tokio::test] -pub async fn delete_deleted() { - // Set up the environment - - let app = fixtures::scratch_app().await; - let sender = fixtures::user::create(&app, &fixtures::now()).await; - let channel = fixtures::channel::create(&app, &fixtures::now()).await; - let message = fixtures::message::send(&app, &channel, &sender, &fixtures::now()).await; - - app.messages() - .delete(&sender, &message.id, &fixtures::now()) - .await - .expect("deleting a recently-sent message succeeds"); - - // Send the request - - let deleter = fixtures::identity::create(&app, &fixtures::now()).await; - let delete::Error(error) = delete::handler( - State(app.clone()), - Path(message.id.clone()), - fixtures::now(), - deleter, - ) - .await - .expect_err("deleting a deleted message fails"); - - // Verify the response - - assert!(matches!(error, app::DeleteError::Deleted(id) if id == message.id)); -} - -#[tokio::test] -pub async fn delete_expired() { - // Set up the environment - - let app = fixtures::scratch_app().await; - let sender = fixtures::user::create(&app, &fixtures::ancient()).await; - let channel = fixtures::channel::create(&app, &fixtures::ancient()).await; - let message = fixtures::message::send(&app, &channel, &sender, &fixtures::ancient()).await; - - app.messages() - .expire(&fixtures::now()) - .await - .expect("expiring messages always succeeds"); - - // Send the request - - let deleter = fixtures::identity::create(&app, &fixtures::now()).await; - let delete::Error(error) = delete::handler( - State(app.clone()), - Path(message.id.clone()), - fixtures::now(), - deleter, - ) - .await - .expect_err("deleting an expired message fails"); - - // Verify the response - - assert!(matches!(error, app::DeleteError::Deleted(id) if id == message.id)); -} - -#[tokio::test] -pub async fn delete_purged() { - // Set up the environment - - let app = fixtures::scratch_app().await; - let sender = fixtures::user::create(&app, &fixtures::ancient()).await; - let channel = fixtures::channel::create(&app, &fixtures::ancient()).await; - let message = fixtures::message::send(&app, &channel, &sender, &fixtures::ancient()).await; - - app.messages() - .expire(&fixtures::old()) - .await - .expect("expiring messages always succeeds"); - - app.messages() - .purge(&fixtures::now()) - .await - .expect("purging messages always succeeds"); - - // Send the request - - let deleter = fixtures::identity::create(&app, &fixtures::now()).await; - let delete::Error(error) = delete::handler( - State(app.clone()), - Path(message.id.clone()), - fixtures::now(), - deleter, - ) - .await - .expect_err("deleting a purged message fails"); - - // Verify the response - - assert!(matches!(error, app::DeleteError::NotFound(id) if id == message.id)); -} - -#[tokio::test] -pub async fn delete_not_sender() { - // Set up the environment - - let app = fixtures::scratch_app().await; - let sender = fixtures::user::create(&app, &fixtures::now()).await; - let channel = fixtures::channel::create(&app, &fixtures::now()).await; - let message = fixtures::message::send(&app, &channel, &sender, &fixtures::now()).await; - - // Send the request - - let deleter = fixtures::identity::create(&app, &fixtures::now()).await; - let delete::Error(error) = delete::handler( - State(app.clone()), - Path(message.id.clone()), - fixtures::now(), - deleter.clone(), - ) - .await - .expect_err("deleting a message someone else sent fails"); - - // Verify the response - - assert!( - matches!(error, app::DeleteError::NotSender(error_sender) if deleter.user == error_sender) - ); -} diff --git a/src/message/routes/mod.rs b/src/message/routes/mod.rs deleted file mode 100644 index e216a50..0000000 --- a/src/message/routes/mod.rs +++ /dev/null @@ -1 +0,0 @@ -pub mod message; diff --git a/src/routes.rs b/src/routes.rs index 5bb5f91..1e66582 100644 --- a/src/routes.rs +++ b/src/routes.rs @@ -9,56 +9,35 @@ use crate::{app::App, boot, channel, event, expire, invite, message, setup, ui, pub fn routes(app: &App) -> Router { // UI routes that can be accessed before the administrator completes setup. let ui_bootstrap = Router::new() - .route("/{*path}", get(ui::routes::path::get::handler)) - .route("/setup", get(ui::routes::setup::get::handler)); + .route("/{*path}", get(ui::handlers::asset)) + .route("/setup", get(ui::handlers::setup)); // UI routes that require the administrator to complete setup first. let ui_setup_required = Router::new() - .route("/", get(ui::routes::get::handler)) - .route("/ch/{channel}", get(ui::routes::ch::channel::get::handler)) - .route( - "/invite/{invite}", - get(ui::routes::invite::invite::get::handler), - ) - .route("/login", get(ui::routes::login::get::handler)) - .route("/me", get(ui::routes::me::get::handler)) + .route("/", get(ui::handlers::index)) + .route("/ch/{channel}", get(ui::handlers::channel)) + .route("/invite/{invite}", get(ui::handlers::invite)) + .route("/login", get(ui::handlers::login)) + .route("/me", get(ui::handlers::me)) .route_layer(crate::setup::Required(app.clone()).with_fallback(Redirect::to("/setup"))); // API routes that can run before the administrator completes setup. - let api_bootstrap = Router::new().route("/api/setup", post(setup::routes::post::handler)); + let api_bootstrap = Router::new().route("/api/setup", post(setup::handlers::setup)); // API routes that require the administrator to complete setup first. let api_setup_required = Router::new() - .route("/api/auth/login", post(user::routes::login::post::handler)) - .route( - "/api/auth/logout", - post(user::routes::logout::post::handler), - ) - .route("/api/boot", get(boot::routes::get::handler)) - .route("/api/channels", post(channel::routes::post::handler)) - .route( - "/api/channels/{channel}", - post(channel::routes::channel::post::handler), - ) - .route( - "/api/channels/{channel}", - delete(channel::routes::channel::delete::handler), - ) - .route("/api/events", get(event::routes::get::handler)) - .route("/api/invite", post(invite::routes::post::handler)) - .route( - "/api/invite/{invite}", - get(invite::routes::invite::get::handler), - ) - .route( - "/api/invite/{invite}", - post(invite::routes::invite::post::handler), - ) - .route( - "/api/messages/{message}", - delete(message::routes::message::delete::handler), - ) - .route("/api/password", post(user::routes::password::post::handler)) + .route("/api/auth/login", post(user::handlers::login)) + .route("/api/auth/logout", post(user::handlers::logout)) + .route("/api/boot", get(boot::handlers::boot)) + .route("/api/channels", post(channel::handlers::create)) + .route("/api/channels/{channel}", post(channel::handlers::send)) + .route("/api/channels/{channel}", delete(channel::handlers::delete)) + .route("/api/events", get(event::handlers::stream)) + .route("/api/invite", post(invite::handlers::issue)) + .route("/api/invite/{invite}", get(invite::handlers::get)) + .route("/api/invite/{invite}", post(invite::handlers::accept)) + .route("/api/messages/{message}", delete(message::handlers::delete)) + .route("/api/password", post(user::handlers::change_password)) // Run expiry whenever someone accesses the API. This was previously a blanket middleware // affecting the whole service, but loading the client makes a several requests before the // client can completely load, each of which was triggering expiry. There is absolutely no diff --git a/src/setup/handlers/mod.rs b/src/setup/handlers/mod.rs new file mode 100644 index 0000000..3d4a4e6 --- /dev/null +++ b/src/setup/handlers/mod.rs @@ -0,0 +1,3 @@ +mod setup; + +pub use setup::handler as setup; diff --git a/src/setup/handlers/setup/mod.rs b/src/setup/handlers/setup/mod.rs new file mode 100644 index 0000000..cbb3072 --- /dev/null +++ b/src/setup/handlers/setup/mod.rs @@ -0,0 +1,55 @@ +use axum::{ + extract::{Json, State}, + http::StatusCode, + response::{IntoResponse, Response}, +}; + +use crate::{ + app::App, + clock::RequestedAt, + error::Internal, + name::Name, + setup::app, + token::extract::IdentityCookie, + user::{Password, User}, +}; + +#[cfg(test)] +mod test; + +pub async fn handler( + State(app): State, + RequestedAt(setup_at): RequestedAt, + identity: IdentityCookie, + Json(request): Json, +) -> Result<(IdentityCookie, Json), Error> { + let (user, secret) = app + .setup() + .initial(&request.name, &request.password, &setup_at) + .await + .map_err(Error)?; + let identity = identity.set(secret); + Ok((identity, Json(user))) +} + +#[derive(serde::Deserialize)] +pub struct Request { + pub name: Name, + pub password: Password, +} + +#[derive(Debug)] +pub struct Error(pub app::Error); + +impl IntoResponse for Error { + fn into_response(self) -> Response { + let Self(error) = self; + match error { + app::Error::InvalidName(_) => { + (StatusCode::BAD_REQUEST, error.to_string()).into_response() + } + app::Error::SetupCompleted => (StatusCode::CONFLICT, error.to_string()).into_response(), + other => Internal::from(other).into_response(), + } + } +} diff --git a/src/setup/handlers/setup/test.rs b/src/setup/handlers/setup/test.rs new file mode 100644 index 0000000..8243ac3 --- /dev/null +++ b/src/setup/handlers/setup/test.rs @@ -0,0 +1,93 @@ +use axum::extract::{Json, State}; + +use crate::{setup::app, test::fixtures}; + +#[tokio::test] +async fn fresh_instance() { + // Set up the environment + + let app = fixtures::scratch_app().await; + + // Call the endpoint + let identity = fixtures::cookie::not_logged_in(); + let (name, password) = fixtures::user::propose(); + let request = super::Request { + name: name.clone(), + password: password.clone(), + }; + let (identity, Json(response)) = + super::handler(State(app.clone()), fixtures::now(), identity, Json(request)) + .await + .expect("setup in a fresh app succeeds"); + + // Verify the response + + assert_eq!(name, response.name); + + // Verify that the issued token is valid + + let secret = identity + .secret() + .expect("newly-issued identity has a token secret"); + let (_, login) = app + .tokens() + .validate(&secret, &fixtures::now()) + .await + .expect("newly-issued identity cookie is valid"); + assert_eq!(response, login); + + // Verify that the given credentials can log in + + let (login, _) = app + .tokens() + .login(&name, &password, &fixtures::now()) + .await + .expect("credentials given on signup are valid"); + assert_eq!(response, login); +} + +#[tokio::test] +async fn login_exists() { + // Set up the environment + + let app = fixtures::scratch_app().await; + fixtures::user::create(&app, &fixtures::now()).await; + + // Call the endpoint + let identity = fixtures::cookie::not_logged_in(); + let (name, password) = fixtures::user::propose(); + let request = super::Request { name, password }; + let super::Error(error) = + super::handler(State(app.clone()), fixtures::now(), identity, Json(request)) + .await + .expect_err("setup in a populated app fails"); + + // Verify the response + + assert!(matches!(error, app::Error::SetupCompleted)); +} + +#[tokio::test] +async fn invalid_name() { + // Set up the environment + + let app = fixtures::scratch_app().await; + + // Call the endpoint + + let name = fixtures::user::propose_invalid_name(); + let password = fixtures::user::propose_password(); + let identity = fixtures::cookie::not_logged_in(); + let request = super::Request { + name: name.clone(), + password: password.clone(), + }; + let super::Error(error) = + super::handler(State(app.clone()), fixtures::now(), identity, Json(request)) + .await + .expect_err("setup with an invalid name fails"); + + // Verify the response + + assert!(matches!(error, app::Error::InvalidName(error_name) if name == error_name)); +} diff --git a/src/setup/mod.rs b/src/setup/mod.rs index e741a60..f5d12df 100644 --- a/src/setup/mod.rs +++ b/src/setup/mod.rs @@ -1,6 +1,6 @@ pub mod app; +pub mod handlers; pub mod repo; mod required; -pub mod routes; pub use self::required::Required; diff --git a/src/setup/routes/mod.rs b/src/setup/routes/mod.rs deleted file mode 100644 index e94a249..0000000 --- a/src/setup/routes/mod.rs +++ /dev/null @@ -1,3 +0,0 @@ -pub mod post; -#[cfg(test)] -mod test; diff --git a/src/setup/routes/post.rs b/src/setup/routes/post.rs deleted file mode 100644 index 0ff5d69..0000000 --- a/src/setup/routes/post.rs +++ /dev/null @@ -1,52 +0,0 @@ -use axum::{ - extract::{Json, State}, - http::StatusCode, - response::{IntoResponse, Response}, -}; - -use crate::{ - app::App, - clock::RequestedAt, - error::Internal, - name::Name, - setup::app, - token::extract::IdentityCookie, - user::{Password, User}, -}; - -pub async fn handler( - State(app): State, - RequestedAt(setup_at): RequestedAt, - identity: IdentityCookie, - Json(request): Json, -) -> Result<(IdentityCookie, Json), Error> { - let (user, secret) = app - .setup() - .initial(&request.name, &request.password, &setup_at) - .await - .map_err(Error)?; - let identity = identity.set(secret); - Ok((identity, Json(user))) -} - -#[derive(serde::Deserialize)] -pub struct Request { - pub name: Name, - pub password: Password, -} - -#[derive(Debug)] -pub struct Error(pub app::Error); - -impl IntoResponse for Error { - fn into_response(self) -> Response { - let Self(error) = self; - match error { - app::Error::InvalidName(_) => { - (StatusCode::BAD_REQUEST, error.to_string()).into_response() - } - app::Error::SetupCompleted => (StatusCode::CONFLICT, error.to_string()).into_response(), - other => Internal::from(other).into_response(), - } - } -} diff --git a/src/setup/routes/test.rs b/src/setup/routes/test.rs deleted file mode 100644 index e9f5cd6..0000000 --- a/src/setup/routes/test.rs +++ /dev/null @@ -1,94 +0,0 @@ -use axum::extract::{Json, State}; - -use super::post; -use crate::{setup::app, test::fixtures}; - -#[tokio::test] -async fn fresh_instance() { - // Set up the environment - - let app = fixtures::scratch_app().await; - - // Call the endpoint - let identity = fixtures::cookie::not_logged_in(); - let (name, password) = fixtures::user::propose(); - let request = post::Request { - name: name.clone(), - password: password.clone(), - }; - let (identity, Json(response)) = - post::handler(State(app.clone()), fixtures::now(), identity, Json(request)) - .await - .expect("setup in a fresh app succeeds"); - - // Verify the response - - assert_eq!(name, response.name); - - // Verify that the issued token is valid - - let secret = identity - .secret() - .expect("newly-issued identity has a token secret"); - let (_, login) = app - .tokens() - .validate(&secret, &fixtures::now()) - .await - .expect("newly-issued identity cookie is valid"); - assert_eq!(response, login); - - // Verify that the given credentials can log in - - let (login, _) = app - .tokens() - .login(&name, &password, &fixtures::now()) - .await - .expect("credentials given on signup are valid"); - assert_eq!(response, login); -} - -#[tokio::test] -async fn login_exists() { - // Set up the environment - - let app = fixtures::scratch_app().await; - fixtures::user::create(&app, &fixtures::now()).await; - - // Call the endpoint - let identity = fixtures::cookie::not_logged_in(); - let (name, password) = fixtures::user::propose(); - let request = post::Request { name, password }; - let post::Error(error) = - post::handler(State(app.clone()), fixtures::now(), identity, Json(request)) - .await - .expect_err("setup in a populated app fails"); - - // Verify the response - - assert!(matches!(error, app::Error::SetupCompleted)); -} - -#[tokio::test] -async fn invalid_name() { - // Set up the environment - - let app = fixtures::scratch_app().await; - - // Call the endpoint - - let name = fixtures::user::propose_invalid_name(); - let password = fixtures::user::propose_password(); - let identity = fixtures::cookie::not_logged_in(); - let request = post::Request { - name: name.clone(), - password: password.clone(), - }; - let post::Error(error) = - post::handler(State(app.clone()), fixtures::now(), identity, Json(request)) - .await - .expect_err("setup with an invalid name fails"); - - // Verify the response - - assert!(matches!(error, app::Error::InvalidName(error_name) if name == error_name)); -} diff --git a/src/ui/handlers/asset.rs b/src/ui/handlers/asset.rs new file mode 100644 index 0000000..1d5b8be --- /dev/null +++ b/src/ui/handlers/asset.rs @@ -0,0 +1,7 @@ +use axum::extract::Path; + +use crate::ui::assets::{Asset, Assets, Error}; + +pub async fn handler(Path(path): Path) -> Result { + Assets::load(path) +} diff --git a/src/ui/handlers/channel.rs b/src/ui/handlers/channel.rs new file mode 100644 index 0000000..d3199dd --- /dev/null +++ b/src/ui/handlers/channel.rs @@ -0,0 +1,58 @@ +use axum::{ + extract::{Path, State}, + response::{self, IntoResponse, Redirect}, +}; + +use crate::{ + app::App, + channel::{self, app}, + error::Internal, + token::extract::Identity, + ui::{ + assets::{Asset, Assets}, + error::NotFound, + }, +}; + +pub async fn handler( + State(app): State, + identity: Option, + Path(channel): Path, +) -> Result { + let _ = identity.ok_or(Error::NotLoggedIn)?; + app.channels().get(&channel).await.map_err(Error::from)?; + + Assets::index().map_err(Error::Internal) +} + +#[derive(Debug, thiserror::Error)] +pub enum Error { + #[error("channel not found")] + NotFound, + #[error("not logged in")] + NotLoggedIn, + #[error("{0}")] + Internal(Internal), +} + +impl From for Error { + fn from(error: app::Error) -> Self { + match error { + app::Error::NotFound(_) | app::Error::Deleted(_) => Self::NotFound, + other => Self::Internal(other.into()), + } + } +} + +impl IntoResponse for Error { + fn into_response(self) -> response::Response { + match self { + Self::NotFound => match Assets::index() { + Ok(asset) => NotFound(asset).into_response(), + Err(internal) => internal.into_response(), + }, + Self::NotLoggedIn => Redirect::temporary("/login").into_response(), + Self::Internal(error) => error.into_response(), + } + } +} diff --git a/src/ui/handlers/index.rs b/src/ui/handlers/index.rs new file mode 100644 index 0000000..2fcb51c --- /dev/null +++ b/src/ui/handlers/index.rs @@ -0,0 +1,30 @@ +use axum::response::{self, IntoResponse, Redirect}; + +use crate::{ + error::Internal, + token::extract::Identity, + ui::assets::{Asset, Assets}, +}; + +pub async fn handler(identity: Option) -> Result { + let _ = identity.ok_or(Error::NotLoggedIn)?; + + Assets::index().map_err(Error::Internal) +} + +#[derive(Debug, thiserror::Error)] +pub enum Error { + #[error("not logged in")] + NotLoggedIn, + #[error("{0}")] + Internal(Internal), +} + +impl IntoResponse for Error { + fn into_response(self) -> response::Response { + match self { + Self::NotLoggedIn => Redirect::temporary("/login").into_response(), + Self::Internal(error) => error.into_response(), + } + } +} diff --git a/src/ui/handlers/invite.rs b/src/ui/handlers/invite.rs new file mode 100644 index 0000000..0f9580a --- /dev/null +++ b/src/ui/handlers/invite.rs @@ -0,0 +1,53 @@ +use axum::{ + extract::{Path, State}, + response::{self, IntoResponse}, +}; + +use crate::{ + app::App, + error::Internal, + invite, + ui::{ + assets::{Asset, Assets}, + error::NotFound, + }, +}; + +pub async fn handler( + State(app): State, + Path(invite): Path, +) -> Result { + app.invites() + .get(&invite) + .await + .map_err(Error::internal)? + .ok_or(Error::NotFound)?; + + Assets::index().map_err(Error::Internal) +} + +#[derive(Debug, thiserror::Error)] +pub enum Error { + #[error("invite not found")] + NotFound, + #[error("{0}")] + Internal(Internal), +} + +impl Error { + fn internal(err: impl Into) -> Self { + Self::Internal(err.into()) + } +} + +impl IntoResponse for Error { + fn into_response(self) -> response::Response { + match self { + Self::NotFound => match Assets::index() { + Ok(asset) => NotFound(asset).into_response(), + Err(internal) => internal.into_response(), + }, + Self::Internal(error) => error.into_response(), + } + } +} diff --git a/src/ui/handlers/login.rs b/src/ui/handlers/login.rs new file mode 100644 index 0000000..4562b04 --- /dev/null +++ b/src/ui/handlers/login.rs @@ -0,0 +1,8 @@ +use crate::{ + error::Internal, + ui::assets::{Asset, Assets}, +}; + +pub async fn handler() -> Result { + Assets::index() +} diff --git a/src/ui/handlers/me.rs b/src/ui/handlers/me.rs new file mode 100644 index 0000000..2fcb51c --- /dev/null +++ b/src/ui/handlers/me.rs @@ -0,0 +1,30 @@ +use axum::response::{self, IntoResponse, Redirect}; + +use crate::{ + error::Internal, + token::extract::Identity, + ui::assets::{Asset, Assets}, +}; + +pub async fn handler(identity: Option) -> Result { + let _ = identity.ok_or(Error::NotLoggedIn)?; + + Assets::index().map_err(Error::Internal) +} + +#[derive(Debug, thiserror::Error)] +pub enum Error { + #[error("not logged in")] + NotLoggedIn, + #[error("{0}")] + Internal(Internal), +} + +impl IntoResponse for Error { + fn into_response(self) -> response::Response { + match self { + Self::NotLoggedIn => Redirect::temporary("/login").into_response(), + Self::Internal(error) => error.into_response(), + } + } +} diff --git a/src/ui/handlers/mod.rs b/src/ui/handlers/mod.rs new file mode 100644 index 0000000..5bfd0d6 --- /dev/null +++ b/src/ui/handlers/mod.rs @@ -0,0 +1,15 @@ +mod asset; +mod channel; +mod index; +mod invite; +mod login; +mod me; +mod setup; + +pub use asset::handler as asset; +pub use channel::handler as channel; +pub use index::handler as index; +pub use invite::handler as invite; +pub use login::handler as login; +pub use me::handler as me; +pub use setup::handler as setup; diff --git a/src/ui/handlers/setup.rs b/src/ui/handlers/setup.rs new file mode 100644 index 0000000..49821cf --- /dev/null +++ b/src/ui/handlers/setup.rs @@ -0,0 +1,41 @@ +use axum::{ + extract::State, + response::{self, IntoResponse, Redirect}, +}; + +use crate::{ + app::App, + error::Internal, + ui::assets::{Asset, Assets}, +}; + +pub async fn handler(State(app): State) -> Result { + if app + .setup() + .completed() + .await + .map_err(Internal::from) + .map_err(Error::Internal)? + { + Err(Error::SetupCompleted) + } else { + Assets::index().map_err(Error::Internal) + } +} + +#[derive(Debug, thiserror::Error)] +pub enum Error { + #[error("setup already completed")] + SetupCompleted, + #[error("{0}")] + Internal(Internal), +} + +impl IntoResponse for Error { + fn into_response(self) -> response::Response { + match self { + Self::SetupCompleted => Redirect::to("/login").into_response(), + Self::Internal(error) => error.into_response(), + } + } +} diff --git a/src/ui/mod.rs b/src/ui/mod.rs index eeaf27a..9668300 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -1,4 +1,4 @@ mod assets; mod error; +pub mod handlers; mod mime; -pub mod routes; diff --git a/src/ui/routes/ch/channel.rs b/src/ui/routes/ch/channel.rs deleted file mode 100644 index a854f14..0000000 --- a/src/ui/routes/ch/channel.rs +++ /dev/null @@ -1,60 +0,0 @@ -pub mod get { - use axum::{ - extract::{Path, State}, - response::{self, IntoResponse, Redirect}, - }; - - use crate::{ - app::App, - channel::{self, app}, - error::Internal, - token::extract::Identity, - ui::{ - assets::{Asset, Assets}, - error::NotFound, - }, - }; - - pub async fn handler( - State(app): State, - identity: Option, - Path(channel): Path, - ) -> Result { - let _ = identity.ok_or(Error::NotLoggedIn)?; - app.channels().get(&channel).await.map_err(Error::from)?; - - Assets::index().map_err(Error::Internal) - } - - #[derive(Debug, thiserror::Error)] - pub enum Error { - #[error("channel not found")] - NotFound, - #[error("not logged in")] - NotLoggedIn, - #[error("{0}")] - Internal(Internal), - } - - impl From for Error { - fn from(error: app::Error) -> Self { - match error { - app::Error::NotFound(_) | app::Error::Deleted(_) => Self::NotFound, - other => Self::Internal(other.into()), - } - } - } - - impl IntoResponse for Error { - fn into_response(self) -> response::Response { - match self { - Self::NotFound => match Assets::index() { - Ok(asset) => NotFound(asset).into_response(), - Err(internal) => internal.into_response(), - }, - Self::NotLoggedIn => Redirect::temporary("/login").into_response(), - Self::Internal(error) => error.into_response(), - } - } - } -} diff --git a/src/ui/routes/ch/mod.rs b/src/ui/routes/ch/mod.rs deleted file mode 100644 index ff02972..0000000 --- a/src/ui/routes/ch/mod.rs +++ /dev/null @@ -1 +0,0 @@ -pub mod channel; diff --git a/src/ui/routes/get.rs b/src/ui/routes/get.rs deleted file mode 100644 index 2fcb51c..0000000 --- a/src/ui/routes/get.rs +++ /dev/null @@ -1,30 +0,0 @@ -use axum::response::{self, IntoResponse, Redirect}; - -use crate::{ - error::Internal, - token::extract::Identity, - ui::assets::{Asset, Assets}, -}; - -pub async fn handler(identity: Option) -> Result { - let _ = identity.ok_or(Error::NotLoggedIn)?; - - Assets::index().map_err(Error::Internal) -} - -#[derive(Debug, thiserror::Error)] -pub enum Error { - #[error("not logged in")] - NotLoggedIn, - #[error("{0}")] - Internal(Internal), -} - -impl IntoResponse for Error { - fn into_response(self) -> response::Response { - match self { - Self::NotLoggedIn => Redirect::temporary("/login").into_response(), - Self::Internal(error) => error.into_response(), - } - } -} diff --git a/src/ui/routes/invite/invite.rs b/src/ui/routes/invite/invite.rs deleted file mode 100644 index 06e5792..0000000 --- a/src/ui/routes/invite/invite.rs +++ /dev/null @@ -1,55 +0,0 @@ -pub mod get { - use axum::{ - extract::{Path, State}, - response::{self, IntoResponse}, - }; - - use crate::{ - app::App, - error::Internal, - invite, - ui::{ - assets::{Asset, Assets}, - error::NotFound, - }, - }; - - pub async fn handler( - State(app): State, - Path(invite): Path, - ) -> Result { - app.invites() - .get(&invite) - .await - .map_err(Error::internal)? - .ok_or(Error::NotFound)?; - - Assets::index().map_err(Error::Internal) - } - - #[derive(Debug, thiserror::Error)] - pub enum Error { - #[error("invite not found")] - NotFound, - #[error("{0}")] - Internal(Internal), - } - - impl Error { - fn internal(err: impl Into) -> Self { - Self::Internal(err.into()) - } - } - - impl IntoResponse for Error { - fn into_response(self) -> response::Response { - match self { - Self::NotFound => match Assets::index() { - Ok(asset) => NotFound(asset).into_response(), - Err(internal) => internal.into_response(), - }, - Self::Internal(error) => error.into_response(), - } - } - } -} diff --git a/src/ui/routes/invite/mod.rs b/src/ui/routes/invite/mod.rs deleted file mode 100644 index 50af8be..0000000 --- a/src/ui/routes/invite/mod.rs +++ /dev/null @@ -1,4 +0,0 @@ -// In this case, the first redundant `invite` is a literal path segment, and the -// second `invite` reflects a placeholder. -#[allow(clippy::module_inception)] -pub mod invite; diff --git a/src/ui/routes/login.rs b/src/ui/routes/login.rs deleted file mode 100644 index 39d45b9..0000000 --- a/src/ui/routes/login.rs +++ /dev/null @@ -1,10 +0,0 @@ -pub mod get { - use crate::{ - error::Internal, - ui::assets::{Asset, Assets}, - }; - - pub async fn handler() -> Result { - Assets::index() - } -} diff --git a/src/ui/routes/me.rs b/src/ui/routes/me.rs deleted file mode 100644 index f1f118f..0000000 --- a/src/ui/routes/me.rs +++ /dev/null @@ -1,32 +0,0 @@ -pub mod get { - use axum::response::{self, IntoResponse, Redirect}; - - use crate::{ - error::Internal, - token::extract::Identity, - ui::assets::{Asset, Assets}, - }; - - pub async fn handler(identity: Option) -> Result { - let _ = identity.ok_or(Error::NotLoggedIn)?; - - Assets::index().map_err(Error::Internal) - } - - #[derive(Debug, thiserror::Error)] - pub enum Error { - #[error("not logged in")] - NotLoggedIn, - #[error("{0}")] - Internal(Internal), - } - - impl IntoResponse for Error { - fn into_response(self) -> response::Response { - match self { - Self::NotLoggedIn => Redirect::temporary("/login").into_response(), - Self::Internal(error) => error.into_response(), - } - } - } -} diff --git a/src/ui/routes/mod.rs b/src/ui/routes/mod.rs deleted file mode 100644 index 2390802..0000000 --- a/src/ui/routes/mod.rs +++ /dev/null @@ -1,7 +0,0 @@ -pub mod ch; -pub mod get; -pub mod invite; -pub mod login; -pub mod me; -pub mod path; -pub mod setup; diff --git a/src/ui/routes/path.rs b/src/ui/routes/path.rs deleted file mode 100644 index a387552..0000000 --- a/src/ui/routes/path.rs +++ /dev/null @@ -1,9 +0,0 @@ -pub mod get { - use axum::extract::Path; - - use crate::ui::assets::{Asset, Assets, Error}; - - pub async fn handler(Path(path): Path) -> Result { - Assets::load(path) - } -} diff --git a/src/ui/routes/setup.rs b/src/ui/routes/setup.rs deleted file mode 100644 index 649cc5f..0000000 --- a/src/ui/routes/setup.rs +++ /dev/null @@ -1,43 +0,0 @@ -pub mod get { - use axum::{ - extract::State, - response::{self, IntoResponse, Redirect}, - }; - - use crate::{ - app::App, - error::Internal, - ui::assets::{Asset, Assets}, - }; - - pub async fn handler(State(app): State) -> Result { - if app - .setup() - .completed() - .await - .map_err(Internal::from) - .map_err(Error::Internal)? - { - Err(Error::SetupCompleted) - } else { - Assets::index().map_err(Error::Internal) - } - } - - #[derive(Debug, thiserror::Error)] - pub enum Error { - #[error("setup already completed")] - SetupCompleted, - #[error("{0}")] - Internal(Internal), - } - - impl IntoResponse for Error { - fn into_response(self) -> response::Response { - match self { - Self::SetupCompleted => Redirect::to("/login").into_response(), - Self::Internal(error) => error.into_response(), - } - } - } -} diff --git a/src/user/handlers/login/mod.rs b/src/user/handlers/login/mod.rs new file mode 100644 index 0000000..e80377e --- /dev/null +++ b/src/user/handlers/login/mod.rs @@ -0,0 +1,55 @@ +use axum::{ + extract::{Json, State}, + http::StatusCode, + response::{IntoResponse, Response}, +}; + +use crate::{ + app::App, + clock::RequestedAt, + error::Internal, + name::Name, + token::{app, extract::IdentityCookie}, + user::{Password, User}, +}; + +#[cfg(test)] +mod test; + +pub async fn handler( + State(app): State, + RequestedAt(now): RequestedAt, + identity: IdentityCookie, + Json(request): Json, +) -> Result<(IdentityCookie, Json), Error> { + let (user, secret) = app + .tokens() + .login(&request.name, &request.password, &now) + .await + .map_err(Error)?; + let identity = identity.set(secret); + Ok((identity, Json(user))) +} + +#[derive(serde::Deserialize)] +pub struct Request { + pub name: Name, + pub password: Password, +} + +#[derive(Debug, thiserror::Error)] +#[error(transparent)] +pub struct Error(#[from] pub app::LoginError); + +impl IntoResponse for Error { + fn into_response(self) -> Response { + let Self(error) = self; + match error { + app::LoginError::Rejected => { + // not error::Unauthorized due to differing messaging + (StatusCode::UNAUTHORIZED, "invalid name or password").into_response() + } + other => Internal::from(other).into_response(), + } + } +} diff --git a/src/user/handlers/login/test.rs b/src/user/handlers/login/test.rs new file mode 100644 index 0000000..b8f24f6 --- /dev/null +++ b/src/user/handlers/login/test.rs @@ -0,0 +1,127 @@ +use axum::extract::{Json, State}; + +use crate::{test::fixtures, token::app}; + +#[tokio::test] +async fn correct_credentials() { + // Set up the environment + + let app = fixtures::scratch_app().await; + let (name, password) = fixtures::user::create_with_password(&app, &fixtures::now()).await; + + // Call the endpoint + + let identity = fixtures::cookie::not_logged_in(); + let logged_in_at = fixtures::now(); + let request = super::Request { + name: name.clone(), + password, + }; + let (identity, Json(response)) = + super::handler(State(app.clone()), logged_in_at, identity, Json(request)) + .await + .expect("logged in with valid credentials"); + + // Verify the return value's basic structure + + assert_eq!(name, response.name); + let secret = identity + .secret() + .expect("logged in with valid credentials issues an identity cookie"); + + // Verify the semantics + + let validated_at = fixtures::now(); + let (_, validated_login) = app + .tokens() + .validate(&secret, &validated_at) + .await + .expect("identity secret is valid"); + + assert_eq!(response, validated_login); +} + +#[tokio::test] +async fn invalid_name() { + // Set up the environment + + let app = fixtures::scratch_app().await; + + // Call the endpoint + + let identity = fixtures::cookie::not_logged_in(); + let logged_in_at = fixtures::now(); + let (name, password) = fixtures::user::propose(); + let request = super::Request { + name: name.clone(), + password, + }; + let super::Error(error) = + super::handler(State(app.clone()), logged_in_at, identity, Json(request)) + .await + .expect_err("logged in with an incorrect password fails"); + + // Verify the return value's basic structure + + assert!(matches!(error, app::LoginError::Rejected)); +} + +#[tokio::test] +async fn incorrect_password() { + // Set up the environment + + let app = fixtures::scratch_app().await; + let login = fixtures::user::create(&app, &fixtures::now()).await; + + // Call the endpoint + + let logged_in_at = fixtures::now(); + let identity = fixtures::cookie::not_logged_in(); + let request = super::Request { + name: login.name, + password: fixtures::user::propose_password(), + }; + let super::Error(error) = + super::handler(State(app.clone()), logged_in_at, identity, Json(request)) + .await + .expect_err("logged in with an incorrect password"); + + // Verify the return value's basic structure + + assert!(matches!(error, app::LoginError::Rejected)); +} + +#[tokio::test] +async fn token_expires() { + // Set up the environment + + let app = fixtures::scratch_app().await; + let (name, password) = fixtures::user::create_with_password(&app, &fixtures::now()).await; + + // Call the endpoint + + let logged_in_at = fixtures::ancient(); + let identity = fixtures::cookie::not_logged_in(); + let request = super::Request { name, password }; + let (identity, _) = super::handler(State(app.clone()), logged_in_at, identity, Json(request)) + .await + .expect("logged in with valid credentials"); + let secret = identity.secret().expect("logged in with valid credentials"); + + // Verify the semantics + + let expired_at = fixtures::now(); + app.tokens() + .expire(&expired_at) + .await + .expect("expiring tokens never fails"); + + let verified_at = fixtures::now(); + let error = app + .tokens() + .validate(&secret, &verified_at) + .await + .expect_err("validating an expired token"); + + assert!(matches!(error, app::ValidateError::InvalidToken)); +} diff --git a/src/user/handlers/logout/mod.rs b/src/user/handlers/logout/mod.rs new file mode 100644 index 0000000..45a376a --- /dev/null +++ b/src/user/handlers/logout/mod.rs @@ -0,0 +1,53 @@ +use axum::{ + extract::{Json, State}, + http::StatusCode, + response::{IntoResponse, Response}, +}; + +use crate::{ + app::App, + clock::RequestedAt, + error::{Internal, Unauthorized}, + token::{app, extract::IdentityCookie}, +}; + +#[cfg(test)] +mod test; + +pub async fn handler( + State(app): State, + RequestedAt(now): RequestedAt, + identity: IdentityCookie, + Json(_): Json, +) -> Result<(IdentityCookie, StatusCode), Error> { + if let Some(secret) = identity.secret() { + let (token, _) = app.tokens().validate(&secret, &now).await?; + app.tokens().logout(&token).await?; + } + + let identity = identity.clear(); + Ok((identity, StatusCode::NO_CONTENT)) +} + +// This forces the only valid request to be `{}`, and not the infinite +// variation allowed when there's no body extractor. +#[derive(Default, serde::Deserialize)] +pub struct Request {} + +#[derive(Debug, thiserror::Error)] +#[error(transparent)] +pub struct Error(#[from] pub app::ValidateError); + +impl IntoResponse for Error { + fn into_response(self) -> Response { + let Self(error) = self; + match error { + app::ValidateError::InvalidToken | app::ValidateError::LoginDeleted => { + Unauthorized.into_response() + } + app::ValidateError::Name(_) | app::ValidateError::Database(_) => { + Internal::from(error).into_response() + } + } + } +} diff --git a/src/user/handlers/logout/test.rs b/src/user/handlers/logout/test.rs new file mode 100644 index 0000000..8dc4636 --- /dev/null +++ b/src/user/handlers/logout/test.rs @@ -0,0 +1,79 @@ +use axum::{ + extract::{Json, State}, + http::StatusCode, +}; + +use crate::{test::fixtures, token::app}; + +#[tokio::test] +async fn successful() { + // Set up the environment + + let app = fixtures::scratch_app().await; + let now = fixtures::now(); + let creds = fixtures::user::create_with_password(&app, &fixtures::now()).await; + let identity = fixtures::cookie::logged_in(&app, &creds, &now).await; + let secret = fixtures::cookie::secret(&identity); + + // Call the endpoint + + let (response_identity, response_status) = super::handler( + State(app.clone()), + fixtures::now(), + identity.clone(), + Json::default(), + ) + .await + .expect("logged out with a valid token"); + + // Verify the return value's basic structure + + assert!(response_identity.secret().is_none()); + assert_eq!(StatusCode::NO_CONTENT, response_status); + + // Verify the semantics + let error = app + .tokens() + .validate(&secret, &now) + .await + .expect_err("secret is invalid"); + assert!(matches!(error, app::ValidateError::InvalidToken)); +} + +#[tokio::test] +async fn no_identity() { + // Set up the environment + + let app = fixtures::scratch_app().await; + + // Call the endpoint + + let identity = fixtures::cookie::not_logged_in(); + let (identity, status) = super::handler(State(app), fixtures::now(), identity, Json::default()) + .await + .expect("logged out with no token succeeds"); + + // Verify the return value's basic structure + + assert!(identity.secret().is_none()); + assert_eq!(StatusCode::NO_CONTENT, status); +} + +#[tokio::test] +async fn invalid_token() { + // Set up the environment + + let app = fixtures::scratch_app().await; + + // Call the endpoint + + let identity = fixtures::cookie::fictitious(); + let super::Error(error) = + super::handler(State(app), fixtures::now(), identity, Json::default()) + .await + .expect_err("logged out with an invalid token fails"); + + // Verify the return value's basic structure + + assert!(matches!(error, app::ValidateError::InvalidToken)); +} diff --git a/src/user/handlers/mod.rs b/src/user/handlers/mod.rs new file mode 100644 index 0000000..5cadbb5 --- /dev/null +++ b/src/user/handlers/mod.rs @@ -0,0 +1,7 @@ +mod login; +mod logout; +mod password; + +pub use login::handler as login; +pub use logout::handler as logout; +pub use password::handler as change_password; diff --git a/src/user/handlers/password/mod.rs b/src/user/handlers/password/mod.rs new file mode 100644 index 0000000..9158325 --- /dev/null +++ b/src/user/handlers/password/mod.rs @@ -0,0 +1,57 @@ +use axum::{ + extract::{Json, State}, + http::StatusCode, + response::{IntoResponse, Response}, +}; + +use crate::{ + app::App, + clock::RequestedAt, + error::Internal, + token::{ + app, + extract::{Identity, IdentityCookie}, + }, + user::{Password, User}, +}; + +#[cfg(test)] +mod test; + +pub async fn handler( + State(app): State, + RequestedAt(now): RequestedAt, + identity: Identity, + cookie: IdentityCookie, + Json(request): Json, +) -> Result<(IdentityCookie, Json), Error> { + let (login, secret) = app + .tokens() + .change_password(&identity.user, &request.password, &request.to, &now) + .await + .map_err(Error)?; + let cookie = cookie.set(secret); + Ok((cookie, Json(login))) +} + +#[derive(serde::Deserialize)] +pub struct Request { + pub password: Password, + pub to: Password, +} + +#[derive(Debug, thiserror::Error)] +#[error(transparent)] +pub struct Error(#[from] pub app::LoginError); + +impl IntoResponse for Error { + fn into_response(self) -> Response { + let Self(error) = self; + match error { + app::LoginError::Rejected => { + (StatusCode::BAD_REQUEST, "invalid name or password").into_response() + } + other => Internal::from(other).into_response(), + } + } +} diff --git a/src/user/handlers/password/test.rs b/src/user/handlers/password/test.rs new file mode 100644 index 0000000..42e41d8 --- /dev/null +++ b/src/user/handlers/password/test.rs @@ -0,0 +1,67 @@ +use axum::extract::{Json, State}; + +use crate::{ + test::fixtures, + token::app::{LoginError, ValidateError}, +}; + +#[tokio::test] +async fn password_change() { + // Set up the environment + let app = fixtures::scratch_app().await; + let creds = fixtures::user::create_with_password(&app, &fixtures::now()).await; + let cookie = fixtures::cookie::logged_in(&app, &creds, &fixtures::now()).await; + let identity = fixtures::identity::from_cookie(&app, &cookie, &fixtures::now()).await; + + // Call the endpoint + let (name, password) = creds; + let to = fixtures::user::propose_password(); + let request = super::Request { + password: password.clone(), + to: to.clone(), + }; + let (new_cookie, Json(response)) = super::handler( + State(app.clone()), + fixtures::now(), + identity.clone(), + cookie.clone(), + Json(request), + ) + .await + .expect("changing passwords succeeds"); + + // Verify that we have a new session + assert_ne!(cookie.secret(), new_cookie.secret()); + + // Verify that we're still ourselves + assert_eq!(identity.user, response); + + // Verify that our original token is no longer valid + let validate_err = app + .tokens() + .validate( + &cookie + .secret() + .expect("original identity cookie has a secret"), + &fixtures::now(), + ) + .await + .expect_err("validating the original identity secret should fail"); + assert!(matches!(validate_err, ValidateError::InvalidToken)); + + // Verify that our original password is no longer valid + let login_err = app + .tokens() + .login(&name, &password, &fixtures::now()) + .await + .expect_err("logging in with the original password should fail"); + assert!(matches!(login_err, LoginError::Rejected)); + + // Verify that our new password is valid + let (login, _) = app + .tokens() + .login(&name, &to, &fixtures::now()) + .await + .expect("logging in with the new password should succeed"); + assert_eq!(identity.user, login); +} diff --git a/src/user/mod.rs b/src/user/mod.rs index 7ea3d26..44e1633 100644 --- a/src/user/mod.rs +++ b/src/user/mod.rs @@ -2,11 +2,11 @@ pub mod app; pub mod create; pub mod event; +pub mod handlers; mod history; mod id; pub mod password; pub mod repo; -pub mod routes; mod snapshot; mod validate; diff --git a/src/user/routes/login/mod.rs b/src/user/routes/login/mod.rs deleted file mode 100644 index 36b384e..0000000 --- a/src/user/routes/login/mod.rs +++ /dev/null @@ -1,4 +0,0 @@ -pub mod post; - -#[cfg(test)] -mod test; diff --git a/src/user/routes/login/post.rs b/src/user/routes/login/post.rs deleted file mode 100644 index 39f9eea..0000000 --- a/src/user/routes/login/post.rs +++ /dev/null @@ -1,52 +0,0 @@ -use axum::{ - extract::{Json, State}, - http::StatusCode, - response::{IntoResponse, Response}, -}; - -use crate::{ - app::App, - clock::RequestedAt, - error::Internal, - name::Name, - token::{app, extract::IdentityCookie}, - user::{Password, User}, -}; - -pub async fn handler( - State(app): State, - RequestedAt(now): RequestedAt, - identity: IdentityCookie, - Json(request): Json, -) -> Result<(IdentityCookie, Json), Error> { - let (user, secret) = app - .tokens() - .login(&request.name, &request.password, &now) - .await - .map_err(Error)?; - let identity = identity.set(secret); - Ok((identity, Json(user))) -} - -#[derive(serde::Deserialize)] -pub struct Request { - pub name: Name, - pub password: Password, -} - -#[derive(Debug, thiserror::Error)] -#[error(transparent)] -pub struct Error(#[from] pub app::LoginError); - -impl IntoResponse for Error { - fn into_response(self) -> Response { - let Self(error) = self; - match error { - app::LoginError::Rejected => { - // not error::Unauthorized due to differing messaging - (StatusCode::UNAUTHORIZED, "invalid name or password").into_response() - } - other => Internal::from(other).into_response(), - } - } -} diff --git a/src/user/routes/login/test.rs b/src/user/routes/login/test.rs deleted file mode 100644 index d2e7ee2..0000000 --- a/src/user/routes/login/test.rs +++ /dev/null @@ -1,128 +0,0 @@ -use axum::extract::{Json, State}; - -use super::post; -use crate::{test::fixtures, token::app}; - -#[tokio::test] -async fn correct_credentials() { - // Set up the environment - - let app = fixtures::scratch_app().await; - let (name, password) = fixtures::user::create_with_password(&app, &fixtures::now()).await; - - // Call the endpoint - - let identity = fixtures::cookie::not_logged_in(); - let logged_in_at = fixtures::now(); - let request = post::Request { - name: name.clone(), - password, - }; - let (identity, Json(response)) = - post::handler(State(app.clone()), logged_in_at, identity, Json(request)) - .await - .expect("logged in with valid credentials"); - - // Verify the return value's basic structure - - assert_eq!(name, response.name); - let secret = identity - .secret() - .expect("logged in with valid credentials issues an identity cookie"); - - // Verify the semantics - - let validated_at = fixtures::now(); - let (_, validated_login) = app - .tokens() - .validate(&secret, &validated_at) - .await - .expect("identity secret is valid"); - - assert_eq!(response, validated_login); -} - -#[tokio::test] -async fn invalid_name() { - // Set up the environment - - let app = fixtures::scratch_app().await; - - // Call the endpoint - - let identity = fixtures::cookie::not_logged_in(); - let logged_in_at = fixtures::now(); - let (name, password) = fixtures::user::propose(); - let request = post::Request { - name: name.clone(), - password, - }; - let post::Error(error) = - post::handler(State(app.clone()), logged_in_at, identity, Json(request)) - .await - .expect_err("logged in with an incorrect password fails"); - - // Verify the return value's basic structure - - assert!(matches!(error, app::LoginError::Rejected)); -} - -#[tokio::test] -async fn incorrect_password() { - // Set up the environment - - let app = fixtures::scratch_app().await; - let login = fixtures::user::create(&app, &fixtures::now()).await; - - // Call the endpoint - - let logged_in_at = fixtures::now(); - let identity = fixtures::cookie::not_logged_in(); - let request = post::Request { - name: login.name, - password: fixtures::user::propose_password(), - }; - let post::Error(error) = - post::handler(State(app.clone()), logged_in_at, identity, Json(request)) - .await - .expect_err("logged in with an incorrect password"); - - // Verify the return value's basic structure - - assert!(matches!(error, app::LoginError::Rejected)); -} - -#[tokio::test] -async fn token_expires() { - // Set up the environment - - let app = fixtures::scratch_app().await; - let (name, password) = fixtures::user::create_with_password(&app, &fixtures::now()).await; - - // Call the endpoint - - let logged_in_at = fixtures::ancient(); - let identity = fixtures::cookie::not_logged_in(); - let request = post::Request { name, password }; - let (identity, _) = post::handler(State(app.clone()), logged_in_at, identity, Json(request)) - .await - .expect("logged in with valid credentials"); - let secret = identity.secret().expect("logged in with valid credentials"); - - // Verify the semantics - - let expired_at = fixtures::now(); - app.tokens() - .expire(&expired_at) - .await - .expect("expiring tokens never fails"); - - let verified_at = fixtures::now(); - let error = app - .tokens() - .validate(&secret, &verified_at) - .await - .expect_err("validating an expired token"); - - assert!(matches!(error, app::ValidateError::InvalidToken)); -} diff --git a/src/user/routes/logout/mod.rs b/src/user/routes/logout/mod.rs deleted file mode 100644 index 36b384e..0000000 --- a/src/user/routes/logout/mod.rs +++ /dev/null @@ -1,4 +0,0 @@ -pub mod post; - -#[cfg(test)] -mod test; diff --git a/src/user/routes/logout/post.rs b/src/user/routes/logout/post.rs deleted file mode 100644 index 0ac663e..0000000 --- a/src/user/routes/logout/post.rs +++ /dev/null @@ -1,50 +0,0 @@ -use axum::{ - extract::{Json, State}, - http::StatusCode, - response::{IntoResponse, Response}, -}; - -use crate::{ - app::App, - clock::RequestedAt, - error::{Internal, Unauthorized}, - token::{app, extract::IdentityCookie}, -}; - -pub async fn handler( - State(app): State, - RequestedAt(now): RequestedAt, - identity: IdentityCookie, - Json(_): Json, -) -> Result<(IdentityCookie, StatusCode), Error> { - if let Some(secret) = identity.secret() { - let (token, _) = app.tokens().validate(&secret, &now).await?; - app.tokens().logout(&token).await?; - } - - let identity = identity.clear(); - Ok((identity, StatusCode::NO_CONTENT)) -} - -// This forces the only valid request to be `{}`, and not the infinite -// variation allowed when there's no body extractor. -#[derive(Default, serde::Deserialize)] -pub struct Request {} - -#[derive(Debug, thiserror::Error)] -#[error(transparent)] -pub struct Error(#[from] pub app::ValidateError); - -impl IntoResponse for Error { - fn into_response(self) -> Response { - let Self(error) = self; - match error { - app::ValidateError::InvalidToken | app::ValidateError::LoginDeleted => { - Unauthorized.into_response() - } - app::ValidateError::Name(_) | app::ValidateError::Database(_) => { - Internal::from(error).into_response() - } - } - } -} diff --git a/src/user/routes/logout/test.rs b/src/user/routes/logout/test.rs deleted file mode 100644 index ce93760..0000000 --- a/src/user/routes/logout/test.rs +++ /dev/null @@ -1,79 +0,0 @@ -use axum::{ - extract::{Json, State}, - http::StatusCode, -}; - -use super::post; -use crate::{test::fixtures, token::app}; - -#[tokio::test] -async fn successful() { - // Set up the environment - - let app = fixtures::scratch_app().await; - let now = fixtures::now(); - let creds = fixtures::user::create_with_password(&app, &fixtures::now()).await; - let identity = fixtures::cookie::logged_in(&app, &creds, &now).await; - let secret = fixtures::cookie::secret(&identity); - - // Call the endpoint - - let (response_identity, response_status) = post::handler( - State(app.clone()), - fixtures::now(), - identity.clone(), - Json::default(), - ) - .await - .expect("logged out with a valid token"); - - // Verify the return value's basic structure - - assert!(response_identity.secret().is_none()); - assert_eq!(StatusCode::NO_CONTENT, response_status); - - // Verify the semantics - let error = app - .tokens() - .validate(&secret, &now) - .await - .expect_err("secret is invalid"); - assert!(matches!(error, app::ValidateError::InvalidToken)); -} - -#[tokio::test] -async fn no_identity() { - // Set up the environment - - let app = fixtures::scratch_app().await; - - // Call the endpoint - - let identity = fixtures::cookie::not_logged_in(); - let (identity, status) = post::handler(State(app), fixtures::now(), identity, Json::default()) - .await - .expect("logged out with no token succeeds"); - - // Verify the return value's basic structure - - assert!(identity.secret().is_none()); - assert_eq!(StatusCode::NO_CONTENT, status); -} - -#[tokio::test] -async fn invalid_token() { - // Set up the environment - - let app = fixtures::scratch_app().await; - - // Call the endpoint - - let identity = fixtures::cookie::fictitious(); - let post::Error(error) = post::handler(State(app), fixtures::now(), identity, Json::default()) - .await - .expect_err("logged out with an invalid token fails"); - - // Verify the return value's basic structure - - assert!(matches!(error, app::ValidateError::InvalidToken)); -} diff --git a/src/user/routes/mod.rs b/src/user/routes/mod.rs deleted file mode 100644 index f9bbed7..0000000 --- a/src/user/routes/mod.rs +++ /dev/null @@ -1,3 +0,0 @@ -pub mod login; -pub mod logout; -pub mod password; diff --git a/src/user/routes/password/mod.rs b/src/user/routes/password/mod.rs deleted file mode 100644 index 36b384e..0000000 --- a/src/user/routes/password/mod.rs +++ /dev/null @@ -1,4 +0,0 @@ -pub mod post; - -#[cfg(test)] -mod test; diff --git a/src/user/routes/password/post.rs b/src/user/routes/password/post.rs deleted file mode 100644 index 296f6cd..0000000 --- a/src/user/routes/password/post.rs +++ /dev/null @@ -1,54 +0,0 @@ -use axum::{ - extract::{Json, State}, - http::StatusCode, - response::{IntoResponse, Response}, -}; - -use crate::{ - app::App, - clock::RequestedAt, - error::Internal, - token::{ - app, - extract::{Identity, IdentityCookie}, - }, - user::{Password, User}, -}; - -pub async fn handler( - State(app): State, - RequestedAt(now): RequestedAt, - identity: Identity, - cookie: IdentityCookie, - Json(request): Json, -) -> Result<(IdentityCookie, Json), Error> { - let (login, secret) = app - .tokens() - .change_password(&identity.user, &request.password, &request.to, &now) - .await - .map_err(Error)?; - let cookie = cookie.set(secret); - Ok((cookie, Json(login))) -} - -#[derive(serde::Deserialize)] -pub struct Request { - pub password: Password, - pub to: Password, -} - -#[derive(Debug, thiserror::Error)] -#[error(transparent)] -pub struct Error(#[from] pub app::LoginError); - -impl IntoResponse for Error { - fn into_response(self) -> Response { - let Self(error) = self; - match error { - app::LoginError::Rejected => { - (StatusCode::BAD_REQUEST, "invalid name or password").into_response() - } - other => Internal::from(other).into_response(), - } - } -} diff --git a/src/user/routes/password/test.rs b/src/user/routes/password/test.rs deleted file mode 100644 index f977327..0000000 --- a/src/user/routes/password/test.rs +++ /dev/null @@ -1,68 +0,0 @@ -use axum::extract::{Json, State}; - -use super::post; -use crate::{ - test::fixtures, - token::app::{LoginError, ValidateError}, -}; - -#[tokio::test] -async fn password_change() { - // Set up the environment - let app = fixtures::scratch_app().await; - let creds = fixtures::user::create_with_password(&app, &fixtures::now()).await; - let cookie = fixtures::cookie::logged_in(&app, &creds, &fixtures::now()).await; - let identity = fixtures::identity::from_cookie(&app, &cookie, &fixtures::now()).await; - - // Call the endpoint - let (name, password) = creds; - let to = fixtures::user::propose_password(); - let request = post::Request { - password: password.clone(), - to: to.clone(), - }; - let (new_cookie, Json(response)) = post::handler( - State(app.clone()), - fixtures::now(), - identity.clone(), - cookie.clone(), - Json(request), - ) - .await - .expect("changing passwords succeeds"); - - // Verify that we have a new session - assert_ne!(cookie.secret(), new_cookie.secret()); - - // Verify that we're still ourselves - assert_eq!(identity.user, response); - - // Verify that our original token is no longer valid - let validate_err = app - .tokens() - .validate( - &cookie - .secret() - .expect("original identity cookie has a secret"), - &fixtures::now(), - ) - .await - .expect_err("validating the original identity secret should fail"); - assert!(matches!(validate_err, ValidateError::InvalidToken)); - - // Verify that our original password is no longer valid - let login_err = app - .tokens() - .login(&name, &password, &fixtures::now()) - .await - .expect_err("logging in with the original password should fail"); - assert!(matches!(login_err, LoginError::Rejected)); - - // Verify that our new password is valid - let (login, _) = app - .tokens() - .login(&name, &to, &fixtures::now()) - .await - .expect("logging in with the new password should succeed"); - assert_eq!(identity.user, login); -} -- cgit v1.2.3