refactor: simplify account standing and verification levels (#3130)

This commit is contained in:
Hampus authored and GitHub committed 2026-10-02 18:17:41 +02:00
1 parent 1abde06824
commit 1ec8f31253
488 files changed
+10390 -47117

No files matched your search

@@ -16,7 +16,6 @@ import {BulkJobResponse} from '@fluxer/schema/src/domains/admin/AdminSchemas';
const BULK_TASK_ACLS: Record<AdminBulkTaskType, string> = {
[AdminBulkTaskType.UPDATE_USER_FLAGS]: AdminACLs.BULK_UPDATE_USER_FLAGS,
[AdminBulkTaskType.UPDATE_SUSPICIOUS_ACTIVITY_FLAGS]: AdminACLs.BULK_UPDATE_SUSPICIOUS_ACTIVITY,
[AdminBulkTaskType.UPDATE_GUILD_FEATURES]: AdminACLs.BULK_UPDATE_GUILD_FEATURES,
[AdminBulkTaskType.ADD_GUILD_MEMBERS]: AdminACLs.BULK_ADD_GUILD_MEMBERS,
[AdminBulkTaskType.SCHEDULE_USER_DELETION]: AdminACLs.BULK_DELETE_USERS,
@@ -43,18 +42,6 @@ async function queueBulkJob(
},
options,
);
case AdminBulkTaskType.UPDATE_SUSPICIOUS_ACTIVITY_FLAGS:
return await workerService.addJob(
'bulkUpdateSuspiciousActivityFlags',
{
user_ids: body.user_ids.map((id) => id.toString()),
add_flags: body.add_flags,
remove_flags: body.remove_flags,
admin_user_id: adminUserId.toString(),
audit_log_reason: auditLogReason,
},
options,
);
case AdminBulkTaskType.UPDATE_GUILD_FEATURES:
return await workerService.addJob(
'bulkUpdateGuildFeatures',
@@ -112,7 +99,6 @@ export function BulkAdminController(app: HonoApp) {
Validator('json', AdminBulkJobCreateRequest),
requireAnyAdminACL([
AdminACLs.BULK_UPDATE_USER_FLAGS,
AdminACLs.BULK_UPDATE_SUSPICIOUS_ACTIVITY,
AdminACLs.BULK_UPDATE_GUILD_FEATURES,
AdminACLs.BULK_ADD_GUILD_MEMBERS,
AdminACLs.BULK_DELETE_USERS,
@@ -122,7 +108,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.',
'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_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',
@@ -31,12 +31,9 @@ import {
AdminUserFlagsUpdateRequest,
AdminUserGuildListQuery,
AdminUserListQuery,
AdminUserPhoneVerificationRequest,
AdminUserPremiumFlagsUpdateRequest,
AdminUserRelationshipCategoryQuery,
AdminUserRelationshipParam,
AdminUserSuspiciousActivityFlagsRequest,
AdminUserSuspiciousDisableRequest,
AdminUserSystemStatusRequest,
AdminUsersMeResponse,
AdminUserTraitsRequest,
@@ -1129,38 +1126,6 @@ export function UserAdminController(app: HonoApp) {
);
},
);
app.put(
'/admin/users/:user_id/phone-verification',
RateLimitMiddleware(RateLimitConfigs.ADMIN_USER_MODIFY),
requireAdminACL(AdminACLs.USER_UPDATE_PHONE),
Validator('param', UserIdParam),
Validator('json', AdminUserPhoneVerificationRequest),
OpenAPI({
operationId: 'update_admin_user_phone_verification',
summary: 'Update user phone verification flag',
responseSchema: UserMutationResponse,
statusCode: 200,
security: 'adminApiKey',
tags: 'Admin',
description:
'Set whether a user is treated as having completed phone verification. This is the only supported path for clearing the irreversible user-facing phone verification flag. Requires USER_UPDATE_PHONE permission.',
}),
async (ctx) => {
const adminService = ctx.get('adminService');
const adminUserId = ctx.get('adminUserId');
const auditLogReason = ctx.get('auditLogReason');
const adminUserAcls = ctx.get('adminUserAcls');
const {user_id: userId} = ctx.req.valid('param');
return ctx.json(
await adminService.userService.securityService.updateHasVerifiedPhone(
{user_id: userId, ...ctx.req.valid('json')},
adminUserId,
auditLogReason,
adminUserAcls,
),
);
},
);
app.patch(
'/admin/users/:user_id/date-of-birth',
RateLimitMiddleware(RateLimitConfigs.ADMIN_USER_MODIFY),
@@ -1193,68 +1158,4 @@ export function UserAdminController(app: HonoApp) {
);
},
);
app.put(
'/admin/users/:user_id/suspicious-activity-flags',
RateLimitMiddleware(RateLimitConfigs.ADMIN_USER_MODIFY),
requireAdminACL(AdminACLs.USER_UPDATE_SUSPICIOUS_ACTIVITY),
Validator('param', UserIdParam),
Validator('json', AdminUserSuspiciousActivityFlagsRequest),
OpenAPI({
operationId: 'update_admin_user_suspicious_activity_flags',
summary: 'Update suspicious activity flags',
responseSchema: UserMutationResponse,
statusCode: 200,
security: 'adminApiKey',
tags: 'Admin',
description:
'Flag user as suspicious for account abuse, fraud, or policy violations. Enables enforcement actions and rate limiting. Creates audit log entry. Requires USER_UPDATE_SUSPICIOUS_ACTIVITY permission.',
}),
async (ctx) => {
const adminService = ctx.get('adminService');
const adminUserId = ctx.get('adminUserId');
const auditLogReason = ctx.get('auditLogReason');
const adminUserAcls = ctx.get('adminUserAcls');
const {user_id: userId} = ctx.req.valid('param');
return ctx.json(
await adminService.userService.securityService.updateSuspiciousActivityFlags(
{user_id: userId, ...ctx.req.valid('json')},
adminUserId,
auditLogReason,
adminUserAcls,
),
);
},
);
app.put(
'/admin/users/:user_id/suspicious-activity-disablement',
RateLimitMiddleware(RateLimitConfigs.ADMIN_USER_MODIFY),
requireAdminACL(AdminACLs.USER_DISABLE_SUSPICIOUS),
Validator('param', UserIdParam),
Validator('json', AdminUserSuspiciousDisableRequest),
OpenAPI({
operationId: 'disable_admin_user_suspicious',
summary: 'Disable user for suspicious activity',
responseSchema: UserMutationResponse,
statusCode: 200,
security: 'adminApiKey',
tags: 'Admin',
description:
'Disable user account due to suspicious activity or abuse. Account is locked pending review. User cannot access services. Emails the user unless notify_user is false. Creates audit log entry. Requires USER_DISABLE_SUSPICIOUS permission.',
}),
async (ctx) => {
const adminService = ctx.get('adminService');
const adminUserId = ctx.get('adminUserId');
const auditLogReason = ctx.get('auditLogReason');
const adminUserAcls = ctx.get('adminUserAcls');
const {user_id: userId} = ctx.req.valid('param');
return ctx.json(
await adminService.userService.securityService.disableForSuspiciousActivity(
{user_id: userId, ...ctx.req.valid('json')},
adminUserId,
auditLogReason,
adminUserAcls,
),
);
},
);
}
@@ -3,7 +3,6 @@
import type {User} from '@app/api/models/User';
import {getIpAddressReverse, lookupGeoip} from '@app/api/utils/IpUtils';
import {AdminACLs} from '@fluxer/constants/src/AdminACLs';
import {DEFERRED_PHONE_ON_COMMUNITY_JOIN} from '@fluxer/constants/src/UserConstants';
import type {UserAdminResponse} from '@fluxer/schema/src/domains/admin/AdminUserSchemas';
import type {ICacheService} from '@pkgs/cache/src/ICacheService';
import {formatGeoipLocation} from '@pkgs/geoip/src/GeoipLookup';
@@ -50,7 +49,6 @@ export async function mapUserToAdminResponse(
email: canViewEmail ? (user.email ?? null) : null,
email_verified: canViewEmail ? user.emailVerified : false,
email_bounced: canViewEmail ? user.emailBounced : false,
has_verified_phone: user.hasVerifiedPhone,
date_of_birth: canViewDob ? user.dateOfBirth : null,
locale: user.locale,
premium_type: user.premiumType,
@@ -58,8 +56,6 @@ export async function mapUserToAdminResponse(
premium_until: user.premiumUntil?.toISOString() ?? null,
premium_grace_ends_at: user.premiumGraceEndsAt?.toISOString() ?? null,
premium_lifetime_sequence: user.premiumLifetimeSequence ?? null,
suspicious_activity_flags: user.suspiciousActivityFlags,
phone_verification_deferred: ((user.suspiciousActivityFlags ?? 0) & DEFERRED_PHONE_ON_COMMUNITY_JOIN) !== 0,
temp_banned_until:
user.tempBannedUntil && user.tempBannedUntil.getTime() > Date.now() ? user.tempBannedUntil.toISOString() : null,
pending_deletion_at: user.pendingDeletionAt?.toISOString() ?? null,
@@ -49,14 +49,8 @@ export class AdminUserBanService {
} else {
tempBannedUntil.setHours(tempBannedUntil.getHours() + data.duration_hours);
}
const updatedUser = await userRepository.patchUpsert(
userId,
{
temp_banned_until: tempBannedUntil,
flags: user.flags | UserFlags.DISABLED,
},
user.toRow(),
);
const bannedUser = await userRepository.patchUpsert(userId, {temp_banned_until: tempBannedUntil}, user.toRow());
const updatedUser = (await userRepository.updateFlags(userId, (flags) => flags | UserFlags.DISABLED)) ?? bannedUser;
await AuthSession.terminateAllUserSessions(this.deps.apiContext, userId);
await updatePropagator.propagateUserUpdate({userId, oldUser: user, updatedUser: updatedUser});
const email = user.email;
@@ -146,14 +140,9 @@ export class AdminUserBanService {
throw new UnknownUserError();
}
const wasTempBanned = isTemporarilyBanned(user) && !isAccountClosed(user);
const updatedUser = await userRepository.patchUpsert(
userId,
{
temp_banned_until: null,
flags: user.flags & ~UserFlags.DISABLED,
},
user.toRow(),
);
const unbannedUser = await userRepository.patchUpsert(userId, {temp_banned_until: null}, user.toRow());
const updatedUser =
(await userRepository.updateFlags(userId, (flags) => flags & ~UserFlags.DISABLED)) ?? unbannedUser;
await updatePropagator.propagateUserUpdate({userId, oldUser: user, updatedUser: updatedUser});
const email = user.email;
const notificationSent =
@@ -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 {EMAIL_CLEARABLE_SUSPICIOUS_ACTIVITY_FLAGS} from '@app/api/auth/AuthEmail';
import {createUserID, type UserID} from '@app/api/BrandedTypes';
import type {IGuildRepositoryAggregate} from '@app/api/guild/repositories/IGuildRepositoryAggregate';
import {GuildMemberSearchIndexService} from '@app/api/guild/services/member/GuildMemberSearchIndexService';
@@ -188,21 +187,11 @@ export class AdminUserProfileService {
if (!user) {
throw new UnknownUserError();
}
const updates: {
email_verified: boolean;
email_bounced: boolean;
suspicious_activity_flags?: number;
} = {
email_verified: true,
email_bounced: false,
};
if (user.suspiciousActivityFlags !== null && user.suspiciousActivityFlags !== 0) {
const newFlags = user.suspiciousActivityFlags & ~EMAIL_CLEARABLE_SUSPICIOUS_ACTIVITY_FLAGS;
if (newFlags !== user.suspiciousActivityFlags) {
updates.suspicious_activity_flags = newFlags;
}
}
const updatedUser = await userRepository.patchUpsert(userId, updates, user.toRow());
const updatedUser = await userRepository.patchUpsert(
userId,
{email_verified: true, email_bounced: false},
user.toRow(),
);
await updatePropagator.propagateUserUpdate({userId, oldUser: user, updatedUser: updatedUser});
await auditService.createAuditLog({
adminUserId,
@@ -3,7 +3,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 {trySendAdminNotification} from '@app/api/admin/services/AdminNotification';
import type {AdminUserUpdatePropagator} from '@app/api/admin/services/AdminUserUpdatePropagator';
import * as AuthEmail from '@app/api/auth/AuthEmail';
import * as AuthMfa from '@app/api/auth/AuthMfa';
@@ -11,10 +10,10 @@ import * as AuthSession from '@app/api/auth/AuthSession';
import * as AuthUtility from '@app/api/auth/AuthUtility';
import {visibleWebAuthnCredentials} from '@app/api/auth/services/PasskeyRelyingParty';
import {createPasswordResetToken, createUserID, type UserID} from '@app/api/BrandedTypes';
import type {UserRow} from '@app/api/database/types/UserTypes';
import {emitAdminAction} from '@app/api/infrastructure/activity/AccountChangeEvents';
import {Logger} from '@app/api/Logger';
import {getInstanceConfigRepository} from '@app/api/middleware/ServiceSingletons';
import {User} from '@app/api/models/User';
import {clearNewConversationLimit} from '@app/api/user/NewConversationLimit';
import {mapWebAuthnCredentialToResponse} from '@app/api/user/UserMappers';
import {resolveAssignedTraits} from '@app/api/user/UserTraits';
@@ -22,13 +21,7 @@ import {getIpAddressReverse, getLocationLabelFromIp} from '@app/api/utils/IpUtil
import {resolveSessionClientInfo} from '@app/api/utils/SessionClientIdentity';
import {AdminACLs} from '@fluxer/constants/src/AdminACLs';
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
import {
ADMIN_PHONE_TOGGLE_CLEARABLE_FLAGS,
DEFERRABLE_PHONE_FLAGS,
DEFERRED_PHONE_ON_COMMUNITY_JOIN,
PHONE_GATE_PROMOTED_FROM_DEFERRAL,
UserFlags,
} from '@fluxer/constants/src/UserConstants';
import {UserFlags} from '@fluxer/constants/src/UserConstants';
import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
import {AccessDeniedError} from '@fluxer/errors/src/domains/core/AccessDeniedError';
import {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidationError';
@@ -37,7 +30,6 @@ import {ServiceUnavailableError} from '@fluxer/errors/src/domains/core/ServiceUn
import {UnknownUserError} from '@fluxer/errors/src/domains/user/UnknownUserError';
import type {
DeleteWebAuthnCredentialRequest,
DisableForSuspiciousActivityRequest,
DisableMfaRequest,
ListWebAuthnCredentialsRequest,
ResendVerificationEmailRequest,
@@ -45,8 +37,6 @@ import type {
SetUserAclsRequest,
SetUserTraitsRequest,
TerminateSessionsRequest,
UpdateHasVerifiedPhoneRequest,
UpdateSuspiciousActivityFlagsRequest,
} from '@fluxer/schema/src/domains/admin/AdminUserSchemas';
import type {WebAuthnCredentialListResponse} from '@fluxer/schema/src/domains/auth/AuthSchemas';
@@ -132,23 +122,21 @@ export class AdminUserSecurityService {
}) {
const {users: userRepository, cache: cacheService} = this.deps.apiContext.services;
const {auditService, updatePropagator} = this.deps;
const user = await userRepository.findUnique(userId);
if (!user) {
let previousFlags = 0n;
const updatedUser = await userRepository.updateFlags(userId, (flags) => {
previousFlags = flags;
return applyBigIntFlagUpdates(flags, data.addFlags, data.removeFlags);
});
if (!updatedUser) {
throw new UnknownUserError();
}
const newFlags = applyBigIntFlagUpdates(user.flags, data.addFlags, data.removeFlags);
const updatedUser = await userRepository.patchUpsert(
userId,
{
flags: newFlags,
},
user.toRow(),
);
const trusted = (newFlags & UserFlags.NOT_SUSPICIOUS) !== 0n && (user.flags & UserFlags.NOT_SUSPICIOUS) === 0n;
if (trusted || (user.flags & ~newFlags) !== 0n) {
const newFlags = updatedUser.flags;
const trusted = (newFlags & UserFlags.LIMIT_EXEMPT) !== 0n && (previousFlags & UserFlags.LIMIT_EXEMPT) === 0n;
if (trusted || (previousFlags & ~newFlags) !== 0n) {
await clearNewConversationLimit(userId, {cache: cacheService});
}
await updatePropagator.propagateUserUpdate({userId, oldUser: user, updatedUser: updatedUser});
const oldUser = new User({...updatedUser.toRow(), flags: previousFlags});
await updatePropagator.propagateUserUpdate({userId, oldUser, updatedUser});
await auditService.createAuditLog({
adminUserId,
targetType: 'user',
@@ -399,150 +387,6 @@ export class AdminUserSecurityService {
};
}
async updateHasVerifiedPhone(
data: UpdateHasVerifiedPhoneRequest,
adminUserId: UserID,
auditLogReason: string | null,
acls: ReadonlySet<string>,
) {
const {users: userRepository, cache: cacheService} = 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 phonePatch: Partial<UserRow> = {has_verified_phone: data.has_verified_phone};
if (data.has_verified_phone) {
const clearedFlags = (user.suspiciousActivityFlags ?? 0) & ~ADMIN_PHONE_TOGGLE_CLEARABLE_FLAGS;
if (clearedFlags !== (user.suspiciousActivityFlags ?? 0)) {
phonePatch.suspicious_activity_flags = clearedFlags;
}
}
const updatedUser = await userRepository.patchUpsert(userId, phonePatch, user.toRow());
await updatePropagator.propagateUserUpdate({userId, oldUser: user, updatedUser});
await auditService.createAuditLog({
adminUserId,
targetType: 'user',
targetId: BigInt(userId),
action: 'update_has_verified_phone',
auditLogReason,
metadata: new Map(
phonePatch.suspicious_activity_flags === undefined
? [['has_verified_phone', String(data.has_verified_phone)]]
: [
['has_verified_phone', String(data.has_verified_phone)],
['suspicious_activity_flags_before', String(user.suspiciousActivityFlags ?? 0)],
['suspicious_activity_flags_after', String(phonePatch.suspicious_activity_flags)],
],
),
});
await emitAdminAction(adminUserId, userId, 'set_phone_verified');
return {
user: await mapUserToAdminResponse(updatedUser, cacheService, acls),
};
}
async updateSuspiciousActivityFlags(
data: UpdateSuspiciousActivityFlagsRequest,
adminUserId: UserID,
auditLogReason: string | null,
acls: ReadonlySet<string>,
) {
const {users: userRepository, cache: cacheService} = 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 currentFlags = user.suspiciousActivityFlags ?? 0;
const keepsDeferral =
(currentFlags & DEFERRED_PHONE_ON_COMMUNITY_JOIN) !== 0 &&
(data.flags & DEFERRABLE_PHONE_FLAGS) !== 0 &&
(data.flags & DEFERRABLE_PHONE_FLAGS) === (currentFlags & DEFERRABLE_PHONE_FLAGS);
const keepsPromotion =
(currentFlags & PHONE_GATE_PROMOTED_FROM_DEFERRAL) !== 0 &&
(data.flags & DEFERRABLE_PHONE_FLAGS) !== 0 &&
(data.flags & DEFERRABLE_PHONE_FLAGS) === (currentFlags & DEFERRABLE_PHONE_FLAGS);
const newFlags =
(keepsDeferral ? data.flags | DEFERRED_PHONE_ON_COMMUNITY_JOIN : data.flags) |
(keepsPromotion ? PHONE_GATE_PROMOTED_FROM_DEFERRAL : 0);
const updatedUser = await userRepository.patchUpsert(
userId,
{
suspicious_activity_flags: newFlags,
},
user.toRow(),
);
if ((currentFlags & ~newFlags) !== 0) {
await clearNewConversationLimit(userId, {cache: cacheService});
}
await updatePropagator.propagateUserUpdate({userId, oldUser: user, updatedUser: updatedUser});
await auditService.createAuditLog({
adminUserId,
targetType: 'user',
targetId: BigInt(userId),
action: 'update_suspicious_activity_flags',
auditLogReason,
metadata: new Map([['flags', data.flags.toString()]]),
});
await emitAdminAction(adminUserId, userId, 'set_suspicious_flags');
return {
user: await mapUserToAdminResponse(updatedUser, cacheService, acls),
};
}
async disableForSuspiciousActivity(
data: DisableForSuspiciousActivityRequest,
adminUserId: UserID,
auditLogReason: string | null,
acls: ReadonlySet<string>,
) {
const {users: userRepository, email: emailService, cache: cacheService} = 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 updatedUser = await userRepository.patchUpsert(
userId,
{
flags: user.flags | UserFlags.DISABLED_SUSPICIOUS_ACTIVITY,
suspicious_activity_flags: data.flags,
password_hash: null,
},
user.toRow(),
);
await AuthSession.terminateAllUserSessions(this.deps.apiContext, userId);
await updatePropagator.propagateUserUpdate({userId, oldUser: user, updatedUser: updatedUser});
const email = user.email;
const notificationSent =
data.notify_user && email
? await trySendAdminNotification(
() => emailService.sendAccountDisabledForSuspiciousActivityEmail(email, user.username, null, user.locale),
{action: 'disable_suspicious_activity', targetId: userId.toString()},
)
: false;
await auditService.createAuditLog({
adminUserId,
targetType: 'user',
targetId: BigInt(userId),
action: 'disable_suspicious_activity',
auditLogReason,
metadata: new Map([
['flags', data.flags.toString()],
['notify_user', data.notify_user ? 'true' : 'false'],
['notification_sent', notificationSent ? 'true' : 'false'],
]),
});
await emitAdminAction(adminUserId, userId, 'disable_suspicious');
return {
user: await mapUserToAdminResponse(updatedUser, cacheService, acls),
};
}
async listWebAuthnCredentials(
data: ListWebAuthnCredentialsRequest,
adminUserId: UserID,
@@ -48,6 +48,7 @@ export class AdminGuildMembershipService {
guildId,
sendJoinMessage,
skipBanCheck: true,
skipAccountLimitCheck: true,
joinSourceType: JoinSourceTypes.ADMIN_FORCE_ADD,
requestCache,
initiatorId: adminUserId,
@@ -5,7 +5,7 @@ import {createGuild} from '@app/api/channel/tests/ChannelTestUtils';
import {getUserActivityBuffer} from '@app/api/middleware/ServiceSingletons';
import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHarness';
import {HTTP_STATUS} from '@app/api/test/TestConstants';
import {createBuilder, createBuilderWithoutAuth} from '@app/api/test/TestRequestBuilder';
import {createBuilder} from '@app/api/test/TestRequestBuilder';
import type {GuildAdminResponse} from '@fluxer/schema/src/domains/admin/AdminGuildSchemas';
import type {UserAdminResponse} from '@fluxer/schema/src/domains/admin/AdminUserSchemas';
import {afterEach, beforeEach, describe, expect, test} from 'vitest';
@@ -20,21 +20,6 @@ interface GuildSearchResponse {
total: number;
}
async function setContactInfo(
harness: ApiTestHarness,
userId: string,
data: {
has_verified_phone?: boolean;
email?: string | null;
},
): Promise<void> {
await createBuilderWithoutAuth(harness)
.post(`/test/users/${userId}/set-contact-info`)
.body(data)
.expect(HTTP_STATUS.OK)
.execute();
}
async function setLastActiveIp(harness: ApiTestHarness, token: string, ip: string): Promise<void> {
await createBuilder(harness, `${token}`)
.get('/users/@me')
@@ -197,7 +182,6 @@ describe('Admin Search Field Coverage', () => {
const email = `fields-check-${Date.now()}@fieldtest.example`;
const username = `fieldcheck_${Date.now()}`;
const targetUser = await createTestAccount(harness, {email, username});
await setContactInfo(harness, targetUser.userId, {has_verified_phone: true});
const result = await createBuilder<UserSearchResponse>(harness, `${admin.token}`)
.get(`/admin/users?q=${encodeURIComponent(email)}&limit=10&offset=0`)
.expect(HTTP_STATUS.OK)
@@ -207,7 +191,6 @@ describe('Admin Search Field Coverage', () => {
expect(found!.id).toBe(targetUser.userId);
expect(found!.username).toBe(username);
expect(found!.email).toBe(email);
expect(found!.has_verified_phone).toBe(true);
expect(found!).toHaveProperty('discriminator');
expect(found!).toHaveProperty('global_name');
expect(found!).toHaveProperty('flags');
@@ -215,7 +198,6 @@ describe('Admin Search Field Coverage', () => {
expect(found!).toHaveProperty('email_bounced');
expect(found!).toHaveProperty('premium_type');
expect(found!).toHaveProperty('acls');
expect(found!).toHaveProperty('suspicious_activity_flags');
});
});
describe('user search isolation across fields', () => {
@@ -15,7 +15,6 @@ import {HTTP_STATUS} from '@app/api/test/TestConstants';
import {createBuilder} from '@app/api/test/TestRequestBuilder';
import {AdminACLs} from '@fluxer/constants/src/AdminACLs';
import {DeletionReasons} from '@fluxer/constants/src/Core';
import {SuspiciousActivityFlags} from '@fluxer/constants/src/UserConstants';
import {afterEach, beforeEach, describe, expect, test} from 'vitest';
const DELETION_TEMPLATES = [
@@ -257,29 +256,6 @@ describe('Admin staff notifications', () => {
});
});
describe('suspicious activity disable', () => {
test.each([
[undefined, true],
[false, false],
])('notify_user %s emails %s', async (notifyUser, expected) => {
const target = await createTestAccount(harness);
await clearTestEmails(harness);
await createBuilder(harness, admin.token)
.put(`/admin/users/${target.userId}/suspicious-activity-disablement`)
.body({
flags: SuspiciousActivityFlags.REQUIRE_VERIFIED_PHONE,
...(notifyUser === undefined ? {} : {notify_user: notifyUser}),
})
.expect(HTTP_STATUS.OK)
.execute();
const types = (await emailsTo(target)).map((email) => email.type);
expect(types.includes('account_disabled_suspicious')).toBe(expected);
const metadata = await auditMetadata('disable_suspicious_activity', target.userId);
expect(metadata?.get('notify_user')).toBe(expected ? 'true' : 'false');
expect(metadata?.get('notification_sent')).toBe(expected ? 'true' : 'false');
});
});
describe('report resolve', () => {
async function fileReport(): Promise<{reporter: TestAccount; reportId: string}> {
const reporter = await createTestAccount(harness);
@@ -5,7 +5,7 @@ import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHa
import {HTTP_STATUS, TEST_CREDENTIALS} from '@app/api/test/TestConstants';
import {createBuilder, createBuilderWithoutAuth} from '@app/api/test/TestRequestBuilder';
import {AdminACLs} from '@fluxer/constants/src/AdminACLs';
import {SuspiciousActivityFlags} from '@fluxer/constants/src/UserConstants';
import {UserFlags} from '@fluxer/constants/src/UserConstants';
import {afterAll, beforeAll, beforeEach, describe, expect, test} from 'vitest';
interface ChangeLogResponse {
@@ -24,7 +24,7 @@ interface ChangeLogResponse {
interface UserMutationResponse {
user: {
id: string;
suspicious_activity_flags: number;
flags: string;
};
}
@@ -33,11 +33,10 @@ interface VerifyEmailMutationResponse {
id: string;
email_verified: boolean;
email_bounced: boolean;
suspicious_activity_flags: number;
};
}
describe('Admin User Change Log and Suspicious Flags', () => {
describe('Admin User Change Log and Flags', () => {
let harness: ApiTestHarness;
beforeAll(async () => {
harness = await createApiTestHarness();
@@ -130,101 +129,23 @@ describe('Admin User Change Log and Suspicious Flags', () => {
.execute();
});
});
describe('PUT /admin/users/{user_id}/suspicious-activity-flags', () => {
test('sets suspicious activity flags', async () => {
describe('PATCH /admin/users/{user_id}/flags account limitation', () => {
test('sets and clears the account limitation', async () => {
const admin = await createTestAccount(harness);
await setUserACLs(harness, admin, [AdminACLs.AUTHENTICATE, AdminACLs.WILDCARD]);
const target = await createTestAccount(harness);
const flags = SuspiciousActivityFlags.REQUIRE_VERIFIED_EMAIL | SuspiciousActivityFlags.REQUIRE_VERIFIED_PHONE;
const result = await createBuilder<UserMutationResponse>(harness, `${admin.token}`)
.put(`/admin/users/${target.userId}/suspicious-activity-flags`)
.body({flags})
const limited = await createBuilder<UserMutationResponse>(harness, `${admin.token}`)
.patch(`/admin/users/${target.userId}/flags`)
.body({add_flags: [UserFlags.ACCOUNT_LIMITED.toString()]})
.expect(HTTP_STATUS.OK)
.execute();
expect(result.user.suspicious_activity_flags).toBe(flags);
});
test('clears all suspicious activity flags by setting to zero', async () => {
const admin = await createTestAccount(harness);
await setUserACLs(harness, admin, [AdminACLs.AUTHENTICATE, AdminACLs.WILDCARD]);
const target = await createTestAccount(harness);
await createBuilder<UserMutationResponse>(harness, `${admin.token}`)
.put(`/admin/users/${target.userId}/suspicious-activity-flags`)
.body({flags: SuspiciousActivityFlags.REQUIRE_VERIFIED_EMAIL})
expect(BigInt(limited.user.flags) & UserFlags.ACCOUNT_LIMITED).toBe(UserFlags.ACCOUNT_LIMITED);
const lifted = await createBuilder<UserMutationResponse>(harness, `${admin.token}`)
.patch(`/admin/users/${target.userId}/flags`)
.body({remove_flags: [UserFlags.ACCOUNT_LIMITED.toString()]})
.expect(HTTP_STATUS.OK)
.execute();
const result = await createBuilder<UserMutationResponse>(harness, `${admin.token}`)
.put(`/admin/users/${target.userId}/suspicious-activity-flags`)
.body({flags: 0})
.expect(HTTP_STATUS.OK)
.execute();
expect(result.user.suspicious_activity_flags).toBe(0);
});
test('rejects invalid user_id format', async () => {
const admin = await createTestAccount(harness);
await setUserACLs(harness, admin, [AdminACLs.AUTHENTICATE, AdminACLs.WILDCARD]);
await createBuilder(harness, `${admin.token}`)
.put('/admin/users/not-a-snowflake/suspicious-activity-flags')
.body({flags: 1})
.expect(HTTP_STATUS.BAD_REQUEST, 'INVALID_FORM_BODY')
.execute();
});
test('rejects missing flags', async () => {
const admin = await createTestAccount(harness);
await setUserACLs(harness, admin, [AdminACLs.AUTHENTICATE, AdminACLs.WILDCARD]);
const target = await createTestAccount(harness);
await createBuilder(harness, `${admin.token}`)
.put(`/admin/users/${target.userId}/suspicious-activity-flags`)
.body({})
.expect(HTTP_STATUS.BAD_REQUEST, 'INVALID_FORM_BODY')
.execute();
});
test('rejects negative flags', async () => {
const admin = await createTestAccount(harness);
await setUserACLs(harness, admin, [AdminACLs.AUTHENTICATE, AdminACLs.WILDCARD]);
const target = await createTestAccount(harness);
await createBuilder(harness, `${admin.token}`)
.put(`/admin/users/${target.userId}/suspicious-activity-flags`)
.body({flags: -1})
.expect(HTTP_STATUS.BAD_REQUEST, 'INVALID_FORM_BODY')
.execute();
});
test('rejects non-existent user', async () => {
const admin = await createTestAccount(harness);
await setUserACLs(harness, admin, [AdminACLs.AUTHENTICATE, AdminACLs.WILDCARD]);
await createBuilder(harness, `${admin.token}`)
.put('/admin/users/999999999999999999/suspicious-activity-flags')
.body({flags: 1})
.expect(HTTP_STATUS.NOT_FOUND)
.execute();
});
test('requires USER_UPDATE_SUSPICIOUS_ACTIVITY ACL', async () => {
const admin = await createTestAccount(harness);
await setUserACLs(harness, admin, [AdminACLs.AUTHENTICATE, AdminACLs.USER_LOOKUP]);
const target = await createTestAccount(harness);
await createBuilder(harness, `${admin.token}`)
.put(`/admin/users/${target.userId}/suspicious-activity-flags`)
.body({flags: 1})
.expect(HTTP_STATUS.FORBIDDEN)
.execute();
});
test('updates flags multiple times in sequence', async () => {
const admin = await createTestAccount(harness);
await setUserACLs(harness, admin, [AdminACLs.AUTHENTICATE, AdminACLs.WILDCARD]);
const target = await createTestAccount(harness);
const result1 = await createBuilder<UserMutationResponse>(harness, `${admin.token}`)
.put(`/admin/users/${target.userId}/suspicious-activity-flags`)
.body({flags: SuspiciousActivityFlags.REQUIRE_VERIFIED_EMAIL})
.expect(HTTP_STATUS.OK)
.execute();
expect(result1.user.suspicious_activity_flags).toBe(SuspiciousActivityFlags.REQUIRE_VERIFIED_EMAIL);
const combined =
SuspiciousActivityFlags.REQUIRE_VERIFIED_EMAIL | SuspiciousActivityFlags.REQUIRE_REVERIFIED_EMAIL;
const result2 = await createBuilder<UserMutationResponse>(harness, `${admin.token}`)
.put(`/admin/users/${target.userId}/suspicious-activity-flags`)
.body({flags: combined})
.expect(HTTP_STATUS.OK)
.execute();
expect(result2.user.suspicious_activity_flags).toBe(combined);
expect(BigInt(lifted.user.flags) & UserFlags.ACCOUNT_LIMITED).toBe(0n);
});
});
describe('DELETE /admin/users/{user_id}/mfa', () => {
@@ -256,7 +177,7 @@ describe('Admin User Change Log and Suspicious Flags', () => {
});
});
describe('PUT /admin/users/{user_id}/email-verification', () => {
test('verifying email clears email_bounced and only email-related suspicious flags', async () => {
test('verifying email clears email_bounced', async () => {
const admin = await createTestAccount(harness);
await setUserACLs(harness, admin, [AdminACLs.AUTHENTICATE, AdminACLs.WILDCARD]);
const target = await createTestAccount(harness);
@@ -265,7 +186,6 @@ describe('Admin User Change Log and Suspicious Flags', () => {
.body({
email_bounced: true,
email_verified: false,
suspicious_activity_flag_names: ['REQUIRE_REVERIFIED_EMAIL', 'REQUIRE_VERIFIED_PHONE'],
})
.expect(HTTP_STATUS.OK)
.execute();
@@ -275,7 +195,6 @@ describe('Admin User Change Log and Suspicious Flags', () => {
.execute();
expect(result.user.email_verified).toBe(true);
expect(result.user.email_bounced).toBe(false);
expect(result.user.suspicious_activity_flags).toBe(SuspiciousActivityFlags.REQUIRE_VERIFIED_PHONE);
});
});
describe('POST /admin/users/{user_id}/verification-email', () => {
@@ -25,7 +25,7 @@ import type {User} from '@app/api/models/User';
import {createBuilder} from '@app/api/test/TestRequestBuilder';
import {AdminACLs} from '@fluxer/constants/src/AdminACLs';
import {DeletionReasons} from '@fluxer/constants/src/Core';
import {PremiumFlags, SuspiciousActivityFlags, UserFlags} from '@fluxer/constants/src/UserConstants';
import {PremiumFlags, UserFlags} from '@fluxer/constants/src/UserConstants';
import {expect} from 'vitest';
async function loadUser(account: TestAccount): Promise<User> {
@@ -533,34 +533,6 @@ export const UserWriteAdminAuditCases: ReadonlyArray<AdminAuditCoverageCase> = [
};
},
},
{
method: 'PUT',
route: '/admin/users/:user_id/phone-verification',
async prepare(context) {
const target = await createTestAccount(context.harness);
const flags = SuspiciousActivityFlags.REQUIRE_VERIFIED_EMAIL | SuspiciousActivityFlags.REQUIRE_VERIFIED_PHONE;
await adminBuilder(context)
.put(`/admin/users/${target.userId}/suspicious-activity-flags`)
.body({flags})
.execute();
return {
request: {
path: `/admin/users/${target.userId}/phone-verification`,
body: {has_verified_phone: true},
},
expected: {
action: 'update_has_verified_phone',
targetType: 'user',
targetId: target.userId,
metadata: {
has_verified_phone: 'true',
suspicious_activity_flags_before: flags.toString(),
suspicious_activity_flags_after: SuspiciousActivityFlags.REQUIRE_VERIFIED_EMAIL.toString(),
},
},
};
},
},
{
method: 'PATCH',
route: '/admin/users/:user_id/date-of-birth',
@@ -577,38 +549,4 @@ export const UserWriteAdminAuditCases: ReadonlyArray<AdminAuditCoverageCase> = [
};
},
},
{
method: 'PUT',
route: '/admin/users/:user_id/suspicious-activity-flags',
async prepare({harness}) {
const target = await createTestAccount(harness);
const flags = SuspiciousActivityFlags.REQUIRE_VERIFIED_EMAIL | SuspiciousActivityFlags.REQUIRE_VERIFIED_PHONE;
return {
request: {path: `/admin/users/${target.userId}/suspicious-activity-flags`, body: {flags}},
expected: {
action: 'update_suspicious_activity_flags',
targetType: 'user',
targetId: target.userId,
metadata: {flags: flags.toString()},
},
};
},
},
{
method: 'PUT',
route: '/admin/users/:user_id/suspicious-activity-disablement',
async prepare({harness}) {
const target = await createTestAccount(harness);
const flags = SuspiciousActivityFlags.REQUIRE_VERIFIED_PHONE;
return {
request: {path: `/admin/users/${target.userId}/suspicious-activity-disablement`, body: {flags}},
expected: {
action: 'disable_suspicious_activity',
targetType: 'user',
targetId: target.userId,
metadata: {flags: flags.toString(), notify_user: 'true', notification_sent: 'true'},
},
};
},
},
];