refactor progress

This commit is contained in:
Hampus Kraft
2026-02-17 12:22:36 +00:00
parent cb31608523
commit d5abd1a7e4
8257 changed files with 1190207 additions and 761040 deletions

View File

@@ -0,0 +1,320 @@
/*
* 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,
createGuild,
loadFixture,
sendMessageWithAttachments,
} from '@fluxer/api/src/channel/tests/AttachmentTestUtils';
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 {getExpiryBucket} from '@fluxer/api/src/utils/AttachmentDecay';
import {afterEach, beforeEach, describe, expect, test} from 'vitest';
interface AttachmentDecayRow {
attachment_id: string;
channel_id: string;
message_id: string;
filename: string;
size_bytes: string;
expires_at: string;
expiry_bucket: number;
status: string | null;
}
interface AttachmentDecayQueryResponse {
rows: Array<AttachmentDecayRow>;
has_more: boolean;
count: number;
}
describe('Attachment Decay', () => {
let harness: ApiTestHarness;
beforeEach(async () => {
harness = await createApiTestHarness();
});
afterEach(async () => {
await harness?.shutdown();
});
test('should create decay metadata when uploading attachment', async () => {
const account = await createTestAccount(harness);
const guild = await createGuild(harness, account.token, 'Decay Test Guild');
const channel = await createChannel(harness, account.token, guild.id, 'test-channel');
const channelId = guild.system_channel_id ?? channel.id;
const fileData = loadFixture('yeah.png');
const {response, json} = await sendMessageWithAttachments(
harness,
account.token,
channelId,
{
content: 'Attachment with decay tracking',
attachments: [{id: 0, filename: 'test.png'}],
},
[{index: 0, filename: 'test.png', data: fileData}],
);
expect(response.status).toBe(HTTP_STATUS.OK);
expect(json.attachments).toBeDefined();
expect(json.attachments).not.toBeNull();
expect(json.attachments!.length).toBe(1);
const attachmentId = json.attachments![0].id;
const decayResponse = await createBuilderWithoutAuth<{row: AttachmentDecayRow | null}>(harness)
.get(`/test/attachment-decay/${attachmentId}`)
.execute();
expect(decayResponse.row).not.toBeNull();
expect(decayResponse.row?.attachment_id).toBe(attachmentId);
expect(decayResponse.row?.channel_id).toBe(channelId);
expect(decayResponse.row?.message_id).toBe(json.id);
});
test('should track multiple attachments in single message', async () => {
const account = await createTestAccount(harness);
const guild = await createGuild(harness, account.token, 'Multi Attachment Guild');
const channel = await createChannel(harness, account.token, guild.id, 'test-channel');
const channelId = guild.system_channel_id ?? channel.id;
const file1Data = loadFixture('yeah.png');
const file2Data = loadFixture('thisisfine.gif');
const {response, json} = await sendMessageWithAttachments(
harness,
account.token,
channelId,
{
content: 'Multiple attachments',
attachments: [
{id: 0, filename: 'first.png'},
{id: 1, filename: 'second.gif'},
],
},
[
{index: 0, filename: 'first.png', data: file1Data},
{index: 1, filename: 'second.gif', data: file2Data},
],
);
expect(response.status).toBe(HTTP_STATUS.OK);
expect(json.attachments).toBeDefined();
expect(json.attachments).not.toBeNull();
expect(json.attachments!.length).toBe(2);
for (const attachment of json.attachments!) {
const decayResponse = await createBuilderWithoutAuth<{row: AttachmentDecayRow | null}>(harness)
.get(`/test/attachment-decay/${attachment.id}`)
.execute();
expect(decayResponse.row).not.toBeNull();
expect(decayResponse.row?.message_id).toBe(json.id);
}
});
test('should seed attachment decay rows via test endpoint', async () => {
const account = await createTestAccount(harness);
const guild = await createGuild(harness, account.token, 'Seed Test Guild');
const channelId = guild.system_channel_id!;
const futureExpiryDate = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000);
const futureExpiry = futureExpiryDate.toISOString();
const bucket = getExpiryBucket(futureExpiryDate);
await createBuilderWithoutAuth(harness)
.post('/test/attachment-decay/rows')
.body({
rows: [
{
attachment_id: '123456789012345678',
channel_id: channelId,
message_id: '234567890123456789',
filename: 'seeded.png',
size_bytes: '1024',
expires_at: futureExpiry,
},
],
})
.execute();
const queryTimeAfterExpiry = new Date(futureExpiryDate.getTime() + 1000).toISOString();
const queryResponse = await createBuilderWithoutAuth<AttachmentDecayQueryResponse>(harness)
.post('/test/attachment-decay/query')
.body({
bucket,
limit: 100,
current_time: queryTimeAfterExpiry,
})
.execute();
expect(queryResponse.rows.some((r) => r.attachment_id === '123456789012345678')).toBe(true);
});
test('should clear attachment decay data', async () => {
const account = await createTestAccount(harness);
const guild = await createGuild(harness, account.token, 'Clear Test Guild');
const channelId = guild.system_channel_id!;
const expiry = new Date(Date.now() + 1000).toISOString();
await createBuilderWithoutAuth(harness)
.post('/test/attachment-decay/rows')
.body({
rows: [
{
attachment_id: '111111111111111111',
channel_id: channelId,
message_id: '222222222222222222',
filename: 'clear-test.png',
size_bytes: '512',
expires_at: expiry,
},
],
})
.execute();
await createBuilderWithoutAuth(harness).post('/test/attachment-decay/clear').body({}).execute();
const checkResponse = await createBuilderWithoutAuth<{row: AttachmentDecayRow | null}>(harness)
.get('/test/attachment-decay/111111111111111111')
.execute();
expect(checkResponse.row).toBeNull();
});
test('should include decay metadata in message response', async () => {
const account = await createTestAccount(harness);
const guild = await createGuild(harness, account.token, 'Metadata Test Guild');
const channel = await createChannel(harness, account.token, guild.id, 'test-channel');
const channelId = guild.system_channel_id ?? channel.id;
const fileData = loadFixture('yeah.png');
const {response, json} = await sendMessageWithAttachments(
harness,
account.token,
channelId,
{
content: 'Decay metadata test',
attachments: [{id: 0, filename: 'metadata.png'}],
},
[{index: 0, filename: 'metadata.png', data: fileData}],
);
expect(response.status).toBe(HTTP_STATUS.OK);
const messageResponse = await createBuilder<{
id: string;
attachments: Array<{id: string; filename: string; url: string}>;
}>(harness, account.token)
.get(`/channels/${channelId}/messages/${json.id}`)
.execute();
expect(messageResponse.attachments).toBeDefined();
expect(messageResponse.attachments).not.toBeNull();
expect(messageResponse.attachments!.length).toBe(1);
expect(messageResponse.attachments![0].url).toBeTruthy();
});
test('should handle attachment with different sizes', async () => {
const account = await createTestAccount(harness);
const guild = await createGuild(harness, account.token, 'Size Test Guild');
const channel = await createChannel(harness, account.token, guild.id, 'test-channel');
const channelId = guild.system_channel_id ?? channel.id;
const smallFile = loadFixture('yeah.png');
const largeFile = loadFixture('thisisfine.gif');
const smallResult = await sendMessageWithAttachments(
harness,
account.token,
channelId,
{
content: 'Small file',
attachments: [{id: 0, filename: 'small.png'}],
},
[{index: 0, filename: 'small.png', data: smallFile}],
);
const largeResult = await sendMessageWithAttachments(
harness,
account.token,
channelId,
{
content: 'Large file',
attachments: [{id: 0, filename: 'large.gif'}],
},
[{index: 0, filename: 'large.gif', data: largeFile}],
);
expect(smallResult.response.status).toBe(HTTP_STATUS.OK);
expect(largeResult.response.status).toBe(HTTP_STATUS.OK);
const smallDecay = await createBuilderWithoutAuth<{row: AttachmentDecayRow | null}>(harness)
.get(`/test/attachment-decay/${smallResult.json.attachments![0].id}`)
.execute();
const largeDecay = await createBuilderWithoutAuth<{row: AttachmentDecayRow | null}>(harness)
.get(`/test/attachment-decay/${largeResult.json.attachments![0].id}`)
.execute();
expect(smallDecay.row).not.toBeNull();
expect(largeDecay.row).not.toBeNull();
const smallSize = BigInt(smallDecay.row?.size_bytes ?? '0');
const largeSize = BigInt(largeDecay.row?.size_bytes ?? '0');
expect(largeSize).toBeGreaterThan(smallSize);
});
test('should track filename in decay metadata', async () => {
const account = await createTestAccount(harness);
const guild = await createGuild(harness, account.token, 'Filename Test Guild');
const channel = await createChannel(harness, account.token, guild.id, 'test-channel');
const channelId = guild.system_channel_id ?? channel.id;
const fileData = loadFixture('yeah.png');
const {response, json} = await sendMessageWithAttachments(
harness,
account.token,
channelId,
{
content: 'Filename tracking test',
attachments: [{id: 0, filename: 'unique-filename-test.png'}],
},
[{index: 0, filename: 'unique-filename-test.png', data: fileData}],
);
expect(response.status).toBe(HTTP_STATUS.OK);
const decayResponse = await createBuilderWithoutAuth<{row: AttachmentDecayRow | null}>(harness)
.get(`/test/attachment-decay/${json.attachments![0].id}`)
.execute();
expect(decayResponse.row).not.toBeNull();
expect(decayResponse.row?.filename).toBe('unique-filename-test.png');
});
});

View File

@@ -0,0 +1,165 @@
/*
* 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 {readFileSync} from 'node:fs';
import {join} from 'node:path';
import {createTestAccount, type TestAccount} from '@fluxer/api/src/auth/tests/AuthTestUtils';
import {Config} from '@fluxer/api/src/Config';
import {ensureSessionStarted} from '@fluxer/api/src/message/tests/MessageTestUtils';
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 {GuildResponse} from '@fluxer/schema/src/domains/guild/GuildResponseSchemas';
import type {MessageResponse} from '@fluxer/schema/src/domains/message/MessageResponseSchemas';
export interface AttachmentMetadata {
id: number;
filename: string;
title?: string;
description?: string;
flags?: number;
}
export function createMultipartFormData(
payload: Record<string, unknown>,
files: Array<{index: number; filename: string; data: Buffer}>,
): {body: Buffer; contentType: string} {
const boundary = `----FormBoundary${Math.random().toString(16).slice(2)}`;
const chunks: Array<Buffer> = [];
const payloadJson = JSON.stringify(payload);
chunks.push(Buffer.from(`--${boundary}\r\n`));
chunks.push(Buffer.from(`Content-Disposition: form-data; name="payload_json"\r\n`));
chunks.push(Buffer.from(`Content-Type: application/json\r\n\r\n`));
chunks.push(Buffer.from(`${payloadJson}\r\n`));
for (const file of files) {
chunks.push(Buffer.from(`--${boundary}\r\n`));
chunks.push(
Buffer.from(`Content-Disposition: form-data; name="files[${file.index}]"; filename="${file.filename}"\r\n`),
);
chunks.push(Buffer.from(`Content-Type: application/octet-stream\r\n\r\n`));
chunks.push(file.data);
chunks.push(Buffer.from(`\r\n`));
}
chunks.push(Buffer.from(`--${boundary}--\r\n`));
return {
body: Buffer.concat(chunks),
contentType: `multipart/form-data; boundary=${boundary}`,
};
}
export function loadFixture(filename: string): Buffer {
const fixturesPath = join(import.meta.dirname, '..', '..', 'test', 'fixtures', filename);
return readFileSync(fixturesPath);
}
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 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 sendMessageWithAttachments(
harness: ApiTestHarness,
token: string,
channelId: string,
payload: Record<string, unknown>,
files: Array<{index: number; filename: string; data: Buffer}>,
): Promise<{response: Response; text: string; json: MessageResponse}> {
await ensureSessionStarted(harness, token);
const {body, contentType} = createMultipartFormData(payload, files);
const mergedHeaders = new Headers();
mergedHeaders.set('Content-Type', contentType);
mergedHeaders.set('Authorization', token);
if (!mergedHeaders.has('x-forwarded-for')) {
mergedHeaders.set('x-forwarded-for', '127.0.0.1');
}
const response = await harness.app.request(`/channels/${channelId}/messages`, {
method: 'POST',
headers: mergedHeaders,
body,
});
const text = await response.text();
let json: unknown = null;
try {
json = text.length > 0 ? (JSON.parse(text) as unknown) : null;
} catch {
json = null;
}
return {response, text, json: json as MessageResponse};
}
export async function getMessage(
harness: ApiTestHarness,
token: string,
channelId: string,
messageId: string,
): Promise<{response: Response; json: MessageResponse}> {
return createBuilder<MessageResponse>(harness, token)
.get(`/channels/${channelId}/messages/${messageId}`)
.executeWithResponse();
}
export async function setupTestGuildAndChannel(
harness: ApiTestHarness,
account?: TestAccount,
): Promise<{account: TestAccount; guild: GuildResponse; channel: ChannelResponse}> {
const testAccount = account ?? (await createTestAccount(harness));
const guild = await createGuild(harness, testAccount.token, 'Test Guild');
const channel = await createChannel(harness, testAccount.token, guild.id, 'test-channel');
return {account: testAccount, guild, channel};
}
export async function createTestAccountForAttachmentTests(harness: ApiTestHarness): Promise<TestAccount> {
const account = await createTestAccount(harness);
await ensureSessionStarted(harness, account.token);
await createBuilder<{type: 'session'}>(harness, `Bearer ${Config.gateway.rpcSecret}`)
.post('/_rpc')
.body({
type: 'session',
token: account.token,
version: 1,
ip: '127.0.0.1',
})
.execute();
return account;
}

View File

@@ -0,0 +1,561 @@
/*
* 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,
createGuild,
loadFixture,
sendMessageWithAttachments,
} from '@fluxer/api/src/channel/tests/AttachmentTestUtils';
import {type ApiTestHarness, createApiTestHarness} from '@fluxer/api/src/test/ApiTestHarness';
import {beforeAll, beforeEach, describe, expect, it} from 'vitest';
describe('Attachment Upload Validation', () => {
let harness: ApiTestHarness;
beforeAll(async () => {
harness = await createApiTestHarness();
});
beforeEach(async () => {
await harness.reset();
});
describe('File Validation', () => {
describe('size limits', () => {
it('should reject files exceeding size limits', async () => {
const account = await createTestAccount(harness);
const guild = await createGuild(harness, account.token, 'Too Large File Test Guild');
const channel = await createChannel(harness, account.token, guild.id, 'test-channel');
const channelId = guild.system_channel_id ?? channel.id;
const largeFileData = Buffer.alloc(26 * 1024 * 1024);
const payload = {
content: 'Large file test',
attachments: [{id: 0, filename: 'large.bin'}],
};
const {response} = await sendMessageWithAttachments(harness, account.token, channelId, payload, [
{index: 0, filename: 'large.bin', data: largeFileData},
]);
expect(response.status).toBe(400);
});
});
describe('filename validation', () => {
it('should reject empty filenames', async () => {
const account = await createTestAccount(harness);
const guild = await createGuild(harness, account.token, 'Empty Filename Test Guild');
const channel = await createChannel(harness, account.token, guild.id, 'test-channel');
const channelId = guild.system_channel_id ?? channel.id;
const fileData = Buffer.from('test content');
const payload = {
content: 'Empty filename test',
attachments: [
{
id: 0,
filename: '',
},
],
};
const {response} = await sendMessageWithAttachments(harness, account.token, channelId, payload, [
{index: 0, filename: 'actualname.txt', data: fileData},
]);
expect(response.status).toBe(400);
});
it('should reject empty filename in metadata and upload', async () => {
const account = await createTestAccount(harness);
const guild = await createGuild(harness, account.token, 'Invalid Filename Test Guild');
const channel = await createChannel(harness, account.token, guild.id, 'test-channel');
const channelId = guild.system_channel_id ?? channel.id;
const fileData = Buffer.from('test content');
const payload = {
content: 'Invalid filename test',
attachments: [
{
id: 0,
filename: '',
},
],
};
const {response} = await sendMessageWithAttachments(harness, account.token, channelId, payload, [
{index: 0, filename: '', data: fileData},
]);
expect(response.status).not.toBe(200);
});
it('should reject excessively long filename', async () => {
const account = await createTestAccount(harness);
const guild = await createGuild(harness, account.token, 'Invalid Filename Test Guild');
const channel = await createChannel(harness, account.token, guild.id, 'test-channel');
const channelId = guild.system_channel_id ?? channel.id;
const fileData = Buffer.from('test content');
const longFilename = 'a'.repeat(300);
const payload = {
content: 'Invalid filename test',
attachments: [
{
id: 0,
filename: longFilename,
},
],
};
const {response} = await sendMessageWithAttachments(harness, account.token, channelId, payload, [
{index: 0, filename: longFilename, data: fileData},
]);
expect(response.status).not.toBe(200);
});
it('should use metadata filename when different from upload filename', async () => {
const account = await createTestAccount(harness);
const guild = await createGuild(harness, account.token, 'Filename Mismatch Test Guild');
const channel = await createChannel(harness, account.token, guild.id, 'test-channel');
const channelId = guild.system_channel_id ?? channel.id;
const fileData = Buffer.from('test content');
const payload = {
content: 'Filename mismatch test',
attachments: [{id: 0, filename: 'expected.txt'}],
};
const {response, text, json} = await sendMessageWithAttachments(harness, account.token, channelId, payload, [
{index: 0, filename: 'different.txt', data: fileData},
]);
if (response.status !== 200) {
console.log('Error response:', text);
}
expect(response.status).toBe(200);
expect(json.attachments).toBeDefined();
expect(json.attachments).not.toBeNull();
expect(json.attachments!).toHaveLength(1);
expect(json.attachments![0].filename).toBe('expected.txt');
});
});
describe('special characters', () => {
let channelId: string;
let accountToken: string;
beforeEach(async () => {
const account = await createTestAccount(harness);
accountToken = account.token;
const guild = await createGuild(harness, account.token, 'Special Chars Test Guild');
const channel = await createChannel(harness, account.token, guild.id, 'test-channel');
channelId = guild.system_channel_id ?? channel.id;
});
const testCases = [
{filename: 'file<script>.txt', shouldSanitize: true, description: 'HTML tag in filename'},
{filename: 'file>output.txt', shouldSanitize: true, description: 'redirect operator'},
{filename: 'file|pipe.txt', shouldSanitize: true, description: 'pipe character'},
{filename: 'file:colon.txt', shouldSanitize: true, description: 'colon (Windows reserved)'},
{filename: 'file*star.txt', shouldSanitize: true, description: 'asterisk (wildcard)'},
{filename: 'file?question.txt', shouldSanitize: true, description: 'question mark'},
{filename: 'file"quote.txt', shouldSanitize: true, description: 'double quote'},
{filename: 'COM1.txt', shouldSanitize: true, description: 'Windows reserved name'},
{filename: 'LPT1.txt', shouldSanitize: true, description: 'Windows reserved name'},
{filename: 'file with spaces.txt', shouldSanitize: false, description: 'spaces should be OK'},
{
filename: 'file-dash_underscore.txt',
shouldSanitize: false,
description: 'dash and underscore OK',
},
{filename: 'file.multiple.dots.txt', shouldSanitize: false, description: 'multiple dots OK'},
{filename: 'файл.txt', shouldSanitize: false, description: 'unicode characters OK'},
{filename: '文件.txt', shouldSanitize: false, description: 'CJK characters OK'},
{filename: '😀.txt', shouldSanitize: false, description: 'emoji OK'},
];
for (const tc of testCases) {
it(`should handle ${tc.description}`, async () => {
const fileData = Buffer.from('test content');
const payload = {
content: `Special char test: ${tc.description}`,
attachments: [
{
id: 0,
filename: tc.filename,
},
],
};
const {response, json} = await sendMessageWithAttachments(harness, accountToken, channelId, payload, [
{index: 0, filename: 'test.txt', data: fileData},
]);
expect(response.status).toBe(200);
expect(json.attachments).toBeDefined();
expect(json.attachments).not.toBeNull();
expect(json.attachments!).toHaveLength(1);
const sanitized = json.attachments![0].filename;
if (tc.shouldSanitize) {
const dangerousChars = ['<', '>', ':', '"', '|', '?', '*'];
for (const char of dangerousChars) {
expect(sanitized).not.toContain(char);
}
}
});
}
});
describe('path traversal', () => {
const testCases = [
{input: '../../../etc/passwd', expectedSuffix: 'passwd'},
{input: '..\\\\..\\\\..\\\\windows\\\\system32\\\\config\\\\sam', expectedSuffix: 'sam'},
{input: '....//....//....//etc/passwd', expectedSuffix: 'passwd'},
{input: '..\\\\..\\\\..\\\\', expectedSuffix: ''},
{input: '../../sensitive.txt', expectedSuffix: 'sensitive.txt'},
{input: './../../etc/hosts', expectedSuffix: 'hosts'},
];
for (const tc of testCases) {
it(`should sanitize path traversal in filename: ${tc.input}`, async () => {
const account = await createTestAccount(harness);
const guild = await createGuild(harness, account.token, 'Path Traversal Test Guild');
const channel = await createChannel(harness, account.token, guild.id, 'test-channel');
const channelId = guild.system_channel_id ?? channel.id;
const fileData = Buffer.from('test content');
const payload = {
content: 'Path traversal test',
attachments: [{id: 0, filename: tc.input}],
};
const {response, json} = await sendMessageWithAttachments(harness, account.token, channelId, payload, [
{index: 0, filename: tc.input, data: fileData},
]);
expect(response.status).toBe(200);
expect(json.attachments).toBeDefined();
expect(json.attachments).not.toBeNull();
expect(json.attachments!).toHaveLength(1);
const sanitized = json.attachments![0].filename;
expect(sanitized).not.toContain('..');
expect(sanitized).not.toContain('/');
expect(sanitized).not.toContain('\\');
});
}
});
});
describe('Metadata Validation', () => {
describe('id matching', () => {
it('should reject negative IDs in metadata', async () => {
const account = await createTestAccount(harness);
const guild = await createGuild(harness, account.token, 'Negative ID Test Guild');
const channel = await createChannel(harness, account.token, guild.id, 'test-channel');
const channelId = guild.system_channel_id ?? channel.id;
const fileData = Buffer.from('test content');
const payload = {
content: 'Negative ID test',
attachments: [{id: -1, filename: 'test.txt'}],
};
const {response} = await sendMessageWithAttachments(harness, account.token, channelId, payload, [
{index: 0, filename: 'test.txt', data: fileData},
]);
expect(response.status).toBe(400);
});
it('should reject mismatched IDs between metadata and files', async () => {
const account = await createTestAccount(harness);
const guild = await createGuild(harness, account.token, 'ID Mismatch Test Guild');
const channel = await createChannel(harness, account.token, guild.id, 'test-channel');
const channelId = guild.system_channel_id ?? channel.id;
const fileData = Buffer.from('test content');
const payload = {
content: 'ID mismatch test',
attachments: [{id: 5, filename: 'test.txt'}],
};
const {response} = await sendMessageWithAttachments(harness, account.token, channelId, payload, [
{index: 0, filename: 'test.txt', data: fileData},
]);
expect(response.status).toBe(400);
});
it('should correctly match files with metadata when sent in natural order', async () => {
const account = await createTestAccount(harness);
const guild = await createGuild(harness, account.token, 'ID Matching Test Guild');
const channel = await createChannel(harness, account.token, guild.id, 'test-channel');
const channelId = guild.system_channel_id ?? channel.id;
const file1Data = loadFixture('yeah.png');
const file2Data = loadFixture('thisisfine.gif');
const payload = {
content: 'Ordered files test',
attachments: [
{id: 0, filename: 'yeah.png', description: 'First file', title: 'First'},
{id: 1, filename: 'thisisfine.gif', description: 'Second file', title: 'Second'},
],
};
const {response, json} = await sendMessageWithAttachments(harness, account.token, channelId, payload, [
{index: 0, filename: 'yeah.png', data: file1Data},
{index: 1, filename: 'thisisfine.gif', data: file2Data},
]);
expect(response.status).toBe(200);
expect(json.attachments).toBeDefined();
expect(json.attachments).not.toBeNull();
expect(json.attachments!).toHaveLength(2);
expect(json.attachments![0].filename).toBe('yeah.png');
expect(json.attachments![0].description).toBe('First file');
expect(json.attachments![0].title).toBe('First');
expect(json.attachments![1].filename).toBe('thisisfine.gif');
expect(json.attachments![1].description).toBe('Second file');
expect(json.attachments![1].title).toBe('Second');
});
it('should handle non-sequential IDs like 2, 5', async () => {
const account = await createTestAccount(harness);
const guild = await createGuild(harness, account.token, 'Sparse IDs Test Guild');
const channel = await createChannel(harness, account.token, guild.id, 'test-channel');
const channelId = guild.system_channel_id ?? channel.id;
const file1Data = loadFixture('yeah.png');
const file2Data = loadFixture('thisisfine.gif');
const payload = {
content: 'Sparse IDs test',
attachments: [
{id: 2, filename: 'yeah.png', description: 'ID is 2', title: 'Two'},
{id: 5, filename: 'thisisfine.gif', description: 'ID is 5', title: 'Five'},
],
};
const {response, json} = await sendMessageWithAttachments(harness, account.token, channelId, payload, [
{index: 2, filename: 'yeah.png', data: file1Data},
{index: 5, filename: 'thisisfine.gif', data: file2Data},
]);
expect(response.status).toBe(200);
expect(json.attachments).toBeDefined();
expect(json.attachments).not.toBeNull();
expect(json.attachments!).toHaveLength(2);
expect(json.attachments![0].description).toBe('ID is 2');
expect(json.attachments![0].title).toBe('Two');
expect(json.attachments![1].description).toBe('ID is 5');
expect(json.attachments![1].title).toBe('Five');
});
});
describe('metadata requirements', () => {
it('should reject file upload without attachment metadata', async () => {
const account = await createTestAccount(harness);
const guild = await createGuild(harness, account.token, 'Without Metadata Test Guild');
const channel = await createChannel(harness, account.token, guild.id, 'test-channel');
const channelId = guild.system_channel_id ?? channel.id;
const fileData = loadFixture('yeah.png');
const payload = {
content: 'File without metadata',
};
const {response} = await sendMessageWithAttachments(harness, account.token, channelId, payload, [
{index: 0, filename: 'yeah.png', data: fileData},
]);
expect(response.status).toBe(400);
});
it('should reject attachment metadata without corresponding file', async () => {
const account = await createTestAccount(harness);
const guild = await createGuild(harness, account.token, 'Metadata Without File Test Guild');
const channel = await createChannel(harness, account.token, guild.id, 'test-channel');
const channelId = guild.system_channel_id ?? channel.id;
const payload = {
content: 'Metadata without file',
attachments: [{id: 0, filename: 'missing.png', description: 'This file does not exist'}],
};
const {response} = await sendMessageWithAttachments(harness, account.token, channelId, payload, []);
expect(response.status).toBe(400);
});
});
describe('title and description', () => {
it('should preserve title and description fields through upload flow', async () => {
const account = await createTestAccount(harness);
const guild = await createGuild(harness, account.token, 'Title Description Test Guild');
const channel = await createChannel(harness, account.token, guild.id, 'test-channel');
const channelId = guild.system_channel_id ?? channel.id;
const fileData = loadFixture('yeah.png');
const payload = {
content: 'Testing title and description',
attachments: [
{
id: 0,
filename: 'yeah.png',
title: 'My Awesome Title',
description: 'This is a detailed description of the attachment with special chars: émoji 🎉',
},
],
};
const {response, json} = await sendMessageWithAttachments(harness, account.token, channelId, payload, [
{index: 0, filename: 'yeah.png', data: fileData},
]);
expect(response.status).toBe(200);
expect(json.attachments).toBeDefined();
expect(json.attachments).not.toBeNull();
expect(json.attachments!).toHaveLength(1);
const att = json.attachments![0];
expect(att.title).toBe('My Awesome Title');
const expectedDesc = 'This is a detailed description of the attachment with special chars: émoji 🎉';
expect(att.description).toBe(expectedDesc);
});
});
});
describe('Flag Preservation', () => {
it('should preserve attachment flags through upload flow', async () => {
const account = await createTestAccount(harness);
const guild = await createGuild(harness, account.token, 'Flags Preserved Test Guild');
const channel = await createChannel(harness, account.token, guild.id, 'test-channel');
const channelId = guild.system_channel_id ?? channel.id;
const fileData = loadFixture('yeah.png');
const payload = {
content: 'Flags preservation test',
attachments: [
{
id: 0,
filename: 'spoiler.png',
flags: 8,
},
],
};
const {response, json} = await sendMessageWithAttachments(harness, account.token, channelId, payload, [
{index: 0, filename: 'spoiler.png', data: fileData},
]);
expect(response.status).toBe(200);
expect(json.attachments).toBeDefined();
expect(json.attachments).not.toBeNull();
expect(json.attachments!).toHaveLength(1);
expect(json.attachments![0].flags).toBe(8);
});
});
describe('Multiple File Upload', () => {
it('should handle multiple files with mixed metadata quality', async () => {
const account = await createTestAccount(harness);
const guild = await createGuild(harness, account.token, 'Mixed Metadata Test Guild');
const channel = await createChannel(harness, account.token, guild.id, 'test-channel');
const channelId = guild.system_channel_id ?? channel.id;
const file1Data = loadFixture('yeah.png');
const file2Data = loadFixture('thisisfine.gif');
const payload = {
content: 'Mixed metadata test',
attachments: [
{
id: 0,
filename: 'yeah.png',
title: 'Full Metadata',
description: 'Complete description',
flags: 0,
},
{
id: 1,
filename: 'thisisfine.gif',
},
],
};
const {response, json} = await sendMessageWithAttachments(harness, account.token, channelId, payload, [
{index: 0, filename: 'yeah.png', data: file1Data},
{index: 1, filename: 'thisisfine.gif', data: file2Data},
]);
expect(response.status).toBe(200);
expect(json.attachments).toBeDefined();
expect(json.attachments).not.toBeNull();
expect(json.attachments!).toHaveLength(2);
expect(json.attachments![0].title).toBe('Full Metadata');
expect(json.attachments![0].description).toBe('Complete description');
expect(json.attachments![1].title).toBeNull();
expect(json.attachments![1].description).toBeNull();
});
});
});

View File

@@ -0,0 +1,116 @@
/*
* 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} from '@fluxer/api/src/channel/tests/ChannelTestUtils';
import {type ApiTestHarness, createApiTestHarness} from '@fluxer/api/src/test/ApiTestHarness';
import {createBuilder} from '@fluxer/api/src/test/TestRequestBuilder';
import {afterAll, beforeAll, beforeEach, describe, it} from 'vitest';
describe('Bulk Delete Messages', () => {
let harness: ApiTestHarness;
beforeAll(async () => {
harness = await createApiTestHarness();
});
beforeEach(async () => {
await harness.reset();
});
afterAll(async () => {
await harness?.shutdown();
});
it('rejects empty message_ids array', async () => {
const owner = await createTestAccount(harness);
const guild = await createGuild(harness, owner.token, 'Validation Test Guild');
if (!guild.system_channel_id) {
throw new Error('Guild should have a system channel');
}
await createBuilder(harness, owner.token)
.post(`/channels/${guild.system_channel_id}/messages/bulk-delete`)
.body({message_ids: []})
.expect(400)
.execute();
});
it('rejects more than 100 messages', async () => {
const owner = await createTestAccount(harness);
const guild = await createGuild(harness, owner.token, 'Validation Test Guild');
if (!guild.system_channel_id) {
throw new Error('Guild should have a system channel');
}
const tooManyIds: Array<string> = [];
for (let i = 0; i < 101; i++) {
tooManyIds.push(`${1000000000000000000n + BigInt(i)}`);
}
await createBuilder(harness, owner.token)
.post(`/channels/${guild.system_channel_id}/messages/bulk-delete`)
.body({message_ids: tooManyIds})
.expect(400)
.execute();
});
it('rejects bulk delete without MANAGE_MESSAGES permission when channel does not exist', async () => {
const owner = await createTestAccount(harness);
const member = await createTestAccount(harness);
const guild = await createGuild(harness, owner.token, 'Permissions Test Guild');
if (!guild.system_channel_id) {
throw new Error('Guild should have a system channel');
}
const messageIds: Array<string> = [];
for (let i = 0; i < 3; i++) {
messageIds.push(`${1000000000000000000n + BigInt(i)}`);
}
await createBuilder(harness, member.token)
.post(`/channels/${guild.system_channel_id}/messages/bulk-delete`)
.body({message_ids: messageIds})
.expect(403)
.execute();
});
it('accepts bulk delete request with valid message IDs (does not require messages to exist)', async () => {
const owner = await createTestAccount(harness);
const guild = await createGuild(harness, owner.token, 'Bulk Delete Test Guild');
if (!guild.system_channel_id) {
throw new Error('Guild should have a system channel');
}
const messageIds: Array<string> = [];
for (let i = 0; i < 5; i++) {
messageIds.push(`${1000000000000000000n + BigInt(i)}`);
}
await createBuilder(harness, owner.token)
.post(`/channels/${guild.system_channel_id}/messages/bulk-delete`)
.body({message_ids: messageIds})
.expect(204)
.execute();
});
});

View File

@@ -0,0 +1,188 @@
/*
* 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 {createDmChannel, createGuild} from '@fluxer/api/src/channel/tests/ChannelTestUtils';
import {type ApiTestHarness, createApiTestHarness} from '@fluxer/api/src/test/ApiTestHarness';
import {createBuilder} from '@fluxer/api/src/test/TestRequestBuilder';
import type {GuildInviteMetadataResponse} from '@fluxer/schema/src/domains/invite/InviteSchemas';
import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest';
describe('Call Endpoints', () => {
let harness: ApiTestHarness;
beforeAll(async () => {
harness = await createApiTestHarness();
});
beforeEach(async () => {
await harness.reset();
});
afterAll(async () => {
await harness?.shutdown();
});
async function setupUsersWithMutualGuild() {
const user1 = await createTestAccount(harness);
const user2 = await createTestAccount(harness);
const guild = await createGuild(harness, user1.token, 'Test Guild');
const invite = await createBuilder<GuildInviteMetadataResponse>(harness, user1.token)
.post(`/channels/${guild.system_channel_id}/invites`)
.body({})
.execute();
await createBuilder(harness, user2.token).post(`/invites/${invite.code}`).body(null).execute();
return {user1, user2};
}
describe('GET /channels/:channel_id/call', () => {
it('returns call eligibility for DM channel when voice is disabled', async () => {
const {user1, user2} = await setupUsersWithMutualGuild();
const dmChannel = await createDmChannel(harness, user1.token, user2.userId);
const callData = await createBuilder<{ringable: boolean}>(harness, user1.token)
.get(`/channels/${dmChannel.id}/call`)
.execute();
expect(callData).toHaveProperty('ringable');
expect(typeof callData.ringable).toBe('boolean');
});
it('returns call eligibility for non-existent channel', async () => {
const user = await createTestAccount(harness);
await createBuilder(harness, user.token).get('/channels/999999999999999999/call').expect(404).execute();
});
});
describe('PATCH /channels/:channel_id/call', () => {
it('updates call region for DM channel', async () => {
const {user1, user2} = await setupUsersWithMutualGuild();
const dmChannel = await createDmChannel(harness, user1.token, user2.userId);
await createBuilder(harness, user1.token)
.patch(`/channels/${dmChannel.id}/call`)
.body({region: 'us-west'})
.expect(404)
.execute();
});
it('returns error when updating non-existent call', async () => {
const {user1, user2} = await setupUsersWithMutualGuild();
const dmChannel = await createDmChannel(harness, user1.token, user2.userId);
await createBuilder(harness, user1.token).patch(`/channels/${dmChannel.id}/call`).body({}).expect(404).execute();
});
});
describe('POST /channels/:channel_id/call/ring', () => {
it('rings call recipients for DM channel', async () => {
const {user1, user2} = await setupUsersWithMutualGuild();
const dmChannel = await createDmChannel(harness, user1.token, user2.userId);
await createBuilder(harness, user1.token)
.post(`/channels/${dmChannel.id}/call/ring`)
.body({recipients: [user2.userId]})
.expect(204)
.execute();
});
it('creates silent call without ringing when recipients is empty array (shift-click)', async () => {
const {user1, user2} = await setupUsersWithMutualGuild();
const dmChannel = await createDmChannel(harness, user1.token, user2.userId);
await createBuilder(harness, user1.token)
.post(`/channels/${dmChannel.id}/call/ring`)
.body({recipients: []})
.expect(204)
.execute();
});
it('rings call without specifying recipients for DM channel', async () => {
const {user1, user2} = await setupUsersWithMutualGuild();
const dmChannel = await createDmChannel(harness, user1.token, user2.userId);
await createBuilder(harness, user1.token)
.post(`/channels/${dmChannel.id}/call/ring`)
.body({})
.expect(204)
.execute();
});
it('rejects ringing for non-existent channel', async () => {
const {user1, user2} = await setupUsersWithMutualGuild();
await createBuilder(harness, user1.token)
.post('/channels/123456789/call/ring')
.body({recipients: [user2.userId]})
.expect(404)
.execute();
});
});
describe('POST /channels/:channel_id/call/stop-ringing', () => {
it('returns error when stopping ringing for non-existent call', async () => {
const {user1, user2} = await setupUsersWithMutualGuild();
const dmChannel = await createDmChannel(harness, user1.token, user2.userId);
await createBuilder(harness, user1.token)
.post(`/channels/${dmChannel.id}/call/stop-ringing`)
.body({recipients: [user2.userId]})
.expect(404)
.execute();
});
it('returns error when stopping ringing all for non-existent call', async () => {
const {user1, user2} = await setupUsersWithMutualGuild();
const dmChannel = await createDmChannel(harness, user1.token, user2.userId);
await createBuilder(harness, user1.token)
.post(`/channels/${dmChannel.id}/call/stop-ringing`)
.body({})
.expect(404)
.execute();
});
});
describe('POST /channels/:channel_id/call/end', () => {
it('ends call for DM channel', async () => {
const {user1, user2} = await setupUsersWithMutualGuild();
const dmChannel = await createDmChannel(harness, user1.token, user2.userId);
await createBuilder(harness, user1.token)
.post(`/channels/${dmChannel.id}/call/end`)
.body(null)
.expect(204)
.execute();
});
});
});

View File

@@ -0,0 +1,137 @@
/*
* 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,
createChannel,
createChannelInvite,
createGuild,
deleteChannel,
getChannel,
updateChannel,
} from '@fluxer/api/src/channel/tests/ChannelTestUtils';
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('Channel Operation Permissions', () => {
let harness: ApiTestHarness;
beforeAll(async () => {
harness = await createApiTestHarness();
});
beforeEach(async () => {
await harness.reset();
});
it('should allow member to get channel', async () => {
const owner = await createTestAccount(harness);
const member = await createTestAccount(harness);
const guild = await createGuild(harness, owner.token, 'Channel Perms 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 channel = await getChannel(harness, member.token, systemChannel.id);
expect(channel.id).toBe(systemChannel.id);
});
it('should reject nonmember from getting channel', async () => {
const owner = await createTestAccount(harness);
const member = await createTestAccount(harness);
const nonmember = await createTestAccount(harness);
const guild = await createGuild(harness, owner.token, 'Channel Perms 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(`/channels/${systemChannel.id}`)
.expect(HTTP_STATUS.FORBIDDEN)
.execute();
});
it('should reject member from updating channel without MANAGE_CHANNELS', async () => {
const owner = await createTestAccount(harness);
const member = await createTestAccount(harness);
const guild = await createGuild(harness, owner.token, 'Channel Perms 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(`/channels/${systemChannel.id}`)
.body({name: 'hacked'})
.expect(HTTP_STATUS.FORBIDDEN)
.execute();
});
it('should reject member from deleting channel without MANAGE_CHANNELS', async () => {
const owner = await createTestAccount(harness);
const member = await createTestAccount(harness);
const guild = await createGuild(harness, owner.token, 'Channel Perms 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)
.delete(`/channels/${systemChannel.id}`)
.expect(HTTP_STATUS.FORBIDDEN)
.execute();
});
it('should allow owner to update channel', async () => {
const owner = await createTestAccount(harness);
const guild = await createGuild(harness, owner.token, 'Channel Perms Guild');
const testChannel = await createChannel(harness, owner.token, guild.id, 'test-channel');
const updated = await updateChannel(harness, owner.token, testChannel.id, {name: 'owner-updated'});
expect(updated.name).toBe('owner-updated');
});
it('should allow owner to delete channel', async () => {
const owner = await createTestAccount(harness);
const guild = await createGuild(harness, owner.token, 'Channel Perms Guild');
const testChannel = await createChannel(harness, owner.token, guild.id, 'test-channel');
await deleteChannel(harness, owner.token, testChannel.id);
await createBuilder(harness, owner.token)
.get(`/channels/${testChannel.id}`)
.expect(HTTP_STATUS.NOT_FOUND)
.execute();
});
});

View File

@@ -0,0 +1,113 @@
/*
* 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,
createGuild,
deleteChannel,
getChannel,
updateChannel,
} from '@fluxer/api/src/channel/tests/ChannelTestUtils';
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('Channel Operation Validation', () => {
let harness: ApiTestHarness;
beforeAll(async () => {
harness = await createApiTestHarness();
});
beforeEach(async () => {
await harness.reset();
});
it('should reject getting nonexistent channel', async () => {
const account = await createTestAccount(harness);
await createBuilder(harness, account.token)
.get('/channels/999999999999999999')
.expect(HTTP_STATUS.NOT_FOUND)
.execute();
});
it('should reject updating nonexistent channel', async () => {
const account = await createTestAccount(harness);
await createBuilder(harness, account.token)
.patch('/channels/999999999999999999')
.body({name: 'new-name'})
.expect(HTTP_STATUS.NOT_FOUND)
.execute();
});
it('should reject deleting nonexistent channel', async () => {
const account = await createTestAccount(harness);
await createBuilder(harness, account.token)
.delete('/channels/999999999999999999')
.expect(HTTP_STATUS.NOT_FOUND)
.execute();
});
it('should get channel successfully', async () => {
const account = await createTestAccount(harness);
const guild = await createGuild(harness, account.token, 'Channel Operation Guild');
const channel = await getChannel(harness, account.token, guild.system_channel_id!);
expect(channel.id).toBe(guild.system_channel_id);
});
it('should update channel name successfully', async () => {
const account = await createTestAccount(harness);
const guild = await createGuild(harness, account.token, 'Channel Operation Guild');
const channelId = guild.system_channel_id!;
const updated = await updateChannel(harness, account.token, channelId, {name: 'updated-name'});
expect(updated.name).toBe('updated-name');
});
it('should update channel topic successfully', async () => {
const account = await createTestAccount(harness);
const guild = await createGuild(harness, account.token, 'Channel Operation Guild');
const channelId = guild.system_channel_id!;
const updated = await updateChannel(harness, account.token, channelId, {topic: 'New topic'});
expect(updated.topic).toBe('New topic');
});
it('should delete channel successfully', async () => {
const account = await createTestAccount(harness);
const guild = await createGuild(harness, account.token, 'Channel Operation Guild');
const newChannel = await createChannel(harness, account.token, guild.id, 'to-delete');
await deleteChannel(harness, account.token, newChannel.id);
await createBuilder(harness, account.token)
.get(`/channels/${newChannel.id}`)
.expect(HTTP_STATUS.NOT_FOUND)
.execute();
});
});

View File

@@ -0,0 +1,252 @@
/*
* 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,
createGuild,
createPermissionOverwrite,
createRole,
getChannel,
setupTestGuildWithMembers,
} from '@fluxer/api/src/channel/tests/ChannelTestUtils';
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('Channel Permission Overwrites', () => {
let harness: ApiTestHarness;
beforeEach(async () => {
harness = await createApiTestHarness();
});
afterEach(async () => {
await harness?.shutdown();
});
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'});
const overwrite = await createPermissionOverwrite(harness, account.token, channel.id, role.id, {
type: 0,
allow: Permissions.SEND_MESSAGES.toString(),
deny: '0',
});
expect(overwrite.id).toBe(role.id);
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];
const overwrite = await createPermissionOverwrite(harness, owner.token, systemChannel.id, member.userId, {
type: 1,
allow: Permissions.VIEW_CHANNEL.toString(),
deny: Permissions.SEND_MESSAGES.toString(),
});
expect(overwrite.id).toBe(member.userId);
expect(overwrite.type).toBe(1);
});
test('should deny permission via overwrite', async () => {
const {owner, members, systemChannel} = await setupTestGuildWithMembers(harness, 1);
const member = members[0];
await createPermissionOverwrite(harness, owner.token, systemChannel.id, member.userId, {
type: 1,
allow: '0',
deny: Permissions.SEND_MESSAGES.toString(),
});
await createBuilder(harness, member.token)
.post(`/channels/${systemChannel.id}/messages`)
.body({content: 'Test message'})
.expect(HTTP_STATUS.FORBIDDEN)
.execute();
});
test('should allow permission via overwrite', async () => {
const {owner, members, systemChannel} = await setupTestGuildWithMembers(harness, 1);
const member = members[0];
await createPermissionOverwrite(harness, owner.token, systemChannel.id, member.userId, {
type: 1,
allow: Permissions.SEND_MESSAGES.toString(),
deny: '0',
});
await createBuilder(harness, member.token)
.post(`/channels/${systemChannel.id}/messages`)
.body({content: 'Test message'})
.execute();
});
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 createPermissionOverwrite(harness, account.token, channel.id, role.id, {
type: 0,
allow: Permissions.SEND_MESSAGES.toString(),
deny: '0',
});
const updated = await createPermissionOverwrite(harness, account.token, channel.id, role.id, {
type: 0,
allow: (Permissions.SEND_MESSAGES | Permissions.EMBED_LINKS).toString(),
deny: '0',
});
expect(BigInt(updated.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 createPermissionOverwrite(harness, account.token, channel.id, role.id, {
type: 0,
allow: Permissions.SEND_MESSAGES.toString(),
deny: '0',
});
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 require MANAGE_ROLES 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();
});
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 createPermissionOverwrite(harness, account.token, channel.id, role.id, {
type: 0,
allow: Permissions.SEND_MESSAGES.toString(),
deny: '0',
});
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 prioritize member overwrite over role overwrite', async () => {
const {owner, members, guild, systemChannel} = await setupTestGuildWithMembers(harness, 1);
const member = members[0];
const role = await createRole(harness, owner.token, guild.id, {name: 'Deny Role'});
await createBuilder<void>(harness, owner.token)
.put(`/guilds/${guild.id}/members/${member.userId}/roles/${role.id}`)
.expect(HTTP_STATUS.NO_CONTENT)
.execute();
await createPermissionOverwrite(harness, owner.token, systemChannel.id, role.id, {
type: 0,
allow: '0',
deny: Permissions.SEND_MESSAGES.toString(),
});
await createPermissionOverwrite(harness, owner.token, systemChannel.id, member.userId, {
type: 1,
allow: Permissions.SEND_MESSAGES.toString(),
deny: '0',
});
await createBuilder(harness, member.token)
.post(`/channels/${systemChannel.id}/messages`)
.body({content: 'Test message'})
.execute();
});
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 handle multiple overlapping role overwrites', 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 role1 = await createRole(harness, account.token, guild.id, {name: 'Role 1'});
const role2 = await createRole(harness, account.token, guild.id, {name: 'Role 2'});
await createPermissionOverwrite(harness, account.token, channel.id, role1.id, {
type: 0,
allow: Permissions.SEND_MESSAGES.toString(),
deny: '0',
});
await createPermissionOverwrite(harness, account.token, channel.id, role2.id, {
type: 0,
allow: Permissions.EMBED_LINKS.toString(),
deny: '0',
});
const channelData = await getChannel(harness, account.token, channel.id);
expect(channelData.permission_overwrites?.length).toBeGreaterThanOrEqual(2);
});
});

View File

@@ -0,0 +1,415 @@
/*
* 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 {ensureSessionStarted} from '@fluxer/api/src/message/tests/MessageTestUtils';
import type {ApiTestHarness} from '@fluxer/api/src/test/ApiTestHarness';
import {createBuilder} from '@fluxer/api/src/test/TestRequestBuilder';
import type {ChannelOverwriteResponse, 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';
import type {MessageResponse} from '@fluxer/schema/src/domains/message/MessageResponseSchemas';
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 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<
Pick<ChannelResponse, 'name' | 'type' | 'topic' | 'parent_id' | 'position' | 'rate_limit_per_user' | 'nsfw'>
>,
): 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> {
return createBuilder<void>(harness, token).delete(`/channels/${channelId}`).expect(204).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 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 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> {
return createBuilder<void>(harness, token).delete(`/guilds/${guildId}/roles/${roleId}`).expect(204).execute();
}
export async function addMemberRole(
harness: ApiTestHarness,
token: string,
guildId: string,
userId: string,
roleId: string,
): Promise<void> {
return createBuilder<void>(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> {
return createBuilder<void>(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 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> {
return createBuilder<void>(harness, token).delete(`/users/@me/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 createPermissionOverwrite(
harness: ApiTestHarness,
token: string,
channelId: string,
overwriteId: string,
overwrite: Omit<ChannelOverwriteResponse, 'id'>,
): Promise<ChannelOverwriteResponse> {
await createBuilder<void>(harness, token)
.put(`/channels/${channelId}/permissions/${overwriteId}`)
.body({
type: overwrite.type,
allow: overwrite.allow,
deny: overwrite.deny,
})
.expect(204)
.execute();
return {
id: overwriteId,
type: overwrite.type,
allow: overwrite.allow,
deny: overwrite.deny,
};
}
export async function setupTestGuildWithChannels(
harness: ApiTestHarness,
account?: TestAccount,
): Promise<{
account: TestAccount;
guild: GuildResponse;
systemChannel: ChannelResponse;
}> {
const testAccount = account ?? (await createTestAccount(harness));
const guild = await createGuild(harness, testAccount.token, 'Test Guild');
const systemChannel = await getChannel(harness, testAccount.token, guild.system_channel_id!);
return {account: testAccount, guild, systemChannel};
}
export async function setupTestGuildWithMembers(
harness: ApiTestHarness,
memberCount = 2,
): Promise<{
owner: TestAccount;
members: Array<TestAccount>;
guild: GuildResponse;
systemChannel: 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 systemChannel = await getChannel(harness, owner.token, guild.system_channel_id!);
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, systemChannel};
}
export interface MinimalChannelResponse {
id: string;
type: number;
recipients?: Array<{id: string; username: string}>;
}
export async function createFriendship(harness: ApiTestHarness, user1: TestAccount, user2: TestAccount): Promise<void> {
await createBuilder<unknown>(harness, user1.token)
.post(`/users/@me/relationships/${user2.userId}`)
.body(null)
.execute();
await createBuilder<unknown>(harness, user2.token).put(`/users/@me/relationships/${user1.userId}`).body({}).execute();
}
export async function createDmChannel(
harness: ApiTestHarness,
token: string,
recipientId: string,
): Promise<MinimalChannelResponse> {
const channel = await createBuilder<MinimalChannelResponse>(harness, token)
.post('/users/@me/channels')
.body({
recipient_id: recipientId,
})
.execute();
if (!channel.id) {
throw new Error('DM channel response missing id');
}
return channel;
}
export async function sendChannelMessage(
harness: ApiTestHarness,
token: string,
channelId: string,
content: string,
): Promise<MessageResponse> {
await ensureSessionStarted(harness, token);
const msg = await createBuilder<MessageResponse>(harness, token)
.post(`/channels/${channelId}/messages`)
.body({
content,
})
.execute();
if (!msg.id) {
throw new Error('Message response missing id');
}
return msg;
}
export async function blockUser(harness: ApiTestHarness, user: TestAccount, targetUserId: string): Promise<void> {
await createBuilder<unknown>(harness, user.token)
.put(`/users/@me/relationships/${targetUserId}`)
.body({
type: 2,
})
.execute();
}
export async function initiateCall(
harness: ApiTestHarness,
token: string,
channelId: string,
recipients: Array<string>,
expectedStatus: number = 204,
): Promise<{response: Response; json: unknown}> {
return createBuilder(harness, token)
.post(`/channels/${channelId}/call/ring`)
.body({recipients})
.expect(expectedStatus)
.executeWithResponse();
}
export async function pinMessage(
harness: ApiTestHarness,
token: string,
channelId: string,
messageId: string,
expectedStatus: number = 204,
): Promise<{response: Response; json: unknown}> {
return createBuilder(harness, token)
.put(`/channels/${channelId}/pins/${messageId}`)
.body(null)
.expect(expectedStatus)
.executeWithResponse();
}
export interface GroupDmChannelResponse {
id: string;
type: number;
name: string | null;
owner_id: string;
recipients: Array<{id: string; username: string}>;
}
export async function createGroupDmChannel(
harness: ApiTestHarness,
token: string,
recipientUserIds: Array<string>,
): Promise<GroupDmChannelResponse> {
const channel = await createBuilder<GroupDmChannelResponse>(harness, token)
.post('/users/@me/channels')
.body({
recipients: recipientUserIds,
})
.execute();
if (!channel.id) {
throw new Error('Group DM channel response missing id');
}
return channel;
}
export async function addRecipientToGroupDm(
harness: ApiTestHarness,
token: string,
channelId: string,
userId: string,
): Promise<void> {
await createBuilder(harness, token)
.put(`/channels/${channelId}/recipients/${userId}`)
.body(null)
.expect(204)
.execute();
}
export async function removeRecipientFromGroupDm(
harness: ApiTestHarness,
token: string,
channelId: string,
userId: string,
): Promise<void> {
await createBuilder(harness, token).delete(`/channels/${channelId}/recipients/${userId}`).expect(204).execute();
}
export interface SeedPrivateChannelsResult {
group_dms: Array<{channel_id: string; last_message_id: string}>;
dms: Array<{channel_id: string; last_message_id: string}>;
}
export async function seedPrivateChannels(
harness: ApiTestHarness,
_token: string,
userId: string,
params: {
group_dm_count?: number;
dm_count?: number;
recipients?: Array<string>;
clear_existing?: boolean;
},
): Promise<SeedPrivateChannelsResult> {
return createBuilder<SeedPrivateChannelsResult>(harness, '')
.post(`/test/users/${userId}/private-channels`)
.body(params)
.execute();
}

View File

@@ -0,0 +1,188 @@
/*
* 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,
blockUser,
createChannelInvite,
createDmChannel,
createFriendship,
createGuild,
getChannel,
initiateCall,
pinMessage,
sendChannelMessage,
} from '@fluxer/api/src/channel/tests/ChannelTestUtils';
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';
describe('DM Blocking Behaviors', () => {
let harness: ApiTestHarness;
beforeAll(async () => {
harness = await createApiTestHarness();
});
beforeEach(async () => {
await harness.reset();
});
afterAll(async () => {
await harness?.shutdown();
});
describe('DM Creation Blocking', () => {
it('prevents DM creation when the other user has blocked you, even with mutual guild', async () => {
const user1 = await createTestAccount(harness);
const user2 = await createTestAccount(harness);
const guild = await createGuild(harness, user1.token, 'Test Community');
const systemChannel = await getChannel(harness, user1.token, guild.system_channel_id!);
const invite = await createChannelInvite(harness, user1.token, systemChannel.id);
await acceptInvite(harness, user2.token, invite.code);
await blockUser(harness, user1, user2.userId);
await createBuilder(harness, user2.token)
.post('/users/@me/channels')
.body({recipient_id: user1.userId})
.expect(HTTP_STATUS.BAD_REQUEST)
.execute();
});
it('prevents DM creation with someone you have blocked, even with mutual guild', async () => {
const user1 = await createTestAccount(harness);
const user2 = await createTestAccount(harness);
const guild = await createGuild(harness, user1.token, 'Test Community');
const systemChannel = await getChannel(harness, user1.token, guild.system_channel_id!);
const invite = await createChannelInvite(harness, user1.token, systemChannel.id);
await acceptInvite(harness, user2.token, invite.code);
await blockUser(harness, user1, user2.userId);
await createBuilder(harness, user1.token)
.post('/users/@me/channels')
.body({recipient_id: user2.userId})
.expect(HTTP_STATUS.BAD_REQUEST)
.execute();
});
});
describe('Voice Call Blocking', () => {
it('prevents the user who blocked someone from initiating calls in the DM', async () => {
const user1 = await createTestAccount(harness);
const user2 = await createTestAccount(harness);
await createFriendship(harness, user1, user2);
const channel = await createDmChannel(harness, user1.token, user2.userId);
await blockUser(harness, user1, user2.userId);
const {response, json} = await initiateCall(harness, user1.token, channel.id, [user2.userId], 400);
expect(response.status).toBe(400);
expect((json as {code: string}).code).toBe('CANNOT_SEND_MESSAGES_TO_USER');
});
it('prevents calls in a DM after one user blocks the other', async () => {
const user1 = await createTestAccount(harness);
const user2 = await createTestAccount(harness);
await createFriendship(harness, user1, user2);
const channel = await createDmChannel(harness, user1.token, user2.userId);
await blockUser(harness, user1, user2.userId);
const {response, json} = await initiateCall(harness, user2.token, channel.id, [user1.userId], 400);
expect(response.status).toBe(400);
expect((json as {code: string}).code).toBe('CANNOT_SEND_MESSAGES_TO_USER');
});
});
describe('Pin Operation Blocking', () => {
it('prevents the user who blocked someone from pinning messages in the DM', async () => {
const user1 = await createTestAccount(harness);
const user2 = await createTestAccount(harness);
await ensureSessionStarted(harness, user1.token);
await ensureSessionStarted(harness, user2.token);
await createFriendship(harness, user1, user2);
const channel = await createDmChannel(harness, user1.token, user2.userId);
const msg = await sendChannelMessage(harness, user2.token, channel.id, 'message to pin');
await blockUser(harness, user1, user2.userId);
const {response, json} = await pinMessage(harness, user1.token, channel.id, msg.id, 400);
expect(response.status).toBe(400);
expect((json as {code: string}).code).toBe('CANNOT_SEND_MESSAGES_TO_USER');
});
it('prevents pinning messages in a DM after one user blocks the other', async () => {
const user1 = await createTestAccount(harness);
const user2 = await createTestAccount(harness);
await ensureSessionStarted(harness, user1.token);
await ensureSessionStarted(harness, user2.token);
await createFriendship(harness, user1, user2);
const channel = await createDmChannel(harness, user1.token, user2.userId);
const msg = await sendChannelMessage(harness, user1.token, channel.id, 'message to pin');
await blockUser(harness, user1, user2.userId);
const {response, json} = await pinMessage(harness, user2.token, channel.id, msg.id, 400);
expect(response.status).toBe(400);
expect((json as {code: string}).code).toBe('CANNOT_SEND_MESSAGES_TO_USER');
});
});
describe('Message Blocking', () => {
it('prevents messages in a DM after one user blocks the other', async () => {
const user1 = await createTestAccount(harness);
const user2 = await createTestAccount(harness);
await createFriendship(harness, user1, user2);
const channel = await createDmChannel(harness, user1.token, user2.userId);
await blockUser(harness, user1, user2.userId);
await createBuilder(harness, user2.token)
.post(`/channels/${channel.id}/messages`)
.body({content: 'hello'})
.expect(HTTP_STATUS.BAD_REQUEST, 'CANNOT_SEND_MESSAGES_TO_USER')
.execute();
});
});
});

View File

@@ -0,0 +1,74 @@
/*
* 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 {createDmChannel, createFriendship, deleteChannel} from '@fluxer/api/src/channel/tests/ChannelTestUtils';
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';
describe('DM channel management', () => {
let harness: ApiTestHarness;
beforeAll(async () => {
harness = await createApiTestHarness();
});
beforeEach(async () => {
await harness.reset();
});
afterAll(async () => {
await harness?.shutdown();
});
it('can create DM channel', async () => {
const user1 = await createTestAccount(harness);
const user2 = await createTestAccount(harness);
await createFriendship(harness, user1, user2);
const dm = await createDmChannel(harness, user1.token, user2.userId);
expect(dm.id).toBeTruthy();
expect(dm.id.length).toBeGreaterThan(0);
});
it('can get DM channel', async () => {
const user1 = await createTestAccount(harness);
const user2 = await createTestAccount(harness);
await createFriendship(harness, user1, user2);
const dm = await createDmChannel(harness, user1.token, user2.userId);
await createBuilder(harness, user1.token).get(`/channels/${dm.id}`).expect(HTTP_STATUS.OK).execute();
});
it('can close DM channel', async () => {
const user1 = await createTestAccount(harness);
const user2 = await createTestAccount(harness);
await createFriendship(harness, user1, user2);
const dm = await createDmChannel(harness, user1.token, user2.userId);
await deleteChannel(harness, user1.token, dm.id);
});
});

View File

@@ -0,0 +1,50 @@
/*
* 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 {createDmChannel, createFriendship} from '@fluxer/api/src/channel/tests/ChannelTestUtils';
import {type ApiTestHarness, createApiTestHarness} from '@fluxer/api/src/test/ApiTestHarness';
import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest';
describe('DM creation allowed with friendship', () => {
let harness: ApiTestHarness;
beforeAll(async () => {
harness = await createApiTestHarness();
});
beforeEach(async () => {
await harness.reset();
});
afterAll(async () => {
await harness?.shutdown();
});
it('allows friends to create DMs with each other', async () => {
const user1 = await createTestAccount(harness);
const user2 = await createTestAccount(harness);
await createFriendship(harness, user1, user2);
const dm = await createDmChannel(harness, user1.token, user2.userId);
expect(dm.id).toBeTruthy();
expect(dm.id.length).toBeGreaterThan(0);
});
});

View File

@@ -0,0 +1,59 @@
/*
* 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,
createDmChannel,
createGuild,
getChannel,
} from '@fluxer/api/src/channel/tests/ChannelTestUtils';
import {type ApiTestHarness, createApiTestHarness} from '@fluxer/api/src/test/ApiTestHarness';
import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest';
describe('DM creation allowed with mutual guild', () => {
let harness: ApiTestHarness;
beforeAll(async () => {
harness = await createApiTestHarness();
});
beforeEach(async () => {
await harness.reset();
});
afterAll(async () => {
await harness?.shutdown();
});
it('allows users sharing a guild to create DMs without being friends', async () => {
const user1 = await createTestAccount(harness);
const user2 = await createTestAccount(harness);
const guild = await createGuild(harness, user1.token, 'Test Community');
const systemChannel = await getChannel(harness, user1.token, guild.system_channel_id!);
const invite = await createChannelInvite(harness, user1.token, systemChannel.id);
await acceptInvite(harness, user2.token, invite.code);
const dm = await createDmChannel(harness, user1.token, user2.userId);
expect(dm.id).toBeTruthy();
expect(dm.id.length).toBeGreaterThan(0);
});
});

View File

@@ -0,0 +1,50 @@
/*
* 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 {createBuilder} from '@fluxer/api/src/test/TestRequestBuilder';
import {afterAll, beforeAll, beforeEach, describe, it} from 'vitest';
describe('DM creation requires friendship or mutual guild', () => {
let harness: ApiTestHarness;
beforeAll(async () => {
harness = await createApiTestHarness();
});
beforeEach(async () => {
await harness.reset();
});
afterAll(async () => {
await harness?.shutdown();
});
it('prevents DM creation with strangers who you do not share a guild with and are not friends with', async () => {
const user1 = await createTestAccount(harness);
const user2 = await createTestAccount(harness);
await createBuilder(harness, user1.token)
.post('/users/@me/channels')
.body({recipient_id: user2.userId})
.expect(400)
.execute();
});
});

View File

@@ -0,0 +1,845 @@
/*
* 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,
createGuild,
loadFixture,
sendMessageWithAttachments,
} from '@fluxer/api/src/channel/tests/AttachmentTestUtils';
import {type ApiTestHarness, createApiTestHarness} from '@fluxer/api/src/test/ApiTestHarness';
import {createBuilder} from '@fluxer/api/src/test/TestRequestBuilder';
import {MessageAttachmentFlags} from '@fluxer/constants/src/ChannelConstants';
import {beforeAll, beforeEach, describe, expect, it} from 'vitest';
describe('Embed Attachment URL Resolution', () => {
let harness: ApiTestHarness;
beforeAll(async () => {
harness = await createApiTestHarness();
});
beforeEach(async () => {
await harness.reset();
});
describe('Basic URL Resolution', () => {
it('should resolve attachment:// URLs in embed image field', async () => {
const account = await createTestAccount(harness);
const guild = await createGuild(harness, account.token, 'Embed Attachment Test Guild');
const channel = await createChannel(harness, account.token, guild.id, 'test-channel');
const channelId = guild.system_channel_id ?? channel.id;
const fileData = loadFixture('yeah.png');
const payload = {
content: 'Test message with embed',
attachments: [{id: 0, filename: 'yeah.png'}],
embeds: [
{
title: 'Test Embed',
description: 'This embed uses an attached image',
image: {url: 'attachment://yeah.png'},
},
],
};
const {response, json} = await sendMessageWithAttachments(harness, account.token, channelId, payload, [
{index: 0, filename: 'yeah.png', data: fileData},
]);
expect(response.status).toBe(200);
expect(json.content).toBe('Test message with embed');
expect(json.embeds).toBeDefined();
expect(json.embeds).toHaveLength(1);
const embed = json.embeds![0];
expect(embed.title).toBe('Test Embed');
expect(embed.image?.url).toBeTruthy();
expect(embed.image?.url).not.toContain('attachment://');
});
it('should resolve attachment:// URLs in embed thumbnail field', async () => {
const account = await createTestAccount(harness);
const guild = await createGuild(harness, account.token, 'Thumbnail Test Guild');
const channel = await createChannel(harness, account.token, guild.id, 'test-channel');
const channelId = guild.system_channel_id ?? channel.id;
const fileData = loadFixture('yeah.png');
const payload = {
content: 'Test with thumbnail',
attachments: [{id: 0, filename: 'yeah.png'}],
embeds: [
{
title: 'Thumbnail Test',
description: 'This embed uses a thumbnail',
thumbnail: {url: 'attachment://yeah.png'},
},
],
};
const {response, json} = await sendMessageWithAttachments(harness, account.token, channelId, payload, [
{index: 0, filename: 'yeah.png', data: fileData},
]);
expect(response.status).toBe(200);
expect(json.embeds).toBeDefined();
expect(json.embeds).toHaveLength(1);
const embed = json.embeds![0];
expect(embed.title).toBe('Thumbnail Test');
expect(embed.thumbnail?.url).toBeTruthy();
expect(embed.thumbnail?.url).not.toContain('attachment://');
});
});
describe('Image and Thumbnail Fields', () => {
it('should handle single embed using attachment:// for both image and thumbnail', async () => {
const account = await createTestAccount(harness);
const guild = await createGuild(harness, account.token, 'Both Fields Test Guild');
const channel = await createChannel(harness, account.token, guild.id, 'test-channel');
const channelId = guild.system_channel_id ?? channel.id;
const file1Data = loadFixture('yeah.png');
const file2Data = loadFixture('thisisfine.gif');
const payload = {
content: 'Both image and thumbnail',
attachments: [
{id: 0, filename: 'yeah.png'},
{id: 1, filename: 'thisisfine.gif'},
],
embeds: [
{
title: 'Complete Embed',
description: 'This embed uses both image and thumbnail',
image: {url: 'attachment://thisisfine.gif'},
thumbnail: {url: 'attachment://yeah.png'},
},
],
};
const {response, json} = await sendMessageWithAttachments(harness, account.token, channelId, payload, [
{index: 0, filename: 'yeah.png', data: file1Data},
{index: 1, filename: 'thisisfine.gif', data: file2Data},
]);
expect(response.status).toBe(200);
expect(json.embeds).toBeDefined();
expect(json.embeds).toHaveLength(1);
const embed = json.embeds![0];
expect(embed.image?.url).toBeTruthy();
expect(embed.thumbnail?.url).toBeTruthy();
expect(embed.image?.url).not.toContain('attachment://');
expect(embed.thumbnail?.url).not.toContain('attachment://');
});
it('should handle image and thumbnail in same embed from different files', async () => {
const account = await createTestAccount(harness);
const guild = await createGuild(harness, account.token, 'Image And Thumbnail Guild');
const channel = await createChannel(harness, account.token, guild.id, 'test-channel');
const channelId = guild.system_channel_id ?? channel.id;
const fileData = loadFixture('yeah.png');
const payload = {
content: 'Image and thumbnail from different attachments',
attachments: [
{id: 0, filename: 'main-image.png'},
{id: 1, filename: 'thumb-image.png'},
],
embeds: [
{
title: 'Dual Image Embed',
image: {url: 'attachment://main-image.png'},
thumbnail: {url: 'attachment://thumb-image.png'},
},
],
};
const {response, json} = await sendMessageWithAttachments(harness, account.token, channelId, payload, [
{index: 0, filename: 'main-image.png', data: fileData},
{index: 1, filename: 'thumb-image.png', data: fileData},
]);
expect(response.status).toBe(200);
expect(json.attachments).toBeDefined();
expect(json.attachments).not.toBeNull();
expect(json.attachments!).toHaveLength(2);
expect(json.embeds).toHaveLength(1);
const embed = json.embeds![0];
expect(embed.image?.url).not.toContain('attachment://');
expect(embed.thumbnail?.url).not.toContain('attachment://');
expect(embed.image?.url).not.toBe(embed.thumbnail?.url);
});
it('should handle mixed attachment:// and https:// URLs in embeds', async () => {
const account = await createTestAccount(harness);
const guild = await createGuild(harness, account.token, 'Mixed URLs Test Guild');
const channel = await createChannel(harness, account.token, guild.id, 'test-channel');
const channelId = guild.system_channel_id ?? channel.id;
const fileData = loadFixture('yeah.png');
const payload = {
content: 'Test message with mixed embed URLs',
attachments: [{id: 0, filename: 'local-image.png'}],
embeds: [
{
title: 'Mixed URL Embed',
description: 'This embed uses both attachment and external URLs',
image: {url: 'attachment://local-image.png'},
thumbnail: {url: 'https://example.com/external-image.png'},
},
],
};
const {response, json} = await sendMessageWithAttachments(harness, account.token, channelId, payload, [
{index: 0, filename: 'local-image.png', data: fileData},
]);
expect(response.status).toBe(200);
expect(json.embeds).toBeDefined();
expect(json.embeds).toHaveLength(1);
const embed = json.embeds![0];
expect(embed.image?.url).toBeTruthy();
expect(embed.image?.url).not.toContain('attachment://');
expect(embed.thumbnail?.url).toBe('https://example.com/external-image.png');
});
it('should resolve attachment:// URL while preserving external thumbnail URL', async () => {
const account = await createTestAccount(harness);
const guild = await createGuild(harness, account.token, 'Preserve External URL Guild');
const channel = await createChannel(harness, account.token, guild.id, 'test-channel');
const channelId = guild.system_channel_id ?? channel.id;
const fileData = loadFixture('yeah.png');
const payload = {
content: 'Test with external thumbnail',
attachments: [{id: 0, filename: 'attached.png'}],
embeds: [
{
title: 'External Thumbnail Test',
image: {url: 'attachment://attached.png'},
thumbnail: {url: 'https://cdn.example.com/thumb.jpg'},
},
],
};
const {response, json} = await sendMessageWithAttachments(harness, account.token, channelId, payload, [
{index: 0, filename: 'attached.png', data: fileData},
]);
expect(response.status).toBe(200);
expect(json.embeds).toHaveLength(1);
const embed = json.embeds![0];
expect(embed.image?.url).not.toContain('attachment://');
expect(embed.thumbnail?.url).toBe('https://cdn.example.com/thumb.jpg');
});
});
describe('Filename Matching', () => {
it('should require exact filename matching', async () => {
const account = await createTestAccount(harness);
const guild = await createGuild(harness, account.token, 'Filename Matching Test Guild');
const channel = await createChannel(harness, account.token, guild.id, 'test-channel');
const channelId = guild.system_channel_id ?? channel.id;
const fileData = loadFixture('yeah.png');
const payload1 = {
content: 'Case-sensitive filename test',
attachments: [{id: 0, filename: 'yeah.png'}],
embeds: [
{
title: 'Exact Match Required',
image: {url: 'attachment://yeah.png'},
},
],
};
const {response: response1} = await sendMessageWithAttachments(harness, account.token, channelId, payload1, [
{index: 0, filename: 'yeah.png', data: fileData},
]);
expect(response1.status).toBe(200);
const payload2 = {
content: 'Case mismatch test',
attachments: [{id: 0, filename: 'yeah.png'}],
embeds: [
{
title: 'Wrong Case',
image: {url: 'attachment://Yeah.png'},
},
],
};
const {response: response2} = await sendMessageWithAttachments(harness, account.token, channelId, payload2, [
{index: 0, filename: 'yeah.png', data: fileData},
]);
expect(response2.status).toBe(400);
});
it('should match correct file by filename', async () => {
const account = await createTestAccount(harness);
const guild = await createGuild(harness, account.token, 'Filename Match Test Guild');
const channel = await createChannel(harness, account.token, guild.id, 'test-channel');
const channelId = guild.system_channel_id ?? channel.id;
const fileData = loadFixture('yeah.png');
const payload = {
content: 'Testing filename matching',
attachments: [
{id: 0, filename: 'alpha.png'},
{id: 1, filename: 'beta.png'},
],
embeds: [
{
title: 'Beta Image',
image: {url: 'attachment://beta.png'},
},
{
title: 'Alpha Image',
image: {url: 'attachment://alpha.png'},
},
],
};
const {response, json} = await sendMessageWithAttachments(harness, account.token, channelId, payload, [
{index: 0, filename: 'alpha.png', data: fileData},
{index: 1, filename: 'beta.png', data: fileData},
]);
expect(response.status).toBe(200);
expect(json.attachments).toHaveLength(2);
expect(json.embeds).toHaveLength(2);
expect(json.embeds![0].title).toBe('Beta Image');
expect(json.embeds![0].image?.url).not.toContain('attachment://');
expect(json.embeds![1].title).toBe('Alpha Image');
expect(json.embeds![1].image?.url).not.toContain('attachment://');
});
it('should resolve spoiler attachment in embed', async () => {
const account = await createTestAccount(harness);
const guild = await createGuild(harness, account.token, 'Spoiler Test Guild');
const channel = await createChannel(harness, account.token, guild.id, 'test-channel');
const channelId = guild.system_channel_id ?? channel.id;
const fileData = loadFixture('yeah.png');
const payload = {
content: 'Test message with spoiler attachment in embed',
attachments: [{id: 0, filename: 'SPOILER_secret.png', flags: MessageAttachmentFlags.IS_SPOILER}],
embeds: [
{
title: 'Spoiler Embed',
description: 'This embed uses a spoiler attachment',
image: {url: 'attachment://SPOILER_secret.png'},
},
],
};
const {response, json} = await sendMessageWithAttachments(harness, account.token, channelId, payload, [
{index: 0, filename: 'SPOILER_secret.png', data: fileData},
]);
expect(response.status).toBe(200);
expect(json.attachments).toBeDefined();
expect(json.attachments).not.toBeNull();
expect(json.attachments!).toHaveLength(1);
expect(json.attachments![0].filename).toBe('SPOILER_secret.png');
expect(json.embeds).toBeDefined();
expect(json.embeds).toHaveLength(1);
const embed = json.embeds![0];
expect(embed.image?.url).toBeTruthy();
expect(embed.image?.url).not.toContain('attachment://');
});
it('should preserve spoiler flag on attachment when referenced by embed', async () => {
const account = await createTestAccount(harness);
const guild = await createGuild(harness, account.token, 'Spoiler Flag Test Guild');
const channel = await createChannel(harness, account.token, guild.id, 'test-channel');
const channelId = guild.system_channel_id ?? channel.id;
const fileData = loadFixture('yeah.png');
const payload = {
content: 'Spoiler flag preservation test',
attachments: [{id: 0, filename: 'SPOILER_hidden.png', flags: MessageAttachmentFlags.IS_SPOILER}],
embeds: [
{
title: 'Hidden Content',
image: {url: 'attachment://SPOILER_hidden.png'},
},
],
};
const {response, json} = await sendMessageWithAttachments(harness, account.token, channelId, payload, [
{index: 0, filename: 'SPOILER_hidden.png', data: fileData},
]);
expect(response.status).toBe(200);
expect(json.attachments).toBeDefined();
expect(json.attachments).not.toBeNull();
expect(json.attachments!).toHaveLength(1);
const attachment = json.attachments![0];
expect(attachment.flags).toBeDefined();
expect(attachment.flags! & MessageAttachmentFlags.IS_SPOILER).toBe(MessageAttachmentFlags.IS_SPOILER);
});
it('should handle spoiler attachment alongside non-spoiler in embed', async () => {
const account = await createTestAccount(harness);
const guild = await createGuild(harness, account.token, 'Mixed Spoiler Guild');
const channel = await createChannel(harness, account.token, guild.id, 'test-channel');
const channelId = guild.system_channel_id ?? channel.id;
const fileData = loadFixture('yeah.png');
const payload = {
content: 'Mixed spoiler and non-spoiler attachments',
attachments: [
{id: 0, filename: 'SPOILER_hidden.png', flags: MessageAttachmentFlags.IS_SPOILER},
{id: 1, filename: 'visible.png'},
],
embeds: [
{
title: 'Spoiler Image',
image: {url: 'attachment://SPOILER_hidden.png'},
},
{
title: 'Visible Image',
thumbnail: {url: 'attachment://visible.png'},
},
],
};
const {response, json} = await sendMessageWithAttachments(harness, account.token, channelId, payload, [
{index: 0, filename: 'SPOILER_hidden.png', data: fileData},
{index: 1, filename: 'visible.png', data: fileData},
]);
expect(response.status).toBe(200);
expect(json.attachments).toBeDefined();
expect(json.attachments).not.toBeNull();
expect(json.attachments!).toHaveLength(2);
const spoilerAttachment = json.attachments!.find((a) => a.filename === 'SPOILER_hidden.png');
const visibleAttachment = json.attachments!.find((a) => a.filename === 'visible.png');
expect(spoilerAttachment).toBeDefined();
expect(spoilerAttachment!.flags! & MessageAttachmentFlags.IS_SPOILER).toBe(MessageAttachmentFlags.IS_SPOILER);
expect(visibleAttachment).toBeDefined();
expect(visibleAttachment!.flags ?? 0).not.toBe(MessageAttachmentFlags.IS_SPOILER);
expect(json.embeds).toHaveLength(2);
expect(json.embeds![0].image?.url).not.toContain('attachment://');
expect(json.embeds![1].thumbnail?.url).not.toContain('attachment://');
});
});
describe('Error Handling', () => {
it('should reject embed with attachment:// URL when no files are uploaded', async () => {
const account = await createTestAccount(harness);
const guild = await createGuild(harness, account.token, 'No Attachments Test Guild');
const channel = await createChannel(harness, account.token, guild.id, 'test-channel');
const channelId = guild.system_channel_id ?? channel.id;
const payload = {
content: 'Embed references attachment but none provided',
embeds: [
{
title: 'Invalid Reference',
description: 'No attachment uploaded',
image: {url: 'attachment://image.png'},
},
],
};
await createBuilder(harness, account.token)
.post(`/channels/${channelId}/messages`)
.body(payload)
.expect(400)
.execute();
});
it('should reject embed referencing non-existent filename', async () => {
const account = await createTestAccount(harness);
const guild = await createGuild(harness, account.token, 'Missing Attachment Test Guild');
const channel = await createChannel(harness, account.token, guild.id, 'test-channel');
const channelId = guild.system_channel_id ?? channel.id;
const fileData = loadFixture('yeah.png');
const payload = {
content: 'Embed references missing file',
attachments: [{id: 0, filename: 'yeah.png'}],
embeds: [
{
title: 'Invalid Reference',
description: 'This embed references a non-existent file',
image: {url: 'attachment://nonexistent.png'},
},
],
};
const {response} = await sendMessageWithAttachments(harness, account.token, channelId, payload, [
{index: 0, filename: 'yeah.png', data: fileData},
]);
expect(response.status).toBe(400);
});
});
describe('Validation Rules', () => {
it('should reject non-image attachment in embed image field', async () => {
const account = await createTestAccount(harness);
const guild = await createGuild(harness, account.token, 'Non-Image Test Guild');
const channel = await createChannel(harness, account.token, guild.id, 'test-channel');
const channelId = guild.system_channel_id ?? channel.id;
const textFileData = Buffer.from('This is a text file, not an image.');
const payload = {
content: 'Test message with non-image in embed',
attachments: [{id: 0, filename: 'document.txt'}],
embeds: [
{
title: 'Invalid Embed',
image: {url: 'attachment://document.txt'},
},
],
};
const {response} = await sendMessageWithAttachments(harness, account.token, channelId, payload, [
{index: 0, filename: 'document.txt', data: textFileData},
]);
expect(response.status).toBe(400);
});
it('should reject PDF attachment in embed image field', async () => {
const account = await createTestAccount(harness);
const guild = await createGuild(harness, account.token, 'PDF Rejection Test Guild');
const channel = await createChannel(harness, account.token, guild.id, 'test-channel');
const channelId = guild.system_channel_id ?? channel.id;
const pdfHeader = Buffer.from('%PDF-1.4\n');
const pdfData = Buffer.concat([pdfHeader, Buffer.alloc(100)]);
const payload = {
content: 'Test message with PDF in embed',
attachments: [{id: 0, filename: 'document.pdf'}],
embeds: [
{
title: 'PDF Embed Attempt',
image: {url: 'attachment://document.pdf'},
},
],
};
const {response} = await sendMessageWithAttachments(harness, account.token, channelId, payload, [
{index: 0, filename: 'document.pdf', data: pdfData},
]);
expect(response.status).toBe(400);
});
it('should reject JSON attachment in embed thumbnail field', async () => {
const account = await createTestAccount(harness);
const guild = await createGuild(harness, account.token, 'JSON Rejection Test Guild');
const channel = await createChannel(harness, account.token, guild.id, 'test-channel');
const channelId = guild.system_channel_id ?? channel.id;
const jsonData = Buffer.from(JSON.stringify({key: 'value'}));
const payload = {
content: 'Test message with JSON in embed thumbnail',
attachments: [{id: 0, filename: 'data.json'}],
embeds: [
{
title: 'JSON Embed Attempt',
thumbnail: {url: 'attachment://data.json'},
},
],
};
const {response} = await sendMessageWithAttachments(harness, account.token, channelId, payload, [
{index: 0, filename: 'data.json', data: jsonData},
]);
expect(response.status).toBe(400);
});
it('should reject executable attachment in embed image field', async () => {
const account = await createTestAccount(harness);
const guild = await createGuild(harness, account.token, 'Executable Rejection Guild');
const channel = await createChannel(harness, account.token, guild.id, 'test-channel');
const channelId = guild.system_channel_id ?? channel.id;
const exeData = Buffer.from('MZ');
const payload = {
content: 'Test message with executable in embed',
attachments: [{id: 0, filename: 'program.exe'}],
embeds: [
{
title: 'Executable Embed Attempt',
image: {url: 'attachment://program.exe'},
},
],
};
const {response} = await sendMessageWithAttachments(harness, account.token, channelId, payload, [
{index: 0, filename: 'program.exe', data: exeData},
]);
expect(response.status).toBe(400);
});
it('should accept file with image extension but corrupted content for embed reference', async () => {
const account = await createTestAccount(harness);
const guild = await createGuild(harness, account.token, 'Content Type Mismatch Guild');
const channel = await createChannel(harness, account.token, guild.id, 'test-channel');
const channelId = guild.system_channel_id ?? channel.id;
const textData = Buffer.from('Not an image, just text.');
const payload = {
content: 'Test with fake PNG extension',
attachments: [{id: 0, filename: 'fake.png'}],
embeds: [
{
title: 'Fake Image Embed',
image: {url: 'attachment://fake.png'},
},
],
};
const {response, json} = await sendMessageWithAttachments(harness, account.token, channelId, payload, [
{index: 0, filename: 'fake.png', data: textData},
]);
expect(response.status).toBe(200);
expect(json.embeds).toHaveLength(1);
expect(json.embeds![0].image?.url).not.toContain('attachment://');
});
});
describe('Multiple Embeds and Files', () => {
it('should handle multiple embeds with different URL types', async () => {
const account = await createTestAccount(harness);
const guild = await createGuild(harness, account.token, 'Multiple Embeds Guild');
const channel = await createChannel(harness, account.token, guild.id, 'test-channel');
const channelId = guild.system_channel_id ?? channel.id;
const fileData = loadFixture('yeah.png');
const payload = {
content: 'Multiple embeds with different URL types',
attachments: [{id: 0, filename: 'image1.png'}],
embeds: [
{
title: 'First Embed - Attachment',
image: {url: 'attachment://image1.png'},
},
{
title: 'Second Embed - External',
image: {url: 'https://example.com/external.png'},
},
],
};
const {response, json} = await sendMessageWithAttachments(harness, account.token, channelId, payload, [
{index: 0, filename: 'image1.png', data: fileData},
]);
expect(response.status).toBe(200);
expect(json.embeds).toHaveLength(2);
expect(json.embeds![0].image?.url).not.toContain('attachment://');
expect(json.embeds![1].image?.url).toBe('https://example.com/external.png');
});
it('should handle multiple embeds each referencing different attachments', async () => {
const account = await createTestAccount(harness);
const guild = await createGuild(harness, account.token, 'Multiple Embeds Test Guild');
const channel = await createChannel(harness, account.token, guild.id, 'test-channel');
const channelId = guild.system_channel_id ?? channel.id;
const file1Data = loadFixture('yeah.png');
const file2Data = loadFixture('thisisfine.gif');
const payload = {
content: 'Multiple embeds with different attachments',
attachments: [
{id: 0, filename: 'yeah.png'},
{id: 1, filename: 'thisisfine.gif'},
],
embeds: [
{
title: 'First Embed',
description: 'Uses PNG',
image: {url: 'attachment://yeah.png'},
},
{
title: 'Second Embed',
description: 'Uses GIF',
image: {url: 'attachment://thisisfine.gif'},
},
],
};
const {response, json} = await sendMessageWithAttachments(harness, account.token, channelId, payload, [
{index: 0, filename: 'yeah.png', data: file1Data},
{index: 1, filename: 'thisisfine.gif', data: file2Data},
]);
expect(response.status).toBe(200);
expect(json.embeds).toBeDefined();
expect(json.embeds).toHaveLength(2);
expect(json.embeds![0].image?.url).toContain('yeah.png');
expect(json.embeds![1].image?.url).toContain('thisisfine.gif');
});
it('should resolve multiple files referenced by embeds', async () => {
const account = await createTestAccount(harness);
const guild = await createGuild(harness, account.token, 'Multiple Files Test Guild');
const channel = await createChannel(harness, account.token, guild.id, 'test-channel');
const channelId = guild.system_channel_id ?? channel.id;
const fileData = loadFixture('yeah.png');
const payload = {
content: 'Test message with multiple attachments in embeds',
attachments: [
{id: 0, filename: 'image1.png'},
{id: 1, filename: 'image2.png'},
{id: 2, filename: 'image3.png'},
],
embeds: [
{
title: 'First Image',
image: {url: 'attachment://image1.png'},
},
{
title: 'Second Image',
image: {url: 'attachment://image2.png'},
},
{
title: 'Third Image',
thumbnail: {url: 'attachment://image3.png'},
},
],
};
const {response, json} = await sendMessageWithAttachments(harness, account.token, channelId, payload, [
{index: 0, filename: 'image1.png', data: fileData},
{index: 1, filename: 'image2.png', data: fileData},
{index: 2, filename: 'image3.png', data: fileData},
]);
expect(response.status).toBe(200);
expect(json.attachments).toHaveLength(3);
expect(json.embeds).toHaveLength(3);
for (const embed of json.embeds!) {
if (embed.image) {
expect(embed.image.url).not.toContain('attachment://');
}
if (embed.thumbnail) {
expect(embed.thumbnail.url).not.toContain('attachment://');
}
}
});
it('should allow same attachment to be used in multiple embeds', async () => {
const account = await createTestAccount(harness);
const guild = await createGuild(harness, account.token, 'Reused Attachment Guild');
const channel = await createChannel(harness, account.token, guild.id, 'test-channel');
const channelId = guild.system_channel_id ?? channel.id;
const fileData = loadFixture('yeah.png');
const payload = {
content: 'Same attachment in multiple embeds',
attachments: [{id: 0, filename: 'shared.png'}],
embeds: [
{
title: 'First Use',
image: {url: 'attachment://shared.png'},
},
{
title: 'Second Use',
thumbnail: {url: 'attachment://shared.png'},
},
],
};
const {response, json} = await sendMessageWithAttachments(harness, account.token, channelId, payload, [
{index: 0, filename: 'shared.png', data: fileData},
]);
expect(response.status).toBe(200);
expect(json.attachments).toHaveLength(1);
expect(json.embeds).toHaveLength(2);
expect(json.embeds![0].image?.url).not.toContain('attachment://');
expect(json.embeds![1].thumbnail?.url).not.toContain('attachment://');
});
});
});

View File

@@ -0,0 +1,69 @@
/*
* 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 {
createFriendship,
createGroupDmChannel,
type GroupDmChannelResponse,
} from '@fluxer/api/src/channel/tests/ChannelTestUtils';
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';
describe('Group DM name update', () => {
let harness: ApiTestHarness;
beforeAll(async () => {
harness = await createApiTestHarness();
});
beforeEach(async () => {
await harness.reset();
});
afterAll(async () => {
await harness?.shutdown();
});
it('updates group DM name correctly', async () => {
const user1 = await createTestAccount(harness);
const user2 = await createTestAccount(harness);
const user3 = await createTestAccount(harness);
await ensureSessionStarted(harness, user1.token);
await ensureSessionStarted(harness, user2.token);
await ensureSessionStarted(harness, user3.token);
await createFriendship(harness, user1, user2);
await createFriendship(harness, user1, user3);
const groupDm = await createGroupDmChannel(harness, user1.token, [user2.userId, user3.userId]);
const updated = await createBuilder<GroupDmChannelResponse>(harness, user1.token)
.patch(`/channels/${groupDm.id}`)
.body({name: 'Cool Group Chat'})
.expect(HTTP_STATUS.OK)
.execute();
expect(updated.name).toBe('Cool Group Chat');
});
});

View File

@@ -0,0 +1,154 @@
/*
* 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 {createFriendship, createGroupDmChannel, getChannel} from '@fluxer/api/src/channel/tests/ChannelTestUtils';
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 type {ChannelResponse} from '@fluxer/schema/src/domains/channel/ChannelSchemas';
import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest';
describe('Group DM nickname update', () => {
let harness: ApiTestHarness;
beforeAll(async () => {
harness = await createApiTestHarness();
});
beforeEach(async () => {
await harness.reset();
});
afterAll(async () => {
await harness?.shutdown();
});
it('user can update their own nickname in a group DM', async () => {
const user1 = await createTestAccount(harness);
const user2 = await createTestAccount(harness);
const user3 = await createTestAccount(harness);
await ensureSessionStarted(harness, user1.token);
await ensureSessionStarted(harness, user2.token);
await ensureSessionStarted(harness, user3.token);
await createFriendship(harness, user1, user2);
await createFriendship(harness, user1, user3);
const groupDm = await createGroupDmChannel(harness, user1.token, [user2.userId, user3.userId]);
const updatedChannel = await createBuilder<ChannelResponse>(harness, user2.token)
.patch(`/channels/${groupDm.id}`)
.body({
nicks: {
[user2.userId]: 'User 2 Nick',
},
})
.expect(HTTP_STATUS.OK)
.execute();
expect(updatedChannel.nicks).toBeDefined();
expect(updatedChannel.nicks?.[user2.userId]).toBe('User 2 Nick');
});
it('nickname is returned correctly in channel response after update', async () => {
const user1 = await createTestAccount(harness);
const user2 = await createTestAccount(harness);
const user3 = await createTestAccount(harness);
await ensureSessionStarted(harness, user1.token);
await ensureSessionStarted(harness, user2.token);
await ensureSessionStarted(harness, user3.token);
await createFriendship(harness, user1, user2);
await createFriendship(harness, user1, user3);
const groupDm = await createGroupDmChannel(harness, user1.token, [user2.userId, user3.userId]);
await createBuilder<ChannelResponse>(harness, user2.token)
.patch(`/channels/${groupDm.id}`)
.body({
nicks: {
[user2.userId]: 'My Custom Nick',
},
})
.expect(HTTP_STATUS.OK)
.execute();
const fetchedChannel = await getChannel(harness, user2.token, groupDm.id);
expect(fetchedChannel.nicks).toBeDefined();
expect(fetchedChannel.nicks?.[user2.userId]).toBe('My Custom Nick');
});
it('non-owner cannot update another users nickname', async () => {
const user1 = await createTestAccount(harness);
const user2 = await createTestAccount(harness);
const user3 = await createTestAccount(harness);
await ensureSessionStarted(harness, user1.token);
await ensureSessionStarted(harness, user2.token);
await ensureSessionStarted(harness, user3.token);
await createFriendship(harness, user1, user2);
await createFriendship(harness, user1, user3);
const groupDm = await createGroupDmChannel(harness, user1.token, [user2.userId, user3.userId]);
await createBuilder(harness, user2.token)
.patch(`/channels/${groupDm.id}`)
.body({
nicks: {
[user3.userId]: 'User 3 Nick by User 2',
},
})
.expect(HTTP_STATUS.FORBIDDEN)
.execute();
});
it('owner can update another users nickname', async () => {
const user1 = await createTestAccount(harness);
const user2 = await createTestAccount(harness);
const user3 = await createTestAccount(harness);
await ensureSessionStarted(harness, user1.token);
await ensureSessionStarted(harness, user2.token);
await ensureSessionStarted(harness, user3.token);
await createFriendship(harness, user1, user2);
await createFriendship(harness, user1, user3);
const groupDm = await createGroupDmChannel(harness, user1.token, [user2.userId, user3.userId]);
const updatedChannel = await createBuilder<ChannelResponse>(harness, user1.token)
.patch(`/channels/${groupDm.id}`)
.body({
nicks: {
[user3.userId]: 'User 3 Nick by Owner',
},
})
.expect(HTTP_STATUS.OK)
.execute();
expect(updatedChannel.nicks).toBeDefined();
expect(updatedChannel.nicks?.[user3.userId]).toBe('User 3 Nick by Owner');
});
});

View File

@@ -0,0 +1,141 @@
/*
* 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 {createFriendship, createGroupDmChannel} from '@fluxer/api/src/channel/tests/ChannelTestUtils';
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, it} from 'vitest';
describe('Group DM Security Boundaries', () => {
let harness: ApiTestHarness;
beforeAll(async () => {
harness = await createApiTestHarness();
});
beforeEach(async () => {
await harness.reset();
});
afterAll(async () => {
await harness?.shutdown();
});
it('non-member cannot read messages from group DM', async () => {
const creator = await createTestAccount(harness);
const recipient = await createTestAccount(harness);
const attacker = await createTestAccount(harness);
await createFriendship(harness, creator, recipient);
const groupDm = await createGroupDmChannel(harness, creator.token, [recipient.userId]);
await createBuilder(harness, attacker.token)
.get(`/channels/${groupDm.id}/messages`)
.expect(HTTP_STATUS.NOT_FOUND, 'UNKNOWN_CHANNEL')
.execute();
});
it('non-member cannot send messages to group DM', async () => {
const creator = await createTestAccount(harness);
const recipient = await createTestAccount(harness);
const attacker = await createTestAccount(harness);
await createFriendship(harness, creator, recipient);
const groupDm = await createGroupDmChannel(harness, creator.token, [recipient.userId]);
await createBuilder(harness, attacker.token)
.post(`/channels/${groupDm.id}/messages`)
.body({content: 'Unauthorized message'})
.expect(HTTP_STATUS.NOT_FOUND, 'UNKNOWN_CHANNEL')
.execute();
});
it('non-member cannot add themselves to group DM', async () => {
const creator = await createTestAccount(harness);
const recipient = await createTestAccount(harness);
const attacker = await createTestAccount(harness);
await createFriendship(harness, creator, recipient);
const groupDm = await createGroupDmChannel(harness, creator.token, [recipient.userId]);
await createBuilder(harness, attacker.token)
.put(`/channels/${groupDm.id}/recipients/${attacker.userId}`)
.body(null)
.expect(HTTP_STATUS.FORBIDDEN, 'MISSING_ACCESS')
.execute();
});
it('recipient can leave group DM', async () => {
const creator = await createTestAccount(harness);
const recipient = await createTestAccount(harness);
await createFriendship(harness, creator, recipient);
const groupDm = await createGroupDmChannel(harness, creator.token, [recipient.userId]);
await createBuilder(harness, recipient.token)
.delete(`/channels/${groupDm.id}/recipients/${recipient.userId}`)
.expect(HTTP_STATUS.NO_CONTENT)
.execute();
});
it('after leaving, former member cannot read messages', async () => {
const creator = await createTestAccount(harness);
const recipient = await createTestAccount(harness);
await createFriendship(harness, creator, recipient);
const groupDm = await createGroupDmChannel(harness, creator.token, [recipient.userId]);
await createBuilder(harness, recipient.token)
.delete(`/channels/${groupDm.id}/recipients/${recipient.userId}`)
.expect(HTTP_STATUS.NO_CONTENT)
.execute();
await createBuilder(harness, recipient.token)
.get(`/channels/${groupDm.id}/messages`)
.expect(HTTP_STATUS.NOT_FOUND, 'UNKNOWN_CHANNEL')
.execute();
});
it('after leaving, former member cannot send messages', async () => {
const creator = await createTestAccount(harness);
const recipient = await createTestAccount(harness);
await createFriendship(harness, creator, recipient);
const groupDm = await createGroupDmChannel(harness, creator.token, [recipient.userId]);
await createBuilder(harness, recipient.token)
.delete(`/channels/${groupDm.id}/recipients/${recipient.userId}`)
.expect(HTTP_STATUS.NO_CONTENT)
.execute();
await createBuilder(harness, recipient.token)
.post(`/channels/${groupDm.id}/messages`)
.body({content: 'Message after leaving'})
.expect(HTTP_STATUS.NOT_FOUND, 'UNKNOWN_CHANNEL')
.execute();
});
});

View File

@@ -0,0 +1,144 @@
/*
* 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 {createFriendship, createGroupDmChannel} from '@fluxer/api/src/channel/tests/ChannelTestUtils';
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, it} from 'vitest';
describe('Group DM Add Recipient Permissions', () => {
let harness: ApiTestHarness;
beforeAll(async () => {
harness = await createApiTestHarness();
});
beforeEach(async () => {
await harness.reset();
});
afterAll(async () => {
await harness?.shutdown();
});
it('rejects adding non-friend to group DM', async () => {
const user1 = await createTestAccount(harness);
const user2 = await createTestAccount(harness);
const user3 = await createTestAccount(harness);
const user4 = await createTestAccount(harness);
await createFriendship(harness, user1, user2);
await createFriendship(harness, user1, user3);
const groupDmChannel = await createGroupDmChannel(harness, user1.token, [user2.userId, user3.userId]);
await createBuilder(harness, user1.token)
.put(`/channels/${groupDmChannel.id}/recipients/${user4.userId}`)
.body(null)
.expect(HTTP_STATUS.BAD_REQUEST, 'NOT_FRIENDS_WITH_USER')
.execute();
});
it('allows adding friend to group DM', async () => {
const user1 = await createTestAccount(harness);
const user2 = await createTestAccount(harness);
const user3 = await createTestAccount(harness);
const user4 = await createTestAccount(harness);
await createFriendship(harness, user1, user2);
await createFriendship(harness, user1, user3);
await createFriendship(harness, user1, user4);
const groupDmChannel = await createGroupDmChannel(harness, user1.token, [user2.userId, user3.userId]);
await createBuilder(harness, user1.token)
.put(`/channels/${groupDmChannel.id}/recipients/${user4.userId}`)
.body(null)
.expect(HTTP_STATUS.NO_CONTENT)
.execute();
});
it('allows member to add their own friend to group DM', async () => {
const owner = await createTestAccount(harness);
const member = await createTestAccount(harness);
const otherMember = await createTestAccount(harness);
const newUser = await createTestAccount(harness);
await createFriendship(harness, owner, member);
await createFriendship(harness, owner, otherMember);
await createFriendship(harness, member, newUser);
const groupDmChannel = await createGroupDmChannel(harness, owner.token, [member.userId, otherMember.userId]);
await createBuilder(harness, member.token)
.put(`/channels/${groupDmChannel.id}/recipients/${newUser.userId}`)
.body(null)
.expect(HTTP_STATUS.NO_CONTENT)
.execute();
});
it('rejects non-participant from adding recipients to group DM', async () => {
const owner = await createTestAccount(harness);
const member = await createTestAccount(harness);
const third = await createTestAccount(harness);
const outsider = await createTestAccount(harness);
await createFriendship(harness, owner, member);
await createFriendship(harness, owner, third);
await createFriendship(harness, member, third);
await createFriendship(harness, outsider, third);
const groupDmChannel = await createGroupDmChannel(harness, owner.token, [member.userId, third.userId]);
await createBuilder(harness, outsider.token)
.put(`/channels/${groupDmChannel.id}/recipients/${third.userId}`)
.body(null)
.expect(HTTP_STATUS.FORBIDDEN, 'MISSING_ACCESS')
.execute();
});
it('rejects member from adding non-friend to group DM', async () => {
const owner = await createTestAccount(harness);
const member = await createTestAccount(harness);
const otherMember = await createTestAccount(harness);
const ownerFriend = await createTestAccount(harness);
const outsider = await createTestAccount(harness);
await createFriendship(harness, owner, member);
await createFriendship(harness, owner, otherMember);
await createFriendship(harness, owner, ownerFriend);
await createFriendship(harness, outsider, otherMember);
const groupDmChannel = await createGroupDmChannel(harness, owner.token, [member.userId, otherMember.userId]);
await createBuilder(harness, owner.token)
.put(`/channels/${groupDmChannel.id}/recipients/${ownerFriend.userId}`)
.body(null)
.expect(HTTP_STATUS.NO_CONTENT)
.execute();
await createBuilder(harness, member.token)
.put(`/channels/${groupDmChannel.id}/recipients/${outsider.userId}`)
.body(null)
.expect(HTTP_STATUS.BAD_REQUEST, 'NOT_FRIENDS_WITH_USER')
.execute();
});
});

View File

@@ -0,0 +1,76 @@
/*
* 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 {createFriendship, seedPrivateChannels} from '@fluxer/api/src/channel/tests/ChannelTestUtils';
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 MAX_GROUP_DM_LIMIT = 150;
const MAX_GROUP_DMS_ERROR_CODE = 'MAX_GROUP_DMS';
describe('Group DM recipient limit', () => {
let harness: ApiTestHarness;
beforeAll(async () => {
harness = await createApiTestHarness();
});
beforeEach(async () => {
await harness.reset();
});
afterAll(async () => {
await harness?.shutdown();
});
it('rejects creating group DM when user has reached the limit', async () => {
const creator = await createTestAccount(harness);
const target = await createTestAccount(harness);
const recipient = await createTestAccount(harness);
const helper = await createTestAccount(harness);
await ensureSessionStarted(harness, creator.token);
await ensureSessionStarted(harness, target.token);
await ensureSessionStarted(harness, recipient.token);
await ensureSessionStarted(harness, helper.token);
await createFriendship(harness, creator, target);
await createFriendship(harness, creator, recipient);
const seedResult = await seedPrivateChannels(harness, target.token, target.userId, {
group_dm_count: MAX_GROUP_DM_LIMIT,
recipients: [helper.userId, recipient.userId],
clear_existing: true,
});
expect(seedResult.group_dms.length).toBe(MAX_GROUP_DM_LIMIT);
await createBuilder(harness, creator.token)
.post('/users/@me/channels')
.body({
recipients: [helper.userId, target.userId],
})
.expect(HTTP_STATUS.BAD_REQUEST, MAX_GROUP_DMS_ERROR_CODE)
.execute();
});
});

View File

@@ -0,0 +1,156 @@
/*
* 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 {
addRecipientToGroupDm,
createFriendship,
createGroupDmChannel,
getChannel,
removeRecipientFromGroupDm,
updateChannel,
} from '@fluxer/api/src/channel/tests/ChannelTestUtils';
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';
describe('Group DM management', () => {
let harness: ApiTestHarness;
beforeAll(async () => {
harness = await createApiTestHarness();
});
beforeEach(async () => {
await harness.reset();
});
afterAll(async () => {
await harness?.shutdown();
});
it('can create group DM with multiple recipients', async () => {
const user1 = await createTestAccount(harness);
const user2 = await createTestAccount(harness);
const user3 = await createTestAccount(harness);
await ensureSessionStarted(harness, user1.token);
await ensureSessionStarted(harness, user2.token);
await ensureSessionStarted(harness, user3.token);
await createFriendship(harness, user1, user2);
await createFriendship(harness, user1, user3);
const groupDm = await createGroupDmChannel(harness, user1.token, [user2.userId, user3.userId]);
expect(groupDm.id).toBeTruthy();
expect(groupDm.type).toBe(3);
expect(groupDm.owner_id).toBe(user1.userId);
expect(groupDm.recipients.length).toBe(2);
});
it('can update group DM name', async () => {
const user1 = await createTestAccount(harness);
const user2 = await createTestAccount(harness);
const user3 = await createTestAccount(harness);
await ensureSessionStarted(harness, user1.token);
await ensureSessionStarted(harness, user2.token);
await ensureSessionStarted(harness, user3.token);
await createFriendship(harness, user1, user2);
await createFriendship(harness, user1, user3);
const groupDm = await createGroupDmChannel(harness, user1.token, [user2.userId, user3.userId]);
const updated = await updateChannel(harness, user1.token, groupDm.id, {name: 'Cool Group Chat'});
expect(updated.name).toBe('Cool Group Chat');
});
it('can add recipient to existing group DM', async () => {
const user1 = await createTestAccount(harness);
const user2 = await createTestAccount(harness);
const user3 = await createTestAccount(harness);
const user4 = await createTestAccount(harness);
await ensureSessionStarted(harness, user1.token);
await ensureSessionStarted(harness, user2.token);
await ensureSessionStarted(harness, user3.token);
await ensureSessionStarted(harness, user4.token);
await createFriendship(harness, user1, user2);
await createFriendship(harness, user1, user3);
await createFriendship(harness, user1, user4);
const groupDm = await createGroupDmChannel(harness, user1.token, [user2.userId, user3.userId]);
expect(groupDm.recipients.length).toBe(2);
await addRecipientToGroupDm(harness, user1.token, groupDm.id, user4.userId);
const updatedChannel = await getChannel(harness, user1.token, groupDm.id);
expect(updatedChannel.type).toBe(3);
});
it('can remove recipient from group DM', async () => {
const user1 = await createTestAccount(harness);
const user2 = await createTestAccount(harness);
const user3 = await createTestAccount(harness);
await ensureSessionStarted(harness, user1.token);
await ensureSessionStarted(harness, user2.token);
await ensureSessionStarted(harness, user3.token);
await createFriendship(harness, user1, user2);
await createFriendship(harness, user1, user3);
const groupDm = await createGroupDmChannel(harness, user1.token, [user2.userId, user3.userId]);
await removeRecipientFromGroupDm(harness, user1.token, groupDm.id, user3.userId);
const updatedChannel = await getChannel(harness, user1.token, groupDm.id);
expect(updatedChannel.type).toBe(3);
});
it('rejects non-member adding recipient to group DM', async () => {
const user1 = await createTestAccount(harness);
const user2 = await createTestAccount(harness);
const user3 = await createTestAccount(harness);
const outsider = await createTestAccount(harness);
await ensureSessionStarted(harness, user1.token);
await ensureSessionStarted(harness, user2.token);
await ensureSessionStarted(harness, user3.token);
await ensureSessionStarted(harness, outsider.token);
await createFriendship(harness, user1, user2);
await createFriendship(harness, user1, user3);
await createFriendship(harness, outsider, user3);
const groupDm = await createGroupDmChannel(harness, user1.token, [user2.userId, user3.userId]);
await createBuilder(harness, outsider.token)
.put(`/channels/${groupDm.id}/recipients/${user3.userId}`)
.body(null)
.expect(HTTP_STATUS.FORBIDDEN)
.execute();
});
});

View File

@@ -0,0 +1,70 @@
/*
* 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 {createFriendship, seedPrivateChannels} from '@fluxer/api/src/channel/tests/ChannelTestUtils';
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 MAX_GROUP_DM_LIMIT = 150;
const MAX_GROUP_DM_ERROR_CODE = 'MAX_GROUP_DMS';
describe('Group DM Recipient Limit', () => {
let harness: ApiTestHarness;
beforeAll(async () => {
harness = await createApiTestHarness();
});
beforeEach(async () => {
await harness.reset();
});
afterAll(async () => {
await harness?.shutdown();
});
it('rejects creating group DM when user has reached limit', async () => {
const creator = await createTestAccount(harness);
const target = await createTestAccount(harness);
const recipient = await createTestAccount(harness);
const helper = await createTestAccount(harness);
await createFriendship(harness, creator, target);
await createFriendship(harness, creator, recipient);
const seedResult = await seedPrivateChannels(harness, target.token, target.userId, {
group_dm_count: MAX_GROUP_DM_LIMIT,
recipients: [helper.userId, recipient.userId],
clear_existing: true,
});
expect(seedResult.group_dms).toHaveLength(MAX_GROUP_DM_LIMIT);
await createBuilder(harness, creator.token)
.post('/users/@me/channels')
.body({
recipients: [helper.userId, target.userId],
})
.expect(HTTP_STATUS.BAD_REQUEST, MAX_GROUP_DM_ERROR_CODE)
.execute();
});
});

View File

@@ -0,0 +1,83 @@
/*
* 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, getChannel, sendChannelMessage} from '@fluxer/api/src/channel/tests/ChannelTestUtils';
import {createEmoji, getGifDataUrl, getPngDataUrl} from '@fluxer/api/src/emoji/tests/EmojiTestUtils';
import {type ApiTestHarness, createApiTestHarness} from '@fluxer/api/src/test/ApiTestHarness';
import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest';
describe('Message custom emoji sanitization', () => {
let harness: ApiTestHarness;
beforeAll(async () => {
harness = await createApiTestHarness();
});
beforeEach(async () => {
await harness.reset();
});
afterAll(async () => {
await harness?.shutdown();
});
it('preserves guild-local static and animated custom emojis for free users', async () => {
const account = await createTestAccount(harness);
const guild = await createGuild(harness, account.token, 'Local emoji guild');
if (!guild.system_channel_id) {
throw new Error('Guild system channel id is missing');
}
const channel = await getChannel(harness, account.token, guild.system_channel_id);
const staticEmoji = await createEmoji(harness, account.token, guild.id, {
name: 'local_static',
image: getPngDataUrl(),
});
const animatedEmoji = await createEmoji(harness, account.token, guild.id, {
name: 'local_animated',
image: getGifDataUrl(),
});
const content = `Static <:${staticEmoji.name}:${staticEmoji.id}> animated <a:${animatedEmoji.name}:${animatedEmoji.id}>`;
const message = await sendChannelMessage(harness, account.token, channel.id, content);
expect(message.content).toBe(content);
});
it('strips external custom emojis for free users', async () => {
const account = await createTestAccount(harness);
const destinationGuild = await createGuild(harness, account.token, 'Destination guild');
if (!destinationGuild.system_channel_id) {
throw new Error('Destination guild system channel id is missing');
}
const destinationChannel = await getChannel(harness, account.token, destinationGuild.system_channel_id);
const sourceGuild = await createGuild(harness, account.token, 'External emoji guild');
const externalEmoji = await createEmoji(harness, account.token, sourceGuild.id, {
name: 'external_emoji',
image: getPngDataUrl(),
});
const content = `External <:${externalEmoji.name}:${externalEmoji.id}>`;
const message = await sendChannelMessage(harness, account.token, destinationChannel.id, content);
expect(message.content).toBe('External :external_emoji:');
});
});

View File

@@ -0,0 +1,206 @@
/*
* 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 {ensureSessionStarted} from '@fluxer/api/src/message/tests/MessageTestUtils';
import type {ApiTestHarness} from '@fluxer/api/src/test/ApiTestHarness';
import {createBuilder} from '@fluxer/api/src/test/TestRequestBuilder';
import {ManagedTraits} from '@fluxer/constants/src/ManagedTraits';
import type {MessageResponse} from '@fluxer/schema/src/domains/message/MessageResponseSchemas';
import type {ScheduledMessageResponseSchema} from '@fluxer/schema/src/domains/message/ScheduledMessageSchemas';
import type {z} from 'zod';
export type ScheduledMessageResponse = z.infer<typeof ScheduledMessageResponseSchema>;
export async function createGuildChannel(
harness: ApiTestHarness,
token: string,
guildId: string,
name: string,
): Promise<{id: string}> {
const {response, json} = await createBuilder<{id: string}>(harness, token)
.post(`/guilds/${guildId}/channels`)
.body({name, type: 0})
.executeWithResponse();
if (response.status !== 200 && response.status !== 201) {
throw new Error(`Failed to create guild channel: ${response.status}`);
}
return json as {id: string};
}
export async function enableMessageSchedulingForGuild(harness: ApiTestHarness, guildId: string): Promise<void> {
await createBuilder<void>(harness, '')
.post(`/test/guilds/${guildId}/features`)
.body({
add_features: [ManagedTraits.MESSAGE_SCHEDULING],
})
.expect(200)
.execute();
}
export async function scheduleMessage(
harness: ApiTestHarness,
channelId: string,
token: string,
content: string,
scheduledAt?: Date,
): Promise<ScheduledMessageResponse> {
const scheduledTime = scheduledAt ?? new Date(Date.now() + 60 * 1000);
await ensureSessionStarted(harness, token);
const {json} = await createBuilder<ScheduledMessageResponse>(harness, token)
.post(`/channels/${channelId}/messages/schedule`)
.body({
content,
scheduled_local_at: scheduledTime.toISOString(),
timezone: 'UTC',
})
.expect(201)
.executeWithResponse();
return json as ScheduledMessageResponse;
}
export async function updateScheduledMessage(
harness: ApiTestHarness,
scheduledMessageId: string,
token: string,
updates: {
content?: string;
scheduled_local_at?: string;
timezone?: string;
},
): Promise<ScheduledMessageResponse> {
const {json} = await createBuilder<ScheduledMessageResponse>(harness, token)
.patch(`/users/@me/scheduled-messages/${scheduledMessageId}`)
.body(updates)
.expect(200)
.executeWithResponse();
return json as ScheduledMessageResponse;
}
export async function getScheduledMessages(
harness: ApiTestHarness,
token: string,
): Promise<Array<ScheduledMessageResponse>> {
const {json} = await createBuilder<Array<ScheduledMessageResponse>>(harness, token)
.get('/users/@me/scheduled-messages')
.expect(200)
.executeWithResponse();
return json;
}
export async function getScheduledMessage(
harness: ApiTestHarness,
scheduledMessageId: string,
token: string,
expectedStatus: 200 | 404 = 200,
): Promise<ScheduledMessageResponse | null> {
const {response, json} = await createBuilder<ScheduledMessageResponse>(harness, token)
.get(`/users/@me/scheduled-messages/${scheduledMessageId}`)
.expect(expectedStatus)
.executeWithResponse();
if (response.status === 404) {
return null;
}
return json as ScheduledMessageResponse;
}
export async function cancelScheduledMessage(
harness: ApiTestHarness,
scheduledMessageId: string,
token: string,
): Promise<void> {
await createBuilder<void>(harness, token)
.delete(`/users/@me/scheduled-messages/${scheduledMessageId}`)
.expect(204)
.execute();
}
export async function triggerScheduledMessageWorker(
harness: ApiTestHarness,
userId: string,
scheduledMessageId: string,
): Promise<void> {
await createBuilder<void>(harness, '')
.post(`/test/worker/send-scheduled-message/${userId}/${scheduledMessageId}`)
.expect(200)
.execute();
}
export async function getChannelMessages(
harness: ApiTestHarness,
channelId: string,
token: string,
limit = 20,
): Promise<Array<MessageResponse>> {
const {json} = await createBuilder<Array<MessageResponse>>(harness, token)
.get(`/channels/${channelId}/messages?limit=${limit}`)
.expect(200)
.executeWithResponse();
return json;
}
export function messageFromAuthorContains(
messages: Array<MessageResponse>,
authorId: string,
content: string,
): boolean {
return messages.some((msg) => msg.author.id === authorId && msg.content === content);
}
export function containsMessageContent(messages: Array<MessageResponse>, content: string): boolean {
return messages.some((msg) => msg.content === content);
}
export async function createChannelInvite(
harness: ApiTestHarness,
token: string,
channelId: string,
): Promise<{code: string}> {
const {json} = await createBuilder<{code: string}>(harness, token)
.post(`/channels/${channelId}/invites`)
.body({
max_age: 86400,
})
.expect(200)
.executeWithResponse();
return json;
}
export async function joinGuild(harness: ApiTestHarness, token: string, inviteCode: string): Promise<void> {
await createBuilder<void>(harness, token).post(`/invites/${inviteCode}`).body({}).expect(200).execute();
}
export async function removeGuildMember(
harness: ApiTestHarness,
token: string,
guildId: string,
userId: string,
): Promise<void> {
await createBuilder<void>(harness, token).delete(`/guilds/${guildId}/members/${userId}`).expect(204).execute();
}

View File

@@ -0,0 +1,65 @@
/*
* 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 {
createGuildChannel,
enableMessageSchedulingForGuild,
scheduleMessage,
} from '@fluxer/api/src/channel/tests/ScheduledMessageTestUtils';
import {createGuild} 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 {createBuilder} from '@fluxer/api/src/test/TestRequestBuilder';
import {beforeAll, beforeEach, describe, expect, it} from 'vitest';
describe('Scheduled message trait gating', () => {
let harness: ApiTestHarness;
beforeAll(async () => {
harness = await createApiTestHarness();
});
beforeEach(async () => {
await harness.reset();
});
it('rejects scheduling message before trait enabled', async () => {
const owner = await createTestAccount(harness);
const guild = await createGuild(harness, owner.token, 'scheduled-flag');
const channel = await createGuildChannel(harness, owner.token, guild.id, 'scheduled-channel');
await ensureSessionStarted(harness, owner.token);
await createBuilder(harness, owner.token)
.post(`/channels/${channel.id}/messages/schedule`)
.body({
content: 'trying to schedule',
scheduled_local_at: new Date(Date.now() + 60 * 1000).toISOString(),
timezone: 'UTC',
})
.expect(403)
.execute();
await enableMessageSchedulingForGuild(harness, guild.id);
const scheduled = await scheduleMessage(harness, channel.id, owner.token, 'enabled now');
expect(scheduled.id).toBeDefined();
expect(scheduled.id).not.toBe('');
});
});

View File

@@ -0,0 +1,148 @@
/*
* 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 {
createGuildChannel,
enableMessageSchedulingForGuild,
scheduleMessage,
} from '@fluxer/api/src/channel/tests/ScheduledMessageTestUtils';
import {createGuild} 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 {
generateFutureTimestamp,
generatePastTimestamp,
HTTP_STATUS,
TEST_LIMITS,
} from '@fluxer/api/src/test/TestConstants';
import {createBuilder} from '@fluxer/api/src/test/TestRequestBuilder';
import {beforeAll, beforeEach, describe, expect, it} from 'vitest';
describe('Scheduled message validation', () => {
let harness: ApiTestHarness;
beforeAll(async () => {
harness = await createApiTestHarness();
});
beforeEach(async () => {
await harness.reset();
});
it('rejects scheduling message with past time', async () => {
const owner = await createTestAccount(harness);
const guild = await createGuild(harness, owner.token, 'sched-validation-past');
await enableMessageSchedulingForGuild(harness, guild.id);
const channel = await createGuildChannel(harness, owner.token, guild.id, 'test');
await ensureSessionStarted(harness, owner.token);
const pastTime = generatePastTimestamp(1);
const {json} = await createBuilder<{errors: Array<{path: string; message: string; code: string}>}>(
harness,
owner.token,
)
.post(`/channels/${channel.id}/messages/schedule`)
.body({
content: 'should fail - past time',
scheduled_local_at: pastTime,
timezone: 'UTC',
})
.expect(HTTP_STATUS.BAD_REQUEST)
.executeWithResponse();
const errorJson = json as {errors: Array<{path: string; message: string; code: string}>};
const hasScheduledAtError = errorJson.errors.some((e) => e.path === 'scheduled_local_at');
expect(hasScheduledAtError).toBe(true);
});
it('rejects scheduling message exceeding 30 days', async () => {
const owner = await createTestAccount(harness);
const guild = await createGuild(harness, owner.token, 'sched-validation-30day');
await enableMessageSchedulingForGuild(harness, guild.id);
const channel = await createGuildChannel(harness, owner.token, guild.id, 'test');
await ensureSessionStarted(harness, owner.token);
const futureTime = new Date(Date.now() + TEST_LIMITS.SCHEDULED_MESSAGE_MAX_DELAY_MS).toISOString();
const {json} = await createBuilder<{errors: Array<{path: string; message: string; code: string}>}>(
harness,
owner.token,
)
.post(`/channels/${channel.id}/messages/schedule`)
.body({
content: 'should fail - exceeds 30 days',
scheduled_local_at: futureTime,
timezone: 'UTC',
})
.expect(HTTP_STATUS.BAD_REQUEST)
.executeWithResponse();
const errorJson = json as {errors: Array<{path: string; message: string; code: string}>};
const hasScheduledAtError = errorJson.errors.some((e) => e.path === 'scheduled_local_at');
expect(hasScheduledAtError).toBe(true);
});
it('rejects scheduling message with invalid timezone', async () => {
const owner = await createTestAccount(harness);
const guild = await createGuild(harness, owner.token, 'sched-validation-tz');
await enableMessageSchedulingForGuild(harness, guild.id);
const channel = await createGuildChannel(harness, owner.token, guild.id, 'test');
await ensureSessionStarted(harness, owner.token);
const futureTime = generateFutureTimestamp(5);
const {json} = await createBuilder<{errors: Array<{path: string; message: string; code: string}>}>(
harness,
owner.token,
)
.post(`/channels/${channel.id}/messages/schedule`)
.body({
content: 'should fail - invalid timezone',
scheduled_local_at: futureTime,
timezone: 'Invalid/NotATimezone',
})
.expect(HTTP_STATUS.BAD_REQUEST)
.executeWithResponse();
const errorJson = json as {errors: Array<{path: string; message: string; code: string}>};
const hasTimezoneError = errorJson.errors.some((e) => e.path === 'timezone');
expect(hasTimezoneError).toBe(true);
});
it('accepts scheduling message at 30 day boundary', async () => {
const owner = await createTestAccount(harness);
const guild = await createGuild(harness, owner.token, 'sched-validation-boundary');
await enableMessageSchedulingForGuild(harness, guild.id);
const channel = await createGuildChannel(harness, owner.token, guild.id, 'test');
const futureTime = new Date(Date.now() + 29 * 24 * 60 * 60 * 1000 + 23 * 60 * 60 * 1000).toISOString();
const scheduled = await scheduleMessage(
harness,
channel.id,
owner.token,
'should succeed - within 30 days',
new Date(futureTime),
);
expect(scheduled.status).toBe('pending');
});
});

View File

@@ -0,0 +1,132 @@
/*
* 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 {
createChannelInvite,
createGuildChannel,
enableMessageSchedulingForGuild,
getChannelMessages,
getScheduledMessage,
joinGuild,
messageFromAuthorContains,
removeGuildMember,
scheduleMessage,
triggerScheduledMessageWorker,
updateScheduledMessage,
} from '@fluxer/api/src/channel/tests/ScheduledMessageTestUtils';
import {createGuild} from '@fluxer/api/src/guild/tests/GuildTestUtils';
import {type ApiTestHarness, createApiTestHarness} from '@fluxer/api/src/test/ApiTestHarness';
import {beforeAll, beforeEach, describe, expect, it} from 'vitest';
describe('Scheduled message worker lifecycle', () => {
let harness: ApiTestHarness;
beforeAll(async () => {
harness = await createApiTestHarness();
});
beforeEach(async () => {
await harness.reset();
});
it('delivers scheduled message when permissions remain', async () => {
const owner = await createTestAccount(harness);
const guild = await createGuild(harness, owner.token, 'scheduled-messages');
await enableMessageSchedulingForGuild(harness, guild.id);
const channel = await createGuildChannel(harness, owner.token, guild.id, 'scheduled');
const content = 'scheduled message goes through';
const scheduled = await scheduleMessage(harness, channel.id, owner.token, content);
await triggerScheduledMessageWorker(harness, owner.userId, scheduled.id);
const fetched = await getScheduledMessage(harness, scheduled.id, owner.token, 404);
expect(fetched).toBeNull();
const messages = await getChannelMessages(harness, channel.id, owner.token);
expect(messageFromAuthorContains(messages, owner.userId, content)).toBe(true);
});
it('reschedules pending message before worker execution', async () => {
const owner = await createTestAccount(harness);
const guild = await createGuild(harness, owner.token, 'scheduled-messages');
await enableMessageSchedulingForGuild(harness, guild.id);
const channel = await createGuildChannel(harness, owner.token, guild.id, 'scheduled');
const content = 'scheduled message initial content';
const scheduled = await scheduleMessage(harness, channel.id, owner.token, content);
const oldScheduledAt = new Date(scheduled.scheduled_at);
const updatedContent = 'scheduled message updated content';
const newLocalTime = new Date(Date.now() + 5 * 60 * 1000);
const newLocalStr = newLocalTime.toISOString();
const updated = await updateScheduledMessage(harness, scheduled.id, owner.token, {
content: updatedContent,
scheduled_local_at: newLocalStr,
timezone: 'America/Los_Angeles',
});
expect(updated.status).toBe('pending');
expect(updated.scheduled_local_at).toBe(newLocalStr);
expect(updated.timezone).toBe('America/Los_Angeles');
const updatedScheduledAt = new Date(updated.scheduled_at);
expect(updatedScheduledAt.getTime()).toBeGreaterThan(oldScheduledAt.getTime());
await triggerScheduledMessageWorker(harness, owner.userId, updated.id);
const respMessages = await getChannelMessages(harness, channel.id, owner.token);
expect(messageFromAuthorContains(respMessages, owner.userId, updatedContent)).toBe(true);
expect(messageFromAuthorContains(respMessages, owner.userId, content)).toBe(false);
const fetched = await getScheduledMessage(harness, updated.id, owner.token, 404);
expect(fetched).toBeNull();
});
it('marks scheduled message invalid when access lost', async () => {
const owner = await createTestAccount(harness);
const member = await createTestAccount(harness);
const guild = await createGuild(harness, owner.token, 'scheduled-messages');
await enableMessageSchedulingForGuild(harness, guild.id);
const channel = await createGuildChannel(harness, owner.token, guild.id, 'scheduled');
const invite = await createChannelInvite(harness, owner.token, channel.id);
await joinGuild(harness, member.token, invite.code);
const content = 'scheduled message invalidation';
const scheduled = await scheduleMessage(harness, channel.id, member.token, content);
await removeGuildMember(harness, owner.token, guild.id, member.userId);
await triggerScheduledMessageWorker(harness, member.userId, scheduled.id);
const fetched = await getScheduledMessage(harness, scheduled.id, member.token);
expect(fetched).not.toBeNull();
expect(fetched!.status).toBe('invalid');
expect(fetched!.status_reason).not.toBeNull();
expect(fetched!.status_reason).not.toBe('');
const messages = await getChannelMessages(harness, channel.id, owner.token);
const hasContent = messages.some((msg) => msg.content === content);
expect(hasContent).toBe(false);
});
});

View File

@@ -0,0 +1,71 @@
/*
* 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 {
createChannelInvite,
createGuildChannel,
enableMessageSchedulingForGuild,
getScheduledMessages,
joinGuild,
removeGuildMember,
scheduleMessage,
triggerScheduledMessageWorker,
} from '@fluxer/api/src/channel/tests/ScheduledMessageTestUtils';
import {createGuild} from '@fluxer/api/src/guild/tests/GuildTestUtils';
import {type ApiTestHarness, createApiTestHarness} from '@fluxer/api/src/test/ApiTestHarness';
import {beforeAll, beforeEach, describe, expect, it} from 'vitest';
describe('Scheduled messages list invalid entry', () => {
let harness: ApiTestHarness;
beforeAll(async () => {
harness = await createApiTestHarness();
});
beforeEach(async () => {
await harness.reset();
});
it('shows invalid scheduled message in list', async () => {
const owner = await createTestAccount(harness);
const member = await createTestAccount(harness);
const guild = await createGuild(harness, owner.token, 'scheduled-invalid');
await enableMessageSchedulingForGuild(harness, guild.id);
const channel = await createGuildChannel(harness, owner.token, guild.id, 'scheduled-invalid');
const invite = await createChannelInvite(harness, owner.token, channel.id);
await joinGuild(harness, member.token, invite.code);
const content = 'invalid scheduled';
const scheduled = await scheduleMessage(harness, channel.id, member.token, content);
await removeGuildMember(harness, owner.token, guild.id, member.userId);
await triggerScheduledMessageWorker(harness, member.userId, scheduled.id);
const list = await getScheduledMessages(harness, member.token);
const entry = list.find((e) => e.id === scheduled.id);
expect(entry).toBeDefined();
expect(entry!.status).toBe('invalid');
expect(entry!.status_reason).not.toBeNull();
expect(entry!.status_reason).not.toBe('');
});
});

View File

@@ -0,0 +1,62 @@
/*
* 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 {
cancelScheduledMessage,
createGuildChannel,
enableMessageSchedulingForGuild,
getScheduledMessages,
scheduleMessage,
} from '@fluxer/api/src/channel/tests/ScheduledMessageTestUtils';
import {createGuild} from '@fluxer/api/src/guild/tests/GuildTestUtils';
import {type ApiTestHarness, createApiTestHarness} from '@fluxer/api/src/test/ApiTestHarness';
import {beforeAll, beforeEach, describe, expect, it} from 'vitest';
describe('Scheduled messages list lifecycle', () => {
let harness: ApiTestHarness;
beforeAll(async () => {
harness = await createApiTestHarness();
});
beforeEach(async () => {
await harness.reset();
});
it('lists scheduled messages and removes after cancel', async () => {
const owner = await createTestAccount(harness);
const guild = await createGuild(harness, owner.token, 'scheduled-list');
await enableMessageSchedulingForGuild(harness, guild.id);
const channel = await createGuildChannel(harness, owner.token, guild.id, 'scheduled-list');
const content = 'list scheduled';
const scheduled = await scheduleMessage(harness, channel.id, owner.token, content);
const list = await getScheduledMessages(harness, owner.token);
const found = list.some((entry) => entry.id === scheduled.id);
expect(found).toBe(true);
await cancelScheduledMessage(harness, scheduled.id, owner.token);
const listAfterCancel = await getScheduledMessages(harness, owner.token);
const foundAfterCancel = listAfterCancel.some((entry) => entry.id === scheduled.id);
expect(foundAfterCancel).toBe(false);
});
});

View 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,
createChannel,
createChannelInvite,
createGuild,
createPermissionOverwrite,
} from '@fluxer/api/src/channel/tests/ChannelTestUtils';
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 {ChannelTypes, Permissions} from '@fluxer/constants/src/ChannelConstants';
import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest';
const STREAM_PREVIEW_BASE64 = 'dGVzdC1zdHJlYW0tcHJldmlldw==';
const STREAM_PREVIEW_TEXT = 'test-stream-preview';
function createGuildStreamKey(guildId: string, channelId: string): string {
return `${guildId}:${channelId}:test-connection`;
}
function createStreamPreviewPath(streamKey: string): string {
return `/streams/${streamKey}/preview`;
}
async function uploadStreamPreview(
harness: ApiTestHarness,
token: string,
streamKey: string,
channelId: string,
): Promise<void> {
await createBuilder(harness, token)
.post(createStreamPreviewPath(streamKey))
.body({
channel_id: channelId,
thumbnail: STREAM_PREVIEW_BASE64,
content_type: 'image/jpeg',
})
.expect(HTTP_STATUS.NO_CONTENT)
.execute();
}
describe('Stream preview auth', () => {
let harness: ApiTestHarness;
beforeAll(async () => {
harness = await createApiTestHarness();
});
beforeEach(async () => {
await harness.reset();
});
afterAll(async () => {
await harness?.shutdown();
});
it('allows preview access for a guild member with CONNECT permission', async () => {
const owner = await createTestAccount(harness);
const member = await createTestAccount(harness);
const guild = await createGuild(harness, owner.token, 'Test Guild');
const voiceChannel = await createChannel(harness, owner.token, guild.id, 'stream-voice', ChannelTypes.GUILD_VOICE);
const streamKey = createGuildStreamKey(guild.id, voiceChannel.id);
const invite = await createChannelInvite(harness, owner.token, guild.system_channel_id!);
await acceptInvite(harness, member.token, invite.code);
await createPermissionOverwrite(harness, owner.token, voiceChannel.id, member.userId, {
type: 1,
allow: Permissions.CONNECT.toString(),
deny: '0',
});
await uploadStreamPreview(harness, owner.token, streamKey, voiceChannel.id);
const {response, text} = await createBuilder(harness, member.token)
.get(createStreamPreviewPath(streamKey))
.expect(HTTP_STATUS.OK)
.executeRaw();
expect(response.headers.get('content-type')).toBe('image/jpeg');
expect(text).toBe(STREAM_PREVIEW_TEXT);
});
it('rejects preview access for a user outside the guild', async () => {
const owner = await createTestAccount(harness);
const outsider = await createTestAccount(harness);
const guild = await createGuild(harness, owner.token, 'Test Guild');
const voiceChannel = await createChannel(harness, owner.token, guild.id, 'stream-voice', ChannelTypes.GUILD_VOICE);
const streamKey = createGuildStreamKey(guild.id, voiceChannel.id);
await uploadStreamPreview(harness, owner.token, streamKey, voiceChannel.id);
await createBuilder(harness, outsider.token)
.get(createStreamPreviewPath(streamKey))
.expect(HTTP_STATUS.FORBIDDEN, APIErrorCodes.ACCESS_DENIED)
.execute();
});
it('rejects preview access when CONNECT is denied in the stream channel', async () => {
const owner = await createTestAccount(harness);
const member = await createTestAccount(harness);
const guild = await createGuild(harness, owner.token, 'Test Guild');
const voiceChannel = await createChannel(harness, owner.token, guild.id, 'stream-voice', ChannelTypes.GUILD_VOICE);
const streamKey = createGuildStreamKey(guild.id, voiceChannel.id);
const invite = await createChannelInvite(harness, owner.token, guild.system_channel_id!);
await acceptInvite(harness, member.token, invite.code);
await createPermissionOverwrite(harness, owner.token, voiceChannel.id, member.userId, {
type: 1,
allow: '0',
deny: Permissions.CONNECT.toString(),
});
await uploadStreamPreview(harness, owner.token, streamKey, voiceChannel.id);
await createBuilder(harness, member.token)
.get(createStreamPreviewPath(streamKey))
.expect(HTTP_STATUS.FORBIDDEN, APIErrorCodes.MISSING_PERMISSIONS)
.execute();
});
});

View File

@@ -0,0 +1,189 @@
/*
* 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,
createFriendship,
createGuild,
createPermissionOverwrite,
createRole,
setupTestGuildWithMembers,
} from '@fluxer/api/src/channel/tests/ChannelTestUtils';
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, test} from 'vitest';
async function sendTypingIndicator(harness: ApiTestHarness, token: string, channelId: string): Promise<void> {
await createBuilder<void>(harness, token).post(`/channels/${channelId}/typing`).body({}).expect(204).execute();
}
async function createDmChannel(harness: ApiTestHarness, token: string, recipientId: string): Promise<{id: string}> {
return createBuilder<{id: string}>(harness, token)
.post('/users/@me/channels')
.body({recipient_id: recipientId})
.execute();
}
describe('Typing Indicators', () => {
let harness: ApiTestHarness;
beforeEach(async () => {
harness = await createApiTestHarness();
});
afterEach(async () => {
await harness?.shutdown();
});
test('should send typing indicator in DM channel', async () => {
const user1 = await createTestAccount(harness);
const user2 = await createTestAccount(harness);
await createFriendship(harness, user1, user2);
const dmChannel = await createDmChannel(harness, user1.token, user2.userId);
await sendTypingIndicator(harness, user1.token, dmChannel.id);
});
test('should send typing indicator in guild channel as member', async () => {
const {members, systemChannel} = await setupTestGuildWithMembers(harness, 1);
const member = members[0];
await sendTypingIndicator(harness, member.token, systemChannel.id);
});
test('should reject typing indicator from user not in DM channel', async () => {
const user1 = await createTestAccount(harness);
const user2 = await createTestAccount(harness);
const user3 = await createTestAccount(harness);
await createFriendship(harness, user1, user2);
const dmChannel = await createDmChannel(harness, user1.token, user2.userId);
await createBuilder(harness, user3.token)
.post(`/channels/${dmChannel.id}/typing`)
.body({})
.expect(HTTP_STATUS.NOT_FOUND)
.execute();
});
test('should reject typing indicator from non-member in guild channel', async () => {
const account = await createTestAccount(harness);
const nonMember = await createTestAccount(harness);
const guild = await createGuild(harness, account.token, 'Typing Test Guild');
const channelId = guild.system_channel_id!;
await createBuilder(harness, nonMember.token)
.post(`/channels/${channelId}/typing`)
.body({})
.expect(HTTP_STATUS.FORBIDDEN)
.execute();
});
test('should require SEND_MESSAGES permission for typing indicator', async () => {
const {owner, members, systemChannel} = await setupTestGuildWithMembers(harness, 1);
const member = members[0];
await createPermissionOverwrite(harness, owner.token, systemChannel.id, member.userId, {
type: 1,
allow: '0',
deny: Permissions.SEND_MESSAGES.toString(),
});
await createBuilder(harness, member.token)
.post(`/channels/${systemChannel.id}/typing`)
.body({})
.expect(HTTP_STATUS.FORBIDDEN)
.execute();
});
test('should allow typing with SEND_MESSAGES permission from role', async () => {
const {owner, members, guild, systemChannel} = await setupTestGuildWithMembers(harness, 1);
const member = members[0];
const typingRole = await createRole(harness, owner.token, guild.id, {
name: 'Can Type',
permissions: Permissions.SEND_MESSAGES.toString(),
});
await createBuilder<void>(harness, owner.token)
.put(`/guilds/${guild.id}/members/${member.userId}/roles/${typingRole.id}`)
.expect(HTTP_STATUS.NO_CONTENT)
.execute();
await sendTypingIndicator(harness, member.token, systemChannel.id);
});
test('should reject typing indicator for unknown channel', async () => {
const account = await createTestAccount(harness);
await createBuilder(harness, account.token)
.post('/channels/999999999999999999/typing')
.body({})
.expect(HTTP_STATUS.NOT_FOUND)
.execute();
});
test('owner can always send typing indicator', async () => {
const account = await createTestAccount(harness);
const guild = await createGuild(harness, account.token, 'Owner Typing Test');
const channelId = guild.system_channel_id!;
await sendTypingIndicator(harness, account.token, channelId);
});
test('both participants can send typing indicator in DM', async () => {
const user1 = await createTestAccount(harness);
const user2 = await createTestAccount(harness);
await createFriendship(harness, user1, user2);
const dmChannel = await createDmChannel(harness, user1.token, user2.userId);
await sendTypingIndicator(harness, user1.token, dmChannel.id);
await sendTypingIndicator(harness, user2.token, dmChannel.id);
});
test('should send typing indicator in created channel', async () => {
const account = await createTestAccount(harness);
const guild = await createGuild(harness, account.token, 'Channel Typing Test');
const channel = await createChannel(harness, account.token, guild.id, 'typing-test');
await sendTypingIndicator(harness, account.token, channel.id);
});
test('should reject typing indicator without authorization', async () => {
const account = await createTestAccount(harness);
const guild = await createGuild(harness, account.token, 'No Auth Test');
const channelId = guild.system_channel_id!;
await createBuilderWithoutAuth(harness)
.post(`/channels/${channelId}/typing`)
.body({})
.expect(HTTP_STATUS.UNAUTHORIZED)
.execute();
});
});