Skip to main content

max / makenotwork

5.7 KB · 191 lines History Blame Raw
1 //! A throwaway in-memory host, implementing all four traits.
2 //!
3 //! This is not a test of behavior. It is a test that the trait shapes can
4 //! actually be satisfied: that the signatures compose, that the lifetimes work
5 //! through `async_trait`, and that a host can carry its own state. The design
6 //! calls for `ChatRooms` to be provably general rather than shaped around one
7 //! consumer, and the only way to know that is to have a second implementor that
8 //! is nothing like a community or a stream.
9 //!
10 //! When Multithreaded's real impl lands, this stays. If a signature change breaks
11 //! it, that is the signal that the change leaked host-specific assumptions into
12 //! the crate.
13
14 use std::collections::HashMap;
15 use std::sync::Mutex;
16 use std::time::Duration;
17
18 use async_trait::async_trait;
19 use livechat::{
20 ChatAuthz, ChatError, ChatIdentity, ChatModeration, ChatRooms, DenyReason, Identity, MessageId,
21 Retention, Room, RoomId, RoomState, UserId, WriteAccess,
22 };
23 use uuid::Uuid;
24
25 #[derive(Default)]
26 struct Host {
27 banned: Mutex<Vec<UserId>>,
28 purged: Mutex<Vec<(RoomId, UserId)>>,
29 log: Mutex<Vec<String>>,
30 }
31
32 fn room(state: RoomState) -> Room {
33 Room {
34 id: RoomId(Uuid::nil()),
35 state,
36 retention: Retention::forum_default(),
37 }
38 }
39
40 #[async_trait]
41 impl ChatRooms for Host {
42 async fn resolve(&self, key: &str) -> Result<Option<Room>, ChatError> {
43 Ok(match key {
44 "open" => Some(room(RoomState::Open)),
45 "frozen" => Some(room(RoomState::ReadOnly)),
46 "disabled" => Some(room(RoomState::Closed)),
47 _ => None,
48 })
49 }
50 }
51
52 #[async_trait]
53 impl ChatAuthz for Host {
54 async fn can_read(&self, _viewer: Option<UserId>, room: &Room) -> Result<bool, ChatError> {
55 Ok(room.state.allows_reads())
56 }
57
58 async fn can_write(&self, user: UserId, room: &Room) -> Result<WriteAccess, ChatError> {
59 if !room.state.allows_writes() {
60 return Ok(WriteAccess::Deny(DenyReason::NotAMember));
61 }
62 if self.banned.lock().unwrap().contains(&user) {
63 return Ok(WriteAccess::Deny(DenyReason::Banned));
64 }
65 Ok(WriteAccess::Allow)
66 }
67
68 async fn is_moderator(&self, _user: UserId, _room: &Room) -> Result<bool, ChatError> {
69 Ok(true)
70 }
71 }
72
73 #[async_trait]
74 impl ChatIdentity for Host {
75 async fn identify(&self, users: &[UserId]) -> Result<HashMap<UserId, Identity>, ChatError> {
76 Ok(users
77 .iter()
78 .map(|u| {
79 (
80 *u,
81 Identity {
82 display_name: u.to_string(),
83 avatar_url: None,
84 flair: None,
85 },
86 )
87 })
88 .collect())
89 }
90 }
91
92 #[async_trait]
93 impl ChatModeration for Host {
94 async fn delete_message(
95 &self,
96 _actor: UserId,
97 _room: &Room,
98 _message: MessageId,
99 ) -> Result<(), ChatError> {
100 Ok(())
101 }
102
103 async fn purge_user(
104 &self,
105 _actor: UserId,
106 room: &Room,
107 target: UserId,
108 ) -> Result<u64, ChatError> {
109 self.purged.lock().unwrap().push((room.id, target));
110 Ok(3)
111 }
112
113 async fn timeout_user(
114 &self,
115 _actor: UserId,
116 _room: &Room,
117 _target: UserId,
118 _duration: Duration,
119 ) -> Result<(), ChatError> {
120 Ok(())
121 }
122
123 async fn ban_user(
124 &self,
125 _actor: UserId,
126 _room: &Room,
127 target: UserId,
128 _reason: Option<&str>,
129 ) -> Result<(), ChatError> {
130 self.banned.lock().unwrap().push(target);
131 Ok(())
132 }
133
134 async fn log_action(
135 &self,
136 _actor: UserId,
137 _room_id: RoomId,
138 action: &str,
139 _detail: Option<&str>,
140 ) -> Result<(), ChatError> {
141 self.log.lock().unwrap().push(action.to_owned());
142 Ok(())
143 }
144 }
145
146 /// The traits must be usable behind a trait object, not only as generics. Both
147 /// hosts will hang these off application state that is cloned per request.
148 fn as_dyn(host: &Host) -> (&dyn ChatRooms, &dyn ChatAuthz, &dyn ChatModeration) {
149 (host, host, host)
150 }
151
152 #[tokio::test]
153 async fn a_host_unlike_either_consumer_can_satisfy_every_trait() {
154 let host = Host::default();
155 let (rooms, authz, moderation) = as_dyn(&host);
156 let user = UserId(Uuid::new_v4());
157
158 assert!(rooms.resolve("nope").await.unwrap().is_none());
159
160 let open = rooms.resolve("open").await.unwrap().unwrap();
161 assert!(authz.can_write(user, &open).await.unwrap().is_allowed());
162
163 // A disabled room resolves as Closed rather than absent, so the host can tell
164 // "chat is off here" apart from "no such room".
165 let closed = rooms.resolve("disabled").await.unwrap().unwrap();
166 assert_eq!(closed.state, RoomState::Closed);
167 assert!(!authz.can_read(Some(user), &closed).await.unwrap());
168
169 // Read-only rooms still serve their backlog.
170 let frozen = rooms.resolve("frozen").await.unwrap().unwrap();
171 assert!(authz.can_read(Some(user), &frozen).await.unwrap());
172 assert!(!authz.can_write(user, &frozen).await.unwrap().is_allowed());
173
174 // Ban then purge, which is the pairing the design calls for.
175 moderation.ban_user(user, &open, user, None).await.unwrap();
176 let removed = moderation.purge_user(user, &open, user).await.unwrap();
177 assert_eq!(removed, 3);
178 assert_eq!(
179 authz.can_write(user, &open).await.unwrap(),
180 WriteAccess::Deny(DenyReason::Banned)
181 );
182 }
183
184 #[tokio::test]
185 async fn identify_is_batched() {
186 let host = Host::default();
187 let users: Vec<UserId> = (0..3).map(|_| UserId(Uuid::new_v4())).collect();
188 let resolved = host.identify(&users).await.unwrap();
189 assert_eq!(resolved.len(), 3);
190 }
191