refactor progress
This commit is contained in:
@@ -0,0 +1,291 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {createTestAccount, setUserACLs} from '@fluxer/api/src/auth/tests/AuthTestUtils';
|
||||
import {createGuild, getGuild} from '@fluxer/api/src/guild/tests/GuildTestUtils';
|
||||
import {type ApiTestHarness, createApiTestHarness} from '@fluxer/api/src/test/ApiTestHarness';
|
||||
import {HTTP_STATUS} from '@fluxer/api/src/test/TestConstants';
|
||||
import {createBuilder} from '@fluxer/api/src/test/TestRequestBuilder';
|
||||
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
|
||||
import {DiscoveryCategories} from '@fluxer/constants/src/DiscoveryConstants';
|
||||
import {GuildFeatures} from '@fluxer/constants/src/GuildConstants';
|
||||
import type {DiscoveryApplicationResponse} from '@fluxer/schema/src/domains/guild/GuildDiscoverySchemas';
|
||||
import {afterEach, beforeEach, describe, expect, test} from 'vitest';
|
||||
|
||||
async function setGuildMemberCount(harness: ApiTestHarness, guildId: string, memberCount: number): Promise<void> {
|
||||
await createBuilder(harness, '').post(`/test/guilds/${guildId}/member-count`).body({member_count: memberCount}).execute();
|
||||
}
|
||||
|
||||
async function applyForDiscovery(
|
||||
harness: ApiTestHarness,
|
||||
token: string,
|
||||
guildId: string,
|
||||
description = 'A great community for testing discovery features',
|
||||
categoryId = DiscoveryCategories.GAMING,
|
||||
): Promise<DiscoveryApplicationResponse> {
|
||||
return createBuilder<DiscoveryApplicationResponse>(harness, token)
|
||||
.post(`/guilds/${guildId}/discovery`)
|
||||
.body({description, category_id: categoryId})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
}
|
||||
|
||||
async function adminApprove(
|
||||
harness: ApiTestHarness,
|
||||
adminToken: string,
|
||||
guildId: string,
|
||||
reason?: string,
|
||||
): Promise<DiscoveryApplicationResponse> {
|
||||
return createBuilder<DiscoveryApplicationResponse>(harness, `Bearer ${adminToken}`)
|
||||
.post(`/admin/discovery/applications/${guildId}/approve`)
|
||||
.body({reason})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
}
|
||||
|
||||
async function adminReject(
|
||||
harness: ApiTestHarness,
|
||||
adminToken: string,
|
||||
guildId: string,
|
||||
reason: string,
|
||||
): Promise<DiscoveryApplicationResponse> {
|
||||
return createBuilder<DiscoveryApplicationResponse>(harness, `Bearer ${adminToken}`)
|
||||
.post(`/admin/discovery/applications/${guildId}/reject`)
|
||||
.body({reason})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
}
|
||||
|
||||
async function createAdminAccount(harness: ApiTestHarness) {
|
||||
const admin = await createTestAccount(harness);
|
||||
return setUserACLs(harness, admin, ['admin:authenticate', 'discovery:review', 'discovery:remove']);
|
||||
}
|
||||
|
||||
describe('Discovery Application Lifecycle', () => {
|
||||
let harness: ApiTestHarness;
|
||||
|
||||
beforeEach(async () => {
|
||||
harness = await createApiTestHarness();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await harness?.shutdown();
|
||||
});
|
||||
|
||||
test('should apply for discovery and return pending application', async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, owner.token, 'Discovery Test Guild');
|
||||
await setGuildMemberCount(harness, guild.id, 1);
|
||||
|
||||
const application = await applyForDiscovery(harness, owner.token, guild.id);
|
||||
|
||||
expect(application.guild_id).toBe(guild.id);
|
||||
expect(application.status).toBe('pending');
|
||||
expect(application.description).toBe('A great community for testing discovery features');
|
||||
expect(application.category_id).toBe(DiscoveryCategories.GAMING);
|
||||
expect(application.applied_at).toBeTruthy();
|
||||
expect(application.reviewed_at).toBeNull();
|
||||
expect(application.review_reason).toBeNull();
|
||||
});
|
||||
|
||||
test('should retrieve application status', async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, owner.token, 'Status Test Guild');
|
||||
await setGuildMemberCount(harness, guild.id, 1);
|
||||
|
||||
await applyForDiscovery(harness, owner.token, guild.id);
|
||||
|
||||
const status = await createBuilder<DiscoveryApplicationResponse>(harness, owner.token)
|
||||
.get(`/guilds/${guild.id}/discovery`)
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
|
||||
expect(status.guild_id).toBe(guild.id);
|
||||
expect(status.status).toBe('pending');
|
||||
});
|
||||
|
||||
test('should edit pending application description', async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, owner.token, 'Edit Test Guild');
|
||||
await setGuildMemberCount(harness, guild.id, 1);
|
||||
|
||||
await applyForDiscovery(harness, owner.token, guild.id);
|
||||
|
||||
const updated = await createBuilder<DiscoveryApplicationResponse>(harness, owner.token)
|
||||
.patch(`/guilds/${guild.id}/discovery`)
|
||||
.body({description: 'Updated community description'})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
|
||||
expect(updated.description).toBe('Updated community description');
|
||||
expect(updated.category_id).toBe(DiscoveryCategories.GAMING);
|
||||
});
|
||||
|
||||
test('should edit pending application category', async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, owner.token, 'Category Edit Guild');
|
||||
await setGuildMemberCount(harness, guild.id, 1);
|
||||
|
||||
await applyForDiscovery(harness, owner.token, guild.id);
|
||||
|
||||
const updated = await createBuilder<DiscoveryApplicationResponse>(harness, owner.token)
|
||||
.patch(`/guilds/${guild.id}/discovery`)
|
||||
.body({category_id: DiscoveryCategories.EDUCATION})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
|
||||
expect(updated.category_id).toBe(DiscoveryCategories.EDUCATION);
|
||||
});
|
||||
|
||||
test('should withdraw pending application', async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, owner.token, 'Withdraw Test Guild');
|
||||
await setGuildMemberCount(harness, guild.id, 1);
|
||||
|
||||
await applyForDiscovery(harness, owner.token, guild.id);
|
||||
|
||||
await createBuilder(harness, owner.token)
|
||||
.delete(`/guilds/${guild.id}/discovery`)
|
||||
.expect(HTTP_STATUS.NO_CONTENT)
|
||||
.execute();
|
||||
|
||||
await createBuilder(harness, owner.token)
|
||||
.get(`/guilds/${guild.id}/discovery`)
|
||||
.expect(HTTP_STATUS.NOT_FOUND, APIErrorCodes.DISCOVERY_APPLICATION_NOT_FOUND)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('should complete full lifecycle: apply → approve → verify feature → withdraw', async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, owner.token, 'Full Lifecycle Guild');
|
||||
await setGuildMemberCount(harness, guild.id, 1);
|
||||
const admin = await createAdminAccount(harness);
|
||||
|
||||
const application = await applyForDiscovery(harness, owner.token, guild.id);
|
||||
expect(application.status).toBe('pending');
|
||||
|
||||
const approved = await adminApprove(harness, admin.token, guild.id, 'Meets all criteria');
|
||||
expect(approved.status).toBe('approved');
|
||||
expect(approved.reviewed_at).toBeTruthy();
|
||||
expect(approved.review_reason).toBe('Meets all criteria');
|
||||
|
||||
const guildData = await getGuild(harness, owner.token, guild.id);
|
||||
expect(guildData.features).toContain(GuildFeatures.DISCOVERABLE);
|
||||
|
||||
await createBuilder(harness, owner.token)
|
||||
.delete(`/guilds/${guild.id}/discovery`)
|
||||
.expect(HTTP_STATUS.NO_CONTENT)
|
||||
.execute();
|
||||
|
||||
const guildAfterWithdraw = await getGuild(harness, owner.token, guild.id);
|
||||
expect(guildAfterWithdraw.features).not.toContain(GuildFeatures.DISCOVERABLE);
|
||||
});
|
||||
|
||||
test('should allow reapplication after rejection', async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, owner.token, 'Reapply Test Guild');
|
||||
await setGuildMemberCount(harness, guild.id, 1);
|
||||
const admin = await createAdminAccount(harness);
|
||||
|
||||
await applyForDiscovery(harness, owner.token, guild.id);
|
||||
|
||||
await adminReject(harness, admin.token, guild.id, 'Needs more detail');
|
||||
|
||||
const status = await createBuilder<DiscoveryApplicationResponse>(harness, owner.token)
|
||||
.get(`/guilds/${guild.id}/discovery`)
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
expect(status.status).toBe('rejected');
|
||||
|
||||
const reapplication = await applyForDiscovery(
|
||||
harness,
|
||||
owner.token,
|
||||
guild.id,
|
||||
'Improved description with more detail about the community',
|
||||
);
|
||||
expect(reapplication.status).toBe('pending');
|
||||
});
|
||||
|
||||
test('should edit approved application description', async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, owner.token, 'Edit Approved Guild');
|
||||
await setGuildMemberCount(harness, guild.id, 1);
|
||||
const admin = await createAdminAccount(harness);
|
||||
|
||||
await applyForDiscovery(harness, owner.token, guild.id);
|
||||
await adminApprove(harness, admin.token, guild.id);
|
||||
|
||||
const updated = await createBuilder<DiscoveryApplicationResponse>(harness, owner.token)
|
||||
.patch(`/guilds/${guild.id}/discovery`)
|
||||
.body({description: 'Updated description after approval'})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
|
||||
expect(updated.description).toBe('Updated description after approval');
|
||||
expect(updated.status).toBe('approved');
|
||||
});
|
||||
|
||||
test('should apply with each valid category', async () => {
|
||||
const categories = [
|
||||
DiscoveryCategories.GAMING,
|
||||
DiscoveryCategories.MUSIC,
|
||||
DiscoveryCategories.ENTERTAINMENT,
|
||||
DiscoveryCategories.EDUCATION,
|
||||
DiscoveryCategories.SCIENCE_AND_TECHNOLOGY,
|
||||
DiscoveryCategories.CONTENT_CREATOR,
|
||||
DiscoveryCategories.ANIME_AND_MANGA,
|
||||
DiscoveryCategories.MOVIES_AND_TV,
|
||||
DiscoveryCategories.OTHER,
|
||||
];
|
||||
|
||||
for (const categoryId of categories) {
|
||||
const owner = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, owner.token, `Cat ${categoryId} Guild`);
|
||||
await setGuildMemberCount(harness, guild.id, 1);
|
||||
|
||||
const application = await applyForDiscovery(
|
||||
harness,
|
||||
owner.token,
|
||||
guild.id,
|
||||
'Valid description for this category',
|
||||
categoryId,
|
||||
);
|
||||
expect(application.category_id).toBe(categoryId);
|
||||
}
|
||||
});
|
||||
|
||||
test('should allow applying with minimum description length', async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, owner.token, 'Min Desc Guild');
|
||||
await setGuildMemberCount(harness, guild.id, 1);
|
||||
|
||||
const application = await applyForDiscovery(harness, owner.token, guild.id, 'Short desc');
|
||||
expect(application.description).toBe('Short desc');
|
||||
});
|
||||
|
||||
test('should allow applying with maximum description length', async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, owner.token, 'Max Desc Guild');
|
||||
await setGuildMemberCount(harness, guild.id, 1);
|
||||
|
||||
const maxDescription = 'A'.repeat(300);
|
||||
const application = await applyForDiscovery(harness, owner.token, guild.id, maxDescription);
|
||||
expect(application.description).toBe(maxDescription);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,364 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {createTestAccount, setUserACLs} from '@fluxer/api/src/auth/tests/AuthTestUtils';
|
||||
import {createGuild, setupTestGuildWithMembers} from '@fluxer/api/src/guild/tests/GuildTestUtils';
|
||||
import {type ApiTestHarness, createApiTestHarness} from '@fluxer/api/src/test/ApiTestHarness';
|
||||
import {HTTP_STATUS, TEST_IDS} from '@fluxer/api/src/test/TestConstants';
|
||||
import {createBuilder, createBuilderWithoutAuth} from '@fluxer/api/src/test/TestRequestBuilder';
|
||||
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
|
||||
import {DiscoveryCategories} from '@fluxer/constants/src/DiscoveryConstants';
|
||||
import type {DiscoveryApplicationResponse} from '@fluxer/schema/src/domains/guild/GuildDiscoverySchemas';
|
||||
import {afterEach, beforeEach, describe, expect, test} from 'vitest';
|
||||
|
||||
async function setGuildMemberCount(harness: ApiTestHarness, guildId: string, memberCount: number): Promise<void> {
|
||||
await createBuilder(harness, '').post(`/test/guilds/${guildId}/member-count`).body({member_count: memberCount}).execute();
|
||||
}
|
||||
|
||||
describe('Discovery Application Validation', () => {
|
||||
let harness: ApiTestHarness;
|
||||
|
||||
beforeEach(async () => {
|
||||
harness = await createApiTestHarness();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await harness?.shutdown();
|
||||
});
|
||||
|
||||
describe('member count requirements', () => {
|
||||
test('should allow application with 1 member in test mode', async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, owner.token, 'Small Guild');
|
||||
await setGuildMemberCount(harness, guild.id, 1);
|
||||
|
||||
const application = await createBuilder<DiscoveryApplicationResponse>(harness, owner.token)
|
||||
.post(`/guilds/${guild.id}/discovery`)
|
||||
.body({description: 'Small but active community', category_id: DiscoveryCategories.GAMING})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
|
||||
expect(application.status).toBe('pending');
|
||||
});
|
||||
|
||||
test('should reject application with 0 members', async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, owner.token, 'Empty Guild');
|
||||
await setGuildMemberCount(harness, guild.id, 0);
|
||||
|
||||
await createBuilder(harness, owner.token)
|
||||
.post(`/guilds/${guild.id}/discovery`)
|
||||
.body({description: 'No members yet', category_id: DiscoveryCategories.GAMING})
|
||||
.expect(HTTP_STATUS.BAD_REQUEST, APIErrorCodes.DISCOVERY_INSUFFICIENT_MEMBERS)
|
||||
.execute();
|
||||
});
|
||||
});
|
||||
|
||||
describe('category validation', () => {
|
||||
test('should reject invalid category ID above range', async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, owner.token, 'Bad Category Guild');
|
||||
await setGuildMemberCount(harness, guild.id, 1);
|
||||
|
||||
await createBuilder(harness, owner.token)
|
||||
.post(`/guilds/${guild.id}/discovery`)
|
||||
.body({description: 'Valid description here', category_id: 99})
|
||||
.expect(HTTP_STATUS.BAD_REQUEST)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('should reject negative category ID', async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, owner.token, 'Negative Cat Guild');
|
||||
await setGuildMemberCount(harness, guild.id, 1);
|
||||
|
||||
await createBuilder(harness, owner.token)
|
||||
.post(`/guilds/${guild.id}/discovery`)
|
||||
.body({description: 'Valid description here', category_id: -1})
|
||||
.expect(HTTP_STATUS.BAD_REQUEST)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('should reject invalid category on edit', async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, owner.token, 'Edit Bad Cat Guild');
|
||||
await setGuildMemberCount(harness, guild.id, 1);
|
||||
|
||||
await createBuilder<DiscoveryApplicationResponse>(harness, owner.token)
|
||||
.post(`/guilds/${guild.id}/discovery`)
|
||||
.body({description: 'Valid description here', category_id: DiscoveryCategories.GAMING})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
|
||||
await createBuilder(harness, owner.token)
|
||||
.patch(`/guilds/${guild.id}/discovery`)
|
||||
.body({category_id: 99})
|
||||
.expect(HTTP_STATUS.BAD_REQUEST)
|
||||
.execute();
|
||||
});
|
||||
});
|
||||
|
||||
describe('description validation', () => {
|
||||
test('should reject description shorter than minimum length', async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, owner.token, 'Short Desc Guild');
|
||||
await setGuildMemberCount(harness, guild.id, 1);
|
||||
|
||||
await createBuilder(harness, owner.token)
|
||||
.post(`/guilds/${guild.id}/discovery`)
|
||||
.body({description: 'Too short', category_id: DiscoveryCategories.GAMING})
|
||||
.expect(HTTP_STATUS.BAD_REQUEST)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('should reject description longer than maximum length', async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, owner.token, 'Long Desc Guild');
|
||||
await setGuildMemberCount(harness, guild.id, 1);
|
||||
|
||||
await createBuilder(harness, owner.token)
|
||||
.post(`/guilds/${guild.id}/discovery`)
|
||||
.body({description: 'A'.repeat(301), category_id: DiscoveryCategories.GAMING})
|
||||
.expect(HTTP_STATUS.BAD_REQUEST)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('should reject missing description', async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, owner.token, 'No Desc Guild');
|
||||
await setGuildMemberCount(harness, guild.id, 1);
|
||||
|
||||
await createBuilder(harness, owner.token)
|
||||
.post(`/guilds/${guild.id}/discovery`)
|
||||
.body({category_id: DiscoveryCategories.GAMING})
|
||||
.expect(HTTP_STATUS.BAD_REQUEST)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('should reject missing category_id', async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, owner.token, 'No Cat Guild');
|
||||
await setGuildMemberCount(harness, guild.id, 1);
|
||||
|
||||
await createBuilder(harness, owner.token)
|
||||
.post(`/guilds/${guild.id}/discovery`)
|
||||
.body({description: 'Valid description here'})
|
||||
.expect(HTTP_STATUS.BAD_REQUEST)
|
||||
.execute();
|
||||
});
|
||||
});
|
||||
|
||||
describe('duplicate application', () => {
|
||||
test('should reject duplicate application when pending', async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, owner.token, 'Dupe Pending Guild');
|
||||
await setGuildMemberCount(harness, guild.id, 1);
|
||||
|
||||
await createBuilder<DiscoveryApplicationResponse>(harness, owner.token)
|
||||
.post(`/guilds/${guild.id}/discovery`)
|
||||
.body({description: 'First application attempt', category_id: DiscoveryCategories.GAMING})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
|
||||
await createBuilder(harness, owner.token)
|
||||
.post(`/guilds/${guild.id}/discovery`)
|
||||
.body({description: 'Second application attempt', category_id: DiscoveryCategories.GAMING})
|
||||
.expect(HTTP_STATUS.CONFLICT, APIErrorCodes.DISCOVERY_ALREADY_APPLIED)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('should reject duplicate application when approved', async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, owner.token, 'Dupe Approved Guild');
|
||||
await setGuildMemberCount(harness, guild.id, 1);
|
||||
const admin = await createTestAccount(harness);
|
||||
await setUserACLs(harness, admin, ['admin:authenticate', 'discovery:review']);
|
||||
|
||||
await createBuilder<DiscoveryApplicationResponse>(harness, owner.token)
|
||||
.post(`/guilds/${guild.id}/discovery`)
|
||||
.body({description: 'Application to be approved', category_id: DiscoveryCategories.GAMING})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
|
||||
await createBuilder(harness, `Bearer ${admin.token}`)
|
||||
.post(`/admin/discovery/applications/${guild.id}/approve`)
|
||||
.body({})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
|
||||
await createBuilder(harness, owner.token)
|
||||
.post(`/guilds/${guild.id}/discovery`)
|
||||
.body({description: 'Trying to reapply while approved', category_id: DiscoveryCategories.GAMING})
|
||||
.expect(HTTP_STATUS.CONFLICT, APIErrorCodes.DISCOVERY_ALREADY_APPLIED)
|
||||
.execute();
|
||||
});
|
||||
});
|
||||
|
||||
describe('permission requirements', () => {
|
||||
test('should require MANAGE_GUILD permission to apply', async () => {
|
||||
const {members, guild} = await setupTestGuildWithMembers(harness, 1);
|
||||
const member = members[0];
|
||||
await setGuildMemberCount(harness, guild.id, 1);
|
||||
|
||||
await createBuilder(harness, member.token)
|
||||
.post(`/guilds/${guild.id}/discovery`)
|
||||
.body({description: 'Should not be allowed', category_id: DiscoveryCategories.GAMING})
|
||||
.expect(HTTP_STATUS.FORBIDDEN, APIErrorCodes.MISSING_PERMISSIONS)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('should require MANAGE_GUILD permission to edit', async () => {
|
||||
const {owner, members, guild} = await setupTestGuildWithMembers(harness, 1);
|
||||
const member = members[0];
|
||||
await setGuildMemberCount(harness, guild.id, 1);
|
||||
|
||||
await createBuilder<DiscoveryApplicationResponse>(harness, owner.token)
|
||||
.post(`/guilds/${guild.id}/discovery`)
|
||||
.body({description: 'Owner applied for discovery', category_id: DiscoveryCategories.GAMING})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
|
||||
await createBuilder(harness, member.token)
|
||||
.patch(`/guilds/${guild.id}/discovery`)
|
||||
.body({description: 'Member tries to edit'})
|
||||
.expect(HTTP_STATUS.FORBIDDEN, APIErrorCodes.MISSING_PERMISSIONS)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('should require MANAGE_GUILD permission to withdraw', async () => {
|
||||
const {owner, members, guild} = await setupTestGuildWithMembers(harness, 1);
|
||||
const member = members[0];
|
||||
await setGuildMemberCount(harness, guild.id, 1);
|
||||
|
||||
await createBuilder<DiscoveryApplicationResponse>(harness, owner.token)
|
||||
.post(`/guilds/${guild.id}/discovery`)
|
||||
.body({description: 'Owner applied for discovery', category_id: DiscoveryCategories.GAMING})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
|
||||
await createBuilder(harness, member.token)
|
||||
.delete(`/guilds/${guild.id}/discovery`)
|
||||
.expect(HTTP_STATUS.FORBIDDEN, APIErrorCodes.MISSING_PERMISSIONS)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('should require MANAGE_GUILD permission to get status', async () => {
|
||||
const {members, guild} = await setupTestGuildWithMembers(harness, 1);
|
||||
const member = members[0];
|
||||
|
||||
await createBuilder(harness, member.token)
|
||||
.get(`/guilds/${guild.id}/discovery`)
|
||||
.expect(HTTP_STATUS.FORBIDDEN, APIErrorCodes.MISSING_PERMISSIONS)
|
||||
.execute();
|
||||
});
|
||||
});
|
||||
|
||||
describe('authentication requirements', () => {
|
||||
test('should require login to apply', async () => {
|
||||
await createBuilderWithoutAuth(harness)
|
||||
.post(`/guilds/${TEST_IDS.NONEXISTENT_GUILD}/discovery`)
|
||||
.body({description: 'No auth attempt', category_id: DiscoveryCategories.GAMING})
|
||||
.expect(HTTP_STATUS.UNAUTHORIZED)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('should require login to get status', async () => {
|
||||
await createBuilderWithoutAuth(harness)
|
||||
.get(`/guilds/${TEST_IDS.NONEXISTENT_GUILD}/discovery`)
|
||||
.expect(HTTP_STATUS.UNAUTHORIZED)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('should require login to edit', async () => {
|
||||
await createBuilderWithoutAuth(harness)
|
||||
.patch(`/guilds/${TEST_IDS.NONEXISTENT_GUILD}/discovery`)
|
||||
.body({description: 'No auth edit'})
|
||||
.expect(HTTP_STATUS.UNAUTHORIZED)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('should require login to withdraw', async () => {
|
||||
await createBuilderWithoutAuth(harness)
|
||||
.delete(`/guilds/${TEST_IDS.NONEXISTENT_GUILD}/discovery`)
|
||||
.expect(HTTP_STATUS.UNAUTHORIZED)
|
||||
.execute();
|
||||
});
|
||||
});
|
||||
|
||||
describe('non-existent application', () => {
|
||||
test('should return error when getting status with no application', async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, owner.token, 'No App Guild');
|
||||
|
||||
await createBuilder(harness, owner.token)
|
||||
.get(`/guilds/${guild.id}/discovery`)
|
||||
.expect(HTTP_STATUS.NOT_FOUND, APIErrorCodes.DISCOVERY_APPLICATION_NOT_FOUND)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('should return error when editing non-existent application', async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, owner.token, 'No Edit App Guild');
|
||||
|
||||
await createBuilder(harness, owner.token)
|
||||
.patch(`/guilds/${guild.id}/discovery`)
|
||||
.body({description: 'Editing nothing'})
|
||||
.expect(HTTP_STATUS.NOT_FOUND, APIErrorCodes.DISCOVERY_APPLICATION_NOT_FOUND)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('should return error when withdrawing non-existent application', async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, owner.token, 'No Withdraw App Guild');
|
||||
|
||||
await createBuilder(harness, owner.token)
|
||||
.delete(`/guilds/${guild.id}/discovery`)
|
||||
.expect(HTTP_STATUS.NOT_FOUND, APIErrorCodes.DISCOVERY_APPLICATION_NOT_FOUND)
|
||||
.execute();
|
||||
});
|
||||
});
|
||||
|
||||
describe('edit restrictions', () => {
|
||||
test('should not allow editing rejected application', async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, owner.token, 'Rejected Edit Guild');
|
||||
await setGuildMemberCount(harness, guild.id, 1);
|
||||
const admin = await createTestAccount(harness);
|
||||
await setUserACLs(harness, admin, ['admin:authenticate', 'discovery:review']);
|
||||
|
||||
await createBuilder<DiscoveryApplicationResponse>(harness, owner.token)
|
||||
.post(`/guilds/${guild.id}/discovery`)
|
||||
.body({description: 'To be rejected for edit test', category_id: DiscoveryCategories.GAMING})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
|
||||
await createBuilder(harness, `Bearer ${admin.token}`)
|
||||
.post(`/admin/discovery/applications/${guild.id}/reject`)
|
||||
.body({reason: 'Not suitable'})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
|
||||
await createBuilder(harness, owner.token)
|
||||
.patch(`/guilds/${guild.id}/discovery`)
|
||||
.body({description: 'Trying to edit rejected'})
|
||||
.expect(HTTP_STATUS.CONFLICT, APIErrorCodes.DISCOVERY_APPLICATION_ALREADY_REVIEWED)
|
||||
.execute();
|
||||
});
|
||||
});
|
||||
});
|
||||
295
packages/api/src/guild/tests/DiscoverySearchAndJoin.test.tsx
Normal file
295
packages/api/src/guild/tests/DiscoverySearchAndJoin.test.tsx
Normal file
@@ -0,0 +1,295 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {createTestAccount, setUserACLs} from '@fluxer/api/src/auth/tests/AuthTestUtils';
|
||||
import {createGuild, getUserGuilds} from '@fluxer/api/src/guild/tests/GuildTestUtils';
|
||||
import {type ApiTestHarness, createApiTestHarness} from '@fluxer/api/src/test/ApiTestHarness';
|
||||
import {HTTP_STATUS, TEST_IDS} from '@fluxer/api/src/test/TestConstants';
|
||||
import {createBuilder, createBuilderWithoutAuth} from '@fluxer/api/src/test/TestRequestBuilder';
|
||||
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
|
||||
import {DiscoveryCategories, DiscoveryCategoryLabels} from '@fluxer/constants/src/DiscoveryConstants';
|
||||
import type {
|
||||
DiscoveryApplicationResponse,
|
||||
DiscoveryCategoryResponse,
|
||||
DiscoveryGuildListResponse,
|
||||
} from '@fluxer/schema/src/domains/guild/GuildDiscoverySchemas';
|
||||
import {afterEach, beforeEach, describe, expect, test} from 'vitest';
|
||||
|
||||
async function setGuildMemberCount(harness: ApiTestHarness, guildId: string, memberCount: number): Promise<void> {
|
||||
await createBuilder(harness, '').post(`/test/guilds/${guildId}/member-count`).body({member_count: memberCount}).execute();
|
||||
}
|
||||
|
||||
async function applyAndApprove(
|
||||
harness: ApiTestHarness,
|
||||
ownerToken: string,
|
||||
adminToken: string,
|
||||
guildId: string,
|
||||
description: string,
|
||||
categoryId: number,
|
||||
): Promise<void> {
|
||||
await createBuilder<DiscoveryApplicationResponse>(harness, ownerToken)
|
||||
.post(`/guilds/${guildId}/discovery`)
|
||||
.body({description, category_id: categoryId})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
|
||||
await createBuilder(harness, `Bearer ${adminToken}`)
|
||||
.post(`/admin/discovery/applications/${guildId}/approve`)
|
||||
.body({})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
}
|
||||
|
||||
describe('Discovery Search and Join', () => {
|
||||
let harness: ApiTestHarness;
|
||||
|
||||
beforeEach(async () => {
|
||||
harness = await createApiTestHarness();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await harness?.shutdown();
|
||||
});
|
||||
|
||||
describe('categories', () => {
|
||||
test('should list all discovery categories', async () => {
|
||||
const user = await createTestAccount(harness);
|
||||
|
||||
const categories = await createBuilder<Array<DiscoveryCategoryResponse>>(harness, user.token)
|
||||
.get('/discovery/categories')
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
|
||||
const expectedCount = Object.keys(DiscoveryCategoryLabels).length;
|
||||
expect(categories).toHaveLength(expectedCount);
|
||||
|
||||
for (const category of categories) {
|
||||
expect(category.id).toBeTypeOf('number');
|
||||
expect(category.name).toBeTypeOf('string');
|
||||
expect(category.name.length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
test('should include known categories', async () => {
|
||||
const user = await createTestAccount(harness);
|
||||
|
||||
const categories = await createBuilder<Array<DiscoveryCategoryResponse>>(harness, user.token)
|
||||
.get('/discovery/categories')
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
|
||||
const names = categories.map((c) => c.name);
|
||||
expect(names).toContain('Gaming');
|
||||
expect(names).toContain('Music');
|
||||
expect(names).toContain('Education');
|
||||
expect(names).toContain('Science & Technology');
|
||||
});
|
||||
|
||||
test('should require login to list categories', async () => {
|
||||
await createBuilderWithoutAuth(harness)
|
||||
.get('/discovery/categories')
|
||||
.expect(HTTP_STATUS.UNAUTHORIZED)
|
||||
.execute();
|
||||
});
|
||||
});
|
||||
|
||||
describe('search', () => {
|
||||
test('should return empty results when no guilds are discoverable', async () => {
|
||||
const user = await createTestAccount(harness);
|
||||
|
||||
const results = await createBuilder<DiscoveryGuildListResponse>(harness, user.token)
|
||||
.get('/discovery/guilds')
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
|
||||
expect(results.guilds).toHaveLength(0);
|
||||
expect(results.total).toBe(0);
|
||||
});
|
||||
|
||||
test('should return approved guilds in search results', async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, owner.token, 'Searchable Guild');
|
||||
await setGuildMemberCount(harness, guild.id, 10);
|
||||
|
||||
const admin = await createTestAccount(harness);
|
||||
await setUserACLs(harness, admin, ['admin:authenticate', 'discovery:review']);
|
||||
|
||||
await applyAndApprove(
|
||||
harness,
|
||||
owner.token,
|
||||
admin.token,
|
||||
guild.id,
|
||||
'A searchable community for all',
|
||||
DiscoveryCategories.GAMING,
|
||||
);
|
||||
|
||||
const searcher = await createTestAccount(harness);
|
||||
const results = await createBuilder<DiscoveryGuildListResponse>(harness, searcher.token)
|
||||
.get('/discovery/guilds')
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
|
||||
expect(results.guilds.length).toBeGreaterThanOrEqual(1);
|
||||
const found = results.guilds.find((g) => g.id === guild.id);
|
||||
expect(found).toBeDefined();
|
||||
expect(found!.name).toBe('Searchable Guild');
|
||||
expect(found!.description).toBe('A searchable community for all');
|
||||
expect(found!.category_id).toBe(DiscoveryCategories.GAMING);
|
||||
});
|
||||
|
||||
test('should not return pending guilds in search results', async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, owner.token, 'Pending Guild');
|
||||
await setGuildMemberCount(harness, guild.id, 1);
|
||||
|
||||
await createBuilder<DiscoveryApplicationResponse>(harness, owner.token)
|
||||
.post(`/guilds/${guild.id}/discovery`)
|
||||
.body({description: 'Pending application guild', category_id: DiscoveryCategories.GAMING})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
|
||||
const searcher = await createTestAccount(harness);
|
||||
const results = await createBuilder<DiscoveryGuildListResponse>(harness, searcher.token)
|
||||
.get('/discovery/guilds')
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
|
||||
const found = results.guilds.find((g) => g.id === guild.id);
|
||||
expect(found).toBeUndefined();
|
||||
});
|
||||
|
||||
test('should filter by category', async () => {
|
||||
const admin = await createTestAccount(harness);
|
||||
await setUserACLs(harness, admin, ['admin:authenticate', 'discovery:review']);
|
||||
|
||||
const owner1 = await createTestAccount(harness);
|
||||
const gamingGuild = await createGuild(harness, owner1.token, 'Gaming Community');
|
||||
await setGuildMemberCount(harness, gamingGuild.id, 10);
|
||||
await applyAndApprove(harness, owner1.token, admin.token, gamingGuild.id, 'All about gaming', DiscoveryCategories.GAMING);
|
||||
|
||||
const owner2 = await createTestAccount(harness);
|
||||
const musicGuild = await createGuild(harness, owner2.token, 'Music Community');
|
||||
await setGuildMemberCount(harness, musicGuild.id, 10);
|
||||
await applyAndApprove(harness, owner2.token, admin.token, musicGuild.id, 'All about music', DiscoveryCategories.MUSIC);
|
||||
|
||||
const searcher = await createTestAccount(harness);
|
||||
const results = await createBuilder<DiscoveryGuildListResponse>(harness, searcher.token)
|
||||
.get(`/discovery/guilds?category=${DiscoveryCategories.GAMING}`)
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
|
||||
for (const guild of results.guilds) {
|
||||
expect(guild.category_id).toBe(DiscoveryCategories.GAMING);
|
||||
}
|
||||
});
|
||||
|
||||
test('should respect limit parameter', async () => {
|
||||
const admin = await createTestAccount(harness);
|
||||
await setUserACLs(harness, admin, ['admin:authenticate', 'discovery:review']);
|
||||
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const owner = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, owner.token, `Limit Test Guild ${i}`);
|
||||
await setGuildMemberCount(harness, guild.id, 10);
|
||||
await applyAndApprove(harness, owner.token, admin.token, guild.id, `Community number ${i} for testing`, DiscoveryCategories.GAMING);
|
||||
}
|
||||
|
||||
const searcher = await createTestAccount(harness);
|
||||
const results = await createBuilder<DiscoveryGuildListResponse>(harness, searcher.token)
|
||||
.get('/discovery/guilds?limit=2')
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
|
||||
expect(results.guilds.length).toBeLessThanOrEqual(2);
|
||||
});
|
||||
|
||||
test('should require login to search', async () => {
|
||||
await createBuilderWithoutAuth(harness)
|
||||
.get('/discovery/guilds')
|
||||
.expect(HTTP_STATUS.UNAUTHORIZED)
|
||||
.execute();
|
||||
});
|
||||
});
|
||||
|
||||
describe('join', () => {
|
||||
test('should join a discoverable guild', async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, owner.token, 'Joinable Guild');
|
||||
await setGuildMemberCount(harness, guild.id, 10);
|
||||
|
||||
const admin = await createTestAccount(harness);
|
||||
await setUserACLs(harness, admin, ['admin:authenticate', 'discovery:review']);
|
||||
await applyAndApprove(harness, owner.token, admin.token, guild.id, 'Join this community', DiscoveryCategories.GAMING);
|
||||
|
||||
const joiner = await createTestAccount(harness);
|
||||
await createBuilder(harness, joiner.token)
|
||||
.post(`/discovery/guilds/${guild.id}/join`)
|
||||
.expect(HTTP_STATUS.NO_CONTENT)
|
||||
.execute();
|
||||
|
||||
const guilds = await getUserGuilds(harness, joiner.token);
|
||||
const joined = guilds.find((g) => g.id === guild.id);
|
||||
expect(joined).toBeDefined();
|
||||
});
|
||||
|
||||
test('should not allow joining non-discoverable guild', async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, owner.token, 'Not Discoverable');
|
||||
|
||||
const joiner = await createTestAccount(harness);
|
||||
await createBuilder(harness, joiner.token)
|
||||
.post(`/discovery/guilds/${guild.id}/join`)
|
||||
.expect(HTTP_STATUS.BAD_REQUEST, APIErrorCodes.DISCOVERY_NOT_DISCOVERABLE)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('should not allow joining guild with only pending application', async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, owner.token, 'Pending Join Guild');
|
||||
await setGuildMemberCount(harness, guild.id, 1);
|
||||
|
||||
await createBuilder<DiscoveryApplicationResponse>(harness, owner.token)
|
||||
.post(`/guilds/${guild.id}/discovery`)
|
||||
.body({description: 'Pending but not yet approved', category_id: DiscoveryCategories.GAMING})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
|
||||
const joiner = await createTestAccount(harness);
|
||||
await createBuilder(harness, joiner.token)
|
||||
.post(`/discovery/guilds/${guild.id}/join`)
|
||||
.expect(HTTP_STATUS.BAD_REQUEST, APIErrorCodes.DISCOVERY_NOT_DISCOVERABLE)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('should not allow joining with nonexistent guild ID', async () => {
|
||||
const joiner = await createTestAccount(harness);
|
||||
await createBuilder(harness, joiner.token)
|
||||
.post(`/discovery/guilds/${TEST_IDS.NONEXISTENT_GUILD}/join`)
|
||||
.expect(HTTP_STATUS.BAD_REQUEST, APIErrorCodes.DISCOVERY_NOT_DISCOVERABLE)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('should require login to join', async () => {
|
||||
await createBuilderWithoutAuth(harness)
|
||||
.post(`/discovery/guilds/${TEST_IDS.NONEXISTENT_GUILD}/join`)
|
||||
.expect(HTTP_STATUS.UNAUTHORIZED)
|
||||
.execute();
|
||||
});
|
||||
});
|
||||
});
|
||||
208
packages/api/src/guild/tests/GuildAssetUpload.test.tsx
Normal file
208
packages/api/src/guild/tests/GuildAssetUpload.test.tsx
Normal file
@@ -0,0 +1,208 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {createTestAccount} from '@fluxer/api/src/auth/tests/AuthTestUtils';
|
||||
import {getGifDataUrl, getPngDataUrl} from '@fluxer/api/src/emoji/tests/EmojiTestUtils';
|
||||
import {createGuild, updateGuild} from '@fluxer/api/src/guild/tests/GuildTestUtils';
|
||||
import {ensureSessionStarted} from '@fluxer/api/src/message/tests/MessageTestUtils';
|
||||
import {type ApiTestHarness, createApiTestHarness} from '@fluxer/api/src/test/ApiTestHarness';
|
||||
import {HTTP_STATUS} from '@fluxer/api/src/test/TestConstants';
|
||||
import {createBuilder} from '@fluxer/api/src/test/TestRequestBuilder';
|
||||
import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest';
|
||||
|
||||
const AVATAR_MAX_SIZE = 10 * 1024 * 1024;
|
||||
|
||||
function getTooLargeImageDataUrl(): string {
|
||||
const largeData = 'A'.repeat(AVATAR_MAX_SIZE + 10000);
|
||||
const base64 = Buffer.from(largeData).toString('base64');
|
||||
return getPngDataUrl(base64);
|
||||
}
|
||||
|
||||
async function grantGuildFeature(harness: ApiTestHarness, guildId: string, feature: string): Promise<void> {
|
||||
await createBuilder<{success: boolean}>(harness, '')
|
||||
.post(`/test/guilds/${guildId}/features`)
|
||||
.body({add_features: [feature]})
|
||||
.execute();
|
||||
}
|
||||
|
||||
describe('Guild Asset Upload', () => {
|
||||
let harness: ApiTestHarness;
|
||||
|
||||
beforeAll(async () => {
|
||||
harness = await createApiTestHarness();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await harness.reset();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await harness?.shutdown();
|
||||
});
|
||||
|
||||
describe('Guild Icon', () => {
|
||||
it('allows uploading valid GIF icon', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
await ensureSessionStarted(harness, account.token);
|
||||
|
||||
const guild = await createGuild(harness, account.token, 'Icon GIF Test Guild');
|
||||
|
||||
const updated = await updateGuild(harness, account.token, guild.id, {
|
||||
icon: getGifDataUrl(),
|
||||
});
|
||||
|
||||
expect(updated.icon).toBeTruthy();
|
||||
});
|
||||
|
||||
it('rejects icon that exceeds size limit', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
await ensureSessionStarted(harness, account.token);
|
||||
|
||||
const guild = await createGuild(harness, account.token, 'Icon Size Limit Test');
|
||||
|
||||
const json = await createBuilder<{errors?: Array<{path?: string; code?: string}>}>(harness, account.token)
|
||||
.patch(`/guilds/${guild.id}`)
|
||||
.body({icon: getTooLargeImageDataUrl()})
|
||||
.expect(HTTP_STATUS.BAD_REQUEST)
|
||||
.execute();
|
||||
|
||||
expect(json.errors?.[0]?.code).toBe('BASE64_LENGTH_INVALID');
|
||||
});
|
||||
|
||||
it('allows clearing icon by setting to null', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
await ensureSessionStarted(harness, account.token);
|
||||
|
||||
const guild = await createGuild(harness, account.token, 'Icon Clear Test');
|
||||
|
||||
await updateGuild(harness, account.token, guild.id, {
|
||||
icon: getPngDataUrl(),
|
||||
});
|
||||
|
||||
const cleared = await updateGuild(harness, account.token, guild.id, {
|
||||
icon: null,
|
||||
});
|
||||
|
||||
expect(cleared.icon).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Guild Banner', () => {
|
||||
it('allows banner upload with BANNER feature', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
await ensureSessionStarted(harness, account.token);
|
||||
|
||||
const guild = await createGuild(harness, account.token, 'Banner Feature Test');
|
||||
|
||||
await grantGuildFeature(harness, guild.id, 'BANNER');
|
||||
|
||||
const updated = await updateGuild(harness, account.token, guild.id, {
|
||||
banner: getPngDataUrl(),
|
||||
});
|
||||
|
||||
expect(updated.banner).toBeTruthy();
|
||||
});
|
||||
|
||||
it('allows animated banner with BANNER and ANIMATED_BANNER features', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
await ensureSessionStarted(harness, account.token);
|
||||
|
||||
const guild = await createGuild(harness, account.token, 'Animated Banner Test');
|
||||
|
||||
await grantGuildFeature(harness, guild.id, 'BANNER');
|
||||
await grantGuildFeature(harness, guild.id, 'ANIMATED_BANNER');
|
||||
|
||||
const updated = await updateGuild(harness, account.token, guild.id, {
|
||||
banner: getGifDataUrl(),
|
||||
});
|
||||
|
||||
expect(updated.banner).toBeTruthy();
|
||||
});
|
||||
|
||||
it('rejects banner that exceeds size limit', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
await ensureSessionStarted(harness, account.token);
|
||||
|
||||
const guild = await createGuild(harness, account.token, 'Banner Size Test');
|
||||
|
||||
await grantGuildFeature(harness, guild.id, 'BANNER');
|
||||
|
||||
const json = await createBuilder<{errors?: Array<{path?: string; code?: string}>}>(harness, account.token)
|
||||
.patch(`/guilds/${guild.id}`)
|
||||
.body({banner: getTooLargeImageDataUrl()})
|
||||
.expect(HTTP_STATUS.BAD_REQUEST)
|
||||
.execute();
|
||||
|
||||
expect(json.errors?.[0]?.code).toBe('BASE64_LENGTH_INVALID');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Guild Splash', () => {
|
||||
it('allows splash upload with INVITE_SPLASH feature', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
await ensureSessionStarted(harness, account.token);
|
||||
|
||||
const guild = await createGuild(harness, account.token, 'Splash Feature Test');
|
||||
|
||||
await grantGuildFeature(harness, guild.id, 'INVITE_SPLASH');
|
||||
|
||||
const updated = await updateGuild(harness, account.token, guild.id, {
|
||||
splash: getPngDataUrl(),
|
||||
});
|
||||
|
||||
expect(updated.splash).toBeTruthy();
|
||||
});
|
||||
|
||||
it('allows clearing splash by setting to null', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
await ensureSessionStarted(harness, account.token);
|
||||
|
||||
const guild = await createGuild(harness, account.token, 'Splash Clear Test');
|
||||
|
||||
await grantGuildFeature(harness, guild.id, 'INVITE_SPLASH');
|
||||
|
||||
await updateGuild(harness, account.token, guild.id, {
|
||||
splash: getPngDataUrl(),
|
||||
});
|
||||
|
||||
const cleared = await updateGuild(harness, account.token, guild.id, {
|
||||
splash: null,
|
||||
});
|
||||
|
||||
expect(cleared.splash).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects splash that exceeds size limit', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
await ensureSessionStarted(harness, account.token);
|
||||
|
||||
const guild = await createGuild(harness, account.token, 'Splash Size Test');
|
||||
|
||||
await grantGuildFeature(harness, guild.id, 'INVITE_SPLASH');
|
||||
|
||||
const json = await createBuilder<{errors?: Array<{path?: string; code?: string}>}>(harness, account.token)
|
||||
.patch(`/guilds/${guild.id}`)
|
||||
.body({splash: getTooLargeImageDataUrl()})
|
||||
.expect(HTTP_STATUS.BAD_REQUEST)
|
||||
.execute();
|
||||
|
||||
expect(json.errors?.[0]?.code).toBe('BASE64_LENGTH_INVALID');
|
||||
});
|
||||
});
|
||||
});
|
||||
249
packages/api/src/guild/tests/GuildAuditLogs.test.tsx
Normal file
249
packages/api/src/guild/tests/GuildAuditLogs.test.tsx
Normal file
@@ -0,0 +1,249 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {
|
||||
createChannel,
|
||||
createChannelInvite,
|
||||
createRole,
|
||||
setupTestGuildWithMembers,
|
||||
updateRole,
|
||||
} from '@fluxer/api/src/guild/tests/GuildTestUtils';
|
||||
import {type ApiTestHarness, createApiTestHarness} from '@fluxer/api/src/test/ApiTestHarness';
|
||||
import {HTTP_STATUS} from '@fluxer/api/src/test/TestConstants';
|
||||
import {createBuilder, createBuilderWithoutAuth} from '@fluxer/api/src/test/TestRequestBuilder';
|
||||
import {createWebhook} from '@fluxer/api/src/webhook/tests/WebhookTestUtils';
|
||||
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
|
||||
import {AuditLogActionType} from '@fluxer/constants/src/AuditLogActionType';
|
||||
import {Permissions} from '@fluxer/constants/src/ChannelConstants';
|
||||
import {afterEach, beforeEach, describe, expect, test} from 'vitest';
|
||||
|
||||
interface AuditLogChange {
|
||||
key: string;
|
||||
old_value?: unknown;
|
||||
new_value?: unknown;
|
||||
}
|
||||
|
||||
interface AuditLogOptions {
|
||||
channel_id?: string;
|
||||
max_age?: number;
|
||||
max_uses?: number;
|
||||
temporary?: boolean;
|
||||
}
|
||||
|
||||
interface AuditLogEntry {
|
||||
id: string;
|
||||
action_type: number;
|
||||
user_id: string | null;
|
||||
target_id: string | null;
|
||||
reason?: string;
|
||||
options?: AuditLogOptions;
|
||||
changes?: Array<AuditLogChange>;
|
||||
}
|
||||
|
||||
interface AuditLogWebhook {
|
||||
id: string;
|
||||
type: number;
|
||||
guild_id: string | null;
|
||||
channel_id: string | null;
|
||||
name: string;
|
||||
avatar_hash: string | null;
|
||||
}
|
||||
|
||||
interface AuditLogResponse {
|
||||
audit_log_entries: Array<AuditLogEntry>;
|
||||
users: Array<{id: string}>;
|
||||
webhooks: Array<AuditLogWebhook>;
|
||||
}
|
||||
|
||||
interface PermissionsDiff {
|
||||
added: Array<string>;
|
||||
removed: Array<string>;
|
||||
}
|
||||
|
||||
describe('Guild audit log endpoint', () => {
|
||||
let harness: ApiTestHarness;
|
||||
|
||||
beforeEach(async () => {
|
||||
harness = await createApiTestHarness();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await harness?.shutdown();
|
||||
});
|
||||
|
||||
test('rejects unauthenticated requests', async () => {
|
||||
const {guild} = await setupTestGuildWithMembers(harness, 1);
|
||||
|
||||
await createBuilderWithoutAuth(harness)
|
||||
.get(`/guilds/${guild.id}/audit-logs`)
|
||||
.expect(HTTP_STATUS.UNAUTHORIZED)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('requires view audit log permission', async () => {
|
||||
const {owner, members, guild} = await setupTestGuildWithMembers(harness, 1);
|
||||
const member = members[0];
|
||||
|
||||
await updateRole(harness, owner.token, guild.id, guild.id, {
|
||||
permissions: Permissions.VIEW_CHANNEL.toString(),
|
||||
});
|
||||
|
||||
await createBuilder(harness, member.token)
|
||||
.get(`/guilds/${guild.id}/audit-logs`)
|
||||
.expect(HTTP_STATUS.FORBIDDEN)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('includes permissions diff entries for role updates', async () => {
|
||||
const {owner, guild} = await setupTestGuildWithMembers(harness, 1);
|
||||
|
||||
const role = await createRole(harness, owner.token, guild.id, {
|
||||
name: 'Audit Role',
|
||||
permissions: Permissions.VIEW_CHANNEL.toString(),
|
||||
});
|
||||
|
||||
await updateRole(harness, owner.token, guild.id, role.id, {
|
||||
permissions: (Permissions.VIEW_CHANNEL | Permissions.SEND_MESSAGES).toString(),
|
||||
});
|
||||
|
||||
const response = await createBuilder<AuditLogResponse>(harness, owner.token)
|
||||
.get(`/guilds/${guild.id}/audit-logs?action_type=${AuditLogActionType.ROLE_UPDATE}`)
|
||||
.execute();
|
||||
|
||||
const entry = response.audit_log_entries.find((log) => log.target_id === role.id);
|
||||
expect(entry).toBeDefined();
|
||||
|
||||
const diffChange = entry?.changes?.find((change) => change.key === 'permissions_diff');
|
||||
expect(diffChange).toBeDefined();
|
||||
|
||||
const permissionsDiff = diffChange?.new_value as PermissionsDiff | undefined;
|
||||
expect(permissionsDiff).toBeDefined();
|
||||
expect(permissionsDiff?.added).toContain('SEND_MESSAGES');
|
||||
expect(permissionsDiff?.removed).toEqual([]);
|
||||
});
|
||||
|
||||
test('returns webhook references for webhook audit log entries', async () => {
|
||||
const {owner, guild} = await setupTestGuildWithMembers(harness, 1);
|
||||
const channel = await createChannel(harness, owner.token, guild.id, 'hooks');
|
||||
const webhook = await createWebhook(harness, channel.id, owner.token, 'Audit Webhook');
|
||||
|
||||
const response = await createBuilder<AuditLogResponse>(harness, owner.token)
|
||||
.get(`/guilds/${guild.id}/audit-logs?action_type=${AuditLogActionType.WEBHOOK_CREATE}`)
|
||||
.execute();
|
||||
|
||||
const entry = response.audit_log_entries.find((log) => log.target_id === webhook.id);
|
||||
expect(entry).toBeDefined();
|
||||
expect(entry?.options?.channel_id).toBe(channel.id);
|
||||
|
||||
const webhookEntry = response.webhooks.find((record) => record.id === webhook.id);
|
||||
expect(webhookEntry).toBeDefined();
|
||||
expect(webhookEntry?.channel_id).toBe(channel.id);
|
||||
expect(webhookEntry?.guild_id).toBe(guild.id);
|
||||
});
|
||||
|
||||
test('maps invite metadata into audit log options', async () => {
|
||||
const {owner, guild} = await setupTestGuildWithMembers(harness, 1);
|
||||
const channel = await createChannel(harness, owner.token, guild.id, 'invites');
|
||||
|
||||
await createChannelInvite(harness, owner.token, channel.id);
|
||||
|
||||
const response = await createBuilder<AuditLogResponse>(harness, owner.token)
|
||||
.get(`/guilds/${guild.id}/audit-logs?action_type=${AuditLogActionType.INVITE_CREATE}`)
|
||||
.execute();
|
||||
|
||||
const entry = response.audit_log_entries.find((log) => log.options?.channel_id === channel.id);
|
||||
expect(entry).toBeDefined();
|
||||
expect(typeof entry?.options?.max_age).toBe('number');
|
||||
expect(typeof entry?.options?.max_uses).toBe('number');
|
||||
expect(typeof entry?.options?.temporary).toBe('boolean');
|
||||
});
|
||||
|
||||
test('filters audit logs by user and action type', async () => {
|
||||
const {owner, members, guild} = await setupTestGuildWithMembers(harness, 1);
|
||||
const member = members[0];
|
||||
|
||||
await createRole(harness, owner.token, guild.id, {
|
||||
name: 'Owner Role',
|
||||
permissions: Permissions.VIEW_CHANNEL.toString(),
|
||||
});
|
||||
|
||||
await createBuilder(harness, member.token)
|
||||
.patch(`/guilds/${guild.id}/members/@me`)
|
||||
.body({nick: 'Audit Nick'})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
|
||||
const responseByUser = await createBuilder<AuditLogResponse>(harness, owner.token)
|
||||
.get(`/guilds/${guild.id}/audit-logs?user_id=${member.userId}`)
|
||||
.execute();
|
||||
|
||||
expect(responseByUser.audit_log_entries.length).toBeGreaterThan(0);
|
||||
for (const entry of responseByUser.audit_log_entries) {
|
||||
expect(entry.user_id).toBe(member.userId);
|
||||
}
|
||||
|
||||
const responseByAction = await createBuilder<AuditLogResponse>(harness, owner.token)
|
||||
.get(`/guilds/${guild.id}/audit-logs?action_type=${AuditLogActionType.ROLE_CREATE}`)
|
||||
.execute();
|
||||
|
||||
expect(responseByAction.audit_log_entries.length).toBeGreaterThan(0);
|
||||
for (const entry of responseByAction.audit_log_entries) {
|
||||
expect(entry.action_type).toBe(AuditLogActionType.ROLE_CREATE);
|
||||
}
|
||||
});
|
||||
|
||||
test('includes target users for user-target audit log entries', async () => {
|
||||
const {owner, members, guild} = await setupTestGuildWithMembers(harness, 1);
|
||||
const member = members[0];
|
||||
|
||||
await createBuilder(harness, owner.token)
|
||||
.put(`/guilds/${guild.id}/bans/${member.userId}`)
|
||||
.body({reason: 'Audit log user list check'})
|
||||
.expect(HTTP_STATUS.NO_CONTENT)
|
||||
.execute();
|
||||
|
||||
const response = await createBuilder<AuditLogResponse>(harness, owner.token)
|
||||
.get(`/guilds/${guild.id}/audit-logs?action_type=${AuditLogActionType.MEMBER_BAN_ADD}`)
|
||||
.execute();
|
||||
|
||||
const userIds = response.users.map((user) => user.id);
|
||||
expect(userIds).toContain(owner.userId);
|
||||
expect(userIds).toContain(member.userId);
|
||||
});
|
||||
|
||||
test('rejects specifying before and after together', async () => {
|
||||
const {owner, guild} = await setupTestGuildWithMembers(harness, 1);
|
||||
await createRole(harness, owner.token, guild.id, {
|
||||
name: 'Pagination Role',
|
||||
permissions: Permissions.VIEW_CHANNEL.toString(),
|
||||
});
|
||||
|
||||
const response = await createBuilder<AuditLogResponse>(harness, owner.token)
|
||||
.get(`/guilds/${guild.id}/audit-logs?action_type=${AuditLogActionType.ROLE_CREATE}`)
|
||||
.execute();
|
||||
|
||||
const logId = response.audit_log_entries[0]?.id;
|
||||
expect(logId).toBeDefined();
|
||||
|
||||
await createBuilder(harness, owner.token)
|
||||
.get(`/guilds/${guild.id}/audit-logs?before=${logId}&after=${logId}`)
|
||||
.expect(HTTP_STATUS.BAD_REQUEST, APIErrorCodes.INVALID_FORM_BODY)
|
||||
.execute();
|
||||
});
|
||||
});
|
||||
659
packages/api/src/guild/tests/GuildChannelManagement.test.tsx
Normal file
659
packages/api/src/guild/tests/GuildChannelManagement.test.tsx
Normal file
@@ -0,0 +1,659 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {createTestAccount} from '@fluxer/api/src/auth/tests/AuthTestUtils';
|
||||
import {
|
||||
createChannel,
|
||||
createRole,
|
||||
getChannel,
|
||||
setupTestGuildWithMembers,
|
||||
updateChannel,
|
||||
} from '@fluxer/api/src/channel/tests/ChannelTestUtils';
|
||||
import {createGuild, getGuildChannels} from '@fluxer/api/src/guild/tests/GuildTestUtils';
|
||||
import {type ApiTestHarness, createApiTestHarness} from '@fluxer/api/src/test/ApiTestHarness';
|
||||
import {HTTP_STATUS} from '@fluxer/api/src/test/TestConstants';
|
||||
import {createBuilder} from '@fluxer/api/src/test/TestRequestBuilder';
|
||||
import {ChannelTypes, Permissions} from '@fluxer/constants/src/ChannelConstants';
|
||||
import type {ChannelResponse} from '@fluxer/schema/src/domains/channel/ChannelSchemas';
|
||||
import {afterEach, beforeEach, describe, expect, test} from 'vitest';
|
||||
|
||||
describe('Guild Channel Management', () => {
|
||||
let harness: ApiTestHarness;
|
||||
|
||||
beforeEach(async () => {
|
||||
harness = await createApiTestHarness();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await harness?.shutdown();
|
||||
});
|
||||
|
||||
describe('Channel Name Updates', () => {
|
||||
test('should normalize channel name with spaces to hyphens', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, account.token, 'Test Guild');
|
||||
const channel = await createChannel(harness, account.token, guild.id, 'test-channel');
|
||||
|
||||
const updated = await updateChannel(harness, account.token, channel.id, {name: 'my new channel'});
|
||||
|
||||
expect(updated.name).toBe('my-new-channel');
|
||||
});
|
||||
|
||||
test('should reject channel name exceeding maximum length', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, account.token, 'Test Guild');
|
||||
const channel = await createChannel(harness, account.token, guild.id, 'test-channel');
|
||||
|
||||
const longName = 'a'.repeat(101);
|
||||
|
||||
await createBuilder(harness, account.token)
|
||||
.patch(`/channels/${channel.id}`)
|
||||
.body({name: longName, type: ChannelTypes.GUILD_TEXT})
|
||||
.expect(HTTP_STATUS.BAD_REQUEST)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('should preserve channel name when update name is empty', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, account.token, 'Test Guild');
|
||||
const channel = await createChannel(harness, account.token, guild.id, 'test-channel');
|
||||
|
||||
const updated = await updateChannel(harness, account.token, channel.id, {name: ''});
|
||||
|
||||
expect(updated.name).toBe('test-channel');
|
||||
});
|
||||
|
||||
test('should convert channel name to lowercase', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, account.token, 'Test Guild');
|
||||
const channel = await createChannel(harness, account.token, guild.id, 'test-channel');
|
||||
|
||||
const updated = await updateChannel(harness, account.token, channel.id, {name: 'MyChannel'});
|
||||
|
||||
expect(updated.name).toBe('mychannel');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Channel Topic Updates', () => {
|
||||
test('should allow clearing channel topic', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, account.token, 'Test Guild');
|
||||
const channel = await createChannel(harness, account.token, guild.id, 'test-channel');
|
||||
|
||||
await updateChannel(harness, account.token, channel.id, {topic: 'Initial topic'});
|
||||
const updated = await updateChannel(harness, account.token, channel.id, {topic: null});
|
||||
|
||||
expect(updated.topic).toBeNull();
|
||||
});
|
||||
|
||||
test('should reject channel topic exceeding maximum length', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, account.token, 'Test Guild');
|
||||
const channel = await createChannel(harness, account.token, guild.id, 'test-channel');
|
||||
|
||||
const longTopic = 'a'.repeat(1025);
|
||||
|
||||
await createBuilder(harness, account.token)
|
||||
.patch(`/channels/${channel.id}`)
|
||||
.body({topic: longTopic, type: ChannelTypes.GUILD_TEXT})
|
||||
.expect(HTTP_STATUS.BAD_REQUEST)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('should accept topic at maximum length', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, account.token, 'Test Guild');
|
||||
const channel = await createChannel(harness, account.token, guild.id, 'test-channel');
|
||||
|
||||
const maxTopic = 'a'.repeat(1024);
|
||||
const updated = await updateChannel(harness, account.token, channel.id, {topic: maxTopic});
|
||||
|
||||
expect(updated.topic).toBe(maxTopic);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Channel Slowmode (rate_limit_per_user)', () => {
|
||||
test('should set slowmode on text channel', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, account.token, 'Test Guild');
|
||||
const channel = await createChannel(harness, account.token, guild.id, 'test-channel');
|
||||
|
||||
const updated = await updateChannel(harness, account.token, channel.id, {rate_limit_per_user: 60});
|
||||
|
||||
expect(updated.rate_limit_per_user).toBe(60);
|
||||
});
|
||||
|
||||
test('should disable slowmode by setting to zero', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, account.token, 'Test Guild');
|
||||
const channel = await createChannel(harness, account.token, guild.id, 'test-channel');
|
||||
|
||||
await updateChannel(harness, account.token, channel.id, {rate_limit_per_user: 60});
|
||||
const updated = await updateChannel(harness, account.token, channel.id, {rate_limit_per_user: 0});
|
||||
|
||||
expect(updated.rate_limit_per_user).toBe(0);
|
||||
});
|
||||
|
||||
test('should reject negative slowmode value', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, account.token, 'Test Guild');
|
||||
const channel = await createChannel(harness, account.token, guild.id, 'test-channel');
|
||||
|
||||
await createBuilder(harness, account.token)
|
||||
.patch(`/channels/${channel.id}`)
|
||||
.body({rate_limit_per_user: -1, type: ChannelTypes.GUILD_TEXT})
|
||||
.expect(HTTP_STATUS.BAD_REQUEST)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('should reject slowmode exceeding maximum (21600 seconds)', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, account.token, 'Test Guild');
|
||||
const channel = await createChannel(harness, account.token, guild.id, 'test-channel');
|
||||
|
||||
await createBuilder(harness, account.token)
|
||||
.patch(`/channels/${channel.id}`)
|
||||
.body({rate_limit_per_user: 21601, type: ChannelTypes.GUILD_TEXT})
|
||||
.expect(HTTP_STATUS.BAD_REQUEST)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('should accept maximum slowmode value (21600 seconds)', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, account.token, 'Test Guild');
|
||||
const channel = await createChannel(harness, account.token, guild.id, 'test-channel');
|
||||
|
||||
const updated = await updateChannel(harness, account.token, channel.id, {rate_limit_per_user: 21600});
|
||||
|
||||
expect(updated.rate_limit_per_user).toBe(21600);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Channel Position Updates', () => {
|
||||
test('should update single channel position via direct PATCH', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, account.token, 'Test Guild');
|
||||
|
||||
await createChannel(harness, account.token, guild.id, 'channel-1');
|
||||
const channel2 = await createChannel(harness, account.token, guild.id, 'channel-2');
|
||||
|
||||
await createBuilder(harness, account.token)
|
||||
.patch(`/guilds/${guild.id}/channels`)
|
||||
.body([{id: channel2.id, position: 0}])
|
||||
.expect(HTTP_STATUS.NO_CONTENT)
|
||||
.execute();
|
||||
|
||||
const channels = await getGuildChannels(harness, account.token, guild.id);
|
||||
const updatedChannel2 = channels.find((c) => c.id === channel2.id);
|
||||
expect(updatedChannel2).toBeDefined();
|
||||
});
|
||||
|
||||
test('should update multiple channel positions in bulk via direct PATCH', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, account.token, 'Test Guild');
|
||||
|
||||
const channel1 = await createChannel(harness, account.token, guild.id, 'channel-1');
|
||||
const channel2 = await createChannel(harness, account.token, guild.id, 'channel-2');
|
||||
const channel3 = await createChannel(harness, account.token, guild.id, 'channel-3');
|
||||
|
||||
await createBuilder(harness, account.token)
|
||||
.patch(`/guilds/${guild.id}/channels`)
|
||||
.body([
|
||||
{id: channel3.id, position: 0},
|
||||
{id: channel2.id, position: 1},
|
||||
{id: channel1.id, position: 2},
|
||||
])
|
||||
.expect(HTTP_STATUS.NO_CONTENT)
|
||||
.execute();
|
||||
|
||||
const channels = await getGuildChannels(harness, account.token, guild.id);
|
||||
const textChannels = channels.filter((c) => c.type === ChannelTypes.GUILD_TEXT);
|
||||
|
||||
expect(textChannels.some((c) => c.id === channel1.id)).toBe(true);
|
||||
expect(textChannels.some((c) => c.id === channel2.id)).toBe(true);
|
||||
expect(textChannels.some((c) => c.id === channel3.id)).toBe(true);
|
||||
});
|
||||
|
||||
test('should move channel into category via direct PATCH', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, account.token, 'Test Guild');
|
||||
|
||||
const category = await createChannel(harness, account.token, guild.id, 'Category', ChannelTypes.GUILD_CATEGORY);
|
||||
const textChannel = await createChannel(harness, account.token, guild.id, 'text-channel');
|
||||
|
||||
await createBuilder(harness, account.token)
|
||||
.patch(`/guilds/${guild.id}/channels`)
|
||||
.body([{id: textChannel.id, parent_id: category.id}])
|
||||
.expect(HTTP_STATUS.NO_CONTENT)
|
||||
.execute();
|
||||
|
||||
const updatedChannel = await getChannel(harness, account.token, textChannel.id);
|
||||
expect(updatedChannel.parent_id).toBe(category.id);
|
||||
});
|
||||
|
||||
test('should move channel out of category via direct PATCH', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, account.token, 'Test Guild');
|
||||
|
||||
const category = await createChannel(harness, account.token, guild.id, 'Category', ChannelTypes.GUILD_CATEGORY);
|
||||
const textChannel = await createChannel(harness, account.token, guild.id, 'text-channel');
|
||||
|
||||
await createBuilder(harness, account.token)
|
||||
.patch(`/guilds/${guild.id}/channels`)
|
||||
.body([{id: textChannel.id, parent_id: category.id}])
|
||||
.expect(HTTP_STATUS.NO_CONTENT)
|
||||
.execute();
|
||||
|
||||
let updatedChannel = await getChannel(harness, account.token, textChannel.id);
|
||||
expect(updatedChannel.parent_id).toBe(category.id);
|
||||
|
||||
await createBuilder(harness, account.token)
|
||||
.patch(`/guilds/${guild.id}/channels`)
|
||||
.body([{id: textChannel.id, parent_id: null}])
|
||||
.expect(HTTP_STATUS.NO_CONTENT)
|
||||
.execute();
|
||||
|
||||
updatedChannel = await getChannel(harness, account.token, textChannel.id);
|
||||
expect(updatedChannel.parent_id).toBeNull();
|
||||
});
|
||||
|
||||
test('should lock permissions when moving to category via direct PATCH', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, account.token, 'Test Guild');
|
||||
|
||||
const category = await createChannel(harness, account.token, guild.id, 'Category', ChannelTypes.GUILD_CATEGORY);
|
||||
const textChannel = await createChannel(harness, account.token, guild.id, 'text-channel');
|
||||
|
||||
await createBuilder(harness, account.token)
|
||||
.patch(`/guilds/${guild.id}/channels`)
|
||||
.body([{id: textChannel.id, parent_id: category.id, lock_permissions: true}])
|
||||
.expect(HTTP_STATUS.NO_CONTENT)
|
||||
.execute();
|
||||
|
||||
const updatedChannel = await getChannel(harness, account.token, textChannel.id);
|
||||
expect(updatedChannel.parent_id).toBe(category.id);
|
||||
});
|
||||
|
||||
test('should reject or forbid invalid channel id in position update', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, account.token, 'Test Guild');
|
||||
|
||||
await createBuilder(harness, account.token)
|
||||
.patch(`/guilds/${guild.id}/channels`)
|
||||
.body([{id: '999999999999999999', position: 0}])
|
||||
.expect(HTTP_STATUS.BAD_REQUEST)
|
||||
.execute();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Channel Permission Overwrites Operations', () => {
|
||||
test('should create permission overwrite for role', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, account.token, 'Test Guild');
|
||||
const channel = await createChannel(harness, account.token, guild.id, 'test-channel');
|
||||
const role = await createRole(harness, account.token, guild.id, {name: 'Test Role'});
|
||||
|
||||
await createBuilder(harness, account.token)
|
||||
.put(`/channels/${channel.id}/permissions/${role.id}`)
|
||||
.body({
|
||||
type: 0,
|
||||
allow: Permissions.SEND_MESSAGES.toString(),
|
||||
deny: '0',
|
||||
})
|
||||
.expect(HTTP_STATUS.NO_CONTENT)
|
||||
.execute();
|
||||
|
||||
const channelData = await getChannel(harness, account.token, channel.id);
|
||||
const overwrite = channelData.permission_overwrites?.find((o) => o.id === role.id);
|
||||
expect(overwrite).toBeDefined();
|
||||
expect(overwrite?.type).toBe(0);
|
||||
});
|
||||
|
||||
test('should create permission overwrite for member', async () => {
|
||||
const {owner, members, systemChannel} = await setupTestGuildWithMembers(harness, 1);
|
||||
const member = members[0];
|
||||
|
||||
await createBuilder(harness, owner.token)
|
||||
.put(`/channels/${systemChannel.id}/permissions/${member.userId}`)
|
||||
.body({
|
||||
type: 1,
|
||||
allow: Permissions.VIEW_CHANNEL.toString(),
|
||||
deny: Permissions.SEND_MESSAGES.toString(),
|
||||
})
|
||||
.expect(HTTP_STATUS.NO_CONTENT)
|
||||
.execute();
|
||||
|
||||
const channelData = await getChannel(harness, owner.token, systemChannel.id);
|
||||
const overwrite = channelData.permission_overwrites?.find((o) => o.id === member.userId);
|
||||
expect(overwrite).toBeDefined();
|
||||
expect(overwrite?.type).toBe(1);
|
||||
});
|
||||
|
||||
test('should update existing permission overwrite', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, account.token, 'Test Guild');
|
||||
const channel = await createChannel(harness, account.token, guild.id, 'test-channel');
|
||||
const role = await createRole(harness, account.token, guild.id, {name: 'Test Role'});
|
||||
|
||||
await createBuilder(harness, account.token)
|
||||
.put(`/channels/${channel.id}/permissions/${role.id}`)
|
||||
.body({
|
||||
type: 0,
|
||||
allow: Permissions.SEND_MESSAGES.toString(),
|
||||
deny: '0',
|
||||
})
|
||||
.expect(HTTP_STATUS.NO_CONTENT)
|
||||
.execute();
|
||||
|
||||
await createBuilder(harness, account.token)
|
||||
.put(`/channels/${channel.id}/permissions/${role.id}`)
|
||||
.body({
|
||||
type: 0,
|
||||
allow: (Permissions.SEND_MESSAGES | Permissions.EMBED_LINKS).toString(),
|
||||
deny: '0',
|
||||
})
|
||||
.expect(HTTP_STATUS.NO_CONTENT)
|
||||
.execute();
|
||||
|
||||
const channelData = await getChannel(harness, account.token, channel.id);
|
||||
const overwrite = channelData.permission_overwrites?.find((o) => o.id === role.id);
|
||||
expect(BigInt(overwrite!.allow)).toBe(Permissions.SEND_MESSAGES | Permissions.EMBED_LINKS);
|
||||
});
|
||||
|
||||
test('should delete permission overwrite', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, account.token, 'Test Guild');
|
||||
const channel = await createChannel(harness, account.token, guild.id, 'test-channel');
|
||||
const role = await createRole(harness, account.token, guild.id, {name: 'Test Role'});
|
||||
|
||||
await createBuilder(harness, account.token)
|
||||
.put(`/channels/${channel.id}/permissions/${role.id}`)
|
||||
.body({
|
||||
type: 0,
|
||||
allow: Permissions.SEND_MESSAGES.toString(),
|
||||
deny: '0',
|
||||
})
|
||||
.expect(HTTP_STATUS.NO_CONTENT)
|
||||
.execute();
|
||||
|
||||
await createBuilder(harness, account.token)
|
||||
.delete(`/channels/${channel.id}/permissions/${role.id}`)
|
||||
.expect(HTTP_STATUS.NO_CONTENT)
|
||||
.execute();
|
||||
|
||||
const channelData = await getChannel(harness, account.token, channel.id);
|
||||
const overwrite = channelData.permission_overwrites?.find((o) => o.id === role.id);
|
||||
expect(overwrite).toBeUndefined();
|
||||
});
|
||||
|
||||
test('should show overwrites in channel response', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, account.token, 'Test Guild');
|
||||
const channel = await createChannel(harness, account.token, guild.id, 'test-channel');
|
||||
const role = await createRole(harness, account.token, guild.id, {name: 'Test Role'});
|
||||
|
||||
await createBuilder(harness, account.token)
|
||||
.put(`/channels/${channel.id}/permissions/${role.id}`)
|
||||
.body({
|
||||
type: 0,
|
||||
allow: Permissions.SEND_MESSAGES.toString(),
|
||||
deny: '0',
|
||||
})
|
||||
.expect(HTTP_STATUS.NO_CONTENT)
|
||||
.execute();
|
||||
|
||||
const channelData = await getChannel(harness, account.token, channel.id);
|
||||
expect(channelData.permission_overwrites).toBeDefined();
|
||||
expect(channelData.permission_overwrites?.some((o) => o.id === role.id)).toBe(true);
|
||||
});
|
||||
|
||||
test('should reject invalid overwrite type', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, account.token, 'Test Guild');
|
||||
const channel = await createChannel(harness, account.token, guild.id, 'test-channel');
|
||||
|
||||
await createBuilder(harness, account.token)
|
||||
.put(`/channels/${channel.id}/permissions/123456789`)
|
||||
.body({
|
||||
type: 999,
|
||||
allow: '0',
|
||||
deny: '0',
|
||||
})
|
||||
.expect(HTTP_STATUS.BAD_REQUEST)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('should require MANAGE_ROLES permission to create overwrites', async () => {
|
||||
const {owner, members, guild, systemChannel} = await setupTestGuildWithMembers(harness, 1);
|
||||
const member = members[0];
|
||||
const role = await createRole(harness, owner.token, guild.id, {name: 'Test Role'});
|
||||
|
||||
await createBuilder(harness, member.token)
|
||||
.put(`/channels/${systemChannel.id}/permissions/${role.id}`)
|
||||
.body({
|
||||
type: 0,
|
||||
allow: Permissions.SEND_MESSAGES.toString(),
|
||||
deny: '0',
|
||||
})
|
||||
.expect(HTTP_STATUS.FORBIDDEN)
|
||||
.execute();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Voice Channel Bitrate and User Limit Updates', () => {
|
||||
test('should update voice channel bitrate', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, account.token, 'Test Guild');
|
||||
const voiceChannel = await createChannel(
|
||||
harness,
|
||||
account.token,
|
||||
guild.id,
|
||||
'voice-channel',
|
||||
ChannelTypes.GUILD_VOICE,
|
||||
);
|
||||
|
||||
const data = await createBuilder<ChannelResponse>(harness, account.token)
|
||||
.patch(`/channels/${voiceChannel.id}`)
|
||||
.body({type: ChannelTypes.GUILD_VOICE, bitrate: 96000})
|
||||
.execute();
|
||||
expect(data.bitrate).toBe(96000);
|
||||
});
|
||||
|
||||
test('should update voice channel user limit', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, account.token, 'Test Guild');
|
||||
const voiceChannel = await createChannel(
|
||||
harness,
|
||||
account.token,
|
||||
guild.id,
|
||||
'voice-channel',
|
||||
ChannelTypes.GUILD_VOICE,
|
||||
);
|
||||
|
||||
const data = await createBuilder<ChannelResponse>(harness, account.token)
|
||||
.patch(`/channels/${voiceChannel.id}`)
|
||||
.body({type: ChannelTypes.GUILD_VOICE, user_limit: 10})
|
||||
.execute();
|
||||
expect(data.user_limit).toBe(10);
|
||||
});
|
||||
|
||||
test('should set unlimited user capacity with zero', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, account.token, 'Test Guild');
|
||||
const voiceChannel = await createChannel(
|
||||
harness,
|
||||
account.token,
|
||||
guild.id,
|
||||
'voice-channel',
|
||||
ChannelTypes.GUILD_VOICE,
|
||||
);
|
||||
|
||||
const data = await createBuilder<ChannelResponse>(harness, account.token)
|
||||
.patch(`/channels/${voiceChannel.id}`)
|
||||
.body({type: ChannelTypes.GUILD_VOICE, user_limit: 0})
|
||||
.execute();
|
||||
expect(data.user_limit).toBe(0);
|
||||
});
|
||||
|
||||
test('should reject bitrate below minimum (8000)', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, account.token, 'Test Guild');
|
||||
const voiceChannel = await createChannel(
|
||||
harness,
|
||||
account.token,
|
||||
guild.id,
|
||||
'voice-channel',
|
||||
ChannelTypes.GUILD_VOICE,
|
||||
);
|
||||
|
||||
await createBuilder(harness, account.token)
|
||||
.patch(`/channels/${voiceChannel.id}`)
|
||||
.body({type: ChannelTypes.GUILD_VOICE, bitrate: 7999})
|
||||
.expect(HTTP_STATUS.BAD_REQUEST)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('should reject bitrate above maximum (320000)', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, account.token, 'Test Guild');
|
||||
const voiceChannel = await createChannel(
|
||||
harness,
|
||||
account.token,
|
||||
guild.id,
|
||||
'voice-channel',
|
||||
ChannelTypes.GUILD_VOICE,
|
||||
);
|
||||
|
||||
await createBuilder(harness, account.token)
|
||||
.patch(`/channels/${voiceChannel.id}`)
|
||||
.body({type: ChannelTypes.GUILD_VOICE, bitrate: 320001})
|
||||
.expect(HTTP_STATUS.BAD_REQUEST)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('should reject negative user limit', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, account.token, 'Test Guild');
|
||||
const voiceChannel = await createChannel(
|
||||
harness,
|
||||
account.token,
|
||||
guild.id,
|
||||
'voice-channel',
|
||||
ChannelTypes.GUILD_VOICE,
|
||||
);
|
||||
|
||||
await createBuilder(harness, account.token)
|
||||
.patch(`/channels/${voiceChannel.id}`)
|
||||
.body({type: ChannelTypes.GUILD_VOICE, user_limit: -1})
|
||||
.expect(HTTP_STATUS.BAD_REQUEST)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('should reject user limit above maximum (99)', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, account.token, 'Test Guild');
|
||||
const voiceChannel = await createChannel(
|
||||
harness,
|
||||
account.token,
|
||||
guild.id,
|
||||
'voice-channel',
|
||||
ChannelTypes.GUILD_VOICE,
|
||||
);
|
||||
|
||||
await createBuilder(harness, account.token)
|
||||
.patch(`/channels/${voiceChannel.id}`)
|
||||
.body({type: ChannelTypes.GUILD_VOICE, user_limit: 100})
|
||||
.expect(HTTP_STATUS.BAD_REQUEST)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('should accept minimum bitrate (8000)', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, account.token, 'Test Guild');
|
||||
const voiceChannel = await createChannel(
|
||||
harness,
|
||||
account.token,
|
||||
guild.id,
|
||||
'voice-channel',
|
||||
ChannelTypes.GUILD_VOICE,
|
||||
);
|
||||
|
||||
const data = await createBuilder<ChannelResponse>(harness, account.token)
|
||||
.patch(`/channels/${voiceChannel.id}`)
|
||||
.body({type: ChannelTypes.GUILD_VOICE, bitrate: 8000})
|
||||
.execute();
|
||||
expect(data.bitrate).toBe(8000);
|
||||
});
|
||||
|
||||
test('should accept maximum bitrate (320000)', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, account.token, 'Test Guild');
|
||||
const voiceChannel = await createChannel(
|
||||
harness,
|
||||
account.token,
|
||||
guild.id,
|
||||
'voice-channel',
|
||||
ChannelTypes.GUILD_VOICE,
|
||||
);
|
||||
|
||||
const data = await createBuilder<ChannelResponse>(harness, account.token)
|
||||
.patch(`/channels/${voiceChannel.id}`)
|
||||
.body({type: ChannelTypes.GUILD_VOICE, bitrate: 320000})
|
||||
.execute();
|
||||
expect(data.bitrate).toBe(320000);
|
||||
});
|
||||
|
||||
test('should accept maximum user limit (99)', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, account.token, 'Test Guild');
|
||||
const voiceChannel = await createChannel(
|
||||
harness,
|
||||
account.token,
|
||||
guild.id,
|
||||
'voice-channel',
|
||||
ChannelTypes.GUILD_VOICE,
|
||||
);
|
||||
|
||||
const data = await createBuilder<ChannelResponse>(harness, account.token)
|
||||
.patch(`/channels/${voiceChannel.id}`)
|
||||
.body({type: ChannelTypes.GUILD_VOICE, user_limit: 99})
|
||||
.execute();
|
||||
expect(data.user_limit).toBe(99);
|
||||
});
|
||||
|
||||
test('should update both bitrate and user limit together', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, account.token, 'Test Guild');
|
||||
const voiceChannel = await createChannel(
|
||||
harness,
|
||||
account.token,
|
||||
guild.id,
|
||||
'voice-channel',
|
||||
ChannelTypes.GUILD_VOICE,
|
||||
);
|
||||
|
||||
const data = await createBuilder<ChannelResponse>(harness, account.token)
|
||||
.patch(`/channels/${voiceChannel.id}`)
|
||||
.body({type: ChannelTypes.GUILD_VOICE, bitrate: 128000, user_limit: 25})
|
||||
.execute();
|
||||
expect(data.bitrate).toBe(128000);
|
||||
expect(data.user_limit).toBe(25);
|
||||
});
|
||||
});
|
||||
});
|
||||
260
packages/api/src/guild/tests/GuildChannelPositions.test.tsx
Normal file
260
packages/api/src/guild/tests/GuildChannelPositions.test.tsx
Normal file
@@ -0,0 +1,260 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {createTestAccount} from '@fluxer/api/src/auth/tests/AuthTestUtils';
|
||||
import {
|
||||
acceptInvite,
|
||||
addMemberRole,
|
||||
createChannel,
|
||||
createChannelInvite,
|
||||
createGuild,
|
||||
createRole,
|
||||
getChannel,
|
||||
getGuildChannels,
|
||||
updateChannelPositions,
|
||||
} from '@fluxer/api/src/guild/tests/GuildTestUtils';
|
||||
import {type ApiTestHarness, createApiTestHarness} from '@fluxer/api/src/test/ApiTestHarness';
|
||||
import {HTTP_STATUS} from '@fluxer/api/src/test/TestConstants';
|
||||
import {createBuilder} from '@fluxer/api/src/test/TestRequestBuilder';
|
||||
import {ChannelTypes, Permissions} from '@fluxer/constants/src/ChannelConstants';
|
||||
import {afterEach, beforeEach, describe, expect, test} from 'vitest';
|
||||
|
||||
describe('Guild Channel Positions', () => {
|
||||
let harness: ApiTestHarness;
|
||||
|
||||
beforeEach(async () => {
|
||||
harness = await createApiTestHarness();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await harness?.shutdown();
|
||||
});
|
||||
|
||||
test('should reorder channels within guild', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, account.token, 'Test Guild');
|
||||
|
||||
const channel1 = await createChannel(harness, account.token, guild.id, 'channel-1');
|
||||
const channel2 = await createChannel(harness, account.token, guild.id, 'channel-2');
|
||||
const channel3 = await createChannel(harness, account.token, guild.id, 'channel-3');
|
||||
|
||||
await updateChannelPositions(harness, account.token, guild.id, [
|
||||
{id: channel3.id, position: 0},
|
||||
{id: channel1.id, position: 1},
|
||||
{id: channel2.id, position: 2},
|
||||
]);
|
||||
|
||||
const channels = await getGuildChannels(harness, account.token, guild.id);
|
||||
const textChannels = channels.filter((c) => c.type === ChannelTypes.GUILD_TEXT);
|
||||
|
||||
expect(textChannels.some((c) => c.id === channel1.id)).toBe(true);
|
||||
expect(textChannels.some((c) => c.id === channel2.id)).toBe(true);
|
||||
expect(textChannels.some((c) => c.id === channel3.id)).toBe(true);
|
||||
});
|
||||
|
||||
test('should move channel to category', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, account.token, 'Test Guild');
|
||||
|
||||
const category = await createChannel(harness, account.token, guild.id, 'Category', ChannelTypes.GUILD_CATEGORY);
|
||||
const textChannel = await createChannel(harness, account.token, guild.id, 'text-channel');
|
||||
|
||||
await updateChannelPositions(harness, account.token, guild.id, [{id: textChannel.id, parent_id: category.id}]);
|
||||
|
||||
const updatedChannel = await getChannel(harness, account.token, textChannel.id);
|
||||
expect(updatedChannel.parent_id).toBe(category.id);
|
||||
});
|
||||
|
||||
test('should move channel out of category', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, account.token, 'Test Guild');
|
||||
|
||||
const category = await createChannel(harness, account.token, guild.id, 'Category', ChannelTypes.GUILD_CATEGORY);
|
||||
const textChannel = await createChannel(harness, account.token, guild.id, 'text-channel');
|
||||
|
||||
await updateChannelPositions(harness, account.token, guild.id, [{id: textChannel.id, parent_id: category.id}]);
|
||||
|
||||
let updatedChannel = await getChannel(harness, account.token, textChannel.id);
|
||||
expect(updatedChannel.parent_id).toBe(category.id);
|
||||
|
||||
await updateChannelPositions(harness, account.token, guild.id, [{id: textChannel.id, parent_id: null}]);
|
||||
|
||||
updatedChannel = await getChannel(harness, account.token, textChannel.id);
|
||||
expect(updatedChannel.parent_id).toBeNull();
|
||||
});
|
||||
|
||||
test('should require MANAGE_CHANNELS permission to reorder', async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
const member = await createTestAccount(harness);
|
||||
|
||||
const guild = await createGuild(harness, owner.token, 'Test Guild');
|
||||
const systemChannel = await getChannel(harness, owner.token, guild.system_channel_id!);
|
||||
|
||||
const invite = await createChannelInvite(harness, owner.token, systemChannel.id);
|
||||
await acceptInvite(harness, member.token, invite.code);
|
||||
|
||||
const channel1 = await createChannel(harness, owner.token, guild.id, 'channel-1');
|
||||
const channel2 = await createChannel(harness, owner.token, guild.id, 'channel-2');
|
||||
|
||||
await createBuilder(harness, member.token)
|
||||
.patch(`/guilds/${guild.id}/channels`)
|
||||
.body([
|
||||
{id: channel1.id, position: 1},
|
||||
{id: channel2.id, position: 0},
|
||||
])
|
||||
.expect(HTTP_STATUS.FORBIDDEN)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('should allow MANAGE_CHANNELS role to reorder channels', async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
const member = await createTestAccount(harness);
|
||||
|
||||
const guild = await createGuild(harness, owner.token, 'Test Guild');
|
||||
const systemChannel = await getChannel(harness, owner.token, guild.system_channel_id!);
|
||||
|
||||
const managerRole = await createRole(harness, owner.token, guild.id, {
|
||||
name: 'Channel Manager',
|
||||
permissions: Permissions.MANAGE_CHANNELS.toString(),
|
||||
});
|
||||
|
||||
const invite = await createChannelInvite(harness, owner.token, systemChannel.id);
|
||||
await acceptInvite(harness, member.token, invite.code);
|
||||
|
||||
await addMemberRole(harness, owner.token, guild.id, member.userId, managerRole.id);
|
||||
|
||||
const channel1 = await createChannel(harness, owner.token, guild.id, 'channel-1');
|
||||
const channel2 = await createChannel(harness, owner.token, guild.id, 'channel-2');
|
||||
|
||||
await updateChannelPositions(harness, member.token, guild.id, [
|
||||
{id: channel1.id, position: 1},
|
||||
{id: channel2.id, position: 0},
|
||||
]);
|
||||
});
|
||||
|
||||
test('should reject invalid channel id in position update', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, account.token, 'Test Guild');
|
||||
|
||||
await createBuilder(harness, account.token)
|
||||
.patch(`/guilds/${guild.id}/channels`)
|
||||
.body([{id: '999999999999999999', position: 0}])
|
||||
.expect(HTTP_STATUS.BAD_REQUEST)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('should lock permissions when moving to category', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, account.token, 'Test Guild');
|
||||
|
||||
const category = await createChannel(harness, account.token, guild.id, 'Category', ChannelTypes.GUILD_CATEGORY);
|
||||
const textChannel = await createChannel(harness, account.token, guild.id, 'text-channel');
|
||||
|
||||
await updateChannelPositions(harness, account.token, guild.id, [
|
||||
{id: textChannel.id, parent_id: category.id, lock_permissions: true},
|
||||
]);
|
||||
|
||||
const updatedChannel = await getChannel(harness, account.token, textChannel.id);
|
||||
expect(updatedChannel.parent_id).toBe(category.id);
|
||||
});
|
||||
|
||||
test('should handle moving multiple channels at once', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, account.token, 'Test Guild');
|
||||
|
||||
const channel1 = await createChannel(harness, account.token, guild.id, 'channel-1');
|
||||
const channel2 = await createChannel(harness, account.token, guild.id, 'channel-2');
|
||||
const channel3 = await createChannel(harness, account.token, guild.id, 'channel-3');
|
||||
|
||||
await updateChannelPositions(harness, account.token, guild.id, [
|
||||
{id: channel1.id, position: 2},
|
||||
{id: channel2.id, position: 0},
|
||||
{id: channel3.id, position: 1},
|
||||
]);
|
||||
});
|
||||
|
||||
test('should reject category as parent of category', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, account.token, 'Test Guild');
|
||||
|
||||
const category1 = await createChannel(harness, account.token, guild.id, 'Category 1', ChannelTypes.GUILD_CATEGORY);
|
||||
const category2 = await createChannel(harness, account.token, guild.id, 'Category 2', ChannelTypes.GUILD_CATEGORY);
|
||||
|
||||
await createBuilder(harness, account.token)
|
||||
.patch(`/guilds/${guild.id}/channels`)
|
||||
.body([{id: category2.id, parent_id: category1.id}])
|
||||
.expect(HTTP_STATUS.BAD_REQUEST)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('should move a voice channel into a text category with provided position', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, account.token, 'Test Guild');
|
||||
|
||||
const textCategory = await createChannel(harness, account.token, guild.id, 'Text', ChannelTypes.GUILD_CATEGORY);
|
||||
const voiceCategory = await createChannel(harness, account.token, guild.id, 'Voice', ChannelTypes.GUILD_CATEGORY);
|
||||
const textChannel = await createChannel(harness, account.token, guild.id, 'general', ChannelTypes.GUILD_TEXT);
|
||||
const voiceChannel = await createChannel(harness, account.token, guild.id, 'lounge', ChannelTypes.GUILD_VOICE);
|
||||
|
||||
await updateChannelPositions(harness, account.token, guild.id, [{id: textChannel.id, parent_id: textCategory.id}]);
|
||||
await updateChannelPositions(harness, account.token, guild.id, [
|
||||
{id: voiceChannel.id, parent_id: voiceCategory.id},
|
||||
]);
|
||||
|
||||
await updateChannelPositions(harness, account.token, guild.id, [
|
||||
{id: voiceChannel.id, parent_id: textCategory.id, position: 1},
|
||||
]);
|
||||
|
||||
const channels = await getGuildChannels(harness, account.token, guild.id);
|
||||
const movedVoice = channels.find((channel) => channel.id === voiceChannel.id);
|
||||
expect(movedVoice?.parent_id).toBe(textCategory.id);
|
||||
|
||||
const destinationSiblings = channels
|
||||
.filter((channel) => channel.parent_id === textCategory.id)
|
||||
.sort((a, b) => (a.position ?? 0) - (b.position ?? 0));
|
||||
expect(destinationSiblings.map((channel) => channel.id)).toEqual([textChannel.id, voiceChannel.id]);
|
||||
});
|
||||
|
||||
test('should place moved voice channels after all text siblings', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, account.token, 'Test Guild');
|
||||
|
||||
const textCategory = await createChannel(harness, account.token, guild.id, 'Text', ChannelTypes.GUILD_CATEGORY);
|
||||
const voiceCategory = await createChannel(harness, account.token, guild.id, 'Voice', ChannelTypes.GUILD_CATEGORY);
|
||||
const textOne = await createChannel(harness, account.token, guild.id, 'one', ChannelTypes.GUILD_TEXT);
|
||||
const textTwo = await createChannel(harness, account.token, guild.id, 'two', ChannelTypes.GUILD_TEXT);
|
||||
const voiceChannel = await createChannel(harness, account.token, guild.id, 'lounge', ChannelTypes.GUILD_VOICE);
|
||||
|
||||
await updateChannelPositions(harness, account.token, guild.id, [
|
||||
{id: textOne.id, parent_id: textCategory.id},
|
||||
{id: textTwo.id, parent_id: textCategory.id},
|
||||
{id: voiceChannel.id, parent_id: voiceCategory.id},
|
||||
]);
|
||||
|
||||
await updateChannelPositions(harness, account.token, guild.id, [
|
||||
{id: voiceChannel.id, parent_id: textCategory.id, position: 2},
|
||||
]);
|
||||
|
||||
const channels = await getGuildChannels(harness, account.token, guild.id);
|
||||
const destinationSiblings = channels
|
||||
.filter((channel) => channel.parent_id === textCategory.id)
|
||||
.sort((a, b) => (a.position ?? 0) - (b.position ?? 0));
|
||||
expect(destinationSiblings.map((channel) => channel.id)).toEqual([textOne.id, textTwo.id, voiceChannel.id]);
|
||||
});
|
||||
});
|
||||
311
packages/api/src/guild/tests/GuildFeatures.test.tsx
Normal file
311
packages/api/src/guild/tests/GuildFeatures.test.tsx
Normal file
@@ -0,0 +1,311 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {createTestAccount} from '@fluxer/api/src/auth/tests/AuthTestUtils';
|
||||
import {
|
||||
addMemberRole,
|
||||
createGuild,
|
||||
createRole,
|
||||
setupTestGuildWithMembers,
|
||||
updateGuild,
|
||||
updateMember,
|
||||
updateRolePositions,
|
||||
} from '@fluxer/api/src/guild/tests/GuildTestUtils';
|
||||
import {type ApiTestHarness, createApiTestHarness} from '@fluxer/api/src/test/ApiTestHarness';
|
||||
import {HTTP_STATUS} from '@fluxer/api/src/test/TestConstants';
|
||||
import {createBuilder} from '@fluxer/api/src/test/TestRequestBuilder';
|
||||
import {Permissions} from '@fluxer/constants/src/ChannelConstants';
|
||||
import {GuildFeatures} from '@fluxer/constants/src/GuildConstants';
|
||||
import {afterEach, beforeEach, describe, expect, test} from 'vitest';
|
||||
|
||||
interface AuditLogEntry {
|
||||
id: string;
|
||||
action_type: number;
|
||||
user_id: string | null;
|
||||
target_id: string | null;
|
||||
reason?: string;
|
||||
options?: Record<string, unknown>;
|
||||
changes?: Array<{key: string; old_value?: unknown; new_value?: unknown}>;
|
||||
}
|
||||
|
||||
interface AuditLogResponse {
|
||||
audit_log_entries: Array<AuditLogEntry>;
|
||||
users: Array<{id: string}>;
|
||||
webhooks: Array<unknown>;
|
||||
}
|
||||
|
||||
describe('Guild Features', () => {
|
||||
let harness: ApiTestHarness;
|
||||
|
||||
beforeEach(async () => {
|
||||
harness = await createApiTestHarness();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await harness?.shutdown();
|
||||
});
|
||||
|
||||
describe('ADMINISTRATOR Permission Effects', () => {
|
||||
test('should allow owner to assign ADMINISTRATOR role to member', async () => {
|
||||
const {owner, members, guild} = await setupTestGuildWithMembers(harness, 1);
|
||||
const member = members[0];
|
||||
|
||||
const adminRole = await createRole(harness, owner.token, guild.id, {
|
||||
name: 'Admin Role',
|
||||
permissions: Permissions.ADMINISTRATOR.toString(),
|
||||
});
|
||||
|
||||
await addMemberRole(harness, owner.token, guild.id, member.userId, adminRole.id);
|
||||
|
||||
const memberData = await createBuilder<{roles: Array<string>}>(harness, owner.token)
|
||||
.get(`/guilds/${guild.id}/members/${member.userId}`)
|
||||
.execute();
|
||||
|
||||
expect(memberData.roles).toContain(adminRole.id);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Guild Audit Log Does Not Leak IP Addresses in Ban Entries', () => {
|
||||
test('should not include IP address in ban audit log changes', async () => {
|
||||
const {owner, members, guild} = await setupTestGuildWithMembers(harness, 1);
|
||||
const targetMember = members[0];
|
||||
|
||||
await createBuilder(harness, owner.token)
|
||||
.put(`/guilds/${guild.id}/bans/${targetMember.userId}`)
|
||||
.body({reason: 'Test ban for audit log check'})
|
||||
.expect(HTTP_STATUS.NO_CONTENT)
|
||||
.execute();
|
||||
|
||||
const auditLogResponse = await createBuilder<AuditLogResponse>(harness, owner.token)
|
||||
.get(`/guilds/${guild.id}/audit-logs?action_type=22`)
|
||||
.execute();
|
||||
|
||||
expect(auditLogResponse.audit_log_entries.length).toBeGreaterThan(0);
|
||||
|
||||
for (const entry of auditLogResponse.audit_log_entries) {
|
||||
if (entry.changes) {
|
||||
for (const change of entry.changes) {
|
||||
expect(change.key).not.toBe('ip');
|
||||
expect(change.key).not.toBe('ip_address');
|
||||
}
|
||||
}
|
||||
if (entry.options) {
|
||||
expect(entry.options).not.toHaveProperty('ip');
|
||||
expect(entry.options).not.toHaveProperty('ip_address');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('should preserve other ban details in audit log while scrubbing IP', async () => {
|
||||
const {owner, members, guild} = await setupTestGuildWithMembers(harness, 1);
|
||||
const targetMember = members[0];
|
||||
const banReason = 'Ban reason for audit test';
|
||||
|
||||
await createBuilder(harness, owner.token)
|
||||
.put(`/guilds/${guild.id}/bans/${targetMember.userId}`)
|
||||
.body({reason: banReason})
|
||||
.expect(HTTP_STATUS.NO_CONTENT)
|
||||
.execute();
|
||||
|
||||
const auditLogResponse = await createBuilder<AuditLogResponse>(harness, owner.token)
|
||||
.get(`/guilds/${guild.id}/audit-logs?action_type=22`)
|
||||
.execute();
|
||||
|
||||
const banEntry = auditLogResponse.audit_log_entries.find(
|
||||
(entry) => entry.target_id === targetMember.userId && entry.action_type === 22,
|
||||
);
|
||||
|
||||
expect(banEntry).toBeDefined();
|
||||
expect(banEntry!.user_id).toBe(owner.userId);
|
||||
expect(banEntry!.target_id).toBe(targetMember.userId);
|
||||
});
|
||||
});
|
||||
|
||||
describe('CHANGE_NICKNAME Permission Enforcement', () => {
|
||||
test('should allow member with CHANGE_NICKNAME to change their own nickname', async () => {
|
||||
const {members, guild} = await setupTestGuildWithMembers(harness, 1);
|
||||
const member = members[0];
|
||||
|
||||
await createBuilder(harness, member.token)
|
||||
.patch(`/guilds/${guild.id}/members/@me`)
|
||||
.body({nick: 'My New Nickname'})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('should not allow member without CHANGE_NICKNAME to change their own nickname when @everyone denies it', async () => {
|
||||
const {owner, members, guild} = await setupTestGuildWithMembers(harness, 1);
|
||||
const member = members[0];
|
||||
|
||||
await createBuilder(harness, owner.token)
|
||||
.patch(`/guilds/${guild.id}/roles/${guild.id}`)
|
||||
.body({
|
||||
permissions: (Permissions.VIEW_CHANNEL | Permissions.SEND_MESSAGES).toString(),
|
||||
})
|
||||
.execute();
|
||||
|
||||
await createBuilder(harness, member.token)
|
||||
.patch(`/guilds/${guild.id}/members/@me`)
|
||||
.body({nick: 'Cannot Set This'})
|
||||
.expect(HTTP_STATUS.FORBIDDEN)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('should allow MANAGE_NICKNAMES holder to change other member nicknames', async () => {
|
||||
const {owner, members, guild} = await setupTestGuildWithMembers(harness, 2);
|
||||
const [moderator, target] = members;
|
||||
|
||||
const modRole = await createRole(harness, owner.token, guild.id, {
|
||||
name: 'Moderator',
|
||||
permissions: Permissions.MANAGE_NICKNAMES.toString(),
|
||||
});
|
||||
|
||||
await addMemberRole(harness, owner.token, guild.id, moderator.userId, modRole.id);
|
||||
|
||||
const updatedMember = await updateMember(harness, moderator.token, guild.id, target.userId, {
|
||||
nick: 'Mod Set Nick',
|
||||
});
|
||||
|
||||
expect(updatedMember.nick).toBe('Mod Set Nick');
|
||||
});
|
||||
|
||||
test('should not allow CHANGE_NICKNAME holder to change other member nicknames', async () => {
|
||||
const {owner, members, guild} = await setupTestGuildWithMembers(harness, 2);
|
||||
const [member1, member2] = members;
|
||||
|
||||
const changeNickRole = await createRole(harness, owner.token, guild.id, {
|
||||
name: 'Change Nick Only',
|
||||
permissions: Permissions.CHANGE_NICKNAME.toString(),
|
||||
});
|
||||
|
||||
await addMemberRole(harness, owner.token, guild.id, member1.userId, changeNickRole.id);
|
||||
|
||||
await createBuilder(harness, member1.token)
|
||||
.patch(`/guilds/${guild.id}/members/${member2.userId}`)
|
||||
.body({nick: 'Should Fail'})
|
||||
.expect(HTTP_STATUS.FORBIDDEN)
|
||||
.execute();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Guild Feature Flags Validation', () => {
|
||||
test('should allow toggling INVITES_DISABLED feature', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, account.token, 'Feature Test Guild');
|
||||
|
||||
const updatedGuild = await updateGuild(harness, account.token, guild.id, {
|
||||
features: [GuildFeatures.INVITES_DISABLED],
|
||||
});
|
||||
|
||||
expect(updatedGuild.features).toContain(GuildFeatures.INVITES_DISABLED);
|
||||
|
||||
const enabledGuild = await updateGuild(harness, account.token, guild.id, {
|
||||
features: [],
|
||||
});
|
||||
|
||||
expect(enabledGuild.features).not.toContain(GuildFeatures.INVITES_DISABLED);
|
||||
});
|
||||
|
||||
test('should allow toggling TEXT_CHANNEL_FLEXIBLE_NAMES feature', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, account.token, 'Flexible Names Test');
|
||||
|
||||
const updatedGuild = await updateGuild(harness, account.token, guild.id, {
|
||||
features: [GuildFeatures.TEXT_CHANNEL_FLEXIBLE_NAMES],
|
||||
});
|
||||
|
||||
expect(updatedGuild.features).toContain(GuildFeatures.TEXT_CHANNEL_FLEXIBLE_NAMES);
|
||||
});
|
||||
|
||||
test('should preserve base features when toggling user-controlled features', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, account.token, 'Base Features Test');
|
||||
|
||||
expect(guild.features).toContain(GuildFeatures.ANIMATED_ICON);
|
||||
expect(guild.features).toContain(GuildFeatures.BANNER);
|
||||
|
||||
const updatedGuild = await updateGuild(harness, account.token, guild.id, {
|
||||
features: [GuildFeatures.INVITES_DISABLED],
|
||||
});
|
||||
|
||||
expect(updatedGuild.features).toContain(GuildFeatures.ANIMATED_ICON);
|
||||
expect(updatedGuild.features).toContain(GuildFeatures.BANNER);
|
||||
expect(updatedGuild.features).toContain(GuildFeatures.INVITES_DISABLED);
|
||||
});
|
||||
|
||||
test('should require MANAGE_GUILD permission to update features', async () => {
|
||||
const {members, guild} = await setupTestGuildWithMembers(harness, 1);
|
||||
const member = members[0];
|
||||
|
||||
await createBuilder(harness, member.token)
|
||||
.patch(`/guilds/${guild.id}`)
|
||||
.body({features: [GuildFeatures.INVITES_DISABLED]})
|
||||
.expect(HTTP_STATUS.FORBIDDEN)
|
||||
.execute();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Role Hierarchy With ADMINISTRATOR', () => {
|
||||
test('should not allow non-owner ADMINISTRATOR to modify roles above their highest role', async () => {
|
||||
const {owner, members, guild} = await setupTestGuildWithMembers(harness, 1);
|
||||
const adminMember = members[0];
|
||||
|
||||
const highRole = await createRole(harness, owner.token, guild.id, {
|
||||
name: 'High Role',
|
||||
permissions: '0',
|
||||
});
|
||||
|
||||
const adminRole = await createRole(harness, owner.token, guild.id, {
|
||||
name: 'Admin Role',
|
||||
permissions: Permissions.ADMINISTRATOR.toString(),
|
||||
});
|
||||
|
||||
await updateRolePositions(harness, owner.token, guild.id, [
|
||||
{id: highRole.id, position: 3},
|
||||
{id: adminRole.id, position: 2},
|
||||
]);
|
||||
|
||||
await addMemberRole(harness, owner.token, guild.id, adminMember.userId, adminRole.id);
|
||||
|
||||
await createBuilder(harness, adminMember.token)
|
||||
.patch(`/guilds/${guild.id}/roles/${highRole.id}`)
|
||||
.body({name: 'Trying to Modify'})
|
||||
.expect(HTTP_STATUS.FORBIDDEN)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('should allow owner to modify any role regardless of hierarchy', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, account.token, 'Owner Hierarchy Test');
|
||||
|
||||
const highRole = await createRole(harness, account.token, guild.id, {
|
||||
name: 'High Role',
|
||||
permissions: Permissions.ADMINISTRATOR.toString(),
|
||||
});
|
||||
|
||||
await updateRolePositions(harness, account.token, guild.id, [{id: highRole.id, position: 10}]);
|
||||
|
||||
await createBuilder(harness, account.token)
|
||||
.patch(`/guilds/${guild.id}/roles/${highRole.id}`)
|
||||
.body({name: 'Modified By Owner'})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
});
|
||||
});
|
||||
});
|
||||
260
packages/api/src/guild/tests/GuildFolderOperations.test.tsx
Normal file
260
packages/api/src/guild/tests/GuildFolderOperations.test.tsx
Normal file
@@ -0,0 +1,260 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {createTestAccount} from '@fluxer/api/src/auth/tests/AuthTestUtils';
|
||||
import {authorizeBot, createTestBotAccount} from '@fluxer/api/src/bot/tests/BotTestUtils';
|
||||
import {acceptInvite, createChannelInvite, createGuild, getChannel} from '@fluxer/api/src/guild/tests/GuildTestUtils';
|
||||
import {type ApiTestHarness, createApiTestHarness} from '@fluxer/api/src/test/ApiTestHarness';
|
||||
import {HTTP_STATUS} from '@fluxer/api/src/test/TestConstants';
|
||||
import {createBuilder} from '@fluxer/api/src/test/TestRequestBuilder';
|
||||
import {UNCATEGORIZED_FOLDER_ID} from '@fluxer/constants/src/UserConstants';
|
||||
import type {UserSettingsResponse} from '@fluxer/schema/src/domains/user/UserResponseSchemas';
|
||||
import {afterEach, beforeEach, describe, expect, test} from 'vitest';
|
||||
|
||||
describe('Guild Folder Operations', () => {
|
||||
let harness: ApiTestHarness;
|
||||
|
||||
beforeEach(async () => {
|
||||
harness = await createApiTestHarness();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await harness?.shutdown();
|
||||
});
|
||||
|
||||
test('should prepend newly created guild to uncategorized folder', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
|
||||
const guild1 = await createGuild(harness, account.token, 'Guild 1');
|
||||
const guild2 = await createGuild(harness, account.token, 'Guild 2');
|
||||
|
||||
const {json: settings} = await createBuilder<UserSettingsResponse>(harness, account.token)
|
||||
.get('/users/@me/settings')
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.executeWithResponse();
|
||||
|
||||
expect(settings.guild_folders).toBeDefined();
|
||||
const uncategorizedFolder = settings.guild_folders?.find((folder) => folder.id === UNCATEGORIZED_FOLDER_ID);
|
||||
|
||||
expect(uncategorizedFolder).toBeDefined();
|
||||
expect(uncategorizedFolder?.guild_ids).toEqual([guild2.id, guild1.id]);
|
||||
});
|
||||
|
||||
test('should prepend newly joined guild to uncategorized folder', async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
const member = await createTestAccount(harness);
|
||||
|
||||
const guild1 = await createGuild(harness, owner.token, 'Guild 1');
|
||||
const channel1 = await getChannel(harness, owner.token, guild1.system_channel_id!);
|
||||
const invite1 = await createChannelInvite(harness, owner.token, channel1.id);
|
||||
|
||||
const guild2 = await createGuild(harness, owner.token, 'Guild 2');
|
||||
const channel2 = await getChannel(harness, owner.token, guild2.system_channel_id!);
|
||||
const invite2 = await createChannelInvite(harness, owner.token, channel2.id);
|
||||
|
||||
await acceptInvite(harness, member.token, invite1.code);
|
||||
await acceptInvite(harness, member.token, invite2.code);
|
||||
|
||||
const {json: settings} = await createBuilder<UserSettingsResponse>(harness, member.token)
|
||||
.get('/users/@me/settings')
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.executeWithResponse();
|
||||
|
||||
const uncategorizedFolder = settings.guild_folders?.find((folder) => folder.id === UNCATEGORIZED_FOLDER_ID);
|
||||
|
||||
expect(uncategorizedFolder).toBeDefined();
|
||||
expect(uncategorizedFolder?.guild_ids).toEqual([guild2.id, guild1.id]);
|
||||
});
|
||||
|
||||
test('should remove guild from all folders when leaving', async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
const member = await createTestAccount(harness);
|
||||
|
||||
const guild1 = await createGuild(harness, owner.token, 'Guild 1');
|
||||
const channel1 = await getChannel(harness, owner.token, guild1.system_channel_id!);
|
||||
const invite1 = await createChannelInvite(harness, owner.token, channel1.id);
|
||||
|
||||
const guild2 = await createGuild(harness, owner.token, 'Guild 2');
|
||||
const channel2 = await getChannel(harness, owner.token, guild2.system_channel_id!);
|
||||
const invite2 = await createChannelInvite(harness, owner.token, channel2.id);
|
||||
|
||||
await acceptInvite(harness, member.token, invite1.code);
|
||||
await acceptInvite(harness, member.token, invite2.code);
|
||||
|
||||
await createBuilder(harness, member.token)
|
||||
.patch('/users/@me/settings')
|
||||
.body({
|
||||
guild_folders: [
|
||||
{
|
||||
id: 1,
|
||||
name: 'My Folder',
|
||||
guild_ids: [guild1.id],
|
||||
},
|
||||
{
|
||||
id: UNCATEGORIZED_FOLDER_ID,
|
||||
guild_ids: [guild2.id],
|
||||
},
|
||||
],
|
||||
})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.executeWithResponse();
|
||||
|
||||
await createBuilder(harness, member.token)
|
||||
.delete(`/users/@me/guilds/${guild1.id}`)
|
||||
.expect(HTTP_STATUS.NO_CONTENT)
|
||||
.executeWithResponse();
|
||||
|
||||
const {json: settings} = await createBuilder<UserSettingsResponse>(harness, member.token)
|
||||
.get('/users/@me/settings')
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.executeWithResponse();
|
||||
|
||||
const customFolder = settings.guild_folders?.find((folder) => folder.id === 1);
|
||||
expect(customFolder).toBeUndefined();
|
||||
|
||||
const uncategorizedFolder = settings.guild_folders?.find((folder) => folder.id === UNCATEGORIZED_FOLDER_ID);
|
||||
expect(uncategorizedFolder?.guild_ids).toEqual([guild2.id]);
|
||||
});
|
||||
|
||||
test('should remove guild from folders across multiple custom folders', async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
const member = await createTestAccount(harness);
|
||||
|
||||
const guild1 = await createGuild(harness, owner.token, 'Guild 1');
|
||||
const channel1 = await getChannel(harness, owner.token, guild1.system_channel_id!);
|
||||
const invite1 = await createChannelInvite(harness, owner.token, channel1.id);
|
||||
|
||||
const guild2 = await createGuild(harness, owner.token, 'Guild 2');
|
||||
const channel2 = await getChannel(harness, owner.token, guild2.system_channel_id!);
|
||||
const invite2 = await createChannelInvite(harness, owner.token, channel2.id);
|
||||
|
||||
const guild3 = await createGuild(harness, owner.token, 'Guild 3');
|
||||
const channel3 = await getChannel(harness, owner.token, guild3.system_channel_id!);
|
||||
const invite3 = await createChannelInvite(harness, owner.token, channel3.id);
|
||||
|
||||
await acceptInvite(harness, member.token, invite1.code);
|
||||
await acceptInvite(harness, member.token, invite2.code);
|
||||
await acceptInvite(harness, member.token, invite3.code);
|
||||
|
||||
await createBuilder(harness, member.token)
|
||||
.patch('/users/@me/settings')
|
||||
.body({
|
||||
guild_folders: [
|
||||
{
|
||||
id: 1,
|
||||
name: 'Folder 1',
|
||||
guild_ids: [guild1.id, guild2.id],
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: 'Folder 2',
|
||||
guild_ids: [guild3.id],
|
||||
},
|
||||
],
|
||||
})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.executeWithResponse();
|
||||
|
||||
await createBuilder(harness, member.token)
|
||||
.delete(`/users/@me/guilds/${guild2.id}`)
|
||||
.expect(HTTP_STATUS.NO_CONTENT)
|
||||
.executeWithResponse();
|
||||
|
||||
const {json: settings} = await createBuilder<UserSettingsResponse>(harness, member.token)
|
||||
.get('/users/@me/settings')
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.executeWithResponse();
|
||||
|
||||
const folder1 = settings.guild_folders?.find((folder) => folder.id === 1);
|
||||
expect(folder1?.guild_ids).toEqual([guild1.id]);
|
||||
|
||||
const folder2 = settings.guild_folders?.find((folder) => folder.id === 2);
|
||||
expect(folder2?.guild_ids).toEqual([guild3.id]);
|
||||
});
|
||||
|
||||
test('should remove guild from all members folders when guild is deleted', async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
const member1 = await createTestAccount(harness);
|
||||
const member2 = await createTestAccount(harness);
|
||||
|
||||
const guild = await createGuild(harness, owner.token, 'Test Guild');
|
||||
const channel = await getChannel(harness, owner.token, guild.system_channel_id!);
|
||||
const invite = await createChannelInvite(harness, owner.token, channel.id);
|
||||
|
||||
await acceptInvite(harness, member1.token, invite.code);
|
||||
await acceptInvite(harness, member2.token, invite.code);
|
||||
|
||||
await createBuilder(harness, owner.token)
|
||||
.post(`/guilds/${guild.id}/delete`)
|
||||
.body({password: owner.password})
|
||||
.expect(HTTP_STATUS.NO_CONTENT)
|
||||
.executeWithResponse();
|
||||
|
||||
const {json: ownerSettings} = await createBuilder<UserSettingsResponse>(harness, owner.token)
|
||||
.get('/users/@me/settings')
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.executeWithResponse();
|
||||
|
||||
const {json: member1Settings} = await createBuilder<UserSettingsResponse>(harness, member1.token)
|
||||
.get('/users/@me/settings')
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.executeWithResponse();
|
||||
|
||||
const {json: member2Settings} = await createBuilder<UserSettingsResponse>(harness, member2.token)
|
||||
.get('/users/@me/settings')
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.executeWithResponse();
|
||||
|
||||
const ownerUncategorized = ownerSettings.guild_folders?.find((folder) => folder.id === UNCATEGORIZED_FOLDER_ID);
|
||||
const member1Uncategorized = member1Settings.guild_folders?.find((folder) => folder.id === UNCATEGORIZED_FOLDER_ID);
|
||||
const member2Uncategorized = member2Settings.guild_folders?.find((folder) => folder.id === UNCATEGORIZED_FOLDER_ID);
|
||||
|
||||
expect(ownerUncategorized?.guild_ids ?? []).not.toContain(guild.id);
|
||||
expect(member1Uncategorized?.guild_ids ?? []).not.toContain(guild.id);
|
||||
expect(member2Uncategorized?.guild_ids ?? []).not.toContain(guild.id);
|
||||
});
|
||||
|
||||
test('should not update guild folders for bot users when joining', async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
const botAccount = await createTestBotAccount(harness);
|
||||
|
||||
const guild = await createGuild(harness, owner.token, 'Test Guild');
|
||||
|
||||
await authorizeBot(harness, owner.token, botAccount.appId, ['bot'], guild.id, '0');
|
||||
|
||||
expect(true).toBe(true);
|
||||
});
|
||||
|
||||
test('should not update guild folders for bot users when leaving', async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
const botAccount = await createTestBotAccount(harness);
|
||||
|
||||
const guild = await createGuild(harness, owner.token, 'Test Guild');
|
||||
|
||||
await authorizeBot(harness, owner.token, botAccount.appId, ['bot'], guild.id, '0');
|
||||
|
||||
const {json: ownerGuilds} = await createBuilder<Array<{id: string}>>(harness, owner.token)
|
||||
.get('/users/@me/guilds')
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.executeWithResponse();
|
||||
|
||||
const botIsInGuild = ownerGuilds.some((g) => g.id === guild.id);
|
||||
expect(botIsInGuild).toBe(true);
|
||||
});
|
||||
});
|
||||
730
packages/api/src/guild/tests/GuildMemberManagement.test.tsx
Normal file
730
packages/api/src/guild/tests/GuildMemberManagement.test.tsx
Normal file
@@ -0,0 +1,730 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {createTestAccount} from '@fluxer/api/src/auth/tests/AuthTestUtils';
|
||||
import {getPngDataUrl, getTooLargePngDataUrl} from '@fluxer/api/src/emoji/tests/EmojiTestUtils';
|
||||
import {
|
||||
addMemberRole,
|
||||
createRole,
|
||||
getMember,
|
||||
removeMemberRole,
|
||||
setupTestGuildWithMembers,
|
||||
updateMember,
|
||||
updateRolePositions,
|
||||
} from '@fluxer/api/src/guild/tests/GuildTestUtils';
|
||||
import {ensureSessionStarted} from '@fluxer/api/src/message/tests/MessageTestUtils';
|
||||
import {type ApiTestHarness, createApiTestHarness} from '@fluxer/api/src/test/ApiTestHarness';
|
||||
import {HTTP_STATUS} from '@fluxer/api/src/test/TestConstants';
|
||||
import {createBuilder} from '@fluxer/api/src/test/TestRequestBuilder';
|
||||
import {grantPremium} from '@fluxer/api/src/user/tests/UserTestUtils';
|
||||
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
|
||||
import {Permissions} from '@fluxer/constants/src/ChannelConstants';
|
||||
import type {GuildMemberResponse} from '@fluxer/schema/src/domains/guild/GuildMemberSchemas';
|
||||
import {afterEach, beforeEach, describe, expect, test} from 'vitest';
|
||||
|
||||
describe('Guild Member Management', () => {
|
||||
let harness: ApiTestHarness;
|
||||
|
||||
beforeEach(async () => {
|
||||
harness = await createApiTestHarness();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await harness?.shutdown();
|
||||
});
|
||||
|
||||
test('should remove role from member', async () => {
|
||||
const {owner, members, guild} = await setupTestGuildWithMembers(harness, 1);
|
||||
const member = members[0];
|
||||
|
||||
const role = await createRole(harness, owner.token, guild.id, {
|
||||
name: 'Test Role',
|
||||
});
|
||||
|
||||
await addMemberRole(harness, owner.token, guild.id, member.userId, role.id);
|
||||
|
||||
let memberInfo = await getMember(harness, owner.token, guild.id, member.userId);
|
||||
expect(memberInfo.roles).toContain(role.id);
|
||||
|
||||
await removeMemberRole(harness, owner.token, guild.id, member.userId, role.id);
|
||||
|
||||
memberInfo = await getMember(harness, owner.token, guild.id, member.userId);
|
||||
expect(memberInfo.roles).not.toContain(role.id);
|
||||
});
|
||||
|
||||
test('should reject assigning @everyone via member update', async () => {
|
||||
const {owner, members, guild} = await setupTestGuildWithMembers(harness, 1);
|
||||
const member = members[0];
|
||||
|
||||
type InvalidFormResponse = {errors: Array<{path: string}>; code: string};
|
||||
const {json} = await createBuilder<InvalidFormResponse>(harness, owner.token)
|
||||
.patch(`/guilds/${guild.id}/members/${member.userId}`)
|
||||
.body({roles: [guild.id]})
|
||||
.expect(HTTP_STATUS.BAD_REQUEST, APIErrorCodes.INVALID_FORM_BODY)
|
||||
.executeWithResponse();
|
||||
|
||||
expect(json.errors?.some((error) => error.path === 'roles')).toBe(true);
|
||||
});
|
||||
|
||||
test('should reject adding @everyone role explicitly', async () => {
|
||||
const {owner, members, guild} = await setupTestGuildWithMembers(harness, 1);
|
||||
const member = members[0];
|
||||
|
||||
type InvalidFormResponse = {errors: Array<{path: string}>; code: string};
|
||||
const {json} = await createBuilder<InvalidFormResponse>(harness, owner.token)
|
||||
.put(`/guilds/${guild.id}/members/${member.userId}/roles/${guild.id}`)
|
||||
.expect(HTTP_STATUS.BAD_REQUEST, APIErrorCodes.INVALID_FORM_BODY)
|
||||
.executeWithResponse();
|
||||
|
||||
expect(json.errors?.some((error) => error.path === 'role_id')).toBe(true);
|
||||
});
|
||||
|
||||
test('should update member nickname', async () => {
|
||||
const {owner, members, guild} = await setupTestGuildWithMembers(harness, 1);
|
||||
const member = members[0];
|
||||
|
||||
const updatedMember = await updateMember(harness, owner.token, guild.id, member.userId, {
|
||||
nick: 'New Nickname',
|
||||
});
|
||||
|
||||
expect(updatedMember.nick).toBe('New Nickname');
|
||||
});
|
||||
|
||||
test('should require MANAGE_NICKNAMES to change others nickname', async () => {
|
||||
const {members, guild} = await setupTestGuildWithMembers(harness, 2);
|
||||
const [member1, member2] = members;
|
||||
|
||||
await createBuilder(harness, member1.token)
|
||||
.patch(`/guilds/${guild.id}/members/${member2.userId}`)
|
||||
.body({nick: 'New Nick'})
|
||||
.expect(HTTP_STATUS.FORBIDDEN)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('should allow changing own nickname with CHANGE_NICKNAME', async () => {
|
||||
const {members, guild} = await setupTestGuildWithMembers(harness, 1);
|
||||
const member = members[0];
|
||||
|
||||
await createBuilder(harness, member.token)
|
||||
.patch(`/guilds/${guild.id}/members/@me`)
|
||||
.body({nick: 'My Nickname'})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('should require MANAGE_ROLES to add roles', async () => {
|
||||
const {owner, members, guild} = await setupTestGuildWithMembers(harness, 2);
|
||||
const [member1, member2] = members;
|
||||
|
||||
const role = await createRole(harness, owner.token, guild.id, {
|
||||
name: 'Test Role',
|
||||
});
|
||||
|
||||
await createBuilder(harness, member1.token)
|
||||
.put(`/guilds/${guild.id}/members/${member2.userId}/roles/${role.id}`)
|
||||
.expect(HTTP_STATUS.FORBIDDEN)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('should enforce role hierarchy when adding roles', async () => {
|
||||
const {owner, members, guild} = await setupTestGuildWithMembers(harness, 1);
|
||||
const member = members[0];
|
||||
|
||||
const higherRole = await createRole(harness, owner.token, guild.id, {
|
||||
name: 'Higher Role',
|
||||
permissions: Permissions.MANAGE_ROLES.toString(),
|
||||
});
|
||||
|
||||
const lowerRole = await createRole(harness, owner.token, guild.id, {
|
||||
name: 'Lower Role',
|
||||
});
|
||||
|
||||
await updateRolePositions(harness, owner.token, guild.id, [
|
||||
{id: higherRole.id, position: 2},
|
||||
{id: lowerRole.id, position: 1},
|
||||
]);
|
||||
|
||||
await addMemberRole(harness, owner.token, guild.id, member.userId, lowerRole.id);
|
||||
|
||||
await createBuilder(harness, member.token)
|
||||
.put(`/guilds/${guild.id}/members/${owner.userId}/roles/${higherRole.id}`)
|
||||
.expect(HTTP_STATUS.FORBIDDEN)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('should kick member from guild', async () => {
|
||||
const {owner, members, guild} = await setupTestGuildWithMembers(harness, 1);
|
||||
const member = members[0];
|
||||
|
||||
await createBuilder(harness, owner.token)
|
||||
.delete(`/guilds/${guild.id}/members/${member.userId}`)
|
||||
.expect(HTTP_STATUS.NO_CONTENT)
|
||||
.execute();
|
||||
|
||||
await createBuilder(harness, owner.token)
|
||||
.get(`/guilds/${guild.id}/members/${member.userId}`)
|
||||
.expect(HTTP_STATUS.NOT_FOUND)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('should require KICK_MEMBERS to kick members', async () => {
|
||||
const {members, guild} = await setupTestGuildWithMembers(harness, 2);
|
||||
const [member1, member2] = members;
|
||||
|
||||
await createBuilder(harness, member1.token)
|
||||
.delete(`/guilds/${guild.id}/members/${member2.userId}`)
|
||||
.expect(HTTP_STATUS.FORBIDDEN)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('should not allow kicking the owner', async () => {
|
||||
const {owner, members, guild} = await setupTestGuildWithMembers(harness, 1);
|
||||
const member = members[0];
|
||||
|
||||
const modRole = await createRole(harness, owner.token, guild.id, {
|
||||
name: 'Moderator',
|
||||
permissions: Permissions.KICK_MEMBERS.toString(),
|
||||
});
|
||||
|
||||
await addMemberRole(harness, owner.token, guild.id, member.userId, modRole.id);
|
||||
|
||||
await createBuilder(harness, member.token)
|
||||
.delete(`/guilds/${guild.id}/members/${owner.userId}`)
|
||||
.expect(HTTP_STATUS.NOT_FOUND)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('should ban member from guild', async () => {
|
||||
const {owner, members, guild} = await setupTestGuildWithMembers(harness, 1);
|
||||
const member = members[0];
|
||||
|
||||
await createBuilder(harness, owner.token)
|
||||
.put(`/guilds/${guild.id}/bans/${member.userId}`)
|
||||
.body({})
|
||||
.expect(HTTP_STATUS.NO_CONTENT)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('should require BAN_MEMBERS to ban members', async () => {
|
||||
const {members, guild} = await setupTestGuildWithMembers(harness, 2);
|
||||
const [member1, member2] = members;
|
||||
|
||||
await createBuilder(harness, member1.token)
|
||||
.put(`/guilds/${guild.id}/bans/${member2.userId}`)
|
||||
.body({})
|
||||
.expect(HTTP_STATUS.FORBIDDEN)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('should not allow member to ban themselves', async () => {
|
||||
const {owner, members, guild} = await setupTestGuildWithMembers(harness, 1);
|
||||
const member = members[0];
|
||||
|
||||
const modRole = await createRole(harness, owner.token, guild.id, {
|
||||
name: 'Moderator',
|
||||
permissions: Permissions.BAN_MEMBERS.toString(),
|
||||
});
|
||||
|
||||
await addMemberRole(harness, owner.token, guild.id, member.userId, modRole.id);
|
||||
|
||||
await createBuilder(harness, member.token)
|
||||
.put(`/guilds/${guild.id}/bans/${member.userId}`)
|
||||
.body({})
|
||||
.expect(HTTP_STATUS.NOT_FOUND)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('should clear member nickname by setting to null', async () => {
|
||||
const {owner, members, guild} = await setupTestGuildWithMembers(harness, 1);
|
||||
const member = members[0];
|
||||
|
||||
await updateMember(harness, owner.token, guild.id, member.userId, {
|
||||
nick: 'Temporary Nick',
|
||||
});
|
||||
|
||||
const memberInfo = await getMember(harness, owner.token, guild.id, member.userId);
|
||||
expect(memberInfo.nick).toBe('Temporary Nick');
|
||||
|
||||
await createBuilder(harness, owner.token)
|
||||
.patch(`/guilds/${guild.id}/members/${member.userId}`)
|
||||
.body({nick: null})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
});
|
||||
|
||||
describe('List Members Permission Checks', () => {
|
||||
test('should reject non-member from listing guild members', async () => {
|
||||
const {guild} = await setupTestGuildWithMembers(harness, 1);
|
||||
const nonMember = await createTestAccount(harness);
|
||||
|
||||
await createBuilder(harness, nonMember.token)
|
||||
.get(`/guilds/${guild.id}/members`)
|
||||
.expect(HTTP_STATUS.NOT_FOUND)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('should allow regular member to list guild members', async () => {
|
||||
const {members, guild} = await setupTestGuildWithMembers(harness, 1);
|
||||
const member = members[0];
|
||||
|
||||
await createBuilder(harness, member.token).get(`/guilds/${guild.id}/members`).expect(HTTP_STATUS.OK).execute();
|
||||
});
|
||||
|
||||
test('should support limit parameter for listing members', async () => {
|
||||
const {owner, guild} = await setupTestGuildWithMembers(harness, 3);
|
||||
|
||||
const memberList = await createBuilder<Array<{user: {id: string}}>>(harness, owner.token)
|
||||
.get(`/guilds/${guild.id}/members?limit=2`)
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
|
||||
expect(memberList.length).toBeLessThanOrEqual(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Get Member Permission Checks', () => {
|
||||
test('should reject non-member from getting member info', async () => {
|
||||
const {members, guild} = await setupTestGuildWithMembers(harness, 1);
|
||||
const nonMember = await createTestAccount(harness);
|
||||
|
||||
await createBuilder(harness, nonMember.token)
|
||||
.get(`/guilds/${guild.id}/members/${members[0].userId}`)
|
||||
.expect(HTTP_STATUS.NOT_FOUND)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('should allow member to get their own member info', async () => {
|
||||
const {members, guild} = await setupTestGuildWithMembers(harness, 1);
|
||||
const member = members[0];
|
||||
|
||||
const memberInfo = await createBuilder<GuildMemberResponse>(harness, member.token)
|
||||
.get(`/guilds/${guild.id}/members/${member.userId}`)
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
|
||||
expect(memberInfo.user?.id).toBe(member.userId);
|
||||
});
|
||||
|
||||
test('should allow member to get other member info', async () => {
|
||||
const {members, guild} = await setupTestGuildWithMembers(harness, 2);
|
||||
const [member1, member2] = members;
|
||||
|
||||
const memberInfo = await createBuilder<GuildMemberResponse>(harness, member1.token)
|
||||
.get(`/guilds/${guild.id}/members/${member2.userId}`)
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
|
||||
expect(memberInfo.user?.id).toBe(member2.userId);
|
||||
});
|
||||
|
||||
test('should return 404 for non-existent member', async () => {
|
||||
const {owner, guild} = await setupTestGuildWithMembers(harness, 1);
|
||||
|
||||
await createBuilder(harness, owner.token)
|
||||
.get(`/guilds/${guild.id}/members/999999999999999999`)
|
||||
.expect(HTTP_STATUS.NOT_FOUND)
|
||||
.execute();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Update Member Nick Permission Checks', () => {
|
||||
test('should reject member without CHANGE_NICKNAME from changing own nickname when permission revoked', async () => {
|
||||
const {owner, members, guild} = await setupTestGuildWithMembers(harness, 1);
|
||||
const member = members[0];
|
||||
|
||||
const restrictedRole = await createRole(harness, owner.token, guild.id, {
|
||||
name: 'Restricted',
|
||||
permissions: '0',
|
||||
});
|
||||
|
||||
await addMemberRole(harness, owner.token, guild.id, member.userId, restrictedRole.id);
|
||||
|
||||
await createBuilder(harness, member.token)
|
||||
.patch(`/guilds/${guild.id}/members/@me`)
|
||||
.body({nick: 'Test Nick'})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('should allow owner to change any member nickname', async () => {
|
||||
const {owner, members, guild} = await setupTestGuildWithMembers(harness, 1);
|
||||
const member = members[0];
|
||||
|
||||
const updatedMember = await updateMember(harness, owner.token, guild.id, member.userId, {
|
||||
nick: 'Owner Set Nick',
|
||||
});
|
||||
|
||||
expect(updatedMember.nick).toBe('Owner Set Nick');
|
||||
});
|
||||
|
||||
test('should allow member with MANAGE_NICKNAMES to change others nickname', async () => {
|
||||
const {owner, members, guild} = await setupTestGuildWithMembers(harness, 2);
|
||||
const [moderator, target] = members;
|
||||
|
||||
const modRole = await createRole(harness, owner.token, guild.id, {
|
||||
name: 'Moderator',
|
||||
permissions: Permissions.MANAGE_NICKNAMES.toString(),
|
||||
});
|
||||
|
||||
await addMemberRole(harness, owner.token, guild.id, moderator.userId, modRole.id);
|
||||
|
||||
const updatedMember = await updateMember(harness, moderator.token, guild.id, target.userId, {
|
||||
nick: 'Mod Set Nick',
|
||||
});
|
||||
|
||||
expect(updatedMember.nick).toBe('Mod Set Nick');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Kick Member Permission Checks', () => {
|
||||
test('should not allow member to kick someone with higher role', async () => {
|
||||
const {owner, members, guild} = await setupTestGuildWithMembers(harness, 2);
|
||||
const [moderator, target] = members;
|
||||
|
||||
const modRole = await createRole(harness, owner.token, guild.id, {
|
||||
name: 'Moderator',
|
||||
permissions: Permissions.KICK_MEMBERS.toString(),
|
||||
});
|
||||
|
||||
const targetRole = await createRole(harness, owner.token, guild.id, {
|
||||
name: 'Target Role',
|
||||
});
|
||||
|
||||
await updateRolePositions(harness, owner.token, guild.id, [
|
||||
{id: targetRole.id, position: 3},
|
||||
{id: modRole.id, position: 2},
|
||||
]);
|
||||
|
||||
await addMemberRole(harness, owner.token, guild.id, moderator.userId, modRole.id);
|
||||
await addMemberRole(harness, owner.token, guild.id, target.userId, targetRole.id);
|
||||
|
||||
await createBuilder(harness, moderator.token)
|
||||
.delete(`/guilds/${guild.id}/members/${target.userId}`)
|
||||
.expect(HTTP_STATUS.FORBIDDEN)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('should allow member with KICK_MEMBERS to kick lower ranked member', async () => {
|
||||
const {owner, members, guild} = await setupTestGuildWithMembers(harness, 2);
|
||||
const [moderator, target] = members;
|
||||
|
||||
const modRole = await createRole(harness, owner.token, guild.id, {
|
||||
name: 'Moderator',
|
||||
permissions: Permissions.KICK_MEMBERS.toString(),
|
||||
});
|
||||
|
||||
await updateRolePositions(harness, owner.token, guild.id, [{id: modRole.id, position: 2}]);
|
||||
|
||||
await addMemberRole(harness, owner.token, guild.id, moderator.userId, modRole.id);
|
||||
|
||||
await createBuilder(harness, moderator.token)
|
||||
.delete(`/guilds/${guild.id}/members/${target.userId}`)
|
||||
.expect(HTTP_STATUS.NO_CONTENT)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('should not allow member to kick themselves', async () => {
|
||||
const {owner, members, guild} = await setupTestGuildWithMembers(harness, 1);
|
||||
const member = members[0];
|
||||
|
||||
const modRole = await createRole(harness, owner.token, guild.id, {
|
||||
name: 'Moderator',
|
||||
permissions: Permissions.KICK_MEMBERS.toString(),
|
||||
});
|
||||
|
||||
await addMemberRole(harness, owner.token, guild.id, member.userId, modRole.id);
|
||||
|
||||
await createBuilder(harness, member.token)
|
||||
.delete(`/guilds/${guild.id}/members/${member.userId}`)
|
||||
.expect(HTTP_STATUS.NOT_FOUND)
|
||||
.execute();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Member Avatar Upload Validation', () => {
|
||||
test('should reject member avatar upload without premium', async () => {
|
||||
const {members, guild} = await setupTestGuildWithMembers(harness, 1);
|
||||
const member = members[0];
|
||||
|
||||
await ensureSessionStarted(harness, member.token);
|
||||
|
||||
const memberInfo = await createBuilder<GuildMemberResponse>(harness, member.token)
|
||||
.patch(`/guilds/${guild.id}/members/@me`)
|
||||
.body({avatar: getPngDataUrl()})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
|
||||
expect(memberInfo.avatar).toBeNull();
|
||||
});
|
||||
|
||||
test('should allow member avatar upload with premium', async () => {
|
||||
const {members, guild} = await setupTestGuildWithMembers(harness, 1);
|
||||
const member = members[0];
|
||||
|
||||
await ensureSessionStarted(harness, member.token);
|
||||
await grantPremium(harness, member.userId, 2);
|
||||
|
||||
const updatedMember = await createBuilder<GuildMemberResponse>(harness, member.token)
|
||||
.patch(`/guilds/${guild.id}/members/@me`)
|
||||
.body({avatar: getPngDataUrl()})
|
||||
.execute();
|
||||
|
||||
expect(updatedMember.avatar).toBeTruthy();
|
||||
});
|
||||
|
||||
test('should reject avatar that exceeds size limit', async () => {
|
||||
const {members, guild} = await setupTestGuildWithMembers(harness, 1);
|
||||
const member = members[0];
|
||||
|
||||
await ensureSessionStarted(harness, member.token);
|
||||
await grantPremium(harness, member.userId, 2);
|
||||
|
||||
await createBuilder<GuildMemberResponse>(harness, member.token)
|
||||
.patch(`/guilds/${guild.id}/members/@me`)
|
||||
.body({avatar: getTooLargePngDataUrl()})
|
||||
.expect(HTTP_STATUS.BAD_REQUEST)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('should allow clearing member avatar by setting to null', async () => {
|
||||
const {members, guild} = await setupTestGuildWithMembers(harness, 1);
|
||||
const member = members[0];
|
||||
|
||||
await ensureSessionStarted(harness, member.token);
|
||||
await grantPremium(harness, member.userId, 2);
|
||||
|
||||
await createBuilder<GuildMemberResponse>(harness, member.token)
|
||||
.patch(`/guilds/${guild.id}/members/@me`)
|
||||
.body({avatar: getPngDataUrl()})
|
||||
.execute();
|
||||
|
||||
const clearedMember = await createBuilder<GuildMemberResponse>(harness, member.token)
|
||||
.patch(`/guilds/${guild.id}/members/@me`)
|
||||
.body({avatar: null})
|
||||
.execute();
|
||||
|
||||
expect(clearedMember.avatar).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Member Banner Upload Validation', () => {
|
||||
test('should reject member banner upload without premium', async () => {
|
||||
const {members, guild} = await setupTestGuildWithMembers(harness, 1);
|
||||
const member = members[0];
|
||||
|
||||
await ensureSessionStarted(harness, member.token);
|
||||
|
||||
const memberInfo = await createBuilder<GuildMemberResponse>(harness, member.token)
|
||||
.patch(`/guilds/${guild.id}/members/@me`)
|
||||
.body({banner: getPngDataUrl()})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
|
||||
expect(memberInfo.banner).toBeNull();
|
||||
});
|
||||
|
||||
test('should allow member banner upload with premium', async () => {
|
||||
const {members, guild} = await setupTestGuildWithMembers(harness, 1);
|
||||
const member = members[0];
|
||||
|
||||
await ensureSessionStarted(harness, member.token);
|
||||
await grantPremium(harness, member.userId, 2);
|
||||
|
||||
const updatedMember = await createBuilder<GuildMemberResponse>(harness, member.token)
|
||||
.patch(`/guilds/${guild.id}/members/@me`)
|
||||
.body({banner: getPngDataUrl()})
|
||||
.execute();
|
||||
|
||||
expect(updatedMember.banner).toBeTruthy();
|
||||
});
|
||||
|
||||
test('should reject banner that exceeds size limit', async () => {
|
||||
const {members, guild} = await setupTestGuildWithMembers(harness, 1);
|
||||
const member = members[0];
|
||||
|
||||
await ensureSessionStarted(harness, member.token);
|
||||
await grantPremium(harness, member.userId, 2);
|
||||
|
||||
await createBuilder<GuildMemberResponse>(harness, member.token)
|
||||
.patch(`/guilds/${guild.id}/members/@me`)
|
||||
.body({banner: getTooLargePngDataUrl()})
|
||||
.expect(HTTP_STATUS.BAD_REQUEST)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('should allow clearing member banner by setting to null', async () => {
|
||||
const {members, guild} = await setupTestGuildWithMembers(harness, 1);
|
||||
const member = members[0];
|
||||
|
||||
await ensureSessionStarted(harness, member.token);
|
||||
await grantPremium(harness, member.userId, 2);
|
||||
|
||||
await createBuilder<GuildMemberResponse>(harness, member.token)
|
||||
.patch(`/guilds/${guild.id}/members/@me`)
|
||||
.body({banner: getPngDataUrl()})
|
||||
.execute();
|
||||
|
||||
const clearedMember = await createBuilder<GuildMemberResponse>(harness, member.token)
|
||||
.patch(`/guilds/${guild.id}/members/@me`)
|
||||
.body({banner: null})
|
||||
.execute();
|
||||
|
||||
expect(clearedMember.banner).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Member Role Assignment/Removal', () => {
|
||||
test('should not allow assigning role higher than own highest role', async () => {
|
||||
const {owner, members, guild} = await setupTestGuildWithMembers(harness, 2);
|
||||
const [moderator, target] = members;
|
||||
|
||||
const highRole = await createRole(harness, owner.token, guild.id, {
|
||||
name: 'High Role',
|
||||
permissions: '0',
|
||||
});
|
||||
|
||||
const modRole = await createRole(harness, owner.token, guild.id, {
|
||||
name: 'Moderator',
|
||||
permissions: '268435456',
|
||||
});
|
||||
|
||||
await updateRolePositions(harness, owner.token, guild.id, [
|
||||
{id: highRole.id, position: 3},
|
||||
{id: modRole.id, position: 2},
|
||||
]);
|
||||
|
||||
await updateMember(harness, owner.token, guild.id, moderator.userId, {
|
||||
roles: [modRole.id],
|
||||
});
|
||||
|
||||
await createBuilder(harness, moderator.token)
|
||||
.put(`/guilds/${guild.id}/members/${target.userId}/roles/${highRole.id}`)
|
||||
.expect(HTTP_STATUS.FORBIDDEN)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('should allow owner to assign any role to a member', async () => {
|
||||
const {owner, members, guild} = await setupTestGuildWithMembers(harness, 1);
|
||||
const member = members[0];
|
||||
|
||||
const newRole = await createRole(harness, owner.token, guild.id, {
|
||||
name: 'New Role',
|
||||
permissions: '0',
|
||||
});
|
||||
|
||||
const updatedMember = await updateMember(harness, owner.token, guild.id, member.userId, {
|
||||
roles: [newRole.id],
|
||||
});
|
||||
|
||||
expect(updatedMember.roles).toContain(newRole.id);
|
||||
});
|
||||
|
||||
test('should not allow removing role higher than own highest role', async () => {
|
||||
const {owner, members, guild} = await setupTestGuildWithMembers(harness, 2);
|
||||
const [moderator, target] = members;
|
||||
|
||||
const highRole = await createRole(harness, owner.token, guild.id, {
|
||||
name: 'High Role',
|
||||
permissions: '0',
|
||||
});
|
||||
|
||||
const modRole = await createRole(harness, owner.token, guild.id, {
|
||||
name: 'Moderator',
|
||||
permissions: '268435456',
|
||||
});
|
||||
|
||||
await updateRolePositions(harness, owner.token, guild.id, [
|
||||
{id: highRole.id, position: 3},
|
||||
{id: modRole.id, position: 2},
|
||||
]);
|
||||
|
||||
await updateMember(harness, owner.token, guild.id, moderator.userId, {
|
||||
roles: [modRole.id],
|
||||
});
|
||||
await updateMember(harness, owner.token, guild.id, target.userId, {
|
||||
roles: [highRole.id],
|
||||
});
|
||||
|
||||
await createBuilder(harness, moderator.token)
|
||||
.delete(`/guilds/${guild.id}/members/${target.userId}/roles/${highRole.id}`)
|
||||
.expect(HTTP_STATUS.FORBIDDEN)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('should allow owner to remove any role from a member', async () => {
|
||||
const {owner, members, guild} = await setupTestGuildWithMembers(harness, 1);
|
||||
const member = members[0];
|
||||
|
||||
const role = await createRole(harness, owner.token, guild.id, {
|
||||
name: 'Removable Role',
|
||||
permissions: '0',
|
||||
});
|
||||
|
||||
await updateMember(harness, owner.token, guild.id, member.userId, {
|
||||
roles: [role.id],
|
||||
});
|
||||
|
||||
const updatedMember = await updateMember(harness, owner.token, guild.id, member.userId, {
|
||||
roles: [],
|
||||
});
|
||||
|
||||
expect(updatedMember.roles).not.toContain(role.id);
|
||||
});
|
||||
|
||||
test('should not allow member without MANAGE_ROLES to assign roles', async () => {
|
||||
const {owner, members, guild} = await setupTestGuildWithMembers(harness, 2);
|
||||
const [member1, member2] = members;
|
||||
|
||||
const role = await createRole(harness, owner.token, guild.id, {
|
||||
name: 'Test Role',
|
||||
});
|
||||
|
||||
await createBuilder(harness, member1.token)
|
||||
.put(`/guilds/${guild.id}/members/${member2.userId}/roles/${role.id}`)
|
||||
.expect(HTTP_STATUS.FORBIDDEN)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('should not allow member without MANAGE_ROLES to remove roles', async () => {
|
||||
const {owner, members, guild} = await setupTestGuildWithMembers(harness, 2);
|
||||
const [member1, member2] = members;
|
||||
|
||||
const role = await createRole(harness, owner.token, guild.id, {
|
||||
name: 'Test Role',
|
||||
});
|
||||
|
||||
await addMemberRole(harness, owner.token, guild.id, member2.userId, role.id);
|
||||
|
||||
await createBuilder(harness, member1.token)
|
||||
.delete(`/guilds/${guild.id}/members/${member2.userId}/roles/${role.id}`)
|
||||
.expect(HTTP_STATUS.FORBIDDEN)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('should not allow assigning non-existent role', async () => {
|
||||
const {owner, members, guild} = await setupTestGuildWithMembers(harness, 1);
|
||||
const member = members[0];
|
||||
|
||||
await createBuilder(harness, owner.token)
|
||||
.put(`/guilds/${guild.id}/members/${member.userId}/roles/999999999999999999`)
|
||||
.expect(HTTP_STATUS.NOT_FOUND)
|
||||
.execute();
|
||||
});
|
||||
});
|
||||
});
|
||||
164
packages/api/src/guild/tests/GuildMfaLevel.test.tsx
Normal file
164
packages/api/src/guild/tests/GuildMfaLevel.test.tsx
Normal file
@@ -0,0 +1,164 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {createTestAccount, type TestAccount, totpCodeNow} from '@fluxer/api/src/auth/tests/AuthTestUtils';
|
||||
import {createGuild, setupTestGuildWithMembers} from '@fluxer/api/src/guild/tests/GuildTestUtils';
|
||||
import {type ApiTestHarness, createApiTestHarness} from '@fluxer/api/src/test/ApiTestHarness';
|
||||
import {HTTP_STATUS} from '@fluxer/api/src/test/TestConstants';
|
||||
import {createBuilder, createBuilderWithoutAuth} from '@fluxer/api/src/test/TestRequestBuilder';
|
||||
import {GuildMFALevel} from '@fluxer/constants/src/GuildConstants';
|
||||
import type {GuildResponse} from '@fluxer/schema/src/domains/guild/GuildResponseSchemas';
|
||||
import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest';
|
||||
|
||||
const TOTP_SECRET = 'JBSWY3DPEHPK3PXP';
|
||||
|
||||
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};
|
||||
}
|
||||
|
||||
describe('Guild MFA level', () => {
|
||||
let harness: ApiTestHarness;
|
||||
|
||||
beforeAll(async () => {
|
||||
harness = await createApiTestHarness();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await harness?.shutdown();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await harness.reset();
|
||||
});
|
||||
|
||||
it('rejects enabling mfa_level when owner has no 2FA', async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, owner.token, 'MFA Test Guild');
|
||||
|
||||
await createBuilder(harness, owner.token)
|
||||
.patch(`/guilds/${guild.id}`)
|
||||
.body({mfa_level: GuildMFALevel.ELEVATED, password: owner.password})
|
||||
.expect(HTTP_STATUS.BAD_REQUEST)
|
||||
.execute();
|
||||
});
|
||||
|
||||
it('rejects disabling mfa_level when owner has no 2FA', async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, owner.token, 'MFA Test Guild');
|
||||
|
||||
await createBuilder(harness, owner.token)
|
||||
.patch(`/guilds/${guild.id}`)
|
||||
.body({mfa_level: GuildMFALevel.NONE, password: owner.password})
|
||||
.expect(HTTP_STATUS.BAD_REQUEST)
|
||||
.execute();
|
||||
});
|
||||
|
||||
it('requires sudo mode when changing mfa_level', async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
await enableTotp(harness, owner);
|
||||
const loggedIn = await loginWithTotp(harness, owner);
|
||||
const guild = await createGuild(harness, loggedIn.token, 'MFA Test Guild');
|
||||
|
||||
await createBuilder(harness, loggedIn.token)
|
||||
.patch(`/guilds/${guild.id}`)
|
||||
.body({mfa_level: GuildMFALevel.ELEVATED})
|
||||
.expect(HTTP_STATUS.FORBIDDEN, 'SUDO_MODE_REQUIRED')
|
||||
.execute();
|
||||
});
|
||||
|
||||
it('allows enabling mfa_level with sudo verification via TOTP', async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
await enableTotp(harness, owner);
|
||||
const loggedIn = await loginWithTotp(harness, owner);
|
||||
const guild = await createGuild(harness, loggedIn.token, 'MFA Test Guild');
|
||||
|
||||
const updated = await createBuilder<GuildResponse>(harness, loggedIn.token)
|
||||
.patch(`/guilds/${guild.id}`)
|
||||
.body({mfa_level: GuildMFALevel.ELEVATED, mfa_method: 'totp', mfa_code: totpCodeNow(TOTP_SECRET)})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
|
||||
expect(updated.mfa_level).toBe(GuildMFALevel.ELEVATED);
|
||||
});
|
||||
|
||||
it('allows disabling mfa_level with sudo verification via TOTP', async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
await enableTotp(harness, owner);
|
||||
const loggedIn = await loginWithTotp(harness, owner);
|
||||
const guild = await createGuild(harness, loggedIn.token, 'MFA Test Guild');
|
||||
|
||||
await createBuilder<GuildResponse>(harness, loggedIn.token)
|
||||
.patch(`/guilds/${guild.id}`)
|
||||
.body({mfa_level: GuildMFALevel.ELEVATED, mfa_method: 'totp', mfa_code: totpCodeNow(TOTP_SECRET)})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
|
||||
const updated = await createBuilder<GuildResponse>(harness, loggedIn.token)
|
||||
.patch(`/guilds/${guild.id}`)
|
||||
.body({mfa_level: GuildMFALevel.NONE, mfa_method: 'totp', mfa_code: totpCodeNow(TOTP_SECRET)})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
|
||||
expect(updated.mfa_level).toBe(GuildMFALevel.NONE);
|
||||
});
|
||||
|
||||
it('rejects mfa_level change from non-owner', async () => {
|
||||
const {members, guild} = await setupTestGuildWithMembers(harness, 1);
|
||||
const member = members[0]!;
|
||||
|
||||
await createBuilder(harness, member.token)
|
||||
.patch(`/guilds/${guild.id}`)
|
||||
.body({mfa_level: GuildMFALevel.ELEVATED, password: member.password})
|
||||
.expect(HTTP_STATUS.FORBIDDEN)
|
||||
.execute();
|
||||
});
|
||||
|
||||
it('does not require sudo mode for non-mfa_level guild updates', async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, owner.token, 'MFA Test Guild');
|
||||
|
||||
const updated = await createBuilder<GuildResponse>(harness, owner.token)
|
||||
.patch(`/guilds/${guild.id}`)
|
||||
.body({name: 'Renamed Guild'})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
|
||||
expect(updated.name).toBe('Renamed Guild');
|
||||
});
|
||||
});
|
||||
136
packages/api/src/guild/tests/GuildOperationPermissions.test.tsx
Normal file
136
packages/api/src/guild/tests/GuildOperationPermissions.test.tsx
Normal file
@@ -0,0 +1,136 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {createTestAccount} from '@fluxer/api/src/auth/tests/AuthTestUtils';
|
||||
import {
|
||||
acceptInvite,
|
||||
addMemberRole,
|
||||
createChannelInvite,
|
||||
createGuild,
|
||||
createRole,
|
||||
getChannel,
|
||||
leaveGuild,
|
||||
} from '@fluxer/api/src/guild/tests/GuildTestUtils';
|
||||
import {type ApiTestHarness, createApiTestHarness} from '@fluxer/api/src/test/ApiTestHarness';
|
||||
import {HTTP_STATUS} from '@fluxer/api/src/test/TestConstants';
|
||||
import {createBuilder} from '@fluxer/api/src/test/TestRequestBuilder';
|
||||
import {Permissions} from '@fluxer/constants/src/ChannelConstants';
|
||||
import {GuildNSFWLevel} from '@fluxer/constants/src/GuildConstants';
|
||||
import type {GuildResponse} from '@fluxer/schema/src/domains/guild/GuildResponseSchemas';
|
||||
import {beforeAll, beforeEach, describe, expect, it} from 'vitest';
|
||||
|
||||
describe('Guild Operation Permissions', () => {
|
||||
let harness: ApiTestHarness;
|
||||
|
||||
beforeAll(async () => {
|
||||
harness = await createApiTestHarness();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await harness.reset();
|
||||
});
|
||||
|
||||
it('should reject member from updating guild without MANAGE_GUILD', async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
const member = await createTestAccount(harness);
|
||||
|
||||
const guild = await createGuild(harness, owner.token, 'Perms Test Guild');
|
||||
const systemChannel = await getChannel(harness, owner.token, guild.system_channel_id!);
|
||||
|
||||
const invite = await createChannelInvite(harness, owner.token, systemChannel.id);
|
||||
await acceptInvite(harness, member.token, invite.code);
|
||||
|
||||
await createBuilder(harness, member.token)
|
||||
.patch(`/guilds/${guild.id}`)
|
||||
.body({name: 'Hacked Guild'})
|
||||
.expect(HTTP_STATUS.FORBIDDEN)
|
||||
.execute();
|
||||
});
|
||||
|
||||
it('should reject nonmember from getting guild', async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
const member = await createTestAccount(harness);
|
||||
const nonmember = await createTestAccount(harness);
|
||||
|
||||
const guild = await createGuild(harness, owner.token, 'Perms Test Guild');
|
||||
const systemChannel = await getChannel(harness, owner.token, guild.system_channel_id!);
|
||||
|
||||
const invite = await createChannelInvite(harness, owner.token, systemChannel.id);
|
||||
await acceptInvite(harness, member.token, invite.code);
|
||||
|
||||
await createBuilder(harness, nonmember.token).get(`/guilds/${guild.id}`).expect(HTTP_STATUS.FORBIDDEN).execute();
|
||||
});
|
||||
|
||||
it('should allow member to leave guild', async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
const member = await createTestAccount(harness);
|
||||
|
||||
const guild = await createGuild(harness, owner.token, 'Perms Test Guild');
|
||||
const systemChannel = await getChannel(harness, owner.token, guild.system_channel_id!);
|
||||
|
||||
const invite = await createChannelInvite(harness, owner.token, systemChannel.id);
|
||||
await acceptInvite(harness, member.token, invite.code);
|
||||
|
||||
await leaveGuild(harness, member.token, guild.id);
|
||||
|
||||
await createBuilder(harness, member.token).get(`/guilds/${guild.id}`).expect(HTTP_STATUS.FORBIDDEN).execute();
|
||||
});
|
||||
|
||||
it('should reject owner from leaving guild without deleting', async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
const member = await createTestAccount(harness);
|
||||
|
||||
const guild = await createGuild(harness, owner.token, 'Perms Test Guild');
|
||||
const systemChannel = await getChannel(harness, owner.token, guild.system_channel_id!);
|
||||
|
||||
const invite = await createChannelInvite(harness, owner.token, systemChannel.id);
|
||||
await acceptInvite(harness, member.token, invite.code);
|
||||
|
||||
await createBuilder(harness, owner.token)
|
||||
.delete(`/users/@me/guilds/${guild.id}`)
|
||||
.expect(HTTP_STATUS.BAD_REQUEST)
|
||||
.execute();
|
||||
});
|
||||
|
||||
it('should allow member with MANAGE_GUILD to update guild nsfw_level', async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
const member = await createTestAccount(harness);
|
||||
|
||||
const guild = await createGuild(harness, owner.token, 'NSFW Perms Test Guild');
|
||||
const systemChannel = await getChannel(harness, owner.token, guild.system_channel_id!);
|
||||
|
||||
const invite = await createChannelInvite(harness, owner.token, systemChannel.id);
|
||||
await acceptInvite(harness, member.token, invite.code);
|
||||
|
||||
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);
|
||||
|
||||
const updated = await createBuilder<GuildResponse>(harness, member.token)
|
||||
.patch(`/guilds/${guild.id}`)
|
||||
.body({nsfw_level: GuildNSFWLevel.AGE_RESTRICTED})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
|
||||
expect(updated.nsfw_level).toBe(GuildNSFWLevel.AGE_RESTRICTED);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {createTestAccount} from '@fluxer/api/src/auth/tests/AuthTestUtils';
|
||||
import {type ApiTestHarness, createApiTestHarness} from '@fluxer/api/src/test/ApiTestHarness';
|
||||
import {HTTP_STATUS, TEST_IDS} from '@fluxer/api/src/test/TestConstants';
|
||||
import {createBuilder} from '@fluxer/api/src/test/TestRequestBuilder';
|
||||
import {afterAll, beforeAll, beforeEach, describe, it} from 'vitest';
|
||||
|
||||
describe('Guild Operation Validation', () => {
|
||||
let harness: ApiTestHarness;
|
||||
|
||||
beforeAll(async () => {
|
||||
harness = await createApiTestHarness();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await harness?.shutdown();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await harness.reset();
|
||||
});
|
||||
|
||||
it('should reject getting nonexistent guild', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
|
||||
await createBuilder(harness, account.token)
|
||||
.get(`/guilds/${TEST_IDS.NONEXISTENT_GUILD}`)
|
||||
.expect(HTTP_STATUS.NOT_FOUND)
|
||||
.execute();
|
||||
});
|
||||
|
||||
it('should reject updating nonexistent guild', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
|
||||
await createBuilder(harness, account.token)
|
||||
.patch(`/guilds/${TEST_IDS.NONEXISTENT_GUILD}`)
|
||||
.body({name: 'New Name'})
|
||||
.expect(HTTP_STATUS.NOT_FOUND)
|
||||
.execute();
|
||||
});
|
||||
|
||||
it('should reject leaving nonexistent guild', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
|
||||
await createBuilder(harness, account.token)
|
||||
.delete(`/users/@me/guilds/${TEST_IDS.NONEXISTENT_GUILD}`)
|
||||
.expect(HTTP_STATUS.NOT_FOUND)
|
||||
.execute();
|
||||
});
|
||||
});
|
||||
77
packages/api/src/guild/tests/GuildOwnershipTransfer.test.tsx
Normal file
77
packages/api/src/guild/tests/GuildOwnershipTransfer.test.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {createTestAccount} from '@fluxer/api/src/auth/tests/AuthTestUtils';
|
||||
import {acceptInvite, createChannelInvite, createGuild, getChannel} from '@fluxer/api/src/guild/tests/GuildTestUtils';
|
||||
import {type ApiTestHarness, createApiTestHarness} from '@fluxer/api/src/test/ApiTestHarness';
|
||||
import {HTTP_STATUS, TEST_CREDENTIALS} from '@fluxer/api/src/test/TestConstants';
|
||||
import {createBuilder, createBuilderWithoutAuth} from '@fluxer/api/src/test/TestRequestBuilder';
|
||||
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
|
||||
import type {GuildResponse} from '@fluxer/schema/src/domains/guild/GuildResponseSchemas';
|
||||
import {beforeAll, beforeEach, describe, expect, it} from 'vitest';
|
||||
|
||||
describe('Guild Ownership Transfer', () => {
|
||||
let harness: ApiTestHarness;
|
||||
|
||||
beforeAll(async () => {
|
||||
harness = await createApiTestHarness();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await harness.reset();
|
||||
});
|
||||
|
||||
it('rejects transfer to a bot user', async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
const botAccount = await createTestAccount(harness);
|
||||
|
||||
const guild = await createGuild(harness, owner.token, 'Transfer Test Guild');
|
||||
const systemChannel = await getChannel(harness, owner.token, guild.system_channel_id!);
|
||||
const invite = await createChannelInvite(harness, owner.token, systemChannel.id);
|
||||
await acceptInvite(harness, botAccount.token, invite.code);
|
||||
|
||||
await createBuilderWithoutAuth(harness)
|
||||
.post(`/test/users/${botAccount.userId}/set-bot-flag`)
|
||||
.body({is_bot: true})
|
||||
.execute();
|
||||
|
||||
await createBuilder(harness, owner.token)
|
||||
.post(`/guilds/${guild.id}/transfer-ownership`)
|
||||
.body({new_owner_id: botAccount.userId, password: TEST_CREDENTIALS.STRONG_PASSWORD})
|
||||
.expect(HTTP_STATUS.BAD_REQUEST, APIErrorCodes.CANNOT_TRANSFER_OWNERSHIP_TO_BOT)
|
||||
.execute();
|
||||
});
|
||||
|
||||
it('allows transfer to a non-bot user', async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
const member = await createTestAccount(harness);
|
||||
|
||||
const guild = await createGuild(harness, owner.token, 'Transfer Test Guild');
|
||||
const systemChannel = await getChannel(harness, owner.token, guild.system_channel_id!);
|
||||
const invite = await createChannelInvite(harness, owner.token, systemChannel.id);
|
||||
await acceptInvite(harness, member.token, invite.code);
|
||||
|
||||
const updatedGuild = await createBuilder<GuildResponse>(harness, owner.token)
|
||||
.post(`/guilds/${guild.id}/transfer-ownership`)
|
||||
.body({new_owner_id: member.userId, password: TEST_CREDENTIALS.STRONG_PASSWORD})
|
||||
.execute();
|
||||
|
||||
expect(updatedGuild.owner_id).toBe(member.userId);
|
||||
});
|
||||
});
|
||||
468
packages/api/src/guild/tests/GuildRoleManagement.test.tsx
Normal file
468
packages/api/src/guild/tests/GuildRoleManagement.test.tsx
Normal file
@@ -0,0 +1,468 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {createTestAccount} from '@fluxer/api/src/auth/tests/AuthTestUtils';
|
||||
import {
|
||||
acceptInvite,
|
||||
addMemberRole,
|
||||
createChannelInvite,
|
||||
createGuild,
|
||||
createRole,
|
||||
deleteRole,
|
||||
getChannel,
|
||||
getRoles,
|
||||
updateRolePositions,
|
||||
} from '@fluxer/api/src/guild/tests/GuildTestUtils';
|
||||
import {type ApiTestHarness, createApiTestHarness} from '@fluxer/api/src/test/ApiTestHarness';
|
||||
import {HTTP_STATUS} from '@fluxer/api/src/test/TestConstants';
|
||||
import {createBuilder} from '@fluxer/api/src/test/TestRequestBuilder';
|
||||
import {Permissions} from '@fluxer/constants/src/ChannelConstants';
|
||||
import {afterEach, beforeEach, describe, expect, test} from 'vitest';
|
||||
|
||||
describe('Guild Role Management', () => {
|
||||
let harness: ApiTestHarness;
|
||||
|
||||
beforeEach(async () => {
|
||||
harness = await createApiTestHarness();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await harness?.shutdown();
|
||||
});
|
||||
|
||||
describe('New Role Position', () => {
|
||||
test('should have @everyone role at position 0', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, account.token, 'Test Guild');
|
||||
|
||||
const roles = await getRoles(harness, account.token, guild.id);
|
||||
const everyoneRole = roles.find((r) => r.id === guild.id);
|
||||
|
||||
expect(everyoneRole).toBeDefined();
|
||||
expect(everyoneRole!.position).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Role Hierarchy Delete Restrictions', () => {
|
||||
test('should prevent deleting role higher in hierarchy than your highest role', async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
const moderator = await createTestAccount(harness);
|
||||
|
||||
const guild = await createGuild(harness, owner.token, 'Test Guild');
|
||||
|
||||
const highRole = await createRole(harness, owner.token, guild.id, {
|
||||
name: 'High Role',
|
||||
permissions: Permissions.MANAGE_ROLES.toString(),
|
||||
});
|
||||
|
||||
const lowRole = await createRole(harness, owner.token, guild.id, {
|
||||
name: 'Low Role',
|
||||
permissions: Permissions.MANAGE_ROLES.toString(),
|
||||
});
|
||||
|
||||
await updateRolePositions(harness, owner.token, guild.id, [
|
||||
{id: highRole.id, position: 3},
|
||||
{id: lowRole.id, position: 2},
|
||||
]);
|
||||
|
||||
const systemChannel = await getChannel(harness, owner.token, guild.system_channel_id!);
|
||||
const invite = await createChannelInvite(harness, owner.token, systemChannel.id);
|
||||
await acceptInvite(harness, moderator.token, invite.code);
|
||||
|
||||
await addMemberRole(harness, owner.token, guild.id, moderator.userId, lowRole.id);
|
||||
|
||||
await createBuilder(harness, moderator.token)
|
||||
.delete(`/guilds/${guild.id}/roles/${highRole.id}`)
|
||||
.expect(HTTP_STATUS.FORBIDDEN)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('should allow deleting role lower in hierarchy than your highest role', async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
const moderator = await createTestAccount(harness);
|
||||
|
||||
const guild = await createGuild(harness, owner.token, 'Test Guild');
|
||||
|
||||
const highRole = await createRole(harness, owner.token, guild.id, {
|
||||
name: 'High Role',
|
||||
permissions: Permissions.MANAGE_ROLES.toString(),
|
||||
});
|
||||
|
||||
const lowRole = await createRole(harness, owner.token, guild.id, {
|
||||
name: 'Low Role',
|
||||
});
|
||||
|
||||
await updateRolePositions(harness, owner.token, guild.id, [
|
||||
{id: highRole.id, position: 3},
|
||||
{id: lowRole.id, position: 2},
|
||||
]);
|
||||
|
||||
const systemChannel = await getChannel(harness, owner.token, guild.system_channel_id!);
|
||||
const invite = await createChannelInvite(harness, owner.token, systemChannel.id);
|
||||
await acceptInvite(harness, moderator.token, invite.code);
|
||||
|
||||
await addMemberRole(harness, owner.token, guild.id, moderator.userId, highRole.id);
|
||||
|
||||
await deleteRole(harness, moderator.token, guild.id, lowRole.id);
|
||||
|
||||
const roles = await getRoles(harness, owner.token, guild.id);
|
||||
expect(roles.find((r) => r.id === lowRole.id)).toBeUndefined();
|
||||
});
|
||||
|
||||
test('should use ID comparison as tiebreaker when roles have same position', async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
const moderator = await createTestAccount(harness);
|
||||
|
||||
const guild = await createGuild(harness, owner.token, 'Test Guild');
|
||||
|
||||
const roleA = await createRole(harness, owner.token, guild.id, {
|
||||
name: 'Role A',
|
||||
permissions: Permissions.MANAGE_ROLES.toString(),
|
||||
});
|
||||
|
||||
const roleB = await createRole(harness, owner.token, guild.id, {
|
||||
name: 'Role B',
|
||||
});
|
||||
|
||||
await updateRolePositions(harness, owner.token, guild.id, [
|
||||
{id: roleA.id, position: 2},
|
||||
{id: roleB.id, position: 2},
|
||||
]);
|
||||
|
||||
const systemChannel = await getChannel(harness, owner.token, guild.system_channel_id!);
|
||||
const invite = await createChannelInvite(harness, owner.token, systemChannel.id);
|
||||
await acceptInvite(harness, moderator.token, invite.code);
|
||||
|
||||
await addMemberRole(harness, owner.token, guild.id, moderator.userId, roleA.id);
|
||||
|
||||
const roleAIdLower = String(roleA.id) < String(roleB.id);
|
||||
if (roleAIdLower) {
|
||||
await createBuilder(harness, moderator.token)
|
||||
.delete(`/guilds/${guild.id}/roles/${roleB.id}`)
|
||||
.expect(HTTP_STATUS.NO_CONTENT)
|
||||
.execute();
|
||||
} else {
|
||||
await createBuilder(harness, moderator.token)
|
||||
.delete(`/guilds/${guild.id}/roles/${roleB.id}`)
|
||||
.expect(HTTP_STATUS.FORBIDDEN)
|
||||
.execute();
|
||||
}
|
||||
});
|
||||
|
||||
test('should allow owner to delete any role regardless of position', async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
|
||||
const guild = await createGuild(harness, owner.token, 'Test Guild');
|
||||
|
||||
const highRole = await createRole(harness, owner.token, guild.id, {
|
||||
name: 'High Role',
|
||||
});
|
||||
|
||||
await updateRolePositions(harness, owner.token, guild.id, [{id: highRole.id, position: 10}]);
|
||||
|
||||
await deleteRole(harness, owner.token, guild.id, highRole.id);
|
||||
|
||||
const roles = await getRoles(harness, owner.token, guild.id);
|
||||
expect(roles.find((r) => r.id === highRole.id)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Role Permissions Validation', () => {
|
||||
test('should prevent user from granting permissions they do not have', async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
const manager = await createTestAccount(harness);
|
||||
|
||||
const guild = await createGuild(harness, owner.token, 'Test Guild');
|
||||
|
||||
const managerRole = await createRole(harness, owner.token, guild.id, {
|
||||
name: 'Manager',
|
||||
permissions: Permissions.MANAGE_ROLES.toString(),
|
||||
});
|
||||
|
||||
const systemChannel = await getChannel(harness, owner.token, guild.system_channel_id!);
|
||||
const invite = await createChannelInvite(harness, owner.token, systemChannel.id);
|
||||
await acceptInvite(harness, manager.token, invite.code);
|
||||
|
||||
await addMemberRole(harness, owner.token, guild.id, manager.userId, managerRole.id);
|
||||
|
||||
await createBuilder(harness, manager.token)
|
||||
.post(`/guilds/${guild.id}/roles`)
|
||||
.body({name: 'New Role', permissions: Permissions.ADMINISTRATOR.toString()})
|
||||
.expect(HTTP_STATUS.FORBIDDEN)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('should allow user to create role with permissions they have', async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
const manager = await createTestAccount(harness);
|
||||
|
||||
const guild = await createGuild(harness, owner.token, 'Test Guild');
|
||||
|
||||
const permissions = Permissions.MANAGE_ROLES | Permissions.SEND_MESSAGES;
|
||||
const managerRole = await createRole(harness, owner.token, guild.id, {
|
||||
name: 'Manager',
|
||||
permissions: permissions.toString(),
|
||||
});
|
||||
|
||||
const systemChannel = await getChannel(harness, owner.token, guild.system_channel_id!);
|
||||
const invite = await createChannelInvite(harness, owner.token, systemChannel.id);
|
||||
await acceptInvite(harness, manager.token, invite.code);
|
||||
|
||||
await addMemberRole(harness, owner.token, guild.id, manager.userId, managerRole.id);
|
||||
|
||||
const newRole = await createRole(harness, manager.token, guild.id, {
|
||||
name: 'New Role',
|
||||
permissions: Permissions.SEND_MESSAGES.toString(),
|
||||
});
|
||||
|
||||
expect(newRole.name).toBe('New Role');
|
||||
expect(BigInt(newRole.permissions)).toBe(Permissions.SEND_MESSAGES);
|
||||
});
|
||||
|
||||
test('should prevent updating role with permissions user does not have', async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
const manager = await createTestAccount(harness);
|
||||
|
||||
const guild = await createGuild(harness, owner.token, 'Test Guild');
|
||||
|
||||
const managerRole = await createRole(harness, owner.token, guild.id, {
|
||||
name: 'Manager',
|
||||
permissions: Permissions.MANAGE_ROLES.toString(),
|
||||
});
|
||||
|
||||
const targetRole = await createRole(harness, owner.token, guild.id, {
|
||||
name: 'Target Role',
|
||||
permissions: '0',
|
||||
});
|
||||
|
||||
await updateRolePositions(harness, owner.token, guild.id, [
|
||||
{id: managerRole.id, position: 3},
|
||||
{id: targetRole.id, position: 2},
|
||||
]);
|
||||
|
||||
const systemChannel = await getChannel(harness, owner.token, guild.system_channel_id!);
|
||||
const invite = await createChannelInvite(harness, owner.token, systemChannel.id);
|
||||
await acceptInvite(harness, manager.token, invite.code);
|
||||
|
||||
await addMemberRole(harness, owner.token, guild.id, manager.userId, managerRole.id);
|
||||
|
||||
await createBuilder(harness, manager.token)
|
||||
.patch(`/guilds/${guild.id}/roles/${targetRole.id}`)
|
||||
.body({permissions: Permissions.BAN_MEMBERS.toString()})
|
||||
.expect(HTTP_STATUS.FORBIDDEN)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('should allow owner to grant any permissions', async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
|
||||
const guild = await createGuild(harness, owner.token, 'Test Guild');
|
||||
|
||||
const role = await createRole(harness, owner.token, guild.id, {
|
||||
name: 'Admin Role',
|
||||
permissions: Permissions.ADMINISTRATOR.toString(),
|
||||
});
|
||||
|
||||
expect(BigInt(role.permissions)).toBe(Permissions.ADMINISTRATOR);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Bulk Update Role Positions', () => {
|
||||
test('should update multiple role positions at once', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, account.token, 'Test Guild');
|
||||
|
||||
const role1 = await createRole(harness, account.token, guild.id, {name: 'Role 1'});
|
||||
const role2 = await createRole(harness, account.token, guild.id, {name: 'Role 2'});
|
||||
const role3 = await createRole(harness, account.token, guild.id, {name: 'Role 3'});
|
||||
|
||||
await updateRolePositions(harness, account.token, guild.id, [
|
||||
{id: role1.id, position: 3},
|
||||
{id: role2.id, position: 2},
|
||||
{id: role3.id, position: 1},
|
||||
]);
|
||||
|
||||
const roles = await getRoles(harness, account.token, guild.id);
|
||||
const updatedRole1 = roles.find((r) => r.id === role1.id);
|
||||
const updatedRole2 = roles.find((r) => r.id === role2.id);
|
||||
const updatedRole3 = roles.find((r) => r.id === role3.id);
|
||||
|
||||
expect(updatedRole1!.position).toBeGreaterThan(updatedRole2!.position);
|
||||
expect(updatedRole2!.position).toBeGreaterThan(updatedRole3!.position);
|
||||
});
|
||||
|
||||
test('should not allow reordering @everyone role', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, account.token, 'Test Guild');
|
||||
|
||||
await createBuilder(harness, account.token)
|
||||
.patch(`/guilds/${guild.id}/roles`)
|
||||
.body([{id: guild.id, position: 5}])
|
||||
.expect(HTTP_STATUS.BAD_REQUEST)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('should require MANAGE_ROLES permission for bulk position update', async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
const member = await createTestAccount(harness);
|
||||
|
||||
const guild = await createGuild(harness, owner.token, 'Test Guild');
|
||||
|
||||
const role = await createRole(harness, owner.token, guild.id, {name: 'Test Role'});
|
||||
|
||||
const systemChannel = await getChannel(harness, owner.token, guild.system_channel_id!);
|
||||
const invite = await createChannelInvite(harness, owner.token, systemChannel.id);
|
||||
await acceptInvite(harness, member.token, invite.code);
|
||||
|
||||
await createBuilder(harness, member.token)
|
||||
.patch(`/guilds/${guild.id}/roles`)
|
||||
.body([{id: role.id, position: 5}])
|
||||
.expect(HTTP_STATUS.FORBIDDEN)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('should reject invalid role ID in bulk update', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, account.token, 'Test Guild');
|
||||
|
||||
await createBuilder(harness, account.token)
|
||||
.patch(`/guilds/${guild.id}/roles`)
|
||||
.body([{id: '999999999999999999', position: 5}])
|
||||
.expect(HTTP_STATUS.BAD_REQUEST)
|
||||
.execute();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Role Name Validation', () => {
|
||||
test('should reject role name exceeding 100 characters', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, account.token, 'Test Guild');
|
||||
|
||||
await createBuilder(harness, account.token)
|
||||
.post(`/guilds/${guild.id}/roles`)
|
||||
.body({name: 'a'.repeat(101)})
|
||||
.expect(HTTP_STATUS.BAD_REQUEST)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('should accept role name at exactly 100 characters', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, account.token, 'Test Guild');
|
||||
|
||||
const role = await createRole(harness, account.token, guild.id, {
|
||||
name: 'a'.repeat(100),
|
||||
});
|
||||
|
||||
expect(role.name).toBe('a'.repeat(100));
|
||||
});
|
||||
|
||||
test('should require name when creating role', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, account.token, 'Test Guild');
|
||||
|
||||
await createBuilder(harness, account.token)
|
||||
.post(`/guilds/${guild.id}/roles`)
|
||||
.body({})
|
||||
.expect(HTTP_STATUS.BAD_REQUEST)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('should accept role name with unicode characters', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, account.token, 'Test Guild');
|
||||
|
||||
const role = await createRole(harness, account.token, guild.id, {
|
||||
name: 'Moderator',
|
||||
});
|
||||
|
||||
expect(role.name).toBe('Moderator');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Role Color Validation', () => {
|
||||
test('should accept valid color value', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, account.token, 'Test Guild');
|
||||
|
||||
const role = await createRole(harness, account.token, guild.id, {
|
||||
name: 'Colored Role',
|
||||
color: 0xff0000,
|
||||
});
|
||||
|
||||
expect(role.color).toBe(0xff0000);
|
||||
});
|
||||
|
||||
test('should accept color value of 0 (no color)', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, account.token, 'Test Guild');
|
||||
|
||||
const role = await createRole(harness, account.token, guild.id, {
|
||||
name: 'No Color Role',
|
||||
color: 0,
|
||||
});
|
||||
|
||||
expect(role.color).toBe(0);
|
||||
});
|
||||
|
||||
test('should accept maximum valid color value (0xFFFFFF)', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, account.token, 'Test Guild');
|
||||
|
||||
const role = await createRole(harness, account.token, guild.id, {
|
||||
name: 'Max Color Role',
|
||||
color: 0xffffff,
|
||||
});
|
||||
|
||||
expect(role.color).toBe(0xffffff);
|
||||
});
|
||||
|
||||
test('should reject color value exceeding maximum (> 0xFFFFFF)', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, account.token, 'Test Guild');
|
||||
|
||||
await createBuilder(harness, account.token)
|
||||
.post(`/guilds/${guild.id}/roles`)
|
||||
.body({name: 'Invalid Color', color: 0x1000000})
|
||||
.expect(HTTP_STATUS.BAD_REQUEST)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('should reject negative color value', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, account.token, 'Test Guild');
|
||||
|
||||
await createBuilder(harness, account.token)
|
||||
.post(`/guilds/${guild.id}/roles`)
|
||||
.body({name: 'Negative Color', color: -1})
|
||||
.expect(HTTP_STATUS.BAD_REQUEST)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('should default to color 0 when not specified', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, account.token, 'Test Guild');
|
||||
|
||||
const role = await createRole(harness, account.token, guild.id, {
|
||||
name: 'Default Color Role',
|
||||
});
|
||||
|
||||
expect(role.color).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
178
packages/api/src/guild/tests/GuildRoleOperations.test.tsx
Normal file
178
packages/api/src/guild/tests/GuildRoleOperations.test.tsx
Normal file
@@ -0,0 +1,178 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {createTestAccount} from '@fluxer/api/src/auth/tests/AuthTestUtils';
|
||||
import {
|
||||
acceptInvite,
|
||||
addMemberRole,
|
||||
createChannelInvite,
|
||||
createGuild,
|
||||
createRole,
|
||||
deleteRole,
|
||||
getChannel,
|
||||
getRoles,
|
||||
updateRolePositions,
|
||||
} from '@fluxer/api/src/guild/tests/GuildTestUtils';
|
||||
import {type ApiTestHarness, createApiTestHarness} from '@fluxer/api/src/test/ApiTestHarness';
|
||||
import {HTTP_STATUS} from '@fluxer/api/src/test/TestConstants';
|
||||
import {createBuilder} from '@fluxer/api/src/test/TestRequestBuilder';
|
||||
import {Permissions} from '@fluxer/constants/src/ChannelConstants';
|
||||
import {afterEach, beforeEach, describe, expect, test} from 'vitest';
|
||||
|
||||
describe('Guild Role Operations', () => {
|
||||
let harness: ApiTestHarness;
|
||||
|
||||
beforeEach(async () => {
|
||||
harness = await createApiTestHarness();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await harness?.shutdown();
|
||||
});
|
||||
|
||||
test('should delete a role', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, account.token, 'Test Guild');
|
||||
|
||||
const role = await createRole(harness, account.token, guild.id, {
|
||||
name: 'Delete Me',
|
||||
});
|
||||
|
||||
await deleteRole(harness, account.token, guild.id, role.id);
|
||||
|
||||
const roles = await getRoles(harness, account.token, guild.id);
|
||||
expect(roles.find((r) => r.id === role.id)).toBeUndefined();
|
||||
});
|
||||
|
||||
test('should not delete @everyone role', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, account.token, 'Test Guild');
|
||||
|
||||
await createBuilder(harness, account.token)
|
||||
.delete(`/guilds/${guild.id}/roles/${guild.id}`)
|
||||
.expect(HTTP_STATUS.NOT_FOUND)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('should update role positions', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, account.token, 'Test Guild');
|
||||
|
||||
const role1 = await createRole(harness, account.token, guild.id, {name: 'Role 1'});
|
||||
const role2 = await createRole(harness, account.token, guild.id, {name: 'Role 2'});
|
||||
|
||||
await updateRolePositions(harness, account.token, guild.id, [
|
||||
{id: role1.id, position: 2},
|
||||
{id: role2.id, position: 1},
|
||||
]);
|
||||
});
|
||||
|
||||
test('should require MANAGE_ROLES permission to create role', async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
const member = await createTestAccount(harness);
|
||||
|
||||
const guild = await createGuild(harness, owner.token, 'Test Guild');
|
||||
const systemChannel = await getChannel(harness, owner.token, guild.system_channel_id!);
|
||||
|
||||
const invite = await createChannelInvite(harness, owner.token, systemChannel.id);
|
||||
await acceptInvite(harness, member.token, invite.code);
|
||||
|
||||
await createBuilder(harness, member.token)
|
||||
.post(`/guilds/${guild.id}/roles`)
|
||||
.body({name: 'Unauthorized Role'})
|
||||
.expect(HTTP_STATUS.FORBIDDEN)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('should require MANAGE_ROLES permission to delete role', async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
const member = await createTestAccount(harness);
|
||||
|
||||
const guild = await createGuild(harness, owner.token, 'Test Guild');
|
||||
const systemChannel = await getChannel(harness, owner.token, guild.system_channel_id!);
|
||||
|
||||
const role = await createRole(harness, owner.token, guild.id, {name: 'Protected Role'});
|
||||
|
||||
const invite = await createChannelInvite(harness, owner.token, systemChannel.id);
|
||||
await acceptInvite(harness, member.token, invite.code);
|
||||
|
||||
await createBuilder(harness, member.token)
|
||||
.delete(`/guilds/${guild.id}/roles/${role.id}`)
|
||||
.expect(HTTP_STATUS.FORBIDDEN)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('should create role with unicode_emoji', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, account.token, 'Test Guild');
|
||||
|
||||
const role = await createRole(harness, account.token, guild.id, {
|
||||
name: 'Emoji Role',
|
||||
unicode_emoji: '',
|
||||
});
|
||||
|
||||
expect(role.name).toBe('Emoji Role');
|
||||
});
|
||||
|
||||
test('should validate role name length', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, account.token, 'Test Guild');
|
||||
|
||||
await createBuilder(harness, account.token)
|
||||
.post(`/guilds/${guild.id}/roles`)
|
||||
.body({name: 'a'.repeat(101)})
|
||||
.expect(HTTP_STATUS.BAD_REQUEST)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('should require a name when creating role', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, account.token, 'Test Guild');
|
||||
|
||||
await createBuilder(harness, account.token)
|
||||
.post(`/guilds/${guild.id}/roles`)
|
||||
.body({})
|
||||
.expect(HTTP_STATUS.BAD_REQUEST)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('should allow MANAGE_ROLES role to create roles', async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
const member = await createTestAccount(harness);
|
||||
|
||||
const guild = await createGuild(harness, owner.token, 'Test Guild');
|
||||
const systemChannel = await getChannel(harness, owner.token, guild.system_channel_id!);
|
||||
|
||||
const managerRole = await createRole(harness, owner.token, guild.id, {
|
||||
name: 'Role Manager',
|
||||
permissions: Permissions.MANAGE_ROLES.toString(),
|
||||
});
|
||||
|
||||
const invite = await createChannelInvite(harness, owner.token, systemChannel.id);
|
||||
await acceptInvite(harness, member.token, invite.code);
|
||||
|
||||
await addMemberRole(harness, owner.token, guild.id, member.userId, managerRole.id);
|
||||
|
||||
const newRole = await createRole(harness, member.token, guild.id, {
|
||||
name: 'Member Created Role',
|
||||
});
|
||||
|
||||
expect(newRole.name).toBe('Member Created Role');
|
||||
});
|
||||
});
|
||||
287
packages/api/src/guild/tests/GuildTestUtils.tsx
Normal file
287
packages/api/src/guild/tests/GuildTestUtils.tsx
Normal file
@@ -0,0 +1,287 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {createTestAccount, type TestAccount} from '@fluxer/api/src/auth/tests/AuthTestUtils';
|
||||
import type {ApiTestHarness} from '@fluxer/api/src/test/ApiTestHarness';
|
||||
import {createBuilder} from '@fluxer/api/src/test/TestRequestBuilder';
|
||||
import type {ChannelResponse} from '@fluxer/schema/src/domains/channel/ChannelSchemas';
|
||||
import type {GuildMemberResponse} from '@fluxer/schema/src/domains/guild/GuildMemberSchemas';
|
||||
import type {GuildResponse} from '@fluxer/schema/src/domains/guild/GuildResponseSchemas';
|
||||
import type {GuildRoleResponse} from '@fluxer/schema/src/domains/guild/GuildRoleSchemas';
|
||||
import type {GuildInviteMetadataResponse} from '@fluxer/schema/src/domains/invite/InviteSchemas';
|
||||
|
||||
export async function createGuild(harness: ApiTestHarness, token: string, name: string): Promise<GuildResponse> {
|
||||
return createBuilder<GuildResponse>(harness, token).post('/guilds').body({name}).execute();
|
||||
}
|
||||
|
||||
export async function getGuild(harness: ApiTestHarness, token: string, guildId: string): Promise<GuildResponse> {
|
||||
return createBuilder<GuildResponse>(harness, token).get(`/guilds/${guildId}`).execute();
|
||||
}
|
||||
|
||||
export async function updateGuild(
|
||||
harness: ApiTestHarness,
|
||||
token: string,
|
||||
guildId: string,
|
||||
updates: Partial<GuildResponse>,
|
||||
): Promise<GuildResponse> {
|
||||
return createBuilder<GuildResponse>(harness, token).patch(`/guilds/${guildId}`).body(updates).execute();
|
||||
}
|
||||
|
||||
export async function leaveGuild(harness: ApiTestHarness, token: string, guildId: string): Promise<void> {
|
||||
await createBuilder(harness, token).delete(`/users/@me/guilds/${guildId}`).expect(204).execute();
|
||||
}
|
||||
|
||||
export async function deleteGuild(harness: ApiTestHarness, token: string, guildId: string): Promise<void> {
|
||||
await createBuilder(harness, token).delete(`/guilds/${guildId}`).expect(204).execute();
|
||||
}
|
||||
|
||||
export async function getUserGuilds(harness: ApiTestHarness, token: string): Promise<Array<GuildResponse>> {
|
||||
return createBuilder<Array<GuildResponse>>(harness, token).get('/users/@me/guilds').execute();
|
||||
}
|
||||
|
||||
export async function createChannel(
|
||||
harness: ApiTestHarness,
|
||||
token: string,
|
||||
guildId: string,
|
||||
name: string,
|
||||
type = 0,
|
||||
): Promise<ChannelResponse> {
|
||||
return createBuilder<ChannelResponse>(harness, token)
|
||||
.post(`/guilds/${guildId}/channels`)
|
||||
.body({name, type})
|
||||
.execute();
|
||||
}
|
||||
|
||||
export async function getChannel(harness: ApiTestHarness, token: string, channelId: string): Promise<ChannelResponse> {
|
||||
return createBuilder<ChannelResponse>(harness, token).get(`/channels/${channelId}`).execute();
|
||||
}
|
||||
|
||||
export async function updateChannel(
|
||||
harness: ApiTestHarness,
|
||||
token: string,
|
||||
channelId: string,
|
||||
updates: Partial<ChannelResponse>,
|
||||
): Promise<ChannelResponse> {
|
||||
return createBuilder<ChannelResponse>(harness, token).patch(`/channels/${channelId}`).body(updates).execute();
|
||||
}
|
||||
|
||||
export async function deleteChannel(harness: ApiTestHarness, token: string, channelId: string): Promise<void> {
|
||||
await createBuilder(harness, token).delete(`/channels/${channelId}`).expect(204).execute();
|
||||
}
|
||||
|
||||
export async function getGuildChannels(
|
||||
harness: ApiTestHarness,
|
||||
token: string,
|
||||
guildId: string,
|
||||
): Promise<Array<ChannelResponse>> {
|
||||
return createBuilder<Array<ChannelResponse>>(harness, token).get(`/guilds/${guildId}/channels`).execute();
|
||||
}
|
||||
|
||||
export async function updateChannelPositions(
|
||||
harness: ApiTestHarness,
|
||||
token: string,
|
||||
guildId: string,
|
||||
positions: Array<{id: string; position?: number; lock_permissions?: boolean | null; parent_id?: string | null}>,
|
||||
): Promise<void> {
|
||||
await createBuilder(harness, token).patch(`/guilds/${guildId}/channels`).body(positions).expect(204).execute();
|
||||
}
|
||||
|
||||
export async function createRole(
|
||||
harness: ApiTestHarness,
|
||||
token: string,
|
||||
guildId: string,
|
||||
role: Partial<Omit<GuildRoleResponse, 'id' | 'position'>>,
|
||||
): Promise<GuildRoleResponse> {
|
||||
return createBuilder<GuildRoleResponse>(harness, token).post(`/guilds/${guildId}/roles`).body(role).execute();
|
||||
}
|
||||
|
||||
export async function getRoles(
|
||||
harness: ApiTestHarness,
|
||||
token: string,
|
||||
guildId: string,
|
||||
): Promise<Array<GuildRoleResponse>> {
|
||||
return createBuilder<Array<GuildRoleResponse>>(harness, token).get(`/guilds/${guildId}/roles`).execute();
|
||||
}
|
||||
|
||||
export async function updateRole(
|
||||
harness: ApiTestHarness,
|
||||
token: string,
|
||||
guildId: string,
|
||||
roleId: string,
|
||||
updates: Partial<GuildRoleResponse>,
|
||||
): Promise<GuildRoleResponse> {
|
||||
return createBuilder<GuildRoleResponse>(harness, token)
|
||||
.patch(`/guilds/${guildId}/roles/${roleId}`)
|
||||
.body(updates)
|
||||
.execute();
|
||||
}
|
||||
|
||||
export async function deleteRole(
|
||||
harness: ApiTestHarness,
|
||||
token: string,
|
||||
guildId: string,
|
||||
roleId: string,
|
||||
): Promise<void> {
|
||||
await createBuilder(harness, token).delete(`/guilds/${guildId}/roles/${roleId}`).expect(204).execute();
|
||||
}
|
||||
|
||||
export async function updateRolePositions(
|
||||
harness: ApiTestHarness,
|
||||
token: string,
|
||||
guildId: string,
|
||||
positions: Array<{id: string; position: number}>,
|
||||
): Promise<void> {
|
||||
await createBuilder(harness, token).patch(`/guilds/${guildId}/roles`).body(positions).expect(204).execute();
|
||||
}
|
||||
|
||||
export async function addMemberRole(
|
||||
harness: ApiTestHarness,
|
||||
token: string,
|
||||
guildId: string,
|
||||
userId: string,
|
||||
roleId: string,
|
||||
): Promise<void> {
|
||||
await createBuilder(harness, token).put(`/guilds/${guildId}/members/${userId}/roles/${roleId}`).expect(204).execute();
|
||||
}
|
||||
|
||||
export async function removeMemberRole(
|
||||
harness: ApiTestHarness,
|
||||
token: string,
|
||||
guildId: string,
|
||||
userId: string,
|
||||
roleId: string,
|
||||
): Promise<void> {
|
||||
await createBuilder(harness, token)
|
||||
.delete(`/guilds/${guildId}/members/${userId}/roles/${roleId}`)
|
||||
.expect(204)
|
||||
.execute();
|
||||
}
|
||||
|
||||
export async function updateMember(
|
||||
harness: ApiTestHarness,
|
||||
token: string,
|
||||
guildId: string,
|
||||
userId: string,
|
||||
updates: {roles?: Array<string>; nick?: string},
|
||||
): Promise<GuildMemberResponse> {
|
||||
return createBuilder<GuildMemberResponse>(harness, token)
|
||||
.patch(`/guilds/${guildId}/members/${userId}`)
|
||||
.body(updates)
|
||||
.execute();
|
||||
}
|
||||
|
||||
export async function getMember(
|
||||
harness: ApiTestHarness,
|
||||
token: string,
|
||||
guildId: string,
|
||||
userId: string,
|
||||
): Promise<GuildMemberResponse> {
|
||||
return createBuilder<GuildMemberResponse>(harness, token).get(`/guilds/${guildId}/members/${userId}`).execute();
|
||||
}
|
||||
|
||||
export async function createChannelInvite(
|
||||
harness: ApiTestHarness,
|
||||
token: string,
|
||||
channelId: string,
|
||||
): Promise<GuildInviteMetadataResponse> {
|
||||
return createBuilder<GuildInviteMetadataResponse>(harness, token)
|
||||
.post(`/channels/${channelId}/invites`)
|
||||
.body({})
|
||||
.execute();
|
||||
}
|
||||
|
||||
export async function acceptInvite(
|
||||
harness: ApiTestHarness,
|
||||
token: string,
|
||||
inviteCode: string,
|
||||
): Promise<{guild: GuildResponse}> {
|
||||
return createBuilder<{guild: GuildResponse}>(harness, token).post(`/invites/${inviteCode}`).body(null).execute();
|
||||
}
|
||||
|
||||
export async function getInvite(harness: ApiTestHarness, inviteCode: string): Promise<GuildInviteMetadataResponse> {
|
||||
return createBuilder<GuildInviteMetadataResponse>(harness, '').get(`/invites/${inviteCode}`).execute();
|
||||
}
|
||||
|
||||
export async function deleteInvite(harness: ApiTestHarness, token: string, inviteCode: string): Promise<void> {
|
||||
await createBuilder(harness, token).delete(`/invites/${inviteCode}`).expect(204).execute();
|
||||
}
|
||||
|
||||
export async function setupTestGuild(
|
||||
harness: ApiTestHarness,
|
||||
account?: TestAccount,
|
||||
): Promise<{
|
||||
account: TestAccount;
|
||||
guild: GuildResponse;
|
||||
}> {
|
||||
const testAccount = account ?? (await createTestAccount(harness));
|
||||
const guild = await createGuild(harness, testAccount.token, 'Test Guild');
|
||||
return {account: testAccount, guild};
|
||||
}
|
||||
|
||||
export async function setupTestGuildWithChannels(
|
||||
harness: ApiTestHarness,
|
||||
account?: TestAccount,
|
||||
): Promise<{
|
||||
account: TestAccount;
|
||||
guild: GuildResponse;
|
||||
channels: Array<ChannelResponse>;
|
||||
}> {
|
||||
const testAccount = account ?? (await createTestAccount(harness));
|
||||
const guild = await createGuild(harness, testAccount.token, 'Test Guild');
|
||||
|
||||
const channels: Array<ChannelResponse> = [];
|
||||
if (guild.system_channel_id) {
|
||||
channels.push(await getChannel(harness, testAccount.token, guild.system_channel_id));
|
||||
}
|
||||
|
||||
return {account: testAccount, guild, channels};
|
||||
}
|
||||
|
||||
export async function setupTestGuildWithMembers(
|
||||
harness: ApiTestHarness,
|
||||
memberCount = 2,
|
||||
): Promise<{
|
||||
owner: TestAccount;
|
||||
members: Array<TestAccount>;
|
||||
guild: GuildResponse;
|
||||
channels: Array<ChannelResponse>;
|
||||
}> {
|
||||
const owner = await createTestAccount(harness);
|
||||
const members: Array<TestAccount> = [];
|
||||
|
||||
for (let i = 0; i < memberCount; i++) {
|
||||
members.push(await createTestAccount(harness));
|
||||
}
|
||||
|
||||
const guild = await createGuild(harness, owner.token, 'Test Guild');
|
||||
const channels: Array<ChannelResponse> = [];
|
||||
|
||||
if (guild.system_channel_id) {
|
||||
const systemChannel = await getChannel(harness, owner.token, guild.system_channel_id);
|
||||
channels.push(systemChannel);
|
||||
|
||||
const invite = await createChannelInvite(harness, owner.token, systemChannel.id);
|
||||
|
||||
for (const member of members) {
|
||||
await acceptInvite(harness, member.token, invite.code);
|
||||
}
|
||||
}
|
||||
|
||||
return {owner, members, guild, channels};
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {createTestAccount} from '@fluxer/api/src/auth/tests/AuthTestUtils';
|
||||
import {acceptInvite, createChannelInvite, createGuild} from '@fluxer/api/src/guild/tests/GuildTestUtils';
|
||||
import {type ApiTestHarness, createApiTestHarness} from '@fluxer/api/src/test/ApiTestHarness';
|
||||
import {HTTP_STATUS} from '@fluxer/api/src/test/TestConstants';
|
||||
import {createBuilder} from '@fluxer/api/src/test/TestRequestBuilder';
|
||||
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
|
||||
import {GuildFeatures} from '@fluxer/constants/src/GuildConstants';
|
||||
import {UserFlags} from '@fluxer/constants/src/UserConstants';
|
||||
import {afterAll, beforeAll, beforeEach, describe, it} from 'vitest';
|
||||
|
||||
const STAFF_TEST_FLAGS = UserFlags.HAS_SESSION_STARTED | UserFlags.STAFF;
|
||||
|
||||
async function addGuildFeaturesForTesting(
|
||||
harness: ApiTestHarness,
|
||||
guildId: string,
|
||||
features: Array<string>,
|
||||
): Promise<void> {
|
||||
await createBuilder<{success: boolean}>(harness, '')
|
||||
.post(`/test/guilds/${guildId}/features`)
|
||||
.body({add_features: features})
|
||||
.execute();
|
||||
}
|
||||
|
||||
async function setUserFlagsForTesting(harness: ApiTestHarness, userId: string, flags: bigint): Promise<void> {
|
||||
await createBuilder<{success: boolean}>(harness, '')
|
||||
.patch(`/test/users/${userId}/flags`)
|
||||
.body({flags: flags.toString()})
|
||||
.execute();
|
||||
}
|
||||
|
||||
describe('Guild unavailable feature access checks', () => {
|
||||
let harness: ApiTestHarness;
|
||||
|
||||
beforeAll(async () => {
|
||||
harness = await createApiTestHarness();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await harness?.shutdown();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await harness.reset();
|
||||
});
|
||||
|
||||
it('blocks /guilds/* and /channels/* when UNAVAILABLE_FOR_EVERYONE is enabled', async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, owner.token, 'Unavailable for everyone');
|
||||
|
||||
if (!guild.system_channel_id) {
|
||||
throw new Error('Guild system channel is missing');
|
||||
}
|
||||
|
||||
await setUserFlagsForTesting(harness, owner.userId, STAFF_TEST_FLAGS);
|
||||
await addGuildFeaturesForTesting(harness, guild.id, [GuildFeatures.UNAVAILABLE_FOR_EVERYONE]);
|
||||
|
||||
await createBuilder(harness, owner.token)
|
||||
.get(`/guilds/${guild.id}`)
|
||||
.expect(HTTP_STATUS.FORBIDDEN, APIErrorCodes.MISSING_ACCESS)
|
||||
.execute();
|
||||
|
||||
await createBuilder(harness, owner.token)
|
||||
.get(`/channels/${guild.system_channel_id}`)
|
||||
.expect(HTTP_STATUS.FORBIDDEN, APIErrorCodes.MISSING_ACCESS)
|
||||
.execute();
|
||||
});
|
||||
|
||||
it('blocks non-staff and allows staff for UNAVAILABLE_FOR_EVERYONE_BUT_STAFF', async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
const member = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, owner.token, 'Unavailable for everyone but staff');
|
||||
|
||||
if (!guild.system_channel_id) {
|
||||
throw new Error('Guild system channel is missing');
|
||||
}
|
||||
|
||||
const invite = await createChannelInvite(harness, owner.token, guild.system_channel_id);
|
||||
await acceptInvite(harness, member.token, invite.code);
|
||||
|
||||
await addGuildFeaturesForTesting(harness, guild.id, [GuildFeatures.UNAVAILABLE_FOR_EVERYONE_BUT_STAFF]);
|
||||
|
||||
await createBuilder(harness, member.token)
|
||||
.get(`/guilds/${guild.id}`)
|
||||
.expect(HTTP_STATUS.FORBIDDEN, APIErrorCodes.MISSING_ACCESS)
|
||||
.execute();
|
||||
|
||||
await createBuilder(harness, member.token)
|
||||
.get(`/channels/${guild.system_channel_id}`)
|
||||
.expect(HTTP_STATUS.FORBIDDEN, APIErrorCodes.MISSING_ACCESS)
|
||||
.execute();
|
||||
|
||||
await setUserFlagsForTesting(harness, owner.userId, STAFF_TEST_FLAGS);
|
||||
|
||||
await createBuilder(harness, owner.token).get(`/guilds/${guild.id}`).expect(HTTP_STATUS.OK).execute();
|
||||
await createBuilder(harness, owner.token)
|
||||
.get(`/channels/${guild.system_channel_id}`)
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
});
|
||||
});
|
||||
237
packages/api/src/guild/tests/InvitePermissions.test.tsx
Normal file
237
packages/api/src/guild/tests/InvitePermissions.test.tsx
Normal file
@@ -0,0 +1,237 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {createTestAccount} from '@fluxer/api/src/auth/tests/AuthTestUtils';
|
||||
import {
|
||||
acceptInvite,
|
||||
addMemberRole,
|
||||
createChannelInvite,
|
||||
createGuild,
|
||||
createRole,
|
||||
deleteInvite,
|
||||
getChannel,
|
||||
getRoles,
|
||||
setupTestGuildWithMembers,
|
||||
updateRole,
|
||||
} from '@fluxer/api/src/guild/tests/GuildTestUtils';
|
||||
import {type ApiTestHarness, createApiTestHarness} from '@fluxer/api/src/test/ApiTestHarness';
|
||||
import {HTTP_STATUS} from '@fluxer/api/src/test/TestConstants';
|
||||
import {createBuilder} from '@fluxer/api/src/test/TestRequestBuilder';
|
||||
import {Permissions} from '@fluxer/constants/src/ChannelConstants';
|
||||
import type {GuildInviteMetadataResponse} from '@fluxer/schema/src/domains/invite/InviteSchemas';
|
||||
import {afterEach, beforeEach, describe, expect, test} from 'vitest';
|
||||
|
||||
describe('Invite Permissions', () => {
|
||||
let harness: ApiTestHarness;
|
||||
|
||||
beforeEach(async () => {
|
||||
harness = await createApiTestHarness();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await harness?.shutdown();
|
||||
});
|
||||
|
||||
test('cannot create invite without CREATE_INSTANT_INVITE permission', async () => {
|
||||
const {owner, members, guild} = await setupTestGuildWithMembers(harness, 1);
|
||||
const member = members[0];
|
||||
|
||||
const roles = await getRoles(harness, owner.token, guild.id);
|
||||
const everyoneRole = roles.find((r) => r.id === guild.id);
|
||||
|
||||
if (everyoneRole) {
|
||||
const permissions = BigInt(everyoneRole.permissions);
|
||||
const newPermissions = permissions & ~Permissions.CREATE_INSTANT_INVITE;
|
||||
await updateRole(harness, owner.token, guild.id, everyoneRole.id, {
|
||||
permissions: newPermissions.toString(),
|
||||
});
|
||||
}
|
||||
|
||||
const systemChannel = await getChannel(harness, owner.token, guild.system_channel_id!);
|
||||
|
||||
await createBuilder(harness, member.token)
|
||||
.post(`/channels/${systemChannel.id}/invites`)
|
||||
.body({})
|
||||
.expect(HTTP_STATUS.FORBIDDEN)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('can create invite with CREATE_INSTANT_INVITE permission', async () => {
|
||||
const {owner, members, guild} = await setupTestGuildWithMembers(harness, 1);
|
||||
const member = members[0];
|
||||
|
||||
const inviterRole = await createRole(harness, owner.token, guild.id, {
|
||||
name: 'Inviter',
|
||||
permissions: Permissions.CREATE_INSTANT_INVITE.toString(),
|
||||
});
|
||||
|
||||
await addMemberRole(harness, owner.token, guild.id, member.userId, inviterRole.id);
|
||||
|
||||
const systemChannel = await getChannel(harness, owner.token, guild.system_channel_id!);
|
||||
|
||||
const invite = await createBuilder<GuildInviteMetadataResponse>(harness, member.token)
|
||||
.post(`/channels/${systemChannel.id}/invites`)
|
||||
.body({})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
|
||||
expect(invite.code).toBeTruthy();
|
||||
|
||||
await deleteInvite(harness, owner.token, invite.code);
|
||||
});
|
||||
|
||||
test('owner can always create invites', async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, owner.token, 'Test Guild');
|
||||
const systemChannel = await getChannel(harness, owner.token, guild.system_channel_id!);
|
||||
|
||||
const invite = await createChannelInvite(harness, owner.token, systemChannel.id);
|
||||
|
||||
expect(invite.code).toBeTruthy();
|
||||
expect(invite.inviter?.id).toBe(owner.userId);
|
||||
|
||||
await deleteInvite(harness, owner.token, invite.code);
|
||||
});
|
||||
|
||||
test('non-member cannot create invite', async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
const nonMember = await createTestAccount(harness);
|
||||
|
||||
const guild = await createGuild(harness, owner.token, 'Test Guild');
|
||||
const systemChannel = await getChannel(harness, owner.token, guild.system_channel_id!);
|
||||
|
||||
await createBuilder(harness, nonMember.token)
|
||||
.post(`/channels/${systemChannel.id}/invites`)
|
||||
.body({})
|
||||
.expect(HTTP_STATUS.FORBIDDEN)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('can create unlimited invite with max_age 0', async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, owner.token, 'Test Guild');
|
||||
const systemChannel = await getChannel(harness, owner.token, guild.system_channel_id!);
|
||||
|
||||
const invite = await createBuilder<GuildInviteMetadataResponse>(harness, owner.token)
|
||||
.post(`/channels/${systemChannel.id}/invites`)
|
||||
.body({max_age: 0})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
|
||||
expect(invite.max_age).toBe(0);
|
||||
|
||||
await deleteInvite(harness, owner.token, invite.code);
|
||||
});
|
||||
|
||||
test('can create unlimited invite with max_uses 0', async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, owner.token, 'Test Guild');
|
||||
const systemChannel = await getChannel(harness, owner.token, guild.system_channel_id!);
|
||||
|
||||
const invite = await createBuilder<GuildInviteMetadataResponse>(harness, owner.token)
|
||||
.post(`/channels/${systemChannel.id}/invites`)
|
||||
.body({max_uses: 0})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
|
||||
expect(invite.max_uses).toBe(0);
|
||||
|
||||
await deleteInvite(harness, owner.token, invite.code);
|
||||
});
|
||||
|
||||
test('invite can be retrieved after use', async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
const joiner = await createTestAccount(harness);
|
||||
|
||||
const guild = await createGuild(harness, owner.token, 'Test Guild');
|
||||
const systemChannel = await getChannel(harness, owner.token, guild.system_channel_id!);
|
||||
|
||||
const invite = await createChannelInvite(harness, owner.token, systemChannel.id);
|
||||
|
||||
await acceptInvite(harness, joiner.token, invite.code);
|
||||
|
||||
const updatedInvite = await createBuilder<GuildInviteMetadataResponse>(harness, owner.token)
|
||||
.get(`/invites/${invite.code}?with_counts=true`)
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
|
||||
expect(updatedInvite.code).toBe(invite.code);
|
||||
|
||||
await deleteInvite(harness, owner.token, invite.code);
|
||||
});
|
||||
|
||||
test('invite includes inviter information', async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, owner.token, 'Test Guild');
|
||||
const systemChannel = await getChannel(harness, owner.token, guild.system_channel_id!);
|
||||
|
||||
const invite = await createChannelInvite(harness, owner.token, systemChannel.id);
|
||||
|
||||
expect(invite.inviter?.id).toBe(owner.userId);
|
||||
|
||||
await deleteInvite(harness, owner.token, invite.code);
|
||||
});
|
||||
|
||||
test('list channel invites requires MANAGE_CHANNELS permission', async () => {
|
||||
const {owner, members, guild} = await setupTestGuildWithMembers(harness, 1);
|
||||
const member = members[0];
|
||||
|
||||
const systemChannel = await getChannel(harness, owner.token, guild.system_channel_id!);
|
||||
await createChannelInvite(harness, owner.token, systemChannel.id);
|
||||
|
||||
await createBuilder(harness, member.token)
|
||||
.get(`/channels/${systemChannel.id}/invites`)
|
||||
.expect(HTTP_STATUS.FORBIDDEN)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('owner can list channel invites', async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, owner.token, 'Test Guild');
|
||||
const systemChannel = await getChannel(harness, owner.token, guild.system_channel_id!);
|
||||
|
||||
const invite = await createChannelInvite(harness, owner.token, systemChannel.id);
|
||||
|
||||
const invites = await createBuilder<Array<GuildInviteMetadataResponse>>(harness, owner.token)
|
||||
.get(`/channels/${systemChannel.id}/invites`)
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
|
||||
expect(invites.length).toBeGreaterThanOrEqual(1);
|
||||
expect(invites.some((i) => i.code === invite.code)).toBe(true);
|
||||
|
||||
await deleteInvite(harness, owner.token, invite.code);
|
||||
});
|
||||
|
||||
test('cannot create invite for channel in another guild', async () => {
|
||||
const owner1 = await createTestAccount(harness);
|
||||
const owner2 = await createTestAccount(harness);
|
||||
|
||||
const guild1 = await createGuild(harness, owner1.token, 'Guild 1');
|
||||
await createGuild(harness, owner2.token, 'Guild 2');
|
||||
|
||||
const channel1 = await getChannel(harness, owner1.token, guild1.system_channel_id!);
|
||||
|
||||
await createBuilder(harness, owner2.token)
|
||||
.post(`/channels/${channel1.id}/invites`)
|
||||
.body({})
|
||||
.expect(HTTP_STATUS.FORBIDDEN)
|
||||
.execute();
|
||||
});
|
||||
});
|
||||
225
packages/api/src/guild/tests/InviteSecurity.test.tsx
Normal file
225
packages/api/src/guild/tests/InviteSecurity.test.tsx
Normal file
@@ -0,0 +1,225 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {createTestAccount} from '@fluxer/api/src/auth/tests/AuthTestUtils';
|
||||
import {
|
||||
addMemberRole,
|
||||
createChannelInvite,
|
||||
createGuild,
|
||||
createRole,
|
||||
deleteInvite,
|
||||
getChannel,
|
||||
setupTestGuildWithMembers,
|
||||
} from '@fluxer/api/src/guild/tests/GuildTestUtils';
|
||||
import {type ApiTestHarness, createApiTestHarness} from '@fluxer/api/src/test/ApiTestHarness';
|
||||
import {HTTP_STATUS} from '@fluxer/api/src/test/TestConstants';
|
||||
import {createBuilder, createBuilderWithoutAuth} from '@fluxer/api/src/test/TestRequestBuilder';
|
||||
import {Permissions} from '@fluxer/constants/src/ChannelConstants';
|
||||
import {afterEach, beforeEach, describe, expect, test} from 'vitest';
|
||||
|
||||
describe('Invite Security', () => {
|
||||
let harness: ApiTestHarness;
|
||||
|
||||
beforeEach(async () => {
|
||||
harness = await createApiTestHarness();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await harness?.shutdown();
|
||||
});
|
||||
|
||||
test('guild members can view invites', async () => {
|
||||
const {owner, members, guild} = await setupTestGuildWithMembers(harness, 1);
|
||||
const member = members[0];
|
||||
|
||||
const systemChannel = await getChannel(harness, owner.token, guild.system_channel_id!);
|
||||
const invite = await createChannelInvite(harness, owner.token, systemChannel.id);
|
||||
|
||||
const inviteData = await createBuilder<{code: string}>(harness, member.token)
|
||||
.get(`/invites/${invite.code}`)
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
|
||||
expect(inviteData.code).toBe(invite.code);
|
||||
|
||||
await deleteInvite(harness, owner.token, invite.code);
|
||||
});
|
||||
|
||||
test('non-members can view public invites', async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
const nonMember = await createTestAccount(harness);
|
||||
|
||||
const guild = await createGuild(harness, owner.token, 'Test Guild');
|
||||
const systemChannel = await getChannel(harness, owner.token, guild.system_channel_id!);
|
||||
const invite = await createChannelInvite(harness, owner.token, systemChannel.id);
|
||||
|
||||
await createBuilder(harness, nonMember.token).get(`/invites/${invite.code}`).expect(HTTP_STATUS.OK).execute();
|
||||
|
||||
await deleteInvite(harness, owner.token, invite.code);
|
||||
});
|
||||
|
||||
test('only owner can delete invites by default', async () => {
|
||||
const {owner, members, guild} = await setupTestGuildWithMembers(harness, 1);
|
||||
const member = members[0];
|
||||
|
||||
const systemChannel = await getChannel(harness, owner.token, guild.system_channel_id!);
|
||||
const invite = await createChannelInvite(harness, owner.token, systemChannel.id);
|
||||
|
||||
await createBuilder(harness, member.token)
|
||||
.delete(`/invites/${invite.code}`)
|
||||
.expect(HTTP_STATUS.FORBIDDEN)
|
||||
.execute();
|
||||
|
||||
await createBuilder(harness, owner.token)
|
||||
.delete(`/invites/${invite.code}`)
|
||||
.expect(HTTP_STATUS.NO_CONTENT)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('member with MANAGE_GUILD permission can delete invites', async () => {
|
||||
const {owner, members, guild} = await setupTestGuildWithMembers(harness, 1);
|
||||
const member = members[0];
|
||||
|
||||
const managerRole = await createRole(harness, owner.token, guild.id, {
|
||||
name: 'Manager',
|
||||
permissions: Permissions.MANAGE_GUILD.toString(),
|
||||
});
|
||||
|
||||
await addMemberRole(harness, owner.token, guild.id, member.userId, managerRole.id);
|
||||
|
||||
const systemChannel = await getChannel(harness, owner.token, guild.system_channel_id!);
|
||||
const invite = await createChannelInvite(harness, owner.token, systemChannel.id);
|
||||
|
||||
await createBuilder(harness, member.token)
|
||||
.delete(`/invites/${invite.code}`)
|
||||
.expect(HTTP_STATUS.NO_CONTENT)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('deleted invites become inaccessible', async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, owner.token, 'Test Guild');
|
||||
const systemChannel = await getChannel(harness, owner.token, guild.system_channel_id!);
|
||||
|
||||
const invite = await createChannelInvite(harness, owner.token, systemChannel.id);
|
||||
const inviteCode = invite.code;
|
||||
|
||||
await createBuilder(harness, owner.token).get(`/invites/${inviteCode}`).expect(HTTP_STATUS.OK).execute();
|
||||
|
||||
await deleteInvite(harness, owner.token, inviteCode);
|
||||
|
||||
await createBuilder(harness, owner.token).get(`/invites/${inviteCode}`).expect(HTTP_STATUS.NOT_FOUND).execute();
|
||||
|
||||
await createBuilder(harness, owner.token)
|
||||
.post(`/invites/${inviteCode}`)
|
||||
.body(null)
|
||||
.expect(HTTP_STATUS.NOT_FOUND)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('unauthenticated requests can view public invites', async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, owner.token, 'Test Guild');
|
||||
const systemChannel = await getChannel(harness, owner.token, guild.system_channel_id!);
|
||||
|
||||
const invite = await createChannelInvite(harness, owner.token, systemChannel.id);
|
||||
|
||||
const inviteData = await createBuilderWithoutAuth<{code: string; guild: {name: string}}>(harness)
|
||||
.get(`/invites/${invite.code}`)
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
|
||||
expect(inviteData.code).toBe(invite.code);
|
||||
|
||||
await deleteInvite(harness, owner.token, invite.code);
|
||||
});
|
||||
|
||||
test('unauthenticated requests cannot accept invites', async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, owner.token, 'Test Guild');
|
||||
const systemChannel = await getChannel(harness, owner.token, guild.system_channel_id!);
|
||||
|
||||
const invite = await createChannelInvite(harness, owner.token, systemChannel.id);
|
||||
|
||||
await createBuilderWithoutAuth(harness)
|
||||
.post(`/invites/${invite.code}`)
|
||||
.body(null)
|
||||
.expect(HTTP_STATUS.UNAUTHORIZED)
|
||||
.execute();
|
||||
|
||||
await deleteInvite(harness, owner.token, invite.code);
|
||||
});
|
||||
|
||||
test('invite creator can delete their own invite', async () => {
|
||||
const {owner, members, guild} = await setupTestGuildWithMembers(harness, 1);
|
||||
const member = members[0];
|
||||
|
||||
const systemChannel = await getChannel(harness, owner.token, guild.system_channel_id!);
|
||||
|
||||
const createInvitesRole = await createRole(harness, owner.token, guild.id, {
|
||||
name: 'Inviter',
|
||||
permissions: Permissions.CREATE_INSTANT_INVITE.toString(),
|
||||
});
|
||||
|
||||
await addMemberRole(harness, owner.token, guild.id, member.userId, createInvitesRole.id);
|
||||
|
||||
const memberInvite = await createChannelInvite(harness, member.token, systemChannel.id);
|
||||
|
||||
await createBuilder(harness, member.token)
|
||||
.delete(`/invites/${memberInvite.code}`)
|
||||
.expect(HTTP_STATUS.NO_CONTENT)
|
||||
.execute();
|
||||
});
|
||||
|
||||
test('member cannot delete invites created by others without permission', async () => {
|
||||
const {owner, members, guild} = await setupTestGuildWithMembers(harness, 2);
|
||||
const [member1, member2] = members;
|
||||
|
||||
const systemChannel = await getChannel(harness, owner.token, guild.system_channel_id!);
|
||||
|
||||
const inviterRole = await createRole(harness, owner.token, guild.id, {
|
||||
name: 'Inviter',
|
||||
permissions: Permissions.CREATE_INSTANT_INVITE.toString(),
|
||||
});
|
||||
|
||||
await addMemberRole(harness, owner.token, guild.id, member1.userId, inviterRole.id);
|
||||
await addMemberRole(harness, owner.token, guild.id, member2.userId, inviterRole.id);
|
||||
|
||||
const member1Invite = await createChannelInvite(harness, member1.token, systemChannel.id);
|
||||
|
||||
await createBuilder(harness, member2.token)
|
||||
.delete(`/invites/${member1Invite.code}`)
|
||||
.expect(HTTP_STATUS.FORBIDDEN)
|
||||
.execute();
|
||||
|
||||
await deleteInvite(harness, owner.token, member1Invite.code);
|
||||
});
|
||||
|
||||
test('double deletion returns not found', async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, owner.token, 'Test Guild');
|
||||
const systemChannel = await getChannel(harness, owner.token, guild.system_channel_id!);
|
||||
|
||||
const invite = await createChannelInvite(harness, owner.token, systemChannel.id);
|
||||
|
||||
await deleteInvite(harness, owner.token, invite.code);
|
||||
|
||||
await createBuilder(harness, owner.token).delete(`/invites/${invite.code}`).expect(HTTP_STATUS.NOT_FOUND).execute();
|
||||
});
|
||||
});
|
||||
102
packages/api/src/guild/tests/InviteValidation.test.tsx
Normal file
102
packages/api/src/guild/tests/InviteValidation.test.tsx
Normal file
@@ -0,0 +1,102 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {createTestAccount} from '@fluxer/api/src/auth/tests/AuthTestUtils';
|
||||
import {createGuild, deleteInvite, getChannel} from '@fluxer/api/src/guild/tests/GuildTestUtils';
|
||||
import {type ApiTestHarness, createApiTestHarness} from '@fluxer/api/src/test/ApiTestHarness';
|
||||
import {HTTP_STATUS} from '@fluxer/api/src/test/TestConstants';
|
||||
import {createBuilder} from '@fluxer/api/src/test/TestRequestBuilder';
|
||||
import type {GuildInviteMetadataResponse} from '@fluxer/schema/src/domains/invite/InviteSchemas';
|
||||
import {beforeAll, beforeEach, describe, expect, it} from 'vitest';
|
||||
|
||||
describe('Invite Validation', () => {
|
||||
let harness: ApiTestHarness;
|
||||
|
||||
beforeAll(async () => {
|
||||
harness = await createApiTestHarness();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await harness.reset();
|
||||
});
|
||||
|
||||
it('should reject getting nonexistent invite', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
|
||||
await createBuilder(harness, account.token).get('/invites/invalidcode123').expect(HTTP_STATUS.NOT_FOUND).execute();
|
||||
});
|
||||
|
||||
it('should reject accepting nonexistent invite', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
|
||||
await createBuilder(harness, account.token)
|
||||
.post('/invites/invalidcode123')
|
||||
.body(null)
|
||||
.expect(HTTP_STATUS.NOT_FOUND)
|
||||
.execute();
|
||||
});
|
||||
|
||||
it('should reject deleting nonexistent invite', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
|
||||
await createBuilder(harness, account.token)
|
||||
.delete('/invites/invalidcode123')
|
||||
.expect(HTTP_STATUS.NOT_FOUND)
|
||||
.execute();
|
||||
});
|
||||
|
||||
it('should reject invalid max_uses value', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, account.token, 'Invite Validation Guild');
|
||||
const channel = await getChannel(harness, account.token, guild.system_channel_id!);
|
||||
|
||||
await createBuilder(harness, account.token)
|
||||
.post(`/channels/${channel.id}/invites`)
|
||||
.body({max_uses: -1})
|
||||
.expect(HTTP_STATUS.BAD_REQUEST)
|
||||
.execute();
|
||||
});
|
||||
|
||||
it('should reject invalid max_age value', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, account.token, 'Invite Validation Guild');
|
||||
const channel = await getChannel(harness, account.token, guild.system_channel_id!);
|
||||
|
||||
await createBuilder(harness, account.token)
|
||||
.post(`/channels/${channel.id}/invites`)
|
||||
.body({max_age: -1})
|
||||
.expect(HTTP_STATUS.BAD_REQUEST)
|
||||
.execute();
|
||||
});
|
||||
|
||||
it('should accept valid max_uses and max_age', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, account.token, 'Invite Validation Guild');
|
||||
const channel = await getChannel(harness, account.token, guild.system_channel_id!);
|
||||
|
||||
const invite = await createBuilder<GuildInviteMetadataResponse>(harness, account.token)
|
||||
.post(`/channels/${channel.id}/invites`)
|
||||
.body({max_uses: 5, max_age: 3600})
|
||||
.execute();
|
||||
|
||||
expect(invite.code).toBeTruthy();
|
||||
|
||||
await deleteInvite(harness, account.token, invite.code);
|
||||
});
|
||||
});
|
||||
146
packages/api/src/guild/tests/RoleHierarchyEnforcement.test.tsx
Normal file
146
packages/api/src/guild/tests/RoleHierarchyEnforcement.test.tsx
Normal file
@@ -0,0 +1,146 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {createTestAccount} from '@fluxer/api/src/auth/tests/AuthTestUtils';
|
||||
import {
|
||||
acceptInvite,
|
||||
createChannelInvite,
|
||||
createGuild,
|
||||
createRole,
|
||||
getChannel,
|
||||
getMember,
|
||||
updateMember,
|
||||
updateRole,
|
||||
updateRolePositions,
|
||||
} from '@fluxer/api/src/guild/tests/GuildTestUtils';
|
||||
import {type ApiTestHarness, createApiTestHarness} from '@fluxer/api/src/test/ApiTestHarness';
|
||||
import {HTTP_STATUS} from '@fluxer/api/src/test/TestConstants';
|
||||
import {createBuilder} from '@fluxer/api/src/test/TestRequestBuilder';
|
||||
import {beforeAll, beforeEach, describe, expect, it} from 'vitest';
|
||||
|
||||
describe('Role Hierarchy Enforcement', () => {
|
||||
let harness: ApiTestHarness;
|
||||
|
||||
beforeAll(async () => {
|
||||
harness = await createApiTestHarness();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await harness.reset();
|
||||
});
|
||||
|
||||
it('should allow moderator to modify lower role but not equal/higher roles', async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
const moderator = await createTestAccount(harness);
|
||||
const member = await createTestAccount(harness);
|
||||
|
||||
const guild = await createGuild(harness, owner.token, 'Role Hierarchy Guild');
|
||||
|
||||
const modRole = await createRole(harness, owner.token, guild.id, {
|
||||
name: 'Moderator',
|
||||
color: 65280,
|
||||
permissions: '268435456',
|
||||
hoist: true,
|
||||
});
|
||||
|
||||
const memberRole = await createRole(harness, owner.token, guild.id, {
|
||||
name: 'Member',
|
||||
color: 16711680,
|
||||
permissions: '0',
|
||||
hoist: false,
|
||||
});
|
||||
|
||||
await updateRolePositions(harness, owner.token, guild.id, [
|
||||
{id: modRole.id, position: 2},
|
||||
{id: memberRole.id, position: 1},
|
||||
]);
|
||||
|
||||
const systemChannel = await getChannel(harness, owner.token, guild.system_channel_id!);
|
||||
const invite = await createChannelInvite(harness, owner.token, systemChannel.id);
|
||||
|
||||
await acceptInvite(harness, moderator.token, invite.code);
|
||||
await acceptInvite(harness, member.token, invite.code);
|
||||
|
||||
await updateMember(harness, owner.token, guild.id, moderator.userId, {
|
||||
roles: [modRole.id],
|
||||
});
|
||||
|
||||
await updateMember(harness, owner.token, guild.id, member.userId, {
|
||||
roles: [memberRole.id],
|
||||
});
|
||||
|
||||
const updatedMemberRole = await updateRole(harness, moderator.token, guild.id, memberRole.id, {
|
||||
color: 255,
|
||||
});
|
||||
|
||||
expect(updatedMemberRole.color).toBe(255);
|
||||
|
||||
await createBuilder(harness, moderator.token)
|
||||
.patch(`/guilds/${guild.id}/roles/${modRole.id}`)
|
||||
.body({permissions: '8'})
|
||||
.expect(HTTP_STATUS.FORBIDDEN)
|
||||
.execute();
|
||||
});
|
||||
|
||||
it('should prevent member from assigning higher role to themselves via @me endpoint', async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
const moderator = await createTestAccount(harness);
|
||||
const member = await createTestAccount(harness);
|
||||
|
||||
const guild = await createGuild(harness, owner.token, 'Role Hierarchy Guild');
|
||||
|
||||
const modRole = await createRole(harness, owner.token, guild.id, {
|
||||
name: 'Moderator',
|
||||
color: 65280,
|
||||
permissions: '268435456',
|
||||
hoist: true,
|
||||
});
|
||||
|
||||
const memberRole = await createRole(harness, owner.token, guild.id, {
|
||||
name: 'Member',
|
||||
color: 16711680,
|
||||
permissions: '0',
|
||||
hoist: false,
|
||||
});
|
||||
|
||||
const systemChannel = await getChannel(harness, owner.token, guild.system_channel_id!);
|
||||
const invite = await createChannelInvite(harness, owner.token, systemChannel.id);
|
||||
|
||||
await acceptInvite(harness, moderator.token, invite.code);
|
||||
await acceptInvite(harness, member.token, invite.code);
|
||||
|
||||
await updateMember(harness, owner.token, guild.id, moderator.userId, {
|
||||
roles: [modRole.id],
|
||||
});
|
||||
|
||||
await updateMember(harness, owner.token, guild.id, member.userId, {
|
||||
roles: [memberRole.id],
|
||||
});
|
||||
|
||||
await createBuilder(harness, member.token)
|
||||
.patch(`/guilds/${guild.id}/members/@me`)
|
||||
.body({roles: [modRole.id]})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
|
||||
const fetchedMember = await getMember(harness, owner.token, guild.id, member.userId);
|
||||
expect(fetchedMember.roles).not.toContain(modRole.id);
|
||||
expect(fetchedMember.roles).toContain(memberRole.id);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,89 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {createTestAccount} from '@fluxer/api/src/auth/tests/AuthTestUtils';
|
||||
import {
|
||||
acceptInvite,
|
||||
createChannelInvite,
|
||||
createGuild,
|
||||
createRole,
|
||||
getChannel,
|
||||
updateRole,
|
||||
} from '@fluxer/api/src/guild/tests/GuildTestUtils';
|
||||
import {type ApiTestHarness, createApiTestHarness} from '@fluxer/api/src/test/ApiTestHarness';
|
||||
import {HTTP_STATUS} from '@fluxer/api/src/test/TestConstants';
|
||||
import {createBuilder} from '@fluxer/api/src/test/TestRequestBuilder';
|
||||
import {beforeAll, beforeEach, describe, expect, it} from 'vitest';
|
||||
|
||||
describe('Role Permission Assignment Hierarchy', () => {
|
||||
let harness: ApiTestHarness;
|
||||
|
||||
beforeAll(async () => {
|
||||
harness = await createApiTestHarness();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await harness.reset();
|
||||
});
|
||||
|
||||
it('should prevent users from granting permissions they do not possess', async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
const manager = await createTestAccount(harness);
|
||||
|
||||
const guild = await createGuild(harness, owner.token, 'Role Hierarchy Guild');
|
||||
|
||||
const roleHigh = await createRole(harness, owner.token, guild.id, {
|
||||
name: 'High',
|
||||
permissions: String(1 << 28),
|
||||
});
|
||||
|
||||
const roleMid = await createRole(harness, owner.token, guild.id, {
|
||||
name: 'Mid',
|
||||
permissions: String(1 << 11),
|
||||
});
|
||||
|
||||
const systemChannel = await getChannel(harness, owner.token, guild.system_channel_id!);
|
||||
const invite = await createChannelInvite(harness, owner.token, systemChannel.id);
|
||||
|
||||
await acceptInvite(harness, manager.token, invite.code);
|
||||
|
||||
await createBuilder(harness, owner.token)
|
||||
.put(`/guilds/${guild.id}/members/${manager.userId}/roles/${roleMid.id}`)
|
||||
.expect(HTTP_STATUS.NO_CONTENT)
|
||||
.execute();
|
||||
|
||||
await createBuilder(harness, manager.token)
|
||||
.patch(`/guilds/${guild.id}/roles/${roleHigh.id}`)
|
||||
.body({permissions: String(1 << 28)})
|
||||
.expect(HTTP_STATUS.FORBIDDEN)
|
||||
.execute();
|
||||
|
||||
await createBuilder(harness, manager.token)
|
||||
.patch(`/guilds/${guild.id}/roles/${roleMid.id}`)
|
||||
.body({permissions: String(1 << 28)})
|
||||
.expect(HTTP_STATUS.FORBIDDEN)
|
||||
.execute();
|
||||
|
||||
const updatedRole = await updateRole(harness, owner.token, guild.id, roleMid.id, {
|
||||
permissions: String((1 << 11) | (1 << 13)),
|
||||
});
|
||||
|
||||
expect(updatedRole.permissions).toBe(String((1 << 11) | (1 << 13)));
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user