// SPDX-License-Identifier: AGPL-3.0-or-later #![recursion_limit = "256"] use axum::{ Json, Router, body::{Body, to_bytes}, extract::{DefaultBodyLimit, Path}, http::{HeaderMap, Method, Request, StatusCode, Uri, header}, response::{IntoResponse, Response}, routing, }; use fluxer_admin::{ build_router, config::{AdminConfig, ProxyConfig}, session, }; use serde_json::{Value, json}; use std::sync::{Arc, Mutex}; use tokio::net::TcpListener; use tower::ServiceExt; const SECRET_KEY: &str = "htmx-acceptance-test-secret"; struct TestApp { router: Router, session_cookie: String, } #[tokio::test] async fn report_legal_hold_form_places_and_clears_the_hold() { let received = Arc::new(Mutex::new(Vec::::new())); let sink = received.clone(); let api = Router::new() .route( "/admin/reports/1800000000000000001/legal-hold", routing::post(move |Json(body): Json| { let sink = sink.clone(); async move { sink.lock().unwrap().push(body.clone()); if body["legal_hold_reason"] == "rejected" { return ( StatusCode::BAD_REQUEST, Json(json!({"code": "INVALID_FORM_BODY", "message": "Invalid"})), ) .into_response(); } json_response(json!({ "report_id": "1800000000000000001", "legal_hold_until": body["legal_hold_until"], "legal_hold_reason": body["legal_hold_reason"] })) } }), ) .fallback(mock_api); let app = setup_with_api(api).await; let (headers, page) = get_with_headers(&app, "/reports/1800000000000000001", &[]).await; let csrf_token = csrf_cookie(&headers) .unwrap_or_else(|| panic!("report page did not set csrf_token cookie\n{page}")); assert_form_has_csrf( &page, "/reports/1800000000000000001/legal-hold", &csrf_token, ); let cookie = format!("{}; csrf_token={}", app.session_cookie, csrf_token); let post = |body: String| { let app = &app; let cookie = cookie.clone(); async move { let (status, headers, text) = post_form_with_headers( app, "/reports/1800000000000000001/legal-hold", &[ ("HX-Request", "true"), ("HX-Target", "flash-container"), ("Cookie", &cookie), ], &body, ) .await; assert_eq!(status, StatusCode::NO_CONTENT, "{text}"); headers .get("X-Fluxer-Admin-Toast") .and_then(|value| value.to_str().ok()) .unwrap_or_else(|| panic!("missing toast header\n{text}")) .to_owned() } }; let toast = post(format!( "_csrf={csrf_token}&legal_hold_until=2027-01-31&legal_hold_reason=Court+order+42" )) .await; assert!(toast.contains("success"), "{toast}"); assert!( toast.contains("Legal hold placed until Jan 31, 2027, 11:59 PM UTC"), "{toast}" ); let toast = post(format!( "_csrf={csrf_token}&clear=1&legal_hold_reason=ignored" )) .await; assert!(toast.contains("Legal hold cleared"), "{toast}"); let toast = post(format!( "_csrf={csrf_token}&legal_hold_until=2027-01-31&legal_hold_reason=rejected" )) .await; assert!(toast.contains("error"), "{toast}"); assert!(toast.contains("The hold must end in the future"), "{toast}"); assert!(!toast.contains("needs a reason"), "{toast}"); let toast = post(format!( "_csrf={csrf_token}&legal_hold_until=2020-01-01&legal_hold_reason=Past+date" )) .await; assert!(toast.contains("error"), "{toast}"); assert!(toast.contains("The hold must end in the future"), "{toast}"); { let received = received.lock().unwrap(); assert_eq!( *received, vec![ json!({"legal_hold_until": "2027-01-31T23:59:59.999Z", "legal_hold_reason": "Court order 42"}), json!({"legal_hold_until": null, "legal_hold_reason": null}), json!({"legal_hold_until": "2027-01-31T23:59:59.999Z", "legal_hold_reason": "rejected"}), ] ); } let toast = post(format!( "_csrf={csrf_token}&legal_hold_until=2027-01-31&legal_hold_reason=+" )) .await; assert!(toast.contains("Give a reason for the hold"), "{toast}"); let toast = post(format!( "_csrf={csrf_token}&legal_hold_until=soon&legal_hold_reason=x" )) .await; assert!(toast.contains("Choose the date the hold ends"), "{toast}"); assert_eq!(received.lock().unwrap().len(), 3); } #[tokio::test] async fn report_delete_form_confirms_sends_the_reason_and_toasts_a_held_report() { let received = Arc::new(Mutex::new(Vec::>::new())); let sink = received.clone(); let api = Router::new() .route( "/admin/reports/1800000000000000001", routing::delete(move |headers: HeaderMap| { let sink = sink.clone(); async move { let reason = headers .get("X-Audit-Log-Reason") .and_then(|value| value.to_str().ok()) .map(str::to_owned); sink.lock().unwrap().push(reason.clone()); if reason.as_deref() == Some("held") { return ( StatusCode::CONFLICT, Json(json!({ "code": "REPORT_UNDER_LEGAL_HOLD", "message": "This report is under a legal hold and can't be deleted." })), ) .into_response(); } StatusCode::NO_CONTENT.into_response() } }) .get(|| async { json_response(searched_report()) }), ) .fallback(mock_api); let app = setup_with_api(api).await; let (headers, page) = get_with_headers(&app, "/reports/1800000000000000001", &[]).await; let csrf_token = csrf_cookie(&headers) .unwrap_or_else(|| panic!("report page did not set csrf_token cookie\n{page}")); assert_form_has_csrf(&page, "/reports/1800000000000000001/delete", &csrf_token); let cookie = format!("{}; csrf_token={}", app.session_cookie, csrf_token); let post = |body: String| { let app = &app; let cookie = cookie.clone(); async move { post_form_with_headers( app, "/reports/1800000000000000001/delete", &[ ("HX-Request", "true"), ("HX-Target", "flash-container"), ("Cookie", &cookie), ], &body, ) .await } }; let toast = |headers: &HeaderMap| { headers .get("X-Fluxer-Admin-Toast") .and_then(|value| value.to_str().ok()) .unwrap_or_else(|| panic!("missing toast header {headers:?}")) .to_owned() }; let (status, headers, text) = post(format!( "_csrf={csrf_token}&audit_log_reason=Erasure+request" )) .await; assert_eq!(status, StatusCode::NO_CONTENT, "{text}"); let message = toast(&headers); assert!(message.contains("error"), "{message}"); assert!( message.contains("Confirm that the report should be deleted"), "{message}" ); assert!(headers.get("HX-Redirect").is_none(), "{headers:?}"); let (status, headers, text) = post(format!( "_csrf={csrf_token}&confirm=true&audit_log_reason=Erasure+request" )) .await; assert_eq!(status, StatusCode::NO_CONTENT, "{text}"); assert_eq!( headers.get("HX-Redirect").and_then(|v| v.to_str().ok()), Some("/reports"), "{headers:?}" ); let message = toast(&headers); assert!(message.contains("success"), "{message}"); assert!(message.contains("Report deleted"), "{message}"); assert!( headers .get_all(header::SET_COOKIE) .iter() .any(|value| value.to_str().is_ok_and(|v| v.starts_with("flash="))), "{headers:?}" ); let (status, headers, text) = post(format!( "_csrf={csrf_token}&confirm=true&audit_log_reason=held" )) .await; assert_eq!(status, StatusCode::NO_CONTENT, "{text}"); assert!(headers.get("HX-Redirect").is_none(), "{headers:?}"); let message = toast(&headers); assert!(message.contains("error"), "{message}"); assert!( message.contains("The report is under a legal hold. Clear the hold before deleting it."), "{message}" ); let (status, headers, text) = post_form_with_headers( &app, "/reports/1800000000000000001/delete", &[("Cookie", &cookie)], &format!("_csrf={csrf_token}&confirm=true&audit_log_reason=+"), ) .await; assert!(status.is_redirection(), "{status} {text}"); assert_eq!( headers.get(header::LOCATION).and_then(|v| v.to_str().ok()), Some("/reports"), "{headers:?}" ); assert_eq!( *received.lock().unwrap(), vec![ Some("Erasure request".to_owned()), Some("held".to_owned()), None ] ); } #[tokio::test] async fn pending_registration_decision_refreshes_the_list_and_sidebar_count() { let received = Arc::new(Mutex::new(Vec::<(String, Value)>::new())); let sink = received.clone(); let api = Router::new() .route( "/admin/users/@me", routing::get(|| async { json_response(json!({"user": admin_user(), "pending_registration_count": 2})) }), ) .route( "/admin/instance/pending-registrations", routing::get(|| async { json_response(json!({"pending_registrations": [ pending_registration("1500000000000000002"), pending_registration("1500000000000000003"), ]})) }), ) .route( "/admin/instance/pending-registrations/{user_id}", routing::patch( move |Path(user_id): Path, Json(body): Json| { let sink = sink.clone(); async move { sink.lock().unwrap().push((user_id, body)); json_response(json!({"pending_registrations": [ pending_registration("1500000000000000003"), ]})) } }, ), ) .fallback(mock_api); let app = setup_with_api(api).await; let (headers, page) = get_with_headers(&app, "/pending-registrations", &[]).await; assert!( page.contains(r#"2"#), "{page}" ); assert!(page.contains("ID: 1500000000000000002"), "{page}"); let csrf_token = csrf_cookie(&headers).unwrap_or_else(|| { panic!("pending registrations page did not set csrf_token cookie\n{page}") }); assert_form_has_csrf(&page, "/pending-registrations/approve", &csrf_token); assert_form_has_csrf(&page, "/pending-registrations/reject", &csrf_token); let cookie = format!("{}; csrf_token={}", app.session_cookie, csrf_token); let (status, headers, fragment) = post_form_with_headers( &app, "/pending-registrations/approve", &[ ("HX-Request", "true"), ("HX-Target", "pending-registration-list"), ("Cookie", &cookie), ], &format!("_csrf={csrf_token}&user_id=1500000000000000002"), ) .await; assert_eq!(status, StatusCode::OK, "{fragment}"); assert!( fragment.contains(r#"id="pending-registration-list""#), "{fragment}" ); assert!(!fragment.contains("ID: 1500000000000000002"), "{fragment}"); assert!(fragment.contains("ID: 1500000000000000003"), "{fragment}"); assert!( fragment.contains(r#"hx-swap-oob="true">1"#), "{fragment}" ); let toast = headers .get("X-Fluxer-Admin-Toast") .and_then(|value| value.to_str().ok()) .unwrap_or_default(); assert!(toast.contains("Registration approved"), "{toast}"); assert_eq!( *received.lock().unwrap(), vec![( "1500000000000000002".to_owned(), json!({"status": "approved"}) )] ); } #[tokio::test] async fn branding_asset_upload_takes_a_large_image_and_swaps_the_field() { let received = Arc::new(Mutex::new(Vec::::new())); let sink = received.clone(); let api = Router::new() .route( "/admin/instance/config", routing::get(|| async { json_response(instance_config(Some( "https://media.example.test/branding/0/old.png", ))) }), ) .route( "/admin/instance/config/branding-assets", routing::post(move |Json(body): Json| { let sink = sink.clone(); async move { sink.lock().unwrap().push(body.clone()); if body["image"] == "data:image/png;base64,bad" { return ( StatusCode::BAD_REQUEST, Json(json!({ "code": "INVALID_FORM_BODY", "message": "Input form validation failed", "errors": [{"path": "image", "message": "Unsupported image format"}] })), ) .into_response(); } let logo_url = body["image"] .is_string() .then_some("https://media.example.test/branding/0/new.png"); json_response(instance_config(logo_url)) } }), ) .layer(DefaultBodyLimit::disable()) .fallback(mock_api); let app = setup_with_api(api).await; let (headers, page) = get_with_headers(&app, "/instance-config", &[]).await; let csrf_token = csrf_cookie(&headers) .unwrap_or_else(|| panic!("instance config page did not set csrf_token cookie\n{page}")); assert_form_has_csrf( &page, "/instance-config?action=update_app_public", &csrf_token, ); assert!(page.contains(r#"id="branding-asset-logo""#), "{page}"); assert!(page.contains(r#"id="branding-asset-favicon""#), "{page}"); assert!( page.contains(r#"src="https://media.example.test/branding/0/old.png""#), "{page}" ); assert!(page.contains(r#"accept=".png,.jpeg,.webp"#), "{page}"); let cookie = format!("{}; csrf_token={}", app.session_cookie, csrf_token); let post = |action: &'static str, fields: Vec<(&'static str, String)>| { let app = &app; let cookie = cookie.clone(); let csrf_token = csrf_token.clone(); async move { let mut body = url::form_urlencoded::Serializer::new(String::new()); body.append_pair("_csrf", &csrf_token); for (name, value) in &fields { body.append_pair(name, value); } post_form_with_headers( app, &format!("/instance-config?action={action}"), &[ ("HX-Request", "true"), ("HX-Target", "branding-asset-logo"), ("X-CSRF-Token", &csrf_token), ("Cookie", &cookie), ], &body.finish(), ) .await } }; let toast_of = |headers: &HeaderMap| { headers .get("X-Fluxer-Admin-Toast") .and_then(|value| value.to_str().ok()) .unwrap_or_default() .to_owned() }; let image = format!( "data:image/png;base64,{}", "iVBO+w/A".repeat(2 * 1024 * 1024 / 8) ); let (status, headers, fragment) = post( "upload_branding_asset", vec![("kind", "logo".to_owned()), ("image", image.clone())], ) .await; assert_eq!(status, StatusCode::OK, "{fragment}"); assert!( fragment.starts_with(r#"