fix(admin): apply audit logs and side effects to bulk actions (#2758)

This commit is contained in:
Hampus authored and GitHub committed 2026-09-14 14:34:43 +02:00
1 parent a9dc74a520
commit 91a2604e9e
42 files changed
+1988 -916

No files matched your search

@@ -54,6 +54,7 @@ pub const NAV_SECTIONS: &[NavSection] = &[
"bulk-actions",
[
acl::BULK_UPDATE_USER_FLAGS,
acl::BULK_UPDATE_SUSPICIOUS_ACTIVITY,
acl::BULK_UPDATE_GUILD_FEATURES,
acl::BULK_ADD_GUILD_MEMBERS,
acl::BULK_DELETE_USERS,
@@ -264,3 +265,27 @@ pub const NAV_SECTIONS: &[NavSection] = &[
)],
},
];
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn bulk_actions_nav_covers_every_acl_the_page_renders_a_section_for() {
let item = NAV_SECTIONS
.iter()
.flat_map(|section| section.items)
.find(|item| item.active_key == "bulk-actions")
.expect("bulk actions nav item");
for required in [
acl::BULK_UPDATE_USER_FLAGS,
acl::BULK_UPDATE_SUSPICIOUS_ACTIVITY,
acl::BULK_UPDATE_GUILD_FEATURES,
acl::BULK_ADD_GUILD_MEMBERS,
acl::BULK_DELETE_USERS,
acl::BULK_DELETE_USER_MESSAGES,
] {
assert!(item.required_acls.contains(&required), "{required}");
}
}
}
@@ -33,6 +33,7 @@ fn filters_section(base: &str, params: &AuditLogsParams<'_>) -> Markup {
("", "Any"),
("user", "User"),
("guild", "Guild"),
("bulk_job", "Bulk job"),
("email_domain", "Email domain"),
("ip", "IP"),
("phrase", "Phrase"),
@@ -156,3 +157,24 @@ pub fn audit_logs_page(
};
admin_layout(config, auth, "Audit Logs", "audit-logs", None, content)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn target_type_filter_offers_bulk_jobs() {
let params = AuditLogsParams {
query: "",
admin_user_id: "",
target_id: "",
target_type: "bulk_job",
sort_by: "createdAt",
sort_order: "desc",
limit: 50,
current_page: 0,
};
let markup = filters_section("/admin", &params).into_string();
assert!(markup.contains(r#"<option value="bulk_job" selected>Bulk job</option>"#));
}
}
@@ -126,6 +126,14 @@ pub fn target_cell(base: &str, entry: &AuditLogEntry) -> Markup {
}
}))
},
"bulk_job" => html! {
(job_link(base, &entry.target_id, html! {
div class="flex flex-col" {
span class="text-sm font-medium" { "Bulk job" }
span class="text-xs text-neutral-500 break-all" { "ID: " (&entry.target_id) }
}
}))
},
"message" => html! {
div class="flex flex-col" {
span class="text-sm font-medium" { "Message" }
@@ -147,6 +155,15 @@ pub fn target_cell(base: &str, entry: &AuditLogEntry) -> Markup {
}
}
fn job_link(base: &str, job_id: &str, display: Markup) -> Markup {
html! {
a href={(base) "/jobs/" (job_id)} title={"Job " (job_id)}
class="text-neutral-900 underline decoration-neutral-300 hover:text-neutral-600 hover:decoration-neutral-500 text-sm" {
(display)
}
}
}
fn channel_href(base: &str, channel_id: &str) -> String {
format!(
"{base}/messages?channel_id={}&context_limit=50",
@@ -310,3 +327,36 @@ pub fn audit_log_table_body(base: &str, entries: &[AuditLogEntry]) -> Markup {
}
})
}
#[cfg(test)]
mod tests {
use super::*;
fn entry(target_type: &str, target_id: &str) -> AuditLogEntry {
serde_json::from_value(serde_json::json!({
"log_id": "1900000000000000001",
"admin_user_id": "1500000000000000001",
"action": "bulk_schedule_deletion",
"target_id": target_id,
"target_type": target_type,
"audit_log_reason": "Raid cleanup",
"created_at": "2026-09-14T12:00:00.000Z"
}))
.expect("audit log fixture must deserialize")
}
#[test]
fn bulk_job_targets_link_to_the_job_detail_page() {
let markup = target_cell("/admin", &entry("bulk_job", "1900000000000000002")).into_string();
assert!(markup.contains(r#"href="/admin/jobs/1900000000000000002""#));
assert!(markup.contains("Bulk job"));
assert!(!markup.contains("/admin/users/"));
}
#[test]
fn unknown_target_types_stay_unlinked() {
let markup = target_cell("/admin", &entry("email_domain", "spam.example")).into_string();
assert!(!markup.contains("<a "));
assert!(markup.contains("Email domain"));
}
}
@@ -7,8 +7,8 @@ use crate::{
templates::{
components::{
form::{
checkbox, csrf_input, danger_button, form_actions, form_field_group, select_input,
submit_button, text_input, textarea_input,
FORM_SELECT_CLASS, checkbox, csrf_input, danger_button, form_actions,
form_field_group, select_chevron, submit_button, text_input, textarea_input,
},
page_container::page_header,
section_card::section_card_simple,
@@ -395,16 +395,33 @@ fn bulk_schedule_deletion_section(base: &str, csrf_token: &str) -> Markup {
(csrf_input(csrf_token))
div class="space-y-4" {
(textarea_input("user_ids", "User IDs (one per line)", "123456789\n987654321", "", 5, true))
(select_input("reason_code", "Deletion Reason", DELETION_REASONS, "1"))
(form_field_group("Deletion Reason", "reason_code", true, None,
Some("User requested skips identifier bans and pending report resolution. Every other reason applies them."),
html! {
div class="relative" {
select id="reason_code" name="reason_code" required
class=(FORM_SELECT_CLASS) {
option value="" selected { "Select a reason" }
@for &(value, label) in DELETION_REASONS {
option value=(value) { (label) }
}
}
(select_chevron())
}
},
))
(text_input("public_reason", "Public Reason (optional)", "", "Terms of service violation"))
(form_field_group("Days Until Deletion", "days_until_deletion", true, None, None, html! {
input type="number" id="days_until_deletion" name="days_until_deletion"
value="14" min="14" required
class="w-full rounded-lg border border-neutral-300 bg-white \
text-neutral-900 text-sm h-8 px-3 py-1.5 \
focus:border-brand-primary focus:outline-none \
focus:ring-2 focus:ring-brand-primary/20";
}))
(form_field_group("Days Until Deletion", "days_until_deletion", true, None,
Some("Moderation reasons are held for at least 60 days. Only User requested allows 14."),
html! {
input type="number" id="days_until_deletion" name="days_until_deletion"
value="60" min="14" max="365" required
class="w-full rounded-lg border border-neutral-300 bg-white \
text-neutral-900 text-sm h-8 px-3 py-1.5 \
focus:border-brand-primary focus:outline-none \
focus:ring-2 focus:ring-brand-primary/20";
},
))
(text_input("audit_log_reason", "Audit Log Reason (optional)", "", "Reason for this bulk operation"))
(form_actions(html! {
(danger_button("Schedule Deletion"))
@@ -449,6 +466,21 @@ mod tests {
assert!(!markup.contains(r#"value="CLONE_STICKER_DISABLED""#));
}
#[test]
fn deletion_form_has_no_preselected_reason() {
let markup = bulk_schedule_deletion_section("/admin", "csrf").into_string();
assert!(markup.contains(r#"<option value="" selected>Select a reason</option>"#));
for (value, _) in DELETION_REASONS {
assert!(!markup.contains(&format!(r#"<option value="{value}" selected>"#)));
}
}
#[test]
fn deletion_form_defaults_to_the_moderation_retention_floor() {
let markup = bulk_schedule_deletion_section("/admin", "csrf").into_string();
assert!(markup.contains(r#"name="days_until_deletion" value="60" min="14" max="365""#));
}
#[test]
fn remove_grid_can_clear_the_deprecated_clone_features() {
let markup = guild_feature_checkbox_grid("remove_features[]", true).into_string();