@jpgilldev / steid

steid/src/application/profile.rs
11.6 KBRaw
1use crate::domain::{
2 Actor, DomainError, OrgName, Organization,
3 repository::{MembershipRepository, OrgRepository},
4};
5
6use super::{authz::is_org_owner, 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_org_owner(&org, actor, memberships).await?,
62 )))
63}
64
65/// Edits a profile's display name and bio.
66///
67/// Authorization lives here, not in the page: the web form and any future `/api`
68/// caller must get the same answer about who may edit. Owners only — a member's
69/// read access is not edit access.
70pub async fn update_profile(
71 actor: &Actor,
72 handle: &OrgName,
73 display_name: Option<String>,
74 bio: Option<String>,
75 orgs: &impl OrgRepository,
76 memberships: &impl MembershipRepository,
77) -> Result<()> {
78 let Some(mut org) = orgs.find_by_name(handle).await? else {
79 return Err(DomainError::NotFound { entity: "profile" }.into());
80 };
81
82 if !is_org_owner(&org, actor, memberships).await? {
83 return Err(DomainError::Forbidden.into());
84 }
85
86 // Validates before saving, so a rejected edit leaves the stored profile untouched.
87 org.update_profile(display_name, bio)?;
88 orgs.save(&org).await?;
89
90 Ok(())
91}
92
93#[cfg(test)]
94mod tests {
95 use super::*;
96 use crate::{
97 domain::{Membership, MembershipId, OrgId, Role, UserId},
98 infrastructure::repository::{InMemoryMembershipRepo, InMemoryOrgRepo},
99 };
100
101 struct Fixture {
102 orgs: InMemoryOrgRepo,
103 memberships: InMemoryMembershipRepo,
104 owner: UserId,
105 org: Organization,
106 }
107
108 async fn fixture() -> Fixture {
109 let orgs = InMemoryOrgRepo::new();
110 let memberships = InMemoryMembershipRepo::new();
111
112 let mut org = Organization::new(OrgId::generate(), "acme", None).expect("valid org");
113 org.update_profile(
114 Some("Acme Inc".to_owned()),
115 Some("We make things.".to_owned()),
116 )
117 .expect("valid profile");
118 orgs.save(&org).await.expect("save org");
119
120 let owner = UserId::generate();
121 memberships
122 .save(&Membership::new(
123 MembershipId::generate(),
124 org.id.clone(),
125 owner.clone(),
126 Role::Owner,
127 ))
128 .await
129 .expect("save membership");
130
131 Fixture {
132 orgs,
133 memberships,
134 owner,
135 org,
136 }
137 }
138
139 async fn view(f: &Fixture, actor: &Actor) -> Option<PublicProfile> {
140 view_profile(&f.org.name, actor, &f.orgs, &f.memberships)
141 .await
142 .expect("view")
143 }
144
145 #[tokio::test]
146 async fn an_anonymous_visitor_sees_the_profile() {
147 let f = fixture().await;
148
149 let profile = view(&f, &Actor::Anonymous).await.expect("should resolve");
150
151 assert_eq!(profile.handle.as_str(), "acme");
152 assert_eq!(profile.label, "Acme Inc");
153 assert_eq!(profile.bio.as_deref(), Some("We make things."));
154 }
155
156 #[tokio::test]
157 async fn an_anonymous_visitor_is_not_the_owner() {
158 let f = fixture().await;
159
160 let profile = view(&f, &Actor::Anonymous).await.expect("should resolve");
161
162 assert!(!profile.viewer_is_owner);
163 }
164
165 #[tokio::test]
166 async fn the_owner_is_recognised() {
167 let f = fixture().await;
168
169 let profile = view(&f, &Actor::User(f.owner.clone()))
170 .await
171 .expect("should resolve");
172
173 assert!(profile.viewer_is_owner);
174 }
175
176 #[tokio::test]
177 async fn a_signed_in_stranger_is_not_the_owner() {
178 let f = fixture().await;
179
180 let profile = view(&f, &Actor::User(UserId::generate()))
181 .await
182 .expect("should resolve");
183
184 assert!(
185 !profile.viewer_is_owner,
186 "being signed in must not confer ownership of someone else's profile"
187 );
188 }
189
190 #[tokio::test]
191 async fn a_member_who_is_not_an_owner_cannot_edit() {
192 let f = fixture().await;
193 let member = UserId::generate();
194 f.memberships
195 .save(&Membership::new(
196 MembershipId::generate(),
197 f.org.id.clone(),
198 member.clone(),
199 Role::Member,
200 ))
201 .await
202 .expect("save membership");
203
204 let profile = view(&f, &Actor::User(member))
205 .await
206 .expect("should resolve");
207
208 assert!(
209 !profile.viewer_is_owner,
210 "membership alone is read access, not edit access"
211 );
212 }
213
214 #[tokio::test]
215 async fn an_unknown_handle_resolves_to_none() {
216 let f = fixture().await;
217
218 let profile = view_profile(
219 &OrgName::new("nobody").expect("valid"),
220 &Actor::Anonymous,
221 &f.orgs,
222 &f.memberships,
223 )
224 .await
225 .expect("view");
226
227 assert_eq!(profile, None);
228 }
229
230 #[tokio::test]
231 async fn a_handle_resolves_regardless_of_the_casing_used() {
232 let f = fixture().await;
233
234 // `OrgName::new` lowercases, so /ACME and /acme are the same handle.
235 let profile = view_profile(
236 &OrgName::new("ACME").expect("valid"),
237 &Actor::Anonymous,
238 &f.orgs,
239 &f.memberships,
240 )
241 .await
242 .expect("view");
243
244 assert!(profile.is_some(), "handles are case-insensitive");
245 }
246
247 #[tokio::test]
248 async fn a_profile_without_a_display_name_labels_with_its_handle() {
249 let orgs = InMemoryOrgRepo::new();
250 let memberships = InMemoryMembershipRepo::new();
251 let org = Organization::new(OrgId::generate(), "bare", None).expect("valid org");
252 orgs.save(&org).await.expect("save");
253
254 let profile = view_profile(&org.name, &Actor::Anonymous, &orgs, &memberships)
255 .await
256 .expect("view")
257 .expect("should resolve");
258
259 assert_eq!(profile.label, "bare");
260 assert_eq!(
261 profile.display_name, None,
262 "the label falls back to the handle, but display_name stays unset so an \
263 edit form does not prefill a value the owner never typed"
264 );
265 assert_eq!(profile.bio, None);
266 }
267 #[tokio::test]
268 async fn the_owner_can_edit() {
269 let f = fixture().await;
270
271 update_profile(
272 &Actor::User(f.owner.clone()),
273 &f.org.name,
274 Some("Renamed".to_owned()),
275 Some("New bio.".to_owned()),
276 &f.orgs,
277 &f.memberships,
278 )
279 .await
280 .expect("owner may edit");
281
282 let profile = view(&f, &Actor::Anonymous).await.expect("resolve");
283 assert_eq!(profile.label, "Renamed");
284 assert_eq!(profile.bio.as_deref(), Some("New bio."));
285 }
286
287 async fn expect_rejected(f: &Fixture, actor: &Actor) -> crate::application::Error {
288 update_profile(
289 actor,
290 &f.org.name,
291 Some("Hijacked".to_owned()),
292 None,
293 &f.orgs,
294 &f.memberships,
295 )
296 .await
297 .expect_err("should reject")
298 }
299
300 #[tokio::test]
301 async fn an_anonymous_actor_cannot_edit() {
302 let f = fixture().await;
303
304 let error = expect_rejected(&f, &Actor::Anonymous).await;
305
306 assert!(matches!(
307 error,
308 crate::application::Error::Domain(DomainError::Forbidden)
309 ));
310 assert_eq!(
311 view(&f, &Actor::Anonymous).await.expect("resolve").label,
312 "Acme Inc",
313 "a rejected edit must change nothing"
314 );
315 }
316
317 #[tokio::test]
318 async fn a_signed_in_stranger_cannot_edit() {
319 let f = fixture().await;
320
321 let error = expect_rejected(&f, &Actor::User(UserId::generate())).await;
322
323 assert!(matches!(
324 error,
325 crate::application::Error::Domain(DomainError::Forbidden)
326 ));
327 }
328
329 #[tokio::test]
330 async fn a_member_who_is_not_an_owner_cannot_edit_either() {
331 let f = fixture().await;
332 let member = UserId::generate();
333 f.memberships
334 .save(&Membership::new(
335 MembershipId::generate(),
336 f.org.id.clone(),
337 member.clone(),
338 Role::Member,
339 ))
340 .await
341 .expect("save membership");
342
343 let error = expect_rejected(&f, &Actor::User(member)).await;
344
345 assert!(matches!(
346 error,
347 crate::application::Error::Domain(DomainError::Forbidden)
348 ));
349 }
350
351 #[tokio::test]
352 async fn editing_an_unknown_handle_is_not_found() {
353 let f = fixture().await;
354
355 let error = update_profile(
356 &Actor::User(f.owner.clone()),
357 &OrgName::new("nobody").expect("valid"),
358 None,
359 None,
360 &f.orgs,
361 &f.memberships,
362 )
363 .await
364 .expect_err("should reject");
365
366 assert!(matches!(
367 error,
368 crate::application::Error::Domain(DomainError::NotFound { .. })
369 ));
370 }
371
372 #[tokio::test]
373 async fn an_invalid_edit_leaves_the_stored_profile_untouched() {
374 let f = fixture().await;
375
376 let error = update_profile(
377 &Actor::User(f.owner.clone()),
378 &f.org.name,
379 Some("Renamed".to_owned()),
380 Some("a".repeat(Organization::MAX_BIO_LEN + 1)),
381 &f.orgs,
382 &f.memberships,
383 )
384 .await
385 .expect_err("should reject");
386
387 assert!(matches!(
388 error,
389 crate::application::Error::Domain(DomainError::Validation { .. })
390 ));
391
392 let profile = view(&f, &Actor::Anonymous).await.expect("resolve");
393 assert_eq!(profile.label, "Acme Inc");
394 assert_eq!(profile.bio.as_deref(), Some("We make things."));
395 }
396}