From 2420f1e75d54a5f209b0267715f078a369d81eb1 Mon Sep 17 00:00:00 2001 From: Owen Jacobson Date: Sun, 23 Mar 2025 15:58:33 -0400 Subject: Rename the `login` module to `user`. --- src/user/app.rs | 56 ++++++++++++++ src/user/create.rs | 95 ++++++++++++++++++++++++ src/user/event.rs | 36 +++++++++ src/user/history.rs | 52 +++++++++++++ src/user/id.rs | 24 ++++++ src/user/mod.rs | 15 ++++ src/user/password.rs | 65 +++++++++++++++++ src/user/repo.rs | 153 +++++++++++++++++++++++++++++++++++++++ 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 | 47 ++++++++++++ src/user/routes/logout/test.rs | 79 ++++++++++++++++++++ src/user/routes/mod.rs | 14 ++++ src/user/routes/password/mod.rs | 4 + src/user/routes/password/post.rs | 54 ++++++++++++++ src/user/routes/password/test.rs | 68 +++++++++++++++++ src/user/snapshot.rs | 52 +++++++++++++ src/user/validate.rs | 23 ++++++ 20 files changed, 1025 insertions(+) create mode 100644 src/user/app.rs create mode 100644 src/user/create.rs create mode 100644 src/user/event.rs create mode 100644 src/user/history.rs create mode 100644 src/user/id.rs create mode 100644 src/user/mod.rs create mode 100644 src/user/password.rs create mode 100644 src/user/repo.rs create mode 100644 src/user/routes/login/mod.rs create mode 100644 src/user/routes/login/post.rs create mode 100644 src/user/routes/login/test.rs create mode 100644 src/user/routes/logout/mod.rs create mode 100644 src/user/routes/logout/post.rs create mode 100644 src/user/routes/logout/test.rs create mode 100644 src/user/routes/mod.rs create mode 100644 src/user/routes/password/mod.rs create mode 100644 src/user/routes/password/post.rs create mode 100644 src/user/routes/password/test.rs create mode 100644 src/user/snapshot.rs create mode 100644 src/user/validate.rs (limited to 'src/user') diff --git a/src/user/app.rs b/src/user/app.rs new file mode 100644 index 0000000..2ab356f --- /dev/null +++ b/src/user/app.rs @@ -0,0 +1,56 @@ +use sqlx::sqlite::SqlitePool; + +use super::{ + Password, User, + create::{self, Create}, +}; +use crate::{clock::DateTime, event::Broadcaster, name::Name}; + +pub struct Users<'a> { + db: &'a SqlitePool, + events: &'a Broadcaster, +} + +impl<'a> Users<'a> { + pub const fn new(db: &'a SqlitePool, events: &'a Broadcaster) -> Self { + Self { db, events } + } + + pub async fn create( + &self, + name: &Name, + password: &Password, + created_at: &DateTime, + ) -> Result { + let create = Create::begin(name, password, created_at); + let validated = create.validate()?; + + let mut tx = self.db.begin().await?; + let stored = validated.store(&mut tx).await?; + tx.commit().await?; + + let user = stored.publish(self.events); + + Ok(user.as_created()) + } +} + +#[derive(Debug, thiserror::Error)] +pub enum CreateError { + #[error("invalid user name: {0}")] + InvalidName(Name), + #[error(transparent)] + PasswordHash(#[from] password_hash::Error), + #[error(transparent)] + Database(#[from] sqlx::Error), +} + +#[cfg(test)] +impl From for CreateError { + fn from(error: create::Error) -> Self { + match error { + create::Error::InvalidName(name) => Self::InvalidName(name), + create::Error::PasswordHash(error) => Self::PasswordHash(error), + } + } +} diff --git a/src/user/create.rs b/src/user/create.rs new file mode 100644 index 0000000..da94685 --- /dev/null +++ b/src/user/create.rs @@ -0,0 +1,95 @@ +use sqlx::{Transaction, sqlite::Sqlite}; + +use super::{History, Password, password::StoredHash, repo::Provider as _, validate}; +use crate::{ + clock::DateTime, + event::{Broadcaster, Event, repo::Provider as _}, + name::Name, +}; + +#[must_use = "dropping a user creation attempt is likely a mistake"] +pub struct Create<'a> { + name: &'a Name, + password: &'a Password, + created_at: &'a DateTime, +} + +impl<'a> Create<'a> { + pub fn begin(name: &'a Name, password: &'a Password, created_at: &'a DateTime) -> Self { + Self { + name, + password, + created_at, + } + } + + pub fn validate(self) -> Result, Error> { + let Self { + name, + password, + created_at, + } = self; + + if !validate::name(name) { + return Err(Error::InvalidName(name.clone())); + } + + let password_hash = password.hash()?; + + Ok(Validated { + name, + password_hash, + created_at, + }) + } +} + +#[must_use = "dropping a user creation attempt is likely a mistake"] +pub struct Validated<'a> { + name: &'a Name, + password_hash: StoredHash, + created_at: &'a DateTime, +} + +impl Validated<'_> { + pub async fn store(self, tx: &mut Transaction<'_, Sqlite>) -> Result { + let Self { + name, + password_hash, + created_at, + } = self; + + let created = tx.sequence().next(created_at).await?; + let user = tx.users().create(name, &password_hash, &created).await?; + + Ok(Stored { user }) + } +} + +#[must_use = "dropping a user creation attempt is likely a mistake"] +pub struct Stored { + user: History, +} + +impl Stored { + #[must_use = "dropping a user creation attempt is likely a mistake"] + pub fn publish(self, events: &Broadcaster) -> History { + let Self { user } = self; + + events.broadcast(user.events().map(Event::from).collect::>()); + + user + } + + pub fn user(&self) -> &History { + &self.user + } +} + +#[derive(Debug, thiserror::Error)] +pub enum Error { + #[error("invalid user name: {0}")] + InvalidName(Name), + #[error(transparent)] + PasswordHash(#[from] password_hash::Error), +} diff --git a/src/user/event.rs b/src/user/event.rs new file mode 100644 index 0000000..a748112 --- /dev/null +++ b/src/user/event.rs @@ -0,0 +1,36 @@ +use super::snapshot::User; +use crate::event::{Instant, Sequenced}; + +#[derive(Clone, Debug, Eq, PartialEq, serde::Serialize)] +#[serde(tag = "event", rename_all = "snake_case")] +pub enum Event { + Created(Created), +} + +impl Sequenced for Event { + fn instant(&self) -> Instant { + match self { + Self::Created(created) => created.instant(), + } + } +} + +#[derive(Clone, Debug, Eq, PartialEq, serde::Serialize)] +pub struct Created { + #[serde(flatten)] + pub instant: Instant, + #[serde(flatten)] + pub user: User, +} + +impl Sequenced for Created { + fn instant(&self) -> Instant { + self.instant + } +} + +impl From for Event { + fn from(event: Created) -> Self { + Self::Created(event) + } +} diff --git a/src/user/history.rs b/src/user/history.rs new file mode 100644 index 0000000..ae7a561 --- /dev/null +++ b/src/user/history.rs @@ -0,0 +1,52 @@ +use super::{ + Id, User, + event::{Created, Event}, +}; +use crate::event::{Instant, Sequence}; + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct History { + pub user: User, + pub created: Instant, +} + +// State interface +impl History { + pub fn id(&self) -> &Id { + &self.user.id + } + + // Snapshot of this user as it was when created. (Note to the future: it's okay + // if this returns a redacted or modified version of the user. If we implement + // renames by redacting the original name, then this should return the edited + // user, not the original, even if that's not how it was "as created.") + pub fn as_created(&self) -> User { + self.user.clone() + } + + pub fn as_of(&self, resume_point: Sequence) -> Option { + self.events() + .filter(Sequence::up_to(resume_point)) + .collect() + } + + // Snapshot of this user, as of all events recorded in this history. + pub fn as_snapshot(&self) -> Option { + self.events().collect() + } +} + +// Events interface +impl History { + fn created(&self) -> Event { + Created { + instant: self.created, + user: self.user.clone(), + } + .into() + } + + pub fn events(&self) -> impl Iterator + use<> { + [self.created()].into_iter() + } +} diff --git a/src/user/id.rs b/src/user/id.rs new file mode 100644 index 0000000..9455deb --- /dev/null +++ b/src/user/id.rs @@ -0,0 +1,24 @@ +use crate::id::Id as BaseId; + +// Stable identifier for a User. Prefixed with `L`. +#[derive(Clone, Debug, Eq, PartialEq, sqlx::Type, serde::Serialize)] +#[sqlx(transparent)] +pub struct Id(BaseId); + +impl From for Id { + fn from(id: BaseId) -> Self { + Self(id) + } +} + +impl Id { + pub fn generate() -> Self { + BaseId::generate("L") + } +} + +impl std::fmt::Display for Id { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + self.0.fmt(f) + } +} diff --git a/src/user/mod.rs b/src/user/mod.rs new file mode 100644 index 0000000..f4c66ab --- /dev/null +++ b/src/user/mod.rs @@ -0,0 +1,15 @@ +#[cfg(test)] +pub mod app; +pub mod create; +pub mod event; +mod history; +mod id; +pub mod password; +pub mod repo; +mod routes; +mod snapshot; +mod validate; + +pub use self::{ + event::Event, history::History, id::Id, password::Password, routes::router, snapshot::User, +}; diff --git a/src/user/password.rs b/src/user/password.rs new file mode 100644 index 0000000..e1d164e --- /dev/null +++ b/src/user/password.rs @@ -0,0 +1,65 @@ +use std::fmt; + +use argon2::Argon2; +use password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString}; +use rand_core::OsRng; + +use crate::normalize::nfc; + +#[derive(sqlx::Type)] +#[sqlx(transparent)] +pub struct StoredHash(String); + +impl StoredHash { + pub fn verify(&self, password: &Password) -> Result { + let hash = PasswordHash::new(&self.0)?; + + match Argon2::default().verify_password(password.as_bytes(), &hash) { + // Successful authentication, not an error + Ok(()) => Ok(true), + // Unsuccessful authentication, also not an error + Err(password_hash::errors::Error::Password) => Ok(false), + // Password validation failed for some other reason, treat as an error + Err(err) => Err(err), + } + } +} + +impl fmt::Debug for StoredHash { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_tuple("StoredHash").field(&"********").finish() + } +} + +#[derive(Clone, serde::Deserialize)] +#[serde(transparent)] +pub struct Password(nfc::String); + +impl Password { + pub fn hash(&self) -> Result { + let Self(password) = self; + let salt = SaltString::generate(&mut OsRng); + let argon2 = Argon2::default(); + let hash = argon2 + .hash_password(password.as_bytes(), &salt)? + .to_string(); + Ok(StoredHash(hash)) + } + + fn as_bytes(&self) -> &[u8] { + let Self(value) = self; + value.as_bytes() + } +} + +impl fmt::Debug for Password { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_tuple("Password").field(&"********").finish() + } +} + +impl From for Password { + fn from(password: String) -> Self { + Password(password.into()) + } +} diff --git a/src/user/repo.rs b/src/user/repo.rs new file mode 100644 index 0000000..c02d50f --- /dev/null +++ b/src/user/repo.rs @@ -0,0 +1,153 @@ +use futures::stream::{StreamExt as _, TryStreamExt as _}; +use sqlx::{SqliteConnection, Transaction, sqlite::Sqlite}; + +use crate::{ + clock::DateTime, + event::{Instant, Sequence}, + name::{self, Name}, + user::{History, Id, User, password::StoredHash}, +}; + +pub trait Provider { + fn users(&mut self) -> Users; +} + +impl Provider for Transaction<'_, Sqlite> { + fn users(&mut self) -> Users { + Users(self) + } +} + +pub struct Users<'t>(&'t mut SqliteConnection); + +impl Users<'_> { + pub async fn create( + &mut self, + name: &Name, + password_hash: &StoredHash, + created: &Instant, + ) -> Result { + let id = Id::generate(); + let display_name = name.display(); + let canonical_name = name.canonical(); + + sqlx::query!( + r#" + insert + into user (id, display_name, canonical_name, password_hash, created_sequence, created_at) + values ($1, $2, $3, $4, $5, $6) + "#, + id, + display_name, + canonical_name, + password_hash, + created.sequence, + created.at, + ) + .execute(&mut *self.0) + .await?; + + let user = History { + created: *created, + user: User { + id, + name: name.clone(), + }, + }; + + Ok(user) + } + + pub async fn set_password( + &mut self, + login: &History, + to: &StoredHash, + ) -> Result<(), sqlx::Error> { + let login = login.id(); + + sqlx::query_scalar!( + r#" + update user + set password_hash = $1 + where id = $2 + returning id as "id: Id" + "#, + to, + login, + ) + .fetch_one(&mut *self.0) + .await?; + + Ok(()) + } + + pub async fn all(&mut self, resume_at: Sequence) -> Result, LoadError> { + let logins = sqlx::query!( + r#" + select + id as "id: Id", + display_name as "display_name: String", + canonical_name as "canonical_name: String", + created_sequence as "created_sequence: Sequence", + created_at as "created_at: DateTime" + from user + where created_sequence <= $1 + order by canonical_name + "#, + resume_at, + ) + .map(|row| { + Ok::<_, LoadError>(History { + user: User { + id: row.id, + name: Name::new(row.display_name, row.canonical_name)?, + }, + created: Instant::new(row.created_at, row.created_sequence), + }) + }) + .fetch(&mut *self.0) + .map(|res| res?) + .try_collect() + .await?; + + Ok(logins) + } + + pub async fn replay(&mut self, resume_at: Sequence) -> Result, LoadError> { + let logins = sqlx::query!( + r#" + select + id as "id: Id", + display_name as "display_name: String", + canonical_name as "canonical_name: String", + created_sequence as "created_sequence: Sequence", + created_at as "created_at: DateTime" + from user + where created_sequence > $1 + "#, + resume_at, + ) + .map(|row| { + Ok::<_, name::Error>(History { + user: User { + id: row.id, + name: Name::new(row.display_name, row.canonical_name)?, + }, + created: Instant::new(row.created_at, row.created_sequence), + }) + }) + .fetch(&mut *self.0) + .map(|res| Ok::<_, LoadError>(res??)) + .try_collect() + .await?; + + Ok(logins) + } +} + +#[derive(Debug, thiserror::Error)] +#[error(transparent)] +pub enum LoadError { + Database(#[from] sqlx::Error), + Name(#[from] name::Error), +} diff --git a/src/user/routes/login/mod.rs b/src/user/routes/login/mod.rs new file mode 100644 index 0000000..36b384e --- /dev/null +++ b/src/user/routes/login/mod.rs @@ -0,0 +1,4 @@ +pub mod post; + +#[cfg(test)] +mod test; diff --git a/src/user/routes/login/post.rs b/src/user/routes/login/post.rs new file mode 100644 index 0000000..39f9eea --- /dev/null +++ b/src/user/routes/login/post.rs @@ -0,0 +1,52 @@ +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 new file mode 100644 index 0000000..7399796 --- /dev/null +++ b/src/user/routes/login/test.rs @@ -0,0 +1,128 @@ +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::login::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::login::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::login::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::login::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::login::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 new file mode 100644 index 0000000..36b384e --- /dev/null +++ b/src/user/routes/logout/mod.rs @@ -0,0 +1,4 @@ +pub mod post; + +#[cfg(test)] +mod test; diff --git a/src/user/routes/logout/post.rs b/src/user/routes/logout/post.rs new file mode 100644 index 0000000..bb09b9f --- /dev/null +++ b/src/user/routes/logout/post.rs @@ -0,0 +1,47 @@ +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; + #[allow(clippy::match_wildcard_for_single_variants)] + match error { + app::ValidateError::InvalidToken => Unauthorized.into_response(), + other => Internal::from(other).into_response(), + } + } +} diff --git a/src/user/routes/logout/test.rs b/src/user/routes/logout/test.rs new file mode 100644 index 0000000..775fa9f --- /dev/null +++ b/src/user/routes/logout/test.rs @@ -0,0 +1,79 @@ +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::login::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 new file mode 100644 index 0000000..ade96cb --- /dev/null +++ b/src/user/routes/mod.rs @@ -0,0 +1,14 @@ +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)) +} diff --git a/src/user/routes/password/mod.rs b/src/user/routes/password/mod.rs new file mode 100644 index 0000000..36b384e --- /dev/null +++ b/src/user/routes/password/mod.rs @@ -0,0 +1,4 @@ +pub mod post; + +#[cfg(test)] +mod test; diff --git a/src/user/routes/password/post.rs b/src/user/routes/password/post.rs new file mode 100644 index 0000000..296f6cd --- /dev/null +++ b/src/user/routes/password/post.rs @@ -0,0 +1,54 @@ +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 new file mode 100644 index 0000000..01dcb38 --- /dev/null +++ b/src/user/routes/password/test.rs @@ -0,0 +1,68 @@ +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::login::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::login::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); +} diff --git a/src/user/snapshot.rs b/src/user/snapshot.rs new file mode 100644 index 0000000..d548e06 --- /dev/null +++ b/src/user/snapshot.rs @@ -0,0 +1,52 @@ +use super::{ + Id, + event::{Created, Event}, +}; +use crate::name::Name; + +// This also implements FromRequestParts (see `./extract.rs`). As a result, it +// can be used as an extractor for endpoints that want to require a user, or for +// endpoints that need to behave differently depending on whether the client is +// or is not logged in. +#[derive(Clone, Debug, Eq, PartialEq, serde::Serialize)] +pub struct User { + pub id: Id, + pub name: Name, + // The omission of the hashed password is deliberate, to minimize the + // chance that it ends up tangled up in debug output or in some other chunk + // of logic elsewhere. +} + +impl User { + // Without this allow, clippy wants the `Option` return type to be `Self`. It's not a bad + // suggestion, but we need `Option` here, for two reasons: + // + // 1. This is used to collect streams using a fold, below, which requires a type + // consistent with the fold, and + // 2. It's also consistent with the other history state machine types. + #[allow(clippy::unnecessary_wraps)] + fn apply(state: Option, event: Event) -> Option { + match (state, event) { + (None, Event::Created(event)) => Some(event.into()), + (state, event) => panic!("invalid message event {event:#?} for state {state:#?}"), + } + } +} + +impl FromIterator for Option { + fn from_iter>(events: I) -> Self { + events.into_iter().fold(None, User::apply) + } +} + +impl From<&Created> for User { + fn from(event: &Created) -> Self { + event.user.clone() + } +} + +impl From for User { + fn from(event: Created) -> Self { + event.user + } +} diff --git a/src/user/validate.rs b/src/user/validate.rs new file mode 100644 index 0000000..0c97293 --- /dev/null +++ b/src/user/validate.rs @@ -0,0 +1,23 @@ +use unicode_segmentation::UnicodeSegmentation as _; + +use crate::name::Name; + +// Picked out of a hat. The power of two is not meaningful. +const NAME_TOO_LONG: usize = 64; + +pub fn name(name: &Name) -> bool { + let display = name.display(); + + [ + display.graphemes(true).count() < NAME_TOO_LONG, + display.chars().all(|ch| !ch.is_control()), + display.chars().next().is_some_and(|c| !c.is_whitespace()), + display.chars().last().is_some_and(|c| !c.is_whitespace()), + display + .chars() + .zip(display.chars().skip(1)) + .all(|(a, b)| !(a.is_whitespace() && b.is_whitespace())), + ] + .into_iter() + .all(|value| value) +} -- cgit v1.2.3 From f6191926ba94b8a1b303bca7c6996dce67781290 Mon Sep 17 00:00:00 2001 From: Owen Jacobson Date: Sun, 23 Mar 2025 16:18:24 -0400 Subject: Change the prefix for newly-generated user IDs to `U`, for `User`. --- src/user/id.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) (limited to 'src/user') diff --git a/src/user/id.rs b/src/user/id.rs index 9455deb..bc14c1f 100644 --- a/src/user/id.rs +++ b/src/user/id.rs @@ -1,6 +1,7 @@ use crate::id::Id as BaseId; -// Stable identifier for a User. Prefixed with `L`. +// Stable identifier for a User. Prefixed with `U`. Users created before March, 2025 may have an `L` +// prefix, instead. #[derive(Clone, Debug, Eq, PartialEq, sqlx::Type, serde::Serialize)] #[sqlx(transparent)] pub struct Id(BaseId); @@ -13,7 +14,7 @@ impl From for Id { impl Id { pub fn generate() -> Self { - BaseId::generate("L") + BaseId::generate("U") } } -- cgit v1.2.3 From 5e4e052c400bb88933125f3549cec6dc12a9d09b Mon Sep 17 00:00:00 2001 From: Owen Jacobson Date: Sun, 23 Mar 2025 19:06:43 -0400 Subject: Rename `login` to `user` throughout the server --- src/boot/app.rs | 2 +- src/boot/mod.rs | 2 +- src/boot/routes/get.rs | 4 +-- src/boot/routes/test.rs | 12 ++++---- src/channel/routes/channel/test/delete.rs | 2 +- src/event/app.rs | 6 ++-- src/event/mod.rs | 6 ++-- src/event/routes/test/invite.rs | 16 +++++----- src/event/routes/test/message.rs | 18 +++++------ src/event/routes/test/resume.rs | 4 +-- src/event/routes/test/setup.rs | 6 ++-- src/event/routes/test/token.rs | 12 ++++---- src/invite/routes/invite/test/get.rs | 4 +-- src/invite/routes/invite/test/post.rs | 28 ++++++++--------- src/message/routes/message/test.rs | 8 ++--- src/setup/routes/test.rs | 10 +++--- src/test/fixtures/event.rs | 6 ++-- src/test/fixtures/identity.rs | 4 +-- src/test/fixtures/login.rs | 51 ------------------------------- src/test/fixtures/mod.rs | 2 +- src/test/fixtures/user.rs | 51 +++++++++++++++++++++++++++++++ src/user/routes/login/test.rs | 10 +++--- src/user/routes/logout/test.rs | 2 +- src/user/routes/password/test.rs | 4 +-- 24 files changed, 135 insertions(+), 135 deletions(-) delete mode 100644 src/test/fixtures/login.rs create mode 100644 src/test/fixtures/user.rs (limited to 'src/user') diff --git a/src/boot/app.rs b/src/boot/app.rs index 9c2559e..264d5ae 100644 --- a/src/boot/app.rs +++ b/src/boot/app.rs @@ -45,7 +45,7 @@ impl<'a> Boot<'a> { Ok(Snapshot { resume_point, - logins, + users: logins, channels, messages, }) diff --git a/src/boot/mod.rs b/src/boot/mod.rs index d614df5..c52b088 100644 --- a/src/boot/mod.rs +++ b/src/boot/mod.rs @@ -8,7 +8,7 @@ pub use self::routes::router; #[derive(serde::Serialize)] pub struct Snapshot { pub resume_point: Sequence, - pub logins: Vec, + pub users: Vec, pub channels: Vec, pub messages: Vec, } diff --git a/src/boot/routes/get.rs b/src/boot/routes/get.rs index c04c6b3..4873b7a 100644 --- a/src/boot/routes/get.rs +++ b/src/boot/routes/get.rs @@ -8,14 +8,14 @@ use crate::{app::App, boot::Snapshot, error::Internal, token::extract::Identity, pub async fn handler(State(app): State, identity: Identity) -> Result { let snapshot = app.boot().snapshot().await?; Ok(Response { - login: identity.user, + user: identity.user, snapshot, }) } #[derive(serde::Serialize)] pub struct Response { - pub login: User, + pub user: User, #[serde(flatten)] pub snapshot: Snapshot, } diff --git a/src/boot/routes/test.rs b/src/boot/routes/test.rs index 5bd9f66..55802fe 100644 --- a/src/boot/routes/test.rs +++ b/src/boot/routes/test.rs @@ -12,20 +12,20 @@ async fn returns_identity() { .await .expect("boot always succeeds"); - assert_eq!(viewer.user, response.login); + assert_eq!(viewer.user, response.user); } #[tokio::test] async fn includes_logins() { let app = fixtures::scratch_app().await; - let spectator = fixtures::login::create(&app, &fixtures::now()).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.logins.contains(&spectator)); + assert!(response.snapshot.users.contains(&spectator)); } #[tokio::test] @@ -44,7 +44,7 @@ async fn includes_channels() { #[tokio::test] async fn includes_messages() { let app = fixtures::scratch_app().await; - let sender = fixtures::login::create(&app, &fixtures::now()).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; @@ -59,7 +59,7 @@ async fn includes_messages() { #[tokio::test] async fn excludes_expired_messages() { let app = fixtures::scratch_app().await; - let sender = fixtures::login::create(&app, &fixtures::ancient()).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; @@ -80,7 +80,7 @@ async fn excludes_expired_messages() { #[tokio::test] async fn excludes_deleted_messages() { let app = fixtures::scratch_app().await; - let sender = fixtures::login::create(&app, &fixtures::now()).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; diff --git a/src/channel/routes/channel/test/delete.rs b/src/channel/routes/channel/test/delete.rs index 77a0b03..bd9261d 100644 --- a/src/channel/routes/channel/test/delete.rs +++ b/src/channel/routes/channel/test/delete.rs @@ -156,7 +156,7 @@ pub async fn channel_not_empty() { let app = fixtures::scratch_app().await; let channel = fixtures::channel::create(&app, &fixtures::now()).await; - let sender = fixtures::login::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 diff --git a/src/event/app.rs b/src/event/app.rs index 447a98f..45a9099 100644 --- a/src/event/app.rs +++ b/src/event/app.rs @@ -33,8 +33,8 @@ impl<'a> Events<'a> { let mut tx = self.db.begin().await?; - let logins = tx.users().replay(resume_at).await?; - let login_events = logins + let users = tx.users().replay(resume_at).await?; + let user_events = users .iter() .map(user::History::events) .kmerge_by(Sequence::merge) @@ -57,7 +57,7 @@ impl<'a> Events<'a> { .filter(Sequence::after(resume_at)) .map(Event::from); - let replay_events = login_events + let replay_events = user_events .merge_by(channel_events, Sequence::merge) .merge_by(message_events, Sequence::merge) .collect::>(); diff --git a/src/event/mod.rs b/src/event/mod.rs index 773adc3..3ab88ec 100644 --- a/src/event/mod.rs +++ b/src/event/mod.rs @@ -16,7 +16,7 @@ pub use self::{ #[derive(Clone, Debug, Eq, PartialEq, serde::Serialize)] #[serde(tag = "type", rename_all = "snake_case")] pub enum Event { - Login(user::Event), + User(user::Event), Channel(channel::Event), Message(message::Event), } @@ -24,7 +24,7 @@ pub enum Event { impl Sequenced for Event { fn instant(&self) -> Instant { match self { - Self::Login(event) => event.instant(), + Self::User(event) => event.instant(), Self::Channel(event) => event.instant(), Self::Message(event) => event.instant(), } @@ -33,7 +33,7 @@ impl Sequenced for Event { impl From for Event { fn from(event: user::Event) -> Self { - Self::Login(event) + Self::User(event) } } diff --git a/src/event/routes/test/invite.rs b/src/event/routes/test/invite.rs index 80b4291..1d1bec6 100644 --- a/src/event/routes/test/invite.rs +++ b/src/event/routes/test/invite.rs @@ -12,7 +12,7 @@ async fn accepting_invite() { // Set up the environment let app = fixtures::scratch_app().await; - let issuer = fixtures::login::create(&app, &fixtures::now()).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; @@ -30,7 +30,7 @@ async fn accepting_invite() { // Accept the invite - let (name, password) = fixtures::login::propose(); + let (name, password) = fixtures::user::propose(); let (joiner, _) = app .invites() .accept(&invite.id, &name, &password, &fixtures::now()) @@ -40,8 +40,8 @@ async fn accepting_invite() { // Expect a login created event let _ = events - .filter_map(fixtures::event::login) - .filter_map(fixtures::event::login::created) + .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") @@ -53,13 +53,13 @@ async fn previously_accepted_invite() { // Set up the environment let app = fixtures::scratch_app().await; - let issuer = fixtures::login::create(&app, &fixtures::now()).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::login::propose(); + let (name, password) = fixtures::user::propose(); let (joiner, _) = app .invites() .accept(&invite.id, &name, &password, &fixtures::now()) @@ -81,8 +81,8 @@ async fn previously_accepted_invite() { // Expect a login created event let _ = events - .filter_map(fixtures::event::login) - .filter_map(fixtures::event::login::created) + .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") diff --git a/src/event/routes/test/message.rs b/src/event/routes/test/message.rs index fafaeb3..84a3aec 100644 --- a/src/event/routes/test/message.rs +++ b/src/event/routes/test/message.rs @@ -32,7 +32,7 @@ async fn sending() { // Send a message - let sender = fixtures::login::create(&app, &fixtures::now()).await; + let sender = fixtures::user::create(&app, &fixtures::now()).await; let message = app .messages() .send( @@ -65,7 +65,7 @@ async fn previously_sent() { // Send a message - let sender = fixtures::login::create(&app, &fixtures::now()).await; + let sender = fixtures::user::create(&app, &fixtures::now()).await; let message = app .messages() .send( @@ -105,7 +105,7 @@ async fn sent_in_multiple_channels() { // Set up the environment let app = fixtures::scratch_app().await; - let sender = fixtures::login::create(&app, &fixtures::now()).await; + let sender = fixtures::user::create(&app, &fixtures::now()).await; let resume_point = fixtures::boot::resume_point(&app).await; let channels = [ @@ -156,7 +156,7 @@ async fn sent_sequentially() { let app = fixtures::scratch_app().await; let channel = fixtures::channel::create(&app, &fixtures::now()).await; - let sender = fixtures::login::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![ @@ -200,7 +200,7 @@ async fn expiring() { let app = fixtures::scratch_app().await; let channel = fixtures::channel::create(&app, &fixtures::ancient()).await; - let sender = fixtures::login::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; @@ -239,7 +239,7 @@ async fn previously_expired() { let app = fixtures::scratch_app().await; let channel = fixtures::channel::create(&app, &fixtures::ancient()).await; - let sender = fixtures::login::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; @@ -278,7 +278,7 @@ async fn deleting() { let app = fixtures::scratch_app().await; let channel = fixtures::channel::create(&app, &fixtures::now()).await; - let sender = fixtures::login::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; @@ -317,7 +317,7 @@ async fn previously_deleted() { let app = fixtures::scratch_app().await; let channel = fixtures::channel::create(&app, &fixtures::now()).await; - let sender = fixtures::login::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; @@ -356,7 +356,7 @@ async fn previously_purged() { let app = fixtures::scratch_app().await; let channel = fixtures::channel::create(&app, &fixtures::ancient()).await; - let sender = fixtures::login::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; diff --git a/src/event/routes/test/resume.rs b/src/event/routes/test/resume.rs index dc27691..633eae3 100644 --- a/src/event/routes/test/resume.rs +++ b/src/event/routes/test/resume.rs @@ -15,7 +15,7 @@ async fn resume() { let app = fixtures::scratch_app().await; let channel = fixtures::channel::create(&app, &fixtures::now()).await; - let sender = fixtures::login::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; @@ -96,7 +96,7 @@ async fn serial_resume() { // Set up the environment let app = fixtures::scratch_app().await; - let sender = fixtures::login::create(&app, &fixtures::now()).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; diff --git a/src/event/routes/test/setup.rs b/src/event/routes/test/setup.rs index 345018e..1170fe4 100644 --- a/src/event/routes/test/setup.rs +++ b/src/event/routes/test/setup.rs @@ -19,7 +19,7 @@ async fn previously_completed() { // Complete initial setup - let (name, password) = fixtures::login::propose(); + let (name, password) = fixtures::user::propose(); let (owner, _) = app .setup() .initial(&name, &password, &fixtures::now()) @@ -41,8 +41,8 @@ async fn previously_completed() { // Expect a login created event let _ = events - .filter_map(fixtures::event::login) - .filter_map(fixtures::event::login::created) + .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") diff --git a/src/event/routes/test/token.rs b/src/event/routes/test/token.rs index d2232a4..a467de5 100644 --- a/src/event/routes/test/token.rs +++ b/src/event/routes/test/token.rs @@ -13,12 +13,12 @@ async fn terminates_on_token_expiry() { let app = fixtures::scratch_app().await; let channel = fixtures::channel::create(&app, &fixtures::now()).await; - let sender = fixtures::login::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::login::create_with_password(&app, &fixtures::now()).await; + let subscriber_creds = fixtures::user::create_with_password(&app, &fixtures::now()).await; let subscriber = fixtures::identity::logged_in(&app, &subscriber_creds, &fixtures::ancient()).await; @@ -60,7 +60,7 @@ async fn terminates_on_logout() { let app = fixtures::scratch_app().await; let channel = fixtures::channel::create(&app, &fixtures::now()).await; - let sender = fixtures::login::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 @@ -106,12 +106,12 @@ async fn terminates_on_password_change() { let app = fixtures::scratch_app().await; let channel = fixtures::channel::create(&app, &fixtures::now()).await; - let sender = fixtures::login::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::login::create_with_password(&app, &fixtures::now()).await; + 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; @@ -127,7 +127,7 @@ async fn terminates_on_password_change() { // Verify the resulting stream's behaviour let (_, password) = creds; - let to = fixtures::login::propose_password(); + let to = fixtures::user::propose_password(); app.tokens() .change_password(&subscriber.user, &password, &to, &fixtures::now()) .await diff --git a/src/invite/routes/invite/test/get.rs b/src/invite/routes/invite/test/get.rs index c6780ed..0dc8a79 100644 --- a/src/invite/routes/invite/test/get.rs +++ b/src/invite/routes/invite/test/get.rs @@ -7,7 +7,7 @@ async fn valid_invite() { // Set up the environment let app = fixtures::scratch_app().await; - let issuer = fixtures::login::create(&app, &fixtures::now()).await; + let issuer = fixtures::user::create(&app, &fixtures::now()).await; let invite = fixtures::invite::issue(&app, &issuer, &fixtures::now()).await; // Call endpoint @@ -45,7 +45,7 @@ async fn expired_invite() { // Set up the environment let app = fixtures::scratch_app().await; - let issuer = fixtures::login::create(&app, &fixtures::ancient()).await; + let issuer = fixtures::user::create(&app, &fixtures::ancient()).await; let invite = fixtures::invite::issue(&app, &issuer, &fixtures::ancient()).await; app.invites() diff --git a/src/invite/routes/invite/test/post.rs b/src/invite/routes/invite/test/post.rs index 3db764c..b204b32 100644 --- a/src/invite/routes/invite/test/post.rs +++ b/src/invite/routes/invite/test/post.rs @@ -11,12 +11,12 @@ async fn valid_invite() { // Set up the environment let app = fixtures::scratch_app().await; - let issuer = fixtures::login::create(&app, &fixtures::now()).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::login::propose(); + let (name, password) = fixtures::user::propose(); let identity = fixtures::cookie::not_logged_in(); let request = post::Request { name: name.clone(), @@ -68,7 +68,7 @@ async fn nonexistent_invite() { // Call the endpoint - let (name, password) = fixtures::login::propose(); + let (name, password) = fixtures::user::propose(); let identity = fixtures::cookie::not_logged_in(); let request = post::Request { name: name.clone(), @@ -94,7 +94,7 @@ async fn expired_invite() { // Set up the environment let app = fixtures::scratch_app().await; - let issuer = fixtures::login::create(&app, &fixtures::ancient()).await; + let issuer = fixtures::user::create(&app, &fixtures::ancient()).await; let invite = fixtures::invite::issue(&app, &issuer, &fixtures::ancient()).await; app.invites() @@ -104,7 +104,7 @@ async fn expired_invite() { // Call the endpoint - let (name, password) = fixtures::login::propose(); + let (name, password) = fixtures::user::propose(); let identity = fixtures::cookie::not_logged_in(); let request = post::Request { name: name.clone(), @@ -130,10 +130,10 @@ async fn accepted_invite() { // Set up the environment let app = fixtures::scratch_app().await; - let issuer = fixtures::login::create(&app, &fixtures::ancient()).await; + let issuer = fixtures::user::create(&app, &fixtures::ancient()).await; let invite = fixtures::invite::issue(&app, &issuer, &fixtures::ancient()).await; - let (name, password) = fixtures::login::propose(); + let (name, password) = fixtures::user::propose(); app.invites() .accept(&invite.id, &name, &password, &fixtures::now()) .await @@ -141,7 +141,7 @@ async fn accepted_invite() { // Call the endpoint - let (name, password) = fixtures::login::propose(); + let (name, password) = fixtures::user::propose(); let identity = fixtures::cookie::not_logged_in(); let request = post::Request { name: name.clone(), @@ -167,14 +167,14 @@ async fn conflicting_name() { // Set up the environment let app = fixtures::scratch_app().await; - let issuer = fixtures::login::create(&app, &fixtures::ancient()).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::login::propose_password(), + &fixtures::user::propose_password(), &fixtures::now(), ) .await @@ -183,7 +183,7 @@ async fn conflicting_name() { // Call the endpoint let conflicting_name = Name::from("r\u{0133}ksmuseum"); - let password = fixtures::login::propose_password(); + let password = fixtures::user::propose_password(); let identity = fixtures::cookie::not_logged_in(); let request = post::Request { @@ -212,13 +212,13 @@ async fn invalid_name() { // Set up the environment let app = fixtures::scratch_app().await; - let issuer = fixtures::login::create(&app, &fixtures::now()).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::login::propose_invalid_name(); - let password = fixtures::login::propose_password(); + 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(), diff --git a/src/message/routes/message/test.rs b/src/message/routes/message/test.rs index 1abd91c..1888be7 100644 --- a/src/message/routes/message/test.rs +++ b/src/message/routes/message/test.rs @@ -62,7 +62,7 @@ pub async fn delete_deleted() { // Set up the environment let app = fixtures::scratch_app().await; - let sender = fixtures::login::create(&app, &fixtures::now()).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; @@ -93,7 +93,7 @@ pub async fn delete_expired() { // Set up the environment let app = fixtures::scratch_app().await; - let sender = fixtures::login::create(&app, &fixtures::ancient()).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; @@ -124,7 +124,7 @@ pub async fn delete_purged() { // Set up the environment let app = fixtures::scratch_app().await; - let sender = fixtures::login::create(&app, &fixtures::ancient()).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; @@ -160,7 +160,7 @@ pub async fn delete_not_sender() { // Set up the environment let app = fixtures::scratch_app().await; - let sender = fixtures::login::create(&app, &fixtures::now()).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; diff --git a/src/setup/routes/test.rs b/src/setup/routes/test.rs index 5794b78..e9f5cd6 100644 --- a/src/setup/routes/test.rs +++ b/src/setup/routes/test.rs @@ -11,7 +11,7 @@ async fn fresh_instance() { // Call the endpoint let identity = fixtures::cookie::not_logged_in(); - let (name, password) = fixtures::login::propose(); + let (name, password) = fixtures::user::propose(); let request = post::Request { name: name.clone(), password: password.clone(), @@ -52,11 +52,11 @@ async fn login_exists() { // Set up the environment let app = fixtures::scratch_app().await; - fixtures::login::create(&app, &fixtures::now()).await; + fixtures::user::create(&app, &fixtures::now()).await; // Call the endpoint let identity = fixtures::cookie::not_logged_in(); - let (name, password) = fixtures::login::propose(); + 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)) @@ -76,8 +76,8 @@ async fn invalid_name() { // Call the endpoint - let name = fixtures::login::propose_invalid_name(); - let password = fixtures::login::propose_password(); + 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(), diff --git a/src/test/fixtures/event.rs b/src/test/fixtures/event.rs index c6e5337..a30bb4b 100644 --- a/src/test/fixtures/event.rs +++ b/src/test/fixtures/event.rs @@ -16,9 +16,9 @@ pub fn message(event: Event) -> Ready> { }) } -pub fn login(event: Event) -> Ready> { +pub fn user(event: Event) -> Ready> { future::ready(match event { - Event::Login(event) => Some(event), + Event::User(event) => Some(event), _ => None, }) } @@ -65,7 +65,7 @@ pub mod message { } } -pub mod login { +pub mod user { use std::future::{self, Ready}; pub use crate::user::Event; diff --git a/src/test/fixtures/identity.rs b/src/test/fixtures/identity.rs index 7611066..0b2f978 100644 --- a/src/test/fixtures/identity.rs +++ b/src/test/fixtures/identity.rs @@ -11,7 +11,7 @@ use crate::{ }; pub async fn create(app: &App, created_at: &RequestedAt) -> Identity { - let credentials = fixtures::login::create_with_password(app, created_at).await; + let credentials = fixtures::user::create_with_password(app, created_at).await; logged_in(app, &credentials, created_at).await } @@ -41,7 +41,7 @@ pub async fn logged_in( pub fn fictitious() -> Identity { let token = token::Id::generate(); - let login = fixtures::login::fictitious(); + let login = fixtures::user::fictitious(); Identity { token, user: login } } diff --git a/src/test/fixtures/login.rs b/src/test/fixtures/login.rs deleted file mode 100644 index e668c95..0000000 --- a/src/test/fixtures/login.rs +++ /dev/null @@ -1,51 +0,0 @@ -use faker_rand::{en_us::internet, lorem::Paragraphs}; -use uuid::Uuid; - -use crate::{ - app::App, - clock::RequestedAt, - name::Name, - user::{self, Password, User}, -}; - -pub async fn create_with_password(app: &App, created_at: &RequestedAt) -> (Name, Password) { - let (name, password) = propose(); - let login = app - .users() - .create(&name, &password, created_at) - .await - .expect("should always succeed if the login is actually new"); - - (login.name, password) -} - -pub async fn create(app: &App, created_at: &RequestedAt) -> User { - let (name, password) = propose(); - app.users() - .create(&name, &password, created_at) - .await - .expect("should always succeed if the login is actually new") -} - -pub fn fictitious() -> User { - User { - id: user::Id::generate(), - name: propose_name(), - } -} - -pub fn propose() -> (Name, Password) { - (propose_name(), propose_password()) -} - -pub fn propose_invalid_name() -> Name { - rand::random::().to_string().into() -} - -fn propose_name() -> Name { - rand::random::().to_string().into() -} - -pub fn propose_password() -> Password { - Uuid::new_v4().to_string().into() -} diff --git a/src/test/fixtures/mod.rs b/src/test/fixtures/mod.rs index 57eee30..418bdb5 100644 --- a/src/test/fixtures/mod.rs +++ b/src/test/fixtures/mod.rs @@ -9,8 +9,8 @@ pub mod event; pub mod future; pub mod identity; pub mod invite; -pub mod login; pub mod message; +pub mod user; pub async fn scratch_app() -> App { let pool = db::prepare("sqlite::memory:", "sqlite::memory:") diff --git a/src/test/fixtures/user.rs b/src/test/fixtures/user.rs new file mode 100644 index 0000000..e668c95 --- /dev/null +++ b/src/test/fixtures/user.rs @@ -0,0 +1,51 @@ +use faker_rand::{en_us::internet, lorem::Paragraphs}; +use uuid::Uuid; + +use crate::{ + app::App, + clock::RequestedAt, + name::Name, + user::{self, Password, User}, +}; + +pub async fn create_with_password(app: &App, created_at: &RequestedAt) -> (Name, Password) { + let (name, password) = propose(); + let login = app + .users() + .create(&name, &password, created_at) + .await + .expect("should always succeed if the login is actually new"); + + (login.name, password) +} + +pub async fn create(app: &App, created_at: &RequestedAt) -> User { + let (name, password) = propose(); + app.users() + .create(&name, &password, created_at) + .await + .expect("should always succeed if the login is actually new") +} + +pub fn fictitious() -> User { + User { + id: user::Id::generate(), + name: propose_name(), + } +} + +pub fn propose() -> (Name, Password) { + (propose_name(), propose_password()) +} + +pub fn propose_invalid_name() -> Name { + rand::random::().to_string().into() +} + +fn propose_name() -> Name { + rand::random::().to_string().into() +} + +pub fn propose_password() -> Password { + Uuid::new_v4().to_string().into() +} diff --git a/src/user/routes/login/test.rs b/src/user/routes/login/test.rs index 7399796..d2e7ee2 100644 --- a/src/user/routes/login/test.rs +++ b/src/user/routes/login/test.rs @@ -8,7 +8,7 @@ async fn correct_credentials() { // Set up the environment let app = fixtures::scratch_app().await; - let (name, password) = fixtures::login::create_with_password(&app, &fixtures::now()).await; + let (name, password) = fixtures::user::create_with_password(&app, &fixtures::now()).await; // Call the endpoint @@ -52,7 +52,7 @@ async fn invalid_name() { let identity = fixtures::cookie::not_logged_in(); let logged_in_at = fixtures::now(); - let (name, password) = fixtures::login::propose(); + let (name, password) = fixtures::user::propose(); let request = post::Request { name: name.clone(), password, @@ -72,7 +72,7 @@ async fn incorrect_password() { // Set up the environment let app = fixtures::scratch_app().await; - let login = fixtures::login::create(&app, &fixtures::now()).await; + let login = fixtures::user::create(&app, &fixtures::now()).await; // Call the endpoint @@ -80,7 +80,7 @@ async fn incorrect_password() { let identity = fixtures::cookie::not_logged_in(); let request = post::Request { name: login.name, - password: fixtures::login::propose_password(), + password: fixtures::user::propose_password(), }; let post::Error(error) = post::handler(State(app.clone()), logged_in_at, identity, Json(request)) @@ -97,7 +97,7 @@ async fn token_expires() { // Set up the environment let app = fixtures::scratch_app().await; - let (name, password) = fixtures::login::create_with_password(&app, &fixtures::now()).await; + let (name, password) = fixtures::user::create_with_password(&app, &fixtures::now()).await; // Call the endpoint diff --git a/src/user/routes/logout/test.rs b/src/user/routes/logout/test.rs index 775fa9f..ce93760 100644 --- a/src/user/routes/logout/test.rs +++ b/src/user/routes/logout/test.rs @@ -12,7 +12,7 @@ async fn successful() { let app = fixtures::scratch_app().await; let now = fixtures::now(); - let creds = fixtures::login::create_with_password(&app, &fixtures::now()).await; + 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); diff --git a/src/user/routes/password/test.rs b/src/user/routes/password/test.rs index 01dcb38..f977327 100644 --- a/src/user/routes/password/test.rs +++ b/src/user/routes/password/test.rs @@ -10,13 +10,13 @@ use crate::{ async fn password_change() { // Set up the environment let app = fixtures::scratch_app().await; - let creds = fixtures::login::create_with_password(&app, &fixtures::now()).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::login::propose_password(); + let to = fixtures::user::propose_password(); let request = post::Request { password: password.clone(), to: to.clone(), -- cgit v1.2.3