@jpgilldev / steid

10.1 KBRaw
1use crate::domain::{
2 Actor, DomainError, Email, Membership, MembershipId, OrgId, OrgName, Organization, Role,
3 SetupToken, User, UserId,
4 repository::{MembershipRepository, OrgRepository, UserRepository},
5};
6
7use super::{error::Result, port::PasswordHasher};
8
9/// The owner account a visitor is asking to create.
10#[derive(Debug, Clone, PartialEq, Eq)]
11pub struct OwnerSpec {
12 pub handle: String,
13 pub email: String,
14 pub password: String,
15}
16
17/// Whether an installation has an owner yet.
18///
19/// Drives whether `/setup` is served and whether other routes redirect to it.
20pub async fn is_claimed(users: &impl UserRepository) -> Result<bool> {
21 Ok(users.any_exist().await?)
22}
23
24/// The handle of the sole user, when this instance has exactly one.
25///
26/// What makes a personal instance's front door work: `jpgill.dev` should be the
27/// owner's profile, not a sign-in prompt, and the root needs to know whose profile that
28/// is without anyone being signed in.
29///
30/// `None` for an unclaimed instance and — deliberately — for one with more than one
31/// user, because "the owner" stops being well defined the moment registration exists.
32/// The root then falls back to a generic landing rather than picking someone.
33pub async fn sole_owner_handle(
34 users: &impl UserRepository,
35 orgs: &impl OrgRepository,
36) -> Result<Option<OrgName>> {
37 let Some(user) = users.sole_user().await? else {
38 return Ok(None);
39 };
40
41 Ok(orgs
42 .find_by_id(&user.personal_org_id)
43 .await?
44 .map(|org| org.name))
45}
46
47/// Creates the owner of an unclaimed installation and returns them as an actor.
48///
49/// Gated on the one-time setup token printed at boot — see `plans/decisions/0002`.
50/// Both the token check and the unclaimed check happen before any write.
51pub async fn claim_instance(
52 presented_token: &str,
53 setup_token: &SetupToken,
54 spec: &OwnerSpec,
55 users: &impl UserRepository,
56 orgs: &impl OrgRepository,
57 memberships: &impl MembershipRepository,
58 hasher: &impl PasswordHasher,
59) -> Result<Actor> {
60 if !setup_token.matches(presented_token) {
61 return Err(DomainError::InvalidCredentials.into());
62 }
63
64 // Checked after the token, so a wrong token cannot be used to probe whether an
65 // instance has been claimed.
66 if is_claimed(users).await? {
67 return Err(DomainError::AlreadyExists { entity: "owner" }.into());
68 }
69
70 let email = Email::new(&spec.email)?;
71 let org = Organization::new(OrgId::generate(), &spec.handle, None)?;
72 let password_hash = hasher.hash(&spec.password)?;
73
74 let user = User::new(UserId::generate(), email, password_hash, org.id.clone());
75 let membership = Membership::new(
76 MembershipId::generate(),
77 org.id.clone(),
78 user.id.clone(),
79 Role::Owner,
80 );
81
82 // Order matters: the user references the org and the membership references both,
83 // so anything else trips the foreign keys. Attempt #2 had to fix exactly this.
84 orgs.save(&org).await?;
85 users.save(&user).await?;
86 memberships.save(&membership).await?;
87
88 Ok(Actor::User(user.id))
89}
90
91#[cfg(test)]
92mod tests {
93 use super::*;
94 use crate::{
95 application::error::Error,
96 domain::OrgName,
97 infrastructure::{
98 password::StubHasher,
99 repository::{InMemoryMembershipRepo, InMemoryOrgRepo, InMemoryUserRepo},
100 },
101 };
102
103 struct Fixture {
104 token: SetupToken,
105 users: InMemoryUserRepo,
106 orgs: InMemoryOrgRepo,
107 memberships: InMemoryMembershipRepo,
108 hasher: StubHasher,
109 }
110
111 impl Fixture {
112 fn new() -> Self {
113 Self {
114 token: SetupToken::generate(),
115 users: InMemoryUserRepo::new(),
116 orgs: InMemoryOrgRepo::new(),
117 memberships: InMemoryMembershipRepo::new(),
118 hasher: StubHasher::new(),
119 }
120 }
121
122 async fn claim_with(&self, presented: &str, spec: &OwnerSpec) -> Result<Actor> {
123 claim_instance(
124 presented,
125 &self.token,
126 spec,
127 &self.users,
128 &self.orgs,
129 &self.memberships,
130 &self.hasher,
131 )
132 .await
133 }
134
135 async fn claim(&self, spec: &OwnerSpec) -> Result<Actor> {
136 self.claim_with(self.token.reveal(), spec).await
137 }
138 }
139
140 fn spec() -> OwnerSpec {
141 OwnerSpec {
142 handle: "james".to_owned(),
143 email: "dev@example.com".to_owned(),
144 password: "hunter2".to_owned(),
145 }
146 }
147
148 #[tokio::test]
149 async fn claiming_creates_org_user_and_owner_membership() {
150 let fixture = Fixture::new();
151
152 let actor = fixture.claim(&spec()).await.expect("claim");
153
154 let org = fixture
155 .orgs
156 .find_by_name(&OrgName::new("james").unwrap())
157 .await
158 .expect("lookup")
159 .expect("org should exist");
160 let user = fixture
161 .users
162 .find_by_email(&Email::new("dev@example.com").unwrap())
163 .await
164 .expect("lookup")
165 .expect("user should exist");
166 let membership = fixture
167 .memberships
168 .find(&org.id, &user.id)
169 .await
170 .expect("lookup")
171 .expect("membership should exist");
172
173 assert_eq!(actor, Actor::User(user.id.clone()));
174 assert_eq!(user.personal_org_id, org.id);
175 assert_eq!(membership.role, Role::Owner);
176 assert!(membership.can_write());
177 }
178
179 #[tokio::test]
180 async fn the_owner_is_returned_signed_in() {
181 let fixture = Fixture::new();
182
183 let actor = fixture.claim(&spec()).await.expect("claim");
184
185 assert!(
186 actor.is_authenticated(),
187 "claiming should hand back a session-able actor, not require a second login"
188 );
189 }
190
191 #[tokio::test]
192 async fn stores_a_hash_never_the_plaintext() {
193 let fixture = Fixture::new();
194 fixture.claim(&spec()).await.expect("claim");
195
196 let user = fixture
197 .users
198 .find_by_email(&Email::new("dev@example.com").unwrap())
199 .await
200 .expect("lookup")
201 .expect("user should exist");
202
203 assert_ne!(user.password_hash.as_str(), "hunter2");
204 assert!(
205 fixture
206 .hasher
207 .verify("hunter2", &user.password_hash)
208 .expect("verify")
209 );
210 }
211
212 #[tokio::test]
213 async fn a_wrong_token_is_rejected_and_writes_nothing() {
214 let fixture = Fixture::new();
215
216 let error = fixture
217 .claim_with(SetupToken::generate().reveal(), &spec())
218 .await
219 .expect_err("should reject");
220
221 assert!(matches!(
222 error,
223 Error::Domain(DomainError::InvalidCredentials)
224 ));
225 assert!(!fixture.users.any_exist().await.expect("any_exist"));
226 }
227
228 #[tokio::test]
229 async fn an_empty_token_is_rejected() {
230 let fixture = Fixture::new();
231
232 let error = fixture
233 .claim_with("", &spec())
234 .await
235 .expect_err("should reject");
236
237 assert!(matches!(
238 error,
239 Error::Domain(DomainError::InvalidCredentials)
240 ));
241 }
242
243 #[tokio::test]
244 async fn a_claimed_instance_cannot_be_claimed_again() {
245 let fixture = Fixture::new();
246 fixture.claim(&spec()).await.expect("first claim");
247
248 let intruder = OwnerSpec {
249 handle: "intruder".to_owned(),
250 email: "intruder@example.com".to_owned(),
251 password: "letmein".to_owned(),
252 };
253 let error = fixture.claim(&intruder).await.expect_err("should reject");
254
255 assert!(matches!(
256 error,
257 Error::Domain(DomainError::AlreadyExists { entity: "owner" })
258 ));
259 }
260
261 #[tokio::test]
262 async fn a_wrong_token_cannot_probe_whether_the_instance_is_claimed() {
263 let unclaimed = Fixture::new();
264 let claimed = Fixture::new();
265 claimed.claim(&spec()).await.expect("claim");
266
267 let wrong = SetupToken::generate();
268 let from_unclaimed = unclaimed
269 .claim_with(wrong.reveal(), &spec())
270 .await
271 .expect_err("should reject");
272 let from_claimed = claimed
273 .claim_with(wrong.reveal(), &spec())
274 .await
275 .expect_err("should reject");
276
277 // Both must be InvalidCredentials. If the claimed instance answered
278 // AlreadyExists, a wrong token would reveal the installation's state.
279 assert!(matches!(
280 from_unclaimed,
281 Error::Domain(DomainError::InvalidCredentials)
282 ));
283 assert!(matches!(
284 from_claimed,
285 Error::Domain(DomainError::InvalidCredentials)
286 ));
287 }
288
289 #[tokio::test]
290 async fn rejects_an_invalid_handle_without_writing_anything() {
291 let fixture = Fixture::new();
292 let bad = OwnerSpec {
293 handle: "not a handle".to_owned(),
294 ..spec()
295 };
296
297 let error = fixture.claim(&bad).await.expect_err("should reject");
298
299 assert!(matches!(
300 error,
301 Error::Domain(DomainError::Validation { .. })
302 ));
303 assert!(
304 !fixture.users.any_exist().await.expect("any_exist"),
305 "nothing should be written when validation fails"
306 );
307 }
308
309 #[tokio::test]
310 async fn rejects_an_invalid_email_without_writing_anything() {
311 let fixture = Fixture::new();
312 let bad = OwnerSpec {
313 email: "not-an-email".to_owned(),
314 ..spec()
315 };
316
317 let error = fixture.claim(&bad).await.expect_err("should reject");
318
319 assert!(matches!(
320 error,
321 Error::Domain(DomainError::Validation { .. })
322 ));
323 assert!(!fixture.users.any_exist().await.expect("any_exist"));
324 }
325
326 #[tokio::test]
327 async fn is_claimed_reports_the_installation_state() {
328 let fixture = Fixture::new();
329 assert!(!is_claimed(&fixture.users).await.expect("is_claimed"));
330
331 fixture.claim(&spec()).await.expect("claim");
332
333 assert!(is_claimed(&fixture.users).await.expect("is_claimed"));
334 }
335}