mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-17 13:05:32 +09:00
1048 lines
37 KiB
Rust
1048 lines
37 KiB
Rust
// 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<String>,
|
|
body: Value,
|
|
}
|
|
|
|
struct Mock {
|
|
admin_acls: Vec<&'static str>,
|
|
user: Value,
|
|
decisions: Vec<Value>,
|
|
decision: Value,
|
|
audit_logs: Vec<Value>,
|
|
captured: Mutex<Vec<Captured>>,
|
|
}
|
|
|
|
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<Mock>,
|
|
}
|
|
|
|
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<Captured> {
|
|
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<Arc<Mock>>,
|
|
method: Method,
|
|
uri: Uri,
|
|
headers: HeaderMap,
|
|
request: Request<Body>,
|
|
) -> 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": "[email protected]",
|
|
"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": "[email protected]", "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</td>"));
|
|
assert!(table.contains(">Active</td>"));
|
|
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("<form"));
|
|
assert!(html.contains("No decisions."));
|
|
let suspend = section(&html, "?action=suspend");
|
|
assert!(suspend.contains(r#"name="duration_hours""#));
|
|
assert!(suspend.contains(r#"name="remove_evidence""#));
|
|
assert!(suspend.contains(&format!(">{REPORT_ID}</textarea>")));
|
|
assert!(suspend.contains(&format!(">{CHANNEL_ID}/{MESSAGE_ID}</textarea>")));
|
|
assert!(section(&html, "?action=warn").contains("Send warning"));
|
|
}
|
|
|
|
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#"<option value="13""#));
|
|
assert!(!close.contains("replace_pending_deletion_at"));
|
|
assert!(!close.contains("notify_user"));
|
|
let limit = between(&html, "?action=limit_features", "?action=warn");
|
|
for expected in [
|
|
r#"data-show-for-codes="3 6 8 18 21" hidden"#,
|
|
r#"data-show-for-codes="29" hidden"#,
|
|
r#"data-show-for-codes="12 14" hidden"#,
|
|
r#"data-show-for-limit="reports" hidden disabled"#,
|
|
r#"name="ends_on""#,
|
|
r#"<option value="29""#,
|
|
"Limit features",
|
|
] {
|
|
assert!(limit.contains(expected), "missing {expected}\n{limit}");
|
|
}
|
|
assert!(!limit.contains(r#"<option value="11""#));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn moderation_tab_for_a_legacy_deletion_offers_the_cancel_form() {
|
|
let mut subject = subject();
|
|
subject["pending_deletion_at"] = json!("2026-12-01T00:00:00.000Z");
|
|
subject["deletion_reason_code"] = json!(1);
|
|
subject["deletion_scheduled_by"] = json!(ADMIN_ID);
|
|
let app = setup(Mock::new(subject)).await;
|
|
let html = page(&app, &moderation_uri()).await;
|
|
let current = current_restriction(&html);
|
|
assert!(
|
|
current.contains("Scheduled for deletion. No decision owns it, so it is cancelled here.")
|
|
);
|
|
assert!(current.contains("?action=cancel_deletion&tab=moderation"));
|
|
assert!(current.contains("Cancel deletion"));
|
|
assert!(!current.contains("notify_user"));
|
|
let close = section(&html, "?action=close");
|
|
assert!(
|
|
close.contains(r#"name="replace_pending_deletion_at" value="2026-12-01T00:00:00.000Z""#)
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn decisions_page_lists_the_review_queue() {
|
|
let mut queued = list_item(12);
|
|
queued["kind"] = json!("feature_limit");
|
|
queued["limit"] = json!("purchases");
|
|
queued["automation"] = json!("automated");
|
|
queued["source"] = json!("own_review");
|
|
queued["ends_at"] = Value::Null;
|
|
queued["decided_by"] = json!("0");
|
|
queued["payment_at"] = json!("2026-10-01T09:00:00.000Z");
|
|
let mut mock = Mock::new(subject());
|
|
mock.decisions = vec![queued];
|
|
let app = setup(mock).await;
|
|
let html = page(&app, "/decisions").await;
|
|
let queue = section(&html, "data-review-queue");
|
|
assert!(queue.contains(&format!(r#"href="/decisions/{DECISION_ID}""#)));
|
|
assert!(queue.contains(&format!(r#"href="/users/{USER_ID}?tab=moderation""#)));
|
|
assert!(queue.contains("Payment fraud"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn decisions_page_hides_the_review_queue_without_permission() {
|
|
let mut mock = Mock::new(subject());
|
|
mock.admin_acls = vec!["user:lookup"];
|
|
let app = setup(mock).await;
|
|
let html = page(&app, "/decisions").await;
|
|
assert!(!html.contains("Review queue"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn moderation_tab_hides_decisions_and_actions_without_their_permissions() {
|
|
let mut mock = decision_mock(11);
|
|
mock.admin_acls = vec!["user:lookup", "user:update:flags"];
|
|
let app = setup(mock).await;
|
|
let html = page(&app, &moderation_uri()).await;
|
|
let current = current_restriction(&html);
|
|
assert!(current.contains("Banned permanently") || current.contains("Suspended until"));
|
|
assert!(current.contains("Open the decision to see or end it."));
|
|
assert!(!html.contains("data-decisions-table"));
|
|
assert!(!html.contains("?action=suspend"));
|
|
assert!(html.contains("?action=warn"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn overview_banner_links_the_decision_and_restriction_flags_are_read_only() {
|
|
let app = setup(decision_mock(11)).await;
|
|
let html = page(&app, &format!("/users/{USER_ID}")).await;
|
|
let banner = section(&html, "data-restriction-banner");
|
|
assert!(banner.contains(&format!(r#"href="/decisions/{DECISION_ID}""#)));
|
|
let disabled = format!(r#"name="flags[]" value="{DISABLED}" checked disabled"#);
|
|
assert!(html.contains(&disabled), "{html}");
|
|
assert!(html.contains("Restriction flags are read-only. Use the moderation tab."));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn decision_page_renders_the_full_response() {
|
|
let app = setup(decision_mock(11)).await;
|
|
let html = page(&app, &format!("/decisions/{DECISION_ID}")).await;
|
|
for expected in [
|
|
"Decision 1900000000000000001",
|
|
"Harassment or bullying",
|
|
"https://fluxer.com/guidelines#1-harassment-threats-and-privacy",
|
|
"Stop sending these messages.",
|
|
"We suspended your account.",
|
|
"We suspended your account until Jan 1, 2099.",
|
|
"the offending message",
|
|
"Attachment: proof.png",
|
|
"/messages?channel_id=1700000000000000001&message_id=1700000000000000002&context_limit=50",
|
|
"/reports/1600000000000000001",
|
|
"Ticket 42, three reports",
|
|
"?action=add_note",
|
|
"?action=end",
|
|
"Network blocks",
|
|
NETWORK_IP,
|
|
] {
|
|
assert!(html.contains(expected), "missing {expected}\n{html}");
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn an_expired_suspension_can_only_be_reversed() {
|
|
let mut mock = decision_mock(11);
|
|
for decision in [&mut mock.decision, &mut mock.decisions[0]] {
|
|
decision["status"] = json!("ended");
|
|
decision["end_outcome"] = json!("expired");
|
|
decision["ended_at"] = json!(SUSPENDED_UNTIL);
|
|
decision["ended_by"] = json!("0");
|
|
}
|
|
mock.decision["network_blocks"] = json!([]);
|
|
let app = setup(mock).await;
|
|
let html = page(&app, &format!("/decisions/{DECISION_ID}")).await;
|
|
assert!(html.contains(">Expired<"), "{html}");
|
|
assert!(html.contains("?action=end"));
|
|
assert!(html.contains(r#"value="reversed""#));
|
|
assert!(!html.contains(r#"value="lifted""#));
|
|
let html = page(&app, &moderation_uri()).await;
|
|
assert!(section(&html, "data-decisions-table").contains(">Expired</td>"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn decision_page_hides_the_end_form_without_the_ip_removal_permission() {
|
|
let mut mock = decision_mock(11);
|
|
mock.admin_acls = vec!["enforcement:view", "user:temp_ban"];
|
|
let app = setup(mock).await;
|
|
let html = page(&app, &format!("/decisions/{DECISION_ID}")).await;
|
|
assert!(html.contains("Network blocks"));
|
|
assert!(!html.contains("?action=end"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn suspend_card_offers_recent_networks_with_their_collateral() {
|
|
let app = setup(Mock::new(subject())).await;
|
|
let html = page(&app, &moderation_uri()).await;
|
|
let suspend = between(&html, "?action=suspend", "?action=close");
|
|
let networks = section(suspend, r#"data-show-for-duration_hours="0""#);
|
|
assert!(networks.contains("hidden disabled"));
|
|
assert!(networks.contains("Block networks this account used"));
|
|
assert!(networks.contains("0 other unrestricted accounts last active here"));
|
|
assert!(networks.contains("2 other unrestricted accounts last active here"));
|
|
assert!(networks.contains("Private or special-purpose range, never blocked"));
|
|
assert!(networks.contains(&format!(r#"value="{NETWORK_IP}" class="hidden""#)));
|
|
assert!(networks.contains(&format!(r#"value="{SHARED_IP}" disabled"#)));
|
|
assert!(networks.contains(r#"name="network_block_days" value="90""#));
|
|
let close = between(&html, "?action=close", "?action=limit_features");
|
|
assert!(close.contains("Block networks this account used"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn suspend_and_close_send_the_chosen_networks() {
|
|
let app = setup(Mock::new(subject())).await;
|
|
let captured = post(
|
|
&app,
|
|
&format!("/users/{USER_ID}?action=suspend&tab=moderation"),
|
|
&format!(
|
|
"duration_hours=0&reason_code=3&source=own_review&automation=none\
|
|
&statement=Spam&internal_note=Ticket+7&block_networks={NETWORK_IP}&network_block_days=30"
|
|
),
|
|
)
|
|
.await;
|
|
assert_eq!(
|
|
captured[0].body["block_networks"],
|
|
json!({"ips": [NETWORK_IP], "days": 30})
|
|
);
|
|
let captured = post(
|
|
&app,
|
|
&format!("/users/{USER_ID}?action=close&tab=moderation"),
|
|
&format!(
|
|
"basis=enforcement&days_until_deletion=74&reason_code=3&source=own_review\
|
|
&automation=none&statement=Spam&internal_note=Ticket+7&block_networks={NETWORK_IP}"
|
|
),
|
|
)
|
|
.await;
|
|
assert_eq!(
|
|
captured.last().unwrap().body["block_networks"],
|
|
json!({"ips": [NETWORK_IP], "days": 90})
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn moderation_tab_skips_networks_without_the_ip_permissions() {
|
|
let mut mock = Mock::new(subject());
|
|
mock.admin_acls = vec!["user:temp_ban", "user:delete"];
|
|
let app = setup(mock).await;
|
|
let html = page(&app, &moderation_uri()).await;
|
|
assert!(!html.contains("Block networks this account used"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn decision_page_renders_a_redacted_response_without_staff_forms() {
|
|
let mut mock = decision_mock(11);
|
|
mock.admin_acls = vec!["enforcement:view"];
|
|
mock.decision = redacted_decision();
|
|
let app = setup(mock).await;
|
|
let html = page(&app, &format!("/decisions/{DECISION_ID}")).await;
|
|
assert!(html.contains("Content is hidden. Needs the message:lookup permission."));
|
|
assert!(html.contains("Attachments: 1"));
|
|
assert!(!html.contains("the offending message"));
|
|
assert!(!html.contains("/reports/1600000000000000001"));
|
|
assert!(!html.contains("?action=add_note"));
|
|
assert!(!html.contains("?action=end"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn decisions_lookup_redirects_to_the_decision() {
|
|
let app = setup(Mock::new(subject())).await;
|
|
let (status, headers, _) = get(&app, &format!("/decisions?id=+{DECISION_ID}+")).await;
|
|
assert!(status.is_redirection());
|
|
assert_eq!(
|
|
headers
|
|
.get(header::LOCATION)
|
|
.and_then(|value| value.to_str().ok()),
|
|
Some(format!("/decisions/{DECISION_ID}").as_str())
|
|
);
|
|
let html = page(&app, "/decisions?id=abc").await;
|
|
assert!(html.contains("A decision ID is a number."));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn suspend_sends_the_decision_and_the_internal_note() {
|
|
let app = setup(Mock::new(subject())).await;
|
|
let captured = post(
|
|
&app,
|
|
&format!("/users/{USER_ID}?action=suspend&tab=moderation"),
|
|
&format!(
|
|
"duration_hours=0&reason_code=11&source=report&automation=flagged\
|
|
&report_ids={REPORT_ID}&evidence={CHANNEL_ID}%2F{MESSAGE_ID}\
|
|
&statement=Stop&internal_note=Ticket+42&remove_evidence=true"
|
|
),
|
|
)
|
|
.await;
|
|
assert_eq!(captured.len(), 1);
|
|
assert_eq!(captured[0].route, format!("PUT /admin/users/{USER_ID}/ban"));
|
|
assert_eq!(captured[0].audit_log_reason.as_deref(), Some("Ticket 42"));
|
|
assert_eq!(
|
|
captured[0].body,
|
|
json!({
|
|
"duration_hours": 0,
|
|
"remove_evidence": true,
|
|
"decision": {
|
|
"reason_code": 11,
|
|
"source": "report",
|
|
"automation": "flagged",
|
|
"report_ids": [REPORT_ID],
|
|
"evidence": [{"channel_id": CHANNEL_ID, "message_id": MESSAGE_ID}],
|
|
"statement": "Stop"
|
|
}
|
|
})
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn suspend_without_a_reason_sends_nothing() {
|
|
let app = setup(Mock::new(subject())).await;
|
|
let captured = post(
|
|
&app,
|
|
&format!("/users/{USER_ID}?action=suspend&tab=moderation"),
|
|
"duration_hours=24&source=report&automation=none&internal_note=x",
|
|
)
|
|
.await;
|
|
assert!(captured.is_empty());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn warn_sends_the_decision_with_the_rights_holder() {
|
|
let app = setup(Mock::new(subject())).await;
|
|
let captured = post(
|
|
&app,
|
|
&format!("/users/{USER_ID}?action=warn&tab=moderation"),
|
|
"reason_code=31&source=own_review&automation=none&statement=Remove+it\
|
|
&rights_holder=Example+Records&internal_note=DMCA+notice",
|
|
)
|
|
.await;
|
|
assert_eq!(captured.len(), 1);
|
|
assert_eq!(
|
|
captured[0].route,
|
|
format!("POST /admin/users/{USER_ID}/warnings")
|
|
);
|
|
assert_eq!(captured[0].audit_log_reason.as_deref(), Some("DMCA notice"));
|
|
assert_eq!(
|
|
captured[0].body,
|
|
json!({
|
|
"decision": {
|
|
"reason_code": 31,
|
|
"source": "own_review",
|
|
"automation": "none",
|
|
"statement": "Remove it",
|
|
"rights_holder": "Example Records"
|
|
}
|
|
})
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn ending_and_annotating_a_decision_send_their_requests() {
|
|
let app = setup(decision_mock(11)).await;
|
|
let end_uri = format!("/decisions/{DECISION_ID}?action=end");
|
|
let captured = post(
|
|
&app,
|
|
&end_uri,
|
|
"outcome=reversed&statement=We+made+a+mistake&internal_note=Appeal+upheld",
|
|
)
|
|
.await;
|
|
assert_eq!(
|
|
captured[0].route,
|
|
format!("POST /admin/enforcement-decisions/{DECISION_ID}/end")
|
|
);
|
|
assert_eq!(
|
|
captured[0].audit_log_reason.as_deref(),
|
|
Some("Appeal upheld")
|
|
);
|
|
assert_eq!(
|
|
captured[0].body,
|
|
json!({"outcome": "reversed", "statement": "We made a mistake"})
|
|
);
|
|
let captured = post(
|
|
&app,
|
|
&format!("/decisions/{DECISION_ID}?action=add_note"),
|
|
"body=Appeal+received",
|
|
)
|
|
.await;
|
|
assert_eq!(
|
|
captured[1].route,
|
|
format!("POST /admin/enforcement-decisions/{DECISION_ID}/notes")
|
|
);
|
|
assert_eq!(captured[1].body, json!({"body": "Appeal received"}));
|
|
let captured = post(&app, &end_uri, "statement=x&internal_note=y").await;
|
|
assert_eq!(captured.len(), 2, "an end without an outcome sends nothing");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn saving_flags_never_touches_restriction_flags() {
|
|
let app = setup(Mock::new(suspended_subject())).await;
|
|
let captured = post(
|
|
&app,
|
|
&format!("/users/{USER_ID}?action=update_flags&tab=overview"),
|
|
&format!("flags[]={}&flags[]={}", 1u64 << 50, 1u64 << 6),
|
|
)
|
|
.await;
|
|
assert_eq!(captured.len(), 1);
|
|
assert_eq!(
|
|
captured[0].route,
|
|
format!("PATCH /admin/users/{USER_ID}/flags")
|
|
);
|
|
assert_eq!(
|
|
captured[0].body,
|
|
json!({"add_flags": [(1u64 << 50).to_string()]})
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn shredder_and_delete_all_offer_a_basis_and_delete_all_refuses_restricted_reasons() {
|
|
let app = setup(Mock::new(subject())).await;
|
|
let html = page(
|
|
&app,
|
|
&format!(
|
|
"{}&delete_all_messages_dry_run=true&delete_all_messages_channel_count=1\
|
|
&delete_all_messages_message_count=2",
|
|
moderation_uri()
|
|
),
|
|
)
|
|
.await;
|
|
let delete_all = between(&html, "Delete all messages", "Message shredder");
|
|
for expected in [
|
|
"2 messages in 1 channel.",
|
|
r#"name="dry_run" value="false""#,
|
|
r#"name="basis" value="enforcement""#,
|
|
r#"name="basis" value="holder_request""#,
|
|
r#"name="basis" value="housekeeping""#,
|
|
r#"name="evidence""#,
|
|
] {
|
|
assert!(
|
|
delete_all.contains(expected),
|
|
"missing {expected}\n{delete_all}"
|
|
);
|
|
}
|
|
assert!(!delete_all.contains("Restricted: no details"));
|
|
let shredder = section(&html, "Message shredder");
|
|
assert!(shredder.contains(r#"name="basis" value="enforcement""#));
|
|
assert!(shredder.contains("Restricted: no details"));
|
|
assert!(
|
|
!shredder.contains(r#"name="evidence""#),
|
|
"the entries are the evidence"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn content_removal_forms_send_their_basis() {
|
|
let app = setup(Mock::new(subject())).await;
|
|
let captured = post(
|
|
&app,
|
|
&format!("/users/{USER_ID}?action=message_shred&tab=moderation"),
|
|
&format!(
|
|
"csv_data={CHANNEL_ID}%2C{MESSAGE_ID}&basis=enforcement&reason_code=3\
|
|
&source=own_review&automation=none&statement=Stop&internal_note=Ticket+7"
|
|
),
|
|
)
|
|
.await;
|
|
assert_eq!(
|
|
captured[0].route,
|
|
format!("POST /admin/users/{USER_ID}/message-shreds")
|
|
);
|
|
assert_eq!(captured[0].audit_log_reason.as_deref(), Some("Ticket 7"));
|
|
assert_eq!(
|
|
captured[0].body,
|
|
json!({
|
|
"entries": [{"channel_id": CHANNEL_ID, "message_id": MESSAGE_ID}],
|
|
"basis": {
|
|
"type": "enforcement",
|
|
"decision": {
|
|
"reason_code": 3,
|
|
"source": "own_review",
|
|
"automation": "none",
|
|
"statement": "Stop"
|
|
}
|
|
}
|
|
})
|
|
);
|
|
let captured = post(
|
|
&app,
|
|
&format!("/users/{USER_ID}?action=delete_all_messages&tab=moderation"),
|
|
"dry_run=false&basis=housekeeping&internal_note=Cleanup",
|
|
)
|
|
.await;
|
|
assert_eq!(
|
|
captured[1].route,
|
|
format!("DELETE /admin/users/{USER_ID}/messages")
|
|
);
|
|
assert_eq!(captured[1].body, json!({"basis": {"type": "housekeeping"}}));
|
|
assert_eq!(captured[1].audit_log_reason.as_deref(), Some("Cleanup"));
|
|
let captured = post(
|
|
&app,
|
|
&format!("/users/{USER_ID}?action=clear_fields&tab=account"),
|
|
"fields[]=bio&basis=holder_request",
|
|
)
|
|
.await;
|
|
assert_eq!(
|
|
captured[2].route,
|
|
format!("DELETE /admin/users/{USER_ID}/profile-fields")
|
|
);
|
|
assert_eq!(
|
|
captured[2].body,
|
|
json!({"fields": ["bio"], "basis": {"type": "holder_request"}})
|
|
);
|
|
let captured = post(
|
|
&app,
|
|
&format!("/users/{USER_ID}?action=delete_all_messages&tab=moderation"),
|
|
"dry_run=false",
|
|
)
|
|
.await;
|
|
assert_eq!(
|
|
captured.len(),
|
|
3,
|
|
"a deletion without a basis sends nothing"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn community_message_and_report_forms_send_their_basis_or_outcome() {
|
|
let app = setup(Mock::new(subject())).await;
|
|
let guild_id = "1800000000000000001";
|
|
let captured = post(
|
|
&app,
|
|
&format!("/guilds/{guild_id}?action=delete_guild&tab=moderation"),
|
|
"basis=enforcement&reason_code=26&source=report&automation=none\
|
|
&report_ids=1600000000000000001&internal_note=Reviewed",
|
|
)
|
|
.await;
|
|
assert_eq!(
|
|
captured[0].route,
|
|
format!("DELETE /admin/guilds/{guild_id}")
|
|
);
|
|
assert_eq!(captured[0].audit_log_reason.as_deref(), Some("Reviewed"));
|
|
assert_eq!(
|
|
captured[0].body,
|
|
json!({
|
|
"basis": {
|
|
"type": "enforcement",
|
|
"decision": {
|
|
"reason_code": 26,
|
|
"source": "report",
|
|
"automation": "none",
|
|
"report_ids": [REPORT_ID]
|
|
}
|
|
}
|
|
})
|
|
);
|
|
let captured = post(
|
|
&app,
|
|
"/messages?action=delete",
|
|
&format!("channel_id={CHANNEL_ID}&message_id={MESSAGE_ID}&basis=housekeeping"),
|
|
)
|
|
.await;
|
|
assert_eq!(
|
|
captured[1].route,
|
|
format!("DELETE /admin/channels/{CHANNEL_ID}/messages/{MESSAGE_ID}")
|
|
);
|
|
assert_eq!(captured[1].body, json!({"basis": {"type": "housekeeping"}}));
|
|
let captured = post(
|
|
&app,
|
|
&format!("/reports/{REPORT_ID}/resolve"),
|
|
"resolution=actioned&public_comment=Handled",
|
|
)
|
|
.await;
|
|
assert_eq!(
|
|
captured.len(),
|
|
2,
|
|
"an actioned resolution without an outcome sends nothing"
|
|
);
|
|
}
|
|
|
|
fn test_config(api_endpoint: String) -> AdminConfig {
|
|
AdminConfig {
|
|
host: "127.0.0.1".to_owned(),
|
|
port: 0,
|
|
secret_key_base: SECRET_KEY.to_owned(),
|
|
base_path: String::new(),
|
|
api_endpoint,
|
|
media_endpoint: "https://media.example.test".to_owned(),
|
|
static_cdn_endpoint: "https://static.example.test".to_owned(),
|
|
reports_bucket_origin: "https://reports.example.test".to_owned(),
|
|
admin_endpoint: "https://admin.example.test".to_owned(),
|
|
web_app_endpoint: "https://app.example.test".to_owned(),
|
|
oauth_client_id: "admin-client".to_owned(),
|
|
oauth_client_secret: "admin-secret".to_owned(),
|
|
oauth_redirect_uri: "https://admin.example.test/callback".to_owned(),
|
|
build_version: "test".to_owned(),
|
|
self_hosted: false,
|
|
proxy: ProxyConfig {
|
|
trust_client_ip_header: false,
|
|
client_ip_header_name: "x-forwarded-for".to_owned(),
|
|
},
|
|
}
|
|
}
|