// SPDX-License-Identifier: AGPL-3.0-or-later #![recursion_limit = "256"] use axum::{ Json, Router, body::{Body, to_bytes}, extract::State, http::{HeaderMap, Method, Request, StatusCode, Uri, header}, response::{IntoResponse, Response}, }; 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 = "enforcement-panel-test-secret"; const ADMIN_ID: &str = "1500000000000000000"; const USER_ID: &str = "1500000000000000001"; const DECISION_ID: &str = "1900000000000000001"; const REPORT_ID: &str = "1600000000000000001"; const CHANNEL_ID: &str = "1700000000000000001"; const MESSAGE_ID: &str = "1700000000000000002"; const SUSPENDED_UNTIL: &str = "2099-01-01T00:00:00.000Z"; const DISABLED: u64 = 1 << 38; const NETWORK_IP: &str = "45.33.10.1"; const SHARED_IP: &str = "45.33.10.2"; #[derive(Clone)] struct Captured { route: String, audit_log_reason: Option, body: Value, } struct Mock { admin_acls: Vec<&'static str>, user: Value, decisions: Vec, decision: Value, audit_logs: Vec, captured: Mutex>, } impl Mock { fn new(user: Value) -> Self { Self { admin_acls: vec!["*"], user, decisions: Vec::new(), decision: Value::Null, audit_logs: Vec::new(), captured: Mutex::new(Vec::new()), } } } struct TestApp { router: Router, session_cookie: String, mock: Arc, } async fn setup(mock: Mock) -> TestApp { let mock = Arc::new(mock); let listener = TcpListener::bind(("127.0.0.1", 0)).await.unwrap(); let addr = listener.local_addr().unwrap(); let state = Arc::clone(&mock); tokio::spawn(async move { axum::serve(listener, Router::new().fallback(mock_api).with_state(state)) .await .unwrap(); }); let router = build_router(test_config(format!("http://{addr}"))); let session_value = session::create_session(ADMIN_ID, "test-token", SECRET_KEY); TestApp { router, session_cookie: format!("{}={session_value}", session::SESSION_COOKIE_NAME), mock, } } async fn get(app: &TestApp, uri: &str) -> (StatusCode, HeaderMap, String) { let response = app .router .clone() .oneshot( Request::builder() .method(Method::GET) .uri(uri) .header(header::COOKIE, &app.session_cookie) .body(Body::empty()) .unwrap(), ) .await .unwrap(); let status = response.status(); let headers = response.headers().clone(); let body = to_bytes(response.into_body(), usize::MAX).await.unwrap(); (status, headers, String::from_utf8(body.to_vec()).unwrap()) } async fn page(app: &TestApp, uri: &str) -> String { let (status, _, body) = get(app, uri).await; assert_eq!(status, StatusCode::OK, "{body}"); body } async fn post(app: &TestApp, uri: &str, fields: &str) -> Vec { let (_, headers, _) = get(app, "/decisions").await; let csrf = headers .get_all(header::SET_COOKIE) .iter() .filter_map(|value| value.to_str().ok()) .find_map(|value| { let pair = value.split(';').next()?; pair.strip_prefix("__Host-csrf_token=") .or_else(|| pair.strip_prefix("csrf_token=")) .map(ToOwned::to_owned) }) .expect("csrf cookie"); let response = app .router .clone() .oneshot( Request::builder() .method(Method::POST) .uri(uri) .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded") .header( header::COOKIE, format!("{}; __Host-csrf_token={csrf}", app.session_cookie), ) .body(Body::from(format!("_csrf={csrf}&{fields}"))) .unwrap(), ) .await .unwrap(); assert_eq!(response.status(), StatusCode::SEE_OTHER); app.mock.captured.lock().unwrap().clone() } async fn mock_api( State(mock): State>, method: Method, uri: Uri, headers: HeaderMap, request: Request, ) -> Response { let path = uri.path().to_owned(); if method != Method::GET { let bytes = to_bytes(request.into_body(), usize::MAX).await.unwrap(); mock.captured.lock().unwrap().push(Captured { route: format!("{method} {path}"), audit_log_reason: headers .get("x-audit-log-reason") .and_then(|value| value.to_str().ok()) .map(ToOwned::to_owned), body: serde_json::from_slice(&bytes).unwrap_or(Value::Null), }); } let user_path = format!("/admin/users/{USER_ID}"); let decision_path = format!("/admin/enforcement-decisions/{DECISION_ID}"); match (method, path.as_str()) { (Method::GET, "/admin/users/@me") => { let mut admin = user(ADMIN_ID, "AdminUser"); admin["acls"] = json!(mock.admin_acls); Json(json!({ "user": admin })).into_response() } (Method::GET, "/admin/users") => Json(json!({ "users": [user(ADMIN_ID, "AdminUser"), mock.user.clone()] })) .into_response(), (Method::GET, p) if p == user_path => { Json(json!({ "users": [mock.user.clone()] })).into_response() } (Method::GET, p) if p == format!("{user_path}/network-block-candidates") => Json(json!({ "networks": [ {"ip": NETWORK_IP, "last_seen_at": "2026-10-13T09:00:00.000Z", "skip_reason": null, "other_unrestricted_accounts": 0}, {"ip": SHARED_IP, "last_seen_at": "2026-10-12T09:00:00.000Z", "skip_reason": null, "other_unrestricted_accounts": 2}, {"ip": "10.0.0.5", "last_seen_at": "2026-10-11T09:00:00.000Z", "skip_reason": "special_purpose", "other_unrestricted_accounts": 0} ] })) .into_response(), (Method::GET, "/admin/enforcement-decisions") => { Json(json!({ "decisions": mock.decisions })).into_response() } (Method::GET, p) if p == decision_path => Json(mock.decision.clone()).into_response(), (Method::GET, "/admin/audit-logs") => Json(json!({ "logs": mock.audit_logs, "total": mock.audit_logs.len() })) .into_response(), (Method::PUT | Method::DELETE | Method::PATCH, p) if p.starts_with(&user_path) => { Json(json!({ "user": mock.user.clone(), "skipped_ips": [] })).into_response() } (Method::POST, p) if p == format!("{user_path}/warnings") => { Json(json!({ "decision_id": DECISION_ID })).into_response() } (Method::POST, p) if p == format!("{decision_path}/end") => { Json(mock.decision.clone()).into_response() } (Method::POST, p) if p == format!("{decision_path}/notes") => Json(json!({ "note_id": "1", "author_id": ADMIN_ID, "body": "x", "created_at": "2026-10-14T12:00:00.000Z" })) .into_response(), _ => ( StatusCode::NOT_FOUND, Json(json!({ "code": "UNKNOWN", "message": "not found" })), ) .into_response(), } } fn user(id: &str, username: &str) -> Value { json!({ "id": id, "username": username, "discriminator": 1, "avatar": null, "banner": null, "email": "user@example.com", "email_verified": true, "email_bounced": false, "global_name": username, "bio": null, "pronouns": null, "accent_color": null, "date_of_birth": null, "locale": "en-US", "acls": [], "traits": [], "flags": "0", "premium_flags": 0, "bot": false, "system": false, "premium_type": null, "premium_since": null, "premium_until": null, "premium_grace_ends_at": null, "premium_lifetime_sequence": null, "has_totp": false, "authenticator_types": [], "temp_banned_until": null, "pending_deletion_at": null, "pending_bulk_message_deletion_at": null, "deletion_reason_code": null, "deletion_public_reason": null, "deletion_audit_log_reason": null, "deletion_scheduled_by": null, "deletion_scheduled_at": null, "enforcement_decision_id": null, "last_active_at": null, "last_active_ip": null, "last_active_ip_reverse": null, "last_active_location": null }) } fn subject() -> Value { user(USER_ID, "Subject") } fn suspended_subject() -> Value { let mut subject = subject(); subject["temp_banned_until"] = json!(SUSPENDED_UNTIL); subject["flags"] = json!(DISABLED.to_string()); subject["enforcement_decision_id"] = json!(DECISION_ID); subject } fn list_item(reason_code: i32) -> Value { json!({ "decision_id": DECISION_ID, "subject_type": "user", "subject_id": USER_ID, "subject_name": null, "recipient_user_id": USER_ID, "kind": "suspension", "reason_code": reason_code, "source": "report", "automation": "none", "statement": null, "rights_holder": null, "ends_at": SUSPENDED_UNTIL, "deletion_at": null, "limit": null, "profile_fields": [], "removed_message_count": null, "removed_all_messages": false, "payment_at": null, "report_ids": [REPORT_ID], "decided_by": ADMIN_ID, "created_at": "2026-10-14T12:00:00.000Z", "status": "active", "end_outcome": null, "ended_at": null, "ended_by": null, "end_statement": null, "notice_status": "sent", "notice_channels": ["email"], "notice_locale": "en-US", "notice_text": "We suspended your account.", "notice_sent_at": "2026-10-14T12:00:01.000Z", "expired": false, "replaced": false }) } fn full_decision() -> Value { let mut decision = list_item(11); decision["statement"] = json!("Stop sending these messages."); decision["notes"] = json!([{ "note_id": "1950000000000000001", "author_id": ADMIN_ID, "body": "Ticket 42, three reports", "created_at": "2026-10-14T12:00:00.000Z" }]); decision["evidence"] = json!([{ "message_id": MESSAGE_ID, "channel_id": CHANNEL_ID, "timestamp": "2026-10-13T09:00:00.000Z", "content": "the offending message", "attachments": [{"filename": "proof.png", "size": "1024", "content_type": "image/png"}], "attachment_count": 1 }]); decision["profile_values"] = Value::Null; decision["network_blocks"] = json!([{"ip": NETWORK_IP, "expires_at": "2027-01-12T12:00:00.000Z"}]); decision["notice"] = json!({ "decision_id": DECISION_ID, "kind": "suspension", "permanent": false, "issued_at": "Oct 14, 2026", "ends_at": "Jan 1, 2099", "deletion_at": null, "summary": "We suspended your account until Jan 1, 2099.", "effects": ["You can't sign in until then."], "rule": {"title": "Harassment", "url": "https://fluxer.com/guidelines#1-harassment-threats-and-privacy"}, "facts": ["You sent messages that harass people."], "statement": "Stop sending these messages.", "how_we_decided": ["We reviewed this after a report."], "appeal": {"email": "appeals@fluxer.com", "deadline": "Dec 13, 2026", "account_has_email": true}, "redress": [] }); decision } fn redacted_decision() -> Value { let mut decision = full_decision(); decision["report_ids"] = json!([]); decision["evidence"][0]["content"] = Value::Null; decision["evidence"][0]["attachments"] = Value::Null; decision } fn decision_mock(reason_code: i32) -> Mock { let mut mock = Mock::new(suspended_subject()); mock.decisions = vec![list_item(reason_code)]; mock.decision = full_decision(); mock } fn moderation_uri() -> String { format!("/users/{USER_ID}?tab=moderation") } fn current_restriction(html: &str) -> &str { let current = section(html, "data-current-restriction"); current .find("Suspend account") .or_else(|| current.find("Account deletion")) .map_or(current, |end| ¤t[..end]) } fn section<'a>(html: &'a str, marker: &str) -> &'a str { let start = html .find(marker) .unwrap_or_else(|| panic!("missing {marker}\n{html}")); &html[start..] } #[tokio::test] async fn moderation_tab_with_a_decision_shows_it_and_ends_it() { let app = setup(decision_mock(11)).await; let html = page(&app, &moderation_uri()).await; let current = current_restriction(&html); assert!( current.contains("Suspension for Harassment or bullying. Until Jan 1, 2099, 12:00 AM UTC") ); assert!(current.contains(&format!(r#"href="/decisions/{DECISION_ID}""#))); assert!(current.contains(&format!(r#"action="/decisions/{DECISION_ID}?action=end""#))); assert!(current.contains(r#"value="lifted""#)); assert!(!current.contains("data-correction-line")); assert!(!html.contains("action=unban")); let table = section(&html, "data-decisions-table"); assert!(table.contains(">Suspension")); assert!(table.contains(">Active")); assert!(html.contains("?action=suspend&tab=moderation")); assert!(html.contains("?action=warn&tab=moderation")); } #[tokio::test] async fn ending_a_restricted_decision_shows_the_correction_line() { let app = setup(decision_mock(13)).await; let html = page(&app, &moderation_uri()).await; let current = current_restriction(&html); assert!(current.contains("data-correction-line")); assert!(current.contains("If this decision was reported to an authority, file a correction.")); } #[tokio::test] async fn moderation_tab_for_a_legacy_suspension_offers_the_lift_form() { let mut subject = suspended_subject(); subject["enforcement_decision_id"] = Value::Null; let mut mock = Mock::new(subject); mock.audit_logs = vec![json!({ "log_id": "1", "admin_user_id": ADMIN_ID, "admin_user": {"id": ADMIN_ID, "username": "AdminUser", "discriminator": "0001", "global_name": null}, "action": "temp_ban", "target_id": USER_ID, "target_type": "user", "audit_log_reason": "Old internal note", "metadata": {"banned_until": SUSPENDED_UNTIL}, "created_at": "2026-09-01T12:00:00.000Z" })]; let app = setup(mock).await; let html = page(&app, &moderation_uri()).await; let current = current_restriction(&html); assert!( current.contains("Banned permanently. Recorded before decisions, so it has no decision.") ); assert!(current.contains("Old internal note")); assert!(current.contains("?action=unban&tab=moderation")); assert!(!current.contains("?action=end")); assert!(!current.contains("notify_user")); } #[tokio::test] async fn moderation_tab_without_a_restriction_offers_suspend_and_warn_with_prefills() { let app = setup(Mock::new(subject())).await; let html = page( &app, &format!( "{}&report_id={REPORT_ID}&message={CHANNEL_ID}%2F{MESSAGE_ID}", moderation_uri() ), ) .await; let current = current_restriction(&html); assert!(current.contains("No current restriction.")); assert!(!current.contains("{REPORT_ID}"))); assert!(suspend.contains(&format!(">{CHANNEL_ID}/{MESSAGE_ID}"))); assert!(section(&html, "?action=warn").contains("Send warning")); let limit = between(&html, "?action=limit_features", "?action=warn"); for expected in [ format!(">{REPORT_ID}"), format!(">{CHANNEL_ID}/{MESSAGE_ID}"), r#"name="source" value="report" required checked"#.to_owned(), r#"Limit*"#.to_owned(), r#"Internal note*"#.to_owned(), ] { assert!(limit.contains(&expected), "missing {expected}\n{limit}"); } } fn between<'a>(html: &'a str, start: &str, end: &str) -> &'a str { let rest = section(html, start); rest.find(end).map_or(rest, |index| &rest[..index]) } #[tokio::test] async fn moderation_tab_offers_close_and_limit_features() { let app = setup(Mock::new(subject())).await; let html = page(&app, &moderation_uri()).await; let close = between(&html, "?action=close", "?action=limit_features"); for expected in [ r#"value="enforcement""#, r#"value="holder_request""#, r#"value="housekeeping""#, "A rule was broken", "The account holder asked", "Inactivity", r#"name="days_until_deletion" value="74""#, r#"data-show-for-basis="enforcement" hidden disabled"#, r#"data-show-for-basis="holder_request housekeeping" hidden disabled"#, r#"name="remove_evidence""#, "Close account", ] { assert!(close.contains(expected), "missing {expected}\n{close}"); } assert!(!close.contains(r#"