Files
fluxer/fluxer_api/src/api/admin/tests/AdminDeletionQueue.test.ts
T

409 lines
17 KiB
TypeScript

// SPDX-License-Identifier: AGPL-3.0-or-later
import {randomInt} from 'node:crypto';
import {
clearTestEmails,
createTestAccount,
findLastTestEmail,
listTestEmails,
setUserACLs,
type TestAccount,
} from '@app/api/auth/tests/AuthTestUtils';
import {createUserID} from '@app/api/BrandedTypes';
import {setInjectedIpInfoService} from '@app/api/middleware/ServiceMiddleware';
import {getAdminRepository} from '@app/api/middleware/ServiceSingletons';
import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHarness';
import {HTTP_STATUS} from '@app/api/test/TestConstants';
import {createBuilder} from '@app/api/test/TestRequestBuilder';
import {UserRepository} from '@app/api/user/repositories/UserRepository';
import {DeletionReasons} from '@fluxer/constants/src/Core';
import type {IpInfoLookupResult} from '@pkgs/geoip/src/IpInfoService';
import {afterEach, beforeEach, describe, expect, test} from 'vitest';
function createUniqueTestIp(): string {
return `198.51.${randomInt(0, 256)}.${randomInt(1, 255)}`;
}
function ipInfoResult(ip: string, overrides: Partial<IpInfoLookupResult> = {}): IpInfoLookupResult {
return {
ip,
available: true,
note: 'test',
geo: {
countryCode: 'US',
countryName: 'United States',
continent: 'North America',
continentCode: 'NA',
region: null,
regionCode: null,
city: null,
postalCode: null,
timezone: null,
latitude: null,
longitude: null,
accuracyRadiusKm: null,
},
asn: {
asn: 'AS64500',
number: 64500,
name: 'Test ISP',
domain: null,
type: null,
},
mobile: {
name: null,
mcc: null,
mnc: null,
},
anonymous: {
isAnonymous: false,
providerName: null,
isVpn: false,
isProxy: false,
isResidentialProxy: false,
isTor: false,
isRelay: false,
percentDaysSeen: null,
},
flags: {
isAnycast: false,
isHosting: false,
isMobile: false,
isSatellite: false,
},
...overrides,
};
}
describe('Admin Deletion Queue', () => {
let harness: ApiTestHarness;
beforeEach(async () => {
harness = await createApiTestHarness();
setInjectedIpInfoService({
async lookup(ip: string) {
return ipInfoResult(ip);
},
});
});
afterEach(async () => {
setInjectedIpInfoService(undefined);
await harness?.shutdown();
});
test('admin scheduling queues deletion and rescheduling replaces the old Cassandra row', async () => {
const adminIp = createUniqueTestIp();
const targetIp = createUniqueTestIp();
const admin = await createTestAccount(harness, {ipAddress: adminIp});
const targetUser = await createTestAccount(harness, {ipAddress: targetIp});
const userRepository = new UserRepository();
await setUserACLs(harness, admin, ['admin:authenticate', 'user:delete']);
const firstSchedule = await createBuilder<{
user: {
pending_deletion_at: string;
};
}>(harness, `${admin.token}`)
.put(`/admin/users/${targetUser.userId}/deletion`)
.header('x-forwarded-for', adminIp)
.body({reason_code: 2, days_until_deletion: 60})
.execute();
expect(await harness.kvProvider.zcard('deletion_queue')).toBe(1);
const secondSchedule = await createBuilder<{
user: {
pending_deletion_at: string;
};
}>(harness, `${admin.token}`)
.put(`/admin/users/${targetUser.userId}/deletion`)
.header('x-forwarded-for', adminIp)
.body({
reason_code: 2,
days_until_deletion: 62,
replace_pending_deletion_at: firstSchedule.user.pending_deletion_at,
})
.execute();
const firstDate = firstSchedule.user.pending_deletion_at.slice(0, 10);
const secondDate = secondSchedule.user.pending_deletion_at.slice(0, 10);
expect(await harness.kvProvider.zcard('deletion_queue')).toBe(1);
expect(
(await userRepository.findUsersPendingDeletionByDate(firstDate)).some(
(row) => row.user_id.toString() === targetUser.userId,
),
).toBe(false);
expect(
(await userRepository.findUsersPendingDeletionByDate(secondDate)).some(
(row) => row.user_id.toString() === targetUser.userId,
),
).toBe(true);
});
test('cancel deletion clears the queued KV entry', async () => {
const adminIp = createUniqueTestIp();
const targetIp = createUniqueTestIp();
const admin = await createTestAccount(harness, {ipAddress: adminIp});
const targetUser = await createTestAccount(harness, {ipAddress: targetIp});
await setUserACLs(harness, admin, ['admin:authenticate', 'user:delete']);
const schedule = await createBuilder<{
user: {
pending_deletion_at: string;
};
}>(harness, `${admin.token}`)
.put(`/admin/users/${targetUser.userId}/deletion`)
.header('x-forwarded-for', adminIp)
.body({reason_code: 1, days_until_deletion: 60})
.execute();
expect(await harness.kvProvider.zcard('deletion_queue')).toBe(1);
await createBuilder<{
user: {
pending_deletion_at: string | null;
};
}>(harness, `${admin.token}`)
.delete(`/admin/users/${targetUser.userId}/deletion`)
.header('x-forwarded-for', adminIp)
.body({expected_pending_deletion_at: schedule.user.pending_deletion_at})
.execute();
expect(await harness.kvProvider.zcard('deletion_queue')).toBe(0);
expect(
(await new UserRepository().findUsersPendingDeletionByDate(schedule.user.pending_deletion_at.slice(0, 10))).some(
(row) => row.user_id.toString() === targetUser.userId,
),
).toBe(false);
});
test('user-requested scheduled deletion does not ban user identifiers', async () => {
const adminIp = createUniqueTestIp();
const targetIp = createUniqueTestIp();
const admin = await createTestAccount(harness, {ipAddress: adminIp});
const targetUser = await createTestAccount(harness, {ipAddress: targetIp});
await setUserACLs(harness, admin, ['admin:authenticate', 'user:delete', 'ban:ip:check', 'ban:email:check']);
await createBuilder(harness, `${admin.token}`)
.put(`/admin/users/${targetUser.userId}/deletion`)
.header('x-forwarded-for', adminIp)
.body({reason_code: DeletionReasons.USER_REQUESTED, days_until_deletion: 14})
.execute();
const ipBan = await createBuilder<{banned: boolean}>(harness, `${admin.token}`)
.get(`/admin/blocklists/ip/entries/${encodeURIComponent(targetIp)}`)
.execute();
const emailBan = await createBuilder<{banned: boolean}>(harness, `${admin.token}`)
.get(`/admin/blocklists/email/entries/${encodeURIComponent(targetUser.email)}`)
.execute();
expect(ipBan.banned).toBe(false);
expect(emailBan.banned).toBe(false);
});
test('moderation scheduled deletion bans email without banning the IP', async () => {
const adminIp = createUniqueTestIp();
const targetIp = createUniqueTestIp();
const admin = await createTestAccount(harness, {ipAddress: adminIp});
const targetUser = await createTestAccount(harness, {ipAddress: targetIp});
await setUserACLs(harness, admin, ['admin:authenticate', 'user:delete', 'ban:ip:check', 'ban:email:check']);
await createBuilder(harness, `${admin.token}`)
.put(`/admin/users/${targetUser.userId}/deletion`)
.header('x-forwarded-for', adminIp)
.body({reason_code: DeletionReasons.SPAM, days_until_deletion: 60})
.execute();
const ipBan = await createBuilder<{banned: boolean}>(harness, `${admin.token}`)
.get(`/admin/blocklists/ip/entries/${encodeURIComponent(targetIp)}`)
.execute();
const emailBan = await createBuilder<{banned: boolean}>(harness, `${admin.token}`)
.get(`/admin/blocklists/email/entries/${encodeURIComponent(targetUser.email)}`)
.execute();
expect(ipBan.banned).toBe(false);
expect(emailBan.banned).toBe(true);
});
test('rejects a scheduled deletion reason code outside the registry', async () => {
const admin = await createTestAccount(harness);
const targetUser = await createTestAccount(harness);
await setUserACLs(harness, admin, ['admin:authenticate', 'user:delete']);
const {json} = await createBuilder<{
code: string;
errors: Array<{
path: string;
code: string;
}>;
}>(harness, `${admin.token}`)
.put(`/admin/users/${targetUser.userId}/deletion`)
.body({reason_code: 999, days_until_deletion: 60})
.expect(HTTP_STATUS.BAD_REQUEST, 'INVALID_FORM_BODY')
.executeWithResponse();
expect(json.errors.some((entry) => entry.path === 'reason_code')).toBe(true);
expect(await harness.kvProvider.zcard('deletion_queue')).toBe(0);
});
test('accepts the highest scheduled deletion reason code of the registry', async () => {
const admin = await createTestAccount(harness);
const targetUser = await createTestAccount(harness);
await setUserACLs(harness, admin, ['admin:authenticate', 'user:delete']);
await createBuilder(harness, `${admin.token}`)
.put(`/admin/users/${targetUser.userId}/deletion`)
.body({reason_code: DeletionReasons.IMPERSONATION_OR_FAKE_IDENTITY, days_until_deletion: 60})
.expect(HTTP_STATUS.OK)
.execute();
expect(await harness.kvProvider.zcard('deletion_queue')).toBe(1);
});
test('rejects a bulk schedule_user_deletion job with a reason code outside the registry', async () => {
const admin = await createTestAccount(harness);
const targetUser = await createTestAccount(harness);
await setUserACLs(harness, admin, ['admin:authenticate', 'bulk:delete:users']);
const {json} = await createBuilder<{
code: string;
errors: Array<{
path: string;
code: string;
}>;
}>(harness, `${admin.token}`)
.post('/admin/bulk-jobs')
.body({task: 'schedule_user_deletion', user_ids: [targetUser.userId], reason_code: 0})
.expect(HTTP_STATUS.BAD_REQUEST, 'INVALID_FORM_BODY')
.executeWithResponse();
expect(json.errors.some((entry) => entry.path === 'reason_code')).toBe(true);
});
describe('naming the deletion', () => {
interface ScheduledUser {
user: {
pending_deletion_at: string | null;
deletion_scheduled_by: string | null;
deletion_scheduled_at: string | null;
deletion_audit_log_reason: string | null;
};
}
async function scheduleAs(admin: TestAccount, target: TestAccount, reasonCode: number, reason: string) {
return createBuilder<ScheduledUser>(harness, admin.token)
.put(`/admin/users/${target.userId}/deletion`)
.header('X-Audit-Log-Reason', reason)
.body({reason_code: reasonCode, days_until_deletion: 60})
.expect(HTTP_STATUS.OK)
.execute();
}
async function createAdmin(acls: Array<string> = []): Promise<TestAccount> {
return setUserACLs(harness, await createTestAccount(harness), ['admin:authenticate', 'user:delete', ...acls]);
}
test('records who scheduled the deletion and shows the private reason to audit log viewers', async () => {
const admin = await createAdmin(['audit_log:view']);
const target = await createTestAccount(harness);
const scheduled = await scheduleAs(admin, target, DeletionReasons.SPAM, 'Batch review');
expect(scheduled.user.deletion_scheduled_by).toBe(admin.userId);
expect(scheduled.user.deletion_scheduled_at).not.toBeNull();
expect(scheduled.user.deletion_audit_log_reason).toBe('Batch review');
const other = await createAdmin(['user:lookup']);
const viewed = await createBuilder<{users: Array<ScheduledUser['user']>}>(harness, other.token)
.get(`/admin/users/${target.userId}`)
.expect(HTTP_STATUS.OK)
.execute();
expect(viewed.users[0]?.deletion_scheduled_by).toBe(admin.userId);
expect(viewed.users[0]?.deletion_audit_log_reason).toBeNull();
});
test('a second schedule without naming the pending deletion is refused and keeps it', async () => {
const first = await createAdmin();
const second = await createAdmin();
const target = await createTestAccount(harness);
const scheduled = await scheduleAs(first, target, DeletionReasons.SPAM, 'First review');
await createBuilder(harness, second.token)
.put(`/admin/users/${target.userId}/deletion`)
.body({reason_code: DeletionReasons.SPAM, days_until_deletion: 60})
.expect(HTTP_STATUS.CONFLICT)
.execute();
await createBuilder(harness, first.token)
.put(`/admin/users/${target.userId}/deletion`)
.body({reason_code: DeletionReasons.OTHER, days_until_deletion: 60})
.expect(HTTP_STATUS.CONFLICT)
.execute();
const user = await new UserRepository().findUnique(createUserID(BigInt(target.userId)));
expect(user?.pendingDeletionAt?.toISOString()).toBe(scheduled.user.pending_deletion_at);
expect(user?.deletionScheduledBy?.toString()).toBe(first.userId);
});
test('replacing a named deletion records the deletion it replaced', async () => {
const first = await createAdmin();
const second = await createAdmin();
const target = await createTestAccount(harness);
const scheduled = await scheduleAs(first, target, DeletionReasons.SPAM, 'First review');
const replaced = await createBuilder<ScheduledUser>(harness, second.token)
.put(`/admin/users/${target.userId}/deletion`)
.body({
reason_code: DeletionReasons.OTHER,
days_until_deletion: 90,
replace_pending_deletion_at: scheduled.user.pending_deletion_at,
})
.expect(HTTP_STATUS.OK)
.execute();
expect(replaced.user.deletion_scheduled_by).toBe(second.userId);
const logs = await getAdminRepository().listAllAuditLogsPaginated(1000);
const log = logs.find(
(entry) =>
entry.action === 'schedule_deletion' &&
entry.targetId.toString() === target.userId &&
entry.adminUserId.toString() === second.userId,
);
expect(log?.metadata.get('replaced_pending_deletion_at')).toBe(scheduled.user.pending_deletion_at);
expect(log?.metadata.get('replaced_scheduled_by')).toBe(first.userId);
expect(log?.metadata.get('replaced_reason_code')).toBe(DeletionReasons.SPAM.toString());
expect(log?.metadata.get('pending_deletion_at')).toBe(replaced.user.pending_deletion_at);
});
test('cancel requires the pending deletion it cancels', async () => {
const admin = await createAdmin();
const target = await createTestAccount(harness);
await scheduleAs(admin, target, DeletionReasons.SPAM, 'Review');
await createBuilder(harness, admin.token)
.delete(`/admin/users/${target.userId}/deletion`)
.expect(HTTP_STATUS.BAD_REQUEST)
.execute();
await createBuilder(harness, admin.token)
.delete(`/admin/users/${target.userId}/deletion`)
.body({expected_pending_deletion_at: new Date(Date.now() + 86_400_000).toISOString()})
.expect(HTTP_STATUS.CONFLICT)
.execute();
const user = await new UserRepository().findUnique(createUserID(BigInt(target.userId)));
expect(user?.pendingDeletionAt).not.toBeNull();
expect(await harness.kvProvider.zcard('deletion_queue')).toBe(1);
});
test('cancel refuses when nothing is pending', async () => {
const admin = await createAdmin();
const target = await createTestAccount(harness);
await createBuilder(harness, admin.token)
.delete(`/admin/users/${target.userId}/deletion`)
.body({expected_pending_deletion_at: new Date().toISOString()})
.expect(HTTP_STATUS.BAD_REQUEST, 'NO_PENDING_DELETION')
.execute();
});
test('cancel records whose deletion it cancelled and emails only on request', async () => {
const scheduler = await createAdmin();
const canceller = await createAdmin();
const target = await createTestAccount(harness);
const scheduled = await scheduleAs(scheduler, target, DeletionReasons.SPAM, 'Review');
await clearTestEmails(harness);
await createBuilder(harness, canceller.token)
.delete(`/admin/users/${target.userId}/deletion`)
.header('X-Audit-Log-Reason', 'Private cancel note')
.body({expected_pending_deletion_at: scheduled.user.pending_deletion_at})
.expect(HTTP_STATUS.OK)
.execute();
expect(
findLastTestEmail(await listTestEmails(harness, {recipient: target.email}), 'account_deletion_cancelled'),
).toBeNull();
const logs = await getAdminRepository().listAllAuditLogsPaginated(1000);
const log = logs.find(
(entry) => entry.action === 'cancel_deletion' && entry.targetId.toString() === target.userId,
);
expect(log?.metadata.get('cancelled_pending_deletion_at')).toBe(scheduled.user.pending_deletion_at);
expect(log?.metadata.get('cancelled_scheduled_by')).toBe(scheduler.userId);
expect(log?.metadata.get('cancelled_reason_code')).toBe(DeletionReasons.SPAM.toString());
expect(log?.metadata.get('notify_user')).toBe('false');
expect(log?.metadata.get('notification_sent')).toBe('false');
const rescheduled = await scheduleAs(scheduler, target, DeletionReasons.SPAM, 'Review again');
await createBuilder(harness, canceller.token)
.delete(`/admin/users/${target.userId}/deletion`)
.header('X-Audit-Log-Reason', 'Private cancel note')
.body({expected_pending_deletion_at: rescheduled.user.pending_deletion_at, notify_user: true})
.expect(HTTP_STATUS.OK)
.execute();
const emails = await listTestEmails(harness, {recipient: target.email});
const email = findLastTestEmail(emails, 'account_deletion_cancelled');
expect(email).not.toBeNull();
expect(JSON.stringify(email)).not.toContain('Private cancel note');
expect(findLastTestEmail(emails, 'unban_notification')).toBeNull();
});
});
});