mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-08 03:32:27 +09:00
fix(admin): apply audit logs and side effects to bulk actions (#2758)
This commit is contained in:
@@ -1667,7 +1667,7 @@
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
|
||||
}
|
||||
},
|
||||
"description": "Enqueue one background administrative job. The `task` discriminator selects both the body variant and the ACL evaluated for the request: `update_user_flags` needs bulk:update:user_flags, `update_suspicious_activity_flags` needs bulk:update:suspicious_activity, `update_guild_features` needs bulk:update:guild_features, `add_guild_members` needs bulk:add:guild_members, `schedule_user_deletion` needs bulk:delete:users, and `delete_user_messages` needs bulk:delete:user_messages. Returns a job_id immediately; observe progress at /admin/jobs/:job_id. Note: the schedule_user_deletion worker skips Stripe refunds, session termination, and identifier banning — apply those separately for high-risk accounts.",
|
||||
"description": "Enqueue one background administrative job. The `task` discriminator selects both the body variant and the ACL evaluated for the request: `update_user_flags` needs bulk:update:user_flags, `update_suspicious_activity_flags` needs bulk:update:suspicious_activity, `update_guild_features` needs bulk:update:guild_features, `add_guild_members` needs bulk:add:guild_members, `schedule_user_deletion` needs bulk:delete:users, and `delete_user_messages` needs bulk:delete:user_messages. Returns a job_id immediately; observe progress at /admin/jobs/:job_id.",
|
||||
"security": [{"adminApiKey": []}],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
|
||||
@@ -244,7 +244,7 @@ pub async fn render(
|
||||
query: None,
|
||||
admin_user_id: None,
|
||||
target_id: Some(user_id.to_owned()),
|
||||
target_type: Some("user".to_owned()),
|
||||
target_type: None,
|
||||
sort_by: Some("created_at".to_owned()),
|
||||
sort_order: Some("desc".to_owned()),
|
||||
limit,
|
||||
|
||||
@@ -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", ¶ms).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();
|
||||
|
||||
@@ -21,10 +21,12 @@ export interface WorkerTaskHelpers {
|
||||
setContextLink: (link: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export type WorkerTaskResult = Record<string, unknown>;
|
||||
|
||||
export type WorkerTaskHandler<Payload = Record<string, unknown>> = (
|
||||
payload: Payload,
|
||||
helpers: WorkerTaskHelpers,
|
||||
) => Promise<void>;
|
||||
) => Promise<WorkerTaskResult> | Promise<void>;
|
||||
|
||||
export class JobCancelledError extends Error {
|
||||
constructor(message = 'Job cancelled by admin') {
|
||||
|
||||
@@ -120,7 +120,7 @@ export function BulkAdminController(app: HonoApp) {
|
||||
operationId: 'create_admin_bulk_job',
|
||||
summary: 'Queue a bulk job',
|
||||
description:
|
||||
'Enqueue one background administrative job. The `task` discriminator selects both the body variant and the ACL evaluated for the request: `update_user_flags` needs bulk:update:user_flags, `update_suspicious_activity_flags` needs bulk:update:suspicious_activity, `update_guild_features` needs bulk:update:guild_features, `add_guild_members` needs bulk:add:guild_members, `schedule_user_deletion` needs bulk:delete:users, and `delete_user_messages` needs bulk:delete:user_messages. Returns a job_id immediately; observe progress at /admin/jobs/:job_id. Note: the schedule_user_deletion worker skips Stripe refunds, session termination, and identifier banning — apply those separately for high-risk accounts.',
|
||||
'Enqueue one background administrative job. The `task` discriminator selects both the body variant and the ACL evaluated for the request: `update_user_flags` needs bulk:update:user_flags, `update_suspicious_activity_flags` needs bulk:update:suspicious_activity, `update_guild_features` needs bulk:update:guild_features, `add_guild_members` needs bulk:add:guild_members, `schedule_user_deletion` needs bulk:delete:users, and `delete_user_messages` needs bulk:delete:user_messages. Returns a job_id immediately; observe progress at /admin/jobs/:job_id.',
|
||||
responseSchema: BulkJobResponse,
|
||||
statusCode: 200,
|
||||
security: 'adminApiKey',
|
||||
|
||||
@@ -35,12 +35,25 @@ export function CodesAdminController(app: HonoApp) {
|
||||
if (Config.instance.selfHosted) {
|
||||
throw new FeatureNotAvailableSelfHostedError();
|
||||
}
|
||||
const adminService = ctx.get('adminService');
|
||||
const {count, duration_type, duration_quantity} = ctx.req.valid('json');
|
||||
const codes = await ctx.get('adminService').codeGenerationService.generateGiftCodes({
|
||||
const codes = await adminService.codeGenerationService.generateGiftCodes({
|
||||
count,
|
||||
durationType: duration_type,
|
||||
durationQuantity: duration_quantity,
|
||||
});
|
||||
await adminService.auditService.createAuditLog({
|
||||
adminUserId: ctx.get('adminUserId'),
|
||||
targetType: 'gift_code',
|
||||
targetId: BigInt(0),
|
||||
action: 'generate_gift_codes',
|
||||
auditLogReason: ctx.get('auditLogReason'),
|
||||
metadata: new Map([
|
||||
['count', codes.length.toString()],
|
||||
['duration_type', duration_type],
|
||||
['duration_quantity', duration_quantity.toString()],
|
||||
]),
|
||||
});
|
||||
const baseUrl = trimTrailingSlash(Config.endpoints.gift);
|
||||
return ctx.json({
|
||||
codes: codes.map((code) => `${baseUrl}/${code}`),
|
||||
|
||||
@@ -4,6 +4,7 @@ import {createGuildID} from '@app/api/BrandedTypes';
|
||||
import type {GuildDiscoveryRow} from '@app/api/database/types/GuildDiscoveryTypes';
|
||||
import {mapGuildFeatures} from '@app/api/guild/GuildFeatureUtils';
|
||||
import type {GuildService} from '@app/api/guild/services/GuildService';
|
||||
import {Logger} from '@app/api/Logger';
|
||||
import {requireAdminACL} from '@app/api/middleware/AdminMiddleware';
|
||||
import {RateLimitMiddleware} from '@app/api/middleware/RateLimitMiddleware';
|
||||
import {OpenAPI} from '@app/api/middleware/ResponseTypeMiddleware';
|
||||
@@ -280,6 +281,7 @@ export function DiscoveryAdminController(app: HonoApp) {
|
||||
async (ctx) => {
|
||||
const data = ctx.req.valid('json');
|
||||
const adminUserId = ctx.get('adminUserId');
|
||||
const auditLogReason = ctx.get('auditLogReason');
|
||||
const discoveryService = ctx.get('discoveryService');
|
||||
const guildIds = [...new Set(data.guild_ids)];
|
||||
const failed: Array<string> = [];
|
||||
@@ -292,10 +294,27 @@ export function DiscoveryAdminController(app: HonoApp) {
|
||||
data: {category_type: data.category_type},
|
||||
});
|
||||
updated += 1;
|
||||
} catch {
|
||||
} catch (error) {
|
||||
Logger.warn(
|
||||
{err: error, guildId: rawGuildId.toString(), categoryType: data.category_type},
|
||||
'Failed to move discovery listing to category',
|
||||
);
|
||||
failed.push(rawGuildId.toString());
|
||||
}
|
||||
}
|
||||
await ctx.get('adminService').auditService.createAuditLog({
|
||||
adminUserId,
|
||||
targetType: 'guild',
|
||||
targetId: BigInt(0),
|
||||
action: 'update_discovery_categories',
|
||||
auditLogReason,
|
||||
metadata: new Map([
|
||||
['category_type', data.category_type.toString()],
|
||||
['guild_count', guildIds.length.toString()],
|
||||
['updated', updated.toString()],
|
||||
['failed', failed.length.toString()],
|
||||
]),
|
||||
});
|
||||
return ctx.json({updated, failed_guild_ids: failed});
|
||||
},
|
||||
);
|
||||
|
||||
@@ -93,9 +93,23 @@ export function GatewayAdminController(app: HonoApp) {
|
||||
}),
|
||||
async (ctx) => {
|
||||
const adminService = ctx.get('adminService');
|
||||
const adminUserId = ctx.get('adminUserId');
|
||||
const auditLogReason = ctx.get('auditLogReason');
|
||||
const body = ctx.req.valid('json');
|
||||
const guildIds = body.guild_ids.map((id) => createGuildID(id));
|
||||
return ctx.json(await adminService.guildServiceAggregate.managementService.reloadAllGuilds(guildIds));
|
||||
const result = await adminService.guildServiceAggregate.managementService.reloadAllGuilds(guildIds);
|
||||
await adminService.auditService.createAuditLog({
|
||||
adminUserId,
|
||||
targetType: 'guild',
|
||||
targetId: BigInt(0),
|
||||
action: 'reload_guilds',
|
||||
auditLogReason,
|
||||
metadata: new Map([
|
||||
['guild_count', guildIds.length.toString()],
|
||||
['reloaded', result.count.toString()],
|
||||
]),
|
||||
});
|
||||
return ctx.json(result);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -588,9 +588,9 @@ export class AdminBanManagementService {
|
||||
},
|
||||
adminUserId: UserID,
|
||||
auditLogReason: string | null,
|
||||
options?: {deferRefresh?: boolean},
|
||||
) {
|
||||
const {adminRepository} = this.deps;
|
||||
const {cache: cacheService} = this.deps.apiContext.services;
|
||||
const hex = data.sha256_hex.toLowerCase();
|
||||
await adminRepository.banFileSha({
|
||||
sha256_hex: hex,
|
||||
@@ -603,7 +603,9 @@ export class AdminBanManagementService {
|
||||
notes: data.notes ?? null,
|
||||
});
|
||||
fileShaCache.add(hex);
|
||||
await cacheService.publish(BANNED_FILE_SHAS_REFRESH_CHANNEL, 'refresh');
|
||||
if (!options?.deferRefresh) {
|
||||
await this.publishFileShaRefresh();
|
||||
}
|
||||
await this.createBlocklistAuditLog({
|
||||
adminUserId,
|
||||
targetType: 'file_sha',
|
||||
@@ -613,6 +615,11 @@ export class AdminBanManagementService {
|
||||
});
|
||||
}
|
||||
|
||||
async publishFileShaRefresh(): Promise<void> {
|
||||
const {cache: cacheService} = this.deps.apiContext.services;
|
||||
await cacheService.publish(BANNED_FILE_SHAS_REFRESH_CHANNEL, 'refresh');
|
||||
}
|
||||
|
||||
async unbanFileSha(
|
||||
data: {
|
||||
sha256_hex: string;
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import type {AdminAuditService} from '@app/api/admin/services/AdminAuditService';
|
||||
import {AdminGuildBulkService} from '@app/api/admin/services/guild/AdminGuildBulkService';
|
||||
import {AdminGuildLookupService} from '@app/api/admin/services/guild/AdminGuildLookupService';
|
||||
import {AdminGuildManagementService} from '@app/api/admin/services/guild/AdminGuildManagementService';
|
||||
import {AdminGuildMembershipService} from '@app/api/admin/services/guild/AdminGuildMembershipService';
|
||||
@@ -37,7 +36,6 @@ export class AdminGuildService {
|
||||
readonly updateService: AdminGuildUpdateService;
|
||||
readonly vanityService: AdminGuildVanityService;
|
||||
readonly membershipService: AdminGuildMembershipService;
|
||||
readonly bulkService: AdminGuildBulkService;
|
||||
readonly managementService: AdminGuildManagementService;
|
||||
private readonly updatePropagator: AdminGuildUpdatePropagator;
|
||||
private readonly guildService: GuildService;
|
||||
@@ -71,10 +69,6 @@ export class AdminGuildService {
|
||||
guildService: deps.guildService,
|
||||
auditService: deps.auditService,
|
||||
});
|
||||
this.bulkService = new AdminGuildBulkService({
|
||||
guildUpdateService: this.updateService,
|
||||
auditService: deps.auditService,
|
||||
});
|
||||
this.managementService = new AdminGuildManagementService({
|
||||
guildRepository: deps.guildRepository,
|
||||
gatewayService: deps.gatewayService,
|
||||
|
||||
@@ -5,7 +5,6 @@ import {mapUserToAdminResponse} from '@app/api/admin/models/UserTypes';
|
||||
import type {AdminAuditService} from '@app/api/admin/services/AdminAuditService';
|
||||
import type {AdminBanManagementService} from '@app/api/admin/services/AdminBanManagementService';
|
||||
import type {AdminUserUpdatePropagator} from '@app/api/admin/services/AdminUserUpdatePropagator';
|
||||
import {BulkCancelledError, type BulkProgressHelpers} from '@app/api/admin/services/BulkProgressHelpers';
|
||||
import * as AuthSession from '@app/api/auth/AuthSession';
|
||||
import {createReportID, createUserID, type UserID} from '@app/api/BrandedTypes';
|
||||
import type {BillingRepository} from '@app/api/billing/repositories/BillingRepository';
|
||||
@@ -20,10 +19,7 @@ import {DeletionReasons} from '@fluxer/constants/src/Core';
|
||||
import {UserFlags} from '@fluxer/constants/src/UserConstants';
|
||||
import {ReportAlreadyResolvedError} from '@fluxer/errors/src/domains/moderation/ReportAlreadyResolvedError';
|
||||
import {UnknownUserError} from '@fluxer/errors/src/domains/user/UnknownUserError';
|
||||
import type {
|
||||
BulkScheduleUserDeletionRequest,
|
||||
ScheduleAccountDeletionRequest,
|
||||
} from '@fluxer/schema/src/domains/admin/AdminUserSchemas';
|
||||
import type {ScheduleAccountDeletionRequest} from '@fluxer/schema/src/domains/admin/AdminUserSchemas';
|
||||
import type Stripe from 'stripe';
|
||||
|
||||
interface AdminUserDeletionServiceDeps {
|
||||
@@ -40,6 +36,12 @@ interface AdminUserDeletionServiceDeps {
|
||||
const minUserRequestedDeletionDays = 14;
|
||||
const minStandardDeletionDays = 60;
|
||||
|
||||
export function resolveDeletionDays(reasonCode: number, requestedDays: number): number {
|
||||
const minDays =
|
||||
reasonCode === DeletionReasons.USER_REQUESTED ? minUserRequestedDeletionDays : minStandardDeletionDays;
|
||||
return Math.max(requestedDays, minDays);
|
||||
}
|
||||
|
||||
export class AdminUserDeletionService {
|
||||
constructor(private readonly deps: AdminUserDeletionServiceDeps) {}
|
||||
|
||||
@@ -49,16 +51,26 @@ export class AdminUserDeletionService {
|
||||
auditLogReason: string | null,
|
||||
acls: ReadonlySet<string>,
|
||||
) {
|
||||
const {users: userRepository, email: emailService, cache: cacheService} = this.deps.apiContext.services;
|
||||
const {cache: cacheService} = this.deps.apiContext.services;
|
||||
const updatedUser = await this.applyScheduledDeletion(data, adminUserId, auditLogReason);
|
||||
return {
|
||||
user: await mapUserToAdminResponse(updatedUser, cacheService, acls),
|
||||
};
|
||||
}
|
||||
|
||||
async applyScheduledDeletion(
|
||||
data: ScheduleAccountDeletionRequest,
|
||||
adminUserId: UserID,
|
||||
auditLogReason: string | null,
|
||||
): Promise<User> {
|
||||
const {users: userRepository, email: emailService} = this.deps.apiContext.services;
|
||||
const {auditService, updatePropagator} = this.deps;
|
||||
const userId = createUserID(data.user_id);
|
||||
const user = await userRepository.findUnique(userId);
|
||||
if (!user) {
|
||||
throw new UnknownUserError();
|
||||
}
|
||||
const minDays =
|
||||
data.reason_code === DeletionReasons.USER_REQUESTED ? minUserRequestedDeletionDays : minStandardDeletionDays;
|
||||
const daysUntilDeletion = Math.max(data.days_until_deletion, minDays);
|
||||
const daysUntilDeletion = resolveDeletionDays(data.reason_code, data.days_until_deletion);
|
||||
const pendingDeletionAt = new Date();
|
||||
pendingDeletionAt.setDate(pendingDeletionAt.getDate() + daysUntilDeletion);
|
||||
const updatedUser = await userRepository.updateDeletionSchedule(user, {
|
||||
@@ -137,25 +149,6 @@ export class AdminUserDeletionService {
|
||||
);
|
||||
}
|
||||
}
|
||||
await updatePropagator.propagateUserUpdate({userId, oldUser: user, updatedUser: updatedUser});
|
||||
if (user.email) {
|
||||
await emailService.sendAccountScheduledForDeletionEmail(
|
||||
user.email,
|
||||
user.username,
|
||||
data.public_reason ?? null,
|
||||
pendingDeletionAt,
|
||||
user.locale,
|
||||
);
|
||||
}
|
||||
if (data.reason_code !== DeletionReasons.USER_REQUESTED) {
|
||||
await this.banIdentifiersForScheduledDeletion({
|
||||
user,
|
||||
adminUserId,
|
||||
auditLogReason,
|
||||
deletionReasonCode: data.reason_code,
|
||||
});
|
||||
await this.resolvePendingReportsAgainstUser({user, adminUserId});
|
||||
}
|
||||
await auditService.createAuditLog({
|
||||
adminUserId,
|
||||
targetType: 'user',
|
||||
@@ -167,9 +160,33 @@ export class AdminUserDeletionService {
|
||||
['reason_code', data.reason_code.toString()],
|
||||
]),
|
||||
});
|
||||
return {
|
||||
user: await mapUserToAdminResponse(updatedUser, cacheService, acls),
|
||||
};
|
||||
if (data.reason_code !== DeletionReasons.USER_REQUESTED) {
|
||||
await this.banIdentifiersForScheduledDeletion({
|
||||
user,
|
||||
adminUserId,
|
||||
auditLogReason,
|
||||
deletionReasonCode: data.reason_code,
|
||||
});
|
||||
await this.resolvePendingReportsAgainstUser({user, adminUserId});
|
||||
}
|
||||
await updatePropagator.propagateUserUpdate({userId, oldUser: user, updatedUser: updatedUser});
|
||||
if (user.email) {
|
||||
try {
|
||||
await emailService.sendAccountScheduledForDeletionEmail(
|
||||
user.email,
|
||||
user.username,
|
||||
data.public_reason ?? null,
|
||||
pendingDeletionAt,
|
||||
user.locale,
|
||||
);
|
||||
} catch (error) {
|
||||
Logger.warn(
|
||||
{error, userId: userId.toString()},
|
||||
'Failed to send scheduled deletion email after the deletion was scheduled',
|
||||
);
|
||||
}
|
||||
}
|
||||
return updatedUser;
|
||||
}
|
||||
|
||||
async cancelAccountDeletion(
|
||||
@@ -217,70 +234,6 @@ export class AdminUserDeletionService {
|
||||
};
|
||||
}
|
||||
|
||||
async bulkScheduleUserDeletion(
|
||||
data: BulkScheduleUserDeletionRequest,
|
||||
adminUserId: UserID,
|
||||
auditLogReason: string | null,
|
||||
acls: ReadonlySet<string>,
|
||||
helpers?: BulkProgressHelpers,
|
||||
) {
|
||||
const {auditService} = this.deps;
|
||||
const successful: Array<string> = [];
|
||||
const failed: Array<{
|
||||
id: string;
|
||||
error: string;
|
||||
}> = [];
|
||||
const total = data.user_ids.length;
|
||||
await helpers?.reportProgress(0, total, `Scheduling deletion of ${total} users`);
|
||||
let processed = 0;
|
||||
for (const userIdBigInt of data.user_ids) {
|
||||
if (helpers && (await helpers.shouldCancel())) throw new BulkCancelledError();
|
||||
try {
|
||||
await this.scheduleAccountDeletion(
|
||||
{
|
||||
user_id: userIdBigInt,
|
||||
reason_code: data.reason_code,
|
||||
public_reason: data.public_reason,
|
||||
days_until_deletion: data.days_until_deletion,
|
||||
},
|
||||
adminUserId,
|
||||
null,
|
||||
acls,
|
||||
);
|
||||
successful.push(userIdBigInt.toString());
|
||||
} catch (error) {
|
||||
failed.push({
|
||||
id: userIdBigInt.toString(),
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
}
|
||||
processed++;
|
||||
if (helpers && processed % 10 === 0) {
|
||||
await helpers.reportProgress(processed, total, null);
|
||||
}
|
||||
}
|
||||
await helpers?.reportProgress(total, total, `+${successful.length} ok, ${failed.length} failed`);
|
||||
const bulkMinDays =
|
||||
data.reason_code === DeletionReasons.USER_REQUESTED ? minUserRequestedDeletionDays : minStandardDeletionDays;
|
||||
const bulkDaysUntilDeletion = Math.max(data.days_until_deletion, bulkMinDays);
|
||||
await auditService.createAuditLog({
|
||||
adminUserId,
|
||||
targetType: 'user',
|
||||
targetId: BigInt(0),
|
||||
action: 'bulk_schedule_deletion',
|
||||
auditLogReason,
|
||||
metadata: new Map([
|
||||
['user_count', data.user_ids.length.toString()],
|
||||
['reason_code', data.reason_code.toString()],
|
||||
['days', bulkDaysUntilDeletion.toString()],
|
||||
]),
|
||||
});
|
||||
return {
|
||||
successful,
|
||||
failed,
|
||||
};
|
||||
}
|
||||
|
||||
private async banIdentifiersForScheduledDeletion(params: {
|
||||
user: User;
|
||||
adminUserId: UserID;
|
||||
@@ -351,11 +304,15 @@ export class AdminUserDeletionService {
|
||||
const {reportService, auditService} = this.deps;
|
||||
const reportSearchService = getReportSearchService();
|
||||
if (!reportSearchService) {
|
||||
Logger.warn(
|
||||
{userId: user.id.toString()},
|
||||
'Report search is unavailable; pending reports were not auto-resolved on scheduled deletion',
|
||||
);
|
||||
return;
|
||||
}
|
||||
const auditLogReason = 'auto-resolved on scheduled deletion of reported user';
|
||||
const pageSize = 100;
|
||||
const seen = new Set<string>();
|
||||
const pendingReportIds = new Set<string>();
|
||||
let resolvedCount = 0;
|
||||
let offset = 0;
|
||||
try {
|
||||
@@ -369,26 +326,10 @@ export class AdminUserDeletionService {
|
||||
{limit: pageSize, offset},
|
||||
);
|
||||
if (hits.length === 0) break;
|
||||
let advanced = false;
|
||||
for (const hit of hits) {
|
||||
if (seen.has(hit.id)) continue;
|
||||
seen.add(hit.id);
|
||||
advanced = true;
|
||||
const reportId = createReportID(BigInt(hit.id));
|
||||
try {
|
||||
await reportService.resolveReport(reportId, adminUserId, null, auditLogReason);
|
||||
resolvedCount++;
|
||||
} catch (error) {
|
||||
if (error instanceof ReportAlreadyResolvedError) continue;
|
||||
Logger.warn(
|
||||
{error, userId: user.id.toString(), reportId: reportId.toString()},
|
||||
'Failed to auto-resolve report on scheduled deletion',
|
||||
);
|
||||
}
|
||||
}
|
||||
if (!advanced) {
|
||||
offset += hits.length;
|
||||
pendingReportIds.add(hit.id);
|
||||
}
|
||||
offset += hits.length;
|
||||
if (hits.length < pageSize) break;
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -397,6 +338,19 @@ export class AdminUserDeletionService {
|
||||
'Failed to enumerate pending reports for auto-resolution on scheduled deletion',
|
||||
);
|
||||
}
|
||||
for (const hitId of pendingReportIds) {
|
||||
const reportId = createReportID(BigInt(hitId));
|
||||
try {
|
||||
await reportService.resolveReport(reportId, adminUserId, null, auditLogReason);
|
||||
resolvedCount++;
|
||||
} catch (error) {
|
||||
if (error instanceof ReportAlreadyResolvedError) continue;
|
||||
Logger.warn(
|
||||
{error, userId: user.id.toString(), reportId: reportId.toString()},
|
||||
'Failed to auto-resolve report on scheduled deletion',
|
||||
);
|
||||
}
|
||||
}
|
||||
if (resolvedCount > 0) {
|
||||
await auditService
|
||||
.createAuditLog({
|
||||
|
||||
@@ -4,7 +4,6 @@ import type {ApiContext} from '@app/api/ApiContext';
|
||||
import {mapUserToAdminResponse} from '@app/api/admin/models/UserTypes';
|
||||
import type {AdminAuditService} from '@app/api/admin/services/AdminAuditService';
|
||||
import type {AdminUserUpdatePropagator} from '@app/api/admin/services/AdminUserUpdatePropagator';
|
||||
import {BulkCancelledError, type BulkProgressHelpers} from '@app/api/admin/services/BulkProgressHelpers';
|
||||
import * as AuthEmail from '@app/api/auth/AuthEmail';
|
||||
import * as AuthMfa from '@app/api/auth/AuthMfa';
|
||||
import * as AuthSession from '@app/api/auth/AuthSession';
|
||||
@@ -25,9 +24,7 @@ import {
|
||||
ALL_SUSPICIOUS_ACTIVITY_FLAGS,
|
||||
DEFERRABLE_PHONE_FLAGS,
|
||||
DEFERRED_PHONE_ON_COMMUNITY_JOIN,
|
||||
imposePhoneRequirements,
|
||||
PHONE_GATE_PROMOTED_FROM_DEFERRAL,
|
||||
SuspiciousActivityFlags,
|
||||
UserFlags,
|
||||
} from '@fluxer/constants/src/UserConstants';
|
||||
import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
|
||||
@@ -37,8 +34,6 @@ import {MissingACLError} from '@fluxer/errors/src/domains/core/MissingACLError';
|
||||
import {ServiceUnavailableError} from '@fluxer/errors/src/domains/core/ServiceUnavailableError';
|
||||
import {UnknownUserError} from '@fluxer/errors/src/domains/user/UnknownUserError';
|
||||
import type {
|
||||
BulkUpdateSuspiciousActivityFlagsRequest,
|
||||
BulkUpdateUserFlagsRequest,
|
||||
DeleteWebAuthnCredentialRequest,
|
||||
DisableForSuspiciousActivityRequest,
|
||||
DisableMfaRequest,
|
||||
@@ -61,7 +56,6 @@ interface AdminUserSecurityServiceDeps {
|
||||
}
|
||||
|
||||
interface FlagAuditMetadataParams {
|
||||
userCount?: number;
|
||||
addFlags: ReadonlyArray<bigint | number | string>;
|
||||
removeFlags: ReadonlyArray<bigint | number | string>;
|
||||
newFlags?: bigint | number | string;
|
||||
@@ -75,19 +69,11 @@ function joinAuditValues(values: ReadonlyArray<bigint | number | string>): strin
|
||||
return values.map((value) => value.toString()).join(',');
|
||||
}
|
||||
|
||||
function createFlagAuditMetadata({
|
||||
userCount,
|
||||
addFlags,
|
||||
removeFlags,
|
||||
newFlags,
|
||||
}: FlagAuditMetadataParams): Map<string, string> {
|
||||
function createFlagAuditMetadata({addFlags, removeFlags, newFlags}: FlagAuditMetadataParams): Map<string, string> {
|
||||
const entries: Array<[string, string]> = [
|
||||
['add_flags', joinAuditValues(addFlags)],
|
||||
['remove_flags', joinAuditValues(removeFlags)],
|
||||
];
|
||||
if (userCount !== undefined) {
|
||||
entries.unshift(['user_count', userCount.toString()]);
|
||||
}
|
||||
if (newFlags !== undefined) {
|
||||
entries.push(['new_flags', newFlags.toString()]);
|
||||
}
|
||||
@@ -547,140 +533,6 @@ export class AdminUserSecurityService {
|
||||
};
|
||||
}
|
||||
|
||||
async bulkUpdateUserFlags(
|
||||
data: BulkUpdateUserFlagsRequest,
|
||||
adminUserId: UserID,
|
||||
auditLogReason: string | null,
|
||||
acls: ReadonlySet<string>,
|
||||
helpers?: BulkProgressHelpers,
|
||||
) {
|
||||
const {auditService} = this.deps;
|
||||
const successful: Array<string> = [];
|
||||
const failed: Array<{
|
||||
id: string;
|
||||
error: string;
|
||||
}> = [];
|
||||
const addFlags = data.add_flags.map((flag) => BigInt(flag));
|
||||
const removeFlags = data.remove_flags.map((flag) => BigInt(flag));
|
||||
const total = data.user_ids.length;
|
||||
await helpers?.reportProgress(0, total, `Updating flags on ${total} users`);
|
||||
let processed = 0;
|
||||
for (const userIdBigInt of data.user_ids) {
|
||||
if (helpers && (await helpers.shouldCancel())) throw new BulkCancelledError();
|
||||
try {
|
||||
const userId = createUserID(userIdBigInt);
|
||||
await this.updateUserFlags({
|
||||
userId,
|
||||
data: {addFlags, removeFlags},
|
||||
adminUserId,
|
||||
auditLogReason: null,
|
||||
acls,
|
||||
});
|
||||
successful.push(userId.toString());
|
||||
} catch (error) {
|
||||
failed.push({
|
||||
id: userIdBigInt.toString(),
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
}
|
||||
processed++;
|
||||
if (helpers && processed % 25 === 0) {
|
||||
await helpers.reportProgress(processed, total, null);
|
||||
}
|
||||
}
|
||||
await helpers?.reportProgress(total, total, `+${successful.length} ok, ${failed.length} failed`);
|
||||
await auditService.createAuditLog({
|
||||
adminUserId,
|
||||
targetType: 'user',
|
||||
targetId: BigInt(0),
|
||||
action: 'bulk_update_user_flags',
|
||||
auditLogReason,
|
||||
metadata: createFlagAuditMetadata({
|
||||
userCount: data.user_ids.length,
|
||||
addFlags: data.add_flags,
|
||||
removeFlags: data.remove_flags,
|
||||
}),
|
||||
});
|
||||
return {
|
||||
successful,
|
||||
failed,
|
||||
};
|
||||
}
|
||||
|
||||
async bulkUpdateSuspiciousActivityFlags(
|
||||
data: BulkUpdateSuspiciousActivityFlagsRequest,
|
||||
adminUserId: UserID,
|
||||
auditLogReason: string | null,
|
||||
helpers?: BulkProgressHelpers,
|
||||
) {
|
||||
const {users: userRepository} = this.deps.apiContext.services;
|
||||
const {auditService, updatePropagator} = this.deps;
|
||||
const successful: Array<string> = [];
|
||||
const failed: Array<{
|
||||
id: string;
|
||||
error: string;
|
||||
}> = [];
|
||||
const addMask = data.add_flags.reduce((mask, flagName) => {
|
||||
const value = SuspiciousActivityFlags[flagName as keyof typeof SuspiciousActivityFlags];
|
||||
return value !== undefined ? mask | value : mask;
|
||||
}, 0);
|
||||
const removeMask = data.remove_flags.reduce((mask, flagName) => {
|
||||
const value = SuspiciousActivityFlags[flagName as keyof typeof SuspiciousActivityFlags];
|
||||
return value !== undefined ? mask | value : mask;
|
||||
}, 0);
|
||||
const total = data.user_ids.length;
|
||||
await helpers?.reportProgress(0, total, `Updating suspicious flags on ${total} users`);
|
||||
let processed = 0;
|
||||
for (const userIdBigInt of data.user_ids) {
|
||||
if (helpers && (await helpers.shouldCancel())) throw new BulkCancelledError();
|
||||
try {
|
||||
const userId = createUserID(userIdBigInt);
|
||||
const user = await userRepository.findUnique(userId);
|
||||
if (!user) {
|
||||
throw new UnknownUserError();
|
||||
}
|
||||
const currentFlags = user.suspiciousActivityFlags ?? 0;
|
||||
const newFlags = imposePhoneRequirements(currentFlags, addMask) & ~removeMask;
|
||||
const updatedUser = await userRepository.patchUpsert(
|
||||
userId,
|
||||
{suspicious_activity_flags: newFlags},
|
||||
user.toRow(),
|
||||
);
|
||||
await updatePropagator.propagateUserUpdate({userId, oldUser: user, updatedUser});
|
||||
if (newFlags !== currentFlags && newFlags !== 0) {
|
||||
await this.recordRiskOutcomes(userId, ['challenged'], 'admin_bulk_update_suspicious_activity_flags');
|
||||
}
|
||||
successful.push(userId.toString());
|
||||
} catch (error) {
|
||||
failed.push({
|
||||
id: userIdBigInt.toString(),
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
}
|
||||
processed++;
|
||||
if (helpers && processed % 25 === 0) {
|
||||
await helpers.reportProgress(processed, total, null);
|
||||
}
|
||||
}
|
||||
await helpers?.reportProgress(total, total, `+${successful.length} ok, ${failed.length} failed`);
|
||||
await auditService.createAuditLog({
|
||||
adminUserId,
|
||||
targetType: 'user',
|
||||
targetId: BigInt(0),
|
||||
action: 'bulk_update_suspicious_activity_flags',
|
||||
auditLogReason,
|
||||
metadata: createFlagAuditMetadata({
|
||||
userCount: data.user_ids.length,
|
||||
addFlags: data.add_flags,
|
||||
removeFlags: data.remove_flags,
|
||||
}),
|
||||
});
|
||||
return {
|
||||
successful,
|
||||
failed,
|
||||
};
|
||||
}
|
||||
|
||||
async listWebAuthnCredentials(
|
||||
data: ListWebAuthnCredentialsRequest,
|
||||
adminUserId: UserID,
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
export interface BulkProgressHelpers {
|
||||
reportProgress: (current: number, total: number, message?: string | null) => Promise<void>;
|
||||
shouldCancel: () => Promise<boolean>;
|
||||
}
|
||||
|
||||
export class BulkCancelledError extends Error {
|
||||
constructor() {
|
||||
super('Bulk operation cancelled');
|
||||
this.name = 'BulkCancelledError';
|
||||
}
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import type {AdminAuditService} from '@app/api/admin/services/AdminAuditService';
|
||||
import {BulkCancelledError, type BulkProgressHelpers} from '@app/api/admin/services/BulkProgressHelpers';
|
||||
import type {AdminGuildUpdateService} from '@app/api/admin/services/guild/AdminGuildUpdateService';
|
||||
import {createGuildID, type UserID} from '@app/api/BrandedTypes';
|
||||
import type {BulkUpdateGuildFeaturesRequest} from '@fluxer/schema/src/domains/admin/AdminGuildSchemas';
|
||||
|
||||
interface AdminGuildBulkServiceDeps {
|
||||
guildUpdateService: AdminGuildUpdateService;
|
||||
auditService: AdminAuditService;
|
||||
}
|
||||
|
||||
export class AdminGuildBulkService {
|
||||
constructor(private readonly deps: AdminGuildBulkServiceDeps) {}
|
||||
|
||||
async bulkUpdateGuildFeatures(
|
||||
data: BulkUpdateGuildFeaturesRequest,
|
||||
adminUserId: UserID,
|
||||
auditLogReason: string | null,
|
||||
helpers?: BulkProgressHelpers,
|
||||
) {
|
||||
const {guildUpdateService, auditService} = this.deps;
|
||||
const successful: Array<string> = [];
|
||||
const failed: Array<{
|
||||
id: string;
|
||||
error: string;
|
||||
}> = [];
|
||||
const total = data.guild_ids.length;
|
||||
await helpers?.reportProgress(0, total, `Updating features on ${total} guilds`);
|
||||
let processed = 0;
|
||||
for (const guildIdBigInt of data.guild_ids) {
|
||||
if (helpers && (await helpers.shouldCancel())) throw new BulkCancelledError();
|
||||
try {
|
||||
const guildId = createGuildID(guildIdBigInt);
|
||||
await guildUpdateService.updateGuildFeatures({
|
||||
guildId,
|
||||
addFeatures: data.add_features,
|
||||
removeFeatures: data.remove_features,
|
||||
adminUserId,
|
||||
auditLogReason: null,
|
||||
});
|
||||
successful.push(guildId.toString());
|
||||
} catch (error) {
|
||||
failed.push({
|
||||
id: guildIdBigInt.toString(),
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
}
|
||||
processed++;
|
||||
if (helpers && processed % 25 === 0) {
|
||||
await helpers.reportProgress(processed, total, null);
|
||||
}
|
||||
}
|
||||
await helpers?.reportProgress(total, total, `+${successful.length} ok, ${failed.length} failed`);
|
||||
await auditService.createAuditLog({
|
||||
adminUserId,
|
||||
targetType: 'guild',
|
||||
targetId: BigInt(0),
|
||||
action: 'bulk_update_guild_features',
|
||||
auditLogReason,
|
||||
metadata: new Map([
|
||||
['guild_count', data.guild_ids.length.toString()],
|
||||
['add_features', data.add_features.join(',')],
|
||||
['remove_features', data.remove_features.join(',')],
|
||||
]),
|
||||
});
|
||||
return {
|
||||
successful,
|
||||
failed,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,16 +1,14 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import type {AdminAuditService} from '@app/api/admin/services/AdminAuditService';
|
||||
import {BulkCancelledError, type BulkProgressHelpers} from '@app/api/admin/services/BulkProgressHelpers';
|
||||
import {createGuildID, createUserID, type UserID} from '@app/api/BrandedTypes';
|
||||
import type {GuildService} from '@app/api/guild/services/GuildService';
|
||||
import {createRequestCache, type RequestCache} from '@app/api/middleware/RequestCacheMiddleware';
|
||||
import type {RequestCache} from '@app/api/middleware/RequestCacheMiddleware';
|
||||
import type {IUserRepository} from '@app/api/user/IUserRepository';
|
||||
import {JoinSourceTypes} from '@fluxer/constants/src/GuildConstants';
|
||||
import {UnknownUserError} from '@fluxer/errors/src/domains/user/UnknownUserError';
|
||||
import type {
|
||||
BanGuildMemberRequest,
|
||||
BulkAddGuildMembersRequest,
|
||||
ForceAddUserToGuildRequest,
|
||||
KickGuildMemberRequest,
|
||||
} from '@fluxer/schema/src/domains/admin/AdminGuildSchemas';
|
||||
@@ -30,11 +28,13 @@ export class AdminGuildMembershipService {
|
||||
requestCache,
|
||||
adminUserId,
|
||||
auditLogReason,
|
||||
sendJoinMessage = true,
|
||||
}: {
|
||||
data: ForceAddUserToGuildRequest;
|
||||
requestCache: RequestCache;
|
||||
adminUserId: UserID;
|
||||
auditLogReason: string | null;
|
||||
sendJoinMessage?: boolean;
|
||||
}): Promise<SuccessResponse> {
|
||||
const {userRepository, guildService, auditService} = this.deps;
|
||||
const userId = createUserID(data.user_id);
|
||||
@@ -47,7 +47,7 @@ export class AdminGuildMembershipService {
|
||||
skipRiskGate: true,
|
||||
userId,
|
||||
guildId,
|
||||
sendJoinMessage: true,
|
||||
sendJoinMessage,
|
||||
skipBanCheck: true,
|
||||
joinSourceType: JoinSourceTypes.ADMIN_FORCE_ADD,
|
||||
requestCache,
|
||||
@@ -64,66 +64,6 @@ export class AdminGuildMembershipService {
|
||||
return {success: true};
|
||||
}
|
||||
|
||||
async bulkAddGuildMembers(
|
||||
data: BulkAddGuildMembersRequest,
|
||||
adminUserId: UserID,
|
||||
auditLogReason: string | null,
|
||||
helpers?: BulkProgressHelpers,
|
||||
) {
|
||||
const {guildService, auditService} = this.deps;
|
||||
const successful: Array<string> = [];
|
||||
const failed: Array<{
|
||||
id: string;
|
||||
error: string;
|
||||
}> = [];
|
||||
const guildId = createGuildID(data.guild_id);
|
||||
const total = data.user_ids.length;
|
||||
await helpers?.reportProgress(0, total, `Adding ${total} members to guild ${guildId}`);
|
||||
let processed = 0;
|
||||
for (const userIdBigInt of data.user_ids) {
|
||||
if (helpers && (await helpers.shouldCancel())) throw new BulkCancelledError();
|
||||
try {
|
||||
const userId = createUserID(userIdBigInt);
|
||||
await guildService.members.addUserToGuild({
|
||||
skipRiskGate: true,
|
||||
userId,
|
||||
guildId,
|
||||
sendJoinMessage: false,
|
||||
skipBanCheck: true,
|
||||
joinSourceType: JoinSourceTypes.ADMIN_FORCE_ADD,
|
||||
requestCache: createRequestCache(),
|
||||
initiatorId: adminUserId,
|
||||
});
|
||||
successful.push(userId.toString());
|
||||
} catch (error) {
|
||||
failed.push({
|
||||
id: userIdBigInt.toString(),
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
}
|
||||
processed++;
|
||||
if (helpers && processed % 25 === 0) {
|
||||
await helpers.reportProgress(processed, total, null);
|
||||
}
|
||||
}
|
||||
await helpers?.reportProgress(total, total, `+${successful.length} ok, ${failed.length} failed`);
|
||||
await auditService.createAuditLog({
|
||||
adminUserId,
|
||||
targetType: 'guild',
|
||||
targetId: BigInt(guildId),
|
||||
action: 'bulk_add_guild_members',
|
||||
auditLogReason,
|
||||
metadata: new Map([
|
||||
['guild_id', guildId.toString()],
|
||||
['user_count', data.user_ids.length.toString()],
|
||||
]),
|
||||
});
|
||||
return {
|
||||
successful,
|
||||
failed,
|
||||
};
|
||||
}
|
||||
|
||||
async banMember(data: BanGuildMemberRequest, adminUserId: UserID, auditLogReason: string | null) {
|
||||
const {guildService, auditService} = this.deps;
|
||||
const guildId = createGuildID(data.guild_id);
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import type {AdminAuditLog} from '@app/api/admin/IAdminRepository';
|
||||
import {createTestAccount, setUserACLs, type TestAccount} from '@app/api/auth/tests/AuthTestUtils';
|
||||
import {createGuild} from '@app/api/guild/tests/GuildTestUtils';
|
||||
import {getAdminRepository} from '@app/api/middleware/ServiceSingletons';
|
||||
import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHarness';
|
||||
import {HTTP_STATUS, TEST_IDS} from '@app/api/test/TestConstants';
|
||||
import {createBuilder} from '@app/api/test/TestRequestBuilder';
|
||||
import {AdminACLs} from '@fluxer/constants/src/AdminACLs';
|
||||
import {DiscoveryCategories} from '@fluxer/constants/src/DiscoveryConstants';
|
||||
import {afterEach, beforeEach, describe, expect, test} from 'vitest';
|
||||
|
||||
const AUDIT_REASON = 'Ticket 4471';
|
||||
|
||||
describe('Multi-item admin endpoint audit entries', () => {
|
||||
let harness: ApiTestHarness;
|
||||
|
||||
beforeEach(async () => {
|
||||
harness = await createApiTestHarness();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await harness?.shutdown();
|
||||
});
|
||||
|
||||
async function createAdmin(acls: Array<string>): Promise<TestAccount> {
|
||||
return setUserACLs(harness, await createTestAccount(harness), [AdminACLs.AUTHENTICATE, ...acls]);
|
||||
}
|
||||
|
||||
async function findAuditLog(action: string): Promise<AdminAuditLog | undefined> {
|
||||
const logs = await getAdminRepository().listAllAuditLogsPaginated(100);
|
||||
return logs.find((log) => log.action === action);
|
||||
}
|
||||
|
||||
test('records a gateway reload of many guilds', async () => {
|
||||
const admin = await createAdmin([AdminACLs.GATEWAY_RELOAD_ALL]);
|
||||
const owner = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, owner.token, 'Reload Guild');
|
||||
|
||||
await createBuilder(harness, `${admin.token}`)
|
||||
.post('/admin/gateway/reloads')
|
||||
.header('X-Audit-Log-Reason', AUDIT_REASON)
|
||||
.body({guild_ids: [guild.id]})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
|
||||
const log = await findAuditLog('reload_guilds');
|
||||
expect(log).toBeDefined();
|
||||
expect(log?.adminUserId.toString()).toBe(admin.userId);
|
||||
expect(log?.targetType).toBe('guild');
|
||||
expect(log?.auditLogReason).toBe(AUDIT_REASON);
|
||||
expect(log?.metadata.get('guild_count')).toBe('1');
|
||||
expect(log?.metadata.get('reloaded')).toBeDefined();
|
||||
});
|
||||
|
||||
test('records a gift code mint', async () => {
|
||||
const admin = await createAdmin([AdminACLs.GIFT_CODES_GENERATE]);
|
||||
|
||||
await createBuilder(harness, `${admin.token}`)
|
||||
.post('/admin/gift-codes')
|
||||
.header('X-Audit-Log-Reason', AUDIT_REASON)
|
||||
.body({count: 3, duration_type: 'months', duration_quantity: 1})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
|
||||
const log = await findAuditLog('generate_gift_codes');
|
||||
expect(log).toBeDefined();
|
||||
expect(log?.adminUserId.toString()).toBe(admin.userId);
|
||||
expect(log?.targetType).toBe('gift_code');
|
||||
expect(log?.auditLogReason).toBe(AUDIT_REASON);
|
||||
expect(Object.fromEntries(log!.metadata)).toEqual({
|
||||
count: '3',
|
||||
duration_type: 'months',
|
||||
duration_quantity: '1',
|
||||
});
|
||||
});
|
||||
|
||||
test('records a bulk discovery listing move with its failure count', async () => {
|
||||
const admin = await createAdmin([AdminACLs.DISCOVERY_REVIEW]);
|
||||
|
||||
const result = await createBuilder<{
|
||||
updated: number;
|
||||
failed_guild_ids: Array<string>;
|
||||
}>(harness, `${admin.token}`)
|
||||
.patch('/admin/discovery/listings')
|
||||
.header('X-Audit-Log-Reason', AUDIT_REASON)
|
||||
.body({guild_ids: [TEST_IDS.NONEXISTENT_GUILD], category_type: DiscoveryCategories.EDUCATION})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
|
||||
expect(result.failed_guild_ids).toEqual([TEST_IDS.NONEXISTENT_GUILD]);
|
||||
const log = await findAuditLog('update_discovery_categories');
|
||||
expect(log).toBeDefined();
|
||||
expect(log?.adminUserId.toString()).toBe(admin.userId);
|
||||
expect(log?.targetType).toBe('guild');
|
||||
expect(log?.auditLogReason).toBe(AUDIT_REASON);
|
||||
expect(Object.fromEntries(log!.metadata)).toEqual({
|
||||
category_type: DiscoveryCategories.EDUCATION.toString(),
|
||||
guild_count: '1',
|
||||
updated: '0',
|
||||
failed: '1',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -174,7 +174,7 @@ export {initializeServiceSingletons} from '@app/api/middleware/ServiceSingletons
|
||||
|
||||
let _reportService: ReportService | null = null;
|
||||
|
||||
function getReportServiceInstance(): ReportService {
|
||||
export function getReportServiceInstance(): ReportService {
|
||||
if (!_reportService) {
|
||||
_reportService = new ReportService(
|
||||
getReportRepository(),
|
||||
@@ -241,7 +241,7 @@ function getRiskAssessmentRepository(): CassandraRiskAssessmentRepository {
|
||||
|
||||
let _historicalOutcomeRepository: CassandraHistoricalOutcomeRepository | null = null;
|
||||
|
||||
function getHistoricalOutcomeRepository(): CassandraHistoricalOutcomeRepository {
|
||||
export function getHistoricalOutcomeRepository(): CassandraHistoricalOutcomeRepository {
|
||||
if (_historicalOutcomeRepository) return _historicalOutcomeRepository;
|
||||
_historicalOutcomeRepository = new CassandraHistoricalOutcomeRepository();
|
||||
return _historicalOutcomeRepository;
|
||||
@@ -249,7 +249,7 @@ function getHistoricalOutcomeRepository(): CassandraHistoricalOutcomeRepository
|
||||
|
||||
let _suspiciousIpRepository: CassandraSuspiciousIpRepository | null = null;
|
||||
|
||||
function getSuspiciousIpRepository(): CassandraSuspiciousIpRepository {
|
||||
export function getSuspiciousIpRepository(): CassandraSuspiciousIpRepository {
|
||||
if (_suspiciousIpRepository) return _suspiciousIpRepository;
|
||||
_suspiciousIpRepository = new CassandraSuspiciousIpRepository();
|
||||
return _suspiciousIpRepository;
|
||||
|
||||
@@ -31,7 +31,6 @@ import {
|
||||
resolveCronSchedulerEnabled,
|
||||
resolveWorkerLanes,
|
||||
validateLaneCompleteness,
|
||||
type WorkerLaneDefinition,
|
||||
} from '@app/api/worker/WorkerLaneConfig';
|
||||
import {createWorkerProcessErrorHandler} from '@app/api/worker/WorkerProcessErrorHandler';
|
||||
import {WorkerQueueOverflowError} from '@app/api/worker/WorkerQueueOverflowError';
|
||||
@@ -45,13 +44,6 @@ import {getDefaultPostgresClient, initPostgres, shutdownPostgres} from '@pkgs/po
|
||||
import type {WorkerTaskHandler} from '@pkgs/worker/src/contracts/WorkerTask';
|
||||
import {ms} from 'itty-time';
|
||||
|
||||
const SEARCH_REQUIRED_TASKS = new Set<string>([
|
||||
'indexChannelMessages',
|
||||
'indexGuildMembers',
|
||||
'refreshSearchIndex',
|
||||
'syncDiscoveryIndex',
|
||||
]);
|
||||
|
||||
function registerCronJobs(cron: CronScheduler): void {
|
||||
cron.upsert('processAssetDeletionQueue', 'processAssetDeletionQueue', {}, '0 */5 * * * *', {ledger: false});
|
||||
if (Config.cachePurge.adapter !== 'none') {
|
||||
@@ -87,10 +79,6 @@ function registerCronJobs(cron: CronScheduler): void {
|
||||
);
|
||||
}
|
||||
|
||||
function workerLanesRequireSearch(activeWorkerLanes: ReadonlyArray<WorkerLaneDefinition>): boolean {
|
||||
return activeWorkerLanes.some((lane) => lane.taskTypes.some((taskType) => SEARCH_REQUIRED_TASKS.has(taskType)));
|
||||
}
|
||||
|
||||
export async function startWorkerMain(): Promise<void> {
|
||||
Logger.info('Starting worker backend...');
|
||||
let cassandraInitialized = false;
|
||||
@@ -240,6 +228,14 @@ export async function startWorkerMain(): Promise<void> {
|
||||
setInjectedWorkerService(workerService);
|
||||
instanceConfigRepository = getInstanceConfigRepository();
|
||||
limitConfigService = getLimitConfigService();
|
||||
try {
|
||||
await initializeSearch(getCacheService());
|
||||
searchInitialized = true;
|
||||
Logger.info('Search initialised for worker backend');
|
||||
} catch (error) {
|
||||
Logger.error({err: error}, 'Search initialisation failed for worker backend');
|
||||
throw error;
|
||||
}
|
||||
dependencies = await initializeWorkerDependencies(snowflakeService);
|
||||
setWorkerDependencies(dependencies);
|
||||
if (Config.blocklistFeeds.enabled) {
|
||||
@@ -284,18 +280,6 @@ export async function startWorkerMain(): Promise<void> {
|
||||
});
|
||||
runners.push(runner);
|
||||
}
|
||||
if (workerLanesRequireSearch(activeWorkerLanes)) {
|
||||
try {
|
||||
await initializeSearch(getCacheService());
|
||||
searchInitialized = true;
|
||||
Logger.info('Search initialised for worker backend');
|
||||
} catch (error) {
|
||||
Logger.error({err: error}, 'Search initialisation failed for worker backend');
|
||||
throw error;
|
||||
}
|
||||
} else {
|
||||
Logger.info('Search initialisation skipped for worker lanes without search tasks');
|
||||
}
|
||||
if (cronSchedulerEnabled) {
|
||||
cron.start();
|
||||
Logger.info('Cron scheduler started');
|
||||
|
||||
@@ -432,10 +432,10 @@ export class WorkerRunner {
|
||||
},
|
||||
};
|
||||
try {
|
||||
await task(jobPayload, helpers);
|
||||
const result = await task(jobPayload, helpers);
|
||||
if (ledgerJobId !== null) {
|
||||
try {
|
||||
await this.ledger.markSucceeded(ledgerJobId, null);
|
||||
await this.ledger.markSucceeded(ledgerJobId, result ?? null);
|
||||
} catch (err) {
|
||||
Logger.warn({err, jobId: ledgerJobId.toString()}, 'Ledger markSucceeded failed');
|
||||
}
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import type {AdminAuditService} from '@app/api/admin/services/AdminAuditService';
|
||||
import type {UserID} from '@app/api/BrandedTypes';
|
||||
import {JobCancelledError, type WorkerTaskHelpers, type WorkerTaskResult} from '@pkgs/worker/src/contracts/WorkerTask';
|
||||
|
||||
const MAX_RECORDED_FAILURES = 100;
|
||||
|
||||
interface AdminBulkFailure {
|
||||
id: string;
|
||||
error: string;
|
||||
}
|
||||
|
||||
interface AdminBulkJobParams {
|
||||
helpers: WorkerTaskHelpers;
|
||||
ids: ReadonlyArray<string>;
|
||||
progressEvery: number;
|
||||
apply: (id: string) => Promise<void>;
|
||||
afterItems?: () => Promise<ReadonlyArray<[string, string]>>;
|
||||
summary: {
|
||||
auditService: AdminAuditService;
|
||||
adminUserId: UserID;
|
||||
action: string;
|
||||
auditLogReason: string | null;
|
||||
metadata: ReadonlyArray<[string, string]>;
|
||||
target?: {type: string; id: bigint};
|
||||
};
|
||||
}
|
||||
|
||||
function describeError(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
export async function runAdminBulkJob({
|
||||
helpers,
|
||||
ids,
|
||||
progressEvery,
|
||||
apply,
|
||||
afterItems,
|
||||
summary,
|
||||
}: AdminBulkJobParams): Promise<WorkerTaskResult> {
|
||||
const total = ids.length;
|
||||
const failed: Array<AdminBulkFailure> = [];
|
||||
let successfulCount = 0;
|
||||
let processed = 0;
|
||||
let cancelled = false;
|
||||
for (const id of ids) {
|
||||
if (await helpers.shouldCancel()) {
|
||||
cancelled = true;
|
||||
break;
|
||||
}
|
||||
try {
|
||||
await apply(id);
|
||||
successfulCount++;
|
||||
} catch (error) {
|
||||
failed.push({id, error: describeError(error)});
|
||||
helpers.logger.warn({id, err: error}, 'Admin bulk job item failed');
|
||||
}
|
||||
processed++;
|
||||
if (processed % progressEvery === 0) {
|
||||
await helpers.reportProgress(processed, total, null);
|
||||
}
|
||||
}
|
||||
const finalMetadata: Array<[string, string]> = [];
|
||||
let finalizeError: string | null = null;
|
||||
if (afterItems) {
|
||||
try {
|
||||
finalMetadata.push(...(await afterItems()));
|
||||
} catch (error) {
|
||||
finalizeError = describeError(error);
|
||||
helpers.logger.error({err: error}, 'Admin bulk job finalisation failed');
|
||||
}
|
||||
}
|
||||
await summary.auditService.createAuditLog({
|
||||
adminUserId: summary.adminUserId,
|
||||
targetType: summary.target?.type ?? 'bulk_job',
|
||||
targetId: summary.target?.id ?? helpers.jobId,
|
||||
action: summary.action,
|
||||
auditLogReason: summary.auditLogReason,
|
||||
metadata: new Map([
|
||||
...summary.metadata,
|
||||
['job_id', helpers.jobId.toString()],
|
||||
...finalMetadata,
|
||||
...(finalizeError !== null ? ([['finalize_error', finalizeError]] as Array<[string, string]>) : []),
|
||||
['processed', processed.toString()],
|
||||
['successful', successfulCount.toString()],
|
||||
['failed', failed.length.toString()],
|
||||
...(cancelled ? ([['cancelled', 'true']] as Array<[string, string]>) : []),
|
||||
]),
|
||||
});
|
||||
if (cancelled) {
|
||||
throw new JobCancelledError();
|
||||
}
|
||||
await helpers.reportProgress(total, total, `+${successfulCount} ok, ${failed.length} failed`);
|
||||
helpers.logger.info({successful: successfulCount, failed: failed.length}, 'Admin bulk job complete');
|
||||
return {
|
||||
successful_count: successfulCount,
|
||||
failed_count: failed.length,
|
||||
failed: failed.slice(0, MAX_RECORDED_FAILURES),
|
||||
...(finalizeError !== null ? {finalize_error: finalizeError} : {}),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {AdminAuditService} from '@app/api/admin/services/AdminAuditService';
|
||||
import {AdminBanManagementService} from '@app/api/admin/services/AdminBanManagementService';
|
||||
import {AdminGuildService} from '@app/api/admin/services/AdminGuildService';
|
||||
import {AdminUserService} from '@app/api/admin/services/AdminUserService';
|
||||
import {createApiContext} from '@app/api/CreateApiContext';
|
||||
import {
|
||||
getHistoricalOutcomeRepository,
|
||||
getIpInfoService,
|
||||
getReportServiceInstance,
|
||||
getSuspiciousIpRepository,
|
||||
} from '@app/api/middleware/ServiceMiddleware';
|
||||
import {
|
||||
getDiscriminatorService,
|
||||
getEntityAssetService,
|
||||
getGuildDiscoveryRepository,
|
||||
getInviteRepository,
|
||||
} from '@app/api/middleware/ServiceSingletons';
|
||||
import type {WorkerDependencies} from '@app/api/worker/WorkerDependencies';
|
||||
|
||||
interface AdminBulkServices {
|
||||
auditService: AdminAuditService;
|
||||
banManagementService: AdminBanManagementService;
|
||||
userService: AdminUserService;
|
||||
guildService: AdminGuildService;
|
||||
}
|
||||
|
||||
export function createAdminBulkServices(deps: WorkerDependencies): AdminBulkServices {
|
||||
const apiContext = createApiContext();
|
||||
const auditService = new AdminAuditService(deps.adminRepository, deps.snowflakeService);
|
||||
const banManagementService = new AdminBanManagementService({
|
||||
apiContext,
|
||||
adminRepository: deps.adminRepository,
|
||||
auditService,
|
||||
ipInfoService: getIpInfoService(),
|
||||
suspiciousIpRepository: getSuspiciousIpRepository(),
|
||||
});
|
||||
const userService = new AdminUserService({
|
||||
apiContext,
|
||||
guildRepository: deps.guildRepository,
|
||||
channelRepository: deps.channelRepository,
|
||||
discriminatorService: getDiscriminatorService(),
|
||||
entityAssetService: getEntityAssetService(),
|
||||
auditService,
|
||||
userCacheService: deps.userCacheService,
|
||||
banManagementService,
|
||||
kvDeletionQueue: deps.deletionQueueService,
|
||||
bulkMessageDeletionQueue: deps.bulkMessageDeletionQueueService,
|
||||
stripe: deps.stripe,
|
||||
riskHistoryRepository: getHistoricalOutcomeRepository(),
|
||||
reportService: getReportServiceInstance(),
|
||||
});
|
||||
const guildService = new AdminGuildService({
|
||||
guildRepository: deps.guildRepository,
|
||||
userRepository: deps.userRepository,
|
||||
channelRepository: deps.channelRepository,
|
||||
inviteRepository: getInviteRepository(),
|
||||
guildService: deps.guildService,
|
||||
gatewayService: deps.gatewayService,
|
||||
entityAssetService: getEntityAssetService(),
|
||||
auditService,
|
||||
discoveryRepository: getGuildDiscoveryRepository(),
|
||||
});
|
||||
return {auditService, banManagementService, userService, guildService};
|
||||
}
|
||||
@@ -1,12 +1,11 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {AdminAuditService} from '@app/api/admin/services/AdminAuditService';
|
||||
import {createGuildID, createUserID} from '@app/api/BrandedTypes';
|
||||
import {createUserID} from '@app/api/BrandedTypes';
|
||||
import {createRequestCache} from '@app/api/middleware/RequestCacheMiddleware';
|
||||
import {runAdminBulkJob} from '@app/api/worker/tasks/admin_bulk/AdminBulkJob';
|
||||
import {createAdminBulkServices} from '@app/api/worker/tasks/admin_bulk/AdminBulkServices';
|
||||
import {getWorkerDependencies} from '@app/api/worker/WorkerContext';
|
||||
import {JoinSourceTypes} from '@fluxer/constants/src/GuildConstants';
|
||||
import type {WorkerTaskHandler} from '@pkgs/worker/src/contracts/WorkerTask';
|
||||
import {JobCancelledError} from '@pkgs/worker/src/contracts/WorkerTask';
|
||||
|
||||
interface Payload {
|
||||
guild_id: string;
|
||||
@@ -22,57 +21,37 @@ const handler: WorkerTaskHandler = async (rawPayload, helpers) => {
|
||||
admin_user_id: rawPayload.admin_user_id as string,
|
||||
audit_log_reason: (rawPayload.audit_log_reason as string | null) ?? null,
|
||||
};
|
||||
const deps = getWorkerDependencies();
|
||||
const auditService = new AdminAuditService(deps.adminRepository, deps.snowflakeService);
|
||||
const {auditService, guildService} = createAdminBulkServices(getWorkerDependencies());
|
||||
const adminUserId = createUserID(BigInt(payload.admin_user_id));
|
||||
const guildId = createGuildID(BigInt(payload.guild_id));
|
||||
const userIds = payload.user_ids.map((id) => BigInt(id));
|
||||
const total = userIds.length;
|
||||
const successful: Array<string> = [];
|
||||
const failed: Array<{
|
||||
id: string;
|
||||
error: string;
|
||||
}> = [];
|
||||
const guildId = BigInt(payload.guild_id);
|
||||
const total = payload.user_ids.length;
|
||||
await helpers.setContextLink(`/guilds/${guildId}`);
|
||||
await helpers.reportProgress(0, total, `Adding ${total} members to guild ${guildId}`);
|
||||
for (let i = 0; i < userIds.length; i++) {
|
||||
if (await helpers.shouldCancel()) throw new JobCancelledError();
|
||||
const userIdBigInt = userIds[i]!;
|
||||
const userId = createUserID(userIdBigInt);
|
||||
try {
|
||||
await deps.guildService.members.addUserToGuild({
|
||||
skipRiskGate: true,
|
||||
userId,
|
||||
guildId,
|
||||
sendJoinMessage: false,
|
||||
skipBanCheck: true,
|
||||
joinSourceType: JoinSourceTypes.ADMIN_FORCE_ADD,
|
||||
return await runAdminBulkJob({
|
||||
helpers,
|
||||
ids: payload.user_ids,
|
||||
progressEvery: 25,
|
||||
apply: async (id) => {
|
||||
await guildService.membershipService.forceAddUserToGuild({
|
||||
data: {guild_id: guildId, user_id: BigInt(id)},
|
||||
requestCache: createRequestCache(),
|
||||
initiatorId: adminUserId,
|
||||
adminUserId,
|
||||
auditLogReason: payload.audit_log_reason,
|
||||
sendJoinMessage: false,
|
||||
});
|
||||
successful.push(userId.toString());
|
||||
} catch (err) {
|
||||
failed.push({id: userIdBigInt.toString(), error: err instanceof Error ? err.message : String(err)});
|
||||
}
|
||||
if ((i + 1) % 25 === 0) {
|
||||
await helpers.reportProgress(i + 1, total, null);
|
||||
}
|
||||
}
|
||||
await auditService.createAuditLog({
|
||||
adminUserId,
|
||||
targetType: 'guild',
|
||||
targetId: BigInt(guildId),
|
||||
action: 'bulk_add_guild_members',
|
||||
auditLogReason: payload.audit_log_reason,
|
||||
metadata: new Map([
|
||||
['guild_id', guildId.toString()],
|
||||
['user_count', total.toString()],
|
||||
['successful', successful.length.toString()],
|
||||
['failed', failed.length.toString()],
|
||||
]),
|
||||
},
|
||||
summary: {
|
||||
auditService,
|
||||
adminUserId,
|
||||
action: 'bulk_add_guild_members',
|
||||
auditLogReason: payload.audit_log_reason,
|
||||
target: {type: 'guild', id: guildId},
|
||||
metadata: [
|
||||
['guild_id', guildId.toString()],
|
||||
['user_count', total.toString()],
|
||||
],
|
||||
},
|
||||
});
|
||||
await helpers.reportProgress(total, total, `+${successful.length} ok, ${failed.length} failed`);
|
||||
helpers.logger.info({successful: successful.length, failed: failed.length}, 'bulkAddGuildMembers complete');
|
||||
};
|
||||
|
||||
export default handler;
|
||||
|
||||
@@ -1,15 +1,11 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {AdminAuditService} from '@app/api/admin/services/AdminAuditService';
|
||||
import {createUserID} from '@app/api/BrandedTypes';
|
||||
import {ContentBlocklistCategory, ContentBlocklistSeverity} from '@app/api/constants/ContentModeration';
|
||||
import {fileShaCache} from '@app/api/middleware/FileShaCache';
|
||||
import {getCacheService} from '@app/api/middleware/ServiceSingletons';
|
||||
import {runAdminBulkJob} from '@app/api/worker/tasks/admin_bulk/AdminBulkJob';
|
||||
import {createAdminBulkServices} from '@app/api/worker/tasks/admin_bulk/AdminBulkServices';
|
||||
import {getWorkerDependencies} from '@app/api/worker/WorkerContext';
|
||||
import type {WorkerTaskHandler} from '@pkgs/worker/src/contracts/WorkerTask';
|
||||
import {JobCancelledError} from '@pkgs/worker/src/contracts/WorkerTask';
|
||||
|
||||
const BANNED_FILE_SHAS_REFRESH_CHANNEL = 'banned_file_shas:refresh';
|
||||
const SHA256_RE = /^[0-9a-fA-F]{64}$/;
|
||||
|
||||
interface Payload {
|
||||
@@ -24,60 +20,35 @@ const handler: WorkerTaskHandler = async (rawPayload, helpers) => {
|
||||
admin_user_id: rawPayload.admin_user_id as string,
|
||||
audit_log_reason: (rawPayload.audit_log_reason as string | null) ?? null,
|
||||
};
|
||||
const deps = getWorkerDependencies();
|
||||
const cacheService = getCacheService();
|
||||
const auditService = new AdminAuditService(deps.adminRepository, deps.snowflakeService);
|
||||
const {auditService, banManagementService} = createAdminBulkServices(getWorkerDependencies());
|
||||
const adminUserId = createUserID(BigInt(payload.admin_user_id));
|
||||
const total = payload.sha256_list.length;
|
||||
const successful: Array<string> = [];
|
||||
const failed: Array<{
|
||||
id: string;
|
||||
error: string;
|
||||
}> = [];
|
||||
const shas = payload.sha256_list.map((sha) => sha.toLowerCase());
|
||||
await helpers.setContextLink('/file-sha-bans');
|
||||
await helpers.reportProgress(0, total, `Banning ${total} file SHAs`);
|
||||
for (let i = 0; i < payload.sha256_list.length; i++) {
|
||||
if (await helpers.shouldCancel()) throw new JobCancelledError();
|
||||
const sha = payload.sha256_list[i]!.toLowerCase();
|
||||
if (!SHA256_RE.test(sha)) {
|
||||
failed.push({id: sha, error: 'invalid_sha256'});
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
await deps.adminRepository.banFileSha({
|
||||
sha256_hex: sha,
|
||||
category: ContentBlocklistCategory.MANUAL,
|
||||
severity: ContentBlocklistSeverity.BLOCK,
|
||||
content_type: null,
|
||||
source_url: null,
|
||||
added_at: new Date(),
|
||||
added_by: adminUserId,
|
||||
notes: null,
|
||||
await helpers.reportProgress(0, shas.length, `Banning ${shas.length} file SHAs`);
|
||||
return runAdminBulkJob({
|
||||
helpers,
|
||||
ids: shas,
|
||||
progressEvery: 50,
|
||||
apply: async (sha) => {
|
||||
if (!SHA256_RE.test(sha)) {
|
||||
throw new Error('invalid_sha256');
|
||||
}
|
||||
await banManagementService.banFileSha({sha256_hex: sha}, adminUserId, payload.audit_log_reason, {
|
||||
deferRefresh: true,
|
||||
});
|
||||
fileShaCache.add(sha);
|
||||
successful.push(sha);
|
||||
} catch (err) {
|
||||
failed.push({id: sha, error: err instanceof Error ? err.message : String(err)});
|
||||
}
|
||||
if ((i + 1) % 50 === 0) {
|
||||
await helpers.reportProgress(i + 1, total, null);
|
||||
}
|
||||
}
|
||||
await cacheService.publish(BANNED_FILE_SHAS_REFRESH_CHANNEL, 'refresh');
|
||||
await auditService.createAuditLog({
|
||||
adminUserId,
|
||||
targetType: 'file_sha',
|
||||
targetId: BigInt(0),
|
||||
action: 'bulk_ban_file_shas',
|
||||
auditLogReason: payload.audit_log_reason,
|
||||
metadata: new Map([
|
||||
['count', total.toString()],
|
||||
['successful', successful.length.toString()],
|
||||
['failed', failed.length.toString()],
|
||||
]),
|
||||
},
|
||||
afterItems: async () => {
|
||||
await banManagementService.publishFileShaRefresh();
|
||||
return [];
|
||||
},
|
||||
summary: {
|
||||
auditService,
|
||||
adminUserId,
|
||||
action: 'bulk_ban_file_shas',
|
||||
auditLogReason: payload.audit_log_reason,
|
||||
metadata: [['count', shas.length.toString()]],
|
||||
},
|
||||
});
|
||||
await helpers.reportProgress(total, total, `+${successful.length} ok, ${failed.length} failed`);
|
||||
helpers.logger.info({successful: successful.length, failed: failed.length}, 'bulkBanFileShas complete');
|
||||
};
|
||||
|
||||
export default handler;
|
||||
|
||||
@@ -3,9 +3,9 @@
|
||||
import {AdminAuditService} from '@app/api/admin/services/AdminAuditService';
|
||||
import {createUserID} from '@app/api/BrandedTypes';
|
||||
import {UserMessageDeletionService} from '@app/api/channel/services/message/UserMessageDeletionService';
|
||||
import {runAdminBulkJob} from '@app/api/worker/tasks/admin_bulk/AdminBulkJob';
|
||||
import {getWorkerDependencies} from '@app/api/worker/WorkerContext';
|
||||
import type {WorkerTaskHandler} from '@pkgs/worker/src/contracts/WorkerTask';
|
||||
import {JobCancelledError} from '@pkgs/worker/src/contracts/WorkerTask';
|
||||
|
||||
interface Payload {
|
||||
user_ids: Array<string>;
|
||||
@@ -29,18 +29,14 @@ const handler: WorkerTaskHandler = async (rawPayload, helpers) => {
|
||||
});
|
||||
const adminUserId = createUserID(BigInt(payload.admin_user_id));
|
||||
const total = payload.user_ids.length;
|
||||
const successful: Array<string> = [];
|
||||
const failed: Array<{
|
||||
id: string;
|
||||
error: string;
|
||||
}> = [];
|
||||
let deletedMessages = 0;
|
||||
await helpers.setContextLink(`/users?ids=${payload.user_ids.slice(0, 50).join(',')}`);
|
||||
await helpers.reportProgress(0, total, `Deleting all messages from ${total} users`);
|
||||
for (let i = 0; i < payload.user_ids.length; i++) {
|
||||
if (await helpers.shouldCancel()) throw new JobCancelledError();
|
||||
const rawUserId = payload.user_ids[i]!;
|
||||
try {
|
||||
return runAdminBulkJob({
|
||||
helpers,
|
||||
ids: payload.user_ids,
|
||||
progressEvery: 1,
|
||||
apply: async (rawUserId) => {
|
||||
const userId = createUserID(BigInt(rawUserId));
|
||||
const deleted = await deletionService.deleteUserMessagesBulk(userId);
|
||||
deletedMessages += deleted;
|
||||
@@ -49,33 +45,22 @@ const handler: WorkerTaskHandler = async (rawPayload, helpers) => {
|
||||
targetType: 'message_deletion',
|
||||
targetId: BigInt(userId),
|
||||
action: 'delete_all_user_messages',
|
||||
auditLogReason: null,
|
||||
metadata: new Map([['message_count', deleted.toString()]]),
|
||||
auditLogReason: payload.audit_log_reason,
|
||||
metadata: new Map([
|
||||
['user_id', userId.toString()],
|
||||
['message_count', deleted.toString()],
|
||||
]),
|
||||
});
|
||||
successful.push(rawUserId);
|
||||
} catch (err) {
|
||||
failed.push({id: rawUserId, error: err instanceof Error ? err.message : String(err)});
|
||||
}
|
||||
await helpers.reportProgress(i + 1, total, `${deletedMessages} messages deleted`);
|
||||
}
|
||||
await auditService.createAuditLog({
|
||||
adminUserId,
|
||||
targetType: 'message_deletion',
|
||||
targetId: BigInt(0),
|
||||
action: 'bulk_delete_user_messages',
|
||||
auditLogReason: payload.audit_log_reason,
|
||||
metadata: new Map([
|
||||
['user_count', total.toString()],
|
||||
['message_count', deletedMessages.toString()],
|
||||
['successful', successful.length.toString()],
|
||||
['failed', failed.length.toString()],
|
||||
]),
|
||||
},
|
||||
afterItems: async () => [['message_count', deletedMessages.toString()]] as Array<[string, string]>,
|
||||
summary: {
|
||||
auditService,
|
||||
adminUserId,
|
||||
action: 'bulk_delete_user_messages',
|
||||
auditLogReason: payload.audit_log_reason,
|
||||
metadata: [['user_count', total.toString()]],
|
||||
},
|
||||
});
|
||||
await helpers.reportProgress(total, total, `${deletedMessages} messages deleted, ${failed.length} users failed`);
|
||||
helpers.logger.info(
|
||||
{successful: successful.length, failed: failed.length, deletedMessages},
|
||||
'bulkDeleteMessagesForUsers complete',
|
||||
);
|
||||
};
|
||||
|
||||
export default handler;
|
||||
|
||||
@@ -1,125 +1,65 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {AdminAuditService} from '@app/api/admin/services/AdminAuditService';
|
||||
import {AdminUserUpdatePropagator} from '@app/api/admin/services/AdminUserUpdatePropagator';
|
||||
import {resolveDeletionDays} from '@app/api/admin/services/AdminUserDeletionService';
|
||||
import {createUserID} from '@app/api/BrandedTypes';
|
||||
import {reschedulePendingDeletion} from '@app/api/user/services/PendingDeletionCoordinator';
|
||||
import {runAdminBulkJob} from '@app/api/worker/tasks/admin_bulk/AdminBulkJob';
|
||||
import {createAdminBulkServices} from '@app/api/worker/tasks/admin_bulk/AdminBulkServices';
|
||||
import {getWorkerDependencies} from '@app/api/worker/WorkerContext';
|
||||
import {DeletionReasons} from '@fluxer/constants/src/Core';
|
||||
import {UserFlags} from '@fluxer/constants/src/UserConstants';
|
||||
import type {ScheduleAccountDeletionRequest} from '@fluxer/schema/src/domains/admin/AdminUserSchemas';
|
||||
import type {WorkerTaskHandler} from '@pkgs/worker/src/contracts/WorkerTask';
|
||||
import {JobCancelledError} from '@pkgs/worker/src/contracts/WorkerTask';
|
||||
|
||||
interface Payload {
|
||||
user_ids: Array<string>;
|
||||
reason_code: number;
|
||||
reason_code: ScheduleAccountDeletionRequest['reason_code'];
|
||||
days_until_deletion: number;
|
||||
public_reason: string | null;
|
||||
admin_user_id: string;
|
||||
audit_log_reason: string | null;
|
||||
}
|
||||
|
||||
const MIN_USER_REQUESTED_DAYS = 14;
|
||||
const MIN_STANDARD_DAYS = 60;
|
||||
const handler: WorkerTaskHandler = async (rawPayload, helpers) => {
|
||||
const payload: Payload = {
|
||||
user_ids: rawPayload.user_ids as Array<string>,
|
||||
reason_code: rawPayload.reason_code as number,
|
||||
reason_code: rawPayload.reason_code as ScheduleAccountDeletionRequest['reason_code'],
|
||||
days_until_deletion: rawPayload.days_until_deletion as number,
|
||||
public_reason: (rawPayload.public_reason as string | null) ?? null,
|
||||
admin_user_id: rawPayload.admin_user_id as string,
|
||||
audit_log_reason: (rawPayload.audit_log_reason as string | null) ?? null,
|
||||
};
|
||||
const deps = getWorkerDependencies();
|
||||
const auditService = new AdminAuditService(deps.adminRepository, deps.snowflakeService);
|
||||
const propagator = new AdminUserUpdatePropagator({
|
||||
userCacheService: deps.userCacheService,
|
||||
userRepository: deps.userRepository,
|
||||
guildRepository: deps.guildRepository,
|
||||
gatewayService: deps.gatewayService,
|
||||
});
|
||||
const {auditService, userService} = createAdminBulkServices(getWorkerDependencies());
|
||||
const adminUserId = createUserID(BigInt(payload.admin_user_id));
|
||||
const userIds = payload.user_ids.map((id) => BigInt(id));
|
||||
const minDays = payload.reason_code === DeletionReasons.USER_REQUESTED ? MIN_USER_REQUESTED_DAYS : MIN_STANDARD_DAYS;
|
||||
const daysUntilDeletion = Math.max(payload.days_until_deletion, minDays);
|
||||
const total = userIds.length;
|
||||
const successful: Array<string> = [];
|
||||
const failed: Array<{
|
||||
id: string;
|
||||
error: string;
|
||||
}> = [];
|
||||
await helpers.setContextLink(`/users?ids=${userIds.slice(0, 50).join(',')}`);
|
||||
const total = payload.user_ids.length;
|
||||
const daysUntilDeletion = resolveDeletionDays(payload.reason_code, payload.days_until_deletion);
|
||||
await helpers.setContextLink(`/users?ids=${payload.user_ids.slice(0, 50).join(',')}`);
|
||||
await helpers.reportProgress(0, total, `Scheduling deletion of ${total} users in ${daysUntilDeletion} days`);
|
||||
for (let i = 0; i < userIds.length; i++) {
|
||||
if (await helpers.shouldCancel()) throw new JobCancelledError();
|
||||
const userIdBigInt = userIds[i]!;
|
||||
const userId = createUserID(userIdBigInt);
|
||||
try {
|
||||
const user = await deps.userRepository.findUnique(userId);
|
||||
if (!user) throw new Error('user_not_found');
|
||||
const pendingDeletionAt = new Date();
|
||||
pendingDeletionAt.setDate(pendingDeletionAt.getDate() + daysUntilDeletion);
|
||||
const updatedUser = await deps.userRepository.updateDeletionSchedule(user, {
|
||||
flags: user.flags | UserFlags.DELETED,
|
||||
pending_deletion_at: pendingDeletionAt,
|
||||
deletion_reason_code: payload.reason_code,
|
||||
deletion_public_reason: payload.public_reason ?? null,
|
||||
deletion_audit_log_reason: payload.audit_log_reason ?? null,
|
||||
});
|
||||
await reschedulePendingDeletion({
|
||||
userId,
|
||||
currentPendingDeletionAt: user.pendingDeletionAt,
|
||||
nextPendingDeletionAt: pendingDeletionAt,
|
||||
deletionReasonCode: payload.reason_code,
|
||||
userRepository: deps.userRepository,
|
||||
deletionQueue: deps.deletionQueueService,
|
||||
});
|
||||
await propagator.propagateUserUpdate({userId, oldUser: user, updatedUser});
|
||||
if (user.email) {
|
||||
try {
|
||||
await deps.emailService.sendAccountScheduledForDeletionEmail(
|
||||
user.email,
|
||||
user.username,
|
||||
payload.public_reason ?? null,
|
||||
pendingDeletionAt,
|
||||
user.locale,
|
||||
);
|
||||
} catch (emailErr) {
|
||||
helpers.logger.warn({err: emailErr, userId: userId.toString()}, 'Failed to send deletion email');
|
||||
}
|
||||
}
|
||||
await auditService.createAuditLog({
|
||||
return await runAdminBulkJob({
|
||||
helpers,
|
||||
ids: payload.user_ids,
|
||||
progressEvery: 10,
|
||||
apply: async (userId) => {
|
||||
await userService.deletionService.applyScheduledDeletion(
|
||||
{
|
||||
user_id: BigInt(userId),
|
||||
reason_code: payload.reason_code,
|
||||
public_reason: payload.public_reason ?? undefined,
|
||||
days_until_deletion: payload.days_until_deletion,
|
||||
},
|
||||
adminUserId,
|
||||
targetType: 'user',
|
||||
targetId: BigInt(userId),
|
||||
action: 'schedule_deletion',
|
||||
auditLogReason: null,
|
||||
metadata: new Map([['days', daysUntilDeletion.toString()]]),
|
||||
});
|
||||
successful.push(userId.toString());
|
||||
} catch (err) {
|
||||
failed.push({id: userIdBigInt.toString(), error: err instanceof Error ? err.message : String(err)});
|
||||
}
|
||||
if ((i + 1) % 10 === 0) {
|
||||
await helpers.reportProgress(i + 1, total, null);
|
||||
}
|
||||
}
|
||||
await auditService.createAuditLog({
|
||||
adminUserId,
|
||||
targetType: 'user',
|
||||
targetId: BigInt(0),
|
||||
action: 'bulk_schedule_deletion',
|
||||
auditLogReason: payload.audit_log_reason,
|
||||
metadata: new Map([
|
||||
['user_count', total.toString()],
|
||||
['reason_code', payload.reason_code.toString()],
|
||||
['days', daysUntilDeletion.toString()],
|
||||
['successful', successful.length.toString()],
|
||||
['failed', failed.length.toString()],
|
||||
]),
|
||||
payload.audit_log_reason,
|
||||
);
|
||||
},
|
||||
summary: {
|
||||
auditService,
|
||||
adminUserId,
|
||||
action: 'bulk_schedule_deletion',
|
||||
auditLogReason: payload.audit_log_reason,
|
||||
metadata: [
|
||||
['user_count', total.toString()],
|
||||
['reason_code', payload.reason_code.toString()],
|
||||
['days', daysUntilDeletion.toString()],
|
||||
],
|
||||
},
|
||||
});
|
||||
await helpers.reportProgress(total, total, `+${successful.length} ok, ${failed.length} failed`);
|
||||
helpers.logger.info({successful: successful.length, failed: failed.length}, 'bulkScheduleUserDeletion complete');
|
||||
};
|
||||
|
||||
export default handler;
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {AdminAuditService} from '@app/api/admin/services/AdminAuditService';
|
||||
import {AdminGuildUpdatePropagator} from '@app/api/admin/services/guild/AdminGuildUpdatePropagator';
|
||||
import {createGuildID, createUserID} from '@app/api/BrandedTypes';
|
||||
import {getGuildDiscoveryRepository} from '@app/api/middleware/ServiceSingletons';
|
||||
import {runAdminBulkJob} from '@app/api/worker/tasks/admin_bulk/AdminBulkJob';
|
||||
import {createAdminBulkServices} from '@app/api/worker/tasks/admin_bulk/AdminBulkServices';
|
||||
import {getWorkerDependencies} from '@app/api/worker/WorkerContext';
|
||||
import type {WorkerTaskHandler} from '@pkgs/worker/src/contracts/WorkerTask';
|
||||
import {JobCancelledError} from '@pkgs/worker/src/contracts/WorkerTask';
|
||||
|
||||
interface Payload {
|
||||
guild_ids: Array<string>;
|
||||
@@ -24,73 +22,36 @@ const handler: WorkerTaskHandler = async (rawPayload, helpers) => {
|
||||
admin_user_id: rawPayload.admin_user_id as string,
|
||||
audit_log_reason: (rawPayload.audit_log_reason as string | null) ?? null,
|
||||
};
|
||||
const deps = getWorkerDependencies();
|
||||
const auditService = new AdminAuditService(deps.adminRepository, deps.snowflakeService);
|
||||
const propagator = new AdminGuildUpdatePropagator({
|
||||
gatewayService: deps.gatewayService,
|
||||
discoveryRepository: getGuildDiscoveryRepository(),
|
||||
});
|
||||
const {auditService, guildService} = createAdminBulkServices(getWorkerDependencies());
|
||||
const adminUserId = createUserID(BigInt(payload.admin_user_id));
|
||||
const guildIds = payload.guild_ids.map((id) => BigInt(id));
|
||||
const total = guildIds.length;
|
||||
const successful: Array<string> = [];
|
||||
const failed: Array<{
|
||||
id: string;
|
||||
error: string;
|
||||
}> = [];
|
||||
await helpers.setContextLink(`/guilds?ids=${guildIds.slice(0, 50).join(',')}`);
|
||||
const total = payload.guild_ids.length;
|
||||
await helpers.setContextLink(`/guilds?ids=${payload.guild_ids.slice(0, 50).join(',')}`);
|
||||
await helpers.reportProgress(0, total, `Updating features on ${total} guilds`);
|
||||
for (let i = 0; i < guildIds.length; i++) {
|
||||
if (await helpers.shouldCancel()) throw new JobCancelledError();
|
||||
const guildIdBigInt = guildIds[i]!;
|
||||
const guildId = createGuildID(guildIdBigInt);
|
||||
try {
|
||||
const guild = await deps.guildRepository.findUnique(guildId);
|
||||
if (!guild) throw new Error('guild_not_found');
|
||||
const newFeatures = new Set(guild.features);
|
||||
for (const f of payload.add_features) newFeatures.add(f);
|
||||
for (const f of payload.remove_features) newFeatures.delete(f);
|
||||
const updatedGuild = await deps.guildRepository.upsertPartial(guildId, {features: newFeatures}, guild.toRow());
|
||||
await propagator.dispatchGuildUpdate(guildId, updatedGuild, {
|
||||
return await runAdminBulkJob({
|
||||
helpers,
|
||||
ids: payload.guild_ids,
|
||||
progressEvery: 25,
|
||||
apply: async (id) => {
|
||||
await guildService.updateService.updateGuildFeatures({
|
||||
guildId: createGuildID(BigInt(id)),
|
||||
addFeatures: payload.add_features,
|
||||
removeFeatures: payload.remove_features,
|
||||
adminUserId,
|
||||
reconcileDiscoveryFeature: true,
|
||||
auditLogReason: payload.audit_log_reason,
|
||||
});
|
||||
await auditService.createAuditLog({
|
||||
adminUserId,
|
||||
targetType: 'guild',
|
||||
targetId: BigInt(guildId),
|
||||
action: 'update_features',
|
||||
auditLogReason: null,
|
||||
metadata: new Map([
|
||||
['add_features', payload.add_features.join(',')],
|
||||
['remove_features', payload.remove_features.join(',')],
|
||||
['new_features', Array.from(newFeatures).join(',')],
|
||||
]),
|
||||
});
|
||||
successful.push(guildId.toString());
|
||||
} catch (err) {
|
||||
failed.push({id: guildIdBigInt.toString(), error: err instanceof Error ? err.message : String(err)});
|
||||
}
|
||||
if ((i + 1) % 25 === 0) {
|
||||
await helpers.reportProgress(i + 1, total, null);
|
||||
}
|
||||
}
|
||||
await auditService.createAuditLog({
|
||||
adminUserId,
|
||||
targetType: 'guild',
|
||||
targetId: BigInt(0),
|
||||
action: 'bulk_update_guild_features',
|
||||
auditLogReason: payload.audit_log_reason,
|
||||
metadata: new Map([
|
||||
['guild_count', total.toString()],
|
||||
['add_features', payload.add_features.join(',')],
|
||||
['remove_features', payload.remove_features.join(',')],
|
||||
['successful', successful.length.toString()],
|
||||
['failed', failed.length.toString()],
|
||||
]),
|
||||
},
|
||||
summary: {
|
||||
auditService,
|
||||
adminUserId,
|
||||
action: 'bulk_update_guild_features',
|
||||
auditLogReason: payload.audit_log_reason,
|
||||
metadata: [
|
||||
['guild_count', total.toString()],
|
||||
['add_features', payload.add_features.join(',')],
|
||||
['remove_features', payload.remove_features.join(',')],
|
||||
],
|
||||
},
|
||||
});
|
||||
await helpers.reportProgress(total, total, `+${successful.length} ok, ${failed.length} failed`);
|
||||
helpers.logger.info({successful: successful.length, failed: failed.length}, 'bulkUpdateGuildFeatures complete');
|
||||
};
|
||||
|
||||
export default handler;
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {AdminAuditService} from '@app/api/admin/services/AdminAuditService';
|
||||
import {AdminUserUpdatePropagator} from '@app/api/admin/services/AdminUserUpdatePropagator';
|
||||
import {createUserID} from '@app/api/BrandedTypes';
|
||||
import {runAdminBulkJob} from '@app/api/worker/tasks/admin_bulk/AdminBulkJob';
|
||||
import {createAdminBulkServices} from '@app/api/worker/tasks/admin_bulk/AdminBulkServices';
|
||||
import {getWorkerDependencies} from '@app/api/worker/WorkerContext';
|
||||
import {imposePhoneRequirements, SuspiciousActivityFlags} from '@fluxer/constants/src/UserConstants';
|
||||
import {ALL_SUSPICIOUS_ACTIVITY_FLAGS, SuspiciousActivityFlags} from '@fluxer/constants/src/UserConstants';
|
||||
import {UnknownUserError} from '@fluxer/errors/src/domains/user/UnknownUserError';
|
||||
import type {WorkerTaskHandler} from '@pkgs/worker/src/contracts/WorkerTask';
|
||||
import {JobCancelledError} from '@pkgs/worker/src/contracts/WorkerTask';
|
||||
|
||||
interface Payload {
|
||||
user_ids: Array<string>;
|
||||
@@ -16,6 +16,25 @@ interface Payload {
|
||||
audit_log_reason: string | null;
|
||||
}
|
||||
|
||||
const PROGRESS_EVERY = 25;
|
||||
|
||||
function resolveFlagMask(flagNames: ReadonlyArray<string>): number {
|
||||
let mask = 0;
|
||||
const unknownNames: Array<string> = [];
|
||||
for (const flagName of flagNames) {
|
||||
const value = SuspiciousActivityFlags[flagName as keyof typeof SuspiciousActivityFlags];
|
||||
if (value === undefined) {
|
||||
unknownNames.push(flagName);
|
||||
continue;
|
||||
}
|
||||
mask |= value;
|
||||
}
|
||||
if (unknownNames.length > 0) {
|
||||
throw new Error(`Unknown suspicious activity flag names: ${unknownNames.join(', ')}`);
|
||||
}
|
||||
return mask;
|
||||
}
|
||||
|
||||
const handler: WorkerTaskHandler = async (rawPayload, helpers) => {
|
||||
const payload: Payload = {
|
||||
user_ids: rawPayload.user_ids as Array<string>,
|
||||
@@ -24,78 +43,48 @@ const handler: WorkerTaskHandler = async (rawPayload, helpers) => {
|
||||
admin_user_id: rawPayload.admin_user_id as string,
|
||||
audit_log_reason: (rawPayload.audit_log_reason as string | null) ?? null,
|
||||
};
|
||||
const addMask = resolveFlagMask(payload.add_flags);
|
||||
const removeMask = resolveFlagMask(payload.remove_flags);
|
||||
const deps = getWorkerDependencies();
|
||||
const auditService = new AdminAuditService(deps.adminRepository, deps.snowflakeService);
|
||||
const propagator = new AdminUserUpdatePropagator({
|
||||
userCacheService: deps.userCacheService,
|
||||
userRepository: deps.userRepository,
|
||||
guildRepository: deps.guildRepository,
|
||||
gatewayService: deps.gatewayService,
|
||||
});
|
||||
const {auditService, userService} = createAdminBulkServices(deps);
|
||||
const adminUserId = createUserID(BigInt(payload.admin_user_id));
|
||||
const userIds = payload.user_ids.map((id) => BigInt(id));
|
||||
const addMask = payload.add_flags.reduce((mask, name) => {
|
||||
const v = SuspiciousActivityFlags[name as keyof typeof SuspiciousActivityFlags];
|
||||
return v !== undefined ? mask | v : mask;
|
||||
}, 0);
|
||||
const removeMask = payload.remove_flags.reduce((mask, name) => {
|
||||
const v = SuspiciousActivityFlags[name as keyof typeof SuspiciousActivityFlags];
|
||||
return v !== undefined ? mask | v : mask;
|
||||
}, 0);
|
||||
const total = userIds.length;
|
||||
const successful: Array<string> = [];
|
||||
const failed: Array<{
|
||||
id: string;
|
||||
error: string;
|
||||
}> = [];
|
||||
await helpers.setContextLink(`/users?ids=${userIds.slice(0, 50).join(',')}`);
|
||||
const acls = new Set<string>();
|
||||
const total = payload.user_ids.length;
|
||||
await helpers.setContextLink(`/users?ids=${payload.user_ids.slice(0, 50).join(',')}`);
|
||||
await helpers.reportProgress(0, total, `Updating suspicious flags on ${total} users`);
|
||||
for (let i = 0; i < userIds.length; i++) {
|
||||
if (await helpers.shouldCancel()) throw new JobCancelledError();
|
||||
const userIdBigInt = userIds[i]!;
|
||||
const userId = createUserID(userIdBigInt);
|
||||
try {
|
||||
const user = await deps.userRepository.findUnique(userId);
|
||||
if (!user) throw new Error('user_not_found');
|
||||
return await runAdminBulkJob({
|
||||
helpers,
|
||||
ids: payload.user_ids,
|
||||
progressEvery: PROGRESS_EVERY,
|
||||
apply: async (id) => {
|
||||
const userIdBigInt = BigInt(id);
|
||||
const user = await deps.userRepository.findUnique(createUserID(userIdBigInt));
|
||||
if (!user) {
|
||||
throw new UnknownUserError();
|
||||
}
|
||||
const currentFlags = user.suspiciousActivityFlags ?? 0;
|
||||
const newFlags = imposePhoneRequirements(currentFlags, addMask) & ~removeMask;
|
||||
const updatedUser = await deps.userRepository.patchUpsert(
|
||||
userId,
|
||||
{suspicious_activity_flags: newFlags},
|
||||
user.toRow(),
|
||||
const flags = (currentFlags | addMask) & ~removeMask & ALL_SUSPICIOUS_ACTIVITY_FLAGS;
|
||||
await userService.securityService.updateSuspiciousActivityFlags(
|
||||
{user_id: userIdBigInt, flags},
|
||||
adminUserId,
|
||||
payload.audit_log_reason,
|
||||
acls,
|
||||
);
|
||||
await propagator.propagateUserUpdate({userId, oldUser: user, updatedUser});
|
||||
successful.push(userId.toString());
|
||||
} catch (err) {
|
||||
failed.push({id: userIdBigInt.toString(), error: err instanceof Error ? err.message : String(err)});
|
||||
}
|
||||
if ((i + 1) % 25 === 0) {
|
||||
await helpers.reportProgress(i + 1, total, null);
|
||||
}
|
||||
}
|
||||
await auditService.createAuditLog({
|
||||
adminUserId,
|
||||
targetType: 'user',
|
||||
targetId: BigInt(0),
|
||||
action: 'bulk_update_suspicious_activity_flags',
|
||||
auditLogReason: payload.audit_log_reason,
|
||||
metadata: new Map(
|
||||
(
|
||||
},
|
||||
summary: {
|
||||
auditService,
|
||||
adminUserId,
|
||||
action: 'bulk_update_suspicious_activity_flags',
|
||||
auditLogReason: payload.audit_log_reason,
|
||||
metadata: (
|
||||
[
|
||||
['user_count', total.toString()],
|
||||
['add_flags', payload.add_flags.join(',')],
|
||||
['remove_flags', payload.remove_flags.join(',')],
|
||||
['successful', successful.length.toString()],
|
||||
['failed', failed.length.toString()],
|
||||
] as Array<[string, string]>
|
||||
).filter(([_, v]) => v.length > 0),
|
||||
),
|
||||
).filter(([, value]) => value.length > 0),
|
||||
},
|
||||
});
|
||||
await helpers.reportProgress(total, total, `+${successful.length} ok, ${failed.length} failed`);
|
||||
helpers.logger.info(
|
||||
{successful: successful.length, failed: failed.length},
|
||||
'bulkUpdateSuspiciousActivityFlags complete',
|
||||
);
|
||||
};
|
||||
|
||||
export default handler;
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {AdminAuditService} from '@app/api/admin/services/AdminAuditService';
|
||||
import {AdminUserUpdatePropagator} from '@app/api/admin/services/AdminUserUpdatePropagator';
|
||||
import {createUserID} from '@app/api/BrandedTypes';
|
||||
import {runAdminBulkJob} from '@app/api/worker/tasks/admin_bulk/AdminBulkJob';
|
||||
import {createAdminBulkServices} from '@app/api/worker/tasks/admin_bulk/AdminBulkServices';
|
||||
import {getWorkerDependencies} from '@app/api/worker/WorkerContext';
|
||||
import type {WorkerTaskHandler} from '@pkgs/worker/src/contracts/WorkerTask';
|
||||
import {JobCancelledError} from '@pkgs/worker/src/contracts/WorkerTask';
|
||||
|
||||
interface BulkUpdateUserFlagsPayload {
|
||||
user_ids: Array<string>;
|
||||
@@ -15,6 +14,8 @@ interface BulkUpdateUserFlagsPayload {
|
||||
audit_log_reason: string | null;
|
||||
}
|
||||
|
||||
const PROGRESS_EVERY = 25;
|
||||
|
||||
const handler: WorkerTaskHandler = async (rawPayload, helpers) => {
|
||||
const payload: BulkUpdateUserFlagsPayload = {
|
||||
user_ids: rawPayload.user_ids as Array<string>,
|
||||
@@ -24,81 +25,41 @@ const handler: WorkerTaskHandler = async (rawPayload, helpers) => {
|
||||
audit_log_reason: (rawPayload.audit_log_reason as string | null) ?? null,
|
||||
};
|
||||
const deps = getWorkerDependencies();
|
||||
const auditService = new AdminAuditService(deps.adminRepository, deps.snowflakeService);
|
||||
const propagator = new AdminUserUpdatePropagator({
|
||||
userCacheService: deps.userCacheService,
|
||||
userRepository: deps.userRepository,
|
||||
guildRepository: deps.guildRepository,
|
||||
gatewayService: deps.gatewayService,
|
||||
});
|
||||
const {auditService, userService} = createAdminBulkServices(deps);
|
||||
const adminUserId = createUserID(BigInt(payload.admin_user_id));
|
||||
const userIds = payload.user_ids.map((id) => BigInt(id));
|
||||
const addFlags = payload.add_flags.map((f) => BigInt(f));
|
||||
const removeFlags = payload.remove_flags.map((f) => BigInt(f));
|
||||
const total = userIds.length;
|
||||
const successful: Array<string> = [];
|
||||
const failed: Array<{
|
||||
id: string;
|
||||
error: string;
|
||||
}> = [];
|
||||
await helpers.setContextLink(`/users?ids=${userIds.slice(0, 50).join(',')}`);
|
||||
const addFlags = payload.add_flags.map((flag) => BigInt(flag));
|
||||
const removeFlags = payload.remove_flags.map((flag) => BigInt(flag));
|
||||
const acls = new Set<string>();
|
||||
const total = payload.user_ids.length;
|
||||
await helpers.setContextLink(`/users?ids=${payload.user_ids.slice(0, 50).join(',')}`);
|
||||
await helpers.reportProgress(0, total, `Updating flags on ${total} users`);
|
||||
for (let i = 0; i < userIds.length; i++) {
|
||||
if (await helpers.shouldCancel()) throw new JobCancelledError();
|
||||
const userIdBigInt = userIds[i]!;
|
||||
const userId = createUserID(userIdBigInt);
|
||||
try {
|
||||
const user = await deps.userRepository.findUnique(userId);
|
||||
if (!user) throw new Error('user_not_found');
|
||||
let newFlags = user.flags;
|
||||
for (const f of addFlags) newFlags |= f;
|
||||
for (const f of removeFlags) newFlags &= ~f;
|
||||
const updatedUser = await deps.userRepository.patchUpsert(userId, {flags: newFlags}, user.toRow());
|
||||
await propagator.propagateUserUpdate({userId, oldUser: user, updatedUser});
|
||||
await auditService.createAuditLog({
|
||||
return await runAdminBulkJob({
|
||||
helpers,
|
||||
ids: payload.user_ids,
|
||||
progressEvery: PROGRESS_EVERY,
|
||||
apply: async (id) => {
|
||||
await userService.securityService.updateUserFlags({
|
||||
userId: createUserID(BigInt(id)),
|
||||
data: {addFlags, removeFlags},
|
||||
adminUserId,
|
||||
targetType: 'user',
|
||||
targetId: BigInt(userId),
|
||||
action: 'update_flags',
|
||||
auditLogReason: null,
|
||||
metadata: new Map(
|
||||
(
|
||||
[
|
||||
['add_flags', addFlags.map((f) => f.toString()).join(',')],
|
||||
['remove_flags', removeFlags.map((f) => f.toString()).join(',')],
|
||||
['new_flags', newFlags.toString()],
|
||||
] as Array<[string, string]>
|
||||
).filter(([_, v]) => v.length > 0),
|
||||
),
|
||||
auditLogReason: payload.audit_log_reason,
|
||||
acls,
|
||||
});
|
||||
successful.push(userId.toString());
|
||||
} catch (err) {
|
||||
failed.push({id: userIdBigInt.toString(), error: err instanceof Error ? err.message : String(err)});
|
||||
}
|
||||
if ((i + 1) % 25 === 0) {
|
||||
await helpers.reportProgress(i + 1, total, null);
|
||||
}
|
||||
}
|
||||
await auditService.createAuditLog({
|
||||
adminUserId,
|
||||
targetType: 'user',
|
||||
targetId: BigInt(0),
|
||||
action: 'bulk_update_user_flags',
|
||||
auditLogReason: payload.audit_log_reason,
|
||||
metadata: new Map(
|
||||
(
|
||||
},
|
||||
summary: {
|
||||
auditService,
|
||||
adminUserId,
|
||||
action: 'bulk_update_user_flags',
|
||||
auditLogReason: payload.audit_log_reason,
|
||||
metadata: (
|
||||
[
|
||||
['user_count', total.toString()],
|
||||
['add_flags', addFlags.map((f) => f.toString()).join(',')],
|
||||
['remove_flags', removeFlags.map((f) => f.toString()).join(',')],
|
||||
['successful', successful.length.toString()],
|
||||
['failed', failed.length.toString()],
|
||||
['add_flags', addFlags.map((flag) => flag.toString()).join(',')],
|
||||
['remove_flags', removeFlags.map((flag) => flag.toString()).join(',')],
|
||||
] as Array<[string, string]>
|
||||
).filter(([_, v]) => v.length > 0),
|
||||
),
|
||||
).filter(([, value]) => value.length > 0),
|
||||
},
|
||||
});
|
||||
await helpers.reportProgress(total, total, `+${successful.length} ok, ${failed.length} failed`);
|
||||
helpers.logger.info({successful: successful.length, failed: failed.length}, 'bulkUpdateUserFlags task complete');
|
||||
};
|
||||
|
||||
export default handler;
|
||||
|
||||
@@ -0,0 +1,311 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import type {AdminAuditLog} from '@app/api/admin/IAdminRepository';
|
||||
import {createTestAccount, setUserACLs, type TestAccount} from '@app/api/auth/tests/AuthTestUtils';
|
||||
import {createGuildID, createUserID} from '@app/api/BrandedTypes';
|
||||
import {createApiContext} from '@app/api/CreateApiContext';
|
||||
import {GuildDiscoveryRepository} from '@app/api/guild/repositories/GuildDiscoveryRepository';
|
||||
import {createGuild} from '@app/api/guild/tests/GuildTestUtils';
|
||||
import {DisabledLiveKitService} from '@app/api/infrastructure/DisabledLiveKitService';
|
||||
import {InMemoryVoiceRoomStore} from '@app/api/infrastructure/InMemoryVoiceRoomStore';
|
||||
import {getMessages} from '@app/api/message/tests/MessageTestUtils';
|
||||
import {createGuildStackServices} from '@app/api/middleware/GuildStackServiceFactory';
|
||||
import {getIpInfoService} from '@app/api/middleware/ServiceMiddleware';
|
||||
import {getGatewayService, getSnowflakeService, getVoiceAvailabilityService} from '@app/api/middleware/ServiceRegistry';
|
||||
import {
|
||||
getAdminRepository,
|
||||
getAssetDeletionQueue,
|
||||
getAttachmentUploadTraceRepository,
|
||||
getAvatarService,
|
||||
getChannelRepository,
|
||||
getEmbedService,
|
||||
getEntityAssetService,
|
||||
getFavoriteMemeRepository,
|
||||
getGuildAuditLogService,
|
||||
getGuildRepository,
|
||||
getInviteRepository,
|
||||
getKVAccountDeletionQueue,
|
||||
getKVBulkMessageDeletionQueue,
|
||||
getLimitConfigService,
|
||||
getPurgeQueue,
|
||||
getReadStateService,
|
||||
getStorageService,
|
||||
getUserCacheService,
|
||||
getUserRepository,
|
||||
getVirusScanServiceInstance,
|
||||
getWebhookRepository,
|
||||
} from '@app/api/middleware/ServiceSingletons';
|
||||
import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHarness';
|
||||
import {NoopLogger} from '@app/api/test/mocks/NoopLogger';
|
||||
import {HTTP_STATUS} from '@app/api/test/TestConstants';
|
||||
import {createBuilder} from '@app/api/test/TestRequestBuilder';
|
||||
import bulkAddGuildMembers from '@app/api/worker/tasks/admin_bulk/BulkAddGuildMembers';
|
||||
import bulkUpdateGuildFeatures from '@app/api/worker/tasks/admin_bulk/BulkUpdateGuildFeatures';
|
||||
import {clearWorkerDependencies, setWorkerDependenciesForTest} from '@app/api/worker/WorkerContext';
|
||||
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
|
||||
import {MessageTypes} from '@fluxer/constants/src/ChannelConstants';
|
||||
import {DiscoveryApplicationStatus, DiscoveryCategories} from '@fluxer/constants/src/DiscoveryConstants';
|
||||
import {GuildFeatures} from '@fluxer/constants/src/GuildConstants';
|
||||
import type {GuildResponse} from '@fluxer/schema/src/domains/guild/GuildResponseSchemas';
|
||||
import type {WorkerTaskHelpers} from '@pkgs/worker/src/contracts/WorkerTask';
|
||||
import {afterEach, beforeEach, describe, expect, test} from 'vitest';
|
||||
|
||||
const JOB_ID = 4242n;
|
||||
const MISSING_ID = '123456789012345678';
|
||||
|
||||
interface BulkJobResult {
|
||||
successful_count: number;
|
||||
failed_count: number;
|
||||
failed: Array<{
|
||||
id: string;
|
||||
error: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
function createHelpers(): WorkerTaskHelpers {
|
||||
return {
|
||||
logger: new NoopLogger(),
|
||||
jobId: JOB_ID,
|
||||
addJob: async () => 0n,
|
||||
reportProgress: async () => {},
|
||||
shouldCancel: async () => false,
|
||||
setContextLink: async () => {},
|
||||
};
|
||||
}
|
||||
|
||||
function installWorkerDependencies(): void {
|
||||
const guildStack = createGuildStackServices({
|
||||
apiContext: createApiContext(),
|
||||
channelRepository: getChannelRepository(),
|
||||
userRepository: getUserRepository(),
|
||||
guildRepository: getGuildRepository(),
|
||||
inviteRepository: getInviteRepository(),
|
||||
webhookRepository: getWebhookRepository(),
|
||||
favoriteMemeRepository: getFavoriteMemeRepository(),
|
||||
avatarService: getAvatarService(),
|
||||
entityAssetService: getEntityAssetService(),
|
||||
assetDeletionQueue: getAssetDeletionQueue(),
|
||||
userCacheService: getUserCacheService(),
|
||||
limitConfigService: getLimitConfigService(),
|
||||
embedService: getEmbedService(),
|
||||
readStateService: getReadStateService(),
|
||||
storageService: getStorageService(),
|
||||
attachmentUploadTraceRepository: getAttachmentUploadTraceRepository(),
|
||||
virusScanService: getVirusScanServiceInstance(),
|
||||
purgeQueue: getPurgeQueue(),
|
||||
guildAuditLogService: getGuildAuditLogService(),
|
||||
voiceRoomStore: new InMemoryVoiceRoomStore(),
|
||||
liveKitService: new DisabledLiveKitService(),
|
||||
voiceAvailabilityService: getVoiceAvailabilityService(),
|
||||
ipInfoService: getIpInfoService(),
|
||||
});
|
||||
setWorkerDependenciesForTest({
|
||||
adminRepository: getAdminRepository(),
|
||||
snowflakeService: getSnowflakeService(),
|
||||
userRepository: getUserRepository(),
|
||||
guildRepository: getGuildRepository(),
|
||||
channelRepository: getChannelRepository(),
|
||||
userCacheService: getUserCacheService(),
|
||||
gatewayService: getGatewayService(),
|
||||
deletionQueueService: getKVAccountDeletionQueue(),
|
||||
bulkMessageDeletionQueueService: getKVBulkMessageDeletionQueue(),
|
||||
guildService: guildStack.guildService,
|
||||
stripe: null,
|
||||
});
|
||||
}
|
||||
|
||||
async function listAuditLogs(): Promise<Array<AdminAuditLog>> {
|
||||
return getAdminRepository().listAllAuditLogsPaginated(500);
|
||||
}
|
||||
|
||||
function findAuditLog(logs: Array<AdminAuditLog>, action: string, targetId?: bigint): AdminAuditLog | undefined {
|
||||
return logs.find((log) => log.action === action && (targetId === undefined || log.targetId === targetId));
|
||||
}
|
||||
|
||||
describe('admin bulk guild worker tasks', () => {
|
||||
let harness: ApiTestHarness;
|
||||
beforeEach(async () => {
|
||||
harness = await createApiTestHarness({search: 'enabled'});
|
||||
installWorkerDependencies();
|
||||
});
|
||||
afterEach(async () => {
|
||||
clearWorkerDependencies();
|
||||
await harness.shutdown();
|
||||
});
|
||||
|
||||
async function createAdmin(acls: Array<string>): Promise<TestAccount> {
|
||||
const admin = await createTestAccount(harness);
|
||||
return setUserACLs(harness, admin, ['admin:authenticate', ...acls]);
|
||||
}
|
||||
|
||||
async function createDiscoveryApplicant(admin: TestAccount, name: string): Promise<GuildResponse> {
|
||||
const guild = await createGuild(harness, admin.token, name);
|
||||
await createBuilder(harness, '').post(`/test/guilds/${guild.id}/member-count`).body({member_count: 10}).execute();
|
||||
await createBuilder(harness, admin.token)
|
||||
.post(`/guilds/${guild.id}/discovery`)
|
||||
.body({description: 'A guild worth discovering', category_type: DiscoveryCategories.GAMING})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
return guild;
|
||||
}
|
||||
|
||||
async function runFeaturesJob(
|
||||
admin: TestAccount,
|
||||
guildIds: Array<string>,
|
||||
auditLogReason: string | null,
|
||||
): Promise<BulkJobResult> {
|
||||
const result = await bulkUpdateGuildFeatures(
|
||||
{
|
||||
guild_ids: guildIds,
|
||||
add_features: [GuildFeatures.DISCOVERABLE],
|
||||
remove_features: [],
|
||||
admin_user_id: admin.userId,
|
||||
audit_log_reason: auditLogReason,
|
||||
},
|
||||
createHelpers(),
|
||||
);
|
||||
return result as unknown as BulkJobResult;
|
||||
}
|
||||
|
||||
async function runAddMembersJob(
|
||||
admin: TestAccount,
|
||||
guildId: string,
|
||||
userIds: Array<string>,
|
||||
auditLogReason: string | null,
|
||||
): Promise<BulkJobResult> {
|
||||
const result = await bulkAddGuildMembers(
|
||||
{
|
||||
guild_id: guildId,
|
||||
user_ids: userIds,
|
||||
admin_user_id: admin.userId,
|
||||
audit_log_reason: auditLogReason,
|
||||
},
|
||||
createHelpers(),
|
||||
);
|
||||
return result as unknown as BulkJobResult;
|
||||
}
|
||||
|
||||
test('a bulk guild-features job writes a per-guild update_features row with the admin reason', async () => {
|
||||
const admin = await createAdmin(['guild:update:features']);
|
||||
const guild = await createGuild(harness, admin.token, `Bulk Features Guild ${Date.now()}`);
|
||||
const reason = 'Lilith ticket 4821';
|
||||
|
||||
const result = await runFeaturesJob(admin, [guild.id], reason);
|
||||
|
||||
expect(result.successful_count).toBe(1);
|
||||
expect(result.failed_count).toBe(0);
|
||||
const logs = await listAuditLogs();
|
||||
const perGuild = findAuditLog(logs, 'update_features', BigInt(guild.id));
|
||||
expect(perGuild?.targetType).toBe('guild');
|
||||
expect(perGuild?.auditLogReason).toBe(reason);
|
||||
expect(perGuild?.metadata.get('add_features')).toBe(GuildFeatures.DISCOVERABLE);
|
||||
expect(perGuild?.metadata.get('new_features')).toContain(GuildFeatures.DISCOVERABLE);
|
||||
const summary = findAuditLog(logs, 'bulk_update_guild_features');
|
||||
expect(summary?.targetType).toBe('bulk_job');
|
||||
expect(summary?.targetId).toBe(JOB_ID);
|
||||
expect(summary?.auditLogReason).toBe(reason);
|
||||
expect(summary?.metadata.get('guild_count')).toBe('1');
|
||||
expect(summary?.metadata.get('add_features')).toBe(GuildFeatures.DISCOVERABLE);
|
||||
expect(summary?.metadata.get('remove_features')).toBe('');
|
||||
expect(summary?.metadata.get('successful')).toBe('1');
|
||||
expect(summary?.metadata.get('failed')).toBe('0');
|
||||
const updatedGuild = await getGuildRepository().findUnique(createGuildID(BigInt(guild.id)));
|
||||
expect(updatedGuild?.features.has(GuildFeatures.DISCOVERABLE)).toBe(true);
|
||||
});
|
||||
|
||||
test('a bulk guild-features job reconciles discovery exactly like the single-guild endpoint', async () => {
|
||||
const admin = await createAdmin(['guild:update:features']);
|
||||
const bulkGuild = await createDiscoveryApplicant(admin, `Bulk Discovery Guild ${Date.now()}`);
|
||||
const singleGuild = await createDiscoveryApplicant(admin, `Single Discovery Guild ${Date.now()}`);
|
||||
const reason = 'Lilith ticket 4822';
|
||||
|
||||
await runFeaturesJob(admin, [bulkGuild.id], reason);
|
||||
await createBuilder(harness, admin.token)
|
||||
.patch(`/admin/guilds/${singleGuild.id}`)
|
||||
.header('X-Audit-Log-Reason', reason)
|
||||
.body({add_features: [GuildFeatures.DISCOVERABLE]})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
|
||||
const discoveryRepository = new GuildDiscoveryRepository();
|
||||
const bulkRow = await discoveryRepository.findByGuildId(createGuildID(BigInt(bulkGuild.id)));
|
||||
const singleRow = await discoveryRepository.findByGuildId(createGuildID(BigInt(singleGuild.id)));
|
||||
expect(bulkRow?.status).toBe(DiscoveryApplicationStatus.APPROVED);
|
||||
expect(singleRow?.status).toBe(DiscoveryApplicationStatus.APPROVED);
|
||||
expect(bulkRow?.reviewed_by?.toString()).toBe(admin.userId);
|
||||
expect(singleRow?.reviewed_by?.toString()).toBe(admin.userId);
|
||||
expect(bulkRow?.review_reason).toBe(singleRow?.review_reason);
|
||||
});
|
||||
|
||||
test('a bulk guild-features job surfaces an unknown guild in the job result', async () => {
|
||||
const admin = await createAdmin(['guild:update:features']);
|
||||
const guild = await createGuild(harness, admin.token, `Partial Features Guild ${Date.now()}`);
|
||||
|
||||
const result = await runFeaturesJob(admin, [guild.id, MISSING_ID], 'Lilith ticket 4823');
|
||||
|
||||
expect(result.successful_count).toBe(1);
|
||||
expect(result.failed_count).toBe(1);
|
||||
expect(result.failed).toEqual([{id: MISSING_ID, error: APIErrorCodes.UNKNOWN_GUILD}]);
|
||||
const summary = findAuditLog(await listAuditLogs(), 'bulk_update_guild_features');
|
||||
expect(summary?.metadata.get('failed')).toBe('1');
|
||||
});
|
||||
|
||||
test('a bulk add-members job adds each member with a per-user force_add_to_guild row and no join message', async () => {
|
||||
const admin = await createAdmin(['guild:force_add_member']);
|
||||
const guild = await createGuild(harness, admin.token, `Bulk Members Guild ${Date.now()}`);
|
||||
const bulkTarget = await createTestAccount(harness);
|
||||
const singleTarget = await createTestAccount(harness);
|
||||
const reason = 'Lilith ticket 4824';
|
||||
|
||||
const result = await runAddMembersJob(admin, guild.id, [bulkTarget.userId], reason);
|
||||
await createBuilder(harness, admin.token)
|
||||
.put(`/admin/guilds/${guild.id}/members/${singleTarget.userId}`)
|
||||
.header('X-Audit-Log-Reason', reason)
|
||||
.body(null)
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
|
||||
expect(result.successful_count).toBe(1);
|
||||
expect(result.failed_count).toBe(0);
|
||||
const member = await getGuildRepository().getMember(
|
||||
createGuildID(BigInt(guild.id)),
|
||||
createUserID(BigInt(bulkTarget.userId)),
|
||||
);
|
||||
expect(member).not.toBeNull();
|
||||
const logs = await listAuditLogs();
|
||||
const perUser = findAuditLog(logs, 'force_add_to_guild', BigInt(bulkTarget.userId));
|
||||
expect(perUser?.targetType).toBe('user');
|
||||
expect(perUser?.auditLogReason).toBe(reason);
|
||||
expect(perUser?.metadata.get('guild_id')).toBe(guild.id);
|
||||
const summary = findAuditLog(logs, 'bulk_add_guild_members');
|
||||
expect(summary?.targetType).toBe('guild');
|
||||
expect(summary?.targetId).toBe(BigInt(guild.id));
|
||||
expect(summary?.auditLogReason).toBe(reason);
|
||||
expect(summary?.metadata.get('job_id')).toBe(JOB_ID.toString());
|
||||
expect(summary?.metadata.get('guild_id')).toBe(guild.id);
|
||||
expect(summary?.metadata.get('user_count')).toBe('1');
|
||||
expect(summary?.metadata.get('successful')).toBe('1');
|
||||
const systemChannelId = guild.system_channel_id;
|
||||
expect(systemChannelId).toBeTruthy();
|
||||
const messages = await getMessages(harness, admin.token, String(systemChannelId));
|
||||
const joinAuthorIds = messages
|
||||
.filter((message) => message.type === MessageTypes.USER_JOIN)
|
||||
.map((message) => message.author?.id);
|
||||
expect(joinAuthorIds).toContain(singleTarget.userId);
|
||||
expect(joinAuthorIds).not.toContain(bulkTarget.userId);
|
||||
});
|
||||
|
||||
test('a bulk add-members job reports an unknown user as an unknown user, not an unknown guild', async () => {
|
||||
const admin = await createAdmin(['guild:force_add_member']);
|
||||
const guild = await createGuild(harness, admin.token, `Partial Members Guild ${Date.now()}`);
|
||||
const target = await createTestAccount(harness);
|
||||
|
||||
const result = await runAddMembersJob(admin, guild.id, [target.userId, MISSING_ID], 'Lilith ticket 4825');
|
||||
|
||||
expect(result.successful_count).toBe(1);
|
||||
expect(result.failed_count).toBe(1);
|
||||
expect(result.failed).toEqual([{id: MISSING_ID, error: APIErrorCodes.UNKNOWN_USER}]);
|
||||
const summary = findAuditLog(await listAuditLogs(), 'bulk_add_guild_members');
|
||||
expect(summary?.metadata.get('failed')).toBe('1');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,161 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import type {AdminAuditLog} from '@app/api/admin/IAdminRepository';
|
||||
import {createTestAccount, setUserACLs} from '@app/api/auth/tests/AuthTestUtils';
|
||||
import {BANNED_FILE_SHAS_REFRESH_CHANNEL} from '@app/api/constants/ContentModeration';
|
||||
import {getSnowflakeService, setInjectedWorkerService} from '@app/api/middleware/ServiceRegistry';
|
||||
import {getAdminRepository} from '@app/api/middleware/ServiceSingletons';
|
||||
import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHarness';
|
||||
import type {MockKVProvider} from '@app/api/test/mocks/MockKVProvider';
|
||||
import {NoopLogger} from '@app/api/test/mocks/NoopLogger';
|
||||
import {NoopWorkerService} from '@app/api/test/NoopWorkerService';
|
||||
import {SyncTaskWorkerService} from '@app/api/test/SyncTaskWorkerService';
|
||||
import {createBuilder} from '@app/api/test/TestRequestBuilder';
|
||||
import bulkBanFileShas from '@app/api/worker/tasks/admin_bulk/BulkBanFileShas';
|
||||
import {clearWorkerDependencies, setWorkerDependenciesForTest} from '@app/api/worker/WorkerContext';
|
||||
import {AdminACLs} from '@fluxer/constants/src/AdminACLs';
|
||||
import {JobCancelledError, type WorkerTaskHelpers} from '@pkgs/worker/src/contracts/WorkerTask';
|
||||
import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest';
|
||||
|
||||
interface BulkJobResult {
|
||||
successful_count: number;
|
||||
failed_count: number;
|
||||
failed: Array<{
|
||||
id: string;
|
||||
error: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
const FIRST_SHA = 'a1'.repeat(32);
|
||||
const SECOND_SHA = 'b2'.repeat(32);
|
||||
const NON_HEX_SHA = 'zz'.repeat(32);
|
||||
|
||||
function createHelpers(overrides: Partial<WorkerTaskHelpers> = {}): WorkerTaskHelpers {
|
||||
return {
|
||||
logger: new NoopLogger(),
|
||||
jobId: 4242n,
|
||||
addJob: async () => 0n,
|
||||
reportProgress: async () => {},
|
||||
shouldCancel: async () => false,
|
||||
setContextLink: async () => {},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('Bulk ban file SHAs', () => {
|
||||
let harness: ApiTestHarness;
|
||||
|
||||
beforeAll(async () => {
|
||||
harness = await createApiTestHarness();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await harness.reset();
|
||||
setWorkerDependenciesForTest({
|
||||
adminRepository: getAdminRepository(),
|
||||
snowflakeService: getSnowflakeService(),
|
||||
});
|
||||
setInjectedWorkerService(new SyncTaskWorkerService({bulkBanFileShas}));
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
clearWorkerDependencies();
|
||||
setInjectedWorkerService(new NoopWorkerService());
|
||||
await harness?.shutdown();
|
||||
});
|
||||
|
||||
function refreshPublishes(): Array<Array<string>> {
|
||||
return (harness.kvProvider as MockKVProvider).publishSpy.mock.calls.filter(
|
||||
([channel]: Array<string>) => channel === BANNED_FILE_SHAS_REFRESH_CHANNEL,
|
||||
);
|
||||
}
|
||||
|
||||
async function listAuditLogs(): Promise<Array<AdminAuditLog>> {
|
||||
return getAdminRepository().listAllAuditLogsPaginated(100);
|
||||
}
|
||||
|
||||
async function runTask(payload: Record<string, unknown>, helpers = createHelpers()): Promise<BulkJobResult> {
|
||||
return (await bulkBanFileShas(payload, helpers)) as unknown as BulkJobResult;
|
||||
}
|
||||
|
||||
it('bans every hash with its own audit row and publishes one cache refresh', async () => {
|
||||
const admin = await setUserACLs(harness, await createTestAccount(harness), [
|
||||
AdminACLs.AUTHENTICATE,
|
||||
AdminACLs.BAN_FILE_SHA_ADD,
|
||||
]);
|
||||
|
||||
await createBuilder(harness, admin.token)
|
||||
.put('/admin/blocklists/file-sha/entries')
|
||||
.header('X-Audit-Log-Reason', 'CSAM hash feed')
|
||||
.body({sha256_list: [FIRST_SHA, SECOND_SHA]})
|
||||
.execute();
|
||||
|
||||
expect(refreshPublishes()).toEqual([[BANNED_FILE_SHAS_REFRESH_CHANNEL, 'refresh']]);
|
||||
const logs = await listAuditLogs();
|
||||
expect(
|
||||
logs
|
||||
.filter((log) => log.action === 'ban_file_sha')
|
||||
.map((log) => ({
|
||||
adminUserId: log.adminUserId.toString(),
|
||||
targetType: log.targetType,
|
||||
auditLogReason: log.auditLogReason,
|
||||
sha256: log.metadata.get('sha256'),
|
||||
})),
|
||||
).toEqual([
|
||||
{adminUserId: admin.userId, targetType: 'file_sha', auditLogReason: 'CSAM hash feed', sha256: FIRST_SHA},
|
||||
{adminUserId: admin.userId, targetType: 'file_sha', auditLogReason: 'CSAM hash feed', sha256: SECOND_SHA},
|
||||
]);
|
||||
const summary = logs.find((log) => log.action === 'bulk_ban_file_shas');
|
||||
expect(summary).toBeDefined();
|
||||
expect(summary?.targetType).toBe('bulk_job');
|
||||
expect(summary?.auditLogReason).toBe('CSAM hash feed');
|
||||
expect(Object.fromEntries(summary!.metadata)).toMatchObject({
|
||||
count: '2',
|
||||
processed: '2',
|
||||
successful: '2',
|
||||
failed: '0',
|
||||
});
|
||||
});
|
||||
|
||||
it('reports a non-hexadecimal hash as a failed item and still bans the rest', async () => {
|
||||
const result = await runTask({
|
||||
sha256_list: [NON_HEX_SHA, FIRST_SHA],
|
||||
admin_user_id: '7',
|
||||
audit_log_reason: 'Feed import',
|
||||
});
|
||||
|
||||
expect(result.successful_count).toBe(1);
|
||||
expect(result.failed).toEqual([{id: NON_HEX_SHA, error: 'invalid_sha256'}]);
|
||||
const logs = await listAuditLogs();
|
||||
expect(logs.filter((log) => log.action === 'ban_file_sha').map((log) => log.metadata.get('sha256'))).toEqual([
|
||||
FIRST_SHA,
|
||||
]);
|
||||
});
|
||||
|
||||
it('publishes the cache refresh for the hashes already banned when the job is cancelled', async () => {
|
||||
let checks = 0;
|
||||
const helpers = createHelpers({
|
||||
shouldCancel: async () => {
|
||||
checks += 1;
|
||||
return checks > 1;
|
||||
},
|
||||
});
|
||||
|
||||
await expect(
|
||||
bulkBanFileShas(
|
||||
{sha256_list: [FIRST_SHA, SECOND_SHA], admin_user_id: '7', audit_log_reason: 'Feed import'},
|
||||
helpers,
|
||||
),
|
||||
).rejects.toBeInstanceOf(JobCancelledError);
|
||||
|
||||
expect(refreshPublishes()).toEqual([[BANNED_FILE_SHAS_REFRESH_CHANNEL, 'refresh']]);
|
||||
const logs = await listAuditLogs();
|
||||
expect(logs.filter((log) => log.action === 'ban_file_sha').map((log) => log.metadata.get('sha256'))).toEqual([
|
||||
FIRST_SHA,
|
||||
]);
|
||||
expect(Object.fromEntries(logs.find((log) => log.action === 'bulk_ban_file_shas')!.metadata)).toMatchObject({
|
||||
successful: '1',
|
||||
cancelled: 'true',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,134 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import type {AdminAuditLog} from '@app/api/admin/IAdminRepository';
|
||||
import {sendChannelMessage, setupTestGuildWithMembers} from '@app/api/channel/tests/ChannelTestUtils';
|
||||
import {getGatewayService, getSnowflakeService} from '@app/api/middleware/ServiceRegistry';
|
||||
import {
|
||||
getAdminRepository,
|
||||
getChannelRepository,
|
||||
getPurgeQueue,
|
||||
getStorageService,
|
||||
} from '@app/api/middleware/ServiceSingletons';
|
||||
import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHarness';
|
||||
import {NoopLogger} from '@app/api/test/mocks/NoopLogger';
|
||||
import {createBuilder} from '@app/api/test/TestRequestBuilder';
|
||||
import bulkDeleteMessagesForUsers from '@app/api/worker/tasks/admin_bulk/BulkDeleteMessagesForUsers';
|
||||
import {clearWorkerDependencies, setWorkerDependenciesForTest} from '@app/api/worker/WorkerContext';
|
||||
import type {MessageResponse} from '@fluxer/schema/src/domains/message/MessageResponseSchemas';
|
||||
import type {WorkerTaskHelpers} from '@pkgs/worker/src/contracts/WorkerTask';
|
||||
import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest';
|
||||
|
||||
interface BulkJobResult {
|
||||
successful_count: number;
|
||||
failed_count: number;
|
||||
failed: Array<{
|
||||
id: string;
|
||||
error: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
const ADMIN_USER_ID = '9';
|
||||
|
||||
function createHelpers(): WorkerTaskHelpers {
|
||||
return {
|
||||
logger: new NoopLogger(),
|
||||
jobId: 909n,
|
||||
addJob: async () => 0n,
|
||||
reportProgress: async () => {},
|
||||
shouldCancel: async () => false,
|
||||
setContextLink: async () => {},
|
||||
};
|
||||
}
|
||||
|
||||
describe('Bulk delete messages for users', () => {
|
||||
let harness: ApiTestHarness;
|
||||
|
||||
beforeAll(async () => {
|
||||
harness = await createApiTestHarness();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await harness.reset();
|
||||
setWorkerDependenciesForTest({
|
||||
adminRepository: getAdminRepository(),
|
||||
snowflakeService: getSnowflakeService(),
|
||||
channelRepository: getChannelRepository(),
|
||||
gatewayService: getGatewayService(),
|
||||
storageService: getStorageService(),
|
||||
purgeQueue: getPurgeQueue(),
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
clearWorkerDependencies();
|
||||
await harness?.shutdown();
|
||||
});
|
||||
|
||||
async function listAuditLogs(): Promise<Array<AdminAuditLog>> {
|
||||
return getAdminRepository().listAllAuditLogsPaginated(100);
|
||||
}
|
||||
|
||||
async function runTask(userIds: Array<string>, auditLogReason: string | null): Promise<BulkJobResult> {
|
||||
return (await bulkDeleteMessagesForUsers(
|
||||
{user_ids: userIds, admin_user_id: ADMIN_USER_ID, audit_log_reason: auditLogReason},
|
||||
createHelpers(),
|
||||
)) as unknown as BulkJobResult;
|
||||
}
|
||||
|
||||
async function listMessages(token: string, channelId: string): Promise<Array<MessageResponse>> {
|
||||
return createBuilder<Array<MessageResponse>>(harness, token).get(`/channels/${channelId}/messages`).execute();
|
||||
}
|
||||
|
||||
async function countMessagesBy(token: string, channelId: string, userId: string): Promise<number> {
|
||||
const messages = await listMessages(token, channelId);
|
||||
return messages.filter((message) => message.author.id === userId).length;
|
||||
}
|
||||
|
||||
it('carries the admin reason and the message count on the per-user audit row', async () => {
|
||||
const {owner, members, systemChannel} = await setupTestGuildWithMembers(harness, 1);
|
||||
const member = members[0]!;
|
||||
await sendChannelMessage(harness, member.token, systemChannel.id, 'first spam');
|
||||
await sendChannelMessage(harness, member.token, systemChannel.id, 'second spam');
|
||||
const authored = await countMessagesBy(owner.token, systemChannel.id, member.userId);
|
||||
|
||||
const result = await runTask([member.userId], 'Spam cleanup');
|
||||
|
||||
expect(result.successful_count).toBe(1);
|
||||
const logs = await listAuditLogs();
|
||||
const perUser = logs.filter((log) => log.action === 'delete_all_user_messages');
|
||||
expect(perUser).toHaveLength(1);
|
||||
expect(perUser[0]!.adminUserId.toString()).toBe(ADMIN_USER_ID);
|
||||
expect(perUser[0]!.targetType).toBe('message_deletion');
|
||||
expect(perUser[0]!.targetId.toString()).toBe(member.userId);
|
||||
expect(perUser[0]!.auditLogReason).toBe('Spam cleanup');
|
||||
expect(Object.fromEntries(perUser[0]!.metadata)).toEqual({
|
||||
user_id: member.userId,
|
||||
message_count: authored.toString(),
|
||||
});
|
||||
expect(await countMessagesBy(owner.token, systemChannel.id, member.userId)).toBe(0);
|
||||
});
|
||||
|
||||
it('summarises the job and reports a user that could not be processed', async () => {
|
||||
const {owner, members, systemChannel} = await setupTestGuildWithMembers(harness, 1);
|
||||
const member = members[0]!;
|
||||
await sendChannelMessage(harness, member.token, systemChannel.id, 'only spam');
|
||||
const authored = await countMessagesBy(owner.token, systemChannel.id, member.userId);
|
||||
|
||||
const result = await runTask(['0', member.userId], 'Spam cleanup');
|
||||
|
||||
expect(result.successful_count).toBe(1);
|
||||
expect(result.failed_count).toBe(1);
|
||||
expect(result.failed[0]!.id).toBe('0');
|
||||
const summary = (await listAuditLogs()).find((log) => log.action === 'bulk_delete_user_messages');
|
||||
expect(summary).toBeDefined();
|
||||
expect(summary?.targetType).toBe('bulk_job');
|
||||
expect(summary?.auditLogReason).toBe('Spam cleanup');
|
||||
expect(Object.fromEntries(summary!.metadata)).toMatchObject({
|
||||
user_count: '2',
|
||||
message_count: authored.toString(),
|
||||
processed: '2',
|
||||
successful: '1',
|
||||
failed: '1',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,215 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {AdminRepository} from '@app/api/admin/AdminRepository';
|
||||
import type {AdminAuditLog} from '@app/api/admin/IAdminRepository';
|
||||
import {createTestAccount, setUserACLs, type TestAccount} from '@app/api/auth/tests/AuthTestUtils';
|
||||
import {createReportID, createUserID} from '@app/api/BrandedTypes';
|
||||
import {getGatewayService, getSnowflakeService} from '@app/api/middleware/ServiceRegistry';
|
||||
import {
|
||||
createUserCacheService,
|
||||
getAdminRepository,
|
||||
getChannelRepository,
|
||||
getGuildRepository,
|
||||
getKVAccountDeletionQueue,
|
||||
getKVBulkMessageDeletionQueue,
|
||||
getUserRepository,
|
||||
} from '@app/api/middleware/ServiceSingletons';
|
||||
import {ReportStatus} from '@app/api/report/IReportRepository';
|
||||
import {ReportRepository} from '@app/api/report/ReportRepository';
|
||||
import {drainSearchTasks} from '@app/api/search/SearchTaskTracker';
|
||||
import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHarness';
|
||||
import {NoopLogger} from '@app/api/test/mocks/NoopLogger';
|
||||
import {HTTP_STATUS} from '@app/api/test/TestConstants';
|
||||
import {createBuilder} from '@app/api/test/TestRequestBuilder';
|
||||
import bulkScheduleUserDeletion from '@app/api/worker/tasks/admin_bulk/BulkScheduleUserDeletion';
|
||||
import {clearWorkerDependencies, setWorkerDependenciesForTest} from '@app/api/worker/WorkerContext';
|
||||
import {DeletionReasons} from '@fluxer/constants/src/Core';
|
||||
import {UserFlags} from '@fluxer/constants/src/UserConstants';
|
||||
import type {WorkerTaskHelpers, WorkerTaskResult} from '@pkgs/worker/src/contracts/WorkerTask';
|
||||
import {afterEach, beforeEach, describe, expect, test} from 'vitest';
|
||||
|
||||
interface ReportResponse {
|
||||
report_id: string;
|
||||
}
|
||||
|
||||
interface BulkJobResult {
|
||||
successful_count: number;
|
||||
failed_count: number;
|
||||
failed: Array<{id: string; error: string}>;
|
||||
}
|
||||
|
||||
const AUDIT_LOG_REASON = 'Lilith spam sweep 2026-09-14';
|
||||
|
||||
function createHelpers(): WorkerTaskHelpers {
|
||||
return {
|
||||
logger: new NoopLogger(),
|
||||
jobId: 4242n,
|
||||
addJob: async () => 0n,
|
||||
reportProgress: async () => {},
|
||||
shouldCancel: async () => false,
|
||||
setContextLink: async () => {},
|
||||
};
|
||||
}
|
||||
|
||||
function installWorkerDependencies(): void {
|
||||
setWorkerDependenciesForTest({
|
||||
adminRepository: getAdminRepository(),
|
||||
snowflakeService: getSnowflakeService(),
|
||||
userRepository: getUserRepository(),
|
||||
userCacheService: createUserCacheService(),
|
||||
guildRepository: getGuildRepository(),
|
||||
channelRepository: getChannelRepository(),
|
||||
gatewayService: getGatewayService(),
|
||||
deletionQueueService: getKVAccountDeletionQueue(),
|
||||
bulkMessageDeletionQueueService: getKVBulkMessageDeletionQueue(),
|
||||
stripe: null,
|
||||
});
|
||||
}
|
||||
|
||||
async function runBulkJob(
|
||||
userIds: Array<string>,
|
||||
adminUserId: string,
|
||||
reasonCode: number = DeletionReasons.SPAM,
|
||||
): Promise<BulkJobResult> {
|
||||
installWorkerDependencies();
|
||||
const result = (await bulkScheduleUserDeletion(
|
||||
{
|
||||
user_ids: userIds,
|
||||
reason_code: reasonCode,
|
||||
days_until_deletion: 60,
|
||||
public_reason: null,
|
||||
admin_user_id: adminUserId,
|
||||
audit_log_reason: AUDIT_LOG_REASON,
|
||||
},
|
||||
createHelpers(),
|
||||
)) as WorkerTaskResult;
|
||||
return result as unknown as BulkJobResult;
|
||||
}
|
||||
|
||||
async function reportUser(harness: ApiTestHarness, reporter: TestAccount, targetUserId: string): Promise<string> {
|
||||
const report = await createBuilder<ReportResponse>(harness, reporter.token)
|
||||
.post('/reports/user')
|
||||
.body({user_id: targetUserId, category: 'spam_account'})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
await drainSearchTasks();
|
||||
return report.report_id;
|
||||
}
|
||||
|
||||
async function getReportStatus(reportId: string): Promise<number | null> {
|
||||
const report = await new ReportRepository().getReport(createReportID(BigInt(reportId)));
|
||||
return report?.status ?? null;
|
||||
}
|
||||
|
||||
async function listAuditLogs(action: string): Promise<Array<AdminAuditLog>> {
|
||||
const logs = await new AdminRepository().listAllAuditLogsPaginated(500);
|
||||
return logs.filter((log) => log.action === action);
|
||||
}
|
||||
|
||||
async function isSessionAlive(harness: ApiTestHarness, token: string): Promise<boolean> {
|
||||
const response = await harness.requestJson({path: '/users/@me', headers: {Authorization: token}});
|
||||
return response.status === HTTP_STATUS.OK;
|
||||
}
|
||||
|
||||
describe('bulkScheduleUserDeletion', () => {
|
||||
let harness: ApiTestHarness;
|
||||
beforeEach(async () => {
|
||||
harness = await createApiTestHarness({search: 'enabled'});
|
||||
});
|
||||
afterEach(async () => {
|
||||
clearWorkerDependencies();
|
||||
await harness.shutdown();
|
||||
});
|
||||
test('runs the same side effects as the single-user endpoint for every user in the job', async () => {
|
||||
const admin = await createTestAccount(harness);
|
||||
await setUserACLs(harness, admin, ['admin:authenticate', 'bulk:delete:users']);
|
||||
const reporter = await createTestAccount(harness);
|
||||
const target = await createTestAccount(harness);
|
||||
const reportId = await reportUser(harness, reporter, target.userId);
|
||||
expect(await getReportStatus(reportId)).toBe(ReportStatus.PENDING);
|
||||
const result = await runBulkJob([target.userId], admin.userId);
|
||||
expect(result.successful_count).toBe(1);
|
||||
expect(result.failed_count).toBe(0);
|
||||
expect(await getReportStatus(reportId)).toBe(ReportStatus.RESOLVED);
|
||||
const perUserLogs = await listAuditLogs('schedule_deletion');
|
||||
const targetLog = perUserLogs.find((log) => log.targetId === BigInt(target.userId));
|
||||
expect(targetLog).toBeDefined();
|
||||
expect(targetLog!.auditLogReason).toBe(AUDIT_LOG_REASON);
|
||||
expect(targetLog!.adminUserId.toString()).toBe(admin.userId);
|
||||
expect(targetLog!.metadata.get('reason_code')).toBe(DeletionReasons.SPAM.toString());
|
||||
expect(targetLog!.metadata.get('days')).toBe('60');
|
||||
expect(await isSessionAlive(harness, target.token)).toBe(false);
|
||||
expect(await new AdminRepository().isEmailBanned(target.email)).toBe(true);
|
||||
const resolutionLogs = await listAuditLogs('auto_resolve_reports_on_deletion');
|
||||
expect(resolutionLogs.some((log) => log.targetId === BigInt(target.userId))).toBe(true);
|
||||
const summaryLogs = await listAuditLogs('bulk_schedule_deletion');
|
||||
expect(summaryLogs).toHaveLength(1);
|
||||
expect(summaryLogs[0]!.targetType).toBe('bulk_job');
|
||||
expect(summaryLogs[0]!.targetId).toBe(4242n);
|
||||
expect(summaryLogs[0]!.auditLogReason).toBe(AUDIT_LOG_REASON);
|
||||
expect(summaryLogs[0]!.metadata.get('user_count')).toBe('1');
|
||||
expect(summaryLogs[0]!.metadata.get('reason_code')).toBe(DeletionReasons.SPAM.toString());
|
||||
expect(summaryLogs[0]!.metadata.get('days')).toBe('60');
|
||||
expect(summaryLogs[0]!.metadata.get('successful')).toBe('1');
|
||||
expect(summaryLogs[0]!.metadata.get('failed')).toBe('0');
|
||||
});
|
||||
test('the single-user endpoint produces the same report, audit, session and ban outcome', async () => {
|
||||
const admin = await createTestAccount(harness);
|
||||
await setUserACLs(harness, admin, ['admin:authenticate', 'user:delete']);
|
||||
const reporter = await createTestAccount(harness);
|
||||
const target = await createTestAccount(harness);
|
||||
const reportId = await reportUser(harness, reporter, target.userId);
|
||||
await createBuilder(harness, admin.token)
|
||||
.put(`/admin/users/${target.userId}/deletion`)
|
||||
.header('X-Audit-Log-Reason', AUDIT_LOG_REASON)
|
||||
.body({reason_code: DeletionReasons.SPAM, days_until_deletion: 60})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
expect(await getReportStatus(reportId)).toBe(ReportStatus.RESOLVED);
|
||||
const perUserLogs = await listAuditLogs('schedule_deletion');
|
||||
const targetLog = perUserLogs.find((log) => log.targetId === BigInt(target.userId));
|
||||
expect(targetLog).toBeDefined();
|
||||
expect(targetLog!.auditLogReason).toBe(AUDIT_LOG_REASON);
|
||||
expect(targetLog!.metadata.get('reason_code')).toBe(DeletionReasons.SPAM.toString());
|
||||
expect(await isSessionAlive(harness, target.token)).toBe(false);
|
||||
expect(await new AdminRepository().isEmailBanned(target.email)).toBe(true);
|
||||
});
|
||||
test('keeps deleting the remaining users after one of them fails and reports the failure', async () => {
|
||||
const admin = await createTestAccount(harness);
|
||||
await setUserACLs(harness, admin, ['admin:authenticate', 'bulk:delete:users']);
|
||||
const reporter = await createTestAccount(harness);
|
||||
const missingUserId = '999999999999999999';
|
||||
const target = await createTestAccount(harness);
|
||||
const reportId = await reportUser(harness, reporter, target.userId);
|
||||
const result = await runBulkJob([missingUserId, target.userId], admin.userId);
|
||||
expect(result.successful_count).toBe(1);
|
||||
expect(result.failed_count).toBe(1);
|
||||
expect(result.failed.map((failure) => failure.id)).toEqual([missingUserId]);
|
||||
expect(result.failed[0]!.error).toBeTruthy();
|
||||
const updatedTarget = await getUserRepository().findUnique(createUserID(BigInt(target.userId)));
|
||||
expect(updatedTarget).not.toBeNull();
|
||||
expect(updatedTarget!.flags & UserFlags.DELETED).toBe(UserFlags.DELETED);
|
||||
expect(updatedTarget!.pendingDeletionAt).not.toBeNull();
|
||||
expect(await getReportStatus(reportId)).toBe(ReportStatus.RESOLVED);
|
||||
const perUserLogs = await listAuditLogs('schedule_deletion');
|
||||
expect(perUserLogs.filter((log) => log.targetId === BigInt(target.userId))).toHaveLength(1);
|
||||
expect(perUserLogs.some((log) => log.targetId === BigInt(missingUserId))).toBe(false);
|
||||
const summaryLogs = await listAuditLogs('bulk_schedule_deletion');
|
||||
expect(summaryLogs).toHaveLength(1);
|
||||
expect(summaryLogs[0]!.metadata.get('successful')).toBe('1');
|
||||
expect(summaryLogs[0]!.metadata.get('failed')).toBe('1');
|
||||
});
|
||||
test('a user-requested bulk deletion neither bans identifiers nor resolves reports', async () => {
|
||||
const admin = await createTestAccount(harness);
|
||||
await setUserACLs(harness, admin, ['admin:authenticate', 'bulk:delete:users']);
|
||||
const reporter = await createTestAccount(harness);
|
||||
const target = await createTestAccount(harness);
|
||||
const reportId = await reportUser(harness, reporter, target.userId);
|
||||
const result = await runBulkJob([target.userId], admin.userId, DeletionReasons.USER_REQUESTED);
|
||||
expect(result.successful_count).toBe(1);
|
||||
expect(await getReportStatus(reportId)).toBe(ReportStatus.PENDING);
|
||||
expect(await new AdminRepository().isEmailBanned(target.email)).toBe(false);
|
||||
const perUserLogs = await listAuditLogs('schedule_deletion');
|
||||
expect(perUserLogs.filter((log) => log.targetId === BigInt(target.userId))).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,229 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {AdminRepository} from '@app/api/admin/AdminRepository';
|
||||
import {createTestAccount, type TestAccount} from '@app/api/auth/tests/AuthTestUtils';
|
||||
import {createUserID} from '@app/api/BrandedTypes';
|
||||
import {getHistoricalOutcomeRepository} from '@app/api/middleware/ServiceMiddleware';
|
||||
import {getGatewayService, getSnowflakeService} from '@app/api/middleware/ServiceRegistry';
|
||||
import {
|
||||
createUserCacheService,
|
||||
getAdminRepository,
|
||||
getChannelRepository,
|
||||
getGuildRepository,
|
||||
getKVAccountDeletionQueue,
|
||||
getKVBulkMessageDeletionQueue,
|
||||
getUserRepository,
|
||||
} from '@app/api/middleware/ServiceSingletons';
|
||||
import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHarness';
|
||||
import {NoopLogger} from '@app/api/test/mocks/NoopLogger';
|
||||
import {createBuilder} from '@app/api/test/TestRequestBuilder';
|
||||
import bulkUpdateSuspiciousActivityFlags from '@app/api/worker/tasks/admin_bulk/BulkUpdateSuspiciousActivityFlags';
|
||||
import {clearWorkerDependencies, setWorkerDependenciesForTest} from '@app/api/worker/WorkerContext';
|
||||
import {
|
||||
DEFERRED_PHONE_ON_COMMUNITY_JOIN,
|
||||
PHONE_GATE_PROMOTED_FROM_DEFERRAL,
|
||||
SuspiciousActivityFlags,
|
||||
} from '@fluxer/constants/src/UserConstants';
|
||||
import type {WorkerTaskHelpers} from '@pkgs/worker/src/contracts/WorkerTask';
|
||||
import {afterAll, afterEach, beforeAll, beforeEach, describe, expect, test} from 'vitest';
|
||||
|
||||
interface BulkJobResult {
|
||||
successful_count: number;
|
||||
failed_count: number;
|
||||
failed: Array<{
|
||||
id: string;
|
||||
error: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
const ADMIN_USER_ID = 4000000000000000000n;
|
||||
const JOB_ID = 7200000000000000000n;
|
||||
const MISSING_USER_ID = 4200000000000000002n;
|
||||
const RISK_CONTEXT_IP = '203.0.113.77';
|
||||
|
||||
function createHelpers(): WorkerTaskHelpers {
|
||||
return {
|
||||
logger: new NoopLogger(),
|
||||
jobId: JOB_ID,
|
||||
addJob: async () => 0n,
|
||||
reportProgress: async () => {},
|
||||
shouldCancel: async () => false,
|
||||
setContextLink: async () => {},
|
||||
};
|
||||
}
|
||||
|
||||
function installWorkerDependencies(): void {
|
||||
setWorkerDependenciesForTest({
|
||||
adminRepository: getAdminRepository(),
|
||||
snowflakeService: getSnowflakeService(),
|
||||
userRepository: getUserRepository(),
|
||||
guildRepository: getGuildRepository(),
|
||||
channelRepository: getChannelRepository(),
|
||||
userCacheService: createUserCacheService(),
|
||||
deletionQueueService: getKVAccountDeletionQueue(),
|
||||
bulkMessageDeletionQueueService: getKVBulkMessageDeletionQueue(),
|
||||
gatewayService: getGatewayService(),
|
||||
stripe: null,
|
||||
});
|
||||
}
|
||||
|
||||
describe('bulkUpdateSuspiciousActivityFlags task', () => {
|
||||
let harness: ApiTestHarness;
|
||||
beforeAll(async () => {
|
||||
harness = await createApiTestHarness({search: 'enabled'});
|
||||
});
|
||||
afterAll(async () => {
|
||||
await harness.shutdown();
|
||||
});
|
||||
beforeEach(async () => {
|
||||
await harness.resetData();
|
||||
installWorkerDependencies();
|
||||
});
|
||||
afterEach(() => {
|
||||
clearWorkerDependencies();
|
||||
});
|
||||
|
||||
async function setSuspiciousFlags(account: TestAccount, flags: number): Promise<void> {
|
||||
await createBuilder(harness, '')
|
||||
.post(`/test/users/${account.userId}/security-flags`)
|
||||
.body({
|
||||
suspicious_activity_flags: flags,
|
||||
})
|
||||
.execute();
|
||||
}
|
||||
|
||||
async function readSuspiciousFlags(account: TestAccount): Promise<number> {
|
||||
const user = await getUserRepository().findUnique(createUserID(BigInt(account.userId)));
|
||||
return user!.suspiciousActivityFlags ?? 0;
|
||||
}
|
||||
|
||||
test('writes a per-user audit row with the admin reason, records the risk outcome, and reports failures', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
await setSuspiciousFlags(account, 0);
|
||||
await getHistoricalOutcomeRepository().upsertLatestContext({
|
||||
userId: account.userId,
|
||||
ip: RISK_CONTEXT_IP,
|
||||
subnet: null,
|
||||
emailDomain: null,
|
||||
asn: null,
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
|
||||
const result = (await bulkUpdateSuspiciousActivityFlags(
|
||||
{
|
||||
user_ids: [account.userId, MISSING_USER_ID.toString()],
|
||||
add_flags: ['REQUIRE_VERIFIED_EMAIL'],
|
||||
remove_flags: [],
|
||||
admin_user_id: ADMIN_USER_ID.toString(),
|
||||
audit_log_reason: 'Lilith verification sweep',
|
||||
},
|
||||
createHelpers(),
|
||||
)) as unknown as BulkJobResult;
|
||||
|
||||
expect(result.successful_count).toBe(1);
|
||||
expect(result.failed).toEqual([{id: MISSING_USER_ID.toString(), error: 'UNKNOWN_USER'}]);
|
||||
expect(await readSuspiciousFlags(account)).toBe(SuspiciousActivityFlags.REQUIRE_VERIFIED_EMAIL);
|
||||
|
||||
const auditLogs = await new AdminRepository().listAllAuditLogsPaginated(50);
|
||||
const perUserLogs = auditLogs.filter((log) => log.action === 'update_suspicious_activity_flags');
|
||||
expect(perUserLogs).toHaveLength(1);
|
||||
expect(perUserLogs[0]!.targetType).toBe('user');
|
||||
expect(perUserLogs[0]!.targetId).toBe(BigInt(account.userId));
|
||||
expect(perUserLogs[0]!.adminUserId.toString()).toBe(ADMIN_USER_ID.toString());
|
||||
expect(perUserLogs[0]!.auditLogReason).toBe('Lilith verification sweep');
|
||||
expect(perUserLogs[0]!.metadata.get('flags')).toBe(SuspiciousActivityFlags.REQUIRE_VERIFIED_EMAIL.toString());
|
||||
|
||||
const summaryLogs = auditLogs.filter((log) => log.action === 'bulk_update_suspicious_activity_flags');
|
||||
expect(summaryLogs).toHaveLength(1);
|
||||
expect(summaryLogs[0]!.targetType).toBe('bulk_job');
|
||||
expect(summaryLogs[0]!.targetId).toBe(JOB_ID);
|
||||
expect(summaryLogs[0]!.auditLogReason).toBe('Lilith verification sweep');
|
||||
expect(summaryLogs[0]!.metadata.get('user_count')).toBe('2');
|
||||
expect(summaryLogs[0]!.metadata.get('add_flags')).toBe('REQUIRE_VERIFIED_EMAIL');
|
||||
expect(summaryLogs[0]!.metadata.has('remove_flags')).toBe(false);
|
||||
expect(summaryLogs[0]!.metadata.get('successful')).toBe('1');
|
||||
expect(summaryLogs[0]!.metadata.get('failed')).toBe('1');
|
||||
|
||||
const outcomes = await getHistoricalOutcomeRepository().listByIp(RISK_CONTEXT_IP, new Date(0), 50);
|
||||
expect(outcomes.map((outcome) => [outcome.outcomeCode, outcome.source])).toEqual([
|
||||
['challenged', 'admin_update_suspicious_activity_flags'],
|
||||
]);
|
||||
});
|
||||
|
||||
test('removing a phone requirement clears the deferral bookkeeping bits', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
await setSuspiciousFlags(
|
||||
account,
|
||||
SuspiciousActivityFlags.REQUIRE_VERIFIED_PHONE |
|
||||
DEFERRED_PHONE_ON_COMMUNITY_JOIN |
|
||||
PHONE_GATE_PROMOTED_FROM_DEFERRAL,
|
||||
);
|
||||
|
||||
const result = (await bulkUpdateSuspiciousActivityFlags(
|
||||
{
|
||||
user_ids: [account.userId],
|
||||
add_flags: [],
|
||||
remove_flags: ['REQUIRE_VERIFIED_PHONE'],
|
||||
admin_user_id: ADMIN_USER_ID.toString(),
|
||||
audit_log_reason: 'Phone gate lifted',
|
||||
},
|
||||
createHelpers(),
|
||||
)) as unknown as BulkJobResult;
|
||||
|
||||
expect(result.failed).toEqual([]);
|
||||
expect(await readSuspiciousFlags(account)).toBe(0);
|
||||
|
||||
const auditLogs = await new AdminRepository().listAllAuditLogsPaginated(50);
|
||||
const perUserLogs = auditLogs.filter((log) => log.action === 'update_suspicious_activity_flags');
|
||||
expect(perUserLogs).toHaveLength(1);
|
||||
expect(perUserLogs[0]!.auditLogReason).toBe('Phone gate lifted');
|
||||
expect(perUserLogs[0]!.metadata.get('flags')).toBe('0');
|
||||
});
|
||||
|
||||
test('keeps the deferral bits when the deferrable phone flags are unchanged', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
await setSuspiciousFlags(
|
||||
account,
|
||||
SuspiciousActivityFlags.REQUIRE_VERIFIED_PHONE | DEFERRED_PHONE_ON_COMMUNITY_JOIN,
|
||||
);
|
||||
|
||||
await bulkUpdateSuspiciousActivityFlags(
|
||||
{
|
||||
user_ids: [account.userId],
|
||||
add_flags: ['REQUIRE_VERIFIED_EMAIL'],
|
||||
remove_flags: [],
|
||||
admin_user_id: ADMIN_USER_ID.toString(),
|
||||
audit_log_reason: 'Add email requirement',
|
||||
},
|
||||
createHelpers(),
|
||||
);
|
||||
|
||||
expect(await readSuspiciousFlags(account)).toBe(
|
||||
SuspiciousActivityFlags.REQUIRE_VERIFIED_PHONE |
|
||||
SuspiciousActivityFlags.REQUIRE_VERIFIED_EMAIL |
|
||||
DEFERRED_PHONE_ON_COMMUNITY_JOIN,
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects an unknown suspicious flag name instead of silently ignoring it', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
await setSuspiciousFlags(account, 0);
|
||||
|
||||
await expect(
|
||||
bulkUpdateSuspiciousActivityFlags(
|
||||
{
|
||||
user_ids: [account.userId],
|
||||
add_flags: ['REQUIRE_VERIFIED_EMAIL', 'REQUIRE_VERIFIED_EMAILL'],
|
||||
remove_flags: [],
|
||||
admin_user_id: ADMIN_USER_ID.toString(),
|
||||
audit_log_reason: 'Typo sweep',
|
||||
},
|
||||
createHelpers(),
|
||||
),
|
||||
).rejects.toThrow('Unknown suspicious activity flag names: REQUIRE_VERIFIED_EMAILL');
|
||||
|
||||
expect(await readSuspiciousFlags(account)).toBe(0);
|
||||
const auditLogs = await new AdminRepository().listAllAuditLogsPaginated(50);
|
||||
expect(auditLogs).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,143 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {AdminRepository} from '@app/api/admin/AdminRepository';
|
||||
import {createTestAccount} from '@app/api/auth/tests/AuthTestUtils';
|
||||
import {createUserID} from '@app/api/BrandedTypes';
|
||||
import {getGatewayService, getSnowflakeService} from '@app/api/middleware/ServiceRegistry';
|
||||
import {
|
||||
createUserCacheService,
|
||||
getAdminRepository,
|
||||
getChannelRepository,
|
||||
getGuildRepository,
|
||||
getKVAccountDeletionQueue,
|
||||
getKVBulkMessageDeletionQueue,
|
||||
getUserRepository,
|
||||
} from '@app/api/middleware/ServiceSingletons';
|
||||
import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHarness';
|
||||
import {NoopLogger} from '@app/api/test/mocks/NoopLogger';
|
||||
import bulkUpdateUserFlags from '@app/api/worker/tasks/admin_bulk/BulkUpdateUserFlags';
|
||||
import {clearWorkerDependencies, setWorkerDependenciesForTest} from '@app/api/worker/WorkerContext';
|
||||
import {UserFlags} from '@fluxer/constants/src/UserConstants';
|
||||
import type {WorkerTaskHelpers} from '@pkgs/worker/src/contracts/WorkerTask';
|
||||
import {afterAll, afterEach, beforeAll, beforeEach, describe, expect, test} from 'vitest';
|
||||
|
||||
interface BulkJobResult {
|
||||
successful_count: number;
|
||||
failed_count: number;
|
||||
failed: Array<{
|
||||
id: string;
|
||||
error: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
const ADMIN_USER_ID = 4000000000000000000n;
|
||||
const JOB_ID = 7100000000000000000n;
|
||||
const MISSING_USER_ID = 4200000000000000001n;
|
||||
|
||||
function createHelpers(): WorkerTaskHelpers {
|
||||
return {
|
||||
logger: new NoopLogger(),
|
||||
jobId: JOB_ID,
|
||||
addJob: async () => 0n,
|
||||
reportProgress: async () => {},
|
||||
shouldCancel: async () => false,
|
||||
setContextLink: async () => {},
|
||||
};
|
||||
}
|
||||
|
||||
function installWorkerDependencies(): void {
|
||||
setWorkerDependenciesForTest({
|
||||
adminRepository: getAdminRepository(),
|
||||
snowflakeService: getSnowflakeService(),
|
||||
userRepository: getUserRepository(),
|
||||
guildRepository: getGuildRepository(),
|
||||
channelRepository: getChannelRepository(),
|
||||
userCacheService: createUserCacheService(),
|
||||
deletionQueueService: getKVAccountDeletionQueue(),
|
||||
bulkMessageDeletionQueueService: getKVBulkMessageDeletionQueue(),
|
||||
gatewayService: getGatewayService(),
|
||||
stripe: null,
|
||||
});
|
||||
}
|
||||
|
||||
describe('bulkUpdateUserFlags task', () => {
|
||||
let harness: ApiTestHarness;
|
||||
beforeAll(async () => {
|
||||
harness = await createApiTestHarness({search: 'enabled'});
|
||||
});
|
||||
afterAll(async () => {
|
||||
await harness.shutdown();
|
||||
});
|
||||
beforeEach(async () => {
|
||||
await harness.resetData();
|
||||
installWorkerDependencies();
|
||||
});
|
||||
afterEach(() => {
|
||||
clearWorkerDependencies();
|
||||
});
|
||||
|
||||
test('writes a per-user audit row carrying the admin reason and records failed items', async () => {
|
||||
const first = await createTestAccount(harness);
|
||||
const second = await createTestAccount(harness);
|
||||
const result = (await bulkUpdateUserFlags(
|
||||
{
|
||||
user_ids: [first.userId, MISSING_USER_ID.toString(), second.userId],
|
||||
add_flags: [UserFlags.SPAMMER.toString()],
|
||||
remove_flags: [UserFlags.HAS_SESSION_STARTED.toString()],
|
||||
admin_user_id: ADMIN_USER_ID.toString(),
|
||||
audit_log_reason: 'Lilith spam sweep',
|
||||
},
|
||||
createHelpers(),
|
||||
)) as unknown as BulkJobResult;
|
||||
|
||||
expect(result.successful_count).toBe(2);
|
||||
expect(result.failed_count).toBe(1);
|
||||
expect(result.failed).toEqual([{id: MISSING_USER_ID.toString(), error: 'UNKNOWN_USER'}]);
|
||||
|
||||
const userRepository = getUserRepository();
|
||||
const updatedFirst = await userRepository.findUnique(createUserID(BigInt(first.userId)));
|
||||
expect(updatedFirst!.flags & UserFlags.SPAMMER).toBe(UserFlags.SPAMMER);
|
||||
expect(updatedFirst!.flags & UserFlags.HAS_SESSION_STARTED).toBe(0n);
|
||||
|
||||
const auditLogs = await new AdminRepository().listAllAuditLogsPaginated(50);
|
||||
const perUserLogs = auditLogs.filter((log) => log.action === 'update_flags');
|
||||
expect(perUserLogs.map((log) => log.targetId).sort()).toEqual([BigInt(first.userId), BigInt(second.userId)].sort());
|
||||
for (const log of perUserLogs) {
|
||||
expect(log.targetType).toBe('user');
|
||||
expect(log.adminUserId.toString()).toBe(ADMIN_USER_ID.toString());
|
||||
expect(log.auditLogReason).toBe('Lilith spam sweep');
|
||||
expect(log.metadata.get('add_flags')).toBe(UserFlags.SPAMMER.toString());
|
||||
expect(log.metadata.get('remove_flags')).toBe(UserFlags.HAS_SESSION_STARTED.toString());
|
||||
expect(log.metadata.get('new_flags')).toBe(UserFlags.SPAMMER.toString());
|
||||
}
|
||||
expect(auditLogs.some((log) => log.action === 'update_flags' && log.targetId === MISSING_USER_ID)).toBe(false);
|
||||
});
|
||||
|
||||
test('writes one summary row against the job id', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
await bulkUpdateUserFlags(
|
||||
{
|
||||
user_ids: [account.userId, MISSING_USER_ID.toString()],
|
||||
add_flags: [UserFlags.STAFF.toString()],
|
||||
remove_flags: [],
|
||||
admin_user_id: ADMIN_USER_ID.toString(),
|
||||
audit_log_reason: 'Staff grant',
|
||||
},
|
||||
createHelpers(),
|
||||
);
|
||||
|
||||
const auditLogs = await new AdminRepository().listAllAuditLogsPaginated(50);
|
||||
const summaryLogs = auditLogs.filter((log) => log.action === 'bulk_update_user_flags');
|
||||
expect(summaryLogs).toHaveLength(1);
|
||||
const summary = summaryLogs[0]!;
|
||||
expect(summary.targetType).toBe('bulk_job');
|
||||
expect(summary.targetId).toBe(JOB_ID);
|
||||
expect(summary.auditLogReason).toBe('Staff grant');
|
||||
expect(summary.metadata.get('user_count')).toBe('2');
|
||||
expect(summary.metadata.get('add_flags')).toBe(UserFlags.STAFF.toString());
|
||||
expect(summary.metadata.has('remove_flags')).toBe(false);
|
||||
expect(summary.metadata.get('processed')).toBe('2');
|
||||
expect(summary.metadata.get('successful')).toBe('1');
|
||||
expect(summary.metadata.get('failed')).toBe('1');
|
||||
});
|
||||
});
|
||||
@@ -420,9 +420,9 @@ Every hash is written with the category `manual`, the severity `2`, and a null c
|
||||
|
||||
### Side effects
|
||||
|
||||
Each hash is lowercased and replaces any existing entry. Changes become visible across the instance when the job completes. If it is cancelled, changes already made can remain unapplied on other nodes until a later blocklist update or the twelve-hour feed sync. No Gateway Dispatch is emitted.
|
||||
Each hash is lowercased and replaces any existing entry. Changes become visible across the instance when the job stops, whether it ran to the end or was cancelled, so a cancelled job still enforces the hashes it already wrote. No Gateway Dispatch is emitted.
|
||||
|
||||
The job records one aggregate [Admin audit entry](/admin-api/#admin-audit-entry-object) under the action `bulk_ban_file_shas`, with the submitted, successful, and failed counts. A cancelled job records none.
|
||||
The job records one [Admin audit entry](/admin-api/#admin-audit-entry-object) per written hash under the action `ban_file_sha`, with that hash in its metadata, exactly as [Add blocklist entry](#add-blocklist-entry) does. It then records one aggregate entry under the action `bulk_ban_file_shas`, with the submitted, processed, successful, and failed counts. A cancelled job records the entries for the hashes it wrote and an aggregate entry marked `cancelled`.
|
||||
|
||||
### Rate limit
|
||||
|
||||
|
||||
@@ -150,21 +150,19 @@ Entities are processed in the submitted order. [Cancel job](/admin-api/jobs/#can
|
||||
|
||||
Progress updates arrive before work starts, after every 25 entities, and at completion. `schedule_user_deletion` updates after every 10 accounts instead. The final message includes successful and failed counts.
|
||||
|
||||
Every task writes one summary Admin audit entry when it finishes, with the action `bulk_update_user_flags`, `bulk_update_suspicious_activity_flags`, `bulk_update_guild_features`, `bulk_add_guild_members`, or `bulk_schedule_deletion`. The summary has the audit reason, the entity count, the operation-specific parameters, and the successful and failed counts. Its `target_id` is the guild for `add_guild_members` and `0` for every other task. A cancelled or failed job writes no summary entry.
|
||||
Every task writes one summary Admin audit entry when it finishes, with the action `bulk_update_user_flags`, `bulk_update_suspicious_activity_flags`, `bulk_update_guild_features`, `bulk_add_guild_members`, `bulk_schedule_deletion`, `bulk_ban_file_shas`, or `bulk_delete_user_messages`. The summary has the audit reason, the entity count, the operation-specific parameters, the job identifier, and the processed, successful, and failed counts. Its `target_type` is `bulk_job` and its `target_id` is the job identifier, except for `add_guild_members`, which targets the guild. A failed job writes no summary entry. A cancelled job writes one, marked `cancelled`, covering the entities it processed before it stopped.
|
||||
|
||||
`update_user_flags` writes one `update_flags` entry for each account and dispatches [User Update](/gateway/events/#user-update) to the account's sessions. A change to a publicly visible flag also dispatches [Guild Member Update](/gateway/events/#guild-member-update) to every guild the account is in.
|
||||
|
||||
`update_suspicious_activity_flags` rewrites each account's verification requirements and dispatches [User Update](/gateway/events/#user-update). No [Guild Member Update](/gateway/events/#guild-member-update) follows. The task writes no per-account audit entry.
|
||||
`update_suspicious_activity_flags` rewrites each account's verification requirements and dispatches [User Update](/gateway/events/#user-update). No [Guild Member Update](/gateway/events/#guild-member-update) follows. The task writes one `update_suspicious_activity_flags` entry for each account, with the audit reason, and records a risk outcome when the requirements become non-empty. An unknown flag name fails the job before any account is changed.
|
||||
|
||||
`update_guild_features` writes one `update_features` entry for each guild, dispatches [Guild Update](/gateway/events/#guild-update), and reindexes the guild for search. Fluxer reconciles a guild that already has a discovery application record against the new feature set, so gaining `DISCOVERABLE` approves the record and losing it marks the record removed. A guild with no discovery record is left alone.
|
||||
|
||||
`add_guild_members` bypasses the ban check and the risk gate. The task suppresses the join system message, records the join source as an Admin force add, and dispatches [Guild Member Add](/gateway/events/#guild-member-add) to the guild and [Guild Create](/gateway/events/#guild-create) to the added account's sessions. The task still enforces the per-account guild cap and the guild member cap, so an account at either ceiling is counted as failed. An account that is already a member is left unchanged and counted as successful, with no second membership and no Dispatch. Adding a bot account also records a `BOT_ADD` guild audit log entry attributed to the acting Admin.
|
||||
|
||||
`schedule_user_deletion` marks each account deleted. The task stores the reason code, public reason, and audit reason on the account, and reschedules its pending deletion. It dispatches [User Update](/gateway/events/#user-update), writes one `schedule_deletion` entry for each account, and emails the account holder when an address is on file. A failed email is logged and does not fail the entity.
|
||||
`delete_user_messages` deletes every message each account wrote, across every channel. It writes one `delete_all_user_messages` entry for each account, with the audit reason, the account, and the deleted message count, and reports progress after every account.
|
||||
|
||||
:::caution[Bulk scheduling is narrower than the single-account operation]
|
||||
The worker does not terminate sessions, cancel or refund a Stripe subscription, ban the account's identifiers, or resolve pending reports. [Schedule user deletion](/admin-api/users/#schedule-user-deletion) does each of those for one account, the last two only when the reason is not `USER_REQUESTED`.
|
||||
:::
|
||||
`schedule_user_deletion` marks each account deleted. The task runs the same steps as [Schedule user deletion](/admin-api/users/#schedule-user-deletion) for one account. It stores the reason code, public reason, and audit reason on the account, reschedules its pending deletion, terminates its sessions, cancels and refunds its Stripe subscription when one is on file, dispatches [User Update](/gateway/events/#user-update), writes one `schedule_deletion` entry for each account with the audit reason and the reason code, and emails the account holder when an address is on file. A failed email is logged and does not fail the entity. When the reason is not `USER_REQUESTED`, the task also bans the account's identifiers and resolves the pending reports against it.
|
||||
|
||||
### Rate limit
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ Admin discovery is the review side of the public guild directory. An Admin decid
|
||||
|
||||
Every operation except [Remove discovery listing](#remove-discovery-listing) requires `discovery:review`, which covers the reads, [Review discovery application](#review-discovery-application), [Move discovery listings to a category](#move-discovery-listings-to-a-category), and [Update discovery listing](#update-discovery-listing). [Remove discovery listing](#remove-discovery-listing) requires `discovery:remove` instead. Neither implies the other.
|
||||
|
||||
No operation here reads `X-Audit-Log-Reason` or records an Admin audit entry. An operation that stores a reason takes it in its request body.
|
||||
[Move discovery listings to a category](#move-discovery-listings-to-a-category) reads `X-Audit-Log-Reason` and records one `update_discovery_categories` Admin audit entry with the audit reason, the category, and the requested, updated, and failed counts. No other operation here reads that header or records an Admin audit entry. An operation that stores a reason takes it in its request body.
|
||||
|
||||
:::note[Review runs regardless of the discovery setting]
|
||||
An operator can disable discovery for the whole instance. Only the public [Discovery](/http-api/discovery/) routes read that state, and they return 400 `DISCOVERY_DISABLED` while it is off, so an application can be reviewed into a directory no account can see.
|
||||
|
||||
@@ -281,7 +281,7 @@ Every reloaded guild process fires one [Guild Update](/gateway/events/#guild-upd
|
||||
|
||||
### Side effects
|
||||
|
||||
A guild whose owner node cannot be resolved is not counted. Reloads can still be in progress when the response arrives. No guild data is changed and no Admin audit entry is recorded.
|
||||
A guild whose owner node cannot be resolved is not counted. Reloads can still be in progress when the response arrives. No guild data is changed. The operation records one `reload_guilds` Admin audit entry with the audit reason, the requested guild count, and the reloaded count.
|
||||
|
||||
### Rate limit
|
||||
|
||||
|
||||
@@ -77,7 +77,7 @@ Each code is 32 characters drawn from the uppercase letters, the lowercase lette
|
||||
|
||||
A failed request can leave some codes redeemable without returning them. Retrying can therefore create additional codes.
|
||||
|
||||
The operation records no Admin audit entry and emits no Gateway Dispatch.
|
||||
The operation records one `generate_gift_codes` Admin audit entry with the audit reason, the code count, and the requested duration. The codes themselves are not recorded. It emits no Gateway Dispatch.
|
||||
|
||||
### Rate limit
|
||||
|
||||
|
||||
Reference in New Issue
Block a user