mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-18 21:45:31 +09:00
refactor(api): emit moderation events and apply account actions (#3031)
This commit is contained in:
1 parent
c488906131
commit
364084c819
385 files changed
+8994
-22221
No files matched your search
@@ -0,0 +1,158 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {emitActivity} from '@app/api/infrastructure/activity/ActivityEvents';
|
||||
import {workerMeta} from '@app/api/infrastructure/activity/ActivityMeta';
|
||||
import {
|
||||
ACTION_PARTITIONS,
|
||||
ACTIONS_STREAM,
|
||||
type ActionEnvelope,
|
||||
type ActionOutcome,
|
||||
CONTRACT_VERSION,
|
||||
effectsConsumer,
|
||||
MAX_ACTION_ATTEMPTS,
|
||||
} from '@app/api/infrastructure/activity/Contract.generated';
|
||||
import {Logger} from '@app/api/Logger';
|
||||
import {
|
||||
type AccountStateDeps,
|
||||
applyPhoneVerified,
|
||||
applySpammer,
|
||||
applySuspiciousFlags,
|
||||
applyTempBanIp,
|
||||
outcomeOf,
|
||||
} from '@app/api/user/services/AccountStateApplier';
|
||||
import {AckPolicy, type ConsumerMessages, type JetStreamClient, type JsMsg} from '@nats-io/jetstream';
|
||||
|
||||
export interface AccountActionDeps {
|
||||
js: Pick<JetStreamClient, 'consumers'>;
|
||||
state: AccountStateDeps;
|
||||
publishOutcome?: (key: string, outcome: ActionOutcome) => Promise<void>;
|
||||
now?: () => number;
|
||||
retryDelayMs?: number;
|
||||
}
|
||||
|
||||
const RETRY_DELAY_MS = 5000;
|
||||
const MAX_NAK_DELAY_MS = 30_000;
|
||||
|
||||
interface ConsumerState {
|
||||
stopped: boolean;
|
||||
loops: Array<ConsumerMessages>;
|
||||
tasks: Array<Promise<void>>;
|
||||
}
|
||||
|
||||
let running: ConsumerState | null = null;
|
||||
|
||||
export function decodeActionEnvelope(data: Uint8Array | string): ActionEnvelope | null {
|
||||
try {
|
||||
const value = JSON.parse(typeof data === 'string' ? data : new TextDecoder().decode(data)) as unknown;
|
||||
if (
|
||||
typeof value === 'object' &&
|
||||
value !== null &&
|
||||
typeof (value as ActionEnvelope).id === 'string' &&
|
||||
typeof (value as ActionEnvelope).key === 'string' &&
|
||||
typeof (value as ActionEnvelope).type === 'string' &&
|
||||
typeof (value as ActionEnvelope).v === 'number' &&
|
||||
typeof (value as ActionEnvelope).expires_at_ms === 'number'
|
||||
) {
|
||||
return value as ActionEnvelope;
|
||||
}
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function applyAction(deps: AccountActionDeps, env: ActionEnvelope): Promise<ActionOutcome> {
|
||||
if (env.v !== CONTRACT_VERSION) return Promise.resolve(outcomeOf(env, 'unsupported'));
|
||||
if (env.expires_at_ms <= (deps.now?.() ?? Date.now())) return Promise.resolve(outcomeOf(env, 'expired'));
|
||||
switch (env.type) {
|
||||
case 'set_suspicious_flags':
|
||||
return applySuspiciousFlags(deps.state, env);
|
||||
case 'set_spammer':
|
||||
return applySpammer(deps.state, env);
|
||||
case 'phone_verified':
|
||||
return applyPhoneVerified(deps.state, env);
|
||||
case 'temp_ban_ip':
|
||||
return applyTempBanIp(deps.state, env);
|
||||
default:
|
||||
return Promise.resolve(outcomeOf(env as ActionEnvelope, 'unsupported'));
|
||||
}
|
||||
}
|
||||
|
||||
async function publishOutcome(deps: AccountActionDeps, key: string, outcome: ActionOutcome): Promise<void> {
|
||||
if (deps.publishOutcome) {
|
||||
await deps.publishOutcome(key, outcome);
|
||||
return;
|
||||
}
|
||||
await emitActivity('action_outcome', key, outcome, workerMeta(), outcome.action_id);
|
||||
}
|
||||
|
||||
export async function handleActionMessage(deps: AccountActionDeps, msg: JsMsg): Promise<void> {
|
||||
const env = decodeActionEnvelope(msg.data);
|
||||
if (!env) {
|
||||
msg.term('undecodable action envelope');
|
||||
return;
|
||||
}
|
||||
let outcome: ActionOutcome;
|
||||
try {
|
||||
outcome = await applyAction(deps, env);
|
||||
} catch (error) {
|
||||
if (msg.info.deliveryCount < MAX_ACTION_ATTEMPTS) {
|
||||
Logger.warn({err: error, actionId: env.id, attempt: msg.info.deliveryCount}, 'Account action apply failed');
|
||||
msg.nak(Math.min(MAX_NAK_DELAY_MS, 1000 * msg.info.deliveryCount));
|
||||
return;
|
||||
}
|
||||
Logger.error({err: error, actionId: env.id}, 'Account action failed after all attempts');
|
||||
outcome = outcomeOf(env, 'failed', null, String(error).slice(0, 500));
|
||||
}
|
||||
await publishOutcome(deps, env.key, outcome);
|
||||
await msg.ackAck();
|
||||
}
|
||||
|
||||
async function runActionPartition(deps: AccountActionDeps, partition: number, state: ConsumerState): Promise<void> {
|
||||
const retryDelayMs = deps.retryDelayMs ?? RETRY_DELAY_MS;
|
||||
while (!state.stopped) {
|
||||
try {
|
||||
const consumer = await deps.js.consumers.get(ACTIONS_STREAM, effectsConsumer(partition));
|
||||
const info = await consumer.info(true);
|
||||
if (info.config.ack_policy !== AckPolicy.Explicit) {
|
||||
throw new Error(`Consumer ${effectsConsumer(partition)} must use explicit acks`);
|
||||
}
|
||||
const messages = await consumer.consume({max_messages: 1});
|
||||
state.loops.push(messages);
|
||||
if (state.stopped) {
|
||||
await messages.close();
|
||||
return;
|
||||
}
|
||||
for await (const msg of messages) {
|
||||
try {
|
||||
await handleActionMessage(deps, msg);
|
||||
} catch (error) {
|
||||
Logger.warn({err: error, partition}, 'Account action outcome could not be recorded');
|
||||
msg.nak(retryDelayMs);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (state.stopped) return;
|
||||
Logger.debug({err: error, partition}, 'Account action consumer unavailable, retrying');
|
||||
}
|
||||
if (!state.stopped) await new Promise((resolve) => setTimeout(resolve, retryDelayMs).unref?.());
|
||||
}
|
||||
}
|
||||
|
||||
export function startAccountActionConsumer(deps: AccountActionDeps): void {
|
||||
if (running) return;
|
||||
const state: ConsumerState = {stopped: false, loops: [], tasks: []};
|
||||
running = state;
|
||||
for (let p = 0; p < ACTION_PARTITIONS; p++) {
|
||||
state.tasks.push(runActionPartition(deps, p, state));
|
||||
}
|
||||
}
|
||||
|
||||
export async function stopAccountActionConsumer(): Promise<void> {
|
||||
const state = running;
|
||||
if (!state) return;
|
||||
running = null;
|
||||
state.stopped = true;
|
||||
await Promise.allSettled(state.loops.map((messages) => messages.close()));
|
||||
await Promise.allSettled(state.tasks);
|
||||
}
|
||||
@@ -10,11 +10,7 @@ import {ms} from 'itty-time';
|
||||
const INITIAL_SYNC_KEY = 'sync:email_domains:initialized';
|
||||
const PURGE_KEY = 'sync:blocklist_feeds:purged';
|
||||
const CLAIM_TTL_SECONDS = ms('6 hours') / 1000;
|
||||
const FEED_TASKS = [
|
||||
'syncDisposableEmailDomains',
|
||||
'syncUrlBlocklists',
|
||||
'syncFileShaBlocklists',
|
||||
] as const satisfies ReadonlyArray<WorkerTaskName>;
|
||||
const FEED_TASKS = ['syncUrlBlocklists', 'syncFileShaBlocklists'] as const satisfies ReadonlyArray<WorkerTaskName>;
|
||||
|
||||
export async function queueBlocklistFeedStartupJobs(
|
||||
kvClient: Pick<IKVProvider, 'setnx' | 'del'>,
|
||||
@@ -22,10 +18,7 @@ export async function queueBlocklistFeedStartupJobs(
|
||||
enabled: boolean,
|
||||
): Promise<void> {
|
||||
if (enabled) {
|
||||
if (await kvClient.setnx(INITIAL_SYNC_KEY, '1', CLAIM_TTL_SECONDS)) {
|
||||
Logger.info('Triggering initial disposable email domain sync');
|
||||
await queueJobs(workerService, ['syncDisposableEmailDomains']);
|
||||
}
|
||||
await kvClient.setnx(INITIAL_SYNC_KEY, '1', CLAIM_TTL_SECONDS);
|
||||
return;
|
||||
}
|
||||
const wasEnabled = (await kvClient.del(INITIAL_SYNC_KEY)) > 0;
|
||||
|
||||
@@ -56,6 +56,7 @@ const LANE_CONFIG = {
|
||||
'bulkAddGuildMembers',
|
||||
'bulkBanFileShas',
|
||||
'bulkDeleteMessagesForUsers',
|
||||
'drainActivitySpool',
|
||||
] as const,
|
||||
retiredTasks: ['sendScheduledMessage'],
|
||||
concurrency: 8,
|
||||
@@ -79,7 +80,6 @@ const LANE_CONFIG = {
|
||||
'prunePostgresKvTtl',
|
||||
'refreshSearchIndex',
|
||||
'syncDiscoveryIndex',
|
||||
'syncDisposableEmailDomains',
|
||||
'syncUrlBlocklists',
|
||||
'syncFileShaBlocklists',
|
||||
'flushUserActivityBuffer',
|
||||
|
||||
@@ -1,8 +1,16 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {Config} from '@app/api/Config';
|
||||
import {createApiContext} from '@app/api/CreateApiContext';
|
||||
import {setDatabaseQueryExecutor} from '@app/api/database/CassandraQueryExecution';
|
||||
import {ensurePostgresKvSchema, PostgresKvQueryExecutor} from '@app/api/database/PostgresKvQueryExecutor';
|
||||
import {
|
||||
jetStreamActivityPublisher,
|
||||
shutdownActivityEvents,
|
||||
startActivityEvents,
|
||||
} from '@app/api/infrastructure/activity/ActivityEvents';
|
||||
import {setActivityProcessChannel} from '@app/api/infrastructure/activity/ActivityMeta';
|
||||
import {startSharedListWatch, stopSharedListWatch} from '@app/api/infrastructure/activity/SharedLists';
|
||||
import type {ISnowflakeService} from '@app/api/infrastructure/ISnowflakeService';
|
||||
import {shutdownStorageChangeFeed} from '@app/api/infrastructure/StorageServiceFactory';
|
||||
import type {InstanceConfigRepository} from '@app/api/instance/InstanceConfigRepository';
|
||||
@@ -17,12 +25,15 @@ import {
|
||||
shutdownVoiceResources,
|
||||
} from '@app/api/middleware/ServiceRegistry';
|
||||
import {
|
||||
getAdminRepository,
|
||||
getCacheService,
|
||||
getInstanceConfigRepository,
|
||||
getLimitConfigService,
|
||||
} from '@app/api/middleware/ServiceSingletons';
|
||||
import {initializeSearch, shutdownSearch} from '@app/api/SearchFactory';
|
||||
import {accountStateDepsFromContext} from '@app/api/user/services/AccountStateApplier';
|
||||
import {awaitAll} from '@app/api/utils/ConcurrencyUtils';
|
||||
import {startAccountActionConsumer, stopAccountActionConsumer} from '@app/api/worker/AccountActionConsumer';
|
||||
import {queueBlocklistFeedStartupJobs} from '@app/api/worker/BlocklistFeedStartup';
|
||||
import {CronScheduler} from '@app/api/worker/CronScheduler';
|
||||
import {JetStreamWorkerQueue, JOBS_STREAM_MAX_AGE_MS} from '@app/api/worker/JetStreamWorkerQueue';
|
||||
@@ -70,11 +81,11 @@ function registerCronJobs(cron: CronScheduler, jobsStreamMaxAgeMs: number): void
|
||||
cron.upsert('prunePostgresKvTtl', 'prunePostgresKvTtl', {}, '0 */5 * * * *', {ledger: false});
|
||||
cron.upsert('syncDiscoveryIndex', 'syncDiscoveryIndex', {}, '0 */15 * * * *', {ledger: false});
|
||||
if (Config.blocklistFeeds.enabled) {
|
||||
cron.upsert('syncDisposableEmailDomains', 'syncDisposableEmailDomains', {}, '0 0 */6 * * *', {ledger: true});
|
||||
cron.upsert('syncUrlBlocklists', 'syncUrlBlocklists', {}, '0 0 */6 * * *', {ledger: true});
|
||||
cron.upsert('syncFileShaBlocklists', 'syncFileShaBlocklists', {}, '0 0 */12 * * *', {ledger: true});
|
||||
}
|
||||
cron.upsert('flushUserActivityBuffer', 'flushUserActivityBuffer', {}, '*/10 * * * * *', {ledger: false});
|
||||
cron.upsert('drainActivitySpool', 'drainActivitySpool', {}, '*/5 * * * * *', {ledger: false});
|
||||
Logger.info(
|
||||
{
|
||||
blocklistFeeds: Config.blocklistFeeds.enabled,
|
||||
@@ -113,6 +124,7 @@ export async function startWorkerMain(): Promise<void> {
|
||||
const voiceShutdown = cleanupStep('voice resources', shutdownVoiceResources);
|
||||
await cleanupStep('heartbeat', () => heartbeat.stop());
|
||||
await cleanupStep('cron', () => cron?.stop());
|
||||
await cleanupStep('account actions', stopAccountActionConsumer);
|
||||
await cleanupStep('runners', async () => {
|
||||
await awaitAll(
|
||||
runners.map((runner) => runner.stop()),
|
||||
@@ -121,6 +133,8 @@ export async function startWorkerMain(): Promise<void> {
|
||||
});
|
||||
await voiceShutdown;
|
||||
await cleanupStep('storage change feed', shutdownStorageChangeFeed);
|
||||
await cleanupStep('shared lists', stopSharedListWatch);
|
||||
await cleanupStep('activity events', () => shutdownActivityEvents());
|
||||
await cleanupStep('jetstream', async () => {
|
||||
await jsConnectionManager?.drain();
|
||||
jsConnectionManager = null;
|
||||
@@ -246,6 +260,21 @@ export async function startWorkerMain(): Promise<void> {
|
||||
dependencies = await initializeWorkerDependencies(snowflakeService);
|
||||
setWorkerDependencies(dependencies);
|
||||
await queueBlocklistFeedStartupJobs(dependencies.kvClient, workerService, Config.blocklistFeeds.enabled);
|
||||
setActivityProcessChannel('worker');
|
||||
await startActivityEvents({
|
||||
publisher: jetStreamActivityPublisher(jsConnectionManager.getJetStreamClient()),
|
||||
kv: dependencies.kvClient,
|
||||
jsm: await jsConnectionManager.getJetStreamManager(),
|
||||
spoolWhileMissing: !Config.instance.selfHosted,
|
||||
});
|
||||
startSharedListWatch(jsConnectionManager.getJetStreamClient());
|
||||
if (activeWorkerLanes.some((lane) => lane.name === 'lifecycle')) {
|
||||
startAccountActionConsumer({
|
||||
js: jsConnectionManager.getJetStreamClient(),
|
||||
state: accountStateDepsFromContext(createApiContext(), getAdminRepository()),
|
||||
});
|
||||
Logger.info('Account action consumer started');
|
||||
}
|
||||
cron = new CronScheduler(workerService, Logger, dependencies.kvClient, heartbeat);
|
||||
registerCronJobs(cron, queue.getJobsStreamMaxAgeMs());
|
||||
for (const lane of activeWorkerLanes) {
|
||||
|
||||
@@ -13,6 +13,7 @@ import bulkDeleteSelfMessagesImmediate from '@app/api/worker/tasks/BulkDeleteSel
|
||||
import bulkDeleteUserMessages from '@app/api/worker/tasks/BulkDeleteUserMessages';
|
||||
import bulkDeleteUserMessagesScoped from '@app/api/worker/tasks/BulkDeleteUserMessagesScoped';
|
||||
import deleteUserMessagesInGuildByTime from '@app/api/worker/tasks/DeleteUserMessagesInGuildByTime';
|
||||
import drainActivitySpool from '@app/api/worker/tasks/DrainActivitySpool';
|
||||
import expireAttachments from '@app/api/worker/tasks/ExpireAttachments';
|
||||
import expireStaleJobs from '@app/api/worker/tasks/ExpireStaleJobs';
|
||||
import extractEmbeds from '@app/api/worker/tasks/ExtractEmbeds';
|
||||
@@ -37,7 +38,6 @@ import reconcileUserPayments from '@app/api/worker/tasks/ReconcileUserPayments';
|
||||
import refreshSearchIndex from '@app/api/worker/tasks/RefreshSearchIndex';
|
||||
import {sendSystemDm} from '@app/api/worker/tasks/SendSystemDm';
|
||||
import syncDiscoveryIndex from '@app/api/worker/tasks/SyncDiscoveryIndex';
|
||||
import syncDisposableEmailDomains from '@app/api/worker/tasks/SyncDisposableEmailDomains';
|
||||
import syncFileShaBlocklists from '@app/api/worker/tasks/SyncFileShaBlocklists';
|
||||
import syncUrlBlocklists from '@app/api/worker/tasks/SyncUrlBlocklists';
|
||||
import userProcessPendingDeletion from '@app/api/worker/tasks/UserProcessPendingDeletion';
|
||||
@@ -59,6 +59,7 @@ export const workerTasks: Record<WorkerTaskName, WorkerTaskHandler> = {
|
||||
bulkUpdateSuspiciousActivityFlags: bulkUpdateSuspiciousActivityFlags,
|
||||
bulkUpdateUserFlags: bulkUpdateUserFlags,
|
||||
deleteUserMessagesInGuildByTime,
|
||||
drainActivitySpool,
|
||||
expireAttachments,
|
||||
expireStaleJobs,
|
||||
extractEmbeds,
|
||||
@@ -84,7 +85,6 @@ export const workerTasks: Record<WorkerTaskName, WorkerTaskHandler> = {
|
||||
syncFileShaBlocklists,
|
||||
syncUrlBlocklists,
|
||||
syncDiscoveryIndex,
|
||||
syncDisposableEmailDomains,
|
||||
flushUserActivityBuffer,
|
||||
userProcessPendingDeletion,
|
||||
userProcessPendingDeletions,
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {randomUUID} from 'node:crypto';
|
||||
import {drainActivitySpoolNow} from '@app/api/infrastructure/activity/ActivityEvents';
|
||||
import {getWorkerDependencies} from '@app/api/worker/WorkerContext';
|
||||
import type {WorkerTaskHandler} from '@pkgs/worker/src/contracts/WorkerTask';
|
||||
|
||||
const DRAIN_LOCK_KEY = 'activity:spool:drain';
|
||||
const DRAIN_LOCK_TTL_SECONDS = 30;
|
||||
|
||||
const drainActivitySpool: WorkerTaskHandler = async (_payload, helpers) => {
|
||||
const {kvClient} = getWorkerDependencies();
|
||||
const token = randomUUID();
|
||||
if (!(await kvClient.acquireLock(DRAIN_LOCK_KEY, token, DRAIN_LOCK_TTL_SECONDS))) return;
|
||||
try {
|
||||
const result = await drainActivitySpoolNow({
|
||||
keepLock: () => kvClient.extendLock(DRAIN_LOCK_KEY, token, DRAIN_LOCK_TTL_SECONDS),
|
||||
});
|
||||
if (!result) return;
|
||||
if (result.published + result.expired + result.failed > 0 || result.error) {
|
||||
helpers.logger.info(
|
||||
{
|
||||
published: result.published,
|
||||
expired: result.expired,
|
||||
failed: result.failed,
|
||||
remaining: result.remaining,
|
||||
err: result.error ?? undefined,
|
||||
},
|
||||
'Drained activity spool',
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
await kvClient.releaseLock(DRAIN_LOCK_KEY, token);
|
||||
}
|
||||
};
|
||||
|
||||
export default drainActivitySpool;
|
||||
@@ -1,7 +1,10 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {revokeAllAuthSessions} from '@app/api/auth/AuthSessionRevocation';
|
||||
import type {UserID} from '@app/api/BrandedTypes';
|
||||
import {Config} from '@app/api/Config';
|
||||
import {SYSTEM_USER_ID} from '@app/api/constants/Core';
|
||||
import type {IGatewayService} from '@app/api/infrastructure/IGatewayService';
|
||||
import type {KVAccountDeletionQueueService} from '@app/api/infrastructure/KVAccountDeletionQueueService';
|
||||
import type {KVActivityTracker} from '@app/api/infrastructure/KVActivityTracker';
|
||||
import {Logger} from '@app/api/Logger';
|
||||
@@ -31,6 +34,7 @@ interface InactivityCheckResult {
|
||||
async function scheduleDeletion(
|
||||
userRepository: UserRepository,
|
||||
deletionQueueService: KVAccountDeletionQueueService,
|
||||
gatewayService: IGatewayService,
|
||||
user: User,
|
||||
userId: UserID,
|
||||
): Promise<void> {
|
||||
@@ -40,6 +44,8 @@ async function scheduleDeletion(
|
||||
flags: user.flags | UserFlags.SELF_DELETED,
|
||||
pending_deletion_at: pendingDeletionAt,
|
||||
deletion_reason_code: DeletionReasons.INACTIVITY,
|
||||
deletion_scheduled_by: SYSTEM_USER_ID,
|
||||
deletion_scheduled_at: new Date(),
|
||||
});
|
||||
await reschedulePendingDeletion({
|
||||
userId,
|
||||
@@ -49,12 +55,14 @@ async function scheduleDeletion(
|
||||
userRepository,
|
||||
deletionQueue: deletionQueueService,
|
||||
});
|
||||
await revokeAllAuthSessions({users: userRepository, gateway: gatewayService}, userId);
|
||||
Logger.debug({userId, pendingDeletionAt, reason: 'INACTIVITY'}, 'Scheduled inactive user for deletion');
|
||||
}
|
||||
|
||||
interface ProcessUserDeps {
|
||||
userRepository: UserRepository;
|
||||
deletionQueueService: KVAccountDeletionQueueService;
|
||||
gatewayService: IGatewayService;
|
||||
emailService: IEmailService;
|
||||
isEmailEnabled: () => Promise<boolean>;
|
||||
activityTracker: KVActivityTracker;
|
||||
@@ -82,6 +90,7 @@ async function processUser(
|
||||
const {
|
||||
userRepository,
|
||||
deletionQueueService,
|
||||
gatewayService,
|
||||
emailService,
|
||||
isEmailEnabled,
|
||||
activityTracker,
|
||||
@@ -118,7 +127,7 @@ async function processUser(
|
||||
const hasGracePeriodExpired = await deletionEligibilityService.hasWarningGracePeriodExpired(userId);
|
||||
if (hasGracePeriodExpired) {
|
||||
Logger.debug({userId}, 'Warning grace period expired, scheduling deletion');
|
||||
await scheduleDeletion(userRepository, deletionQueueService, user, userId);
|
||||
await scheduleDeletion(userRepository, deletionQueueService, gatewayService, user, userId);
|
||||
result.deletionsScheduled++;
|
||||
} else {
|
||||
Logger.debug({userId}, 'Warning grace period still active, skipping (idempotency check)');
|
||||
@@ -155,6 +164,7 @@ interface ProcessInactivityDeletionsDeps {
|
||||
kvClient: IKVProvider;
|
||||
userRepository: UserRepository;
|
||||
deletionQueueService: KVAccountDeletionQueueService;
|
||||
gatewayService: IGatewayService;
|
||||
emailService: IEmailService;
|
||||
isEmailEnabled: () => Promise<boolean>;
|
||||
activityTracker: KVActivityTracker;
|
||||
@@ -167,6 +177,7 @@ export async function processInactivityDeletionsCore(
|
||||
const {
|
||||
userRepository,
|
||||
deletionQueueService,
|
||||
gatewayService,
|
||||
emailService,
|
||||
isEmailEnabled,
|
||||
activityTracker,
|
||||
@@ -186,6 +197,7 @@ export async function processInactivityDeletionsCore(
|
||||
const userDeps: ProcessUserDeps = {
|
||||
userRepository,
|
||||
deletionQueueService,
|
||||
gatewayService,
|
||||
emailService,
|
||||
isEmailEnabled,
|
||||
activityTracker,
|
||||
@@ -233,6 +245,7 @@ const processInactivityDeletions: WorkerTaskHandler = async (_payload, helpers)
|
||||
kvClient,
|
||||
userRepository,
|
||||
deletionQueueService,
|
||||
gatewayService,
|
||||
emailService,
|
||||
instanceConfigRepository,
|
||||
activityTracker,
|
||||
@@ -242,6 +255,7 @@ const processInactivityDeletions: WorkerTaskHandler = async (_payload, helpers)
|
||||
kvClient,
|
||||
userRepository,
|
||||
deletionQueueService,
|
||||
gatewayService,
|
||||
emailService,
|
||||
isEmailEnabled: () => instanceConfigRepository.isEmailEnabled(),
|
||||
activityTracker,
|
||||
|
||||
@@ -1,208 +0,0 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {domainToASCII} from 'node:url';
|
||||
import {Config} from '@app/api/Config';
|
||||
import {isAccountPolicyContactDomainReputationExempt} from '@app/api/risk/AccountPolicyService';
|
||||
import {EXTERNAL_RESPONSE_LIMITS} from '@app/api/utils/ExternalResponseLimits';
|
||||
import * as FetchUtils from '@app/api/utils/FetchUtils';
|
||||
import {getWorkerDependencies} from '@app/api/worker/WorkerContext';
|
||||
import {JobCancelledError, type WorkerTaskHandler, type WorkerTaskHelpers} from '@pkgs/worker/src/contracts/WorkerTask';
|
||||
|
||||
const SOURCES = [
|
||||
'https://raw.githubusercontent.com/doodad-labs/disposable-email-domains/main/data/domains.txt',
|
||||
'https://raw.githubusercontent.com/disposable-email-domains/disposable-email-domains/main/disposable_email_blocklist.conf',
|
||||
'https://raw.githubusercontent.com/disposable/disposable-email-domains/master/domains.txt',
|
||||
'https://raw.githubusercontent.com/micke/valid_email2/main/config/disposable_email_domains.txt',
|
||||
'https://raw.githubusercontent.com/amieiro/disposable-email-domains/master/denyDomains.txt',
|
||||
'https://raw.githubusercontent.com/ivolo/disposable-email-domains/master/index.json',
|
||||
'https://raw.githubusercontent.com/7c/fakefilter/main/txt/data.txt',
|
||||
'https://raw.githubusercontent.com/FGRibreau/mailchecker/master/list.txt',
|
||||
'https://raw.githubusercontent.com/wesbos/burner-email-providers/master/emails.txt',
|
||||
'https://raw.githubusercontent.com/unkn0w/disposable-email-domain-list/main/domains.txt',
|
||||
'https://raw.githubusercontent.com/vrittech/disposable-email/main/disposable_domains.txt',
|
||||
'https://raw.githubusercontent.com/martenson/disposable-email-domains/master/disposable_email_blocklist.conf',
|
||||
];
|
||||
const WRITE_PROGRESS_INTERVAL = 500;
|
||||
const DOMAIN_REGEX = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$/;
|
||||
|
||||
interface SourceFetchResult {
|
||||
contentType: string;
|
||||
responseText: string;
|
||||
}
|
||||
|
||||
interface SourceCollectResult {
|
||||
accepted: number;
|
||||
raw: number;
|
||||
}
|
||||
|
||||
async function fetchSource(url: string): Promise<SourceFetchResult> {
|
||||
const res = await fetch(url, {signal: AbortSignal.timeout(60000)});
|
||||
if (!res.ok) {
|
||||
FetchUtils.discardResponseBody(res.body, res.status);
|
||||
throw new Error(`HTTP ${res.status} fetching ${url}`);
|
||||
}
|
||||
const contentType = res.headers.get('content-type') ?? '';
|
||||
const responseText = await FetchUtils.streamToStringWithLimit(res.body, {
|
||||
maxBytes: EXTERNAL_RESPONSE_LIMITS.disposableEmailBytes,
|
||||
headers: res.headers,
|
||||
url: res.url,
|
||||
description: 'Disposable email domain feed',
|
||||
});
|
||||
return {contentType, responseText};
|
||||
}
|
||||
|
||||
function collectDomain(raw: string, freshSet: Set<string>): boolean {
|
||||
const domain = normaliseDomain(raw);
|
||||
if (!domain) return false;
|
||||
if (isAccountPolicyContactDomainReputationExempt(domain)) return false;
|
||||
if (freshSet.has(domain)) return false;
|
||||
freshSet.add(domain);
|
||||
return true;
|
||||
}
|
||||
|
||||
function forEachTextLine(text: string, callback: (line: string) => void): number {
|
||||
let raw = 0;
|
||||
let start = 0;
|
||||
for (let i = 0; i <= text.length; i++) {
|
||||
const isEnd = i === text.length;
|
||||
if (!isEnd && text.charCodeAt(i) !== 10) continue;
|
||||
const line = text.slice(start, i).trim();
|
||||
start = i + 1;
|
||||
if (!line || line.startsWith('#')) continue;
|
||||
raw++;
|
||||
callback(line);
|
||||
}
|
||||
return raw;
|
||||
}
|
||||
|
||||
function collectTextDomains(text: string, freshSet: Set<string>): SourceCollectResult {
|
||||
let accepted = 0;
|
||||
const raw = forEachTextLine(text, (line) => {
|
||||
if (collectDomain(line, freshSet)) accepted++;
|
||||
});
|
||||
return {accepted, raw};
|
||||
}
|
||||
|
||||
function collectJsonDomains(text: string, freshSet: Set<string>): SourceCollectResult {
|
||||
const json: unknown = JSON.parse(text);
|
||||
if (!Array.isArray(json)) {
|
||||
return {accepted: 0, raw: 0};
|
||||
}
|
||||
let raw = 0;
|
||||
let accepted = 0;
|
||||
for (const value of json) {
|
||||
if (typeof value !== 'string') continue;
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) continue;
|
||||
raw++;
|
||||
if (collectDomain(trimmed, freshSet)) accepted++;
|
||||
}
|
||||
return {accepted, raw};
|
||||
}
|
||||
|
||||
async function collectSourceDomains(url: string, freshSet: Set<string>): Promise<SourceCollectResult> {
|
||||
const {contentType, responseText} = await fetchSource(url);
|
||||
if (contentType.includes('application/json') || url.endsWith('.json')) {
|
||||
return collectJsonDomains(responseText, freshSet);
|
||||
}
|
||||
return collectTextDomains(responseText, freshSet);
|
||||
}
|
||||
|
||||
function normaliseDomain(raw: string): string | null {
|
||||
let domain = raw.trim().toLowerCase();
|
||||
if (!domain) return null;
|
||||
if (domain.startsWith('*.')) domain = domain.slice(2);
|
||||
if (domain.startsWith('.')) domain = domain.slice(1);
|
||||
if (!domain || domain.length > 253) return null;
|
||||
if (/[\s@/\\]/.test(domain)) return null;
|
||||
const ascii = domainToASCII(domain);
|
||||
if (!ascii) return null;
|
||||
if (!DOMAIN_REGEX.test(ascii)) return null;
|
||||
return ascii;
|
||||
}
|
||||
|
||||
async function loadCurrentDisposableEmailDomains(): Promise<Set<string>> {
|
||||
const {adminRepository} = getWorkerDependencies();
|
||||
return new Set(await adminRepository.listDisposableEmailDomains());
|
||||
}
|
||||
|
||||
async function throwIfCancelled(helpers: WorkerTaskHelpers): Promise<void> {
|
||||
if (await helpers.shouldCancel()) {
|
||||
throw new JobCancelledError();
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchFeedDomains(helpers: WorkerTaskHelpers): Promise<Set<string>> {
|
||||
const freshSet = new Set<string>();
|
||||
const perSourceCounts: Record<string, number> = {};
|
||||
const perSourceRawCounts: Record<string, number> = {};
|
||||
let successCount = 0;
|
||||
for (const url of SOURCES) {
|
||||
try {
|
||||
const result = await collectSourceDomains(url, freshSet);
|
||||
successCount++;
|
||||
perSourceCounts[url] = result.accepted;
|
||||
perSourceRawCounts[url] = result.raw;
|
||||
} catch (error) {
|
||||
helpers.logger.warn({url, error}, 'Failed to fetch disposable email source');
|
||||
perSourceCounts[url] = 0;
|
||||
perSourceRawCounts[url] = 0;
|
||||
}
|
||||
await throwIfCancelled(helpers);
|
||||
}
|
||||
if (successCount === 0) {
|
||||
throw new Error('All disposable email sources failed; skipping sync (no delta applied)');
|
||||
}
|
||||
helpers.logger.info(
|
||||
{
|
||||
count: freshSet.size,
|
||||
successfulSources: successCount,
|
||||
perSource: perSourceCounts,
|
||||
perSourceRaw: perSourceRawCounts,
|
||||
},
|
||||
'Fetched disposable email domains from all sources',
|
||||
);
|
||||
return freshSet;
|
||||
}
|
||||
|
||||
const syncDisposableEmailDomains: WorkerTaskHandler = async (_payload, helpers) => {
|
||||
helpers.logger.info('Starting disposable email domain sync');
|
||||
await helpers.setContextLink('/suspicious-email-domains');
|
||||
const {adminRepository} = getWorkerDependencies();
|
||||
const freshSet = Config.blocklistFeeds.enabled ? await fetchFeedDomains(helpers) : new Set<string>();
|
||||
const currentSet = await loadCurrentDisposableEmailDomains();
|
||||
let addCount = 0;
|
||||
for (const domain of freshSet) {
|
||||
if (!currentSet.has(domain)) addCount++;
|
||||
}
|
||||
let removeCount = 0;
|
||||
for (const domain of currentSet) {
|
||||
if (!freshSet.has(domain)) removeCount++;
|
||||
}
|
||||
const totalOps = addCount + removeCount;
|
||||
let added = 0;
|
||||
let removed = 0;
|
||||
await helpers.reportProgress(0, totalOps, `Applying ${addCount} adds and ${removeCount} removes`);
|
||||
for (const domain of freshSet) {
|
||||
if (currentSet.has(domain)) continue;
|
||||
await adminRepository.addDisposableEmailDomain(domain);
|
||||
added++;
|
||||
if (added % WRITE_PROGRESS_INTERVAL === 0) {
|
||||
await helpers.reportProgress(added + removed, totalOps, null);
|
||||
await throwIfCancelled(helpers);
|
||||
}
|
||||
}
|
||||
for (const domain of currentSet) {
|
||||
if (freshSet.has(domain)) continue;
|
||||
await adminRepository.removeDisposableEmailDomain(domain);
|
||||
removed++;
|
||||
if (removed % WRITE_PROGRESS_INTERVAL === 0) {
|
||||
await helpers.reportProgress(added + removed, totalOps, null);
|
||||
await throwIfCancelled(helpers);
|
||||
}
|
||||
}
|
||||
await helpers.reportProgress(totalOps, totalOps, `+${added} added, -${removed} removed`);
|
||||
helpers.logger.info({added, removed, total: freshSet.size}, 'Disposable email domain sync complete');
|
||||
};
|
||||
|
||||
export default syncDisposableEmailDomains;
|
||||
@@ -1,8 +1,8 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {BLOCKLIST_S3_KEYS, deleteBlocklistS3Object, writeLinesToS3} from '@app/api/blocklist/BlocklistS3';
|
||||
import {Config} from '@app/api/Config';
|
||||
import {BANNED_URLS_REFRESH_CHANNEL} from '@app/api/constants/ContentModeration';
|
||||
import {deleteRiskS3Object, RISK_S3_KEYS, writeLinesToS3} from '@app/api/risk/RiskBlocklistS3';
|
||||
import {EXTERNAL_RESPONSE_LIMITS} from '@app/api/utils/ExternalResponseLimits';
|
||||
import * as FetchUtils from '@app/api/utils/FetchUtils';
|
||||
import {canonicalizeUrl} from '@app/api/utils/UrlNormalizer';
|
||||
@@ -71,7 +71,7 @@ const syncUrlBlocklists: WorkerTaskHandler = async (_payload, helpers) => {
|
||||
const {storageService, kvClient} = getWorkerDependencies();
|
||||
if (!Config.blocklistFeeds.enabled) {
|
||||
try {
|
||||
await deleteRiskS3Object(storageService, RISK_S3_KEYS.feedUrls);
|
||||
await deleteBlocklistS3Object(storageService, BLOCKLIST_S3_KEYS.feedUrls);
|
||||
} catch (error) {
|
||||
if (!(error instanceof Error && MISSING_FEED_FILE_ERRORS.has(error.name))) {
|
||||
helpers.logger.warn({error}, 'Failed to delete the URL blocklist feed file');
|
||||
@@ -105,7 +105,7 @@ const syncUrlBlocklists: WorkerTaskHandler = async (_payload, helpers) => {
|
||||
helpers.logger.warn('All feeds returned zero URLs — skipping S3 write to avoid wiping blocklist');
|
||||
return;
|
||||
}
|
||||
const count = await writeLinesToS3(storageService, RISK_S3_KEYS.feedUrls, canonicalSet);
|
||||
const count = await writeLinesToS3(storageService, BLOCKLIST_S3_KEYS.feedUrls, canonicalSet);
|
||||
await kvClient.publish(BANNED_URLS_REFRESH_CHANNEL, 'refresh');
|
||||
helpers.logger.info({urls: count}, 'URL blocklist sync complete — wrote feed file to S3');
|
||||
};
|
||||
|
||||
@@ -5,12 +5,7 @@ import {AdminBanManagementService} from '@app/api/admin/services/AdminBanManagem
|
||||
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 {getIpInfoService, getReportServiceInstance} from '@app/api/middleware/ServiceMiddleware';
|
||||
import {
|
||||
getDiscriminatorService,
|
||||
getEntityAssetService,
|
||||
@@ -34,7 +29,6 @@ export function createAdminBulkServices(deps: WorkerDependencies): AdminBulkServ
|
||||
adminRepository: deps.adminRepository,
|
||||
auditService,
|
||||
ipInfoService: getIpInfoService(),
|
||||
suspiciousIpRepository: getSuspiciousIpRepository(),
|
||||
});
|
||||
const userService = new AdminUserService({
|
||||
apiContext,
|
||||
@@ -48,7 +42,6 @@ export function createAdminBulkServices(deps: WorkerDependencies): AdminBulkServ
|
||||
kvDeletionQueue: deps.deletionQueueService,
|
||||
bulkMessageDeletionQueue: deps.bulkMessageDeletionQueueService,
|
||||
stripe: deps.stripe,
|
||||
riskHistoryRepository: getHistoricalOutcomeRepository(),
|
||||
reportService: getReportServiceInstance(),
|
||||
});
|
||||
const guildService = new AdminGuildService({
|
||||
|
||||
@@ -0,0 +1,465 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {createUserID, type UserID} from '@app/api/BrandedTypes';
|
||||
import type {UserRow} from '@app/api/database/types/UserTypes';
|
||||
import {EMPTY_USER_ROW} from '@app/api/database/types/UserTypes';
|
||||
import {
|
||||
ACTIONS_STREAM,
|
||||
type ActionEnvelope,
|
||||
type ActionOutcome,
|
||||
effectsConsumer,
|
||||
} from '@app/api/infrastructure/activity/Contract.generated';
|
||||
import {User} from '@app/api/models/User';
|
||||
import type {AccountStateDeps} from '@app/api/user/services/AccountStateApplier';
|
||||
import {
|
||||
type AccountActionDeps,
|
||||
applyAction,
|
||||
handleActionMessage,
|
||||
startAccountActionConsumer,
|
||||
stopAccountActionConsumer,
|
||||
} from '@app/api/worker/AccountActionConsumer';
|
||||
import {SuspiciousActivityFlags, UserFlags} from '@fluxer/constants/src/UserConstants';
|
||||
import {AckPolicy, DeliverPolicy, type JsMsg, jetstream, jetstreamManager} from '@nats-io/jetstream';
|
||||
import {connect} from '@nats-io/transport-node';
|
||||
import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest';
|
||||
|
||||
const USER_ID = '1174109840998400001';
|
||||
const NOW = 1_759_000_000_000;
|
||||
const NATS_URL = process.env.FLUXER_TEST_ACTIVITY_NATS_URL;
|
||||
|
||||
function envelope<T extends ActionEnvelope['type']>(
|
||||
body: Omit<Extract<ActionEnvelope, {type: T}>, 'v' | 'id' | 'key' | 'issued_at_ms' | 'expires_at_ms'> &
|
||||
Partial<ActionEnvelope>,
|
||||
): Extract<ActionEnvelope, {type: T}> {
|
||||
return {
|
||||
v: 1,
|
||||
id: 'a:07:4242:0',
|
||||
key: USER_ID,
|
||||
issued_at_ms: NOW,
|
||||
expires_at_ms: NOW + 60_000,
|
||||
...body,
|
||||
} as Extract<ActionEnvelope, {type: T}>;
|
||||
}
|
||||
|
||||
class FakeUsers {
|
||||
readonly rows = new Map<string, UserRow>();
|
||||
casFailures = 0;
|
||||
|
||||
put(overrides: Partial<UserRow> = {}): void {
|
||||
const row: UserRow = {...EMPTY_USER_ROW, user_id: createUserID(BigInt(USER_ID)), flags: 0n, ...overrides};
|
||||
this.rows.set(row.user_id.toString(), row);
|
||||
}
|
||||
|
||||
current(): User {
|
||||
return new User(this.rows.get(USER_ID)!);
|
||||
}
|
||||
|
||||
async findUnique(userId: UserID): Promise<User | null> {
|
||||
const row = this.rows.get(userId.toString());
|
||||
return row ? new User(row) : null;
|
||||
}
|
||||
|
||||
async patchUpsert(userId: UserID, patch: Partial<UserRow>): Promise<User> {
|
||||
const row = {...this.rows.get(userId.toString())!, ...patch};
|
||||
this.rows.set(userId.toString(), row);
|
||||
return new User(row);
|
||||
}
|
||||
|
||||
async compareAndSetSuspiciousFlags(user: User, flags: number): Promise<User | null> {
|
||||
const row = this.rows.get(user.id.toString())!;
|
||||
if (this.casFailures > 0) {
|
||||
this.casFailures--;
|
||||
return null;
|
||||
}
|
||||
if ((row.suspicious_activity_flags ?? 0) !== user.suspiciousActivityFlags) return null;
|
||||
const next = {...row, suspicious_activity_flags: flags};
|
||||
this.rows.set(user.id.toString(), next);
|
||||
return new User(next);
|
||||
}
|
||||
}
|
||||
|
||||
interface Harness {
|
||||
users: FakeUsers;
|
||||
presence: Array<unknown>;
|
||||
contactLogs: Array<unknown>;
|
||||
bans: Array<{ip: string; ttl: number}>;
|
||||
refreshes: number;
|
||||
deps: AccountActionDeps;
|
||||
}
|
||||
|
||||
function harness(): Harness {
|
||||
const users = new FakeUsers();
|
||||
const h: Harness = {users, presence: [], contactLogs: [], bans: [], refreshes: 0, deps: null as never};
|
||||
const state: AccountStateDeps = {
|
||||
users: users as unknown as AccountStateDeps['users'],
|
||||
dispatch: {
|
||||
userUpdated: async (user) => {
|
||||
h.presence.push(user.id);
|
||||
},
|
||||
memberProfilesUpdated: async () => {},
|
||||
},
|
||||
contactChangeLog: {
|
||||
recordDiff: async (params) => {
|
||||
h.contactLogs.push(params);
|
||||
},
|
||||
},
|
||||
ipBans: {
|
||||
isIpBanned: async () => false,
|
||||
banIpTemp: async (ip: string, ttl: number) => {
|
||||
h.bans.push({ip, ttl});
|
||||
},
|
||||
},
|
||||
cache: {
|
||||
publish: async () => {
|
||||
h.refreshes++;
|
||||
},
|
||||
} as unknown as AccountStateDeps['cache'],
|
||||
now: () => NOW,
|
||||
};
|
||||
h.deps = {js: {} as AccountActionDeps['js'], state, now: () => NOW};
|
||||
return h;
|
||||
}
|
||||
|
||||
describe('account action apply', () => {
|
||||
let h: Harness;
|
||||
beforeEach(() => {
|
||||
h = harness();
|
||||
h.users.put();
|
||||
});
|
||||
|
||||
it('sets requested suspicious bits and reports what it observed', async () => {
|
||||
const outcome = await applyAction(
|
||||
h.deps,
|
||||
envelope<'set_suspicious_flags'>({
|
||||
type: 'set_suspicious_flags',
|
||||
user_id: USER_ID,
|
||||
set: 1,
|
||||
clear: 0,
|
||||
if_current: 0,
|
||||
}),
|
||||
);
|
||||
expect(outcome).toEqual({
|
||||
action_id: 'a:07:4242:0',
|
||||
action_type: 'set_suspicious_flags',
|
||||
status: 'applied',
|
||||
detail: null,
|
||||
observed: {flags: '0', suspicious_flags: 1, has_verified_phone: false, deleted: false},
|
||||
});
|
||||
expect(h.users.current().suspiciousActivityFlags).toBe(1);
|
||||
expect(h.presence).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('is a noop when the bits already hold, so a redelivery changes nothing', async () => {
|
||||
const action = envelope<'set_suspicious_flags'>({
|
||||
type: 'set_suspicious_flags',
|
||||
user_id: USER_ID,
|
||||
set: 1,
|
||||
clear: 0,
|
||||
if_current: null,
|
||||
});
|
||||
await applyAction(h.deps, action);
|
||||
const second = await applyAction(h.deps, action);
|
||||
expect(second.status).toBe('noop');
|
||||
expect(h.presence).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('refuses a stale precondition and leaves the flags alone', async () => {
|
||||
h.users.put({suspicious_activity_flags: SuspiciousActivityFlags.REQUIRE_REVERIFIED_EMAIL});
|
||||
const outcome = await applyAction(
|
||||
h.deps,
|
||||
envelope<'set_suspicious_flags'>({
|
||||
type: 'set_suspicious_flags',
|
||||
user_id: USER_ID,
|
||||
set: 1,
|
||||
clear: 0,
|
||||
if_current: 0,
|
||||
}),
|
||||
);
|
||||
expect(outcome.status).toBe('conflict');
|
||||
expect(outcome.observed?.suspicious_flags).toBe(SuspiciousActivityFlags.REQUIRE_REVERIFIED_EMAIL);
|
||||
expect(h.users.current().suspiciousActivityFlags).toBe(SuspiciousActivityFlags.REQUIRE_REVERIFIED_EMAIL);
|
||||
});
|
||||
|
||||
it('rereads and retries when the compare and set loses a race', async () => {
|
||||
h.users.casFailures = 2;
|
||||
const outcome = await applyAction(
|
||||
h.deps,
|
||||
envelope<'set_suspicious_flags'>({
|
||||
type: 'set_suspicious_flags',
|
||||
user_id: USER_ID,
|
||||
set: 1,
|
||||
clear: 0,
|
||||
if_current: null,
|
||||
}),
|
||||
);
|
||||
expect(outcome.status).toBe('applied');
|
||||
});
|
||||
|
||||
it('treats missing, deleted and bot accounts as ineligible', async () => {
|
||||
h.users.rows.clear();
|
||||
const missing = await applyAction(
|
||||
h.deps,
|
||||
envelope<'set_spammer'>({type: 'set_spammer', user_id: USER_ID, on: true}),
|
||||
);
|
||||
expect(missing).toMatchObject({status: 'ineligible', observed: null});
|
||||
h.users.put({bot: true});
|
||||
const bot = await applyAction(h.deps, envelope<'set_spammer'>({type: 'set_spammer', user_id: USER_ID, on: true}));
|
||||
expect(bot.status).toBe('ineligible');
|
||||
h.users.put({flags: UserFlags.DELETED});
|
||||
const deleted = await applyAction(
|
||||
h.deps,
|
||||
envelope<'set_spammer'>({type: 'set_spammer', user_id: USER_ID, on: true}),
|
||||
);
|
||||
expect(deleted).toMatchObject({status: 'ineligible', observed: {deleted: true}});
|
||||
});
|
||||
|
||||
it('sets and clears the SPAMMER flag idempotently', async () => {
|
||||
const on = envelope<'set_spammer'>({type: 'set_spammer', user_id: USER_ID, on: true});
|
||||
expect((await applyAction(h.deps, on)).status).toBe('applied');
|
||||
expect(h.users.current().flags & UserFlags.SPAMMER).toBe(UserFlags.SPAMMER);
|
||||
expect((await applyAction(h.deps, on)).status).toBe('noop');
|
||||
const off = envelope<'set_spammer'>({type: 'set_spammer', user_id: USER_ID, on: false});
|
||||
expect((await applyAction(h.deps, off)).status).toBe('applied');
|
||||
expect(h.users.current().flags & UserFlags.SPAMMER).toBe(0n);
|
||||
});
|
||||
|
||||
it('attaches a verified phone once and logs the contact change once', async () => {
|
||||
h.users.put({
|
||||
flags: UserFlags.SPAMMER,
|
||||
suspicious_activity_flags:
|
||||
SuspiciousActivityFlags.REQUIRE_VERIFIED_PHONE | SuspiciousActivityFlags.REQUIRE_VERIFIED_EMAIL,
|
||||
});
|
||||
const action = envelope<'phone_verified'>({
|
||||
type: 'phone_verified',
|
||||
user_id: USER_ID,
|
||||
method: 'outbound',
|
||||
clear_suspicious: SuspiciousActivityFlags.REQUIRE_VERIFIED_PHONE,
|
||||
});
|
||||
expect((await applyAction(h.deps, action)).status).toBe('applied');
|
||||
const user = h.users.current();
|
||||
expect(user.hasVerifiedPhone).toBe(true);
|
||||
expect(user.suspiciousActivityFlags).toBe(SuspiciousActivityFlags.REQUIRE_VERIFIED_EMAIL);
|
||||
expect(user.flags & UserFlags.SPAMMER).toBe(UserFlags.SPAMMER);
|
||||
expect((await applyAction(h.deps, action)).status).toBe('noop');
|
||||
expect(h.contactLogs).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('ignores unknown envelope fields', async () => {
|
||||
h.users.put({
|
||||
flags: UserFlags.HAS_SESSION_STARTED,
|
||||
suspicious_activity_flags: SuspiciousActivityFlags.REQUIRE_VERIFIED_PHONE,
|
||||
});
|
||||
const action = {
|
||||
...envelope<'phone_verified'>({
|
||||
type: 'phone_verified',
|
||||
user_id: USER_ID,
|
||||
method: 'outbound',
|
||||
clear_suspicious: SuspiciousActivityFlags.REQUIRE_VERIFIED_PHONE,
|
||||
}),
|
||||
unknown_field: true,
|
||||
};
|
||||
expect((await applyAction(h.deps, action)).status).toBe('applied');
|
||||
const user = h.users.current();
|
||||
expect(user.hasVerifiedPhone).toBe(true);
|
||||
expect(user.suspiciousActivityFlags).toBe(0);
|
||||
expect(user.flags).toBe(UserFlags.HAS_SESSION_STARTED);
|
||||
});
|
||||
|
||||
it('bans a public address until the requested time and refreshes the ban caches', async () => {
|
||||
const outcome = await applyAction(
|
||||
h.deps,
|
||||
envelope<'temp_ban_ip'>({type: 'temp_ban_ip', ip: '93.184.216.34', until_ms: NOW + 3_600_000}),
|
||||
);
|
||||
expect(outcome.status).toBe('applied');
|
||||
expect(h.bans).toEqual([{ip: '93.184.216.34', ttl: 3600}]);
|
||||
expect(h.refreshes).toBe(1);
|
||||
});
|
||||
|
||||
it('bans an IPv6 client by its /64 same-IP key or by its address', async () => {
|
||||
const byKey = await applyAction(
|
||||
h.deps,
|
||||
envelope<'temp_ban_ip'>({type: 'temp_ban_ip', ip: '2001:4860:1:2::/64', until_ms: NOW + 3_600_000}),
|
||||
);
|
||||
expect(byKey.status).toBe('applied');
|
||||
const loose = await applyAction(
|
||||
h.deps,
|
||||
envelope<'temp_ban_ip'>({type: 'temp_ban_ip', ip: '2001:4860:1:2::/48', until_ms: NOW + 3_600_000}),
|
||||
);
|
||||
expect(loose.status).toBe('exempt');
|
||||
expect(h.bans).toEqual([{ip: '2001:4860:1:2::/64', ttl: 3600}]);
|
||||
});
|
||||
|
||||
it('never bans a private address and skips bans about to end', async () => {
|
||||
const privateIp = await applyAction(
|
||||
h.deps,
|
||||
envelope<'temp_ban_ip'>({type: 'temp_ban_ip', ip: '10.0.0.1', until_ms: NOW + 3_600_000}),
|
||||
);
|
||||
expect(privateIp.status).toBe('exempt');
|
||||
const ending = await applyAction(
|
||||
h.deps,
|
||||
envelope<'temp_ban_ip'>({type: 'temp_ban_ip', ip: '93.184.216.34', until_ms: NOW + 30_000}),
|
||||
);
|
||||
expect(ending.status).toBe('noop');
|
||||
expect(h.bans).toEqual([]);
|
||||
});
|
||||
|
||||
it('answers expired actions and unknown shapes without touching the account', async () => {
|
||||
const expired = await applyAction(
|
||||
h.deps,
|
||||
envelope<'set_spammer'>({type: 'set_spammer', user_id: USER_ID, on: true, expires_at_ms: NOW}),
|
||||
);
|
||||
expect(expired.status).toBe('expired');
|
||||
const future = await applyAction(h.deps, {
|
||||
...envelope<'set_spammer'>({type: 'set_spammer', user_id: USER_ID, on: true}),
|
||||
v: 2,
|
||||
});
|
||||
expect(future.status).toBe('unsupported');
|
||||
const unknown = await applyAction(h.deps, {
|
||||
...envelope<'set_spammer'>({type: 'set_spammer', user_id: USER_ID, on: true}),
|
||||
type: 'future_type',
|
||||
} as unknown as ActionEnvelope);
|
||||
expect(unknown).toMatchObject({status: 'unsupported', action_type: 'future_type'});
|
||||
expect(h.users.current().flags).toBe(0n);
|
||||
});
|
||||
});
|
||||
|
||||
interface FakeMsgState {
|
||||
acked: number;
|
||||
naks: Array<number | undefined>;
|
||||
terms: number;
|
||||
}
|
||||
|
||||
function fakeMsg(data: string, deliveryCount: number): {msg: JsMsg; state: FakeMsgState} {
|
||||
const state: FakeMsgState = {acked: 0, naks: [], terms: 0};
|
||||
const msg = {
|
||||
data: new TextEncoder().encode(data),
|
||||
info: {deliveryCount},
|
||||
ackAck: async () => {
|
||||
state.acked++;
|
||||
return true;
|
||||
},
|
||||
nak: (delay?: number) => {
|
||||
state.naks.push(delay);
|
||||
},
|
||||
term: () => {
|
||||
state.terms++;
|
||||
},
|
||||
} as unknown as JsMsg;
|
||||
return {msg, state};
|
||||
}
|
||||
|
||||
describe('account action messages', () => {
|
||||
it('publishes the outcome before acknowledging, and terminates undecodable envelopes', async () => {
|
||||
const h = harness();
|
||||
h.users.put();
|
||||
const outcomes: Array<ActionOutcome> = [];
|
||||
h.deps.publishOutcome = async (_key, outcome) => {
|
||||
outcomes.push(outcome);
|
||||
};
|
||||
const good = fakeMsg(JSON.stringify(envelope<'set_spammer'>({type: 'set_spammer', user_id: USER_ID, on: true})), 1);
|
||||
await handleActionMessage(h.deps, good.msg);
|
||||
expect(outcomes.map((outcome) => outcome.status)).toEqual(['applied']);
|
||||
expect(good.state.acked).toBe(1);
|
||||
const bad = fakeMsg('{not json', 1);
|
||||
await handleActionMessage(h.deps, bad.msg);
|
||||
expect(bad.state.terms).toBe(1);
|
||||
expect(outcomes).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('retries a failing apply with backoff and reports failed after the last attempt', async () => {
|
||||
const h = harness();
|
||||
h.users.put();
|
||||
h.users.findUnique = async () => {
|
||||
throw new Error('datastore unavailable');
|
||||
};
|
||||
const outcomes: Array<ActionOutcome> = [];
|
||||
h.deps.publishOutcome = async (_key, outcome) => {
|
||||
outcomes.push(outcome);
|
||||
};
|
||||
const data = JSON.stringify(envelope<'set_spammer'>({type: 'set_spammer', user_id: USER_ID, on: true}));
|
||||
const early = fakeMsg(data, 3);
|
||||
await handleActionMessage(h.deps, early.msg);
|
||||
expect(early.state.naks).toEqual([3000]);
|
||||
expect(outcomes).toEqual([]);
|
||||
const last = fakeMsg(data, 10);
|
||||
await handleActionMessage(h.deps, last.msg);
|
||||
expect(outcomes).toMatchObject([{status: 'failed', detail: 'Error: datastore unavailable'}]);
|
||||
expect(last.state.acked).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe.skipIf(!NATS_URL)('account action consumer against JetStream', () => {
|
||||
let nc: Awaited<ReturnType<typeof connect>>;
|
||||
beforeAll(async () => {
|
||||
nc = await connect({servers: NATS_URL, token: process.env.FLUXER_TEST_ACTIVITY_NATS_TOKEN});
|
||||
const jsm = await jetstreamManager(nc);
|
||||
await jsm.streams.delete(ACTIONS_STREAM).catch(() => undefined);
|
||||
await jsm.streams.add({name: ACTIONS_STREAM, subjects: ['act.*']});
|
||||
for (const partition of [0, 7]) {
|
||||
await jsm.consumers.add(ACTIONS_STREAM, {
|
||||
durable_name: effectsConsumer(partition),
|
||||
filter_subject: `act.${String(partition).padStart(2, '0')}`,
|
||||
ack_policy: AckPolicy.Explicit,
|
||||
max_ack_pending: 1,
|
||||
deliver_policy: DeliverPolicy.All,
|
||||
});
|
||||
}
|
||||
});
|
||||
afterAll(async () => {
|
||||
await stopAccountActionConsumer();
|
||||
await nc?.close();
|
||||
});
|
||||
|
||||
it('applies each action in order and acknowledges it after the outcome', async () => {
|
||||
const h = harness();
|
||||
h.users.put();
|
||||
h.deps.now = Date.now;
|
||||
const outcomes: Array<ActionOutcome> = [];
|
||||
h.deps.publishOutcome = async (_key, outcome) => {
|
||||
outcomes.push(outcome);
|
||||
};
|
||||
h.deps.js = jetstream(nc);
|
||||
h.deps.retryDelayMs = 200;
|
||||
const js = jetstream(nc);
|
||||
const expires = Date.now() + 60_000;
|
||||
await js.publish(
|
||||
'act.07',
|
||||
JSON.stringify(
|
||||
envelope<'set_spammer'>({
|
||||
type: 'set_spammer',
|
||||
id: 'a:07:1:0',
|
||||
user_id: USER_ID,
|
||||
on: true,
|
||||
expires_at_ms: expires,
|
||||
}),
|
||||
),
|
||||
{msgID: 'a:07:1:0'},
|
||||
);
|
||||
await js.publish(
|
||||
'act.07',
|
||||
JSON.stringify(
|
||||
envelope<'set_spammer'>({
|
||||
type: 'set_spammer',
|
||||
id: 'a:07:2:0',
|
||||
user_id: USER_ID,
|
||||
on: false,
|
||||
expires_at_ms: expires,
|
||||
}),
|
||||
),
|
||||
{msgID: 'a:07:2:0'},
|
||||
);
|
||||
startAccountActionConsumer(h.deps);
|
||||
const deadline = Date.now() + 10_000;
|
||||
while (outcomes.length < 2 && Date.now() < deadline) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
}
|
||||
expect(outcomes.map((outcome) => [outcome.action_id, outcome.status])).toEqual([
|
||||
['a:07:1:0', 'applied'],
|
||||
['a:07:2:0', 'applied'],
|
||||
]);
|
||||
expect(h.users.current().flags & UserFlags.SPAMMER).toBe(0n);
|
||||
const info = await (await jetstreamManager(nc)).consumers.info(ACTIONS_STREAM, effectsConsumer(7));
|
||||
expect(info.num_ack_pending).toBe(0);
|
||||
expect(info.num_pending).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -8,7 +8,7 @@ import type {WorkerJobPayload} from '@pkgs/worker/src/contracts/WorkerTypes';
|
||||
import {describe, expect, it, vi} from 'vitest';
|
||||
|
||||
const INITIAL_SYNC_KEY = 'sync:email_domains:initialized';
|
||||
const FEED_TASKS = ['syncDisposableEmailDomains', 'syncUrlBlocklists', 'syncFileShaBlocklists'];
|
||||
const FEED_TASKS = ['syncUrlBlocklists', 'syncFileShaBlocklists'];
|
||||
|
||||
function createWorkerService() {
|
||||
return {addJob: vi.fn(async (_task: WorkerTaskName, _payload: WorkerJobPayload) => 1n)};
|
||||
@@ -19,27 +19,17 @@ function queuedTasks(workerService: ReturnType<typeof createWorkerService>): Arr
|
||||
}
|
||||
|
||||
describe('queueBlocklistFeedStartupJobs', () => {
|
||||
it('with feeds on, a fresh start queues only the disposable sync and claims it for six hours', async () => {
|
||||
it('with feeds on, a start queues nothing and marks the feeds as enabled for six hours', async () => {
|
||||
const kv = new MockKVProvider();
|
||||
const workerService = createWorkerService();
|
||||
|
||||
await queueBlocklistFeedStartupJobs(kv, workerService, true);
|
||||
|
||||
expect(workerService.addJob.mock.calls).toEqual([['syncDisposableEmailDomains', {}]]);
|
||||
expect(workerService.addJob).not.toHaveBeenCalled();
|
||||
expect(kv.setnxSpy.mock.calls).toEqual([[INITIAL_SYNC_KEY, '1', 21600]]);
|
||||
expect(kv.delSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('with feeds on, a start inside the claim queues nothing', async () => {
|
||||
const kv = new MockKVProvider();
|
||||
const workerService = createWorkerService();
|
||||
|
||||
await queueBlocklistFeedStartupJobs(kv, workerService, true);
|
||||
await queueBlocklistFeedStartupJobs(kv, workerService, true);
|
||||
|
||||
expect(queuedTasks(workerService)).toEqual(['syncDisposableEmailDomains']);
|
||||
});
|
||||
|
||||
it('with feeds off, the first start queues all three feed tasks and a second start queues none', async () => {
|
||||
const kv = new MockKVProvider();
|
||||
const workerService = createWorkerService();
|
||||
@@ -59,11 +49,11 @@ describe('queueBlocklistFeedStartupJobs', () => {
|
||||
await queueBlocklistFeedStartupJobs(kv, workerService, true);
|
||||
await queueBlocklistFeedStartupJobs(kv, workerService, false);
|
||||
|
||||
expect(queuedTasks(workerService)).toEqual([...FEED_TASKS, 'syncDisposableEmailDomains', ...FEED_TASKS]);
|
||||
expect(queuedTasks(workerService)).toEqual([...FEED_TASKS, ...FEED_TASKS]);
|
||||
expect(await kv.exists(INITIAL_SYNC_KEY)).toBe(0);
|
||||
});
|
||||
|
||||
it('a legacy initial sync key without expiry is cleared, so re-enabling runs the initial sync', async () => {
|
||||
it('a legacy enabled marker without expiry is cleared when feeds are off', async () => {
|
||||
const kv = new MockKVProvider();
|
||||
await kv.set(INITIAL_SYNC_KEY, '1');
|
||||
expect(await kv.ttl(INITIAL_SYNC_KEY)).toBe(-1);
|
||||
@@ -71,9 +61,8 @@ describe('queueBlocklistFeedStartupJobs', () => {
|
||||
await queueBlocklistFeedStartupJobs(kv, createWorkerService(), false);
|
||||
expect(await kv.exists(INITIAL_SYNC_KEY)).toBe(0);
|
||||
|
||||
const workerService = createWorkerService();
|
||||
await queueBlocklistFeedStartupJobs(kv, workerService, true);
|
||||
expect(queuedTasks(workerService)).toEqual(['syncDisposableEmailDomains']);
|
||||
await queueBlocklistFeedStartupJobs(kv, createWorkerService(), true);
|
||||
expect(await kv.ttl(INITIAL_SYNC_KEY)).toBe(21600);
|
||||
});
|
||||
|
||||
it('a full jobs stream drops the job without failing startup', async () => {
|
||||
|
||||
@@ -14,7 +14,6 @@ import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHa
|
||||
import type {MockKVProvider} from '@app/api/test/mocks/MockKVProvider';
|
||||
import {NoopLogger} from '@app/api/test/mocks/NoopLogger';
|
||||
import {canonicalizeUrl} from '@app/api/utils/UrlNormalizer';
|
||||
import syncDisposableEmailDomains from '@app/api/worker/tasks/SyncDisposableEmailDomains';
|
||||
import syncFileShaBlocklists from '@app/api/worker/tasks/SyncFileShaBlocklists';
|
||||
import syncUrlBlocklists from '@app/api/worker/tasks/SyncUrlBlocklists';
|
||||
import {clearWorkerDependencies, setWorkerDependenciesForTest} from '@app/api/worker/WorkerContext';
|
||||
@@ -99,31 +98,6 @@ describe('blocklist feeds turned off', () => {
|
||||
return (harness.kvProvider as MockKVProvider).publishSpy.mock.calls;
|
||||
}
|
||||
|
||||
it('removes every stored disposable domain without fetching a feed', async () => {
|
||||
const adminRepository = getAdminRepository();
|
||||
for (const domain of ['mailinator.com', 'guerrillamail.com', 'tempmail.dev']) {
|
||||
await adminRepository.addDisposableEmailDomain(domain);
|
||||
}
|
||||
Config.blocklistFeeds.enabled = false;
|
||||
const reportProgress = vi.fn(async () => {});
|
||||
|
||||
await syncDisposableEmailDomains({}, createHelpers({reportProgress}));
|
||||
|
||||
expect(await adminRepository.listDisposableEmailDomains()).toEqual([]);
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
expect(reportProgress).toHaveBeenLastCalledWith(3, 3, '+0 added, -3 removed');
|
||||
});
|
||||
|
||||
it('reports a stored domain as disposable only while feeds are on', async () => {
|
||||
const adminRepository = getAdminRepository();
|
||||
await adminRepository.addDisposableEmailDomain('mailinator.com');
|
||||
|
||||
Config.blocklistFeeds.enabled = true;
|
||||
expect(await adminRepository.isEmailDomainDisposable('mailinator.com')).toBe(true);
|
||||
Config.blocklistFeeds.enabled = false;
|
||||
expect(await adminRepository.isEmailDomainDisposable('mailinator.com')).toBe(false);
|
||||
});
|
||||
|
||||
it('removes MalwareBazaar feed rows and keeps every other file-SHA ban', async () => {
|
||||
await seedFileShas();
|
||||
Config.blocklistFeeds.enabled = false;
|
||||
|
||||
@@ -13,7 +13,6 @@ import {InMemoryCassandraQueryExecutor} from '@app/api/test/InMemoryCassandraQue
|
||||
import {MockKVProvider} from '@app/api/test/mocks/MockKVProvider';
|
||||
import {MockStorageService} from '@app/api/test/mocks/MockStorageService';
|
||||
import {NoopLogger} from '@app/api/test/mocks/NoopLogger';
|
||||
import syncDisposableEmailDomains from '@app/api/worker/tasks/SyncDisposableEmailDomains';
|
||||
import syncFileShaBlocklists from '@app/api/worker/tasks/SyncFileShaBlocklists';
|
||||
import {
|
||||
clearWorkerDependencies,
|
||||
@@ -153,24 +152,6 @@ describe.skipIf(!dockerAvailable)('blocklist feeds turned off against postgres',
|
||||
spawnSync('docker', ['rm', '-f', CONTAINER], {stdio: 'ignore'});
|
||||
});
|
||||
|
||||
it('removes every disposable_email_domains row from the KV table', async () => {
|
||||
const repository = new AdminRepository();
|
||||
for (let i = 0; i < 1200; i++) {
|
||||
await repository.addDisposableEmailDomain(`disposable-${i}.example`);
|
||||
}
|
||||
const seeded = await raw.query<{count: number}>(
|
||||
`SELECT count(*)::int AS count FROM ${KV_TABLE} WHERE table_name = 'disposable_email_domains'`,
|
||||
);
|
||||
expect(seeded.rows[0]?.count).toBe(1200);
|
||||
|
||||
await syncDisposableEmailDomains({}, createHelpers());
|
||||
|
||||
const remaining = await raw.query<{count: number}>(
|
||||
`SELECT count(*)::int AS count FROM ${KV_TABLE} WHERE table_name = 'disposable_email_domains'`,
|
||||
);
|
||||
expect(remaining.rows[0]?.count).toBe(0);
|
||||
});
|
||||
|
||||
it('keeps file-SHA rows with added_by set and removes feed rows stored with a JSON null added_by', async () => {
|
||||
const repository = new AdminRepository();
|
||||
await repository.banFileSha(fileShaRow(FEED_SHA, ContentBlocklistCategory.MALWARE_BAZAAR, null));
|
||||
|
||||
@@ -212,4 +212,24 @@ describe('bulkScheduleUserDeletion', () => {
|
||||
const perUserLogs = await listAuditLogs('schedule_deletion');
|
||||
expect(perUserLogs.filter((log) => log.targetId === BigInt(target.userId))).toHaveLength(1);
|
||||
});
|
||||
test('leaves a deletion another admin already scheduled in place and reports the user as not scheduled', async () => {
|
||||
const scheduler = await createTestAccount(harness);
|
||||
await setUserACLs(harness, scheduler, ['admin:authenticate', 'user:delete']);
|
||||
const admin = await createTestAccount(harness);
|
||||
await setUserACLs(harness, admin, ['admin:authenticate', 'bulk:delete:users']);
|
||||
const scheduled = await createTestAccount(harness);
|
||||
const fresh = await createTestAccount(harness);
|
||||
await createBuilder(harness, scheduler.token)
|
||||
.put(`/admin/users/${scheduled.userId}/deletion`)
|
||||
.body({reason_code: DeletionReasons.SPAM, days_until_deletion: 90})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
const before = await getUserRepository().findUnique(createUserID(BigInt(scheduled.userId)));
|
||||
const result = await runBulkJob([scheduled.userId, fresh.userId], admin.userId);
|
||||
expect(result.successful_count).toBe(1);
|
||||
expect(result.failed).toEqual([{id: scheduled.userId, error: 'A deletion is already scheduled for this account'}]);
|
||||
const after = await getUserRepository().findUnique(createUserID(BigInt(scheduled.userId)));
|
||||
expect(after?.pendingDeletionAt?.getTime()).toBe(before?.pendingDeletionAt?.getTime());
|
||||
expect(after?.deletionScheduledBy?.toString()).toBe(scheduler.userId);
|
||||
});
|
||||
});
|
||||
@@ -3,7 +3,6 @@
|
||||
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,
|
||||
@@ -39,7 +38,6 @@ interface BulkJobResult {
|
||||
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 {
|
||||
@@ -97,17 +95,9 @@ describe('bulkUpdateSuspiciousActivityFlags task', () => {
|
||||
return user!.suspiciousActivityFlags ?? 0;
|
||||
}
|
||||
|
||||
test('writes a per-user audit row with the admin reason, records the risk outcome, and reports failures', async () => {
|
||||
test('writes a per-user audit row with the admin reason 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(
|
||||
{
|
||||
@@ -143,11 +133,6 @@ describe('bulkUpdateSuspiciousActivityFlags task', () => {
|
||||
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 () => {
|
||||
|
||||
@@ -61,16 +61,20 @@ describe('WorkerLaneConfig', () => {
|
||||
expect(embedLane[0]!.name).toBe('unfurl');
|
||||
expect(embedLane[0]!.taskTypes).toEqual(['extractEmbeds']);
|
||||
});
|
||||
it('keeps the retired scheduled message subject on the lifecycle lane only', () => {
|
||||
it('keeps each retired subject on the lane that used to run it', () => {
|
||||
const lanes = resolveWorkerLanes({
|
||||
mode: 'all_lanes',
|
||||
laneConcurrencyOverrides: {},
|
||||
});
|
||||
const lifecycleLane = lanes.find((lane) => lane.name === 'lifecycle');
|
||||
expect(lifecycleLane?.retiredTaskTypes).toEqual(['sendScheduledMessage']);
|
||||
expect(lifecycleLane?.taskTypes).not.toContain('sendScheduledMessage');
|
||||
for (const lane of lanes.filter((lane) => lane.name !== 'lifecycle')) {
|
||||
expect(lane.retiredTaskTypes).toEqual([]);
|
||||
const retired = Object.fromEntries(lanes.map((lane) => [lane.name, lane.retiredTaskTypes]));
|
||||
expect(retired).toEqual({
|
||||
realtime: [],
|
||||
unfurl: [],
|
||||
lifecycle: ['sendScheduledMessage'],
|
||||
batch: [],
|
||||
});
|
||||
for (const lane of lanes) {
|
||||
for (const task of lane.retiredTaskTypes) expect(lane.taskTypes).not.toContain(task);
|
||||
}
|
||||
});
|
||||
it('never claims a retired subject from a single_task lane', () => {
|
||||
|
||||
Reference in new issue
Block a user