steid

@jamesgill /

steid/src/application/profile.rs
6.9 KBCode·Blame·Raw
a69e380feat: public profile page at /{handle}1mo
1use crate::domain::{
2 Actor, 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.
20 pub label: String,
21 pub bio: Option<String>,
22 /// Whether the viewer may edit this profile. Decided here rather than in the page,
23 /// so the web form and `/api` cannot disagree about it.
24 pub viewer_is_owner: bool,
25}
26
27impl PublicProfile {
28 fn of(org: &Organization, viewer_is_owner: bool) -> Self {
29 Self {
30 handle: org.name.clone(),
31 label: org.label().to_owned(),
32 bio: org.bio.clone(),
33 viewer_is_owner,
34 }
35 }
36}
37
38/// Resolves a handle into a publicly viewable profile.
39///
40/// `Ok(None)` when no such handle exists — the caller renders a 404. Anonymous viewers
41/// are served the same profile as anyone else; only `viewer_is_owner` differs.
42pub async fn view_profile(
43 handle: &OrgName,
44 actor: &Actor,
45 orgs: &impl OrgRepository,
46 memberships: &impl MembershipRepository,
47) -> Result<Option<PublicProfile>> {
48 let Some(org) = orgs.find_by_name(handle).await? else {
49 return Ok(None);
50 };
51
52 Ok(Some(PublicProfile::of(
53 &org,
54 is_owner(&org, actor, memberships).await?,
55 )))
56}
57
58/// Whether the actor owns this organisation.
59async fn is_owner(
60 org: &Organization,
61 actor: &Actor,
62 memberships: &impl MembershipRepository,
63) -> Result<bool> {
64 let Some(user_id) = actor.user_id() else {
65 return Ok(false);
66 };
67
68 Ok(memberships
69 .find(&org.id, user_id)
70 .await?
71 .is_some_and(|membership| membership.role == Role::Owner))
72}
73
74#[cfg(test)]
75mod tests {
76 use super::*;
77 use crate::{
78 domain::{Membership, MembershipId, OrgId, UserId},
79 infrastructure::repository::{InMemoryMembershipRepo, InMemoryOrgRepo},
80 };
81
82 struct Fixture {
83 orgs: InMemoryOrgRepo,
84 memberships: InMemoryMembershipRepo,
85 owner: UserId,
86 org: Organization,
87 }
88
89 async fn fixture() -> Fixture {
90 let orgs = InMemoryOrgRepo::new();
91 let memberships = InMemoryMembershipRepo::new();
92
93 let mut org = Organization::new(OrgId::generate(), "acme", None).expect("valid org");
94 org.update_profile(
95 Some("Acme Inc".to_owned()),
96 Some("We make things.".to_owned()),
97 )
98 .expect("valid profile");
99 orgs.save(&org).await.expect("save org");
100
101 let owner = UserId::generate();
102 memberships
103 .save(&Membership::new(
104 MembershipId::generate(),
105 org.id.clone(),
106 owner.clone(),
107 Role::Owner,
108 ))
109 .await
110 .expect("save membership");
111
112 Fixture {
113 orgs,
114 memberships,
115 owner,
116 org,
117 }
118 }
119
120 async fn view(f: &Fixture, actor: &Actor) -> Option<PublicProfile> {
121 view_profile(&f.org.name, actor, &f.orgs, &f.memberships)
122 .await
123 .expect("view")
124 }
125
126 #[tokio::test]
127 async fn an_anonymous_visitor_sees_the_profile() {
128 let f = fixture().await;
129
130 let profile = view(&f, &Actor::Anonymous).await.expect("should resolve");
131
132 assert_eq!(profile.handle.as_str(), "acme");
133 assert_eq!(profile.label, "Acme Inc");
134 assert_eq!(profile.bio.as_deref(), Some("We make things."));
135 }
136
137 #[tokio::test]
138 async fn an_anonymous_visitor_is_not_the_owner() {
139 let f = fixture().await;
140
141 let profile = view(&f, &Actor::Anonymous).await.expect("should resolve");
142
143 assert!(!profile.viewer_is_owner);
144 }
145
146 #[tokio::test]
147 async fn the_owner_is_recognised() {
148 let f = fixture().await;
149
150 let profile = view(&f, &Actor::User(f.owner.clone()))
151 .await
152 .expect("should resolve");
153
154 assert!(profile.viewer_is_owner);
155 }
156
157 #[tokio::test]
158 async fn a_signed_in_stranger_is_not_the_owner() {
159 let f = fixture().await;
160
161 let profile = view(&f, &Actor::User(UserId::generate()))
162 .await
163 .expect("should resolve");
164
165 assert!(
166 !profile.viewer_is_owner,
167 "being signed in must not confer ownership of someone else's profile"
168 );
169 }
170
171 #[tokio::test]
172 async fn a_member_who_is_not_an_owner_cannot_edit() {
173 let f = fixture().await;
174 let member = UserId::generate();
175 f.memberships
176 .save(&Membership::new(
177 MembershipId::generate(),
178 f.org.id.clone(),
179 member.clone(),
180 Role::Member,
181 ))
182 .await
183 .expect("save membership");
184
185 let profile = view(&f, &Actor::User(member))
186 .await
187 .expect("should resolve");
188
189 assert!(
190 !profile.viewer_is_owner,
191 "membership alone is read access, not edit access"
192 );
193 }
194
195 #[tokio::test]
196 async fn an_unknown_handle_resolves_to_none() {
197 let f = fixture().await;
198
199 let profile = view_profile(
200 &OrgName::new("nobody").expect("valid"),
201 &Actor::Anonymous,
202 &f.orgs,
203 &f.memberships,
204 )
205 .await
206 .expect("view");
207
208 assert_eq!(profile, None);
209 }
210
211 #[tokio::test]
212 async fn a_handle_resolves_regardless_of_the_casing_used() {
213 let f = fixture().await;
214
215 // `OrgName::new` lowercases, so /ACME and /acme are the same handle.
216 let profile = view_profile(
217 &OrgName::new("ACME").expect("valid"),
218 &Actor::Anonymous,
219 &f.orgs,
220 &f.memberships,
221 )
222 .await
223 .expect("view");
224
225 assert!(profile.is_some(), "handles are case-insensitive");
226 }
227
228 #[tokio::test]
229 async fn a_profile_without_a_display_name_labels_with_its_handle() {
230 let orgs = InMemoryOrgRepo::new();
231 let memberships = InMemoryMembershipRepo::new();
232 let org = Organization::new(OrgId::generate(), "bare", None).expect("valid org");
233 orgs.save(&org).await.expect("save");
234
235 let profile = view_profile(&org.name, &Actor::Anonymous, &orgs, &memberships)
236 .await
237 .expect("view")
238 .expect("should resolve");
239
240 assert_eq!(profile.label, "bare");
241 assert_eq!(profile.bio, None);
242 }
243}