@jpgilldev / steid

12.7 KBRaw
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
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///
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.
31const RESERVED: &[&str] = &[
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",
53];
54
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
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))
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>,
115 /// Free-form profile text shown on the profile page.
116 pub bio: Option<String>,
117}
118
119impl Organization {
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
124 /// Creates an organisation from user-supplied input.
125 ///
126 /// Profile text is set afterwards with [`update_profile`](Self::update_profile) —
127 /// nothing creating an organisation has one to supply.
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)?,
136 display_name: normalise_optional(display_name),
137 bio: None,
138 })
139 }
140
141 /// Reassembles an organisation from storage, skipping validation.
142 pub fn from_trusted(
143 id: OrgId,
144 name: OrgName,
145 display_name: Option<String>,
146 bio: Option<String>,
147 ) -> Self {
148 Self {
149 id,
150 name,
151 display_name,
152 bio,
153 }
154 }
155
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
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
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
197#[cfg(test)]
198mod tests {
199 use super::*;
200
201 #[test]
202 fn accepts_ordinary_handles() {
203 for input in ["james", "acme", "a", "my-org", "org2026", "a-b-c"] {
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
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
310 #[test]
311 fn label_falls_back_to_the_handle() {
312 let org = Organization::new(OrgId::generate(), "acme", None).expect("valid");
313
314 assert_eq!(org.label(), "acme");
315 }
316
317 #[test]
318 fn label_prefers_the_display_name() {
319 let org =
320 Organization::new(OrgId::generate(), "acme", Some("Acme".to_owned())).expect("valid");
321
322 assert_eq!(org.label(), "Acme");
323 }
324
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
414 #[test]
415 fn blank_display_names_are_treated_as_unset() {
416 let org =
417 Organization::new(OrgId::generate(), "acme", Some(" ".to_owned())).expect("valid");
418
419 assert_eq!(org.display_name, None);
420 assert_eq!(org.label(), "acme");
421 }
422}