mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-17 21:15:32 +09:00
fix(api): correct guild permission and mfa checks (#2499)
This commit is contained in:
1 parent
f392636857
commit
908e1b8bd4
12 files changed
+263
-46
No files matched your search
@@ -174,7 +174,7 @@ export abstract class BaseChannelAuthService {
|
|||||||
const guildId = channel.guildId!;
|
const guildId = channel.guildId!;
|
||||||
const [authContextResult, guildMemberResult] = await Promise.all([
|
const [authContextResult, guildMemberResult] = await Promise.all([
|
||||||
this.fetchGuildAuthContextOrThrow({guildId, userId, channelId: this.parentLookupChannelId(channel)}),
|
this.fetchGuildAuthContextOrThrow({guildId, userId, channelId: this.parentLookupChannelId(channel)}),
|
||||||
this.gatewayService.getGuildMember({guildId, userId}),
|
this.fetchGuildMemberOrThrow({guildId, userId}),
|
||||||
]);
|
]);
|
||||||
if (!authContextResult) {
|
if (!authContextResult) {
|
||||||
this.throwGuildAccessError();
|
this.throwGuildAccessError();
|
||||||
@@ -285,6 +285,18 @@ export abstract class BaseChannelAuthService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async fetchGuildMemberOrThrow(params: {guildId: GuildID; userId: UserID}): Promise<{
|
||||||
|
success: boolean;
|
||||||
|
memberData?: GuildMemberResponse;
|
||||||
|
}> {
|
||||||
|
try {
|
||||||
|
return await this.gatewayService.getGuildMember(params);
|
||||||
|
} catch (error) {
|
||||||
|
await this.handleGuildAccessError(error, params.guildId);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private async handleGuildAccessError(error: unknown, guildId: GuildID): Promise<void> {
|
private async handleGuildAccessError(error: unknown, guildId: GuildID): Promise<void> {
|
||||||
if (error instanceof UnknownGuildError) {
|
if (error instanceof UnknownGuildError) {
|
||||||
if (await this.guildExists(guildId)) {
|
if (await this.guildExists(guildId)) {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import {Permissions} from '@fluxer/constants/src/ChannelConstants';
|
import {Permissions} from '@fluxer/constants/src/ChannelConstants';
|
||||||
import {MAX_MESSAGE_LENGTH_PREMIUM} from '@fluxer/constants/src/LimitConstants';
|
import {MAX_MESSAGE_LENGTH_PREMIUM} from '@fluxer/constants/src/LimitConstants';
|
||||||
|
import {UnknownGuildError} from '@fluxer/errors/src/domains/guild/UnknownGuildError';
|
||||||
import type {MessageResponse} from '@fluxer/schema/src/domains/message/MessageResponseSchemas';
|
import type {MessageResponse} from '@fluxer/schema/src/domains/message/MessageResponseSchemas';
|
||||||
import {afterAll, beforeAll, beforeEach, describe, expect, it, vi} from 'vitest';
|
import {afterAll, beforeAll, beforeEach, describe, expect, it, vi} from 'vitest';
|
||||||
import {createTestAccount} from '../../auth/tests/AuthTestUtils';
|
import {createTestAccount} from '../../auth/tests/AuthTestUtils';
|
||||||
@@ -9,7 +10,7 @@ import {authorizeBot, createTestBotAccount} from '../../bot/tests/BotTestUtils';
|
|||||||
import {ensureSessionStarted} from '../../message/tests/MessageTestUtils';
|
import {ensureSessionStarted} from '../../message/tests/MessageTestUtils';
|
||||||
import {getGatewayService} from '../../middleware/ServiceRegistry';
|
import {getGatewayService} from '../../middleware/ServiceRegistry';
|
||||||
import {type ApiTestHarness, createApiTestHarness} from '../../test/ApiTestHarness';
|
import {type ApiTestHarness, createApiTestHarness} from '../../test/ApiTestHarness';
|
||||||
import {HTTP_STATUS} from '../../test/TestConstants';
|
import {HTTP_STATUS, TEST_TIMEOUTS, wait} from '../../test/TestConstants';
|
||||||
import {createBuilder} from '../../test/TestRequestBuilder';
|
import {createBuilder} from '../../test/TestRequestBuilder';
|
||||||
import {ChannelDataRepository} from '../repositories/ChannelDataRepository';
|
import {ChannelDataRepository} from '../repositories/ChannelDataRepository';
|
||||||
import {
|
import {
|
||||||
@@ -169,6 +170,34 @@ describe('Message send permissions', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('returns ACCESS_DENIED not UNKNOWN_GUILD when the Gateway has dropped a guild whose record survives', async () => {
|
||||||
|
const {owner, systemChannel} = await setupTestGuildWithMembers(harness, 0);
|
||||||
|
await ensureSessionStarted(harness, owner.token);
|
||||||
|
const gatewayService = getGatewayService();
|
||||||
|
|
||||||
|
for (const memberSettlesFirst of [true, false]) {
|
||||||
|
const getGuildAuthContext = vi.spyOn(gatewayService, 'getGuildAuthContext').mockImplementation(async () => {
|
||||||
|
if (memberSettlesFirst) await wait(TEST_TIMEOUTS.IMMEDIATE);
|
||||||
|
throw new UnknownGuildError();
|
||||||
|
});
|
||||||
|
const getGuildMember = vi.spyOn(gatewayService, 'getGuildMember').mockImplementation(async () => {
|
||||||
|
if (!memberSettlesFirst) await wait(TEST_TIMEOUTS.IMMEDIATE);
|
||||||
|
throw new UnknownGuildError();
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await createBuilder(harness, owner.token)
|
||||||
|
.post(`/channels/${systemChannel.id}/messages`)
|
||||||
|
.body({content: 'the gateway forgot this guild'})
|
||||||
|
.expect(HTTP_STATUS.FORBIDDEN, 'ACCESS_DENIED')
|
||||||
|
.execute();
|
||||||
|
} finally {
|
||||||
|
getGuildAuthContext.mockRestore();
|
||||||
|
getGuildMember.mockRestore();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it('allows bot message content up to 4000 characters', async () => {
|
it('allows bot message content up to 4000 characters', async () => {
|
||||||
const {owner, guild, systemChannel} = await setupTestGuildWithMembers(harness, 0);
|
const {owner, guild, systemChannel} = await setupTestGuildWithMembers(harness, 0);
|
||||||
const botAccount = await createTestBotAccount(harness);
|
const botAccount = await createTestBotAccount(harness);
|
||||||
|
|||||||
@@ -3,7 +3,6 @@
|
|||||||
import {Permissions} from '@fluxer/constants/src/ChannelConstants';
|
import {Permissions} from '@fluxer/constants/src/ChannelConstants';
|
||||||
import {DiscoveryApplicationStatus, DiscoveryCategoryLabels} from '@fluxer/constants/src/DiscoveryConstants';
|
import {DiscoveryApplicationStatus, DiscoveryCategoryLabels} from '@fluxer/constants/src/DiscoveryConstants';
|
||||||
import {GuildFeatures, JoinSourceTypes} from '@fluxer/constants/src/GuildConstants';
|
import {GuildFeatures, JoinSourceTypes} from '@fluxer/constants/src/GuildConstants';
|
||||||
import {MissingPermissionsError} from '@fluxer/errors/src/domains/core/MissingPermissionsError';
|
|
||||||
import {DiscoveryDisabledError} from '@fluxer/errors/src/domains/discovery/DiscoveryDisabledError';
|
import {DiscoveryDisabledError} from '@fluxer/errors/src/domains/discovery/DiscoveryDisabledError';
|
||||||
import {DiscoveryNotDiscoverableError} from '@fluxer/errors/src/domains/discovery/DiscoveryNotDiscoverableError';
|
import {DiscoveryNotDiscoverableError} from '@fluxer/errors/src/domains/discovery/DiscoveryNotDiscoverableError';
|
||||||
import {InvitesDisabledError} from '@fluxer/errors/src/domains/invite/InvitesDisabledError';
|
import {InvitesDisabledError} from '@fluxer/errors/src/domains/invite/InvitesDisabledError';
|
||||||
@@ -160,14 +159,8 @@ export function GuildDiscoveryController(app: HonoApp) {
|
|||||||
const {guild_id} = ctx.req.valid('param');
|
const {guild_id} = ctx.req.valid('param');
|
||||||
const guildId = createGuildID(guild_id);
|
const guildId = createGuildID(guild_id);
|
||||||
const data = ctx.req.valid('json');
|
const data = ctx.req.valid('json');
|
||||||
const hasPermission = await ctx.get('gatewayService').checkPermission({
|
const {checkPermission} = await ctx.get('guildService').getGuildAuthenticated({userId: user.id, guildId});
|
||||||
guildId,
|
await checkPermission(Permissions.MANAGE_GUILD);
|
||||||
userId: user.id,
|
|
||||||
permission: Permissions.MANAGE_GUILD,
|
|
||||||
});
|
|
||||||
if (!hasPermission) {
|
|
||||||
throw new MissingPermissionsError();
|
|
||||||
}
|
|
||||||
const row = await ctx.get('discoveryService').apply({
|
const row = await ctx.get('discoveryService').apply({
|
||||||
guildId,
|
guildId,
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
@@ -201,14 +194,8 @@ export function GuildDiscoveryController(app: HonoApp) {
|
|||||||
const {guild_id} = ctx.req.valid('param');
|
const {guild_id} = ctx.req.valid('param');
|
||||||
const guildId = createGuildID(guild_id);
|
const guildId = createGuildID(guild_id);
|
||||||
const data = ctx.req.valid('json');
|
const data = ctx.req.valid('json');
|
||||||
const hasPermission = await ctx.get('gatewayService').checkPermission({
|
const {checkPermission} = await ctx.get('guildService').getGuildAuthenticated({userId: user.id, guildId});
|
||||||
guildId,
|
await checkPermission(Permissions.MANAGE_GUILD);
|
||||||
userId: user.id,
|
|
||||||
permission: Permissions.MANAGE_GUILD,
|
|
||||||
});
|
|
||||||
if (!hasPermission) {
|
|
||||||
throw new MissingPermissionsError();
|
|
||||||
}
|
|
||||||
const row = await ctx.get('discoveryService').editApplication({
|
const row = await ctx.get('discoveryService').editApplication({
|
||||||
guildId,
|
guildId,
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
@@ -237,14 +224,8 @@ export function GuildDiscoveryController(app: HonoApp) {
|
|||||||
const user = ctx.get('user');
|
const user = ctx.get('user');
|
||||||
const {guild_id} = ctx.req.valid('param');
|
const {guild_id} = ctx.req.valid('param');
|
||||||
const guildId = createGuildID(guild_id);
|
const guildId = createGuildID(guild_id);
|
||||||
const hasPermission = await ctx.get('gatewayService').checkPermission({
|
const {checkPermission} = await ctx.get('guildService').getGuildAuthenticated({userId: user.id, guildId});
|
||||||
guildId,
|
await checkPermission(Permissions.MANAGE_GUILD);
|
||||||
userId: user.id,
|
|
||||||
permission: Permissions.MANAGE_GUILD,
|
|
||||||
});
|
|
||||||
if (!hasPermission) {
|
|
||||||
throw new MissingPermissionsError();
|
|
||||||
}
|
|
||||||
await ctx.get('discoveryService').withdraw({guildId, userId: user.id});
|
await ctx.get('discoveryService').withdraw({guildId, userId: user.id});
|
||||||
return ctx.body(null, 204);
|
return ctx.body(null, 204);
|
||||||
},
|
},
|
||||||
@@ -267,14 +248,8 @@ export function GuildDiscoveryController(app: HonoApp) {
|
|||||||
const user = ctx.get('user');
|
const user = ctx.get('user');
|
||||||
const {guild_id} = ctx.req.valid('param');
|
const {guild_id} = ctx.req.valid('param');
|
||||||
const guildId = createGuildID(guild_id);
|
const guildId = createGuildID(guild_id);
|
||||||
const hasPermission = await ctx.get('gatewayService').checkPermission({
|
const {checkPermission} = await ctx.get('guildService').getGuildAuthenticated({userId: user.id, guildId});
|
||||||
guildId,
|
await checkPermission(Permissions.MANAGE_GUILD);
|
||||||
userId: user.id,
|
|
||||||
permission: Permissions.MANAGE_GUILD,
|
|
||||||
});
|
|
||||||
if (!hasPermission) {
|
|
||||||
throw new MissingPermissionsError();
|
|
||||||
}
|
|
||||||
const discoveryService = ctx.get('discoveryService');
|
const discoveryService = ctx.get('discoveryService');
|
||||||
const row = await discoveryService.getStatus(guildId);
|
const row = await discoveryService.getStatus(guildId);
|
||||||
const eligibility = await discoveryService.getEligibility(guildId);
|
const eligibility = await discoveryService.getEligibility(guildId);
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import {guildMembersNeedReindexing} from '../../search/GuildMemberIndexingUtils'
|
|||||||
import type {HonoApp} from '../../types/HonoEnv';
|
import type {HonoApp} from '../../types/HonoEnv';
|
||||||
import {Validator} from '../../Validator';
|
import {Validator} from '../../Validator';
|
||||||
import {GuildRepository} from '../repositories/GuildRepository';
|
import {GuildRepository} from '../repositories/GuildRepository';
|
||||||
|
import {createGuildMfaEnforcer} from '../services/GuildMfaEnforcement';
|
||||||
|
|
||||||
const MEMBERS_PAGE_PERMISSIONS =
|
const MEMBERS_PAGE_PERMISSIONS =
|
||||||
Permissions.MANAGE_GUILD |
|
Permissions.MANAGE_GUILD |
|
||||||
@@ -81,11 +82,17 @@ export function GuildMemberSearchController(app: HonoApp) {
|
|||||||
const guildId = createGuildID(ctx.req.valid('param').guild_id);
|
const guildId = createGuildID(ctx.req.valid('param').guild_id);
|
||||||
const guildIdString = guildId.toString();
|
const guildIdString = guildId.toString();
|
||||||
const body = ctx.req.valid('json');
|
const body = ctx.req.valid('json');
|
||||||
const {getMyPermissions} = await ctx.get('guildService').getGuildAuthenticated({userId, guildId});
|
const {getMyPermissions, guildData} = await ctx.get('guildService').getGuildAuthenticated({userId, guildId});
|
||||||
const permissions = await getMyPermissions();
|
const permissions = await getMyPermissions();
|
||||||
if ((permissions & MEMBERS_PAGE_PERMISSIONS) === 0n) {
|
if ((permissions & MEMBERS_PAGE_PERMISSIONS) === 0n) {
|
||||||
throw new MissingPermissionsError();
|
throw new MissingPermissionsError();
|
||||||
}
|
}
|
||||||
|
const enforceGuildMfa = await createGuildMfaEnforcer({
|
||||||
|
userRepository: ctx.get('userRepository'),
|
||||||
|
guildData,
|
||||||
|
userId,
|
||||||
|
});
|
||||||
|
enforceGuildMfa(permissions & MEMBERS_PAGE_PERMISSIONS);
|
||||||
const canViewInvites = (permissions & Permissions.MANAGE_GUILD) !== 0n;
|
const canViewInvites = (permissions & Permissions.MANAGE_GUILD) !== 0n;
|
||||||
const guild = await guildRepository.findUnique(guildId);
|
const guild = await guildRepository.findUnique(guildId);
|
||||||
if (!guild) {
|
if (!guild) {
|
||||||
|
|||||||
@@ -914,11 +914,16 @@ export class GuildMemberOperationsService {
|
|||||||
if (userId !== targetId) {
|
if (userId !== targetId) {
|
||||||
await checkTargetMember(targetId);
|
await checkTargetMember(targetId);
|
||||||
}
|
}
|
||||||
if (data.mute !== undefined || data.deaf !== undefined) {
|
if (data.mute !== undefined) {
|
||||||
if (!(await hasPermission(Permissions.MUTE_MEMBERS))) {
|
if (!(await hasPermission(Permissions.MUTE_MEMBERS))) {
|
||||||
throw new MissingPermissionsError();
|
throw new MissingPermissionsError();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (data.deaf !== undefined) {
|
||||||
|
if (!(await hasPermission(Permissions.DEAFEN_MEMBERS))) {
|
||||||
|
throw new MissingPermissionsError();
|
||||||
|
}
|
||||||
|
}
|
||||||
if (data.channel_id !== undefined) {
|
if (data.channel_id !== undefined) {
|
||||||
if (!(await hasPermission(Permissions.MOVE_MEMBERS))) {
|
if (!(await hasPermission(Permissions.MOVE_MEMBERS))) {
|
||||||
throw new MissingPermissionsError();
|
throw new MissingPermissionsError();
|
||||||
|
|||||||
@@ -1,17 +1,21 @@
|
|||||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
|
||||||
|
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
|
||||||
|
import {Permissions} from '@fluxer/constants/src/ChannelConstants';
|
||||||
import {DiscoveryCategories, type DiscoveryCategory} from '@fluxer/constants/src/DiscoveryConstants';
|
import {DiscoveryCategories, type DiscoveryCategory} from '@fluxer/constants/src/DiscoveryConstants';
|
||||||
import {GuildFeatures} from '@fluxer/constants/src/GuildConstants';
|
import {GuildFeatures, GuildMFALevel} from '@fluxer/constants/src/GuildConstants';
|
||||||
import type {
|
import type {
|
||||||
DiscoveryApplicationResponse,
|
DiscoveryApplicationResponse,
|
||||||
DiscoveryStatusResponse,
|
DiscoveryStatusResponse,
|
||||||
} from '@fluxer/schema/src/domains/guild/GuildDiscoverySchemas';
|
} from '@fluxer/schema/src/domains/guild/GuildDiscoverySchemas';
|
||||||
import {afterEach, beforeEach, describe, expect, test} from 'vitest';
|
import {afterEach, beforeEach, describe, expect, test} from 'vitest';
|
||||||
import {createTestAccount, setUserACLs} from '../../auth/tests/AuthTestUtils';
|
import {createTestAccount, setUserACLs, type TestAccount, totpCodeNow} from '../../auth/tests/AuthTestUtils';
|
||||||
import {type ApiTestHarness, createApiTestHarness} from '../../test/ApiTestHarness';
|
import {type ApiTestHarness, createApiTestHarness} from '../../test/ApiTestHarness';
|
||||||
import {HTTP_STATUS} from '../../test/TestConstants';
|
import {HTTP_STATUS} from '../../test/TestConstants';
|
||||||
import {createBuilder} from '../../test/TestRequestBuilder';
|
import {createBuilder, createBuilderWithoutAuth} from '../../test/TestRequestBuilder';
|
||||||
import {createGuild, getGuild} from './GuildTestUtils';
|
import {addMemberRole, createGuild, createRole, getGuild, setupTestGuildWithMembers} from './GuildTestUtils';
|
||||||
|
|
||||||
|
const TOTP_SECRET = 'JBSWY3DPEHPK3PXP';
|
||||||
|
|
||||||
async function setGuildMemberCount(harness: ApiTestHarness, guildId: string, memberCount: number): Promise<void> {
|
async function setGuildMemberCount(harness: ApiTestHarness, guildId: string, memberCount: number): Promise<void> {
|
||||||
await createBuilder(harness, '')
|
await createBuilder(harness, '')
|
||||||
@@ -67,6 +71,33 @@ async function adminReject(
|
|||||||
.execute();
|
.execute();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function enableTotp(harness: ApiTestHarness, account: TestAccount): Promise<void> {
|
||||||
|
await createBuilder(harness, account.token)
|
||||||
|
.post('/users/@me/mfa/totp/enable')
|
||||||
|
.body({secret: TOTP_SECRET, code: totpCodeNow(TOTP_SECRET), password: account.password})
|
||||||
|
.expect(HTTP_STATUS.OK)
|
||||||
|
.execute();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loginWithTotp(harness: ApiTestHarness, account: TestAccount): Promise<TestAccount> {
|
||||||
|
const loginResp = await createBuilderWithoutAuth<{
|
||||||
|
mfa: true;
|
||||||
|
ticket: string;
|
||||||
|
}>(harness)
|
||||||
|
.post('/auth/login')
|
||||||
|
.body({email: account.email, password: account.password})
|
||||||
|
.expect(HTTP_STATUS.OK)
|
||||||
|
.execute();
|
||||||
|
const mfaResp = await createBuilderWithoutAuth<{
|
||||||
|
token: string;
|
||||||
|
}>(harness)
|
||||||
|
.post('/auth/login/mfa/totp')
|
||||||
|
.body({code: totpCodeNow(TOTP_SECRET), ticket: loginResp.ticket})
|
||||||
|
.expect(HTTP_STATUS.OK)
|
||||||
|
.execute();
|
||||||
|
return {...account, token: mfaResp.token};
|
||||||
|
}
|
||||||
|
|
||||||
async function createAdminAccount(harness: ApiTestHarness) {
|
async function createAdminAccount(harness: ApiTestHarness) {
|
||||||
const admin = await createTestAccount(harness);
|
const admin = await createTestAccount(harness);
|
||||||
return setUserACLs(harness, admin, ['admin:authenticate', 'discovery:review', 'discovery:remove']);
|
return setUserACLs(harness, admin, ['admin:authenticate', 'discovery:review', 'discovery:remove']);
|
||||||
@@ -251,6 +282,28 @@ describe('Discovery Application Lifecycle', () => {
|
|||||||
const application = await applyForDiscovery(harness, owner.token, guild.id, 'Short desc');
|
const application = await applyForDiscovery(harness, owner.token, guild.id, 'Short desc');
|
||||||
expect(application.description).toBe('Short desc');
|
expect(application.description).toBe('Short desc');
|
||||||
});
|
});
|
||||||
|
test('rejects apply with TWO_FACTOR_REQUIRED for a non-owner without MFA in an elevated guild', async () => {
|
||||||
|
const {owner, members, guild} = await setupTestGuildWithMembers(harness, 1);
|
||||||
|
const member = members[0]!;
|
||||||
|
const manageGuildRole = await createRole(harness, owner.token, guild.id, {
|
||||||
|
name: 'Manage Guild',
|
||||||
|
permissions: Permissions.MANAGE_GUILD.toString(),
|
||||||
|
});
|
||||||
|
await addMemberRole(harness, owner.token, guild.id, member.userId, manageGuildRole.id);
|
||||||
|
await enableTotp(harness, owner);
|
||||||
|
const elevatedOwner = await loginWithTotp(harness, owner);
|
||||||
|
await createBuilder(harness, elevatedOwner.token)
|
||||||
|
.patch(`/guilds/${guild.id}`)
|
||||||
|
.body({mfa_level: GuildMFALevel.ELEVATED, mfa_method: 'totp', mfa_code: totpCodeNow(TOTP_SECRET)})
|
||||||
|
.expect(HTTP_STATUS.OK)
|
||||||
|
.execute();
|
||||||
|
await setGuildMemberCount(harness, guild.id, 1);
|
||||||
|
await createBuilder(harness, member.token)
|
||||||
|
.post(`/guilds/${guild.id}/discovery`)
|
||||||
|
.body({description: 'Moderator without an authenticator', category_type: DiscoveryCategories.GAMING})
|
||||||
|
.expect(HTTP_STATUS.BAD_REQUEST, APIErrorCodes.TWO_FACTOR_REQUIRED)
|
||||||
|
.execute();
|
||||||
|
});
|
||||||
test('should allow applying with maximum description length', async () => {
|
test('should allow applying with maximum description length', async () => {
|
||||||
const owner = await createTestAccount(harness);
|
const owner = await createTestAccount(harness);
|
||||||
const guild = await createGuild(harness, owner.token, 'Max Desc Guild');
|
const guild = await createGuild(harness, owner.token, 'Max Desc Guild');
|
||||||
|
|||||||
@@ -530,6 +530,66 @@ describe('Guild Member Management', () => {
|
|||||||
.execute();
|
.execute();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
describe('Voice Mute and Deafen Permission Checks', () => {
|
||||||
|
test('should not allow member with MUTE_MEMBERS only to deafen a member', async () => {
|
||||||
|
const {owner, members, guild} = await setupTestGuildWithMembers(harness, 2);
|
||||||
|
const [moderator, target] = members;
|
||||||
|
const modRole = await createRole(harness, owner.token, guild.id, {
|
||||||
|
name: 'Muter',
|
||||||
|
permissions: Permissions.MUTE_MEMBERS.toString(),
|
||||||
|
});
|
||||||
|
await addMemberRole(harness, owner.token, guild.id, moderator.userId, modRole.id);
|
||||||
|
await createBuilder(harness, moderator.token)
|
||||||
|
.patch(`/guilds/${guild.id}/members/${target.userId}`)
|
||||||
|
.body({deaf: true})
|
||||||
|
.expect(HTTP_STATUS.FORBIDDEN, APIErrorCodes.MISSING_PERMISSIONS)
|
||||||
|
.execute();
|
||||||
|
});
|
||||||
|
test('should not allow member with DEAFEN_MEMBERS only to mute a member', async () => {
|
||||||
|
const {owner, members, guild} = await setupTestGuildWithMembers(harness, 2);
|
||||||
|
const [moderator, target] = members;
|
||||||
|
const modRole = await createRole(harness, owner.token, guild.id, {
|
||||||
|
name: 'Deafener',
|
||||||
|
permissions: Permissions.DEAFEN_MEMBERS.toString(),
|
||||||
|
});
|
||||||
|
await addMemberRole(harness, owner.token, guild.id, moderator.userId, modRole.id);
|
||||||
|
await createBuilder(harness, moderator.token)
|
||||||
|
.patch(`/guilds/${guild.id}/members/${target.userId}`)
|
||||||
|
.body({mute: true})
|
||||||
|
.expect(HTTP_STATUS.FORBIDDEN, APIErrorCodes.MISSING_PERMISSIONS)
|
||||||
|
.execute();
|
||||||
|
});
|
||||||
|
test('should allow member with DEAFEN_MEMBERS only to deafen a member', async () => {
|
||||||
|
const {owner, members, guild} = await setupTestGuildWithMembers(harness, 2);
|
||||||
|
const [moderator, target] = members;
|
||||||
|
const modRole = await createRole(harness, owner.token, guild.id, {
|
||||||
|
name: 'Deafener',
|
||||||
|
permissions: Permissions.DEAFEN_MEMBERS.toString(),
|
||||||
|
});
|
||||||
|
await addMemberRole(harness, owner.token, guild.id, moderator.userId, modRole.id);
|
||||||
|
const updatedMember = await createBuilder<GuildMemberResponse>(harness, moderator.token)
|
||||||
|
.patch(`/guilds/${guild.id}/members/${target.userId}`)
|
||||||
|
.body({deaf: true})
|
||||||
|
.expect(HTTP_STATUS.OK)
|
||||||
|
.execute();
|
||||||
|
expect(updatedMember.deaf).toBe(true);
|
||||||
|
});
|
||||||
|
test('should allow member with MUTE_MEMBERS only to mute a member', async () => {
|
||||||
|
const {owner, members, guild} = await setupTestGuildWithMembers(harness, 2);
|
||||||
|
const [moderator, target] = members;
|
||||||
|
const modRole = await createRole(harness, owner.token, guild.id, {
|
||||||
|
name: 'Muter',
|
||||||
|
permissions: Permissions.MUTE_MEMBERS.toString(),
|
||||||
|
});
|
||||||
|
await addMemberRole(harness, owner.token, guild.id, moderator.userId, modRole.id);
|
||||||
|
const updatedMember = await createBuilder<GuildMemberResponse>(harness, moderator.token)
|
||||||
|
.patch(`/guilds/${guild.id}/members/${target.userId}`)
|
||||||
|
.body({mute: true})
|
||||||
|
.expect(HTTP_STATUS.OK)
|
||||||
|
.execute();
|
||||||
|
expect(updatedMember.mute).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
describe('Member Avatar Upload Validation', () => {
|
describe('Member Avatar Upload Validation', () => {
|
||||||
test('should reject member avatar upload without premium', async () => {
|
test('should reject member avatar upload without premium', async () => {
|
||||||
const {members, guild} = await setupTestGuildWithMembers(harness, 1);
|
const {members, guild} = await setupTestGuildWithMembers(harness, 1);
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
|
||||||
import {JoinSourceTypes} from '@fluxer/constants/src/GuildConstants';
|
import {Permissions} from '@fluxer/constants/src/ChannelConstants';
|
||||||
|
import {GuildMFALevel, JoinSourceTypes} from '@fluxer/constants/src/GuildConstants';
|
||||||
import type {GuildMemberSearchResponse} from '@fluxer/schema/src/domains/guild/GuildMemberSearchSchemas';
|
import type {GuildMemberSearchResponse} from '@fluxer/schema/src/domains/guild/GuildMemberSearchSchemas';
|
||||||
import {afterEach, beforeEach, describe, expect, test} from 'vitest';
|
import {afterEach, beforeEach, describe, expect, test} from 'vitest';
|
||||||
import {createTestAccount} from '../../auth/tests/AuthTestUtils';
|
import {createTestAccount, type TestAccount, totpCodeNow} from '../../auth/tests/AuthTestUtils';
|
||||||
import {createGuildID, createUserID} from '../../BrandedTypes';
|
import {createGuildID, createUserID} from '../../BrandedTypes';
|
||||||
import {GuildRepository} from '../../guild/repositories/GuildRepository';
|
import {GuildRepository} from '../../guild/repositories/GuildRepository';
|
||||||
import {
|
import {
|
||||||
@@ -17,7 +18,37 @@ import {
|
|||||||
} from '../../guild/tests/GuildTestUtils';
|
} from '../../guild/tests/GuildTestUtils';
|
||||||
import {type ApiTestHarness, createApiTestHarness} from '../../test/ApiTestHarness';
|
import {type ApiTestHarness, createApiTestHarness} from '../../test/ApiTestHarness';
|
||||||
import {HTTP_STATUS, wait} from '../../test/TestConstants';
|
import {HTTP_STATUS, wait} from '../../test/TestConstants';
|
||||||
import {createBuilder} from '../../test/TestRequestBuilder';
|
import {createBuilder, createBuilderWithoutAuth} from '../../test/TestRequestBuilder';
|
||||||
|
|
||||||
|
const TOTP_SECRET = 'JBSWY3DPEHPK3PXP';
|
||||||
|
|
||||||
|
async function elevateGuildMfaLevel(harness: ApiTestHarness, owner: TestAccount, guildId: string): Promise<void> {
|
||||||
|
await createBuilder(harness, owner.token)
|
||||||
|
.post('/users/@me/mfa/totp/enable')
|
||||||
|
.body({secret: TOTP_SECRET, code: totpCodeNow(TOTP_SECRET), password: owner.password})
|
||||||
|
.expect(HTTP_STATUS.OK)
|
||||||
|
.execute();
|
||||||
|
const loginResp = await createBuilderWithoutAuth<{
|
||||||
|
mfa: true;
|
||||||
|
ticket: string;
|
||||||
|
}>(harness)
|
||||||
|
.post('/auth/login')
|
||||||
|
.body({email: owner.email, password: owner.password})
|
||||||
|
.expect(HTTP_STATUS.OK)
|
||||||
|
.execute();
|
||||||
|
const mfaResp = await createBuilderWithoutAuth<{
|
||||||
|
token: string;
|
||||||
|
}>(harness)
|
||||||
|
.post('/auth/login/mfa/totp')
|
||||||
|
.body({code: totpCodeNow(TOTP_SECRET), ticket: loginResp.ticket})
|
||||||
|
.expect(HTTP_STATUS.OK)
|
||||||
|
.execute();
|
||||||
|
await createBuilder(harness, mfaResp.token)
|
||||||
|
.patch(`/guilds/${guildId}`)
|
||||||
|
.body({mfa_level: GuildMFALevel.ELEVATED, mfa_method: 'totp', mfa_code: totpCodeNow(TOTP_SECRET)})
|
||||||
|
.expect(HTTP_STATUS.OK)
|
||||||
|
.execute();
|
||||||
|
}
|
||||||
|
|
||||||
async function indexGuildMembers(harness: ApiTestHarness, guildId: string): Promise<void> {
|
async function indexGuildMembers(harness: ApiTestHarness, guildId: string): Promise<void> {
|
||||||
await createBuilder<{
|
await createBuilder<{
|
||||||
@@ -325,6 +356,39 @@ describe('Guild Member Search Endpoint', () => {
|
|||||||
.execute();
|
.execute();
|
||||||
expect(result.members.length).toBeGreaterThan(0);
|
expect(result.members.length).toBeGreaterThan(0);
|
||||||
});
|
});
|
||||||
|
test('rejects an elevated-permission holder without MFA in an elevated guild (400)', async () => {
|
||||||
|
const {owner, members, guild} = await setupTestGuildWithMembers(harness, 1);
|
||||||
|
const moderator = members[0]!;
|
||||||
|
const banRole = await createRole(harness, owner.token, guild.id, {
|
||||||
|
name: 'Ban Members',
|
||||||
|
permissions: Permissions.BAN_MEMBERS.toString(),
|
||||||
|
});
|
||||||
|
await updateMember(harness, owner.token, guild.id, moderator.userId, {roles: [banRole.id]});
|
||||||
|
await indexGuildMembers(harness, guild.id);
|
||||||
|
await elevateGuildMfaLevel(harness, owner, guild.id);
|
||||||
|
await createBuilder(harness, moderator.token)
|
||||||
|
.post(`/guilds/${guild.id}/members-search`)
|
||||||
|
.body({})
|
||||||
|
.expect(HTTP_STATUS.BAD_REQUEST, 'TWO_FACTOR_REQUIRED')
|
||||||
|
.execute();
|
||||||
|
});
|
||||||
|
test('allows a MANAGE_NICKNAMES holder without MFA in an elevated guild', async () => {
|
||||||
|
const {owner, members, guild} = await setupTestGuildWithMembers(harness, 1);
|
||||||
|
const nicknameManager = members[0]!;
|
||||||
|
const nicknameRole = await createRole(harness, owner.token, guild.id, {
|
||||||
|
name: 'Manage Nicknames',
|
||||||
|
permissions: Permissions.MANAGE_NICKNAMES.toString(),
|
||||||
|
});
|
||||||
|
await updateMember(harness, owner.token, guild.id, nicknameManager.userId, {roles: [nicknameRole.id]});
|
||||||
|
await indexGuildMembers(harness, guild.id);
|
||||||
|
await elevateGuildMfaLevel(harness, owner, guild.id);
|
||||||
|
const result = await createBuilder<GuildMemberSearchResponse>(harness, nicknameManager.token)
|
||||||
|
.post(`/guilds/${guild.id}/members-search`)
|
||||||
|
.body({})
|
||||||
|
.expect(HTTP_STATUS.OK)
|
||||||
|
.execute();
|
||||||
|
expect(result.members.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
test('returns 404 for non-guild-member', async () => {
|
test('returns 404 for non-guild-member', async () => {
|
||||||
const {guild} = await setupTestGuildWithMembers(harness, 1);
|
const {guild} = await setupTestGuildWithMembers(harness, 1);
|
||||||
const outsider = await createTestAccount(harness);
|
const outsider = await createTestAccount(harness);
|
||||||
|
|||||||
@@ -246,6 +246,7 @@ export function useVoiceParticipantMenuData(options: VoiceParticipantMenuDataOpt
|
|||||||
const isBlocked = relationshipType === RelationshipTypes.BLOCKED;
|
const isBlocked = relationshipType === RelationshipTypes.BLOCKED;
|
||||||
const hasActiveDirectCall = hasActiveDirectCallWithUser(user.id);
|
const hasActiveDirectCall = hasActiveDirectCallWithUser(user.id);
|
||||||
const canMuteMembers = guildId ? Permission.can(Permissions.MUTE_MEMBERS, {guildId}) : false;
|
const canMuteMembers = guildId ? Permission.can(Permissions.MUTE_MEMBERS, {guildId}) : false;
|
||||||
|
const canDeafenMembers = guildId ? Permission.can(Permissions.DEAFEN_MEMBERS, {guildId}) : false;
|
||||||
const canMoveMembers = guildId ? Permission.can(Permissions.MOVE_MEMBERS, {guildId}) : false;
|
const canMoveMembers = guildId ? Permission.can(Permissions.MOVE_MEMBERS, {guildId}) : false;
|
||||||
const canKickMembers = guildId ? Permission.can(Permissions.KICK_MEMBERS, {guildId}) : false;
|
const canKickMembers = guildId ? Permission.can(Permissions.KICK_MEMBERS, {guildId}) : false;
|
||||||
const canBanMembers = guildId ? Permission.can(Permissions.BAN_MEMBERS, {guildId}) : false;
|
const canBanMembers = guildId ? Permission.can(Permissions.BAN_MEMBERS, {guildId}) : false;
|
||||||
@@ -1181,9 +1182,10 @@ export function useVoiceParticipantMenuData(options: VoiceParticipantMenuDataOpt
|
|||||||
}
|
}
|
||||||
if (guildId && member) {
|
if (guildId && member) {
|
||||||
const nextModerationActions: Array<MenuItemType | MenuCheckboxType> = [];
|
const nextModerationActions: Array<MenuItemType | MenuCheckboxType> = [];
|
||||||
if (canMuteMembers && (isCurrentUser || canManageTarget(user.id))) {
|
if ((canMuteMembers || canDeafenMembers) && (isCurrentUser || canManageTarget(user.id))) {
|
||||||
const isGuildMuted = memberMute;
|
const isGuildMuted = memberMute;
|
||||||
const isGuildDeafened = memberDeaf;
|
const isGuildDeafened = memberDeaf;
|
||||||
|
if (canMuteMembers) {
|
||||||
nextModerationActions.push({
|
nextModerationActions.push({
|
||||||
icon: (
|
icon: (
|
||||||
<GuildMuteIcon
|
<GuildMuteIcon
|
||||||
@@ -1204,6 +1206,8 @@ export function useVoiceParticipantMenuData(options: VoiceParticipantMenuDataOpt
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
if (canDeafenMembers) {
|
||||||
nextModerationActions.push({
|
nextModerationActions.push({
|
||||||
icon: (
|
icon: (
|
||||||
<GuildDeafenIcon
|
<GuildDeafenIcon
|
||||||
@@ -1225,6 +1229,7 @@ export function useVoiceParticipantMenuData(options: VoiceParticipantMenuDataOpt
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
}
|
||||||
if (!isCurrentUser && canMoveMembers && canManageTarget(user.id) && !isParentGroupedItem) {
|
if (!isCurrentUser && canMoveMembers && canManageTarget(user.id) && !isParentGroupedItem) {
|
||||||
nextModerationActions.push({
|
nextModerationActions.push({
|
||||||
icon: (
|
icon: (
|
||||||
@@ -1396,6 +1401,7 @@ export function useVoiceParticipantMenuData(options: VoiceParticipantMenuDataOpt
|
|||||||
canMention,
|
canMention,
|
||||||
focusedChannelId,
|
focusedChannelId,
|
||||||
canMuteMembers,
|
canMuteMembers,
|
||||||
|
canDeafenMembers,
|
||||||
canMoveMembers,
|
canMoveMembers,
|
||||||
canManageTarget,
|
canManageTarget,
|
||||||
canKickTarget,
|
canKickTarget,
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ const DEFAULT_PERMISSIONS: VoiceChannelPermissions = {
|
|||||||
canUseVideo: true,
|
canUseVideo: true,
|
||||||
canConnect: true,
|
canConnect: true,
|
||||||
canPrioritySpeaker: false,
|
canPrioritySpeaker: false,
|
||||||
|
canUseVad: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
function permissions(overrides: Partial<VoiceChannelPermissions> = {}): VoiceChannelPermissions {
|
function permissions(overrides: Partial<VoiceChannelPermissions> = {}): VoiceChannelPermissions {
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ export const DEFAULT_VOICE_PERMISSION_STATE: VoiceChannelPermissions = {
|
|||||||
canUseVideo: true,
|
canUseVideo: true,
|
||||||
canConnect: true,
|
canConnect: true,
|
||||||
canPrioritySpeaker: false,
|
canPrioritySpeaker: false,
|
||||||
|
canUseVad: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
const EMPTY_COMMANDS: ReadonlyArray<VoicePermissionCommand> = [];
|
const EMPTY_COMMANDS: ReadonlyArray<VoicePermissionCommand> = [];
|
||||||
@@ -86,7 +87,8 @@ function permissionsEqual(left: VoiceChannelPermissions, right: VoiceChannelPerm
|
|||||||
left.canStream === right.canStream &&
|
left.canStream === right.canStream &&
|
||||||
left.canUseVideo === right.canUseVideo &&
|
left.canUseVideo === right.canUseVideo &&
|
||||||
left.canConnect === right.canConnect &&
|
left.canConnect === right.canConnect &&
|
||||||
left.canPrioritySpeaker === right.canPrioritySpeaker
|
left.canPrioritySpeaker === right.canPrioritySpeaker &&
|
||||||
|
left.canUseVad === right.canUseVad
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ export interface VoiceChannelPermissions {
|
|||||||
canUseVideo: boolean;
|
canUseVideo: boolean;
|
||||||
canConnect: boolean;
|
canConnect: boolean;
|
||||||
canPrioritySpeaker: boolean;
|
canPrioritySpeaker: boolean;
|
||||||
|
canUseVad: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DEFAULT_VOICE_CHANNEL_PERMISSIONS: VoiceChannelPermissions = {
|
export const DEFAULT_VOICE_CHANNEL_PERMISSIONS: VoiceChannelPermissions = {
|
||||||
@@ -19,6 +20,7 @@ export const DEFAULT_VOICE_CHANNEL_PERMISSIONS: VoiceChannelPermissions = {
|
|||||||
canUseVideo: true,
|
canUseVideo: true,
|
||||||
canConnect: true,
|
canConnect: true,
|
||||||
canPrioritySpeaker: false,
|
canPrioritySpeaker: false,
|
||||||
|
canUseVad: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
export type VoicePermissionMuteReason = 'server_suppress' | 'missing_speak_permission' | null;
|
export type VoicePermissionMuteReason = 'server_suppress' | 'missing_speak_permission' | null;
|
||||||
@@ -38,6 +40,7 @@ export function getVoiceChannelPermissions(channelId: string | null | undefined)
|
|||||||
canUseVideo: (permissions & Permissions.STREAM) === Permissions.STREAM,
|
canUseVideo: (permissions & Permissions.STREAM) === Permissions.STREAM,
|
||||||
canConnect: (permissions & Permissions.CONNECT) === Permissions.CONNECT,
|
canConnect: (permissions & Permissions.CONNECT) === Permissions.CONNECT,
|
||||||
canPrioritySpeaker: (permissions & Permissions.PRIORITY_SPEAKER) === Permissions.PRIORITY_SPEAKER,
|
canPrioritySpeaker: (permissions & Permissions.PRIORITY_SPEAKER) === Permissions.PRIORITY_SPEAKER,
|
||||||
|
canUseVad: (permissions & Permissions.USE_VAD) === Permissions.USE_VAD,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user