| | @@ -1,5 +1,5 @@ |
| 1 | 1 | use crate::domain::{ |
| 2 | | − Actor, OrgName, Organization, Role, |
| 2 | + Actor, DomainError, OrgName, Organization, Role, |
| 3 | 3 | repository::{MembershipRepository, OrgRepository}, |
| 4 | 4 | }; |
| 5 | 5 | |
| | @@ -16,8 +16,14 @@ use super::error::Result; |
| 16 | 16 | #[derive(Debug, Clone, PartialEq, Eq)] |
| 17 | 17 | pub struct PublicProfile { |
| 18 | 18 | pub handle: OrgName, |
| 19 | | − /// Display name if set, otherwise the handle. |
| 19 | + /// Display name if set, otherwise the handle. What a page should show. |
| 20 | 20 | pub label: String, |
| 21 | + /// The stored display name, unset if there isn't one. |
| 22 | + /// |
| 23 | + /// Distinct from `label` so an edit form can leave the field empty rather than |
| 24 | + /// prefilling the handle, which the owner never typed. Public because `label` |
| 25 | + /// already reveals it whenever it is set. |
| 26 | + pub display_name: Option<String>, |
| 21 | 27 | pub bio: Option<String>, |
| 22 | 28 | /// Whether the viewer may edit this profile. Decided here rather than in the page, |
| 23 | 29 | /// so the web form and `/api` cannot disagree about it. |
| | @@ -29,6 +35,7 @@ impl PublicProfile { |
| 29 | 35 | Self { |
| 30 | 36 | handle: org.name.clone(), |
| 31 | 37 | label: org.label().to_owned(), |
| 38 | + display_name: org.display_name.clone(), |
| 32 | 39 | bio: org.bio.clone(), |
| 33 | 40 | viewer_is_owner, |
| 34 | 41 | } |
| | @@ -71,6 +78,34 @@ async fn is_owner( |
| 71 | 78 | .is_some_and(|membership| membership.role == Role::Owner)) |
| 72 | 79 | } |
| 73 | 80 | |
| 81 | +/// Edits a profile's display name and bio. |
| 82 | +/// |
| 83 | +/// Authorization lives here, not in the page: the web form and any future `/api` |
| 84 | +/// caller must get the same answer about who may edit. Owners only — a member's |
| 85 | +/// read access is not edit access. |
| 86 | +pub async fn update_profile( |
| 87 | + actor: &Actor, |
| 88 | + handle: &OrgName, |
| 89 | + display_name: Option<String>, |
| 90 | + bio: Option<String>, |
| 91 | + orgs: &impl OrgRepository, |
| 92 | + memberships: &impl MembershipRepository, |
| 93 | +) -> Result<()> { |
| 94 | + let Some(mut org) = orgs.find_by_name(handle).await? else { |
| 95 | + return Err(DomainError::NotFound { entity: "profile" }.into()); |
| 96 | + }; |
| 97 | + |
| 98 | + if !is_owner(&org, actor, memberships).await? { |
| 99 | + return Err(DomainError::Forbidden.into()); |
| 100 | + } |
| 101 | + |
| 102 | + // Validates before saving, so a rejected edit leaves the stored profile untouched. |
| 103 | + org.update_profile(display_name, bio)?; |
| 104 | + orgs.save(&org).await?; |
| 105 | + |
| 106 | + Ok(()) |
| 107 | +} |
| 108 | + |
| 74 | 109 | #[cfg(test)] |
| 75 | 110 | mod tests { |
| 76 | 111 | use super::*; |
| | @@ -238,6 +273,140 @@ mod tests { |
| 238 | 273 | .expect("should resolve"); |
| 239 | 274 | |
| 240 | 275 | assert_eq!(profile.label, "bare"); |
| 276 | + assert_eq!( |
| 277 | + profile.display_name, None, |
| 278 | + "the label falls back to the handle, but display_name stays unset so an \ |
| 279 | + edit form does not prefill a value the owner never typed" |
| 280 | + ); |
| 241 | 281 | assert_eq!(profile.bio, None); |
| 242 | 282 | } |
| 283 | + #[tokio::test] |
| 284 | + async fn the_owner_can_edit() { |
| 285 | + let f = fixture().await; |
| 286 | + |
| 287 | + update_profile( |
| 288 | + &Actor::User(f.owner.clone()), |
| 289 | + &f.org.name, |
| 290 | + Some("Renamed".to_owned()), |
| 291 | + Some("New bio.".to_owned()), |
| 292 | + &f.orgs, |
| 293 | + &f.memberships, |
| 294 | + ) |
| 295 | + .await |
| 296 | + .expect("owner may edit"); |
| 297 | + |
| 298 | + let profile = view(&f, &Actor::Anonymous).await.expect("resolve"); |
| 299 | + assert_eq!(profile.label, "Renamed"); |
| 300 | + assert_eq!(profile.bio.as_deref(), Some("New bio.")); |
| 301 | + } |
| 302 | + |
| 303 | + async fn expect_rejected(f: &Fixture, actor: &Actor) -> crate::application::Error { |
| 304 | + update_profile( |
| 305 | + actor, |
| 306 | + &f.org.name, |
| 307 | + Some("Hijacked".to_owned()), |
| 308 | + None, |
| 309 | + &f.orgs, |
| 310 | + &f.memberships, |
| 311 | + ) |
| 312 | + .await |
| 313 | + .expect_err("should reject") |
| 314 | + } |
| 315 | + |
| 316 | + #[tokio::test] |
| 317 | + async fn an_anonymous_actor_cannot_edit() { |
| 318 | + let f = fixture().await; |
| 319 | + |
| 320 | + let error = expect_rejected(&f, &Actor::Anonymous).await; |
| 321 | + |
| 322 | + assert!(matches!( |
| 323 | + error, |
| 324 | + crate::application::Error::Domain(DomainError::Forbidden) |
| 325 | + )); |
| 326 | + assert_eq!( |
| 327 | + view(&f, &Actor::Anonymous).await.expect("resolve").label, |
| 328 | + "Acme Inc", |
| 329 | + "a rejected edit must change nothing" |
| 330 | + ); |
| 331 | + } |
| 332 | + |
| 333 | + #[tokio::test] |
| 334 | + async fn a_signed_in_stranger_cannot_edit() { |
| 335 | + let f = fixture().await; |
| 336 | + |
| 337 | + let error = expect_rejected(&f, &Actor::User(UserId::generate())).await; |
| 338 | + |
| 339 | + assert!(matches!( |
| 340 | + error, |
| 341 | + crate::application::Error::Domain(DomainError::Forbidden) |
| 342 | + )); |
| 343 | + } |
| 344 | + |
| 345 | + #[tokio::test] |
| 346 | + async fn a_member_who_is_not_an_owner_cannot_edit_either() { |
| 347 | + let f = fixture().await; |
| 348 | + let member = UserId::generate(); |
| 349 | + f.memberships |
| 350 | + .save(&Membership::new( |
| 351 | + MembershipId::generate(), |
| 352 | + f.org.id.clone(), |
| 353 | + member.clone(), |
| 354 | + Role::Member, |
| 355 | + )) |
| 356 | + .await |
| 357 | + .expect("save membership"); |
| 358 | + |
| 359 | + let error = expect_rejected(&f, &Actor::User(member)).await; |
| 360 | + |
| 361 | + assert!(matches!( |
| 362 | + error, |
| 363 | + crate::application::Error::Domain(DomainError::Forbidden) |
| 364 | + )); |
| 365 | + } |
| 366 | + |
| 367 | + #[tokio::test] |
| 368 | + async fn editing_an_unknown_handle_is_not_found() { |
| 369 | + let f = fixture().await; |
| 370 | + |
| 371 | + let error = update_profile( |
| 372 | + &Actor::User(f.owner.clone()), |
| 373 | + &OrgName::new("nobody").expect("valid"), |
| 374 | + None, |
| 375 | + None, |
| 376 | + &f.orgs, |
| 377 | + &f.memberships, |
| 378 | + ) |
| 379 | + .await |
| 380 | + .expect_err("should reject"); |
| 381 | + |
| 382 | + assert!(matches!( |
| 383 | + error, |
| 384 | + crate::application::Error::Domain(DomainError::NotFound { .. }) |
| 385 | + )); |
| 386 | + } |
| 387 | + |
| 388 | + #[tokio::test] |
| 389 | + async fn an_invalid_edit_leaves_the_stored_profile_untouched() { |
| 390 | + let f = fixture().await; |
| 391 | + |
| 392 | + let error = update_profile( |
| 393 | + &Actor::User(f.owner.clone()), |
| 394 | + &f.org.name, |
| 395 | + Some("Renamed".to_owned()), |
| 396 | + Some("a".repeat(Organization::MAX_BIO_LEN + 1)), |
| 397 | + &f.orgs, |
| 398 | + &f.memberships, |
| 399 | + ) |
| 400 | + .await |
| 401 | + .expect_err("should reject"); |
| 402 | + |
| 403 | + assert!(matches!( |
| 404 | + error, |
| 405 | + crate::application::Error::Domain(DomainError::Validation { .. }) |
| 406 | + )); |
| 407 | + |
| 408 | + let profile = view(&f, &Actor::Anonymous).await.expect("resolve"); |
| 409 | + assert_eq!(profile.label, "Acme Inc"); |
| 410 | + assert_eq!(profile.bio.as_deref(), Some("We make things.")); |
| 411 | + } |
| 243 | 412 | } |