fix(api): send correct staff emails and allow suppressing them (#3048)

This commit is contained in:
Hampus
2026-09-29 23:55:54 +02:00
committed by GitHub
parent f0b3c82cfd
commit 39f9beda5a
113 changed files with 2496 additions and 206 deletions
+51 -8
View File
@@ -5205,7 +5205,7 @@
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
}
},
"description": "Moves a report to the resolved status with an optional public comment shown to the reporter. Marks the report as handled, notifies the reporter, and creates an audit log entry. Requires REPORT_RESOLVE permission.",
"description": "Moves a report to the resolved status with an optional public comment shown to the reporter. Marks the report as handled, notifies the reporter by system DM and email unless notify_reporter is false, and creates an audit log entry. Requires REPORT_RESOLVE permission.",
"security": [{"adminApiKey": []}],
"parameters": [
{
@@ -5993,7 +5993,7 @@
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
}
},
"description": "Apply temporary ban to user account for specified duration, or permanently with a duration of zero. Prevents login and guild operations. Automatically lifts after expiry. Creates audit log entry. Requires USER_TEMP_BAN permission.",
"description": "Apply temporary ban to user account for specified duration, or permanently with a duration of zero. Prevents login and guild operations. Automatically lifts after expiry. Creates audit log entry. Requires USER_TEMP_BAN permission. Emails the user for temporary bans unless notify_user is false. Permanent bans are never emailed.",
"security": [{"adminApiKey": []}],
"parameters": [
{
@@ -6057,7 +6057,7 @@
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
}
},
"description": "Immediately remove temporary ban from user account. User can log in and access guilds again. Creates audit log entry. Requires USER_TEMP_BAN permission.",
"description": "Immediately remove the ban from the user account. Emails the user only when notify_user is true, the ban was still in force and the account is not closed or pending deletion. The email shows public_reason and never the audit log reason. Creates audit log entry. Requires USER_TEMP_BAN permission.",
"security": [{"adminApiKey": []}],
"parameters": [
{
@@ -6067,7 +6067,11 @@
"schema": {"description": "The ID of the user", "allOf": [{"$ref": "#/components/schemas/SnowflakeType"}]},
"description": "The ID of the user"
}
]
],
"requestBody": {
"required": false,
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/AdminUserUnbanRequest"}}}
}
}
},
"/admin/users/{user_id}/ban/notes": {
@@ -6392,7 +6396,7 @@
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
}
},
"description": "Schedule user account for deletion after grace period. Account will be fully deleted with all content unless cancellation is executed. When a deletion is already scheduled, the request must name it in replace_pending_deletion_at or it returns 409. Records who scheduled the deletion. Creates audit log entry. Requires USER_DELETE permission.",
"description": "Schedule user account for deletion after grace period. Account will be fully deleted with all content unless cancellation is executed. When a deletion is already scheduled, the request must name it in replace_pending_deletion_at or it returns 409. Records who scheduled the deletion. Creates audit log entry. Requires USER_DELETE permission. Emails the user unless notify_user is false. The email depends on reason_code: user requested and inactivity get neutral wording, other codes get enforcement wording with an appeal path.",
"security": [{"adminApiKey": []}],
"parameters": [
{
@@ -7772,7 +7776,7 @@
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
}
},
"description": "Disable user account due to suspicious activity or abuse. Account is locked pending review. User cannot access services. Creates audit log entry. Requires USER_DISABLE_SUSPICIOUS permission.",
"description": "Disable user account due to suspicious activity or abuse. Account is locked pending review. User cannot access services. Emails the user unless notify_user is false. Creates audit log entry. Requires USER_DISABLE_SUSPICIOUS permission.",
"security": [{"adminApiKey": []}],
"parameters": [
{
@@ -9394,6 +9398,11 @@
"flags": {
"description": "Bitmask of suspicious activity flags that triggered the disable",
"allOf": [{"$ref": "#/components/schemas/SuspiciousActivityFlags"}]
},
"notify_user": {
"default": true,
"description": "Whether to email the user that the account was disabled",
"type": "boolean"
}
},
"required": ["flags"]
@@ -9806,6 +9815,11 @@
"type": "string",
"format": "date-time",
"pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z))$"
},
"notify_user": {
"default": true,
"description": "Whether to email the user about the scheduled deletion",
"type": "boolean"
}
},
"required": ["reason_code"]
@@ -9860,6 +9874,20 @@
},
"required": ["ban_audit_log_id", "note"]
},
"AdminUserUnbanRequest": {
"type": "object",
"properties": {
"notify_user": {
"default": true,
"description": "Whether to email the user that the suspension was lifted",
"type": "boolean"
},
"public_reason": {
"description": "Reason shown to the user in the unban email. The audit log reason is never emailed",
"type": "string"
}
}
},
"AdminUserBanRequest": {
"type": "object",
"properties": {
@@ -9869,7 +9897,12 @@
"maximum": 8760,
"description": "Duration of the ban in hours. Use 0 for a permanent ban (until manually unbanned)."
},
"reason": {"description": "Reason for the temporary ban", "type": "string"}
"reason": {"description": "Reason shown to the user in the ban email", "type": "string"},
"notify_user": {
"default": true,
"description": "Whether to email the user about a temporary ban. Permanent bans (duration_hours 0) are never emailed",
"type": "boolean"
}
},
"required": ["duration_hours"]
},
@@ -10059,7 +10092,12 @@
"type": "object",
"properties": {
"status": {"type": "string", "enum": ["resolved"], "description": "The status to move the report to"},
"public_comment": {"description": "Public comment to include with the resolution", "type": "string"}
"public_comment": {"description": "Public comment to include with the resolution", "type": "string"},
"notify_reporter": {
"default": true,
"description": "Whether to notify the reporter by system DM and email",
"type": "boolean"
}
},
"required": ["status"]
},
@@ -12318,6 +12356,11 @@
"minimum": 1,
"maximum": 365
},
"notify_user": {
"default": true,
"description": "Whether to email the user about the scheduled deletion",
"type": "boolean"
},
"user_ids": {
"maxItems": 1000,
"type": "array",
+2
View File
@@ -86,6 +86,7 @@ impl AdminApiClient {
reason_code: u32,
days_until_deletion: u32,
public_reason: Option<&str>,
notify_user: bool,
audit_log_reason: Option<&str>,
) -> ApiResult<BulkJobResponse> {
let body = generated_types::AdminBulkJobCreateRequest::ScheduleUserDeletion {
@@ -95,6 +96,7 @@ impl AdminApiClient {
)
.map_err(ApiError::Parse)?
.into(),
notify_user,
public_reason: public_reason.map(std::borrow::ToOwned::to_owned),
reason_code: crate::api::generated::deletion_reason_code(
i32::try_from(reason_code).map_err(|e| ApiError::Parse(e.to_string()))?,
+2
View File
@@ -56,12 +56,14 @@ impl AdminApiClient {
&self,
report_id: &str,
public_comment: Option<&str>,
notify_reporter: bool,
audit_log_reason: Option<&str>,
) -> ApiResult<ResolveReportResponse> {
let mut body = serde_json::json!({"status": "resolved"});
if let Some(public_comment) = public_comment {
body["public_comment"] = serde_json::Value::from(public_comment);
}
body["notify_reporter"] = serde_json::Value::from(notify_reporter);
self.patch_with_reason(
&format!("/admin/reports/{}", urlencoding::encode(report_id)),
Some(&body),
+17 -3
View File
@@ -393,12 +393,14 @@ impl AdminApiClient {
user_id: &str,
duration_hours: u32,
reason: Option<&str>,
notify_user: bool,
private_reason: Option<&str>,
) -> ApiResult<AdminUser> {
let body = generated_types::AdminUserBanRequest {
duration_hours: i32::try_from(duration_hours)
.map_err(|e| ApiError::Parse(e.to_string()))?
.into(),
notify_user,
reason: reason.map(std::borrow::ToOwned::to_owned),
};
let resp: UserMutationResponse = self
@@ -411,10 +413,20 @@ impl AdminApiClient {
Ok(resp.user)
}
pub async fn unban_user(&self, user_id: &str) -> ApiResult<AdminUser> {
pub async fn unban_user(
&self,
user_id: &str,
public_reason: Option<&str>,
notify_user: bool,
private_reason: Option<&str>,
) -> ApiResult<AdminUser> {
let body = generated_types::AdminUserUnbanRequest {
notify_user,
public_reason: public_reason.map(std::borrow::ToOwned::to_owned),
};
let response = self
.generated()
.unban_admin_user(&snowflake(user_id))
.generated_with_reason(private_reason)?
.unban_admin_user(&snowflake(user_id), &body)
.await
.map_err(|e| self.generated_error(e))?;
let resp: UserMutationResponse = self.generated_value(response.into_inner())?;
@@ -427,6 +439,7 @@ impl AdminApiClient {
reason_code: i32,
public_reason: Option<&str>,
days_until_deletion: u32,
notify_user: bool,
audit_log_reason: Option<&str>,
) -> ApiResult<AdminUser> {
let body = generated_types::AdminUserDeletionScheduleRequest {
@@ -436,6 +449,7 @@ impl AdminApiClient {
)
.map_err(ApiError::Parse)?
.into(),
notify_user,
public_reason: public_reason.map(std::borrow::ToOwned::to_owned),
reason_code: crate::api::generated::deletion_reason_code(reason_code, "reason_code")
.map_err(ApiError::Parse)?,
@@ -261,12 +261,14 @@ pub(crate) async fn bulk_actions_post(
);
};
let public_reason = form.clean("public_reason");
let notify_user = form.opt_out_value("notify_user");
client
.bulk_schedule_user_deletion(
&user_ids,
reason_code.unwrap_or(2),
days.unwrap_or(14),
public_reason.as_deref(),
notify_user,
audit_log_reason.as_deref(),
)
.await
+7 -1
View File
@@ -52,6 +52,10 @@ struct ResolveForm {
_csrf: Option<String>,
#[serde(default)]
resolution: Option<String>,
#[serde(default)]
notify_reporter: Option<String>,
#[serde(default)]
notify_reporter_present: Option<String>,
}
pub fn router() -> Router<AppState> {
@@ -227,8 +231,10 @@ async fn report_resolve(
};
let client = AdminApiClient::new(state.http_client(), config, &auth.0.session);
let public_comment = clean_string(form.resolution.as_deref().unwrap_or(""));
let notify_reporter =
form.notify_reporter_present.is_none() || form.notify_reporter.as_deref() == Some("true");
let result = client
.resolve_report(&report_id, public_comment.as_deref(), None)
.resolve_report(&report_id, public_comment.as_deref(), notify_reporter, None)
.await;
match result {
Ok(_) => {
+21 -5
View File
@@ -244,12 +244,14 @@ pub async fn dispatch(
};
let reason = get("reason");
let private = get("private_reason");
let notify_user = form.opt_out_value("notify_user");
DispatchOutcome::from_result(
client
.temp_ban_user(
user_id,
duration.unwrap_or(24),
reason.as_deref(),
notify_user,
private.as_deref(),
)
.await,
@@ -257,11 +259,23 @@ pub async fn dispatch(
"Failed to temporarily ban user",
)
}
"unban" => DispatchOutcome::from_result(
client.unban_user(user_id).await,
"User unbanned successfully",
"Failed to unban user",
),
"unban" => {
let public_reason = get("public_reason");
let private_reason = get("private_reason");
let notify_user = form.opt_out_value("notify_user");
DispatchOutcome::from_result(
client
.unban_user(
user_id,
public_reason.as_deref(),
notify_user,
private_reason.as_deref(),
)
.await,
"User unbanned successfully",
"Failed to unban user",
)
}
"ban_ip" => {
let Some(ip) = get("ip") else {
return DispatchOutcome::error("IP address is required");
@@ -291,6 +305,7 @@ pub async fn dispatch(
let Ok(days) = form.parse_value_any::<u32>(&["days_until_deletion", "days"]) else {
return DispatchOutcome::error("Invalid deletion delay");
};
let notify_user = form.opt_out_value("notify_user");
DispatchOutcome::from_result(
client
.schedule_deletion(
@@ -298,6 +313,7 @@ pub async fn dispatch(
reason_code.unwrap_or(0),
public_reason.as_deref(),
days.unwrap_or(60),
notify_user,
private_reason.as_deref(),
)
.await,
@@ -227,6 +227,13 @@ pub fn checkbox(name: &str, value: &str, label: &str, checked: bool, enabled: bo
}
}
pub fn opt_out_checkbox(name: &str, label: &str) -> Markup {
html! {
input type="hidden" name={(name) "_present"} value="1";
(checkbox(name, "true", label, true, true))
}
}
pub fn secondary_button_link(label: &str, href: &str) -> Markup {
html! {
a href=(href) role="button"
@@ -8,7 +8,8 @@ use crate::{
components::{
form::{
FORM_SELECT_CLASS, checkbox, csrf_input, danger_button, form_actions,
form_field_group, select_chevron, submit_button, text_input, textarea_input,
form_field_group, opt_out_checkbox, select_chevron, submit_button, text_input,
textarea_input,
},
page_container::page_header,
section_card::section_card_simple,
@@ -426,6 +427,7 @@ fn bulk_schedule_deletion_section(base: &str, csrf_token: &str) -> Markup {
},
))
(text_input("audit_log_reason", "Audit Log Reason (optional)", "", "Reason for this bulk operation"))
(opt_out_checkbox("notify_user", "Email each user about the scheduled deletion"))
(form_actions(html! {
(danger_button("Schedule Deletion"))
}))
@@ -484,6 +486,13 @@ mod tests {
assert!(markup.contains(r#"name="days_until_deletion" value="60" min="14" max="365""#));
}
#[test]
fn deletion_form_emails_each_user_by_default() {
let markup = bulk_schedule_deletion_section("/admin", "csrf").into_string();
assert!(markup.contains(r#"name="notify_user" value="true" checked"#));
assert!(markup.contains(r#"name="notify_user_present" value="1""#));
}
#[test]
fn remove_grid_can_clear_the_deprecated_clone_features() {
let markup = guild_feature_checkbox_grid("remove_features[]", true).into_string();
@@ -9,7 +9,7 @@ use crate::{
components::{
badge::{BadgeVariant, badge},
data_field::{data_field, data_field_link_mono, data_field_mono, data_field_text},
form::csrf_input,
form::{csrf_input, opt_out_checkbox},
media::{guild_icon_url, initials, user_avatar_url},
message_data::ordered_messages,
message_list::{message_deletion_script, message_list},
@@ -376,16 +376,7 @@ fn actions_card(config: &AdminConfig, report: &ReportEntry, csrf_token: &str) ->
(section_card(Some("Actions"), None, None, html! {
div class="flex flex-col gap-3" {
@if report.status == 0 {
form method="post"
action={(base) "/reports/" (&report.report_id) "/resolve"} {
(csrf_input(csrf_token))
button type="submit"
class="inline-flex w-full items-center justify-center gap-2 \
font-medium rounded-lg bg-neutral-900 text-white \
px-4 py-2 text-sm" {
"Resolve Report"
}
}
(resolve_report_form(base, &report.report_id, csrf_token))
}
@if report.report_type == 0 || report.report_type == 1 {
@if let Some(ref reported_id) = report.reported_user_id {
@@ -410,6 +401,29 @@ fn actions_card(config: &AdminConfig, report: &ReportEntry, csrf_token: &str) ->
}
}
fn resolve_report_form(base: &str, report_id: &str, csrf_token: &str) -> Markup {
html! {
form method="post" action={(base) "/reports/" (report_id) "/resolve"} class="flex flex-col gap-3" {
(csrf_input(csrf_token))
label for="resolution" class="block text-sm font-medium text-neutral-700" {
"Public comment to the reporter (optional)"
}
textarea id="resolution" name="resolution" rows="3" maxlength="512"
class="block w-full rounded-md border border-neutral-300 \
px-3 py-2 text-sm shadow-sm \
focus:border-brand-primary focus:outline-none \
focus:ring-1 focus:ring-brand-primary" {}
(opt_out_checkbox("notify_reporter", "Notify the reporter by DM and email"))
button type="submit"
class="inline-flex w-full items-center justify-center gap-2 \
font-medium rounded-lg bg-neutral-900 text-white \
px-4 py-2 text-sm" {
"Resolve Report"
}
}
}
}
fn nav_link(href: &str, label: &str) -> Markup {
html! {
a href=(href)
@@ -532,3 +546,18 @@ fn basic_info_section_fragment(config: &AdminConfig, report: &ReportEntry) -> Ma
}))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn resolve_form_offers_a_public_comment_and_notifies_the_reporter_by_default() {
let markup = resolve_report_form("/admin", "1500000000000000001", "csrf").into_string();
assert!(markup.contains(r#"action="/admin/reports/1500000000000000001/resolve""#));
assert!(markup.contains(r#"name="resolution""#));
assert!(markup.contains(r#"maxlength="512""#));
assert!(markup.contains(r#"name="notify_reporter" value="true" checked"#));
assert!(markup.contains(r#"name="notify_reporter_present" value="1""#));
}
}
@@ -8,7 +8,9 @@ use crate::{
},
config::AdminConfig,
templates::components::{
form::{checkbox, csrf_input, danger_button, form_actions, submit_button},
form::{
checkbox, csrf_input, danger_button, form_actions, opt_out_checkbox, submit_button,
},
page_container::card_with_header,
},
utils::timestamps::format_admin_timestamp,
@@ -142,9 +144,26 @@ fn ban_actions_card(
form method="post"
action={(base) "/users/" (user.id) "?action=unban&tab=moderation"} {
(csrf_input(csrf_token))
(form_actions(html! {
(submit_button("Unban User"))
}))
div class="space-y-3" {
(form_label("Public Reason (optional, shown to the user)"))
input type="text" name="public_reason"
placeholder="Enter public unban reason..." maxlength="512"
class="block w-full rounded-md border border-neutral-300 \
px-3 py-2 text-sm shadow-sm \
focus:border-brand-primary focus:outline-none \
focus:ring-1 focus:ring-brand-primary";
(form_label("Private Reason (optional, audit log)"))
input type="text" name="private_reason"
placeholder="Enter private unban reason (audit log)..."
class="block w-full rounded-md border border-neutral-300 \
px-3 py-2 text-sm shadow-sm \
focus:border-brand-primary focus:outline-none \
focus:ring-1 focus:ring-brand-primary";
(opt_out_checkbox("notify_user", "Email the user that the suspension was lifted"))
(form_actions(html! {
(submit_button("Unban User"))
}))
}
}
} @else {
form method="post"
@@ -163,7 +182,7 @@ fn ban_actions_card(
}
(form_label("Public Reason (optional)"))
input type="text" name="reason"
placeholder="Enter public ban reason..."
placeholder="Enter public ban reason..." maxlength="512"
class="block w-full rounded-md border border-neutral-300 \
px-3 py-2 text-sm shadow-sm \
focus:border-brand-primary focus:outline-none \
@@ -175,6 +194,7 @@ fn ban_actions_card(
px-3 py-2 text-sm shadow-sm \
focus:border-brand-primary focus:outline-none \
focus:ring-1 focus:ring-brand-primary";
(opt_out_checkbox("notify_user", "Email the user about this suspension (temporary bans only)"))
(form_actions(html! {
(submit_button("Ban/Suspend User"))
}))
@@ -365,7 +385,7 @@ fn deletion_card(
}
(form_label("Public Reason (optional)"))
input type="text" name="public_reason"
placeholder="Enter public reason..."
placeholder="Enter public reason..." maxlength="512"
class="block w-full rounded-md border border-neutral-300 \
px-3 py-2 text-sm shadow-sm \
focus:border-brand-primary focus:outline-none \
@@ -377,6 +397,7 @@ fn deletion_card(
px-3 py-2 text-sm shadow-sm \
focus:border-brand-primary focus:outline-none \
focus:ring-1 focus:ring-brand-primary";
(opt_out_checkbox("notify_user", "Email the user about the scheduled deletion"))
(form_actions(html! {
(submit_button("Schedule Deletion"))
}))
@@ -778,6 +799,33 @@ mod tests {
assert!(!markup.contains("replace_pending_deletion_at"));
}
#[test]
fn schedule_form_emails_the_user_by_default() {
let markup = deletion_card("/admin", &user(json!({})), "csrf", None).into_string();
assert!(markup.contains(r#"name="notify_user" value="true" checked"#));
assert!(markup.contains(r#"name="notify_user_present" value="1""#));
}
#[test]
fn temp_ban_form_emails_the_user_by_default() {
let markup = ban_actions_card("/admin", &user(json!({})), "csrf", None).into_string();
assert!(markup.contains(r#"name="notify_user" value="true" checked"#));
assert!(markup.contains(r#"name="notify_user_present" value="1""#));
}
#[test]
fn unban_form_separates_the_public_and_private_reasons() {
let target = user(json!({"temp_banned_until": "2026-10-01T00:00:00.000Z"}));
let markup = ban_actions_card("/admin", &target, "csrf", None).into_string();
assert!(markup.contains("?action=unban&amp;tab=moderation"));
assert!(markup.contains(r#"name="notify_user" value="true" checked"#));
assert!(markup.contains(r#"name="notify_user_present" value="1""#));
assert!(markup.contains(
r#"name="public_reason" placeholder="Enter public unban reason..." maxlength="512""#
));
assert!(markup.contains(r#"name="private_reason""#));
}
#[test]
fn current_ban_is_the_entry_matching_the_ban_end_and_notes_attach_to_it() {
let target = user(json!({"temp_banned_until": "2026-10-01T00:00:00.000Z"}));
+4
View File
@@ -100,6 +100,10 @@ impl MultiValueForm {
})
}
pub fn opt_out_value(&self, key: &str) -> bool {
!self.contains_key(&format!("{key}_present")) || self.bool_value(key)
}
pub fn list_values(&self, key: &str) -> Vec<String> {
self.fields
.get(key)
+369
View File
@@ -0,0 +1,369 @@
// 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, RuntimeEnv},
session,
};
use serde_json::{Value, json};
use std::sync::{Arc, Mutex};
use tokio::net::TcpListener;
use tower::ServiceExt;
const SECRET_KEY: &str = "notification-writes-test-secret";
const USER_ID: &str = "1500000000000000001";
const REPORT_ID: &str = "1600000000000000001";
#[derive(Clone)]
struct CapturedRequest {
route: String,
audit_log_reason: Option<String>,
body: Value,
}
type CapturedRequests = Arc<Mutex<Vec<CapturedRequest>>>;
async fn submit(uri: &str, fields: &str) -> CapturedRequest {
let app = setup().await;
let csrf_token = csrf_token(&app).await;
let status = post_form(&app, uri, &format!("_csrf={csrf_token}&{fields}")).await;
assert_eq!(status, StatusCode::SEE_OTHER);
let captured = app.captured.lock().expect("captured requests");
assert_eq!(captured.len(), 1, "expected one write request");
captured[0].clone()
}
#[tokio::test]
async fn temp_ban_sends_notify_user_from_the_checkbox() {
let uri = format!("/users/{USER_ID}?action=temp_ban&tab=moderation");
let checked = submit(
&uri,
"duration=24&reason=Spam&notify_user_present=1&notify_user=true",
)
.await;
assert_eq!(checked.route, format!("PUT /admin/users/{USER_ID}/ban"));
assert_eq!(checked.body["notify_user"], json!(true));
let unchecked = submit(&uri, "duration=24&reason=Spam&notify_user_present=1").await;
assert_eq!(unchecked.body["notify_user"], json!(false));
let stale_form = submit(&uri, "duration=24&reason=Spam").await;
assert_eq!(stale_form.body["notify_user"], json!(true));
}
#[tokio::test]
async fn unban_sends_the_public_reason_in_the_body_and_the_private_reason_as_a_header() {
let uri = format!("/users/{USER_ID}?action=unban&tab=moderation");
let checked = submit(
&uri,
"public_reason=Appeal%20accepted&private_reason=Private%20staff%20note&notify_user_present=1&notify_user=true",
)
.await;
assert_eq!(checked.route, format!("DELETE /admin/users/{USER_ID}/ban"));
assert_eq!(checked.body["notify_user"], json!(true));
assert_eq!(checked.body["public_reason"], json!("Appeal accepted"));
assert_eq!(
checked.audit_log_reason.as_deref(),
Some("Private staff note")
);
assert!(!checked.body.to_string().contains("Private staff note"));
let unchecked = submit(
&uri,
"private_reason=Private%20staff%20note&notify_user_present=1",
)
.await;
assert_eq!(unchecked.body["notify_user"], json!(false));
assert!(!unchecked.body.to_string().contains("Private staff note"));
let stale_form = submit(&uri, "").await;
assert_eq!(stale_form.body["notify_user"], json!(true));
}
#[tokio::test]
async fn schedule_deletion_sends_notify_user_from_the_checkbox() {
let uri = format!("/users/{USER_ID}?action=schedule_deletion&tab=moderation");
let checked = submit(
&uri,
"reason_code=3&days_until_deletion=60&notify_user_present=1&notify_user=true",
)
.await;
assert_eq!(
checked.route,
format!("PUT /admin/users/{USER_ID}/deletion")
);
assert_eq!(checked.body["notify_user"], json!(true));
let unchecked = submit(
&uri,
"reason_code=3&days_until_deletion=60&notify_user_present=1",
)
.await;
assert_eq!(unchecked.body["notify_user"], json!(false));
let stale_form = submit(&uri, "reason_code=3&days_until_deletion=60").await;
assert_eq!(stale_form.body["notify_user"], json!(true));
}
#[tokio::test]
async fn bulk_schedule_deletion_sends_notify_user_from_the_checkbox() {
let uri = "/bulk-actions?action=bulk-schedule-user-deletion";
let fields = format!("user_ids={USER_ID}&reason_code=3&days_until_deletion=60");
let checked = submit(
uri,
&format!("{fields}&notify_user_present=1&notify_user=true"),
)
.await;
assert_eq!(checked.route, "POST /admin/bulk-jobs");
assert_eq!(checked.body["task"], json!("schedule_user_deletion"));
assert_eq!(checked.body["notify_user"], json!(true));
let unchecked = submit(uri, &format!("{fields}&notify_user_present=1")).await;
assert_eq!(unchecked.body["notify_user"], json!(false));
let stale_form = submit(uri, &fields).await;
assert_eq!(stale_form.body["notify_user"], json!(true));
}
#[tokio::test]
async fn report_resolve_sends_notify_reporter_from_the_checkbox() {
let uri = format!("/reports/{REPORT_ID}/resolve");
let checked = submit(
&uri,
"resolution=Handled&notify_reporter_present=1&notify_reporter=true",
)
.await;
assert_eq!(checked.route, format!("PATCH /admin/reports/{REPORT_ID}"));
assert_eq!(checked.body["public_comment"], json!("Handled"));
assert_eq!(checked.body["notify_reporter"], json!(true));
let unchecked = submit(&uri, "resolution=Handled&notify_reporter_present=1").await;
assert_eq!(unchecked.body["notify_reporter"], json!(false));
let stale_form = submit(&uri, "resolution=Handled").await;
assert_eq!(stale_form.body["notify_reporter"], json!(true));
}
struct TestApp {
router: Router,
session_cookie: String,
captured: CapturedRequests,
}
async fn setup() -> TestApp {
let captured: CapturedRequests = Arc::new(Mutex::new(Vec::new()));
let api_endpoint = spawn_mock_api(Arc::clone(&captured)).await;
let router = build_router(test_config(api_endpoint));
let session_value = session::create_session("1500000000000000000", "test-token", SECRET_KEY);
TestApp {
router,
session_cookie: format!("{}={session_value}", session::SESSION_COOKIE_NAME),
captured,
}
}
async fn csrf_token(app: &TestApp) -> String {
let response = app
.router
.clone()
.oneshot(
Request::builder()
.method(Method::GET)
.uri("/voice-regions")
.header(header::COOKIE, &app.session_cookie)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
response
.headers()
.get_all(header::SET_COOKIE)
.iter()
.filter_map(|value| value.to_str().ok())
.find_map(|value| {
let pair = value.split(';').next()?;
let token = pair
.strip_prefix("__Host-csrf_token=")
.or_else(|| pair.strip_prefix("csrf_token="))?;
(!token.is_empty()).then(|| token.to_owned())
})
.expect("csrf_token cookie")
}
async fn post_form(app: &TestApp, uri: &str, body: &str) -> StatusCode {
let csrf = body
.split('&')
.find_map(|pair| pair.strip_prefix("_csrf="))
.expect("form carries a csrf token");
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(body.to_owned()))
.unwrap(),
)
.await
.unwrap();
response.status()
}
async fn spawn_mock_api(captured: CapturedRequests) -> String {
let listener = TcpListener::bind(("127.0.0.1", 0)).await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move {
axum::serve(
listener,
Router::new().fallback(mock_api).with_state(captured),
)
.await
.unwrap();
});
format!("http://{addr}")
}
async fn mock_api(
State(captured): State<CapturedRequests>,
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();
let body: Value = serde_json::from_slice(&bytes).unwrap_or(Value::Null);
let audit_log_reason = headers
.get("x-audit-log-reason")
.and_then(|value| value.to_str().ok())
.map(ToOwned::to_owned);
captured
.lock()
.expect("captured requests")
.push(CapturedRequest {
route: format!("{method} {path}"),
audit_log_reason,
body,
});
}
match (method, path.as_str()) {
(Method::GET, "/admin/users/@me") => Json(json!({ "user": admin_user() })).into_response(),
(Method::GET, "/admin/voice/regions") => {
Json(json!({ "regions": [region()] })).into_response()
}
(Method::PUT | Method::DELETE, _) if path.starts_with("/admin/users/") => {
Json(json!({ "user": admin_user() })).into_response()
}
(Method::POST, "/admin/bulk-jobs") => Json(json!({ "job_id": "1" })).into_response(),
(Method::PATCH, _) if path.starts_with("/admin/reports/") => Json(json!({
"report_id": REPORT_ID,
"status": 1,
"resolved_at": null,
"public_comment": null
}))
.into_response(),
_ => (
StatusCode::NOT_FOUND,
Json(json!({ "message": "not found" })),
)
.into_response(),
}
}
fn region() -> Value {
json!({
"id": "europe-north",
"name": "Northern Europe",
"emoji": "flag",
"latitude": 59.33,
"longitude": 18.06,
"is_default": true,
"vip_only": false,
"required_guild_features": [],
"allowed_guild_ids": [],
"allowed_user_ids": [],
"created_at": null,
"updated_at": null
})
}
fn admin_user() -> Value {
json!({
"id": "1500000000000000000",
"username": "AdminUser",
"discriminator": 1,
"avatar": null,
"banner": null,
"email": "[email protected]",
"email_verified": true,
"email_bounced": false,
"global_name": "AdminUser",
"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,
"suspicious_activity_flags": 0,
"phone_verification_deferred": false,
"has_totp": false,
"authenticator_types": [],
"has_verified_phone": false,
"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,
"last_active_at": null,
"last_active_ip": null,
"last_active_ip_reverse": null,
"last_active_location": null
})
}
fn test_config(api_endpoint: String) -> AdminConfig {
AdminConfig {
env: RuntimeEnv::Test,
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(),
admin_endpoint: "https://admin.example.test".to_owned(),
web_app_endpoint: "https://app.example.test".to_owned(),
kv_url: String::new(),
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(),
release_channel: "test".to_owned(),
self_hosted: false,
proxy: ProxyConfig {
trust_client_ip_header: false,
client_ip_header_name: "x-forwarded-for".to_owned(),
},
}
}