Reviewed-on: #26
This commit was merged in pull request #26.
This commit is contained in:
404
crates/bootstrap/tests/account_deletion_test.rs
Normal file
404
crates/bootstrap/tests/account_deletion_test.rs
Normal file
@@ -0,0 +1,404 @@
|
||||
mod support;
|
||||
|
||||
use axum::{
|
||||
body::Body,
|
||||
http::{Method, Request, header},
|
||||
};
|
||||
use support::{PASSWORD, Session, get, json_request, send};
|
||||
|
||||
async fn promote(pool: &sqlx::SqlitePool, user_id: &str) {
|
||||
sqlx::query("UPDATE users SET role = 'admin' WHERE id = ?")
|
||||
.bind(user_id)
|
||||
.execute(pool)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
fn delete_json(uri: &str, token: &str, password: &str) -> Request<Body> {
|
||||
json_request(
|
||||
Method::DELETE,
|
||||
uri,
|
||||
Some(token),
|
||||
serde_json::json!({ "password": password }),
|
||||
)
|
||||
}
|
||||
|
||||
fn classic_post(uri: &str, cookie: &str, password: &str) -> Request<Body> {
|
||||
let csrf = cookie
|
||||
.split(';')
|
||||
.find_map(|c| c.trim().strip_prefix("csrf="))
|
||||
.unwrap();
|
||||
Request::builder()
|
||||
.method(Method::POST)
|
||||
.uri(uri)
|
||||
.header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
|
||||
.header(header::COOKIE, cookie)
|
||||
.body(Body::from(format!("password={password}&_csrf={csrf}")))
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn location(headers: &axum::http::HeaderMap) -> String {
|
||||
headers
|
||||
.get(header::LOCATION)
|
||||
.map(|v| v.to_str().unwrap().to_string())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
async fn two_users() -> (axum::Router, sqlx::SqlitePool, Session, Session) {
|
||||
let (router, pool) = support::server().await;
|
||||
let alice = support::register(&router, "alice", "alice@example.com").await;
|
||||
let bob = support::register(&router, "bob", "bob@example.com").await;
|
||||
(router, pool, alice, bob)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn self_delete_with_a_wrong_password_is_refused_and_keeps_the_account() {
|
||||
let (router, _pool, alice, _bob) = two_users().await;
|
||||
let (status, _, body) = send(
|
||||
&router,
|
||||
delete_json("/api/v1/users/me", &alice.token, "nope"),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, 401, "{body}");
|
||||
assert!(body.contains("\"INVALID_CREDENTIALS\""), "{body}");
|
||||
let (status, _, _) = send(&router, get(&format!("/users/{}", alice.user_id), None)).await;
|
||||
assert_eq!(status, 200);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn self_delete_removes_the_account_and_revokes_the_token() {
|
||||
let (router, _pool, alice, _bob) = two_users().await;
|
||||
let (status, _, body) = send(
|
||||
&router,
|
||||
delete_json("/api/v1/users/me", &alice.token, PASSWORD),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, 204, "{body}");
|
||||
|
||||
let req = Request::builder()
|
||||
.uri("/api/v1/profile")
|
||||
.header(header::AUTHORIZATION, format!("Bearer {}", alice.token))
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
let (status, _, _) = send(&router, req).await;
|
||||
assert_eq!(status, 401, "token of a deleted account");
|
||||
|
||||
for uri in [
|
||||
format!("/users/{}", alice.user_id),
|
||||
format!("/users/{}?embed=true", alice.user_id),
|
||||
] {
|
||||
let (status, _, _) = send(&router, get(&uri, None)).await;
|
||||
assert_eq!(status, 404, "{uri}");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_standard_user_cannot_delete_someone_else() {
|
||||
let (router, _pool, alice, bob) = two_users().await;
|
||||
let uri = format!("/api/v1/users/{}", bob.user_id);
|
||||
let (status, _, body) = send(&router, delete_json(&uri, &alice.token, PASSWORD)).await;
|
||||
assert_eq!(status, 403, "{body}");
|
||||
let (status, _, _) = send(&router, get(&format!("/users/{}", bob.user_id), None)).await;
|
||||
assert_eq!(status, 200);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn an_admin_deletes_another_account() {
|
||||
let (router, pool, alice, bob) = two_users().await;
|
||||
promote(&pool, &alice.user_id).await;
|
||||
let uri = format!("/api/v1/users/{}", bob.user_id);
|
||||
|
||||
let (status, _, body) = send(&router, delete_json(&uri, &alice.token, "nope")).await;
|
||||
assert_eq!(status, 401, "wrong admin password");
|
||||
assert!(body.contains("\"INVALID_CREDENTIALS\""), "{body}");
|
||||
|
||||
let (status, _, body) = send(&router, delete_json(&uri, &alice.token, PASSWORD)).await;
|
||||
assert_eq!(status, 204, "{body}");
|
||||
|
||||
for uri in [
|
||||
format!("/users/{}", bob.user_id),
|
||||
format!("/users/{}?embed=true", bob.user_id),
|
||||
] {
|
||||
let (status, _, _) = send(&router, get(&uri, None)).await;
|
||||
assert_eq!(status, 404, "{uri}");
|
||||
}
|
||||
let (status, _, _) = send(&router, delete_json(&uri, &alice.token, PASSWORD)).await;
|
||||
assert_eq!(status, 404, "already deleted");
|
||||
|
||||
#[cfg(feature = "federation")]
|
||||
{
|
||||
let req = Request::builder()
|
||||
.uri(format!("/users/{}", bob.user_id))
|
||||
.header(header::ACCEPT, "application/activity+json")
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
let (status, _, _) = send(&router, req).await;
|
||||
assert_eq!(status, 410, "actor endpoint of a deleted account");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn the_last_admin_cannot_be_deleted() {
|
||||
let (router, pool, alice, _bob) = two_users().await;
|
||||
promote(&pool, &alice.user_id).await;
|
||||
let (status, _, body) = send(
|
||||
&router,
|
||||
delete_json("/api/v1/users/me", &alice.token, PASSWORD),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, 409, "{body}");
|
||||
let (status, _, _) = send(&router, get(&format!("/users/{}", alice.user_id), None)).await;
|
||||
assert_eq!(status, 200);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn classic_self_delete_logs_out_and_redirects_home() {
|
||||
let (router, _pool, alice, _bob) = two_users().await;
|
||||
|
||||
let (status, headers, _) = send(
|
||||
&router,
|
||||
classic_post("/settings/account/delete", &alice.cookie, "nope"),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, 303);
|
||||
assert!(
|
||||
location(&headers).starts_with("/settings/profile?error="),
|
||||
"{headers:?}"
|
||||
);
|
||||
assert!(
|
||||
support::cookie_value(&headers, "token").is_none(),
|
||||
"a wrong password must not log out"
|
||||
);
|
||||
|
||||
let (status, headers, _) = send(
|
||||
&router,
|
||||
classic_post("/settings/account/delete", &alice.cookie, PASSWORD),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, 303);
|
||||
assert_eq!(location(&headers), "/");
|
||||
assert_eq!(
|
||||
support::cookie_value(&headers, "token").as_deref(),
|
||||
Some("")
|
||||
);
|
||||
|
||||
let (status, _, _) = send(&router, get(&format!("/users/{}", alice.user_id), None)).await;
|
||||
assert_eq!(status, 404);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn classic_self_delete_requires_the_csrf_token() {
|
||||
let (router, _pool, alice, _bob) = two_users().await;
|
||||
let req = Request::builder()
|
||||
.method(Method::POST)
|
||||
.uri("/settings/account/delete")
|
||||
.header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
|
||||
.header(header::COOKIE, &alice.cookie)
|
||||
.body(Body::from(format!("password={PASSWORD}&_csrf=wrong")))
|
||||
.unwrap();
|
||||
let (status, _, _) = send(&router, req).await;
|
||||
assert_eq!(status, 403);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn classic_admin_delete_redirects_to_the_member_list() {
|
||||
let (router, pool, alice, bob) = two_users().await;
|
||||
let uri = format!("/users/{}/delete", bob.user_id);
|
||||
|
||||
let (status, headers, _) = send(&router, classic_post(&uri, &alice.cookie, PASSWORD)).await;
|
||||
assert_eq!(status, 403, "non-admin: {headers:?}");
|
||||
|
||||
promote(&pool, &alice.user_id).await;
|
||||
let (status, headers, _) = send(&router, classic_post(&uri, &alice.cookie, PASSWORD)).await;
|
||||
assert_eq!(status, 303);
|
||||
assert_eq!(location(&headers), "/users");
|
||||
assert!(support::cookie_value(&headers, "token").is_none());
|
||||
|
||||
let (status, _, _) = send(&router, get(&format!("/users/{}", bob.user_id), None)).await;
|
||||
assert_eq!(status, 404);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn classic_admin_delete_requires_the_csrf_token() {
|
||||
let (router, pool, alice, bob) = two_users().await;
|
||||
promote(&pool, &alice.user_id).await;
|
||||
let req = Request::builder()
|
||||
.method(Method::POST)
|
||||
.uri(format!("/users/{}/delete", bob.user_id))
|
||||
.header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
|
||||
.header(header::COOKIE, &alice.cookie)
|
||||
.body(Body::from(format!("password={PASSWORD}&_csrf=wrong")))
|
||||
.unwrap();
|
||||
let (status, _, _) = send(&router, req).await;
|
||||
assert_eq!(status, 403);
|
||||
let (status, _, _) = send(&router, get(&format!("/users/{}", bob.user_id), None)).await;
|
||||
assert_eq!(status, 200, "target must survive a CSRF rejection");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_non_admin_classic_delete_of_someone_else_is_forbidden() {
|
||||
let (router, _pool, alice, bob) = two_users().await;
|
||||
let uri = format!("/users/{}/delete", bob.user_id);
|
||||
let (status, _, _) = send(&router, classic_post(&uri, &alice.cookie, PASSWORD)).await;
|
||||
assert_eq!(status, 403);
|
||||
let (status, _, _) = send(&router, get(&format!("/users/{}", bob.user_id), None)).await;
|
||||
assert_eq!(status, 200);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn classic_admin_self_delete_logs_out_unless_last_admin() {
|
||||
let (router, pool, alice, bob) = two_users().await;
|
||||
promote(&pool, &alice.user_id).await;
|
||||
let uri = format!("/users/{}/delete", alice.user_id);
|
||||
|
||||
let (status, headers, _) = send(&router, classic_post(&uri, &alice.cookie, PASSWORD)).await;
|
||||
assert_eq!(status, 303);
|
||||
assert!(location(&headers).contains("error="), "{headers:?}");
|
||||
assert!(support::cookie_value(&headers, "token").is_none());
|
||||
let (status, _, _) = send(&router, get(&format!("/users/{}", alice.user_id), None)).await;
|
||||
assert_eq!(status, 200);
|
||||
|
||||
promote(&pool, &bob.user_id).await;
|
||||
let (status, headers, _) = send(&router, classic_post(&uri, &alice.cookie, PASSWORD)).await;
|
||||
assert_eq!(status, 303);
|
||||
assert_eq!(location(&headers), "/");
|
||||
assert_eq!(
|
||||
support::cookie_value(&headers, "token").as_deref(),
|
||||
Some("")
|
||||
);
|
||||
let (status, _, _) = send(&router, get(&format!("/users/{}", alice.user_id), None)).await;
|
||||
assert_eq!(status, 404);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn delete_forms_render_only_where_they_apply() {
|
||||
let (router, pool, alice, bob) = two_users().await;
|
||||
let own_delete = "action=\"/settings/account/delete\"";
|
||||
let user_delete = |id: &str| format!("action=\"/users/{id}/delete\"");
|
||||
|
||||
let (_, _, body) = send(&router, get("/settings/profile", Some(&alice.cookie))).await;
|
||||
assert!(body.contains(own_delete), "owner settings page");
|
||||
|
||||
let bob_page = format!("/users/{}", bob.user_id);
|
||||
let (_, _, body) = send(&router, get(&bob_page, None)).await;
|
||||
assert!(!body.contains(&user_delete(&bob.user_id)), "anonymous");
|
||||
let (_, _, body) = send(&router, get(&bob_page, Some(&alice.cookie))).await;
|
||||
assert!(!body.contains(&user_delete(&bob.user_id)), "non-admin");
|
||||
|
||||
promote(&pool, &alice.user_id).await;
|
||||
let (_, _, body) = send(&router, get(&bob_page, Some(&alice.cookie))).await;
|
||||
assert!(body.contains(&user_delete(&bob.user_id)), "admin on other");
|
||||
|
||||
let (_, _, body) = send(
|
||||
&router,
|
||||
get(&format!("/users/{}", alice.user_id), Some(&alice.cookie)),
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
!body.contains(&user_delete(&alice.user_id)),
|
||||
"admin on self"
|
||||
);
|
||||
|
||||
let (_, _, body) = send(
|
||||
&router,
|
||||
get(&format!("{bob_page}?embed=true"), Some(&alice.cookie)),
|
||||
)
|
||||
.await;
|
||||
assert!(!body.contains("/delete"), "embed page");
|
||||
}
|
||||
|
||||
const SERVICE_ACTOR: &str = "00000000-0000-4000-8000-000000000000";
|
||||
|
||||
#[tokio::test]
|
||||
async fn the_service_actor_is_not_deletable_and_offers_no_delete_ui() {
|
||||
let (router, pool, alice, bob) = two_users().await;
|
||||
promote(&pool, &alice.user_id).await;
|
||||
promote(&pool, &bob.user_id).await;
|
||||
|
||||
let page = format!("/users/{SERVICE_ACTOR}");
|
||||
let (status, _, body) = send(&router, get(&page, Some(&alice.cookie))).await;
|
||||
assert_eq!(status, 200, "{body}");
|
||||
assert!(
|
||||
!body.contains(&format!("action=\"/users/{SERVICE_ACTOR}/delete\"")),
|
||||
"admin sees a delete form for the service actor"
|
||||
);
|
||||
|
||||
let uri = format!("/api/v1/users/{SERVICE_ACTOR}");
|
||||
let (status, _, body) = send(&router, delete_json(&uri, &alice.token, PASSWORD)).await;
|
||||
assert_eq!(status, 403, "{body}");
|
||||
|
||||
let req = Request::builder()
|
||||
.uri(&uri)
|
||||
.header(header::AUTHORIZATION, format!("Bearer {}", alice.token))
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
let (status, _, body) = send(&router, req).await;
|
||||
assert_eq!(status, 200, "{body}");
|
||||
assert!(body.contains("\"is_service_account\":true"), "{body}");
|
||||
|
||||
let req = Request::builder()
|
||||
.uri(format!("/api/v1/users/{}", bob.user_id))
|
||||
.header(header::AUTHORIZATION, format!("Bearer {}", alice.token))
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
let (_, _, body) = send(&router, req).await;
|
||||
assert!(!body.contains("is_service_account"), "{body}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_deleted_users_webhook_token_stops_ingesting_at_once() {
|
||||
let (router, pool, alice, _bob) = two_users().await;
|
||||
let (status, _, body) = send(
|
||||
&router,
|
||||
json_request(
|
||||
Method::POST,
|
||||
"/api/v1/settings/webhook-tokens",
|
||||
Some(&alice.token),
|
||||
serde_json::json!({ "provider": "jellyfin", "label": null }),
|
||||
),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, 200, "{body}");
|
||||
let token = serde_json::from_str::<serde_json::Value>(&body).unwrap()["token"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.to_string();
|
||||
|
||||
let hook = |token: &str| {
|
||||
Request::builder()
|
||||
.method(Method::POST)
|
||||
.uri("/api/v1/webhooks/jellyfin")
|
||||
.header(header::AUTHORIZATION, format!("Bearer {token}"))
|
||||
.header(header::CONTENT_TYPE, "application/json")
|
||||
.body(Body::from(
|
||||
serde_json::json!({
|
||||
"NotificationType": "PlaybackStop", "ItemType": "Movie",
|
||||
"Name": "Heat", "Year": 1995, "PlayedToCompletion": true,
|
||||
"Provider_tmdb": "949"
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.unwrap()
|
||||
};
|
||||
let events = || async {
|
||||
sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM watch_events")
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.unwrap()
|
||||
};
|
||||
|
||||
let (status, _, body) = send(&router, hook(&token)).await;
|
||||
assert_eq!(status, 200, "live token: {body}");
|
||||
assert_eq!(events().await, 1);
|
||||
|
||||
let (status, _, _) = send(
|
||||
&router,
|
||||
delete_json("/api/v1/users/me", &alice.token, PASSWORD),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, 204);
|
||||
|
||||
let (status, _, body) = send(&router, hook(&token)).await;
|
||||
assert_eq!(status, 401, "{body}");
|
||||
assert_eq!(events().await, 1, "no event ingested after deletion");
|
||||
}
|
||||
123
crates/bootstrap/tests/account_purge_test.rs
Normal file
123
crates/bootstrap/tests/account_purge_test.rs
Normal file
@@ -0,0 +1,123 @@
|
||||
mod support;
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use domain::{events::DomainEvent, ports::UserRepository, value_objects::UserId};
|
||||
|
||||
async fn wait_for(pool: &sqlx::SqlitePool, sql: &str) -> bool {
|
||||
for _ in 0..100 {
|
||||
let n: i64 = sqlx::query_scalar(sql).fetch_one(pool).await.unwrap();
|
||||
if n > 0 {
|
||||
return true;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn the_worker_purges_a_deleted_account_and_frees_its_email() {
|
||||
let (router, pool) = support::server().await;
|
||||
let alice = support::register(&router, "alice", "alice@example.com").await;
|
||||
let id = UserId::from_uuid(uuid::Uuid::parse_str(&alice.user_id).unwrap());
|
||||
let users = sqlite::SqliteUserRepository::new(pool.clone());
|
||||
users
|
||||
.mark_deleted(
|
||||
&id,
|
||||
&[DomainEvent::UserDeleted {
|
||||
user_id: id.clone(),
|
||||
}],
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let worker = bootstrap::build_worker_on(&support::test_config(), pool.clone())
|
||||
.await
|
||||
.unwrap();
|
||||
let (stop, stopped) = tokio::sync::watch::channel(false);
|
||||
let running = tokio::spawn(worker.run(stopped));
|
||||
|
||||
let purged = wait_for(
|
||||
&pool,
|
||||
"SELECT COUNT(*) FROM users WHERE username = 'alice' AND purged_at IS NOT NULL",
|
||||
)
|
||||
.await;
|
||||
stop.send(true).unwrap();
|
||||
running.await.unwrap().unwrap();
|
||||
assert!(purged, "the worker should purge the tombstoned account");
|
||||
|
||||
let email: String = sqlx::query_scalar("SELECT email FROM users WHERE username = 'alice'")
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_ne!(email, "alice@example.com");
|
||||
let again = support::register(&router, "alice2", "alice@example.com").await;
|
||||
assert!(!again.user_id.is_empty(), "the email can register again");
|
||||
}
|
||||
|
||||
#[cfg(feature = "federation")]
|
||||
#[tokio::test]
|
||||
async fn followers_are_told_before_the_purge_and_the_keypair_survives_it() {
|
||||
let (router, pool) = support::server().await;
|
||||
let alice = support::register(&router, "alice", "alice@example.com").await;
|
||||
let id = UserId::from_uuid(uuid::Uuid::parse_str(&alice.user_id).unwrap());
|
||||
for sql in [
|
||||
"INSERT INTO ap_remote_actors (url, handle, inbox_url, fetched_at)
|
||||
VALUES ('http://127.0.0.1:1/users/ann', 'ann@127.0.0.1', 'http://127.0.0.1:1/inbox', 'now')",
|
||||
] {
|
||||
sqlx::query(sql).execute(&pool).await.unwrap();
|
||||
}
|
||||
sqlx::query(
|
||||
"INSERT INTO ap_followers (local_user_id, remote_actor_url, status, created_at)
|
||||
VALUES (?, 'http://127.0.0.1:1/users/ann', 'accepted', 'now')",
|
||||
)
|
||||
.bind(&alice.user_id)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
let users = sqlite::SqliteUserRepository::new(pool.clone());
|
||||
users
|
||||
.mark_deleted(
|
||||
&id,
|
||||
&[DomainEvent::UserDeleted {
|
||||
user_id: id.clone(),
|
||||
}],
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let worker = bootstrap::build_worker_on(&support::test_config(), pool.clone())
|
||||
.await
|
||||
.unwrap();
|
||||
let (stop, stopped) = tokio::sync::watch::channel(false);
|
||||
let running = tokio::spawn(worker.run(stopped));
|
||||
|
||||
let purged = wait_for(
|
||||
&pool,
|
||||
"SELECT COUNT(*) FROM users WHERE username = 'alice' AND purged_at IS NOT NULL",
|
||||
)
|
||||
.await;
|
||||
let enqueued = wait_for(
|
||||
&pool,
|
||||
"SELECT COUNT(*) FROM event_queue WHERE event_type = 'FederationDeliveryRequested'",
|
||||
)
|
||||
.await;
|
||||
stop.send(true).unwrap();
|
||||
running.await.unwrap().unwrap();
|
||||
assert!(purged);
|
||||
assert!(enqueued, "the Delete should be enqueued for the follower");
|
||||
|
||||
let deliveries: i64 = sqlx::query_scalar(
|
||||
"SELECT COUNT(*) FROM event_queue WHERE event_type = 'FederationDeliveryRequested'",
|
||||
)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(deliveries, 1, "the Delete was enqueued for the follower");
|
||||
let keys: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM ap_local_actors WHERE user_id = ?")
|
||||
.bind(&alice.user_id)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(keys, 1, "the keypair stays until the tombstone cleanup");
|
||||
}
|
||||
386
crates/bootstrap/tests/api_test.rs
Normal file
386
crates/bootstrap/tests/api_test.rs
Normal file
@@ -0,0 +1,386 @@
|
||||
mod support;
|
||||
|
||||
use axum::{
|
||||
Router,
|
||||
body::Body,
|
||||
http::{Request, StatusCode},
|
||||
};
|
||||
use sqlx::SqlitePool;
|
||||
use tower::ServiceExt;
|
||||
use uuid::Uuid;
|
||||
|
||||
async fn test_app() -> Router {
|
||||
support::server().await.0
|
||||
}
|
||||
|
||||
async fn test_app_with_user() -> (Router, SqlitePool, Uuid, String) {
|
||||
let (app, pool) = support::server().await;
|
||||
let session = support::register(&app, "insights", "insights@example.com").await;
|
||||
let user_id = session.user_id.parse().unwrap();
|
||||
(app, pool, user_id, session.token)
|
||||
}
|
||||
|
||||
/// Inject a fake peer IP so the GovernorLayer can extract ConnectInfo.
|
||||
fn with_ip(req: Request<Body>) -> Request<Body> {
|
||||
let addr: std::net::SocketAddr = "127.0.0.1:12345".parse().unwrap();
|
||||
let mut req = req;
|
||||
req.extensions_mut()
|
||||
.insert(axum::extract::ConnectInfo::<std::net::SocketAddr>(addr));
|
||||
req
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_api_diary_returns_empty_list() {
|
||||
let app = test_app().await;
|
||||
let response = app
|
||||
.oneshot(with_ip(
|
||||
Request::builder()
|
||||
.uri("/api/v1/diary")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
|
||||
let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.unwrap();
|
||||
let json: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
|
||||
|
||||
assert_eq!(json["total_count"], 0);
|
||||
assert_eq!(json["items"], serde_json::json!([]));
|
||||
assert_eq!(json["limit"], 5);
|
||||
assert_eq!(json["offset"], 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn post_api_reviews_without_auth_returns_401() {
|
||||
let app = test_app().await;
|
||||
let response = app
|
||||
.oneshot(with_ip(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/v1/reviews")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(
|
||||
r#"{"rating":4,"watched_at":"2026-01-01T20:00:00","manual_title":"Dune","manual_release_year":2021}"#,
|
||||
))
|
||||
.unwrap(),
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn post_api_auth_login_unknown_user_returns_401() {
|
||||
let app = test_app().await;
|
||||
let response = app
|
||||
.oneshot(with_ip(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/v1/auth/login")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(r#"{"email":"a@b.com","password":"x"}"#))
|
||||
.unwrap(),
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_api_movie_detail_returns_404_for_unknown_id() {
|
||||
let app = test_app().await;
|
||||
let response = app
|
||||
.oneshot(with_ip(
|
||||
Request::builder()
|
||||
.uri("/api/v1/movies/00000000-0000-0000-0000-000000000000")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(response.status(), StatusCode::NOT_FOUND);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn tags_moviesdiary_redirects_to_home() {
|
||||
let app = test_app().await;
|
||||
let response = app
|
||||
.oneshot(with_ip(
|
||||
Request::builder()
|
||||
.uri("/tags/moviesdiary")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::TEMPORARY_REDIRECT);
|
||||
assert_eq!(response.headers().get("location").unwrap(), "/");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn tags_other_redirects_to_search() {
|
||||
let app = test_app().await;
|
||||
let response = app
|
||||
.oneshot(with_ip(
|
||||
Request::builder()
|
||||
.uri("/tags/batman")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::TEMPORARY_REDIRECT);
|
||||
assert_eq!(
|
||||
response.headers().get("location").unwrap(),
|
||||
"/?search=batman"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_movie_detail_html_returns_404_for_unknown_id() {
|
||||
let app = test_app().await;
|
||||
let response = app
|
||||
.oneshot(with_ip(
|
||||
Request::builder()
|
||||
.uri("/movies/00000000-0000-0000-0000-000000000000")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(response.status(), StatusCode::NOT_FOUND);
|
||||
}
|
||||
|
||||
// ── Insights ─────────────────────────────────────────────────────────────────
|
||||
|
||||
async fn seed_insights_diary(pool: &SqlitePool, user_id: Uuid) {
|
||||
// 12 thrillers rated 5 late at night, 12 comedies rated 2 — enough for a
|
||||
// rating lift once genres come from movie_genres.
|
||||
for i in 0..12u32 {
|
||||
let movie_id = uuid::Uuid::new_v4().to_string();
|
||||
sqlx::query("INSERT INTO movies (id, title, release_year) VALUES (?, ?, ?)")
|
||||
.bind(&movie_id)
|
||||
.bind(format!("Thriller {i}"))
|
||||
.bind(2020)
|
||||
.execute(pool)
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query("INSERT INTO movie_genres (movie_id, tmdb_id, name) VALUES (?, ?, ?)")
|
||||
.bind(&movie_id)
|
||||
.bind(44400 + i as i64)
|
||||
.bind("Thriller")
|
||||
.execute(pool)
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query(
|
||||
"INSERT INTO reviews (id, movie_id, user_id, rating, watched_at, created_at) \
|
||||
VALUES (?, ?, ?, ?, ?, ?)",
|
||||
)
|
||||
.bind(uuid::Uuid::new_v4().to_string())
|
||||
.bind(&movie_id)
|
||||
.bind(user_id.to_string())
|
||||
.bind(5)
|
||||
.bind("2024-01-05 22:30:00")
|
||||
.bind("2024-01-05 22:30:00")
|
||||
.execute(pool)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let movie_id = uuid::Uuid::new_v4().to_string();
|
||||
sqlx::query("INSERT INTO movies (id, title, release_year) VALUES (?, ?, ?)")
|
||||
.bind(&movie_id)
|
||||
.bind(format!("Comedy {i}"))
|
||||
.bind(2015)
|
||||
.execute(pool)
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query("INSERT INTO movie_genres (movie_id, tmdb_id, name) VALUES (?, ?, ?)")
|
||||
.bind(&movie_id)
|
||||
.bind(55300 + i as i64)
|
||||
.bind("Comedy")
|
||||
.execute(pool)
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query(
|
||||
"INSERT INTO reviews (id, movie_id, user_id, rating, watched_at, created_at) \
|
||||
VALUES (?, ?, ?, ?, ?, ?)",
|
||||
)
|
||||
.bind(uuid::Uuid::new_v4().to_string())
|
||||
.bind(&movie_id)
|
||||
.bind(user_id.to_string())
|
||||
.bind(2)
|
||||
.bind("2024-03-10 13:00:00")
|
||||
.bind("2024-03-10 13:00:00")
|
||||
.execute(pool)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_api_insights_returns_rating_lift_for_own_user() {
|
||||
let (app, pool, user_id, token) = test_app_with_user().await;
|
||||
seed_insights_diary(&pool, user_id).await;
|
||||
|
||||
let response = app
|
||||
.oneshot(with_ip(
|
||||
Request::builder()
|
||||
.uri("/api/v1/insights")
|
||||
.header("authorization", format!("Bearer {token}"))
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let (status, bytes) = (
|
||||
response.status(),
|
||||
axum::body::to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.unwrap(),
|
||||
);
|
||||
assert_eq!(
|
||||
status,
|
||||
StatusCode::OK,
|
||||
"body: {}",
|
||||
String::from_utf8(bytes.to_vec()).unwrap()
|
||||
);
|
||||
let json: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
|
||||
|
||||
let rating_lifts: Vec<&serde_json::Value> = json["insights"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.filter(|i| i["kind"] == "rating_lift")
|
||||
.collect();
|
||||
assert_eq!(rating_lifts.len(), 2, "thriller up, comedy down: {json}");
|
||||
|
||||
let thriller = rating_lifts
|
||||
.iter()
|
||||
.find(|i| i["genre"] == "Thriller")
|
||||
.unwrap();
|
||||
assert_eq!(thriller["delta"], 1.5);
|
||||
assert_eq!(thriller["sample_count"], 12);
|
||||
// Timestamps are real → rhythm fires. Hours split evenly between 22
|
||||
// (thrillers) and 13 (comedies); every window covering either bucket ties,
|
||||
// so the earliest one, 11:00–14:00, wins. Days tie too (Fri/Sun), sorted
|
||||
// ascending.
|
||||
let rhythm = json["insights"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.find(|i| i["kind"] == "rhythm")
|
||||
.expect("rhythm expected for real timestamps");
|
||||
assert_eq!(rhythm["peak_hour_start"], 11);
|
||||
assert_eq!(rhythm["peak_hour_end"], 14);
|
||||
assert_eq!(rhythm["top_days"], serde_json::json!([5, 7]));
|
||||
}
|
||||
|
||||
async fn get_insights_json(
|
||||
app: Router,
|
||||
token: &str,
|
||||
query: &str,
|
||||
) -> (StatusCode, serde_json::Value) {
|
||||
let response = app
|
||||
.oneshot(with_ip(
|
||||
Request::builder()
|
||||
.uri(format!("/api/v1/insights{query}"))
|
||||
.header("authorization", format!("Bearer {token}"))
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
let status = response.status();
|
||||
let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.unwrap();
|
||||
let json = serde_json::from_slice(&bytes).unwrap_or(serde_json::Value::Null);
|
||||
(status, json)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_api_insights_ignores_remote_reviews() {
|
||||
let (app, pool, user_id, token) = test_app_with_user().await;
|
||||
seed_insights_diary(&pool, user_id).await;
|
||||
// Federated rows stored under the same user_id are someone else's watches.
|
||||
for i in 0..12u32 {
|
||||
let movie_id = uuid::Uuid::new_v4().to_string();
|
||||
sqlx::query("INSERT INTO movies (id, title, release_year) VALUES (?, ?, ?)")
|
||||
.bind(&movie_id)
|
||||
.bind(format!("Horror {i}"))
|
||||
.bind(2019)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query("INSERT INTO movie_genres (movie_id, tmdb_id, name) VALUES (?, ?, ?)")
|
||||
.bind(&movie_id)
|
||||
.bind(66600 + i as i64)
|
||||
.bind("Horror")
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query(
|
||||
"INSERT INTO reviews (id, movie_id, user_id, rating, watched_at, created_at, remote_actor_url) \
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||
)
|
||||
.bind(uuid::Uuid::new_v4().to_string())
|
||||
.bind(&movie_id)
|
||||
.bind(user_id.to_string())
|
||||
.bind(5)
|
||||
.bind("2024-05-05 09:00:00")
|
||||
.bind("2024-05-05 09:00:00")
|
||||
.bind("https://remote.example/users/someone")
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
let (status, json) = get_insights_json(app, &token, "").await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
let insights = json["insights"].as_array().unwrap();
|
||||
assert!(
|
||||
!insights.iter().any(|i| i["genre"] == "Horror"),
|
||||
"remote reviews must not feed insights: {json}"
|
||||
);
|
||||
let thriller = insights
|
||||
.iter()
|
||||
.find(|i| i["kind"] == "rating_lift" && i["genre"] == "Thriller")
|
||||
.unwrap();
|
||||
assert_eq!(thriller["delta"], 1.5);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_api_insights_buckets_rhythm_in_requested_time_zone() {
|
||||
let (app, pool, user_id, token) = test_app_with_user().await;
|
||||
seed_insights_diary(&pool, user_id).await;
|
||||
|
||||
// January in Warsaw is UTC+1: 22:30 → 23:30 and 13:00 → 14:00, so the
|
||||
// earliest tied window moves from 11–14 to 12–15.
|
||||
let (status, json) = get_insights_json(app, &token, "?tz=Europe/Warsaw").await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
let rhythm = json["insights"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.find(|i| i["kind"] == "rhythm")
|
||||
.expect("rhythm expected");
|
||||
assert_eq!(rhythm["peak_hour_start"], 12);
|
||||
assert_eq!(rhythm["peak_hour_end"], 15);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_api_insights_rejects_unknown_time_zone() {
|
||||
let (app, _pool, _user_id, token) = test_app_with_user().await;
|
||||
let (status, _) = get_insights_json(app, &token, "?tz=Mars/Olympus").await;
|
||||
assert_eq!(status, StatusCode::BAD_REQUEST);
|
||||
}
|
||||
307
crates/bootstrap/tests/bootstrap_test.rs
Normal file
307
crates/bootstrap/tests/bootstrap_test.rs
Normal file
@@ -0,0 +1,307 @@
|
||||
mod support;
|
||||
|
||||
#[tokio::test]
|
||||
async fn server_builds_on_a_fresh_database() {
|
||||
let pool = support::memory_pool().await;
|
||||
let router = bootstrap::build_server_on(&support::test_config(), pool)
|
||||
.await
|
||||
.expect("server builds");
|
||||
|
||||
let (status, _, _) = support::send(&router, support::get("/health", None)).await;
|
||||
assert_eq!(status, 200);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn worker_builds_on_a_fresh_database() {
|
||||
let pool = support::memory_pool().await;
|
||||
bootstrap::build_worker_on(&support::test_config(), pool)
|
||||
.await
|
||||
.expect("worker builds");
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "federation"))]
|
||||
#[tokio::test]
|
||||
async fn federation_off_build_wires_local_social() {
|
||||
use axum::http::Method;
|
||||
|
||||
let (router, _pool) = support::server().await;
|
||||
let alice = support::register(&router, "alice", "alice@example.com").await;
|
||||
let bob = support::register(&router, "bob", "bob@example.com").await;
|
||||
|
||||
let (status, _, body) = support::send(
|
||||
&router,
|
||||
support::json_request(
|
||||
Method::POST,
|
||||
"/api/v1/social/follow",
|
||||
Some(&alice.token),
|
||||
serde_json::json!({ "handle": "bob@localhost:3000" }),
|
||||
),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, 200, "follow: {body}");
|
||||
|
||||
let (status, _, body) = support::send(
|
||||
&router,
|
||||
axum::http::Request::builder()
|
||||
.uri("/api/v1/social/followers/pending")
|
||||
.header("authorization", format!("Bearer {}", bob.token))
|
||||
.body(axum::body::Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, 200, "pending followers: {body}");
|
||||
let followers: serde_json::Value = serde_json::from_str(&body).unwrap();
|
||||
let actors = followers["actors"].as_array().unwrap();
|
||||
assert_eq!(
|
||||
actors.len(),
|
||||
1,
|
||||
"bob should have one pending follower: {body}"
|
||||
);
|
||||
assert!(
|
||||
body.contains(&alice.user_id) || body.contains("alice"),
|
||||
"follower should be alice: {body}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn server_publishes_to_the_outbox_even_when_bus_is_nats() {
|
||||
let pool = support::memory_pool().await;
|
||||
let mut config = support::test_config();
|
||||
config.events.bus_backend = "nats".into();
|
||||
config.nats.url = Some("nats://127.0.0.1:1".into());
|
||||
let router = bootstrap::build_server_on(&config, pool.clone())
|
||||
.await
|
||||
.expect("server builds without a reachable NATS");
|
||||
let session = support::register(&router, "alice", "alice@example.com").await;
|
||||
|
||||
let (status, _, body) = support::send(
|
||||
&router,
|
||||
support::json_request(
|
||||
axum::http::Method::POST,
|
||||
"/api/v1/reviews",
|
||||
Some(&session.token),
|
||||
serde_json::json!({
|
||||
"manual_title": "Outbox",
|
||||
"manual_release_year": 2001,
|
||||
"rating": 4,
|
||||
"comment": "queued",
|
||||
"watched_at": "2024-01-01"
|
||||
}),
|
||||
),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, 201, "log review: {body}");
|
||||
|
||||
let queued: i64 = sqlx::query_scalar(
|
||||
"SELECT COUNT(*) FROM event_queue WHERE event_type = 'ReviewLogged' AND event_id IS NOT NULL",
|
||||
)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(queued, 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn handler_names_are_unique() {
|
||||
let pool = support::memory_pool().await;
|
||||
let worker = bootstrap::build_worker_on(&support::test_config(), pool)
|
||||
.await
|
||||
.expect("worker builds");
|
||||
|
||||
let names = worker.handler_names();
|
||||
let unique: std::collections::HashSet<_> = names.iter().collect();
|
||||
assert_eq!(
|
||||
unique.len(),
|
||||
names.len(),
|
||||
"duplicate handler name in {names:?}"
|
||||
);
|
||||
let mut sorted = names.clone();
|
||||
sorted.sort_unstable();
|
||||
let mut expected = vec![
|
||||
"account_purge",
|
||||
"image_cleanup",
|
||||
"movie_discovery_indexer",
|
||||
"movie_enrichment",
|
||||
"person_enrichment",
|
||||
"poster_sync",
|
||||
"search_cleanup",
|
||||
"search_reindex",
|
||||
"wrapup",
|
||||
];
|
||||
if cfg!(feature = "federation") {
|
||||
expected.extend(["activitypub", "follow_backfill"]);
|
||||
}
|
||||
expected.sort_unstable();
|
||||
assert_eq!(sorted, expected);
|
||||
}
|
||||
|
||||
async fn queued(pool: &sqlx::SqlitePool, event_type: &str) -> i64 {
|
||||
sqlx::query_scalar(
|
||||
"SELECT COUNT(*) FROM event_queue WHERE event_type = ? AND event_id IS NOT NULL",
|
||||
)
|
||||
.bind(event_type)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_local_follow_lands_with_its_event_in_the_outbox() {
|
||||
let (router, pool) = support::server().await;
|
||||
let alice = support::register(&router, "alice", "alice@example.com").await;
|
||||
let _bob = support::register(&router, "bob", "bob@example.com").await;
|
||||
|
||||
let (status, _, body) = support::send(
|
||||
&router,
|
||||
support::json_request(
|
||||
axum::http::Method::POST,
|
||||
"/api/v1/social/follow",
|
||||
Some(&alice.token),
|
||||
serde_json::json!({ "handle": "bob@localhost:3000" }),
|
||||
),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, 200, "follow: {body}");
|
||||
|
||||
let following: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM ap_following")
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
let followers: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM ap_followers")
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!((following, followers), (1, 1));
|
||||
assert_eq!(queued(&pool, "FollowRequested").await, 1);
|
||||
}
|
||||
|
||||
#[cfg(feature = "federation")]
|
||||
#[tokio::test]
|
||||
async fn a_remote_block_publishes_its_event_after_the_k_ap_write() {
|
||||
let (router, pool) = support::server().await;
|
||||
let alice = support::register(&router, "alice", "alice@example.com").await;
|
||||
|
||||
let (status, _, body) = support::send(
|
||||
&router,
|
||||
support::json_request(
|
||||
axum::http::Method::POST,
|
||||
"/api/v1/social/block",
|
||||
Some(&alice.token),
|
||||
serde_json::json!({ "actor_url": "https://remote.example/users/ann" }),
|
||||
),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, 204, "block: {body}");
|
||||
|
||||
let blocked: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM blocked_actors")
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(blocked, 1);
|
||||
assert_eq!(queued(&pool, "ActorBlocked").await, 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_deleted_users_token_is_rejected() {
|
||||
let (router, pool) = support::server().await;
|
||||
let alice = support::register(&router, "alice", "alice@example.com").await;
|
||||
let bearer = |token: &str| {
|
||||
axum::http::Request::builder()
|
||||
.uri("/api/v1/diary/export?format=json")
|
||||
.header("authorization", format!("Bearer {token}"))
|
||||
.body(axum::body::Body::empty())
|
||||
.unwrap()
|
||||
};
|
||||
|
||||
let (status, _, body) = support::send(&router, bearer(&alice.token)).await;
|
||||
assert_eq!(status, 200, "token works while the account is live: {body}");
|
||||
let (status, _, _) =
|
||||
support::send(&router, support::get("/diary/export", Some(&alice.cookie))).await;
|
||||
assert_eq!(status, 200, "cookie works while the account is live");
|
||||
|
||||
sqlx::query("UPDATE users SET deleted_at = '2026-01-01T00:00:00Z' WHERE id = ?")
|
||||
.bind(&alice.user_id)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let (status, _, body) = support::send(&router, bearer(&alice.token)).await;
|
||||
assert_eq!(status, 401, "json route: {body}");
|
||||
let (anon_status, anon_headers, _) =
|
||||
support::send(&router, support::get("/diary/export", None)).await;
|
||||
let (status, headers, _) =
|
||||
support::send(&router, support::get("/diary/export", Some(&alice.cookie))).await;
|
||||
assert_eq!(status, anon_status, "classic owner page");
|
||||
assert_eq!(headers.get("location"), anon_headers.get("location"));
|
||||
assert_eq!(headers.get("location").unwrap(), "/login");
|
||||
}
|
||||
|
||||
#[cfg(feature = "federation")]
|
||||
#[tokio::test]
|
||||
async fn actor_document_for_a_tombstone_is_gone() {
|
||||
let (router, pool) = support::server().await;
|
||||
let alice = support::register(&router, "alice", "alice@example.com").await;
|
||||
let ap_get = |path: String| {
|
||||
axum::http::Request::builder()
|
||||
.uri(path)
|
||||
.header("accept", "application/activity+json")
|
||||
.body(axum::body::Body::empty())
|
||||
.unwrap()
|
||||
};
|
||||
let paths = |id: &str| {
|
||||
[
|
||||
format!("/users/{id}"),
|
||||
format!("/users/{id}/followers"),
|
||||
format!("/users/{id}/following"),
|
||||
format!("/users/{id}/outbox"),
|
||||
format!("/users/{id}/featured"),
|
||||
]
|
||||
};
|
||||
let (status, _, body) =
|
||||
support::send(&router, ap_get(format!("/users/{}", alice.user_id))).await;
|
||||
assert_eq!(status, 200, "live actor: {body}");
|
||||
|
||||
sqlx::query("UPDATE users SET deleted_at = '2026-01-01T00:00:00Z' WHERE id = ?")
|
||||
.bind(&alice.user_id)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
for path in paths(&alice.user_id) {
|
||||
let (status, _, body) = support::send(&router, ap_get(path.clone())).await;
|
||||
assert_eq!(status, 410, "{path}: {body}");
|
||||
}
|
||||
let (status, _, _) = support::send(
|
||||
&router,
|
||||
support::get(&format!("/users/{}", alice.user_id), None),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, 404, "the classic profile page stays 404");
|
||||
}
|
||||
|
||||
#[cfg(feature = "federation")]
|
||||
#[tokio::test]
|
||||
async fn posting_to_a_tombstones_inbox_is_gone() {
|
||||
let (router, pool) = support::server().await;
|
||||
let alice = support::register(&router, "alice", "alice@example.com").await;
|
||||
let post = |path: String| {
|
||||
axum::http::Request::builder()
|
||||
.method("POST")
|
||||
.uri(path)
|
||||
.header("content-type", "application/activity+json")
|
||||
.body(axum::body::Body::from("{}"))
|
||||
.unwrap()
|
||||
};
|
||||
let path = format!("/users/{}/inbox", alice.user_id);
|
||||
let (live, _, _) = support::send(&router, post(path.clone())).await;
|
||||
assert_ne!(live, 410, "a live user's inbox is not gone");
|
||||
|
||||
sqlx::query("UPDATE users SET deleted_at = '2026-01-01T00:00:00Z' WHERE id = ?")
|
||||
.bind(&alice.user_id)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let (status, _, body) = support::send(&router, post(path)).await;
|
||||
assert_eq!(status, 410, "{body}");
|
||||
}
|
||||
101
crates/bootstrap/tests/config_propagation_test.rs
Normal file
101
crates/bootstrap/tests/config_propagation_test.rs
Normal file
@@ -0,0 +1,101 @@
|
||||
mod support;
|
||||
|
||||
use axum::{
|
||||
body::Body,
|
||||
http::{Method, Request, header},
|
||||
};
|
||||
|
||||
async fn server_with(config: config::AppConfig) -> axum::Router {
|
||||
bootstrap::build_server_on(&config, support::memory_pool().await)
|
||||
.await
|
||||
.expect("server builds")
|
||||
}
|
||||
|
||||
fn set_cookies(headers: &axum::http::HeaderMap) -> Vec<String> {
|
||||
headers
|
||||
.get_all(header::SET_COOKIE)
|
||||
.iter()
|
||||
.map(|v| v.to_str().unwrap().to_string())
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn registration_is_refused_when_allow_registration_is_false() {
|
||||
let mut config = support::test_config();
|
||||
config.server.allow_registration = false;
|
||||
let router = server_with(config).await;
|
||||
|
||||
let form = Request::builder()
|
||||
.method(Method::POST)
|
||||
.uri("/register")
|
||||
.header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
|
||||
.body(Body::from(format!(
|
||||
"email=a%40example.com&username=alice&password={}&_csrf=x",
|
||||
support::PASSWORD
|
||||
)))
|
||||
.unwrap();
|
||||
let (status, headers, _) = support::send(&router, form).await;
|
||||
assert_eq!(status, 303, "classic form redirects away");
|
||||
assert_eq!(headers.get(header::LOCATION).unwrap(), "/");
|
||||
assert!(
|
||||
support::cookie_value(&headers, "token").is_none(),
|
||||
"no session issued"
|
||||
);
|
||||
|
||||
let (status, _, _) = support::send(
|
||||
&router,
|
||||
support::json_request(
|
||||
Method::POST,
|
||||
"/api/v1/auth/register",
|
||||
None,
|
||||
serde_json::json!({
|
||||
"email": "a@example.com",
|
||||
"username": "alice",
|
||||
"password": support::PASSWORD,
|
||||
}),
|
||||
),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, 401, "JSON register is refused");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn secure_cookies_setting_marks_cookies_secure() {
|
||||
for secure in [true, false] {
|
||||
let mut config = support::test_config();
|
||||
config.server.secure_cookies = secure;
|
||||
let router = server_with(config).await;
|
||||
|
||||
let (status, headers, _) = support::send(&router, support::get("/register", None)).await;
|
||||
assert_eq!(status, 200);
|
||||
let cookies = set_cookies(&headers);
|
||||
assert!(!cookies.is_empty(), "csrf cookie is set");
|
||||
assert!(
|
||||
cookies.iter().all(|c| c.contains("Secure") == secure),
|
||||
"secure_cookies={secure}: {cookies:?}"
|
||||
);
|
||||
|
||||
let csrf = support::cookie_value(&headers, "csrf").expect("csrf cookie");
|
||||
let form = Request::builder()
|
||||
.method(Method::POST)
|
||||
.uri("/register")
|
||||
.header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
|
||||
.header(header::COOKIE, format!("csrf={csrf}"))
|
||||
.body(Body::from(format!(
|
||||
"email=a%40example.com&username=alice&password={}&_csrf={csrf}",
|
||||
support::PASSWORD
|
||||
)))
|
||||
.unwrap();
|
||||
let (status, headers, _) = support::send(&router, form).await;
|
||||
assert_eq!(status, 303);
|
||||
let token_cookie = set_cookies(&headers)
|
||||
.into_iter()
|
||||
.find(|c| c.starts_with("token="))
|
||||
.expect("session cookie");
|
||||
assert_eq!(
|
||||
token_cookie.contains("Secure"),
|
||||
secure,
|
||||
"session cookie, secure_cookies={secure}: {token_cookie}"
|
||||
);
|
||||
}
|
||||
}
|
||||
71
crates/bootstrap/tests/fixtures/openapi_paths.txt
vendored
Normal file
71
crates/bootstrap/tests/fixtures/openapi_paths.txt
vendored
Normal file
@@ -0,0 +1,71 @@
|
||||
DELETE /api/v1/admin/blocked-domains/{domain}
|
||||
DELETE /api/v1/goals/{year}
|
||||
DELETE /api/v1/import/profiles/{id}
|
||||
DELETE /api/v1/reviews/{id}
|
||||
DELETE /api/v1/settings/webhook-tokens/{id}
|
||||
DELETE /api/v1/users/me
|
||||
DELETE /api/v1/users/{id}
|
||||
DELETE /api/v1/watchlist/{movie_id}
|
||||
DELETE /api/v1/wrapups/{id}
|
||||
GET /api/v1/activity-feed
|
||||
GET /api/v1/admin/blocked-domains
|
||||
GET /api/v1/diary
|
||||
GET /api/v1/diary/export
|
||||
GET /api/v1/goals
|
||||
GET /api/v1/import/profiles
|
||||
GET /api/v1/import/sessions/{id}
|
||||
GET /api/v1/insights
|
||||
GET /api/v1/movies
|
||||
GET /api/v1/movies/{id}/history
|
||||
GET /api/v1/movies/{id}/profile
|
||||
GET /api/v1/movies/{movie_id}
|
||||
GET /api/v1/people/{id}
|
||||
GET /api/v1/people/{id}/credits
|
||||
GET /api/v1/profile
|
||||
GET /api/v1/search
|
||||
GET /api/v1/settings
|
||||
GET /api/v1/settings/webhook-tokens
|
||||
GET /api/v1/social/blocked
|
||||
GET /api/v1/social/followers
|
||||
GET /api/v1/social/followers/pending
|
||||
GET /api/v1/social/followers/pending/count
|
||||
GET /api/v1/social/following
|
||||
GET /api/v1/social/following/pending
|
||||
GET /api/v1/social/relationship
|
||||
GET /api/v1/users
|
||||
GET /api/v1/users/{id}
|
||||
GET /api/v1/users/{id}/goals
|
||||
GET /api/v1/watch-queue
|
||||
GET /api/v1/watchlist
|
||||
GET /api/v1/watchlist/{movie_id}
|
||||
GET /api/v1/wrapups
|
||||
GET /api/v1/wrapups/{id}
|
||||
GET /api/v1/wrapups/{id}/report
|
||||
POST /api/v1/admin/blocked-domains
|
||||
POST /api/v1/auth/login
|
||||
POST /api/v1/auth/register
|
||||
POST /api/v1/goals
|
||||
POST /api/v1/import/profiles
|
||||
POST /api/v1/import/sessions
|
||||
POST /api/v1/import/sessions/{id}/confirm
|
||||
POST /api/v1/movies/{id}/sync-poster
|
||||
POST /api/v1/reviews
|
||||
POST /api/v1/settings/webhook-tokens
|
||||
POST /api/v1/social/block
|
||||
POST /api/v1/social/follow
|
||||
POST /api/v1/social/followers/accept
|
||||
POST /api/v1/social/followers/reject
|
||||
POST /api/v1/social/followers/remove
|
||||
POST /api/v1/social/unblock
|
||||
POST /api/v1/social/unfollow
|
||||
POST /api/v1/watch-queue/confirm
|
||||
POST /api/v1/watch-queue/dismiss
|
||||
POST /api/v1/watchlist
|
||||
POST /api/v1/webhooks/jellyfin
|
||||
POST /api/v1/webhooks/plex
|
||||
POST /api/v1/wrapups/generate
|
||||
PUT /api/v1/goals/{year}
|
||||
PUT /api/v1/import/sessions/{id}/mapping
|
||||
PUT /api/v1/profile
|
||||
PUT /api/v1/profile/fields
|
||||
PUT /api/v1/settings
|
||||
54
crates/bootstrap/tests/fixtures/openapi_paths_federation_off.txt
vendored
Normal file
54
crates/bootstrap/tests/fixtures/openapi_paths_federation_off.txt
vendored
Normal file
@@ -0,0 +1,54 @@
|
||||
DELETE /api/v1/goals/{year}
|
||||
DELETE /api/v1/import/profiles/{id}
|
||||
DELETE /api/v1/reviews/{id}
|
||||
DELETE /api/v1/settings/webhook-tokens/{id}
|
||||
DELETE /api/v1/users/me
|
||||
DELETE /api/v1/users/{id}
|
||||
DELETE /api/v1/watchlist/{movie_id}
|
||||
DELETE /api/v1/wrapups/{id}
|
||||
GET /api/v1/activity-feed
|
||||
GET /api/v1/diary
|
||||
GET /api/v1/diary/export
|
||||
GET /api/v1/goals
|
||||
GET /api/v1/import/profiles
|
||||
GET /api/v1/import/sessions/{id}
|
||||
GET /api/v1/insights
|
||||
GET /api/v1/movies
|
||||
GET /api/v1/movies/{id}/history
|
||||
GET /api/v1/movies/{id}/profile
|
||||
GET /api/v1/movies/{movie_id}
|
||||
GET /api/v1/people/{id}
|
||||
GET /api/v1/people/{id}/credits
|
||||
GET /api/v1/profile
|
||||
GET /api/v1/search
|
||||
GET /api/v1/settings
|
||||
GET /api/v1/settings/webhook-tokens
|
||||
GET /api/v1/users
|
||||
GET /api/v1/users/{id}
|
||||
GET /api/v1/users/{id}/goals
|
||||
GET /api/v1/watch-queue
|
||||
GET /api/v1/watchlist
|
||||
GET /api/v1/watchlist/{movie_id}
|
||||
GET /api/v1/wrapups
|
||||
GET /api/v1/wrapups/{id}
|
||||
GET /api/v1/wrapups/{id}/report
|
||||
POST /api/v1/auth/login
|
||||
POST /api/v1/auth/register
|
||||
POST /api/v1/goals
|
||||
POST /api/v1/import/profiles
|
||||
POST /api/v1/import/sessions
|
||||
POST /api/v1/import/sessions/{id}/confirm
|
||||
POST /api/v1/movies/{id}/sync-poster
|
||||
POST /api/v1/reviews
|
||||
POST /api/v1/settings/webhook-tokens
|
||||
POST /api/v1/watch-queue/confirm
|
||||
POST /api/v1/watch-queue/dismiss
|
||||
POST /api/v1/watchlist
|
||||
POST /api/v1/webhooks/jellyfin
|
||||
POST /api/v1/webhooks/plex
|
||||
POST /api/v1/wrapups/generate
|
||||
PUT /api/v1/goals/{year}
|
||||
PUT /api/v1/import/sessions/{id}/mapping
|
||||
PUT /api/v1/profile
|
||||
PUT /api/v1/profile/fields
|
||||
PUT /api/v1/settings
|
||||
9
crates/bootstrap/tests/http_handlers.rs
Normal file
9
crates/bootstrap/tests/http_handlers.rs
Normal file
@@ -0,0 +1,9 @@
|
||||
#[path = "http_handlers/support.rs"]
|
||||
mod support;
|
||||
|
||||
#[path = "http_handlers/api_handlers_test.rs"]
|
||||
mod api_handlers_test;
|
||||
#[path = "http_handlers/app_context_test.rs"]
|
||||
mod app_context_test;
|
||||
#[path = "http_handlers/extractors_test.rs"]
|
||||
mod extractors_test;
|
||||
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())
|
||||
}
|
||||
}
|
||||
99
crates/bootstrap/tests/ledger_recovery_test.rs
Normal file
99
crates/bootstrap/tests/ledger_recovery_test.rs
Normal file
@@ -0,0 +1,99 @@
|
||||
mod support;
|
||||
|
||||
use std::sync::atomic::{AtomicU32, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
|
||||
use application::worker::WorkerService;
|
||||
use async_trait::async_trait;
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
events::{AckHandle, DomainEvent, EventEnvelope, EventId},
|
||||
ports::{Begin, EventConsumer, EventHandler, ProcessedEventLedger},
|
||||
};
|
||||
use futures::stream::BoxStream;
|
||||
use sqlite::SqliteProcessedEventLedger;
|
||||
|
||||
struct Counting(Arc<AtomicU32>);
|
||||
|
||||
#[async_trait]
|
||||
impl EventHandler for Counting {
|
||||
fn name(&self) -> &'static str {
|
||||
"crashy"
|
||||
}
|
||||
async fn handle(&self, _: &DomainEvent) -> Result<(), DomainError> {
|
||||
self.0.fetch_add(1, Ordering::SeqCst);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
struct Outcome(Arc<Mutex<Vec<&'static str>>>);
|
||||
|
||||
#[async_trait]
|
||||
impl AckHandle for Outcome {
|
||||
async fn ack(&self) -> Result<(), DomainError> {
|
||||
self.0.lock().unwrap().push("ack");
|
||||
Ok(())
|
||||
}
|
||||
async fn nack(&self) -> Result<(), DomainError> {
|
||||
self.0.lock().unwrap().push("nack");
|
||||
Ok(())
|
||||
}
|
||||
async fn defer(&self, _: Duration) -> Result<(), DomainError> {
|
||||
self.0.lock().unwrap().push("defer");
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
struct Once(Mutex<Option<EventEnvelope>>);
|
||||
|
||||
impl EventConsumer for Once {
|
||||
fn consume(&self) -> BoxStream<'_, Result<EventEnvelope, DomainError>> {
|
||||
let item = self.0.lock().unwrap().take().map(Ok);
|
||||
Box::pin(futures::stream::iter(item))
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn an_abandoned_claim_defers_deliveries_until_the_lease_lapses_then_runs_once() {
|
||||
let pool = support::memory_pool().await;
|
||||
sqlite::migrate(&pool).await.unwrap();
|
||||
let ledger = Arc::new(SqliteProcessedEventLedger::with_lease(
|
||||
pool,
|
||||
Duration::from_secs(1),
|
||||
));
|
||||
let id = EventId::new();
|
||||
assert!(matches!(
|
||||
ledger.begin(&id, "crashy").await.unwrap(),
|
||||
Begin::Run(_)
|
||||
));
|
||||
|
||||
let runs = Arc::new(AtomicU32::new(0));
|
||||
let outcomes = Arc::new(Mutex::new(Vec::new()));
|
||||
let deliver = || {
|
||||
let envelope = EventEnvelope::new(
|
||||
Some(id),
|
||||
DomainEvent::SearchReindexRequested,
|
||||
Box::new(Outcome(outcomes.clone())),
|
||||
);
|
||||
let service = WorkerService::new(
|
||||
Arc::new(Once(Mutex::new(Some(envelope)))),
|
||||
vec![Arc::new(Counting(runs.clone()))],
|
||||
ledger.clone(),
|
||||
)
|
||||
.unwrap();
|
||||
async move { service.run(tokio::sync::watch::channel(false).1).await }
|
||||
};
|
||||
|
||||
deliver().await;
|
||||
assert_eq!(*outcomes.lock().unwrap(), ["defer"]);
|
||||
assert_eq!(runs.load(Ordering::SeqCst), 0);
|
||||
|
||||
tokio::time::sleep(Duration::from_millis(2100)).await;
|
||||
deliver().await;
|
||||
assert_eq!(*outcomes.lock().unwrap(), ["defer", "ack"]);
|
||||
assert_eq!(runs.load(Ordering::SeqCst), 1);
|
||||
|
||||
deliver().await;
|
||||
assert_eq!(runs.load(Ordering::SeqCst), 1, "done claims never re-run");
|
||||
}
|
||||
209
crates/bootstrap/tests/outbox_relay_test.rs
Normal file
209
crates/bootstrap/tests/outbox_relay_test.rs
Normal file
@@ -0,0 +1,209 @@
|
||||
mod support;
|
||||
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
use tokio::sync::Notify;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
events::{DomainEvent, EventId},
|
||||
ports::{EventPublisher, EventRelaySink},
|
||||
value_objects::UserId,
|
||||
};
|
||||
use sqlite::SqliteEventQueue;
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Default)]
|
||||
struct FakeSink {
|
||||
fail: bool,
|
||||
fail_first: std::sync::atomic::AtomicUsize,
|
||||
slow: Option<Duration>,
|
||||
started: Notify,
|
||||
seen: Mutex<Vec<Option<EventId>>>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl EventRelaySink for FakeSink {
|
||||
async fn relay(&self, id: Option<&EventId>, _event: &DomainEvent) -> Result<(), DomainError> {
|
||||
self.seen.lock().unwrap().push(id.cloned());
|
||||
if let Some(delay) = self.slow {
|
||||
self.started.notify_one();
|
||||
tokio::time::sleep(delay).await;
|
||||
}
|
||||
let outage = self
|
||||
.fail_first
|
||||
.try_update(
|
||||
std::sync::atomic::Ordering::SeqCst,
|
||||
std::sync::atomic::Ordering::SeqCst,
|
||||
|n| n.checked_sub(1),
|
||||
)
|
||||
.is_ok();
|
||||
if self.fail || outage {
|
||||
Err(DomainError::InfrastructureError("bus down".into()))
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn outbox() -> (sqlx::SqlitePool, Arc<dyn EventPublisher>, SqliteEventQueue) {
|
||||
outbox_with_attempts(config::EventsConfig::default().queue_max_attempts).await
|
||||
}
|
||||
|
||||
async fn outbox_with_attempts(
|
||||
max_attempts: i32,
|
||||
) -> (sqlx::SqlitePool, Arc<dyn EventPublisher>, SqliteEventQueue) {
|
||||
let pool = support::memory_pool().await;
|
||||
sqlite::migrate(&pool).await.unwrap();
|
||||
let config = config::EventsConfig {
|
||||
queue_poll_interval_ms: 10,
|
||||
queue_max_attempts: max_attempts,
|
||||
..Default::default()
|
||||
};
|
||||
let queue = SqliteEventQueue::create(
|
||||
pool.clone(),
|
||||
sqlite::DbEventQueueConfig::from_config(&config),
|
||||
);
|
||||
(pool.clone(), Arc::new(queue.clone()), queue)
|
||||
}
|
||||
|
||||
async fn row(pool: &sqlx::SqlitePool) -> (String, i32, Option<String>) {
|
||||
sqlx::query_as("SELECT status, attempts, event_id FROM event_queue")
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
async fn wait_for(pool: &sqlx::SqlitePool, status: &str) {
|
||||
for _ in 0..800 {
|
||||
if row(pool).await.0 == status {
|
||||
return;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
}
|
||||
panic!("row never reached {status}: {:?}", row(pool).await);
|
||||
}
|
||||
|
||||
fn start(
|
||||
queue: SqliteEventQueue,
|
||||
sink: Arc<FakeSink>,
|
||||
) -> (
|
||||
tokio::sync::watch::Sender<bool>,
|
||||
tokio::task::JoinHandle<()>,
|
||||
) {
|
||||
let (stop, rx) = tokio::sync::watch::channel(false);
|
||||
let relay = bootstrap::outbox_relay::OutboxRelay::new(Arc::new(queue), sink)
|
||||
.with_backoff(|_| Duration::from_millis(10));
|
||||
(stop, tokio::spawn(relay.run(rx)))
|
||||
}
|
||||
|
||||
fn event() -> DomainEvent {
|
||||
DomainEvent::UserUpdated {
|
||||
user_id: UserId::from_uuid(Uuid::new_v4()),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn relay_marks_rows_done_only_after_publish_succeeds() {
|
||||
let (pool, publisher, queue) = outbox().await;
|
||||
publisher.publish(&event()).await.unwrap();
|
||||
let sink = Arc::new(FakeSink::default());
|
||||
|
||||
let (stop, relay) = start(queue, sink.clone());
|
||||
wait_for(&pool, "done").await;
|
||||
relay.abort();
|
||||
drop(stop);
|
||||
|
||||
let (_, attempts, event_id) = row(&pool).await;
|
||||
assert_eq!(attempts, 0);
|
||||
let seen = sink.seen.lock().unwrap();
|
||||
assert_eq!(seen.len(), 1);
|
||||
assert_eq!(
|
||||
seen[0].as_ref().map(|id| id.value().to_string()),
|
||||
event_id,
|
||||
"the sink gets the row's own event id"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_failing_bus_never_spends_the_rows_attempts_and_the_row_relays_on_recovery() {
|
||||
let (pool, publisher, queue) = outbox_with_attempts(2).await;
|
||||
publisher.publish(&event()).await.unwrap();
|
||||
let sink = Arc::new(FakeSink {
|
||||
fail_first: std::sync::atomic::AtomicUsize::new(3),
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
let (stop, relay) = start(queue, sink.clone());
|
||||
wait_for(&pool, "done").await;
|
||||
relay.abort();
|
||||
drop(stop);
|
||||
|
||||
let (status, attempts, _) = row(&pool).await;
|
||||
assert_eq!((status.as_str(), attempts), ("done", 0));
|
||||
assert_eq!(
|
||||
sink.seen.lock().unwrap().len(),
|
||||
4,
|
||||
"3 failures then success"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_failed_publish_defers_the_row_without_counting_an_attempt() {
|
||||
let (pool, publisher, queue) = outbox().await;
|
||||
publisher.publish(&event()).await.unwrap();
|
||||
let sink = Arc::new(FakeSink {
|
||||
fail: true,
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
let (stop, relay) = start(queue, sink.clone());
|
||||
for _ in 0..200 {
|
||||
let last_error: Option<String> = sqlx::query_scalar("SELECT last_error FROM event_queue")
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
if last_error.as_deref() == Some("deferred") {
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
}
|
||||
relay.abort();
|
||||
drop(stop);
|
||||
|
||||
let (status, attempts, _) = row(&pool).await;
|
||||
assert_eq!((status.as_str(), attempts), ("pending", 0));
|
||||
let last_error: Option<String> = sqlx::query_scalar("SELECT last_error FROM event_queue")
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(last_error.as_deref(), Some("deferred"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn shutdown_lets_the_in_flight_row_finish_then_stops_the_relay() {
|
||||
let (pool, publisher, queue) = outbox().await;
|
||||
publisher.publish(&event()).await.unwrap();
|
||||
publisher.publish(&event()).await.unwrap();
|
||||
let sink = Arc::new(FakeSink {
|
||||
slow: Some(Duration::from_millis(200)),
|
||||
..Default::default()
|
||||
});
|
||||
let (stop, relay) = start(queue, sink.clone());
|
||||
|
||||
sink.started.notified().await;
|
||||
stop.send(true).unwrap();
|
||||
tokio::time::timeout(Duration::from_secs(5), relay)
|
||||
.await
|
||||
.expect("relay stops on shutdown")
|
||||
.unwrap();
|
||||
|
||||
let statuses: Vec<String> = sqlx::query_scalar("SELECT status FROM event_queue ORDER BY id")
|
||||
.fetch_all(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(statuses[0], "done", "in-flight row was completed");
|
||||
assert_ne!(statuses[0], "processing");
|
||||
assert_eq!(sink.seen.lock().unwrap().len(), 1, "no new row after stop");
|
||||
}
|
||||
298
crates/bootstrap/tests/route_smoke_test.rs
Normal file
298
crates/bootstrap/tests/route_smoke_test.rs
Normal file
@@ -0,0 +1,298 @@
|
||||
mod support;
|
||||
|
||||
use axum::{
|
||||
Router,
|
||||
body::Body,
|
||||
http::{Method, Request, header},
|
||||
};
|
||||
use support::{get, json_request, send};
|
||||
|
||||
const EMAIL: &str = "owner@example.com";
|
||||
const USERNAME: &str = "owner";
|
||||
|
||||
const CLASSIC_VISITOR_PAGES: &[(&str, u16)] = &[
|
||||
("/", 200),
|
||||
("/users", 200),
|
||||
("/u/{username}", 308),
|
||||
("/users/{id}", 200),
|
||||
("/users/{id}?view=ratings", 200),
|
||||
("/users/{id}?view=history", 200),
|
||||
("/movies/{movie}", 200),
|
||||
("/login", 200),
|
||||
("/register", 200),
|
||||
("/logout", 303),
|
||||
("/tags/drama", 307),
|
||||
("/users/{id}/watchlist", 200),
|
||||
("/wrapups/{id}/2025", 200),
|
||||
("/wrapups/global/2025", 200),
|
||||
("/users/{id}/following-list", 303),
|
||||
("/users/{id}/followers-list", 303),
|
||||
("/posters/abc.jpg", 308),
|
||||
("/images/missing.jpg", 404),
|
||||
("/health", 200),
|
||||
];
|
||||
|
||||
const CLASSIC_OWNER_PAGES: &[(&str, u16)] = &[
|
||||
("/reviews/new", 200),
|
||||
("/diary/export", 200),
|
||||
("/import", 200),
|
||||
("/import/done", 200),
|
||||
("/import/00000000-0000-0000-0000-000000000000/mapping", 303),
|
||||
("/import/00000000-0000-0000-0000-000000000000/preview", 303),
|
||||
("/settings/profile", 200),
|
||||
("/settings/integrations", 200),
|
||||
("/watch-queue", 200),
|
||||
("/users/{id}/watchlist", 200),
|
||||
("/users/{id}/following-list", 200),
|
||||
("/users/{id}/followers-list", 200),
|
||||
("/movies/{movie}", 200),
|
||||
("/users/{id}?embed=true", 200),
|
||||
];
|
||||
|
||||
#[cfg(feature = "federation")]
|
||||
const FEDERATION_VISITOR_PAGES: &[(&str, u16)] = &[
|
||||
("/users/{id}/followers", 303),
|
||||
("/users/{id}/following", 303),
|
||||
];
|
||||
|
||||
#[cfg(feature = "federation")]
|
||||
const FEDERATION_OWNER_PAGES: &[(&str, u16)] = &[
|
||||
("/users/{id}/followers", 303),
|
||||
("/users/{id}/following", 303),
|
||||
("/admin/blocked-domains", 403),
|
||||
("/social/blocked", 200),
|
||||
];
|
||||
|
||||
#[cfg(not(feature = "federation"))]
|
||||
const FEDERATION_OFF_PAGES: &[(&str, u16)] = &[
|
||||
("/users/{id}/followers", 308),
|
||||
("/users/{id}/following", 308),
|
||||
];
|
||||
|
||||
struct Seed {
|
||||
router: Router,
|
||||
user_id: String,
|
||||
movie_id: String,
|
||||
owner_cookie: String,
|
||||
}
|
||||
|
||||
async fn seed() -> Seed {
|
||||
let (router, pool) = support::server().await;
|
||||
let session = support::register(&router, USERNAME, EMAIL).await;
|
||||
let (token, user_id, owner_cookie) = (session.token, session.user_id, session.cookie);
|
||||
|
||||
let (status, _, body) = send(
|
||||
&router,
|
||||
json_request(
|
||||
Method::POST,
|
||||
"/api/v1/reviews",
|
||||
Some(&token),
|
||||
serde_json::json!({
|
||||
"manual_title": "Seeded Movie",
|
||||
"manual_release_year": 1999,
|
||||
"manual_director": "Some Director",
|
||||
"rating": 4,
|
||||
"comment": "the butler did it",
|
||||
"watched_at": "2025-03-01",
|
||||
"spoilers": true,
|
||||
}),
|
||||
),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, 201, "log review: {body}");
|
||||
|
||||
let (status, _, body) = send(
|
||||
&router,
|
||||
json_request(
|
||||
Method::POST,
|
||||
"/api/v1/goals",
|
||||
Some(&token),
|
||||
serde_json::json!({ "year": 2025, "target_count": 12 }),
|
||||
),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, 200, "create goal: {body}");
|
||||
|
||||
let (status, _, body) = send(
|
||||
&router,
|
||||
Request::builder()
|
||||
.uri("/api/v1/movies")
|
||||
.header(header::AUTHORIZATION, format!("Bearer {token}"))
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, 200, "list movies: {body}");
|
||||
let movies: serde_json::Value = serde_json::from_str(&body).unwrap();
|
||||
let movie_id = movies["items"][0]["id"]
|
||||
.as_str()
|
||||
.unwrap_or_else(|| panic!("no movie id in {body}"))
|
||||
.to_string();
|
||||
|
||||
seed_ready_wrapup(&pool, Some(&user_id)).await;
|
||||
seed_ready_wrapup(&pool, None).await;
|
||||
|
||||
Seed {
|
||||
router,
|
||||
user_id,
|
||||
movie_id,
|
||||
owner_cookie,
|
||||
}
|
||||
}
|
||||
|
||||
async fn seed_ready_wrapup(pool: &sqlx::SqlitePool, user_id: Option<&str>) {
|
||||
use chrono::NaiveDate;
|
||||
use domain::models::wrapup::{DateRange, WrapUpScope};
|
||||
|
||||
let start = NaiveDate::from_ymd_opt(2025, 1, 1).unwrap();
|
||||
let end = NaiveDate::from_ymd_opt(2026, 1, 1).unwrap();
|
||||
let scope = match user_id {
|
||||
Some(id) => WrapUpScope::User(id.parse().unwrap()),
|
||||
None => WrapUpScope::Global,
|
||||
};
|
||||
let report = domain::services::wrapup_analyzer::build_report(
|
||||
scope,
|
||||
DateRange::new(start, end).unwrap(),
|
||||
&[],
|
||||
);
|
||||
sqlx::query(
|
||||
"INSERT INTO wrap_up_records (id, user_id, start_date, end_date, status, report_json) \
|
||||
VALUES (?, ?, '2025-01-01', '2026-01-01', 'ready', ?)",
|
||||
)
|
||||
.bind(uuid::Uuid::new_v4().to_string())
|
||||
.bind(user_id)
|
||||
.bind(serde_json::to_string(&report).unwrap())
|
||||
.execute(pool)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
fn expand(seed: &Seed, template: &str) -> String {
|
||||
template
|
||||
.replace("{id}", &seed.user_id)
|
||||
.replace("{username}", USERNAME)
|
||||
.replace("{movie}", &seed.movie_id)
|
||||
}
|
||||
|
||||
async fn check_table(seed: &Seed, table: &[(&str, u16)], cookie: Option<&str>) {
|
||||
let mut failures = Vec::new();
|
||||
for (template, expected) in table {
|
||||
let uri = expand(seed, template);
|
||||
let (status, _, _) = send(&seed.router, get(&uri, cookie)).await;
|
||||
if status != *expected {
|
||||
failures.push(format!("{uri}: expected {expected}, got {status}"));
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
failures.is_empty(),
|
||||
"route status drift:\n{}",
|
||||
failures.join("\n")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn every_classic_page_renders_for_a_visitor() {
|
||||
let seed = seed().await;
|
||||
check_table(&seed, CLASSIC_VISITOR_PAGES, None).await;
|
||||
#[cfg(feature = "federation")]
|
||||
check_table(&seed, FEDERATION_VISITOR_PAGES, None).await;
|
||||
#[cfg(not(feature = "federation"))]
|
||||
check_table(&seed, FEDERATION_OFF_PAGES, None).await;
|
||||
|
||||
for template in ["/wrapups/{id}/2025", "/wrapups/global/2025"] {
|
||||
let (status, _, body) = send(&seed.router, get(&expand(&seed, template), None)).await;
|
||||
assert_eq!(status, 200, "{template}");
|
||||
assert!(
|
||||
body.contains("class=\"wu-container\""),
|
||||
"wrapup template not rendered for {template}"
|
||||
);
|
||||
}
|
||||
|
||||
let (status, _, body) = send(
|
||||
&seed.router,
|
||||
get(&expand(&seed, "/users/{id}?embed=true"), None),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, 200);
|
||||
assert!(
|
||||
body.contains("class=\"embed-profile\""),
|
||||
"embed template not rendered: {body}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn every_classic_page_renders_for_the_signed_in_owner() {
|
||||
let seed = seed().await;
|
||||
let cookie = seed.owner_cookie.clone();
|
||||
check_table(&seed, CLASSIC_OWNER_PAGES, Some(&cookie)).await;
|
||||
#[cfg(feature = "federation")]
|
||||
check_table(&seed, FEDERATION_OWNER_PAGES, Some(&cookie)).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn feeds_and_openapi_are_served() {
|
||||
let seed = seed().await;
|
||||
for (template, expected) in [
|
||||
("/feed.rss", 200),
|
||||
("/users/{id}/feed.rss", 200),
|
||||
("/openapi.json", 200),
|
||||
("/docs", 303),
|
||||
("/docs/", 200),
|
||||
] {
|
||||
let uri = expand(&seed, template);
|
||||
let (status, _, _) = send(&seed.router, get(&uri, None)).await;
|
||||
assert_eq!(status, expected, "{uri}");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn openapi_paths_match_the_recorded_list() {
|
||||
let seed = seed().await;
|
||||
let (status, _, body) = send(&seed.router, get("/openapi.json", None)).await;
|
||||
assert_eq!(status, 200);
|
||||
let spec: serde_json::Value = serde_json::from_str(&body).unwrap();
|
||||
let mut lines = Vec::new();
|
||||
for (path, item) in spec["paths"].as_object().unwrap() {
|
||||
for method in item.as_object().unwrap().keys() {
|
||||
if ["get", "put", "post", "delete", "patch", "head", "options"]
|
||||
.contains(&method.as_str())
|
||||
{
|
||||
lines.push(format!("{} {path}", method.to_uppercase()));
|
||||
}
|
||||
}
|
||||
}
|
||||
lines.sort();
|
||||
let actual = lines.join("\n") + "\n";
|
||||
|
||||
let fixture = if cfg!(feature = "federation") {
|
||||
"tests/fixtures/openapi_paths.txt"
|
||||
} else {
|
||||
"tests/fixtures/openapi_paths_federation_off.txt"
|
||||
};
|
||||
let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join(fixture);
|
||||
if std::env::var("UPDATE_OPENAPI_FIXTURE").is_ok() {
|
||||
std::fs::write(&path, &actual).unwrap();
|
||||
}
|
||||
let expected = std::fs::read_to_string(&path).unwrap();
|
||||
assert_eq!(actual, expected, "OpenAPI paths drifted from {fixture}");
|
||||
}
|
||||
|
||||
#[cfg(feature = "federation")]
|
||||
#[tokio::test]
|
||||
async fn profile_page_serves_the_actor_document_to_activitypub_clients() {
|
||||
let seed = seed().await;
|
||||
let req = Request::builder()
|
||||
.method(Method::GET)
|
||||
.uri(format!("/users/{}", seed.user_id))
|
||||
.header(header::ACCEPT, "application/activity+json")
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
let (status, headers, body) = send(&seed.router, req).await;
|
||||
assert_eq!(status, 200, "{body}");
|
||||
assert_eq!(
|
||||
headers.get(header::CONTENT_TYPE).unwrap(),
|
||||
"application/activity+json"
|
||||
);
|
||||
let actor: serde_json::Value = serde_json::from_str(&body).expect("actor JSON");
|
||||
assert_eq!(actor["type"], "Person");
|
||||
}
|
||||
153
crates/bootstrap/tests/support/mod.rs
Normal file
153
crates/bootstrap/tests/support/mod.rs
Normal file
@@ -0,0 +1,153 @@
|
||||
#![allow(dead_code)]
|
||||
|
||||
use axum::{
|
||||
Router,
|
||||
body::Body,
|
||||
http::{Method, Request, header},
|
||||
};
|
||||
use tower::ServiceExt;
|
||||
|
||||
pub const PASSWORD: &str = "correct-horse-battery";
|
||||
|
||||
pub fn test_config() -> config::AppConfig {
|
||||
config::AppConfig {
|
||||
server: config::ServerConfig {
|
||||
allow_registration: true,
|
||||
base_url: "http://localhost:3000".into(),
|
||||
..Default::default()
|
||||
},
|
||||
database: config::DatabaseConfig {
|
||||
url: "sqlite::memory:".into(),
|
||||
},
|
||||
auth: config::AuthConfig {
|
||||
jwt_secret: Some("route-smoke-test-secret-0123456789abcdef".into()),
|
||||
..Default::default()
|
||||
},
|
||||
events: Default::default(),
|
||||
nats: Default::default(),
|
||||
storage: Default::default(),
|
||||
image_conversion: Default::default(),
|
||||
metadata: config::MetadataConfig {
|
||||
tmdb_api_key: Some("test-key".into()),
|
||||
..Default::default()
|
||||
},
|
||||
federation: Default::default(),
|
||||
wrapup: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn send(router: &Router, mut req: Request<Body>) -> (u16, axum::http::HeaderMap, String) {
|
||||
let addr: std::net::SocketAddr = "127.0.0.1:12345".parse().unwrap();
|
||||
req.extensions_mut()
|
||||
.insert(axum::extract::ConnectInfo::<std::net::SocketAddr>(addr));
|
||||
let resp = router.clone().oneshot(req).await.unwrap();
|
||||
let status = resp.status().as_u16();
|
||||
let headers = resp.headers().clone();
|
||||
let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
|
||||
.await
|
||||
.unwrap();
|
||||
(
|
||||
status,
|
||||
headers,
|
||||
String::from_utf8_lossy(&bytes).into_owned(),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn get(uri: &str, cookie: Option<&str>) -> Request<Body> {
|
||||
let mut b = Request::builder().method(Method::GET).uri(uri);
|
||||
if let Some(c) = cookie {
|
||||
b = b.header(header::COOKIE, c);
|
||||
}
|
||||
b.body(Body::empty()).unwrap()
|
||||
}
|
||||
|
||||
pub fn cookie_value(headers: &axum::http::HeaderMap, name: &str) -> Option<String> {
|
||||
headers
|
||||
.get_all(header::SET_COOKIE)
|
||||
.iter()
|
||||
.filter_map(|v| v.to_str().ok())
|
||||
.find_map(|c| {
|
||||
c.split(';')
|
||||
.next()?
|
||||
.trim()
|
||||
.strip_prefix(&format!("{name}="))
|
||||
.map(str::to_string)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn json_request(
|
||||
method: Method,
|
||||
uri: &str,
|
||||
token: Option<&str>,
|
||||
body: serde_json::Value,
|
||||
) -> Request<Body> {
|
||||
let mut b = Request::builder()
|
||||
.method(method)
|
||||
.uri(uri)
|
||||
.header(header::CONTENT_TYPE, "application/json");
|
||||
if let Some(t) = token {
|
||||
b = b.header(header::AUTHORIZATION, format!("Bearer {t}"));
|
||||
}
|
||||
b.body(Body::from(body.to_string())).unwrap()
|
||||
}
|
||||
|
||||
pub async fn memory_pool() -> sqlx::SqlitePool {
|
||||
sqlx::sqlite::SqlitePoolOptions::new()
|
||||
.max_connections(1)
|
||||
.connect("sqlite::memory:")
|
||||
.await
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
pub async fn server() -> (Router, sqlx::SqlitePool) {
|
||||
let pool = memory_pool().await;
|
||||
let router = bootstrap::build_server_on(&test_config(), pool.clone())
|
||||
.await
|
||||
.unwrap();
|
||||
(router, pool)
|
||||
}
|
||||
|
||||
pub struct Session {
|
||||
pub user_id: String,
|
||||
pub token: String,
|
||||
pub cookie: String,
|
||||
}
|
||||
|
||||
pub async fn register(router: &Router, username: &str, email: &str) -> Session {
|
||||
let (status, headers, _) = send(router, get("/register", None)).await;
|
||||
assert_eq!(status, 200, "GET /register");
|
||||
let csrf = cookie_value(&headers, "csrf").expect("csrf cookie");
|
||||
let form = format!(
|
||||
"email={}&username={username}&password={PASSWORD}&_csrf={csrf}",
|
||||
email.replace('@', "%40")
|
||||
);
|
||||
let req = Request::builder()
|
||||
.method(Method::POST)
|
||||
.uri("/register")
|
||||
.header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
|
||||
.header(header::COOKIE, format!("csrf={csrf}"))
|
||||
.body(Body::from(form))
|
||||
.unwrap();
|
||||
let (status, headers, _) = send(router, req).await;
|
||||
assert_eq!(status, 303, "POST /register");
|
||||
let session = cookie_value(&headers, "token").expect("session cookie after register");
|
||||
let cookie = format!("csrf={csrf}; token={session}");
|
||||
|
||||
let (status, _, body) = send(
|
||||
router,
|
||||
json_request(
|
||||
Method::POST,
|
||||
"/api/v1/auth/login",
|
||||
None,
|
||||
serde_json::json!({ "email": email, "password": PASSWORD }),
|
||||
),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, 200, "login: {body}");
|
||||
let login: serde_json::Value = serde_json::from_str(&body).unwrap();
|
||||
Session {
|
||||
user_id: login["user_id"].as_str().unwrap().to_string(),
|
||||
token: login["token"].as_str().unwrap().to_string(),
|
||||
cookie,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user