@jpgilldev / steid

steid/src/application/identity.rs
3.9 KBRaw
1use crate::domain::{
2 Actor, Email, OrgName, UserId,
3 repository::{OrgRepository, UserRepository},
4};
5
6use super::error::Result;
7
8/// Who an actor actually is, resolved for display.
9///
10/// A read model, not an entity: it composes the user with their personal organisation
11/// so callers don't each have to know that a handle lives on the org rather than the
12/// user.
13#[derive(Debug, Clone, PartialEq, Eq)]
14pub struct Identity {
15 pub user_id: UserId,
16 pub email: Email,
17 /// The public handle — the `{owner}` segment of this user's URLs.
18 pub handle: OrgName,
19}
20
21/// Resolves an actor into a displayable identity.
22///
23/// `Ok(None)` for an anonymous actor, and also for an authenticated one whose user or
24/// organisation has since gone: a dangling session should render as signed out, not as
25/// an error page.
26pub async fn describe_identity(
27 actor: &Actor,
28 users: &impl UserRepository,
29 orgs: &impl OrgRepository,
30) -> Result<Option<Identity>> {
31 let Some(user_id) = actor.user_id() else {
32 return Ok(None);
33 };
34
35 let Some(user) = users.find_by_id(user_id).await? else {
36 return Ok(None);
37 };
38
39 let Some(org) = orgs.find_by_id(&user.personal_org_id).await? else {
40 return Ok(None);
41 };
42
43 Ok(Some(Identity {
44 user_id: user.id,
45 email: user.email,
46 handle: org.name,
47 }))
48}
49
50#[cfg(test)]
51mod tests {
52 use super::*;
53 use crate::{
54 domain::{OrgId, Organization, PasswordHash, User},
55 infrastructure::repository::{InMemoryOrgRepo, InMemoryUserRepo},
56 };
57
58 async fn fixture() -> (InMemoryUserRepo, InMemoryOrgRepo, User) {
59 let users = InMemoryUserRepo::new();
60 let orgs = InMemoryOrgRepo::new();
61 let org = Organization::new(OrgId::generate(), "jimbo", None).expect("valid org");
62 orgs.save(&org).await.expect("save org");
63 let user = User::new(
64 UserId::generate(),
65 Email::new("jimbo@example.com").expect("valid email"),
66 PasswordHash::from_trusted("$argon2id$test"),
67 org.id.clone(),
68 );
69 users.save(&user).await.expect("save user");
70 (users, orgs, user)
71 }
72
73 #[tokio::test]
74 async fn an_anonymous_actor_has_no_identity() {
75 let (users, orgs, _) = fixture().await;
76
77 let identity = describe_identity(&Actor::Anonymous, &users, &orgs)
78 .await
79 .expect("describe");
80
81 assert_eq!(identity, None);
82 }
83
84 #[tokio::test]
85 async fn an_authenticated_actor_resolves_to_its_handle_and_email() {
86 let (users, orgs, user) = fixture().await;
87
88 let identity = describe_identity(&Actor::User(user.id.clone()), &users, &orgs)
89 .await
90 .expect("describe")
91 .expect("should resolve");
92
93 assert_eq!(identity.user_id, user.id);
94 assert_eq!(identity.email.as_str(), "jimbo@example.com");
95 assert_eq!(identity.handle.as_str(), "jimbo");
96 }
97
98 #[tokio::test]
99 async fn a_session_for_a_deleted_user_reads_as_signed_out() {
100 let (users, orgs, _) = fixture().await;
101
102 let identity = describe_identity(&Actor::User(UserId::generate()), &users, &orgs)
103 .await
104 .expect("describe");
105
106 assert_eq!(
107 identity, None,
108 "a dangling session should render as signed out, not error"
109 );
110 }
111
112 #[tokio::test]
113 async fn a_user_whose_org_is_missing_reads_as_signed_out() {
114 let users = InMemoryUserRepo::new();
115 let orgs = InMemoryOrgRepo::new();
116 let user = User::new(
117 UserId::generate(),
118 Email::new("orphan@example.com").expect("valid email"),
119 PasswordHash::from_trusted("$argon2id$test"),
120 OrgId::generate(),
121 );
122 users.save(&user).await.expect("save user");
123
124 let identity = describe_identity(&Actor::User(user.id), &users, &orgs)
125 .await
126 .expect("describe");
127
128 assert_eq!(identity, None);
129 }
130}