Reviewed-on: #26
This commit was merged in pull request #26.
This commit is contained in:
553
crates/bootstrap/tests/http_handlers/api_handlers_test.rs
Normal file
553
crates/bootstrap/tests/http_handlers/api_handlers_test.rs
Normal file
@@ -0,0 +1,553 @@
|
||||
use crate::support::{AcceptingAuth, test_app_state, test_app_state_with_repos};
|
||||
use axum::{
|
||||
Router,
|
||||
body::Body,
|
||||
http::{Request, StatusCode},
|
||||
routing::get,
|
||||
};
|
||||
use domain::errors::DomainError;
|
||||
use domain::ports::UserRepository;
|
||||
use domain::testing::InMemoryStore;
|
||||
use std::sync::Arc;
|
||||
use tower::ServiceExt;
|
||||
use uuid::Uuid;
|
||||
|
||||
// Custom stub for SearchPort that returns empty results instead of panicking
|
||||
struct SearchPortStub;
|
||||
#[async_trait::async_trait]
|
||||
impl domain::ports::SearchPort for SearchPortStub {
|
||||
async fn search(
|
||||
&self,
|
||||
_: &domain::models::SearchQuery,
|
||||
) -> Result<domain::models::SearchResults, DomainError> {
|
||||
Ok(domain::models::SearchResults {
|
||||
movies: domain::models::collections::Paginated {
|
||||
items: vec![],
|
||||
total_count: 0,
|
||||
limit: 10,
|
||||
offset: 0,
|
||||
},
|
||||
people: domain::models::collections::Paginated {
|
||||
items: vec![],
|
||||
total_count: 0,
|
||||
limit: 10,
|
||||
offset: 0,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Custom stub for PersonQuery that returns 404 instead of panicking
|
||||
struct PersonQueryStub;
|
||||
#[async_trait::async_trait]
|
||||
impl domain::ports::PersonQuery for PersonQueryStub {
|
||||
async fn get_by_id(
|
||||
&self,
|
||||
_: &domain::models::PersonId,
|
||||
) -> Result<Option<domain::models::Person>, DomainError> {
|
||||
Ok(None) // Return None to trigger 404
|
||||
}
|
||||
async fn get_by_external_id(
|
||||
&self,
|
||||
_: &domain::models::ExternalPersonId,
|
||||
) -> Result<Option<domain::models::Person>, DomainError> {
|
||||
Ok(None)
|
||||
}
|
||||
async fn get_credits(
|
||||
&self,
|
||||
_: &domain::models::PersonId,
|
||||
) -> Result<domain::models::PersonCredits, DomainError> {
|
||||
Err(DomainError::NotFound("Person not found".into()))
|
||||
}
|
||||
async fn list_orphaned_persons(&self) -> Result<Vec<domain::models::PersonId>, DomainError> {
|
||||
Ok(vec![])
|
||||
}
|
||||
async fn list_page(
|
||||
&self,
|
||||
_limit: u32,
|
||||
_offset: u32,
|
||||
) -> Result<Vec<domain::models::Person>, DomainError> {
|
||||
Ok(vec![])
|
||||
}
|
||||
}
|
||||
|
||||
// --- Search endpoint tests ---
|
||||
|
||||
#[tokio::test]
|
||||
async fn search_endpoint_returns_200_with_empty_results() {
|
||||
let state = crate::support::test_app_state_with_search_port(Arc::new(SearchPortStub));
|
||||
let app = Router::new()
|
||||
.route("/api/v1/search", get(http_axum::api::search::get_search))
|
||||
.with_state(state);
|
||||
|
||||
let resp = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/api/v1/search?q=test&limit=10&offset=0")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn search_endpoint_with_no_query_returns_200() {
|
||||
let state = crate::support::test_app_state_with_search_port(Arc::new(SearchPortStub));
|
||||
let app = Router::new()
|
||||
.route("/api/v1/search", get(http_axum::api::search::get_search))
|
||||
.with_state(state);
|
||||
|
||||
let resp = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/api/v1/search?q=&limit=5&offset=0")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
}
|
||||
|
||||
// --- Person endpoint tests ---
|
||||
|
||||
#[tokio::test]
|
||||
async fn person_endpoint_returns_404_for_unknown_id() {
|
||||
let state = crate::support::test_app_state_with_person_query(Arc::new(PersonQueryStub));
|
||||
let app = Router::new()
|
||||
.route(
|
||||
"/api/v1/people/{id}",
|
||||
get(http_axum::api::search::get_person_handler),
|
||||
)
|
||||
.with_state(state);
|
||||
|
||||
let unknown_id = Uuid::new_v4();
|
||||
let resp = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri(format!("/api/v1/people/{}", unknown_id))
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn person_credits_endpoint_returns_404_for_unknown_id() {
|
||||
let state = crate::support::test_app_state_with_person_query(Arc::new(PersonQueryStub));
|
||||
let app = Router::new()
|
||||
.route(
|
||||
"/api/v1/people/{id}/credits",
|
||||
get(http_axum::api::search::get_person_credits_handler),
|
||||
)
|
||||
.with_state(state);
|
||||
|
||||
let unknown_id = Uuid::new_v4();
|
||||
let resp = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri(format!("/api/v1/people/{}/credits", unknown_id))
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn injected_stub_reaches_prebuilt_deps() {
|
||||
let stub: Arc<dyn domain::ports::PersonQuery> = Arc::new(PersonQueryStub);
|
||||
let state = crate::support::test_app_state_with_person_query(Arc::clone(&stub));
|
||||
|
||||
assert!(
|
||||
Arc::ptr_eq(&state.app_ctx.deps.person.get_person.person_query, &stub),
|
||||
"prebuilt deps must hold the injected stub too, or handlers reading deps \
|
||||
will silently use the real dependency"
|
||||
);
|
||||
}
|
||||
|
||||
// --- Watchlist endpoint tests ---
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_watchlist_requires_auth() {
|
||||
let state = test_app_state();
|
||||
let app = Router::new()
|
||||
.route(
|
||||
"/api/v1/watchlist",
|
||||
get(http_axum::api::watchlist::get_watchlist_handler),
|
||||
)
|
||||
.with_state(state);
|
||||
|
||||
let resp = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/api/v1/watchlist")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
// --- Pending-follower-count endpoint tests ---
|
||||
|
||||
struct FixedPendingCount(usize);
|
||||
#[async_trait::async_trait]
|
||||
impl domain::ports::FollowGraphQuery for FixedPendingCount {
|
||||
async fn get_following(
|
||||
&self,
|
||||
_: &domain::value_objects::UserId,
|
||||
) -> Result<Vec<domain::value_objects::SocialActor>, DomainError> {
|
||||
unreachable!()
|
||||
}
|
||||
async fn get_followers(
|
||||
&self,
|
||||
_: &domain::value_objects::UserId,
|
||||
) -> Result<Vec<domain::value_objects::SocialActor>, DomainError> {
|
||||
unreachable!()
|
||||
}
|
||||
async fn get_pending_followers(
|
||||
&self,
|
||||
_: &domain::value_objects::UserId,
|
||||
) -> Result<Vec<domain::value_objects::SocialActor>, DomainError> {
|
||||
unreachable!()
|
||||
}
|
||||
async fn get_pending_following(
|
||||
&self,
|
||||
_: &domain::value_objects::UserId,
|
||||
) -> Result<Vec<domain::value_objects::SocialActor>, DomainError> {
|
||||
unreachable!()
|
||||
}
|
||||
async fn count_following(
|
||||
&self,
|
||||
_: &domain::value_objects::UserId,
|
||||
) -> Result<usize, DomainError> {
|
||||
unreachable!()
|
||||
}
|
||||
async fn count_followers(
|
||||
&self,
|
||||
_: &domain::value_objects::UserId,
|
||||
) -> Result<usize, DomainError> {
|
||||
unreachable!()
|
||||
}
|
||||
async fn count_pending_followers(
|
||||
&self,
|
||||
_: &domain::value_objects::UserId,
|
||||
) -> Result<usize, DomainError> {
|
||||
Ok(self.0)
|
||||
}
|
||||
async fn get_relation(
|
||||
&self,
|
||||
_: &domain::value_objects::UserId,
|
||||
_: &domain::value_objects::SocialIdentity,
|
||||
) -> Result<domain::value_objects::FollowRelation, DomainError> {
|
||||
unreachable!()
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn pending_count_endpoint_reports_the_use_case_result() {
|
||||
let uid = domain::value_objects::UserId::from_uuid(Uuid::new_v4());
|
||||
let users = InMemoryStore::new();
|
||||
users
|
||||
.save(&domain::testing::test_user_with_role(
|
||||
uid.clone(),
|
||||
domain::models::UserRole::Standard,
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
let state = test_app_state_with_repos(
|
||||
|repos| {
|
||||
repos.follow_graph = Arc::new(FixedPendingCount(3));
|
||||
repos.user = users as _;
|
||||
},
|
||||
Arc::new(AcceptingAuth(uid)),
|
||||
);
|
||||
let app = Router::new()
|
||||
.route(
|
||||
"/api/v1/social/followers/pending/count",
|
||||
get(http_axum::api::social::get_pending_follower_count),
|
||||
)
|
||||
.with_state(state);
|
||||
|
||||
let resp = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/api/v1/social/followers/pending/count")
|
||||
.header("authorization", "Bearer test-token")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
|
||||
.await
|
||||
.unwrap();
|
||||
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
|
||||
assert_eq!(json["count"], 3);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_watchlist_status_requires_auth() {
|
||||
let state = test_app_state();
|
||||
let app = Router::new()
|
||||
.route(
|
||||
"/api/v1/watchlist/{movie_id}",
|
||||
get(http_axum::api::watchlist::get_watchlist_status),
|
||||
)
|
||||
.with_state(state);
|
||||
|
||||
let resp = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/api/v1/watchlist/00000000-0000-0000-0000-000000000001")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
// --- Federation collection error-handling tests ---
|
||||
|
||||
#[cfg(feature = "federation")]
|
||||
struct FailingApDocument;
|
||||
|
||||
#[cfg(feature = "federation")]
|
||||
#[async_trait::async_trait]
|
||||
impl domain::ports::ApDocumentPort for FailingApDocument {
|
||||
async fn actor_json(&self, _: &str) -> Result<String, DomainError> {
|
||||
Err(DomainError::InfrastructureError("boom".into()))
|
||||
}
|
||||
async fn followers_collection_json(
|
||||
&self,
|
||||
_: Uuid,
|
||||
_: Option<u32>,
|
||||
) -> Result<String, DomainError> {
|
||||
Err(DomainError::InfrastructureError("boom".into()))
|
||||
}
|
||||
async fn following_collection_json(
|
||||
&self,
|
||||
_: Uuid,
|
||||
_: Option<u32>,
|
||||
) -> Result<String, DomainError> {
|
||||
Err(DomainError::InfrastructureError("boom".into()))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "federation")]
|
||||
fn state_with_failing_ap_document() -> http_axum::state::AppState {
|
||||
let mut state = test_app_state();
|
||||
state.app_ctx.ap_document = Arc::new(FailingApDocument);
|
||||
state
|
||||
}
|
||||
|
||||
#[cfg(feature = "federation")]
|
||||
#[tokio::test]
|
||||
async fn followers_collection_returns_500_on_federation_error() {
|
||||
let app = http_axum::federation::html_routes().with_state(state_with_failing_ap_document());
|
||||
|
||||
let resp = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri(format!("/users/{}/followers", Uuid::nil()))
|
||||
.header(axum::http::header::ACCEPT, "application/activity+json")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
|
||||
#[cfg(feature = "federation")]
|
||||
#[tokio::test]
|
||||
async fn following_collection_returns_500_on_federation_error() {
|
||||
let app = http_axum::federation::html_routes().with_state(state_with_failing_ap_document());
|
||||
|
||||
let resp = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri(format!("/users/{}/following", Uuid::nil()))
|
||||
.header(axum::http::header::ACCEPT, "application/activity+json")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
|
||||
async fn refusal_body(resp: axum::response::Response) -> serde_json::Value {
|
||||
let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
|
||||
.await
|
||||
.unwrap();
|
||||
serde_json::from_slice(&bytes).expect("refusal body is JSON")
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_missing_resource_is_refused_with_not_found_code() {
|
||||
let state = crate::support::test_app_state_with_person_query(Arc::new(PersonQueryStub));
|
||||
let app = Router::new()
|
||||
.route(
|
||||
"/api/v1/people/{id}",
|
||||
get(http_axum::api::search::get_person_handler),
|
||||
)
|
||||
.with_state(state);
|
||||
|
||||
let resp = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri(format!("/api/v1/people/{}", Uuid::new_v4()))
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
|
||||
let body = refusal_body(resp).await;
|
||||
assert_eq!(body["code"], "NOT_FOUND");
|
||||
assert!(body["error"].is_string());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_missing_credential_is_refused_with_unauthorized_code() {
|
||||
let app = Router::new()
|
||||
.route(
|
||||
"/api/v1/watchlist",
|
||||
get(http_axum::api::watchlist::get_watchlist_handler),
|
||||
)
|
||||
.with_state(test_app_state());
|
||||
|
||||
let resp = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/api/v1/watchlist")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
|
||||
assert_eq!(refusal_body(resp).await["code"], "UNAUTHORIZED");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_malformed_path_parameter_is_refused_with_invalid_input_code() {
|
||||
let state = crate::support::test_app_state_with_person_query(Arc::new(PersonQueryStub));
|
||||
let app = Router::new()
|
||||
.route(
|
||||
"/api/v1/people/{id}",
|
||||
get(http_axum::api::search::get_person_handler),
|
||||
)
|
||||
.with_state(state);
|
||||
|
||||
let resp = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/api/v1/people/not-a-uuid")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
|
||||
assert_eq!(refusal_body(resp).await["code"], "INVALID_INPUT");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_malformed_json_body_is_refused_with_invalid_input_code() {
|
||||
let app = Router::new()
|
||||
.route(
|
||||
"/api/v1/auth/login",
|
||||
axum::routing::post(http_axum::api::auth::login),
|
||||
)
|
||||
.with_state(test_app_state());
|
||||
|
||||
let resp = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/v1/auth/login")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from("{not json"))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
|
||||
let body = refusal_body(resp).await;
|
||||
assert_eq!(body["code"], "INVALID_INPUT");
|
||||
assert!(body["error"].is_string());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_json_body_of_the_wrong_shape_is_refused_as_unprocessable() {
|
||||
let app = Router::new()
|
||||
.route(
|
||||
"/api/v1/auth/login",
|
||||
axum::routing::post(http_axum::api::auth::login),
|
||||
)
|
||||
.with_state(test_app_state());
|
||||
|
||||
let resp = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/v1/auth/login")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from("{}"))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY);
|
||||
assert_eq!(refusal_body(resp).await["code"], "INVALID_INPUT");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_non_multipart_upload_is_refused_with_invalid_input_code() {
|
||||
let app = Router::new()
|
||||
.route(
|
||||
"/api/v1/webhooks/plex",
|
||||
axum::routing::post(http_axum::api::webhook::post_plex_webhook),
|
||||
)
|
||||
.with_state(test_app_state());
|
||||
|
||||
let resp = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/v1/webhooks/plex?token=t")
|
||||
.header("content-type", "text/plain")
|
||||
.body(Body::from("hello"))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(resp.status().is_client_error());
|
||||
assert_eq!(refusal_body(resp).await["code"], "INVALID_INPUT");
|
||||
}
|
||||
24
crates/bootstrap/tests/http_handlers/app_context_test.rs
Normal file
24
crates/bootstrap/tests/http_handlers/app_context_test.rs
Normal file
@@ -0,0 +1,24 @@
|
||||
#[test]
|
||||
fn prebuilt_deps_share_port_instances_with_repos() {
|
||||
use std::sync::Arc;
|
||||
|
||||
let repos = crate::support::test_repositories();
|
||||
let user = Arc::clone(&repos.user);
|
||||
let follow_graph = Arc::clone(&repos.follow_graph);
|
||||
|
||||
let state =
|
||||
crate::support::build_test_state_from(repos, Arc::new(domain::testing::PanicAuthService));
|
||||
|
||||
assert!(
|
||||
Arc::ptr_eq(&state.app_ctx.deps.users.get_local_profile.user, &user),
|
||||
"deps.users.get_local_profile.user must be the same Arc as the Repositories it was built from"
|
||||
);
|
||||
assert!(
|
||||
Arc::ptr_eq(&state.app_ctx.deps.social.query.follow_graph, &follow_graph),
|
||||
"deps.social.query.follow_graph must be the same Arc as the Repositories it was built from"
|
||||
);
|
||||
assert_eq!(
|
||||
state.app_ctx.deps.users.get_local_profile.instance, state.app_ctx.instance,
|
||||
"prebuilt deps must carry the context's InstanceIdentity"
|
||||
);
|
||||
}
|
||||
408
crates/bootstrap/tests/http_handlers/extractors_test.rs
Normal file
408
crates/bootstrap/tests/http_handlers/extractors_test.rs
Normal file
@@ -0,0 +1,408 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::{
|
||||
Router,
|
||||
body::Body,
|
||||
http::{Request, StatusCode},
|
||||
routing::get,
|
||||
};
|
||||
use domain::{
|
||||
errors::DomainError, models::GeneratedToken, ports::AuthService, testing::InMemoryStore,
|
||||
value_objects::UserId,
|
||||
};
|
||||
use http_axum::extractors::{
|
||||
AdminApiUser, AdminUser, AuthenticatedUser, OptionalCookieUser, RequiredCookieUser,
|
||||
};
|
||||
use tower::ServiceExt;
|
||||
|
||||
use crate::support::{
|
||||
AcceptingAuth, ErroringUserRepo, make_test_state, test_app_state, test_app_state_with_user,
|
||||
};
|
||||
|
||||
struct RejectingAuth;
|
||||
#[async_trait::async_trait]
|
||||
impl AuthService for RejectingAuth {
|
||||
async fn generate_token(&self, _: &UserId) -> Result<GeneratedToken, DomainError> {
|
||||
panic!()
|
||||
}
|
||||
async fn validate_token(&self, _: &str) -> Result<UserId, DomainError> {
|
||||
Err(DomainError::Unauthorized("bad token".into()))
|
||||
}
|
||||
}
|
||||
|
||||
// --- Routers ---
|
||||
|
||||
async fn protected_handler(user: AuthenticatedUser) -> String {
|
||||
user.0.value().to_string()
|
||||
}
|
||||
async fn optional_cookie_handler(user: OptionalCookieUser) -> String {
|
||||
match user.0 {
|
||||
Some(id) => id.value().to_string(),
|
||||
None => "none".to_string(),
|
||||
}
|
||||
}
|
||||
async fn required_cookie_handler(user: RequiredCookieUser) -> String {
|
||||
user.0.value().to_string()
|
||||
}
|
||||
async fn admin_api_handler(user: AdminApiUser) -> String {
|
||||
user.0.value().to_string()
|
||||
}
|
||||
async fn admin_cookie_handler(user: AdminUser) -> String {
|
||||
user.0.value().to_string()
|
||||
}
|
||||
|
||||
fn router_protected(state: http_axum::state::AppState) -> Router {
|
||||
Router::new()
|
||||
.route("/protected", get(protected_handler))
|
||||
.with_state(state)
|
||||
}
|
||||
fn router_optional(state: http_axum::state::AppState) -> Router {
|
||||
Router::new()
|
||||
.route("/optional", get(optional_cookie_handler))
|
||||
.with_state(state)
|
||||
}
|
||||
fn router_admin_api(state: http_axum::state::AppState) -> Router {
|
||||
Router::new()
|
||||
.route("/admin-api", get(admin_api_handler))
|
||||
.with_state(state)
|
||||
}
|
||||
fn router_admin_cookie(state: http_axum::state::AppState) -> Router {
|
||||
Router::new()
|
||||
.route("/admin-cookie", get(admin_cookie_handler))
|
||||
.with_state(state)
|
||||
}
|
||||
fn router_required(state: http_axum::state::AppState) -> Router {
|
||||
Router::new()
|
||||
.route("/required", get(required_cookie_handler))
|
||||
.with_state(state)
|
||||
}
|
||||
|
||||
// --- Tests ---
|
||||
|
||||
#[tokio::test]
|
||||
async fn missing_auth_header_returns_401() {
|
||||
let app = router_protected(test_app_state());
|
||||
let resp = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/protected")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn optional_cookie_user_returns_none_without_cookie() {
|
||||
let app = router_optional(test_app_state());
|
||||
let resp = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/optional")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(&body[..], b"none");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn optional_cookie_user_returns_none_with_invalid_token() {
|
||||
let app = router_optional(make_test_state(Arc::new(RejectingAuth)));
|
||||
let resp = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/optional")
|
||||
.header("cookie", "token=bad.token.here")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(&body[..], b"none");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn required_cookie_user_redirects_without_cookie() {
|
||||
let app = router_required(test_app_state());
|
||||
let resp = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/required")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::SEE_OTHER);
|
||||
assert_eq!(resp.headers().get("location").unwrap(), "/login");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn required_cookie_user_redirects_with_invalid_token() {
|
||||
let app = router_required(make_test_state(Arc::new(RejectingAuth)));
|
||||
let resp = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/required")
|
||||
.header("cookie", "token=bad.token.here")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::SEE_OTHER);
|
||||
assert_eq!(resp.headers().get("location").unwrap(), "/login");
|
||||
}
|
||||
|
||||
// --- AdminApiUser / AdminUser: per-path rejection pinning ---
|
||||
//
|
||||
// These pin the exact rejection for every path `authorize_admin::execute` can
|
||||
// produce, now that both extractors go through it instead of calling
|
||||
// `repos.user.find_by_id` directly. Each test name states the path; the
|
||||
// asserted status is the byte-identical-to-before-this-task value recorded in
|
||||
// the task-4 report's before/after table.
|
||||
|
||||
fn admin_id() -> UserId {
|
||||
UserId::generate()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn admin_api_user_allows_an_admin_user() {
|
||||
let id = admin_id();
|
||||
let user_repo = InMemoryStore::new();
|
||||
user_repo.seed_user(domain::testing::test_user_with_role(
|
||||
id.clone(),
|
||||
domain::models::UserRole::Admin,
|
||||
));
|
||||
let app = router_admin_api(test_app_state_with_user(
|
||||
user_repo as _,
|
||||
Arc::new(AcceptingAuth(id)),
|
||||
));
|
||||
let resp = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/admin-api")
|
||||
.header("authorization", "Bearer anything")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn admin_api_user_rejects_a_non_admin_user() {
|
||||
let id = admin_id();
|
||||
let user_repo = InMemoryStore::new();
|
||||
user_repo.seed_user(domain::testing::test_user_with_role(
|
||||
id.clone(),
|
||||
domain::models::UserRole::Standard,
|
||||
));
|
||||
let app = router_admin_api(test_app_state_with_user(
|
||||
user_repo as _,
|
||||
Arc::new(AcceptingAuth(id)),
|
||||
));
|
||||
let resp = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/admin-api")
|
||||
.header("authorization", "Bearer anything")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn admin_api_user_rejects_an_unknown_user() {
|
||||
let id = admin_id();
|
||||
let user_repo = InMemoryStore::new(); // empty — id is not in the store
|
||||
let app = router_admin_api(test_app_state_with_user(
|
||||
user_repo as _,
|
||||
Arc::new(AcceptingAuth(id)),
|
||||
));
|
||||
let resp = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/admin-api")
|
||||
.header("authorization", "Bearer anything")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn admin_api_user_surfaces_a_repository_error_as_500() {
|
||||
let id = admin_id();
|
||||
let app = router_admin_api(test_app_state_with_user(
|
||||
Arc::new(ErroringUserRepo),
|
||||
Arc::new(AcceptingAuth(id)),
|
||||
));
|
||||
let resp = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/admin-api")
|
||||
.header("authorization", "Bearer anything")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn admin_user_allows_an_admin_user() {
|
||||
let id = admin_id();
|
||||
let user_repo = InMemoryStore::new();
|
||||
user_repo.seed_user(domain::testing::test_user_with_role(
|
||||
id.clone(),
|
||||
domain::models::UserRole::Admin,
|
||||
));
|
||||
let app = router_admin_cookie(test_app_state_with_user(
|
||||
user_repo as _,
|
||||
Arc::new(AcceptingAuth(id)),
|
||||
));
|
||||
let resp = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/admin-cookie")
|
||||
.header("cookie", "token=anything")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn admin_user_rejects_a_non_admin_user() {
|
||||
let id = admin_id();
|
||||
let user_repo = InMemoryStore::new();
|
||||
user_repo.seed_user(domain::testing::test_user_with_role(
|
||||
id.clone(),
|
||||
domain::models::UserRole::Standard,
|
||||
));
|
||||
let app = router_admin_cookie(test_app_state_with_user(
|
||||
user_repo as _,
|
||||
Arc::new(AcceptingAuth(id)),
|
||||
));
|
||||
let resp = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/admin-cookie")
|
||||
.header("cookie", "token=anything")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn admin_user_rejects_an_unknown_user() {
|
||||
let id = admin_id();
|
||||
let user_repo = InMemoryStore::new(); // empty — id is not in the store
|
||||
let app = router_admin_cookie(test_app_state_with_user(
|
||||
user_repo as _,
|
||||
Arc::new(AcceptingAuth(id)),
|
||||
));
|
||||
let resp = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/admin-cookie")
|
||||
.header("cookie", "token=anything")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::SEE_OTHER);
|
||||
assert_eq!(resp.headers().get("location").unwrap(), "/login");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn admin_user_surfaces_a_repository_error_as_500() {
|
||||
let id = admin_id();
|
||||
let app = router_admin_cookie(test_app_state_with_user(
|
||||
Arc::new(ErroringUserRepo),
|
||||
Arc::new(AcceptingAuth(id)),
|
||||
));
|
||||
let resp = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/admin-cookie")
|
||||
.header("cookie", "token=anything")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
|
||||
async fn path_id_handler(
|
||||
http_axum::extractors::PathId(id): http_axum::extractors::PathId<u32>,
|
||||
) -> String {
|
||||
id.to_string()
|
||||
}
|
||||
|
||||
async fn refusal_code(resp: axum::response::Response) -> String {
|
||||
let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
|
||||
.await
|
||||
.unwrap();
|
||||
let v: serde_json::Value = serde_json::from_slice(&bytes).expect("refusal body is JSON");
|
||||
v["code"].as_str().unwrap().to_string()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn path_extractor_on_a_route_without_params_is_refused_as_internal() {
|
||||
let app = Router::new().route("/no-params", get(path_id_handler));
|
||||
let resp = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/no-params")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR);
|
||||
assert_eq!(refusal_code(resp).await, "INTERNAL");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn path_extractor_with_an_unparseable_param_is_refused_as_invalid_input() {
|
||||
let app = Router::new().route("/items/{id}", get(path_id_handler));
|
||||
let resp = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/items/abc")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
|
||||
assert_eq!(refusal_code(resp).await, "INVALID_INPUT");
|
||||
}
|
||||
259
crates/bootstrap/tests/http_handlers/support.rs
Normal file
259
crates/bootstrap/tests/http_handlers/support.rs
Normal file
@@ -0,0 +1,259 @@
|
||||
#![allow(dead_code)]
|
||||
|
||||
use application::config::AppSettings;
|
||||
use bootstrap::Repositories;
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
models::GeneratedToken,
|
||||
ports::{AuthService, PersonQuery, SearchPort, UserRepository},
|
||||
testing::{
|
||||
PanicAuthService, PanicDiaryExporter, PanicDiaryQuery, PanicDocumentParser,
|
||||
PanicEventPublisher, PanicGoalCommand, PanicGoalQuery, PanicImportProfileRepository,
|
||||
PanicImportSessionRepository, PanicInsightsQuery, PanicMetadataClient, PanicMovieCommand,
|
||||
PanicMovieProfileRepository, PanicMovieQuery, PanicObjectStorage, PanicPasswordHasher,
|
||||
PanicPersonCommand, PanicPersonQuery, PanicPosterFetcher, PanicProfileFieldsRepo,
|
||||
PanicRefreshSessionRepository, PanicRemoteGoalRepository, PanicRemoteWatchlistRepository,
|
||||
PanicReviewRepository, PanicRssFeedRenderer, PanicSearchCommand, PanicSearchPort,
|
||||
PanicStatsRepository, PanicUserRepository, PanicUserSettingsRepository,
|
||||
PanicWatchEventCommand, PanicWatchEventQuery, PanicWatchlistRepository,
|
||||
PanicWebhookTokenRepository, PanicWrapUpRepository, PanicWrapUpStatsQuery,
|
||||
},
|
||||
value_objects::UserId,
|
||||
};
|
||||
use http_axum::context::AppContext;
|
||||
use std::sync::Arc;
|
||||
|
||||
struct PanicReviewLogger;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl application::ports::ReviewLogger for PanicReviewLogger {
|
||||
async fn log_review(
|
||||
&self,
|
||||
_: application::diary::commands::LogReviewCommand,
|
||||
) -> Result<(), DomainError> {
|
||||
panic!()
|
||||
}
|
||||
}
|
||||
|
||||
// --- Repositories factory — `Panic*` stubs everywhere ---
|
||||
|
||||
pub fn test_repositories() -> Repositories {
|
||||
Repositories {
|
||||
movie_command: Arc::new(PanicMovieCommand) as _,
|
||||
movie_query: Arc::new(PanicMovieQuery) as _,
|
||||
review: Arc::new(PanicReviewRepository) as _,
|
||||
diary: Arc::new(PanicDiaryQuery) as _,
|
||||
stats: Arc::new(PanicStatsRepository) as _,
|
||||
user: Arc::new(PanicUserRepository) as _,
|
||||
import_session: Arc::new(PanicImportSessionRepository) as _,
|
||||
import_profile: Arc::new(PanicImportProfileRepository) as _,
|
||||
movie_profile: Arc::new(PanicMovieProfileRepository) as _,
|
||||
watchlist: Arc::new(PanicWatchlistRepository) as _,
|
||||
watch_event_command: Arc::new(PanicWatchEventCommand) as _,
|
||||
watch_event_query: Arc::new(PanicWatchEventQuery) as _,
|
||||
webhook_token: Arc::new(PanicWebhookTokenRepository) as _,
|
||||
profile_fields: Arc::new(PanicProfileFieldsRepo) as _,
|
||||
person_command: Arc::new(PanicPersonCommand) as _,
|
||||
person_query: Arc::new(PanicPersonQuery) as _,
|
||||
search_port: Arc::new(PanicSearchPort) as _,
|
||||
search_command: Arc::new(PanicSearchCommand) as _,
|
||||
remote_watchlist: Arc::new(PanicRemoteWatchlistRepository) as _,
|
||||
social_command: Arc::new(domain::ports::noop::NoopSocialCommand) as _,
|
||||
follow_graph: Arc::new(domain::ports::noop::NoopSocialQuery) as _,
|
||||
block_query: Arc::new(domain::ports::noop::NoopSocialQuery) as _,
|
||||
federation_admin: Arc::new(domain::ports::noop::NoopFederationAdminQuery) as _,
|
||||
wrapup_stats: Arc::new(PanicWrapUpStatsQuery) as _,
|
||||
wrapup_repo: Arc::new(PanicWrapUpRepository) as _,
|
||||
insights: Arc::new(PanicInsightsQuery) as _,
|
||||
goal_command: Arc::new(PanicGoalCommand) as _,
|
||||
goal_query: Arc::new(PanicGoalQuery) as _,
|
||||
user_settings: Arc::new(PanicUserSettingsRepository) as _,
|
||||
remote_goal: Arc::new(PanicRemoteGoalRepository) as _,
|
||||
refresh_session: Arc::new(PanicRefreshSessionRepository) as _,
|
||||
federated_profile: None,
|
||||
}
|
||||
}
|
||||
|
||||
// --- Single state factory — builds `deps` from whatever `repos` it is given ---
|
||||
|
||||
/// Builds an `AppState` from caller-supplied `repos`, running `bootstrap::build_deps`
|
||||
/// AFTER any overrides the caller already applied to `repos`. This is the only place
|
||||
/// `deps` gets built for tests, so `repos` and `deps` can never disagree.
|
||||
pub fn build_test_state_from(
|
||||
repos: Repositories,
|
||||
auth_service: Arc<dyn AuthService>,
|
||||
) -> http_axum::state::AppState {
|
||||
let services = application::Services {
|
||||
auth: auth_service,
|
||||
password_hasher: Arc::new(PanicPasswordHasher) as _,
|
||||
metadata: Arc::new(PanicMetadataClient) as _,
|
||||
poster_fetcher: Arc::new(PanicPosterFetcher) as _,
|
||||
object_storage: Arc::new(PanicObjectStorage) as _,
|
||||
event_publisher: Arc::new(PanicEventPublisher) as _,
|
||||
diary_exporter: Arc::new(PanicDiaryExporter) as _,
|
||||
document_parser: Arc::new(PanicDocumentParser) as _,
|
||||
review_logger: Arc::new(PanicReviewLogger) as _,
|
||||
person_enrichment: None,
|
||||
};
|
||||
|
||||
let config = AppSettings {
|
||||
allow_registration: false,
|
||||
base_url: "http://localhost:3000".to_string(),
|
||||
rate_limit: 20,
|
||||
refresh_ttl_seconds: 2_592_000,
|
||||
secure_cookies: false,
|
||||
cors_origins: String::new(),
|
||||
wrapup: application::config::WrapUpSettings {
|
||||
font_path: None,
|
||||
logo_path: None,
|
||||
bg_dir: None,
|
||||
},
|
||||
};
|
||||
|
||||
let instance = domain::value_objects::InstanceIdentity::new("http://localhost:3000");
|
||||
|
||||
let deps = Arc::new(bootstrap::build_deps(&repos, &services, &config, &instance));
|
||||
|
||||
http_axum::state::AppState {
|
||||
app_ctx: AppContext {
|
||||
deps,
|
||||
services,
|
||||
config,
|
||||
instance,
|
||||
#[cfg(feature = "federation")]
|
||||
ap_document: Arc::new(domain::ports::noop::NoopApDocument),
|
||||
#[cfg(feature = "federation")]
|
||||
ap_blocklist: Arc::new(domain::ports::noop::NoopInstanceBlocklist),
|
||||
},
|
||||
rss_renderer: Arc::new(PanicRssFeedRenderer),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn make_test_state(auth_service: Arc<dyn AuthService>) -> http_axum::state::AppState {
|
||||
build_test_state_from(test_repositories(), auth_service)
|
||||
}
|
||||
|
||||
/// Reusable default test `AppState`: `Panic*` stubs everywhere, real prebuilt `deps`.
|
||||
/// Callers that need a specific stub swapped in should use one of the
|
||||
/// `test_app_state_with_*` constructors below, which inject BEFORE `build_deps`
|
||||
/// runs — mutating the returned `AppState` afterwards would only reach `repos`,
|
||||
/// not the prebuilt `deps`.
|
||||
pub fn test_app_state() -> http_axum::state::AppState {
|
||||
make_test_state(Arc::new(PanicAuthService))
|
||||
}
|
||||
|
||||
/// Test state with `person_query` overridden BEFORE `build_deps` runs, so the
|
||||
/// stub is visible from both `app_ctx.repos.person_query` and every prebuilt
|
||||
/// deps struct that carries a `person_query` (e.g. `deps.person.get_person`).
|
||||
pub fn test_app_state_with_person_query(
|
||||
person_query: Arc<dyn PersonQuery>,
|
||||
) -> http_axum::state::AppState {
|
||||
let mut repos = test_repositories();
|
||||
repos.person_query = person_query;
|
||||
build_test_state_from(repos, Arc::new(PanicAuthService))
|
||||
}
|
||||
|
||||
/// Test state with `search_port` overridden BEFORE `build_deps` runs, so the
|
||||
/// stub is visible from both `app_ctx.repos.search_port` and any prebuilt deps
|
||||
/// struct that carries a `search_port`.
|
||||
pub fn test_app_state_with_search_port(
|
||||
search_port: Arc<dyn SearchPort>,
|
||||
) -> http_axum::state::AppState {
|
||||
let mut repos = test_repositories();
|
||||
repos.search_port = search_port;
|
||||
build_test_state_from(repos, Arc::new(PanicAuthService))
|
||||
}
|
||||
|
||||
pub fn test_app_state_with_repos(
|
||||
adjust: impl FnOnce(&mut Repositories),
|
||||
auth_service: Arc<dyn AuthService>,
|
||||
) -> http_axum::state::AppState {
|
||||
let mut repos = test_repositories();
|
||||
adjust(&mut repos);
|
||||
build_test_state_from(repos, auth_service)
|
||||
}
|
||||
|
||||
/// Test state with `user` overridden BEFORE `build_deps` runs, so the stub is
|
||||
/// visible from both `app_ctx.repos.user` and `deps.users.authorize_admin.user`
|
||||
/// — the field the `AdminApiUser`/`AdminUser` extractors now read through.
|
||||
pub fn test_app_state_with_user(
|
||||
user: Arc<dyn UserRepository>,
|
||||
auth_service: Arc<dyn AuthService>,
|
||||
) -> http_axum::state::AppState {
|
||||
let mut repos = test_repositories();
|
||||
repos.user = user;
|
||||
build_test_state_from(repos, auth_service)
|
||||
}
|
||||
|
||||
/// A `UserRepository` whose `find_by_id` always fails, for exercising the
|
||||
/// repository-error rejection path of the admin extractors. Every other
|
||||
/// method panics — the admin extractors only ever call `find_by_id`.
|
||||
pub struct ErroringUserRepo;
|
||||
#[async_trait::async_trait]
|
||||
impl UserRepository for ErroringUserRepo {
|
||||
async fn find_by_email(
|
||||
&self,
|
||||
_: &domain::value_objects::Email,
|
||||
) -> Result<Option<domain::models::User>, DomainError> {
|
||||
panic!()
|
||||
}
|
||||
async fn save(&self, _: &domain::models::User) -> Result<(), DomainError> {
|
||||
panic!()
|
||||
}
|
||||
async fn find_by_id(&self, _: &UserId) -> Result<Option<domain::models::User>, DomainError> {
|
||||
Err(DomainError::InfrastructureError("db down".into()))
|
||||
}
|
||||
async fn find_by_username(
|
||||
&self,
|
||||
_: &domain::value_objects::Username,
|
||||
) -> Result<Option<domain::models::User>, DomainError> {
|
||||
panic!()
|
||||
}
|
||||
async fn list_with_stats(&self) -> Result<Vec<domain::models::UserSummary>, DomainError> {
|
||||
panic!()
|
||||
}
|
||||
async fn update_profile(
|
||||
&self,
|
||||
_: &UserId,
|
||||
_: &domain::models::UserProfile,
|
||||
_: &[domain::events::DomainEvent],
|
||||
) -> Result<(), DomainError> {
|
||||
panic!()
|
||||
}
|
||||
async fn mark_deleted(
|
||||
&self,
|
||||
_: &UserId,
|
||||
_: &[domain::events::DomainEvent],
|
||||
) -> Result<(), DomainError> {
|
||||
panic!()
|
||||
}
|
||||
async fn find_tombstoned_by_id(
|
||||
&self,
|
||||
_: &UserId,
|
||||
) -> Result<Option<domain::models::User>, DomainError> {
|
||||
panic!()
|
||||
}
|
||||
async fn is_username_retired(
|
||||
&self,
|
||||
_: &domain::value_objects::Username,
|
||||
) -> Result<bool, DomainError> {
|
||||
panic!()
|
||||
}
|
||||
async fn count_active_admins(&self) -> Result<u64, DomainError> {
|
||||
panic!()
|
||||
}
|
||||
}
|
||||
|
||||
/// Auth stub that accepts any bearer token and resolves it to a fixed user id,
|
||||
/// for tests that need to authenticate as a specific user rather than merely
|
||||
/// exercise the unauthenticated-request path.
|
||||
pub struct AcceptingAuth(pub UserId);
|
||||
#[async_trait::async_trait]
|
||||
impl AuthService for AcceptingAuth {
|
||||
async fn generate_token(&self, _: &UserId) -> Result<GeneratedToken, DomainError> {
|
||||
panic!()
|
||||
}
|
||||
async fn validate_token(&self, _: &str) -> Result<UserId, DomainError> {
|
||||
Ok(self.0.clone())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user