diff --git a/crates/presentation/src/handlers/federation.rs b/crates/presentation/src/handlers/federation.rs deleted file mode 100644 index f2881f5..0000000 --- a/crates/presentation/src/handlers/federation.rs +++ /dev/null @@ -1,135 +0,0 @@ -use axum::{ - extract::{Query, State}, - http::StatusCode, - Json, -}; -use serde::Deserialize; - -use api_types::responses::RemoteActorResponse; - -#[derive(serde::Deserialize)] -pub struct FollowRemoteRequest { - pub handle: String, -} - -use crate::{errors::ApiError, extractors::AuthUser, state::AppState}; - -#[derive(Deserialize)] -pub struct LookupQuery { - pub handle: String, -} - -pub async fn lookup_handler( - State(s): State, - Query(q): Query, -) -> Result, ApiError> { - let actor = s.federation.lookup_actor(&q.handle).await?; - Ok(Json(RemoteActorResponse { - handle: actor.handle, - display_name: actor.display_name, - avatar_url: actor.avatar_url, - url: actor.url, - })) -} - -pub async fn follow_remote_handler( - State(s): State, - AuthUser(uid): AuthUser, - Json(body): Json, -) -> Result { - s.federation.follow_remote(&uid, &body.handle).await?; - Ok(StatusCode::NO_CONTENT) -} - -#[cfg(test)] -mod tests { - use super::*; - use async_trait::async_trait; - use axum::{ - body::Body, - http::{Request, StatusCode}, - routing::{get, post}, - Router, - }; - use domain::{ - errors::DomainError, - ports::{AuthService, GeneratedToken, PasswordHasher}, - testing::TestStore, - value_objects::{PasswordHash, UserId}, - }; - use std::sync::Arc; - use tower::ServiceExt; - - struct NoOpAuth; - impl AuthService for NoOpAuth { - fn generate_token(&self, _uid: &UserId) -> Result { - Err(DomainError::Internal("noop".into())) - } - fn validate_token(&self, _token: &str) -> Result { - Err(DomainError::Unauthorized) - } - } - - struct NoOpHasher; - #[async_trait] - impl PasswordHasher for NoOpHasher { - async fn hash(&self, _plain: &str) -> Result { - Err(DomainError::Internal("noop".into())) - } - async fn verify(&self, _plain: &str, _hash: &PasswordHash) -> Result { - Ok(false) - } - } - - fn make_state() -> crate::state::AppState { - let store = Arc::new(TestStore::default()); - crate::state::AppState { - users: store.clone(), - thoughts: store.clone(), - likes: store.clone(), - boosts: store.clone(), - follows: store.clone(), - blocks: store.clone(), - tags: store.clone(), - api_keys: store.clone(), - top_friends: store.clone(), - notifications: store.clone(), - remote_actors: store.clone(), - feed: store.clone(), - search: store.clone(), - auth: Arc::new(NoOpAuth), - hasher: Arc::new(NoOpHasher), - events: store.clone(), - federation: store.clone(), - } - } - - fn app() -> Router { - Router::new() - .route("/federation/lookup", get(lookup_handler)) - .route("/federation/follow", post(follow_remote_handler)) - .with_state(make_state()) - } - - #[tokio::test] - async fn lookup_unknown_handle_returns_404() { - let req = Request::builder() - .uri("/federation/lookup?handle=%40alice%40example.com") - .body(Body::empty()) - .unwrap(); - let resp = app().oneshot(req).await.unwrap(); - assert_eq!(resp.status(), StatusCode::NOT_FOUND); - } - - #[tokio::test] - async fn follow_remote_without_auth_returns_401() { - let req = Request::builder() - .method("POST") - .uri("/federation/follow") - .header("content-type", "application/json") - .body(Body::from(r#"{"handle":"@alice@example.com"}"#)) - .unwrap(); - let resp = app().oneshot(req).await.unwrap(); - assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); - } -} diff --git a/crates/presentation/src/handlers/mod.rs b/crates/presentation/src/handlers/mod.rs index 325aa86..82c8aca 100644 --- a/crates/presentation/src/handlers/mod.rs +++ b/crates/presentation/src/handlers/mod.rs @@ -1,6 +1,5 @@ pub mod api_keys; pub mod auth; -pub mod federation; pub mod feed; pub mod health; pub mod notifications; diff --git a/crates/presentation/src/handlers/social.rs b/crates/presentation/src/handlers/social.rs index 5284d1c..3a7c1b1 100644 --- a/crates/presentation/src/handlers/social.rs +++ b/crates/presentation/src/handlers/social.rs @@ -46,27 +46,46 @@ pub async fn delete_boost( unboost_thought(&*s.boosts, &*s.events, &uid, &ThoughtId::from_uuid(id)).await?; Ok(StatusCode::NO_CONTENT) } -#[utoipa::path(post, path = "/users/{id}/follow", params(("id" = uuid::Uuid, Path, description = "User ID")), responses((status = 204, description = "Following")), security(("bearer_auth" = [])))] +#[utoipa::path( + post, path = "/users/{username}/follow", + params(("username" = String, Path, description = "Username or user@domain handle")), + responses((status = 204, description = "Following")), + security(("bearer_auth" = [])) +)] pub async fn post_follow( State(s): State, AuthUser(uid): AuthUser, Path(username): Path, ) -> Result { - let target = get_user_by_username(&*s.users, &username).await?; - follow_user(&*s.follows, &*s.events, &uid, &target.id).await?; + if username.contains('@') { + s.federation.follow_remote(&uid, &username).await?; + } else { + let target = get_user_by_username(&*s.users, &username).await?; + follow_user(&*s.follows, &*s.events, &uid, &target.id).await?; + } Ok(StatusCode::NO_CONTENT) } -#[utoipa::path(delete, path = "/users/{id}/follow", params(("id" = uuid::Uuid, Path, description = "User ID")), responses((status = 204, description = "Unfollowed")), security(("bearer_auth" = [])))] +#[utoipa::path( + delete, path = "/users/{username}/follow", + params(("username" = String, Path, description = "Username")), + responses((status = 204, description = "Unfollowed")), + security(("bearer_auth" = [])) +)] pub async fn delete_follow( State(s): State, AuthUser(uid): AuthUser, Path(username): Path, ) -> Result { + if username.contains('@') { + return Err(ApiError::BadRequest( + "remote unfollow not yet supported".into(), + )); + } let target = get_user_by_username(&*s.users, &username).await?; unfollow_user(&*s.follows, &*s.events, &uid, &target.id).await?; Ok(StatusCode::NO_CONTENT) } -#[utoipa::path(post, path = "/users/{id}/block", params(("id" = uuid::Uuid, Path, description = "User ID")), responses((status = 204, description = "Blocked")), security(("bearer_auth" = [])))] +#[utoipa::path(post, path = "/users/{username}/block", params(("username" = String, Path, description = "Username")), responses((status = 204, description = "Blocked")), security(("bearer_auth" = [])))] pub async fn post_block( State(s): State, AuthUser(uid): AuthUser, @@ -76,7 +95,7 @@ pub async fn post_block( block_user(&*s.blocks, &*s.events, &uid, &target.id).await?; Ok(StatusCode::NO_CONTENT) } -#[utoipa::path(delete, path = "/users/{id}/block", params(("id" = uuid::Uuid, Path, description = "User ID")), responses((status = 204, description = "Unblocked")), security(("bearer_auth" = [])))] +#[utoipa::path(delete, path = "/users/{username}/block", params(("username" = String, Path, description = "Username")), responses((status = 204, description = "Unblocked")), security(("bearer_auth" = [])))] pub async fn delete_block( State(s): State, AuthUser(uid): AuthUser, @@ -106,3 +125,106 @@ pub async fn get_top_friends_handler( let usernames: Vec<&str> = friends.iter().map(|(_, u)| u.username.as_str()).collect(); Ok(Json(serde_json::json!({ "topFriends": usernames }))) } + +#[cfg(test)] +mod tests { + use super::*; + use async_trait::async_trait; + use axum::{ + body::Body, + http::Request, + routing::{delete, post}, + Router, + }; + use domain::{ + errors::DomainError, + ports::{AuthService, GeneratedToken, PasswordHasher}, + testing::TestStore, + value_objects::{PasswordHash, UserId}, + }; + use std::sync::Arc; + use tower::ServiceExt; + + struct NoOpAuth; + impl AuthService for NoOpAuth { + fn generate_token(&self, _uid: &UserId) -> Result { + Err(DomainError::Internal("noop".into())) + } + fn validate_token(&self, _token: &str) -> Result { + Err(DomainError::Unauthorized) + } + } + + struct NoOpHasher; + #[async_trait] + impl PasswordHasher for NoOpHasher { + async fn hash(&self, _plain: &str) -> Result { + Err(DomainError::Internal("noop".into())) + } + async fn verify(&self, _plain: &str, _hash: &PasswordHash) -> Result { + Ok(false) + } + } + + fn make_state() -> crate::state::AppState { + let store = Arc::new(TestStore::default()); + crate::state::AppState { + users: store.clone(), + thoughts: store.clone(), + likes: store.clone(), + boosts: store.clone(), + follows: store.clone(), + blocks: store.clone(), + tags: store.clone(), + api_keys: store.clone(), + top_friends: store.clone(), + notifications: store.clone(), + remote_actors: store.clone(), + feed: store.clone(), + search: store.clone(), + auth: Arc::new(NoOpAuth), + hasher: Arc::new(NoOpHasher), + events: store.clone(), + federation: store.clone(), + } + } + + fn app() -> Router { + Router::new() + .route( + "/users/{username}/follow", + post(post_follow).delete(delete_follow), + ) + .with_state(make_state()) + } + + #[tokio::test] + async fn follow_without_auth_returns_401() { + let resp = app() + .oneshot( + Request::builder() + .method("POST") + .uri("/users/alice/follow") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), 401); + } + + #[tokio::test] + async fn unfollow_remote_without_auth_returns_401() { + let resp = app() + .oneshot( + Request::builder() + .method("DELETE") + .uri("/users/alice@example.com/follow") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), 401); + } +} diff --git a/crates/presentation/src/routes.rs b/crates/presentation/src/routes.rs index fe77654..390bc36 100644 --- a/crates/presentation/src/routes.rs +++ b/crates/presentation/src/routes.rs @@ -89,13 +89,7 @@ pub fn router() -> Router { "/api-keys", get(api_keys::get_api_keys).post(api_keys::post_api_key), ) - .route("/api-keys/{id}", delete(api_keys::delete_api_key_handler)) - // federation - .route("/federation/lookup", get(federation::lookup_handler)) - .route( - "/federation/follow", - post(federation::follow_remote_handler), - ); + .route("/api-keys/{id}", delete(api_keys::delete_api_key_handler)); openapi::serve(api_routes) }