steid

@jamesgill /

steid/src/application/profile.rs
11.9 KBCode·Blame·Raw
1use crate::domain::{
2 Actor, DomainError, OrgName, Organization, Role,
3 repository::{MembershipRepository, OrgRepository},
4};
5
6use super::error::Result;
7
8/// A profile as anyone may see it.
9///
10/// **Deliberately carries no private data.** There is no email field, and there must
11/// never be one: this type is rendered to anonymous visitors, and the safest way to
12/// avoid leaking the owner's email is for the type reaching the page to have nowhere to
13/// put it. [`Identity`](super::Identity) is the private counterpart — it does carry an
14/// email, and `/api/me` returns it, because that endpoint describes the caller to
15/// themselves.
16#[derive(Debug, Clone, PartialEq, Eq)]
17pub struct PublicProfile {
18 pub handle: OrgName,
19 /// Display name if set, otherwise the handle. What a page should show.
20 pub label: String,
21 /// The stored display name, unset if there isn't one.
22 ///
23 /// Distinct from `label` so an edit form can leave the field empty rather than
24 /// prefilling the handle, which the owner never typed. Public because `label`
25 /// already reveals it whenever it is set.
26 pub display_name: Option<String>,
27 pub bio: Option<String>,
28 /// Whether the viewer may edit this profile. Decided here rather than in the page,
29 /// so the web form and `/api` cannot disagree about it.
30 pub viewer_is_owner: bool,
31}
32
33impl PublicProfile {
34 fn of(org: &Organization, viewer_is_owner: bool) -> Self {
35 Self {
36 handle: org.name.clone(),
37 label: org.label().to_owned(),
38 display_name: org.display_name.clone(),
39 bio: org.bio.clone(),
40 viewer_is_owner,
41 }
42 }
43}
44
45/// Resolves a handle into a publicly viewable profile.
46///
47/// `Ok(None)` when no such handle exists — the caller renders a 404. Anonymous viewers
48/// are served the same profile as anyone else; only `viewer_is_owner` differs.
49pub async fn view_profile(
50 handle: &OrgName,
51 actor: &Actor,
52 orgs: &impl OrgRepository,
53 memberships: &impl MembershipRepository,
54) -> Result<Option<PublicProfile>> {
55 let Some(org) = orgs.find_by_name(handle).await? else {
56 return Ok(None);
57 };
58
59 Ok(Some(PublicProfile::of(
60 &org,
61 is_owner(&org, actor, memberships).await?,
62 )))
63}
64
65/// Whether the actor owns this organisation.
66async fn is_owner(
67 org: &Organization,
68 actor: &Actor,
69 memberships: &impl MembershipRepository,
70) -> Result<bool> {
71 let Some(user_id) = actor.user_id() else {
72 return Ok(false);
73 };
74
75 Ok(memberships
76 .find(&org.id, user_id)
77 .await?
78 .is_some_and(|membership| membership.role == Role::Owner))
79}
80
81/// Edits a profile's display name and bio.
82///
83/// Authorization lives here, not in the page: the web form and any future `/api`
84/// caller must get the same answer about who may edit. Owners only — a member's
85/// read access is not edit access.
86pub async fn update_profile(
87 actor: &Actor,
88 handle: &OrgName,
89 display_name: Option<String>,
90 bio: Option<String>,
91 orgs: &impl OrgRepository,
92 memberships: &impl MembershipRepository,
93) -> Result<()> {
94 let Some(mut org) = orgs.find_by_name(handle).await? else {
95 return Err(DomainError::NotFound { entity: "profile" }.into());
96 };
97
98 if !is_owner(&org, actor, memberships).await? {
99 return Err(DomainError::Forbidden.into());
100 }
101
102 // Validates before saving, so a rejected edit leaves the stored profile untouched.
103 org.update_profile(display_name, bio)?;
104 orgs.save(&org).await?;
105
106 Ok(())
107}
108
109#[cfg(test)]
110mod tests {
111 use super::*;
112 use crate::{
113 domain::{Membership, MembershipId, OrgId, UserId},
114 infrastructure::repository::{InMemoryMembershipRepo, InMemoryOrgRepo},
115 };
116
117 struct Fixture {
118 orgs: InMemoryOrgRepo,
119 memberships: InMemoryMembershipRepo,
120 owner: UserId,
121 org: Organization,
122 }
123
124 async fn fixture() -> Fixture {
125 let orgs = InMemoryOrgRepo::new();
126 let memberships = InMemoryMembershipRepo::new();
127
128 let mut org = Organization::new(OrgId::generate(), "acme", None).expect("valid org");
129 org.update_profile(
130 Some("Acme Inc".to_owned()),
131 Some("We make things.".to_owned()),
132 )
133 .expect("valid profile");
134 orgs.save(&org).await.expect("save org");
135
136 let owner = UserId::generate();
137 memberships
138 .save(&Membership::new(
139 MembershipId::generate(),
140 org.id.clone(),
141 owner.clone(),
142 Role::Owner,
143 ))
144 .await
145 .expect("save membership");
146
147 Fixture {
148 orgs,
149 memberships,
150 owner,
151 org,
152 }
153 }
154
155 async fn view(f: &Fixture, actor: &Actor) -> Option<PublicProfile> {
156 view_profile(&f.org.name, actor, &f.orgs, &f.memberships)
157 .await
158 .expect("view")
159 }
160
161 #[tokio::test]
162 async fn an_anonymous_visitor_sees_the_profile() {
163 let f = fixture().await;
164
165 let profile = view(&f, &Actor::Anonymous).await.expect("should resolve");
166
167 assert_eq!(profile.handle.as_str(), "acme");
168 assert_eq!(profile.label, "Acme Inc");
169 assert_eq!(profile.bio.as_deref(), Some("We make things."));
170 }
171
172 #[tokio::test]
173 async fn an_anonymous_visitor_is_not_the_owner() {
174 let f = fixture().await;
175
176 let profile = view(&f, &Actor::Anonymous).await.expect("should resolve");
177
178 assert!(!profile.viewer_is_owner);
179 }
180
181 #[tokio::test]
182 async fn the_owner_is_recognised() {
183 let f = fixture().await;
184
185 let profile = view(&f, &Actor::User(f.owner.clone()))
186 .await
187 .expect("should resolve");
188
189 assert!(profile.viewer_is_owner);
190 }
191
192 #[tokio::test]
193 async fn a_signed_in_stranger_is_not_the_owner() {
194 let f = fixture().await;
195
196 let profile = view(&f, &Actor::User(UserId::generate()))
197 .await
198 .expect("should resolve");
199
200 assert!(
201 !profile.viewer_is_owner,
202 "being signed in must not confer ownership of someone else's profile"
203 );
204 }
205
206 #[tokio::test]
207 async fn a_member_who_is_not_an_owner_cannot_edit() {
208 let f = fixture().await;
209 let member = UserId::generate();
210 f.memberships
211 .save(&Membership::new(
212 MembershipId::generate(),
213 f.org.id.clone(),
214 member.clone(),
215 Role::Member,
216 ))
217 .await
218 .expect("save membership");
219
220 let profile = view(&f, &Actor::User(member))
221 .await
222 .expect("should resolve");
223
224 assert!(
225 !profile.viewer_is_owner,
226 "membership alone is read access, not edit access"
227 );
228 }
229
230 #[tokio::test]
231 async fn an_unknown_handle_resolves_to_none() {
232 let f = fixture().await;
233
234 let profile = view_profile(
235 &OrgName::new("nobody").expect("valid"),
236 &Actor::Anonymous,
237 &f.orgs,
238 &f.memberships,
239 )
240 .await
241 .expect("view");
242
243 assert_eq!(profile, None);
244 }
245
246 #[tokio::test]
247 async fn a_handle_resolves_regardless_of_the_casing_used() {
248 let f = fixture().await;
249
250 // `OrgName::new` lowercases, so /ACME and /acme are the same handle.
251 let profile = view_profile(
252 &OrgName::new("ACME").expect("valid"),
253 &Actor::Anonymous,
254 &f.orgs,
255 &f.memberships,
256 )
257 .await
258 .expect("view");
259
260 assert!(profile.is_some(), "handles are case-insensitive");
261 }
262
263 #[tokio::test]
264 async fn a_profile_without_a_display_name_labels_with_its_handle() {
265 let orgs = InMemoryOrgRepo::new();
266 let memberships = InMemoryMembershipRepo::new();
267 let org = Organization::new(OrgId::generate(), "bare", None).expect("valid org");
268 orgs.save(&org).await.expect("save");
269
270 let profile = view_profile(&org.name, &Actor::Anonymous, &orgs, &memberships)
271 .await
272 .expect("view")
273 .expect("should resolve");
274
275 assert_eq!(profile.label, "bare");
276 assert_eq!(
277 profile.display_name, None,
278 "the label falls back to the handle, but display_name stays unset so an \
279 edit form does not prefill a value the owner never typed"
280 );
281 assert_eq!(profile.bio, None);
282 }
283 #[tokio::test]
284 async fn the_owner_can_edit() {
285 let f = fixture().await;
286
287 update_profile(
288 &Actor::User(f.owner.clone()),
289 &f.org.name,
290 Some("Renamed".to_owned()),
291 Some("New bio.".to_owned()),
292 &f.orgs,
293 &f.memberships,
294 )
295 .await
296 .expect("owner may edit");
297
298 let profile = view(&f, &Actor::Anonymous).await.expect("resolve");
299 assert_eq!(profile.label, "Renamed");
300 assert_eq!(profile.bio.as_deref(), Some("New bio."));
301 }
302
303 async fn expect_rejected(f: &Fixture, actor: &Actor) -> crate::application::Error {
304 update_profile(
305 actor,
306 &f.org.name,
307 Some("Hijacked".to_owned()),
308 None,
309 &f.orgs,
310 &f.memberships,
311 )
312 .await
313 .expect_err("should reject")
314 }
315
316 #[tokio::test]
317 async fn an_anonymous_actor_cannot_edit() {
318 let f = fixture().await;
319
320 let error = expect_rejected(&f, &Actor::Anonymous).await;
321
322 assert!(matches!(
323 error,
324 crate::application::Error::Domain(DomainError::Forbidden)
325 ));
326 assert_eq!(
327 view(&f, &Actor::Anonymous).await.expect("resolve").label,
328 "Acme Inc",
329 "a rejected edit must change nothing"
330 );
331 }
332
333 #[tokio::test]
334 async fn a_signed_in_stranger_cannot_edit() {
335 let f = fixture().await;
336
337 let error = expect_rejected(&f, &Actor::User(UserId::generate())).await;
338
339 assert!(matches!(
340 error,
341 crate::application::Error::Domain(DomainError::Forbidden)
342 ));
343 }
344
345 #[tokio::test]
346 async fn a_member_who_is_not_an_owner_cannot_edit_either() {
347 let f = fixture().await;
348 let member = UserId::generate();
349 f.memberships
350 .save(&Membership::new(
351 MembershipId::generate(),
352 f.org.id.clone(),
353 member.clone(),
354 Role::Member,
355 ))
356 .await
357 .expect("save membership");
358
359 let error = expect_rejected(&f, &Actor::User(member)).await;
360
361 assert!(matches!(
362 error,
363 crate::application::Error::Domain(DomainError::Forbidden)
364 ));
365 }
366
367 #[tokio::test]
368 async fn editing_an_unknown_handle_is_not_found() {
369 let f = fixture().await;
370
371 let error = update_profile(
372 &Actor::User(f.owner.clone()),
373 &OrgName::new("nobody").expect("valid"),
374 None,
375 None,
376 &f.orgs,
377 &f.memberships,
378 )
379 .await
380 .expect_err("should reject");
381
382 assert!(matches!(
383 error,
384 crate::application::Error::Domain(DomainError::NotFound { .. })
385 ));
386 }
387
388 #[tokio::test]
389 async fn an_invalid_edit_leaves_the_stored_profile_untouched() {
390 let f = fixture().await;
391
392 let error = update_profile(
393 &Actor::User(f.owner.clone()),
394 &f.org.name,
395 Some("Renamed".to_owned()),
396 Some("a".repeat(Organization::MAX_BIO_LEN + 1)),
397 &f.orgs,
398 &f.memberships,
399 )
400 .await
401 .expect_err("should reject");
402
403 assert!(matches!(
404 error,
405 crate::application::Error::Domain(DomainError::Validation { .. })
406 ));
407
408 let profile = view(&f, &Actor::Anonymous).await.expect("resolve");
409 assert_eq!(profile.label, "Acme Inc");
410 assert_eq!(profile.bio.as_deref(), Some("We make things."));
411 }
412}