steid

@jamesgill /

steid/src/application/bootstrap.rs
6.7 KBCode·Blame·Raw
1use crate::domain::{
2 Email, Membership, MembershipId, OrgId, Organization, Role, User, UserId,
3 repository::{MembershipRepository, OrgRepository, UserRepository},
4};
5
6use super::{error::Result, port::PasswordHasher};
7
8/// The owner account to create on an empty installation.
9#[derive(Debug, Clone, PartialEq, Eq)]
10pub struct OwnerSpec {
11 pub handle: String,
12 pub email: String,
13 pub password: String,
14}
15
16/// What `bootstrap_owner` did.
17#[derive(Debug, Clone, Copy, PartialEq, Eq)]
18pub enum Bootstrap {
19 /// The owner was created.
20 Created,
21 /// Users already existed, so nothing happened.
22 AlreadyBootstrapped,
23}
24
25/// Creates the installation's owner if there isn't one yet.
26///
27/// Runs on every boot, so it must be idempotent — the `any_exist` check is what makes
28/// restarting safe rather than a duplicate-key error.
29pub async fn bootstrap_owner(
30 spec: &OwnerSpec,
31 users: &impl UserRepository,
32 orgs: &impl OrgRepository,
33 memberships: &impl MembershipRepository,
34 hasher: &impl PasswordHasher,
35) -> Result<Bootstrap> {
36 if users.any_exist().await? {
37 return Ok(Bootstrap::AlreadyBootstrapped);
38 }
39
40 let email = Email::new(&spec.email)?;
41 let org = Organization::new(OrgId::generate(), &spec.handle, None)?;
42 let password_hash = hasher.hash(&spec.password)?;
43
44 let user = User::new(UserId::generate(), email, password_hash, org.id.clone());
45 let membership = Membership::new(
46 MembershipId::generate(),
47 org.id.clone(),
48 user.id.clone(),
49 Role::Owner,
50 );
51
52 // Order matters: the user references the org and the membership references both,
53 // so anything else trips the foreign keys. Attempt #2 had to fix exactly this.
54 orgs.save(&org).await?;
55 users.save(&user).await?;
56 memberships.save(&membership).await?;
57
58 Ok(Bootstrap::Created)
59}
60
61#[cfg(test)]
62mod tests {
63 use super::*;
64 use crate::{
65 application::error::Error,
66 domain::{DomainError, OrgName},
67 infrastructure::{
68 password::StubHasher,
69 repository::{InMemoryMembershipRepo, InMemoryOrgRepo, InMemoryUserRepo},
70 },
71 };
72
73 struct Fixture {
74 users: InMemoryUserRepo,
75 orgs: InMemoryOrgRepo,
76 memberships: InMemoryMembershipRepo,
77 hasher: StubHasher,
78 }
79
80 impl Fixture {
81 fn new() -> Self {
82 Self {
83 users: InMemoryUserRepo::new(),
84 orgs: InMemoryOrgRepo::new(),
85 memberships: InMemoryMembershipRepo::new(),
86 hasher: StubHasher::new(),
87 }
88 }
89
90 async fn run(&self, spec: &OwnerSpec) -> Result<Bootstrap> {
91 bootstrap_owner(
92 spec,
93 &self.users,
94 &self.orgs,
95 &self.memberships,
96 &self.hasher,
97 )
98 .await
99 }
100 }
101
102 fn spec() -> OwnerSpec {
103 OwnerSpec {
104 handle: "james".to_owned(),
105 email: "dev@example.com".to_owned(),
106 password: "hunter2".to_owned(),
107 }
108 }
109
110 #[tokio::test]
111 async fn creates_org_user_and_owner_membership() {
112 let fixture = Fixture::new();
113
114 let outcome = fixture.run(&spec()).await.expect("bootstrap");
115
116 assert_eq!(outcome, Bootstrap::Created);
117
118 let org = fixture
119 .orgs
120 .find_by_name(&OrgName::new("james").unwrap())
121 .await
122 .expect("lookup")
123 .expect("org should exist");
124 let user = fixture
125 .users
126 .find_by_email(&Email::new("dev@example.com").unwrap())
127 .await
128 .expect("lookup")
129 .expect("user should exist");
130 let membership = fixture
131 .memberships
132 .find(&org.id, &user.id)
133 .await
134 .expect("lookup")
135 .expect("membership should exist");
136
137 assert_eq!(user.personal_org_id, org.id);
138 assert_eq!(membership.role, Role::Owner);
139 assert!(membership.can_write());
140 }
141
142 #[tokio::test]
143 async fn stores_a_hash_never_the_plaintext() {
144 let fixture = Fixture::new();
145 fixture.run(&spec()).await.expect("bootstrap");
146
147 let user = fixture
148 .users
149 .find_by_email(&Email::new("dev@example.com").unwrap())
150 .await
151 .expect("lookup")
152 .expect("user should exist");
153
154 assert_ne!(user.password_hash.as_str(), "hunter2");
155 assert!(
156 fixture
157 .hasher
158 .verify("hunter2", &user.password_hash)
159 .expect("verify")
160 );
161 }
162
163 #[tokio::test]
164 async fn is_idempotent_across_reboots() {
165 let fixture = Fixture::new();
166 fixture.run(&spec()).await.expect("first boot");
167
168 let outcome = fixture.run(&spec()).await.expect("second boot");
169
170 assert_eq!(outcome, Bootstrap::AlreadyBootstrapped);
171 }
172
173 #[tokio::test]
174 async fn a_second_boot_does_not_overwrite_a_changed_password() {
175 let fixture = Fixture::new();
176 fixture.run(&spec()).await.expect("first boot");
177
178 let changed = OwnerSpec {
179 password: "different".to_owned(),
180 ..spec()
181 };
182 fixture.run(&changed).await.expect("second boot");
183
184 let user = fixture
185 .users
186 .find_by_email(&Email::new("dev@example.com").unwrap())
187 .await
188 .expect("lookup")
189 .expect("user should exist");
190
191 assert!(
192 fixture
193 .hasher
194 .verify("hunter2", &user.password_hash)
195 .expect("verify"),
196 "the original password should still be the one that works"
197 );
198 }
199
200 #[tokio::test]
201 async fn rejects_an_invalid_handle_without_writing_anything() {
202 let fixture = Fixture::new();
203 let bad = OwnerSpec {
204 handle: "not a handle".to_owned(),
205 ..spec()
206 };
207
208 let error = fixture.run(&bad).await.expect_err("should reject");
209
210 assert!(matches!(
211 error,
212 Error::Domain(DomainError::Validation { .. })
213 ));
214 assert!(
215 !fixture.users.any_exist().await.expect("any_exist"),
216 "nothing should be written when validation fails"
217 );
218 }
219
220 #[tokio::test]
221 async fn rejects_an_invalid_email_without_writing_anything() {
222 let fixture = Fixture::new();
223 let bad = OwnerSpec {
224 email: "not-an-email".to_owned(),
225 ..spec()
226 };
227
228 let error = fixture.run(&bad).await.expect_err("should reject");
229
230 assert!(matches!(
231 error,
232 Error::Domain(DomainError::Validation { .. })
233 ));
234 assert!(!fixture.users.any_exist().await.expect("any_exist"));
235 }
236}