steid

@jamesgill /

12.7 KBCode·Blame·Raw
1e1ae56feat: identity domain model1mo
1use std::fmt;
2
3use super::{DomainError, OrgId};
4
5/// An organisation handle — the `{owner}` segment of every URL, and unique across the
6/// installation.
7///
8/// Rules follow GitHub's: 1–39 characters, ASCII alphanumeric or hyphen, no leading or
9/// trailing hyphen. Stored lowercase so lookups and URLs agree.
10#[derive(Debug, Clone, PartialEq, Eq, Hash)]
11pub struct OrgName(String);
12
aaefaabfeat: root handles, grouped routes, reserved-handle denylist1mo
13/// Handles that would collide with an application route.
14///
15/// Handles live at the root (`/{handle}`), so anything here would shadow a real route
16/// or be shadowed by one. Kept deliberately lean: routes are grouped under functional
17/// prefixes (`/auth/login`, not `/login`), so this grows per *area*, not per route.
18///
19/// **Reserve generously and early.** Adding an entry later is a breaking change for
20/// whoever already holds that handle — their account has to be renamed and their links
21/// break. Entries here cost nothing while unclaimed.
22///
7148ad4feat: expand the reserved-handle list to twenty1mo
23/// The test for adding one: could this ever be a *top-level* route? Under grouping,
24/// almost nothing is — a health check is `/api/health`, sign-up is `/auth/register` —
25/// so words that would live under a prefix do not belong here. GitHub reserves 590+
26/// because its namespace is flat; copying that list would import the cost without the
27/// cause.
28///
29/// Paths containing characters a handle cannot hold need no entry: `/_topcoat/`,
30/// `/.well-known/`, `robots.txt`, `favicon.ico` are all excluded by the rules above.
aaefaabfeat: root handles, grouped routes, reserved-handle denylist1mo
31const RESERVED: &[&str] = &[
7148ad4feat: expand the reserved-handle list to twenty1mo
32 "about",
33 "admin",
34 "api",
35 "assets",
36 "auth",
37 "dashboard",
38 "docs",
39 "explore",
40 "help",
41 "legal",
42 "notifications",
43 "privacy",
44 "search",
45 "security",
46 "settings",
47 "static",
48 "status",
49 // The project's own name, so nobody can hold it and impersonate the software.
50 "steid",
51 "support",
52 "terms",
aaefaabfeat: root handles, grouped routes, reserved-handle denylist1mo
53];
54
1e1ae56feat: identity domain model1mo
55impl OrgName {
56 pub const MAX_LEN: usize = 39;
57
58 /// Validates and normalises user-supplied input.
59 pub fn new(value: impl Into<String>) -> Result<Self, DomainError> {
60 let value = value.into();
61 let trimmed = value.trim();
62
63 let invalid = |reason: &str| DomainError::validation("name", reason);
64
65 if trimmed.is_empty() {
66 return Err(invalid("must not be empty"));
67 }
68 if trimmed.chars().count() > Self::MAX_LEN {
69 return Err(invalid("must be at most 39 characters"));
70 }
71 if !trimmed
72 .chars()
73 .all(|c| c.is_ascii_alphanumeric() || c == '-')
74 {
75 return Err(invalid("may only contain letters, digits, and hyphens"));
76 }
77 if trimmed.starts_with('-') || trimmed.ends_with('-') {
78 return Err(invalid("must not start or end with a hyphen"));
79 }
80
aaefaabfeat: root handles, grouped routes, reserved-handle denylist1mo
81 let normalised = trimmed.to_lowercase();
82
83 // Checked against the normalised form: `API` and `api` are the same handle.
84 if RESERVED.contains(&normalised.as_str()) {
85 return Err(invalid("is reserved"));
86 }
87
88 Ok(Self(normalised))
1e1ae56feat: identity domain model1mo
89 }
90
91 /// Wraps a value already validated on the way into the database.
92 pub fn from_trusted(value: impl Into<String>) -> Self {
93 Self(value.into())
94 }
95
96 pub fn as_str(&self) -> &str {
97 &self.0
98 }
99}
100
101impl fmt::Display for OrgName {
102 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
103 f.write_str(&self.0)
104 }
105}
106
107/// An organisation. Every user gets a personal one at registration, and repositories
108/// and content hang off organisations rather than users.
109#[derive(Debug, Clone, PartialEq, Eq)]
110pub struct Organization {
111 pub id: OrgId,
112 pub name: OrgName,
113 /// Free-form label shown in the UI. Falls back to `name` when unset.
114 pub display_name: Option<String>,
a69e380feat: public profile page at /{handle}1mo
115 /// Free-form profile text shown on the profile page.
116 pub bio: Option<String>,
1e1ae56feat: identity domain model1mo
117}
118
119impl Organization {
a69e380feat: public profile page at /{handle}1mo
120 /// Longest permitted bio. Generous enough for a few sentences of introduction
121 /// without becoming a page of its own.
122 pub const MAX_BIO_LEN: usize = 500;
123
1e1ae56feat: identity domain model1mo
124 /// Creates an organisation from user-supplied input.
a69e380feat: public profile page at /{handle}1mo
125 ///
126 /// Profile text is set afterwards with [`update_profile`](Self::update_profile) —
127 /// nothing creating an organisation has one to supply.
1e1ae56feat: identity domain model1mo
128 pub fn new(
129 id: OrgId,
130 name: impl Into<String>,
131 display_name: Option<String>,
132 ) -> Result<Self, DomainError> {
133 Ok(Self {
134 id,
135 name: OrgName::new(name)?,
a69e380feat: public profile page at /{handle}1mo
136 display_name: normalise_optional(display_name),
137 bio: None,
1e1ae56feat: identity domain model1mo
138 })
139 }
140
141 /// Reassembles an organisation from storage, skipping validation.
a69e380feat: public profile page at /{handle}1mo
142 pub fn from_trusted(
143 id: OrgId,
144 name: OrgName,
145 display_name: Option<String>,
146 bio: Option<String>,
147 ) -> Self {
1e1ae56feat: identity domain model1mo
148 Self {
149 id,
150 name,
151 display_name,
a69e380feat: public profile page at /{handle}1mo
152 bio,
1e1ae56feat: identity domain model1mo
153 }
154 }
155
a69e380feat: public profile page at /{handle}1mo
156 /// Applies edited profile fields, validating them.
157 ///
158 /// Blank input clears the field rather than storing whitespace, so "cleared" and
159 /// "never set" stay the same state and the page has one case to render.
160 pub fn update_profile(
161 &mut self,
162 display_name: Option<String>,
163 bio: Option<String>,
164 ) -> Result<(), DomainError> {
165 let bio = normalise_optional(bio);
166
167 if let Some(bio) = &bio
168 && bio.chars().count() > Self::MAX_BIO_LEN
169 {
170 return Err(DomainError::validation(
171 "bio",
172 format!("must be at most {} characters", Self::MAX_BIO_LEN),
173 ));
174 }
175
176 self.display_name = normalise_optional(display_name);
177 self.bio = bio;
178
179 Ok(())
180 }
181
1e1ae56feat: identity domain model1mo
182 /// The label to show in the UI.
183 pub fn label(&self) -> &str {
184 self.display_name
185 .as_deref()
186 .unwrap_or_else(|| self.name.as_str())
187 }
188}
189
a69e380feat: public profile page at /{handle}1mo
190/// Trims, and treats blank as absent.
191fn normalise_optional(value: Option<String>) -> Option<String> {
192 value
193 .map(|value| value.trim().to_owned())
194 .filter(|value| !value.is_empty())
195}
196
1e1ae56feat: identity domain model1mo
197#[cfg(test)]
198mod tests {
199 use super::*;
200
201 #[test]
202 fn accepts_ordinary_handles() {
7148ad4feat: expand the reserved-handle list to twenty1mo
203 for input in ["james", "acme", "a", "my-org", "org2026", "a-b-c"] {
1e1ae56feat: identity domain model1mo
204 assert!(
205 OrgName::new(input).is_ok(),
206 "expected {input:?} to be accepted"
207 );
208 }
209 }
210
211 #[test]
212 fn normalises_case_and_whitespace() {
213 let name = OrgName::new(" JamesGill ").expect("should be valid");
214
215 assert_eq!(name.as_str(), "jamesgill");
216 }
217
218 #[test]
219 fn rejects_malformed_handles() {
220 for input in [
221 "",
222 " ",
223 "-leading",
224 "trailing-",
225 "has space",
226 "under_score",
227 "dot.dot",
228 "slash/slash",
229 "emoji🦀",
230 &"a".repeat(40),
231 ] {
232 assert!(
233 OrgName::new(input).is_err(),
234 "expected {input:?} to be rejected"
235 );
236 }
237 }
238
239 #[test]
240 fn accepts_a_handle_at_the_length_limit() {
241 assert!(OrgName::new("a".repeat(OrgName::MAX_LEN)).is_ok());
242 }
243
aaefaabfeat: root handles, grouped routes, reserved-handle denylist1mo
244 #[test]
245 fn rejects_reserved_handles() {
246 for reserved in RESERVED {
247 assert!(
248 OrgName::new(*reserved).is_err(),
249 "expected {reserved:?} to be reserved"
250 );
251 }
252 }
253
254 #[test]
255 fn reservation_ignores_case() {
256 // Handles normalise to lowercase, so a differently-cased reserved word is the
257 // same handle and must be refused too.
258 assert!(OrgName::new("API").is_err());
259 assert!(OrgName::new("Auth").is_err());
260 }
261
262 #[test]
263 fn reservation_does_not_leak_into_substrings() {
264 // Only whole handles are reserved. `apidocs` shadows nothing.
265 for allowed in [
266 "apidocs",
267 "authors",
268 "administrator",
269 "helpful",
270 "settings-app",
271 ] {
272 assert!(
273 OrgName::new(allowed).is_ok(),
274 "expected {allowed:?} to be allowed"
275 );
276 }
277 }
278
279 #[test]
280 fn the_reserved_list_is_sorted_and_unique() {
281 // Sorted so additions are easy to review; unique so a duplicate doesn't hide a
282 // typo'd entry that reserves nothing.
283 let mut sorted = RESERVED.to_vec();
284 sorted.sort_unstable();
285 sorted.dedup();
286
287 assert_eq!(
288 sorted.as_slice(),
289 RESERVED,
290 "keep RESERVED sorted and unique"
291 );
292 }
293
294 #[test]
295 fn reserved_entries_are_themselves_valid_handle_shapes() {
296 // A reserved word that could never be a handle anyway is dead weight and
297 // suggests a misunderstanding of what the list is for.
298 for reserved in RESERVED {
299 assert!(
300 !reserved.is_empty()
301 && reserved.len() <= OrgName::MAX_LEN
302 && reserved.chars().all(|c| c.is_ascii_lowercase())
303 && !reserved.starts_with('-')
304 && !reserved.ends_with('-'),
305 "{reserved:?} could never be a handle, so reserving it is pointless"
306 );
307 }
308 }
309
1e1ae56feat: identity domain model1mo
310 #[test]
311 fn label_falls_back_to_the_handle() {
7148ad4feat: expand the reserved-handle list to twenty1mo
312 let org = Organization::new(OrgId::generate(), "acme", None).expect("valid");
1e1ae56feat: identity domain model1mo
313
7148ad4feat: expand the reserved-handle list to twenty1mo
314 assert_eq!(org.label(), "acme");
1e1ae56feat: identity domain model1mo
315 }
316
317 #[test]
318 fn label_prefers_the_display_name() {
319 let org =
7148ad4feat: expand the reserved-handle list to twenty1mo
320 Organization::new(OrgId::generate(), "acme", Some("Acme".to_owned())).expect("valid");
1e1ae56feat: identity domain model1mo
321
7148ad4feat: expand the reserved-handle list to twenty1mo
322 assert_eq!(org.label(), "Acme");
1e1ae56feat: identity domain model1mo
323 }
324
a69e380feat: public profile page at /{handle}1mo
325 #[test]
326 fn a_new_organisation_has_no_bio() {
327 let org = Organization::new(OrgId::generate(), "acme", None).expect("valid");
328
329 assert_eq!(org.bio, None);
330 }
331
332 #[test]
333 fn update_profile_sets_both_fields() {
334 let mut org = Organization::new(OrgId::generate(), "acme", None).expect("valid");
335
336 org.update_profile(
337 Some("Acme Inc".to_owned()),
338 Some("We make things.".to_owned()),
339 )
340 .expect("valid profile");
341
342 assert_eq!(org.label(), "Acme Inc");
343 assert_eq!(org.bio.as_deref(), Some("We make things."));
344 }
345
346 #[test]
347 fn update_profile_trims_and_clears_blank_input() {
348 let mut org = Organization::new(OrgId::generate(), "acme", None).expect("valid");
349 org.update_profile(Some("Acme".to_owned()), Some("Hello".to_owned()))
350 .expect("valid profile");
351
352 org.update_profile(Some(" ".to_owned()), Some(String::new()))
353 .expect("valid profile");
354
355 // Cleared and never-set are the same state, so the page renders one case.
356 assert_eq!(org.display_name, None);
357 assert_eq!(org.bio, None);
358 assert_eq!(org.label(), "acme");
359 }
360
361 #[test]
362 fn update_profile_trims_surrounding_whitespace() {
363 let mut org = Organization::new(OrgId::generate(), "acme", None).expect("valid");
364
365 org.update_profile(None, Some(" spaced ".to_owned()))
366 .expect("valid profile");
367
368 assert_eq!(org.bio.as_deref(), Some("spaced"));
369 }
370
371 #[test]
372 fn update_profile_accepts_a_bio_at_the_length_limit() {
373 let mut org = Organization::new(OrgId::generate(), "acme", None).expect("valid");
374
375 assert!(
376 org.update_profile(None, Some("a".repeat(Organization::MAX_BIO_LEN)))
377 .is_ok()
378 );
379 }
380
381 #[test]
382 fn update_profile_rejects_an_over_long_bio_and_changes_nothing() {
383 let mut org = Organization::new(OrgId::generate(), "acme", None).expect("valid");
384 org.update_profile(Some("Acme".to_owned()), Some("original".to_owned()))
385 .expect("valid profile");
386
387 let error = org
388 .update_profile(
389 Some("Changed".to_owned()),
390 Some("a".repeat(Organization::MAX_BIO_LEN + 1)),
391 )
392 .expect_err("should reject");
393
394 assert!(matches!(error, DomainError::Validation { .. }));
395 assert_eq!(
396 org.bio.as_deref(),
397 Some("original"),
398 "a rejected edit must not partially apply"
399 );
400 assert_eq!(org.label(), "Acme");
401 }
402
403 #[test]
404 fn bio_length_counts_characters_not_bytes() {
405 let mut org = Organization::new(OrgId::generate(), "acme", None).expect("valid");
406
407 // Multi-byte characters would trip a byte-length check well under the limit.
408 assert!(
409 org.update_profile(None, Some("é".repeat(Organization::MAX_BIO_LEN)))
410 .is_ok()
411 );
412 }
413
1e1ae56feat: identity domain model1mo
414 #[test]
415 fn blank_display_names_are_treated_as_unset() {
416 let org =
7148ad4feat: expand the reserved-handle list to twenty1mo
417 Organization::new(OrgId::generate(), "acme", Some(" ".to_owned())).expect("valid");
1e1ae56feat: identity domain model1mo
418
419 assert_eq!(org.display_name, None);
7148ad4feat: expand the reserved-handle list to twenty1mo
420 assert_eq!(org.label(), "acme");
1e1ae56feat: identity domain model1mo
421 }
422}