steid

@jamesgill /

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