feat(api): add managed guild invite pauses (#3419)

This commit is contained in:
Hampus authored and GitHub committed 2026-10-12 15:14:03 +02:00
1 parent c890956e4f
commit 17746fcdc4
28 files changed
+1203 -60

No files matched your search

@@ -4,9 +4,9 @@ export type Id = string;
export type Flags64 = string;
export type Channel = "stable" | "canary" | "worker" | "internal" | "import" | "other";
export type Meta = { ip: string | null, country: string | null, ua: string | null, locale: string | null, channel: Channel, request_id: string | null, };
export type Kind = "registration" | "email_changed" | "profile_updated" | "account_changed" | "admin_action" | "account_deleted" | "report_filed" | "email_bounced" | "action_outcome" | "login" | "session_started" | "guild_joined" | "dm_opened" | "message_created" | "message_updated" | "friend_request" | "http_errors" | "user_blocked" | "guild_member_banned" | "guild_member_unbanned" | "report_resolved";
export type Event = { v: number, id: string, at_ms: number, key: string, meta: Meta, } & ({ "kind": "registration", "data": Registration } | { "kind": "email_changed", "data": EmailChanged } | { "kind": "profile_updated", "data": ProfileUpdated } | { "kind": "account_changed", "data": AccountChanged } | { "kind": "admin_action", "data": AdminAction } | { "kind": "account_deleted", "data": AccountDeleted } | { "kind": "report_filed", "data": ReportFiled } | { "kind": "email_bounced", "data": EmailBounced } | { "kind": "action_outcome", "data": ActionOutcome } | { "kind": "login", "data": Login } | { "kind": "session_started", "data": SessionStarted } | { "kind": "guild_joined", "data": GuildJoined } | { "kind": "dm_opened", "data": DmOpened } | { "kind": "message_created", "data": MessageCreated } | { "kind": "message_updated", "data": MessageUpdated } | { "kind": "friend_request", "data": FriendRequest } | { "kind": "http_errors", "data": HttpErrors } | { "kind": "user_blocked", "data": UserBlocked } | { "kind": "guild_member_banned", "data": GuildMemberBanned } | { "kind": "guild_member_unbanned", "data": GuildMemberUnbanned } | { "kind": "report_resolved", "data": ReportResolved });
export type Body = { "kind": "registration", "data": Registration } | { "kind": "email_changed", "data": EmailChanged } | { "kind": "profile_updated", "data": ProfileUpdated } | { "kind": "account_changed", "data": AccountChanged } | { "kind": "admin_action", "data": AdminAction } | { "kind": "account_deleted", "data": AccountDeleted } | { "kind": "report_filed", "data": ReportFiled } | { "kind": "email_bounced", "data": EmailBounced } | { "kind": "action_outcome", "data": ActionOutcome } | { "kind": "login", "data": Login } | { "kind": "session_started", "data": SessionStarted } | { "kind": "guild_joined", "data": GuildJoined } | { "kind": "dm_opened", "data": DmOpened } | { "kind": "message_created", "data": MessageCreated } | { "kind": "message_updated", "data": MessageUpdated } | { "kind": "friend_request", "data": FriendRequest } | { "kind": "http_errors", "data": HttpErrors } | { "kind": "user_blocked", "data": UserBlocked } | { "kind": "guild_member_banned", "data": GuildMemberBanned } | { "kind": "guild_member_unbanned", "data": GuildMemberUnbanned } | { "kind": "report_resolved", "data": ReportResolved };
export type Kind = "registration" | "email_changed" | "profile_updated" | "account_changed" | "admin_action" | "account_deleted" | "report_filed" | "email_bounced" | "action_outcome" | "login" | "session_started" | "guild_joined" | "dm_opened" | "message_created" | "message_updated" | "friend_request" | "http_errors" | "user_blocked" | "guild_member_banned" | "guild_member_unbanned" | "report_resolved" | "guild_created" | "guild_features_changed" | "invite_created" | "invite_deleted" | "guild_member_removed" | "guild_join_refused";
export type Event = { v: number, id: string, at_ms: number, key: string, meta: Meta, } & ({ "kind": "registration", "data": Registration } | { "kind": "email_changed", "data": EmailChanged } | { "kind": "profile_updated", "data": ProfileUpdated } | { "kind": "account_changed", "data": AccountChanged } | { "kind": "admin_action", "data": AdminAction } | { "kind": "account_deleted", "data": AccountDeleted } | { "kind": "report_filed", "data": ReportFiled } | { "kind": "email_bounced", "data": EmailBounced } | { "kind": "action_outcome", "data": ActionOutcome } | { "kind": "login", "data": Login } | { "kind": "session_started", "data": SessionStarted } | { "kind": "guild_joined", "data": GuildJoined } | { "kind": "dm_opened", "data": DmOpened } | { "kind": "message_created", "data": MessageCreated } | { "kind": "message_updated", "data": MessageUpdated } | { "kind": "friend_request", "data": FriendRequest } | { "kind": "http_errors", "data": HttpErrors } | { "kind": "user_blocked", "data": UserBlocked } | { "kind": "guild_member_banned", "data": GuildMemberBanned } | { "kind": "guild_member_unbanned", "data": GuildMemberUnbanned } | { "kind": "report_resolved", "data": ReportResolved } | { "kind": "guild_created", "data": GuildCreated } | { "kind": "guild_features_changed", "data": GuildFeaturesChanged } | { "kind": "invite_created", "data": InviteCreated } | { "kind": "invite_deleted", "data": InviteDeleted } | { "kind": "guild_member_removed", "data": GuildMemberRemoved } | { "kind": "guild_join_refused", "data": GuildJoinRefused });
export type Body = { "kind": "registration", "data": Registration } | { "kind": "email_changed", "data": EmailChanged } | { "kind": "profile_updated", "data": ProfileUpdated } | { "kind": "account_changed", "data": AccountChanged } | { "kind": "admin_action", "data": AdminAction } | { "kind": "account_deleted", "data": AccountDeleted } | { "kind": "report_filed", "data": ReportFiled } | { "kind": "email_bounced", "data": EmailBounced } | { "kind": "action_outcome", "data": ActionOutcome } | { "kind": "login", "data": Login } | { "kind": "session_started", "data": SessionStarted } | { "kind": "guild_joined", "data": GuildJoined } | { "kind": "dm_opened", "data": DmOpened } | { "kind": "message_created", "data": MessageCreated } | { "kind": "message_updated", "data": MessageUpdated } | { "kind": "friend_request", "data": FriendRequest } | { "kind": "http_errors", "data": HttpErrors } | { "kind": "user_blocked", "data": UserBlocked } | { "kind": "guild_member_banned", "data": GuildMemberBanned } | { "kind": "guild_member_unbanned", "data": GuildMemberUnbanned } | { "kind": "report_resolved", "data": ReportResolved } | { "kind": "guild_created", "data": GuildCreated } | { "kind": "guild_features_changed", "data": GuildFeaturesChanged } | { "kind": "invite_created", "data": InviteCreated } | { "kind": "invite_deleted", "data": InviteDeleted } | { "kind": "guild_member_removed", "data": GuildMemberRemoved } | { "kind": "guild_join_refused", "data": GuildJoinRefused };
export type Registration = { user_id: Id, method: RegMethod, email: string | null, username: string, username_user_chosen: boolean, global_name: string | null, locale: string | null, timezone: string | null, invite_code: string | null, flags: Flags64, date_of_birth: string | null, };
export type RegMethod = "password" | "unclaimed" | "oauth" | "other";
export type EmailChanged = { user_id: Id, new_email: string, was_unclaimed: boolean, has_ever_purchased: boolean, };
@@ -38,9 +38,18 @@ export type BanBy = "moderator" | "staff";
export type ReportResolved = { report_id: Id, reporter_id: Id, category: string, target_type: ReportTarget, reported_user_id: Id | null, outcome: ReportOutcome, resolved_by: ResolvedBy, };
export type ReportOutcome = "actioned" | "no_violation" | "duplicate" | "auto_resolved" | "unspecified";
export type ResolvedBy = "staff" | "system";
export type ActionEnvelope = { v: number, id: string, key: string, issued_at_ms: number, expires_at_ms: number, } & ({ "type": "set_account_limit", user_id: Id, on: boolean, } | { "type": "temp_ban_ip", ip: string, until_ms: number, } | { "type": "limit_new_conversations", user_id: Id, on: boolean, until_ms: number, } | { "type": "hide_recent_messages", user_id: Id, since_ms: number, on: boolean, } | { "type": "hide_profile", user_id: Id, on: boolean, } | { "type": "delete_user_messages", user_id: Id, on: boolean, });
export type Action = { "type": "set_account_limit", user_id: Id, on: boolean, } | { "type": "temp_ban_ip", ip: string, until_ms: number, } | { "type": "limit_new_conversations", user_id: Id, on: boolean, until_ms: number, } | { "type": "hide_recent_messages", user_id: Id, since_ms: number, on: boolean, } | { "type": "hide_profile", user_id: Id, on: boolean, } | { "type": "delete_user_messages", user_id: Id, on: boolean, };
export type ActionOutcome = { action_id: string, action_type: string, status: OutcomeStatus, detail: string | null, observed: Observed | null, user_id?: Id, };
export type GuildCreated = { guild_id: Id, owner_id: Id, member_count: number | null, };
export type GuildFeaturesChanged = { guild_id: Id, actor_id: Id | null, source: FeatureSource, added: Array<string>, removed: Array<string>, invites_paused_until_ms: number | null, };
export type FeatureSource = "member" | "staff" | "action" | "system";
export type InviteCreated = { guild_id: Id, channel_id: Id | null, code: string, inviter_id: Id | null, max_uses: number, max_age_s: number, temporary: boolean, };
export type InviteDeleted = { guild_id: Id, code: string, };
export type GuildMemberRemoved = { guild_id: Id, user_id: Id, actor_id: Id | null, kind: RemovalKind, };
export type RemovalKind = "left" | "kicked";
export type GuildJoinRefused = { guild_id: Id, user_id: Id | null, cause: RefusalCause, join_source: JoinSource, };
export type RefusalCause = "invites_paused" | "other";
export type ActionEnvelope = { v: number, id: string, key: string, issued_at_ms: number, expires_at_ms: number, } & ({ "type": "set_account_limit", user_id: Id, on: boolean, } | { "type": "temp_ban_ip", ip: string, until_ms: number, } | { "type": "limit_new_conversations", user_id: Id, on: boolean, until_ms: number, } | { "type": "hide_recent_messages", user_id: Id, since_ms: number, on: boolean, } | { "type": "hide_profile", user_id: Id, on: boolean, } | { "type": "delete_user_messages", user_id: Id, on: boolean, } | { "type": "pause_guild_invites", guild_id: Id, on: boolean, until_ms: number, });
export type Action = { "type": "set_account_limit", user_id: Id, on: boolean, } | { "type": "temp_ban_ip", ip: string, until_ms: number, } | { "type": "limit_new_conversations", user_id: Id, on: boolean, until_ms: number, } | { "type": "hide_recent_messages", user_id: Id, since_ms: number, on: boolean, } | { "type": "hide_profile", user_id: Id, on: boolean, } | { "type": "delete_user_messages", user_id: Id, on: boolean, } | { "type": "pause_guild_invites", guild_id: Id, on: boolean, until_ms: number, };
export type ActionOutcome = { action_id: string, action_type: string, status: OutcomeStatus, detail: string | null, observed: Observed | null, user_id?: Id, guild_id?: Id, };
export type OutcomeStatus = "applied" | "noop" | "conflict" | "expired" | "ineligible" | "exempt" | "unsupported" | "failed";
export type Observed = { flags: Flags64, deleted: boolean, };
@@ -49,6 +58,7 @@ export const EVENTS_STREAM = 'EVENTS';
export const ACTIONS_STREAM = 'ACTIONS';
export const ACTION_PARTITIONS = 64;
export const MAX_ACTION_ATTEMPTS = 10;
export const INVITES_ALREADY_PAUSED = 'already_paused';
export const LISTS_BUCKET = 'lists';
export const SHARED_LISTS = ['ip_blocked', 'email_tld_blocked', 'email_domain_exempt'] as const;
export const HEADER = {
@@ -80,6 +90,12 @@ export const EVENT_KINDS: ReadonlyArray<EventKind> = [
'guild_member_banned',
'guild_member_unbanned',
'report_resolved',
'guild_created',
'guild_features_changed',
'invite_created',
'invite_deleted',
'guild_member_removed',
'guild_join_refused',
];
export const EVENT_MAJOR: Record<EventKind, number> = {
registration: 1,
@@ -103,6 +119,12 @@ export const EVENT_MAJOR: Record<EventKind, number> = {
guild_member_banned: 1,
guild_member_unbanned: 1,
report_resolved: 1,
guild_created: 1,
guild_features_changed: 1,
invite_created: 1,
invite_deleted: 1,
guild_member_removed: 1,
guild_join_refused: 1,
};
export const EVENT_TTL: Record<EventKind, string> = {
registration: 'never',
@@ -126,6 +148,12 @@ export const EVENT_TTL: Record<EventKind, string> = {
guild_member_banned: '3024000',
guild_member_unbanned: '3024000',
report_resolved: 'never',
guild_created: '3024000',
guild_features_changed: '3024000',
invite_created: '3024000',
invite_deleted: '3024000',
guild_member_removed: '3024000',
guild_join_refused: '3024000',
};
export const EVENT_CLASS: Record<EventKind, 'fact' | 'signal'> = {
registration: 'fact',
@@ -149,10 +177,17 @@ export const EVENT_CLASS: Record<EventKind, 'fact' | 'signal'> = {
guild_member_banned: 'fact',
guild_member_unbanned: 'fact',
report_resolved: 'fact',
guild_created: 'fact',
guild_features_changed: 'fact',
invite_created: 'fact',
invite_deleted: 'fact',
guild_member_removed: 'signal',
guild_join_refused: 'signal',
};
export const effectsConsumer = (p: number): string => `effects-${String(p).padStart(2, '0')}`;
export function keyToken(key: string): string {
if (/^\d+$/.test(key)) return key;
return createHash('sha256').update(key).digest('hex').slice(0, 16);
}
export const guildKey = (guildId: Id): string => `guild:${guildId}`;
export const eventSubject = (kind: EventKind, key: string): string => `evt.in.${kind}.${keyToken(key)}`;
@@ -0,0 +1,110 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {GuildID, UserID} from '@app/api/BrandedTypes';
import {emitActivity} from '@app/api/infrastructure/activity/ActivityEvents';
import {
type FeatureSource,
guildKey,
type JoinSource,
type Meta,
type RemovalKind,
} from '@app/api/infrastructure/activity/Contract.generated';
import type {Guild} from '@app/api/models/Guild';
import type {Invite} from '@app/api/models/Invite';
export async function emitGuildCreated(guild: Guild): Promise<void> {
await emitActivity(
'guild_created',
guild.ownerId.toString(),
{
guild_id: guild.id.toString(),
owner_id: guild.ownerId.toString(),
member_count: guild.memberCount,
},
null,
guild.id.toString(),
);
}
export async function emitGuildFeaturesChanged(params: {
previous: Guild;
updated: Guild;
actorId: UserID | null;
source: FeatureSource;
meta?: Meta | null;
}): Promise<void> {
const {previous, updated} = params;
const added = [...updated.features].filter((feature) => !previous.features.has(feature)).sort();
const removed = [...previous.features].filter((feature) => !updated.features.has(feature)).sort();
const pausedUntilMs = updated.invitesPausedUntil?.getTime() ?? null;
const pauseChanged = pausedUntilMs !== (previous.invitesPausedUntil?.getTime() ?? null);
if (added.length === 0 && removed.length === 0 && !pauseChanged) return;
const guildId = updated.id.toString();
await emitActivity(
'guild_features_changed',
guildKey(guildId),
{
guild_id: guildId,
actor_id: params.actorId?.toString() ?? null,
source: params.source,
added,
removed,
invites_paused_until_ms: pausedUntilMs,
},
params.meta ?? null,
`${guildId}:${updated.version}`,
);
}
export async function emitInviteCreated(invite: Invite): Promise<void> {
if (!invite.guildId) return;
const guildId = invite.guildId.toString();
await emitActivity(
'invite_created',
guildKey(guildId),
{
guild_id: guildId,
channel_id: invite.channelId?.toString() ?? null,
code: invite.code,
inviter_id: invite.inviterId?.toString() ?? null,
max_uses: invite.maxUses,
max_age_s: invite.maxAge,
temporary: invite.temporary,
},
null,
invite.code,
);
}
export async function emitInviteDeleted(invite: Invite): Promise<void> {
if (!invite.guildId) return;
const guildId = invite.guildId.toString();
await emitActivity('invite_deleted', guildKey(guildId), {guild_id: guildId, code: invite.code}, null, invite.code);
}
export async function emitGuildMemberRemoved(params: {
guildId: GuildID;
userId: UserID;
actorId: UserID | null;
kind: RemovalKind;
}): Promise<void> {
await emitActivity('guild_member_removed', params.userId.toString(), {
guild_id: params.guildId.toString(),
user_id: params.userId.toString(),
actor_id: params.actorId?.toString() ?? null,
kind: params.kind,
});
}
export async function emitGuildJoinRefused(params: {
guildId: GuildID;
userId: UserID;
joinSource: JoinSource;
}): Promise<void> {
await emitActivity('guild_join_refused', params.userId.toString(), {
guild_id: params.guildId.toString(),
user_id: params.userId.toString(),
cause: 'invites_paused',
join_source: params.joinSource,
});
}
@@ -0,0 +1,256 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {createTestAccount, setUserACLs} from '@app/api/auth/tests/AuthTestUtils';
import {createGuildID} from '@app/api/BrandedTypes';
import {applyPauseGuildInvites, type GuildInvitePauseDeps} from '@app/api/guild/services/GuildInvitePauseApplier';
import {
acceptInvite,
createChannelInvite,
createGuild,
deleteInvite,
leaveGuild,
setupTestGuildWithMembers,
updateGuild,
} from '@app/api/guild/tests/GuildTestUtils';
import {
idleActivityEvents,
resetActivityEventsForTests,
startActivityEvents,
} from '@app/api/infrastructure/activity/ActivityEvents';
import type {ActivityPublisher} from '@app/api/infrastructure/activity/ActivitySpool';
import type {Event} from '@app/api/infrastructure/activity/Contract.generated';
import {getGatewayService, getKVClient} from '@app/api/middleware/ServiceRegistry';
import {getCacheService, getGuildAuditLogService, getGuildRepository} from '@app/api/middleware/ServiceSingletons';
import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHarness';
import {HTTP_STATUS} from '@app/api/test/TestConstants';
import {createBuilder} from '@app/api/test/TestRequestBuilder';
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
import {GuildFeatures} from '@fluxer/constants/src/GuildConstants';
import type {GuildResponse} from '@fluxer/schema/src/domains/guild/GuildResponseSchemas';
import {MockKVProvider} from '@pkgs/kv_client/src/testing/MockKVProvider';
import {afterEach, beforeEach, describe, expect, test} from 'vitest';
const HOUR_MS = 3_600_000;
class CapturingPublisher implements ActivityPublisher {
readonly events: Array<Event> = [];
async publish(_subject: string, payload: string): Promise<void> {
this.events.push(JSON.parse(payload) as Event);
}
of<K extends Event['kind']>(kind: K): Array<Extract<Event, {kind: K}>> {
return this.events.filter((event): event is Extract<Event, {kind: K}> => event.kind === kind);
}
}
function pauseDeps(): GuildInvitePauseDeps {
return {
guilds: getGuildRepository(),
locks: getCacheService(),
schedule: getKVClient(),
gateway: getGatewayService(),
auditLog: getGuildAuditLogService(),
};
}
async function pauseInvites(guildId: string, untilMs: number): Promise<void> {
const outcome = await applyPauseGuildInvites(pauseDeps(), {
v: 1,
id: `a:11:${guildId}:0`,
key: `guild:${guildId}`,
issued_at_ms: Date.now(),
expires_at_ms: Date.now() + 60_000,
type: 'pause_guild_invites',
guild_id: guildId,
on: true,
until_ms: untilMs,
});
expect(outcome.status).toBe('applied');
}
async function storedPause(guildId: string): Promise<{features: Set<string>; until: Date | null}> {
const guild = await getGuildRepository().findUnique(createGuildID(BigInt(guildId)));
return {features: new Set(guild!.features), until: guild!.invitesPausedUntil};
}
describe('guild activity events', () => {
let harness: ApiTestHarness;
let publisher: CapturingPublisher;
beforeEach(async () => {
harness = await createApiTestHarness();
publisher = new CapturingPublisher();
await startActivityEvents({publisher, kv: new MockKVProvider()});
});
afterEach(async () => {
resetActivityEventsForTests();
await harness?.shutdown();
});
test('creating a guild publishes who owns it', async () => {
const owner = await createTestAccount(harness);
const guild = await createGuild(harness, owner.token, 'Fresh guild');
const [created] = publisher.of('guild_created');
expect(created?.key).toBe(owner.userId);
expect(created?.data).toEqual({guild_id: guild.id, owner_id: owner.userId, member_count: 1});
});
test('a member feature toggle publishes the actor and the member source', async () => {
const owner = await createTestAccount(harness);
const guild = await createGuild(harness, owner.token, 'Toggle guild');
await updateGuild(harness, owner.token, guild.id, {features: [...guild.features, GuildFeatures.INVITES_DISABLED]});
const [changed] = publisher.of('guild_features_changed');
expect(changed?.key).toBe(`guild:${guild.id}`);
expect(changed?.data).toEqual({
guild_id: guild.id,
actor_id: owner.userId,
source: 'member',
added: [GuildFeatures.INVITES_DISABLED],
removed: [],
invites_paused_until_ms: null,
});
});
test('creating and deleting an invite publishes both with the invite settings', async () => {
const owner = await createTestAccount(harness);
const guild = await createGuild(harness, owner.token, 'Invite guild');
const invite = await createChannelInvite(harness, owner.token, guild.system_channel_id!);
await deleteInvite(harness, owner.token, invite.code);
const [created] = publisher.of('invite_created');
expect(created?.key).toBe(`guild:${guild.id}`);
expect(created?.data).toEqual({
guild_id: guild.id,
channel_id: guild.system_channel_id,
code: invite.code,
inviter_id: owner.userId,
max_uses: invite.max_uses,
max_age_s: invite.max_age,
temporary: false,
});
const [deleted] = publisher.of('invite_deleted');
expect(deleted?.key).toBe(`guild:${guild.id}`);
expect(deleted?.data).toEqual({guild_id: guild.id, code: invite.code});
});
test('leaving and being kicked publish the removal kind and actor', async () => {
const {owner, members, guild} = await setupTestGuildWithMembers(harness, 2);
const [leaver, kicked] = members;
await leaveGuild(harness, leaver!.token, guild.id);
await createBuilder(harness, owner.token)
.delete(`/guilds/${guild.id}/members/${kicked!.userId}`)
.expect(HTTP_STATUS.NO_CONTENT)
.execute();
await idleActivityEvents();
expect(publisher.of('guild_member_removed').map((event) => [event.key, event.data])).toEqual([
[leaver!.userId, {guild_id: guild.id, user_id: leaver!.userId, actor_id: null, kind: 'left'}],
[kicked!.userId, {guild_id: guild.id, user_id: kicked!.userId, actor_id: owner.userId, kind: 'kicked'}],
]);
});
test('a join refused while invites are paused publishes the refusal only for non-members', async () => {
const owner = await createTestAccount(harness);
const joiner = await createTestAccount(harness);
const guild = await createGuild(harness, owner.token, 'Paused guild');
const invite = await createChannelInvite(harness, owner.token, guild.system_channel_id!);
await pauseInvites(guild.id, Date.now() + HOUR_MS);
for (const account of [owner, joiner]) {
await createBuilder(harness, account.token)
.post(`/invites/${invite.code}`)
.body(null)
.expect(HTTP_STATUS.FORBIDDEN, APIErrorCodes.INVITES_DISABLED)
.execute();
}
await idleActivityEvents();
const refusals = publisher.of('guild_join_refused');
expect(refusals).toHaveLength(1);
const [refused] = refusals;
expect(refused?.key).toBe(joiner.userId);
expect(refused?.data).toEqual({
guild_id: guild.id,
user_id: joiner.userId,
cause: 'invites_paused',
join_source: 'invite',
});
});
describe('managed invite pause', () => {
let owner: Awaited<ReturnType<typeof createTestAccount>>;
let guild: GuildResponse;
let untilMs: number;
beforeEach(async () => {
owner = await createTestAccount(harness);
guild = await createGuild(harness, owner.token, 'Managed pause');
untilMs = Date.now() + 2 * HOUR_MS;
await pauseInvites(guild.id, untilMs);
});
test('the owner turning invites back on ends the pause and drops the notice', async () => {
const paused = await storedPause(guild.id);
expect(paused.until?.getTime()).toBe(untilMs);
expect(paused.features.has(GuildFeatures.RAID_DETECTED)).toBe(true);
const features = [...paused.features].filter((feature) => feature !== GuildFeatures.INVITES_DISABLED);
const updated = await updateGuild(harness, owner.token, guild.id, {features});
expect(updated.features).not.toContain(GuildFeatures.INVITES_DISABLED);
expect(updated.features).not.toContain(GuildFeatures.RAID_DETECTED);
const after = await storedPause(guild.id);
expect(after.until).toBeNull();
const memberChange = publisher.of('guild_features_changed').at(-1);
expect(memberChange?.data).toEqual({
guild_id: guild.id,
actor_id: owner.userId,
source: 'member',
added: [],
removed: [GuildFeatures.INVITES_DISABLED, GuildFeatures.RAID_DETECTED],
invites_paused_until_ms: null,
});
const joiner = await createTestAccount(harness);
const invite = await createChannelInvite(harness, owner.token, guild.system_channel_id!);
await acceptInvite(harness, joiner.token, invite.code);
});
test('an unrelated member edit keeps the pause', async () => {
await updateGuild(harness, owner.token, guild.id, {name: 'Renamed'});
expect((await storedPause(guild.id)).until?.getTime()).toBe(untilMs);
});
test('a staff edit of the pause features takes the pause over from the timer', async () => {
const admin = await setUserACLs(harness, await createTestAccount(harness), [
'admin:authenticate',
'guild:update:features',
]);
await createBuilder(harness, admin.token)
.patch(`/admin/guilds/${guild.id}`)
.body({remove_features: [GuildFeatures.RAID_DETECTED]})
.expect(HTTP_STATUS.OK)
.execute();
const after = await storedPause(guild.id);
expect(after.until).toBeNull();
expect(after.features.has(GuildFeatures.INVITES_DISABLED)).toBe(true);
expect(after.features.has(GuildFeatures.RAID_DETECTED)).toBe(false);
expect(publisher.of('guild_features_changed').at(-1)?.data).toEqual({
guild_id: guild.id,
actor_id: admin.userId,
source: 'staff',
added: [],
removed: [GuildFeatures.RAID_DETECTED],
invites_paused_until_ms: null,
});
});
test('a staff edit of other features leaves the timer running', async () => {
const admin = await setUserACLs(harness, await createTestAccount(harness), [
'admin:authenticate',
'guild:update:features',
]);
await createBuilder(harness, admin.token)
.patch(`/admin/guilds/${guild.id}`)
.body({add_features: [GuildFeatures.VERIFIED]})
.expect(HTTP_STATUS.OK)
.execute();
expect((await storedPause(guild.id)).until?.getTime()).toBe(untilMs);
});
});
});