mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-18 21:45:31 +09:00
feat(api): add managed guild invite pauses (#3419)
This commit is contained in:
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user