From 9ae0faf4f027caaaf3bc4a42738d4ed31e67852d Mon Sep 17 00:00:00 2001 From: Owen Jacobson Date: Tue, 29 Oct 2024 20:26:47 -0400 Subject: Create a dedicated workflow type for creating logins. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nasty design corner. Logins need to be created in three places: 1. In tests, using app.logins().create(…); 2. On initial setup, using app.setup().initial(…); and 3. When accepting invites, using app.invites().accept(…). These three places do the same thing with respect to logins, but also do a varying mix of other things. Testing is the simplest and _only_ creates a login. Initial setup and invite acceptance both issue a token for the newly-created login. Accepting an invite also invalidates the invite. Previously, those three functions have been copy-pasted variations on a theme. Now that we have validation, the copy-paste approach is no longer tenable; it will become increasingly hard to ensure that the three functions (plus any future functions) remain in synch. To accommodate the variations while consolidating login creation, I've added a typestate-based state machine, which is driven by method calls: * A creation attempt begins with `let create = Create::begin()`. This always succeeds; it packages up arguments used in later steps, but does nothing else. * A creation attempt can be validated using `let validated = create.validate()?`. This may fail. Input validation and password hashing are carried out at this stage, making it potentially expensive. * A validated attempt can be stored in the DB, using `let stored = validated.store(&mut tx).await?`. This may fail. The login will be written to the DB; the caller is responsible for transaction demarcation, to allow other things to take place in the same transaction. * A fully-stored attempt can be used to publish events, using `let login = stored.publish(self.events)`. This always succeeds, and unwraps the state machine to its final product (a `login::History`). --- src/invite/app.rs | 33 ++++++++++++++++++++------------- 1 file changed, 20 insertions(+), 13 deletions(-) (limited to 'src/invite') diff --git a/src/invite/app.rs b/src/invite/app.rs index 182eb67..d4e877a 100644 --- a/src/invite/app.rs +++ b/src/invite/app.rs @@ -5,8 +5,11 @@ use super::{repo::Provider as _, Id, Invite, Summary}; use crate::{ clock::DateTime, db::{Duplicate as _, NotFound as _}, - event::{repo::Provider as _, Broadcaster, Event}, - login::{repo::Provider as _, validate, Login, Password}, + event::Broadcaster, + login::{ + create::{self, Create}, + Login, Password, + }, name::Name, token::{repo::Provider as _, Secret}, }; @@ -44,9 +47,7 @@ impl<'a> Invites<'a> { password: &Password, accepted_at: &DateTime, ) -> Result<(Login, Secret), AcceptError> { - if !validate::name(name) { - return Err(AcceptError::InvalidName(name.clone())); - } + let create = Create::begin(name, password, accepted_at); let mut tx = self.db.begin().await?; let invite = tx @@ -59,23 +60,20 @@ impl<'a> Invites<'a> { // the invite. Final validation is in the next tx. tx.commit().await?; - let password_hash = password.hash()?; + let validated = create.validate()?; let mut tx = self.db.begin().await?; // If the invite has been deleted or accepted in the interim, this step will // catch it. tx.invites().accept(&invite).await?; - let created = tx.sequence().next(accepted_at).await?; - let login = tx - .logins() - .create(name, &password_hash, &created) + let stored = validated + .store(&mut tx) .await .duplicate(|| AcceptError::DuplicateLogin(name.clone()))?; - let secret = tx.tokens().issue(&login, accepted_at).await?; + let secret = tx.tokens().issue(stored.login(), accepted_at).await?; tx.commit().await?; - self.events - .broadcast(login.events().map(Event::from).collect::>()); + let login = stored.publish(self.events); Ok((login.as_created(), secret)) } @@ -105,3 +103,12 @@ pub enum AcceptError { #[error(transparent)] PasswordHash(#[from] password_hash::Error), } + +impl From for AcceptError { + fn from(error: create::Error) -> Self { + match error { + create::Error::InvalidName(name) => Self::InvalidName(name), + create::Error::PasswordHash(error) => Self::PasswordHash(error), + } + } +} -- cgit v1.2.3