mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-14 10:39:32 +01:00
Merge pull request #741: feat(discord): two-way Discord bridge with a thread per tab
This commit is contained in:
commit
7c8d440928
39 files changed
+4940
-24
No files matched your search
@@ -4,7 +4,7 @@
|
||||
*
|
||||
* ⚠️ test/setup.ts gives the whole FILE one temp HOME (and so one data dir). Port: N/A (inject()).
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { dirname, join } from 'node:path';
|
||||
@@ -120,6 +120,22 @@ describe('/api/config-backups', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('a restored discord.json reaches the running bridge (it caches its config)', async () => {
|
||||
const reloadDiscord = vi.fn(async () => undefined);
|
||||
const { app } = await createRouteTestHarness((a) => registerConfigBackupRoutes(a, { reloadDiscord }));
|
||||
writeFileSync(join(dirname(SETTINGS_PATH), 'discord.json'), '{"enabled":false}', { mode: 0o600 });
|
||||
const id = (await app.inject({ method: 'POST', url: '/api/config-backups' })).json().data.id;
|
||||
writeFileSync(join(dirname(SETTINGS_PATH), 'discord.json'), '{"enabled":true}', { mode: 0o600 });
|
||||
const res = await app.inject({ method: 'POST', url: `/api/config-backups/${id}/restore` });
|
||||
expect(res.json().data.restored).toContain('discord.json');
|
||||
expect(reloadDiscord).toHaveBeenCalledTimes(1);
|
||||
// A restore that does not carry the file leaves the bridge alone.
|
||||
rmSync(join(dirname(SETTINGS_PATH), 'discord.json'), { force: true });
|
||||
const id2 = (await app.inject({ method: 'POST', url: '/api/config-backups' })).json().data.id;
|
||||
await app.inject({ method: 'POST', url: `/api/config-backups/${id2}/restore` });
|
||||
expect(reloadDiscord).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('a restored intents.json is not reverted by the next captured prompt', async () => {
|
||||
const { app } = await createRouteTestHarness(registerConfigBackupRoutes);
|
||||
const dir = mkdtempSync(join(tmpdir(), 'cfg-backup-intent-'));
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
/**
|
||||
* @fileoverview The last message a CLI wrote, read through the response viewer's reader (the one the
|
||||
* Discord bridge posts from), with a message that asks SEVERAL questions: every question must come
|
||||
* back whole, in order, on its own line, and survive the split into Discord posts.
|
||||
*
|
||||
* Uses app.inject() with a temporary HOME and CODEX_HOME; no real ports or user transcripts.
|
||||
* Port: N/A
|
||||
*
|
||||
* Covered here: claude (JSONL transcript), codex (rollout), the codex-shaped pane CLIs (opencode,
|
||||
* gemini, antigravity, pi) and copilot (its own pane dialect). deepseek and omp read their own
|
||||
* transcript formats and are covered by their reader's tests.
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import Fastify, { type FastifyInstance } from 'fastify';
|
||||
import fastifyCookie from '@fastify/cookie';
|
||||
import { mkdirSync, mkdtempSync, rmSync, utimesSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { installRouteErrorHandler } from '../../src/web/route-error-handler.js';
|
||||
import { registerSessionRoutes } from '../../src/web/routes/session-routes.js';
|
||||
import { splitForDiscord } from '../../src/discord-bridge.js';
|
||||
import { createMockRouteContext, type MockRouteContext } from '../mocks/index.js';
|
||||
|
||||
const QUESTIONS = [
|
||||
'Should the sweep run daily or hourly?',
|
||||
'Do you want archived threads deleted as well?',
|
||||
'Is one channel per Codeman acceptable?',
|
||||
];
|
||||
const INTRO = 'I have three questions before I continue:';
|
||||
const OUTRO = 'Once you answer, I will apply the change.';
|
||||
|
||||
/** The message as the CLI wrote it: an intro, a numbered list of questions, an outro. */
|
||||
const MESSAGE = [INTRO, '', ...QUESTIONS.map((q, i) => `${i + 1}. ${q}`), '', OUTRO].join('\n');
|
||||
|
||||
/** Every question present, in order, each on a line of its own. */
|
||||
function expectWholeQuestions(text: string): void {
|
||||
let from = 0;
|
||||
for (const q of QUESTIONS) {
|
||||
const at = text.indexOf(q, from);
|
||||
expect(at, `missing or out of order: ${q}`).toBeGreaterThanOrEqual(from);
|
||||
from = at + q.length;
|
||||
}
|
||||
const lines = text.split('\n').map((l) => l.trim());
|
||||
for (const [i, q] of QUESTIONS.entries()) expect(lines.some((l) => l.endsWith(q))).toBe(true);
|
||||
expect(text).toContain(INTRO);
|
||||
expect(text).toContain(OUTRO);
|
||||
// What goes to Discord: the same questions after the split, joined back.
|
||||
expectWholeQuestionsAfterSplit(text);
|
||||
}
|
||||
|
||||
function expectWholeQuestionsAfterSplit(text: string): void {
|
||||
const joined = splitForDiscord(text).join('\n');
|
||||
for (const q of QUESTIONS) expect(joined).toContain(q);
|
||||
}
|
||||
|
||||
async function createHarness(): Promise<{ app: FastifyInstance; ctx: MockRouteContext }> {
|
||||
const app = Fastify({ logger: false });
|
||||
await app.register(fastifyCookie);
|
||||
const ctx = createMockRouteContext();
|
||||
registerSessionRoutes(app, ctx);
|
||||
app.addHook('preSerialization', (req, _reply, payload: unknown, done) => {
|
||||
if (!req.url.startsWith('/api') || payload === null || typeof payload !== 'object') return done(null, payload);
|
||||
const p = payload as { success?: unknown };
|
||||
return done(null, p.success === undefined ? { success: true, data: payload } : payload);
|
||||
});
|
||||
installRouteErrorHandler(app);
|
||||
await app.ready();
|
||||
return { app, ctx };
|
||||
}
|
||||
|
||||
describe('the last message with several questions, per CLI', () => {
|
||||
let harness: { app: FastifyInstance; ctx: MockRouteContext };
|
||||
let home: string;
|
||||
let prevHome: string | undefined;
|
||||
let prevCodexHome: string | undefined;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let session: any;
|
||||
|
||||
async function lastText(): Promise<string> {
|
||||
const res = await harness.app.inject({ method: 'GET', url: `/api/sessions/${session.id}/last-response` });
|
||||
return JSON.parse(res.body).data.text as string;
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
home = mkdtempSync(join(tmpdir(), 'codeman-discord-last-'));
|
||||
prevHome = process.env.HOME;
|
||||
prevCodexHome = process.env.CODEX_HOME;
|
||||
process.env.HOME = home;
|
||||
process.env.CODEX_HOME = join(home, '.codex');
|
||||
harness = await createHarness();
|
||||
session = harness.ctx._session;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (prevHome === undefined) delete process.env.HOME;
|
||||
else process.env.HOME = prevHome;
|
||||
if (prevCodexHome === undefined) delete process.env.CODEX_HOME;
|
||||
else process.env.CODEX_HOME = prevCodexHome;
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
await harness.app.close();
|
||||
});
|
||||
|
||||
it('claude: reads the whole message from the JSONL transcript', async () => {
|
||||
const id = '40568a29-d4eb-4eb6-b671-8401428e4f39';
|
||||
session.mode = 'claude';
|
||||
session.claudeSessionId = id;
|
||||
session.adoptClaudeSessionId = () => undefined;
|
||||
const dir = join(home, '.claude', 'projects', '-workspace');
|
||||
mkdirSync(dir, { recursive: true });
|
||||
const row = (type: string, text: string, ts: string) => ({
|
||||
type,
|
||||
timestamp: ts,
|
||||
message: { content: [{ type: 'text', text }] },
|
||||
});
|
||||
writeFileSync(
|
||||
join(dir, `${id}.jsonl`),
|
||||
[row('user', 'plan the cleanup', '2026-10-30T00:00:00Z'), row('assistant', MESSAGE, '2026-10-30T00:00:01Z')]
|
||||
.map((e) => JSON.stringify(e))
|
||||
.join('\n')
|
||||
);
|
||||
const text = await lastText();
|
||||
expect(text).toBe(MESSAGE);
|
||||
expectWholeQuestions(text);
|
||||
});
|
||||
|
||||
it('codex: reads the whole message from the rollout', async () => {
|
||||
const uuid = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa';
|
||||
const workdir = join(home, 'work');
|
||||
mkdirSync(workdir, { recursive: true });
|
||||
session.mode = 'codex';
|
||||
session.workingDir = workdir;
|
||||
session.codexConfig = { resumeSessionId: uuid };
|
||||
const dir = join(home, '.codex', 'sessions', '2026', '10', '30');
|
||||
mkdirSync(dir, { recursive: true });
|
||||
const file = join(dir, `rollout-2026-10-30T00-00-00-${uuid}.jsonl`);
|
||||
const entries = [
|
||||
{
|
||||
timestamp: '2026-10-30T00:00:00Z',
|
||||
type: 'session_meta',
|
||||
payload: { id: uuid, cwd: workdir, originator: 'codex_cli_rs' },
|
||||
},
|
||||
{
|
||||
timestamp: '2026-10-30T00:00:01Z',
|
||||
type: 'event_msg',
|
||||
payload: { type: 'user_message', message: 'plan the cleanup' },
|
||||
},
|
||||
{
|
||||
timestamp: '2026-10-30T00:00:02Z',
|
||||
type: 'response_item',
|
||||
payload: { type: 'message', role: 'assistant', content: [{ type: 'output_text', text: MESSAGE }] },
|
||||
},
|
||||
];
|
||||
writeFileSync(file, entries.map((e) => JSON.stringify(e)).join('\n') + '\n');
|
||||
utimesSync(file, 1_750_000_000, 1_750_000_000);
|
||||
const text = await lastText();
|
||||
expectWholeQuestions(text);
|
||||
});
|
||||
|
||||
for (const mode of ['opencode', 'gemini', 'antigravity', 'pi'] as const) {
|
||||
it(`${mode}: reads the whole message from the pane`, async () => {
|
||||
session.mode = mode;
|
||||
session.terminalBuffer = [
|
||||
'› plan the cleanup',
|
||||
'',
|
||||
'model · project · main · Ready · Context 100% left',
|
||||
'',
|
||||
'• Called write_file',
|
||||
'',
|
||||
MESSAGE,
|
||||
].join('\n');
|
||||
expectWholeQuestions(await lastText());
|
||||
});
|
||||
}
|
||||
|
||||
it('copilot: reads the whole message from the pane screen', async () => {
|
||||
session.mode = 'copilot';
|
||||
const screen = [
|
||||
' ❯ plan the cleanup 10:01',
|
||||
'',
|
||||
` ● ${INTRO}`,
|
||||
'',
|
||||
...QUESTIONS.map((q, i) => ` ${i + 1}. ${q}`),
|
||||
'',
|
||||
` ${OUTRO}`,
|
||||
'',
|
||||
' ~/project Session: 1 AIC used',
|
||||
'────────────────────────────────',
|
||||
' ❯ ',
|
||||
'────────────────────────────────',
|
||||
' ← open sidebar · / commands · ? help',
|
||||
].join('\n');
|
||||
harness.ctx.mux = { ...(harness.ctx.mux as object), capturePaneText: () => screen } as typeof harness.ctx.mux;
|
||||
expectWholeQuestions(await lastText());
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,328 @@
|
||||
/**
|
||||
* @fileoverview /api/discord: the bot token is a bearer secret, so it is never returned and the
|
||||
* settings routes are admin only in multi-user mode. Port: N/A (app.inject()).
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { mkdirSync, mkdtempSync, rmSync, statSync, symlinkSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { createRouteTestHarness } from './_route-test-utils.js';
|
||||
import { _resetNotifyBuckets, registerDiscordRoutes } from '../../src/web/routes/discord-routes.js';
|
||||
import { DiscordBridge, discordConfigPath, readDiscordConfig, type DiscordFetch } from '../../src/discord-bridge.js';
|
||||
|
||||
const TOKEN = 'MTA1.secret.token-value-1234567890';
|
||||
const FULL = {
|
||||
enabled: true,
|
||||
token: TOKEN,
|
||||
guildId: '111111111111111111',
|
||||
parentChannelId: '222222222222222222',
|
||||
allowedUserIds: ['333333333333333333'],
|
||||
};
|
||||
|
||||
let dir: string;
|
||||
let fetchImpl: ReturnType<typeof vi.fn<DiscordFetch>>;
|
||||
|
||||
async function harness(authUser?: { username: string; role: 'admin' | 'user' }) {
|
||||
const bridge = new DiscordBridge({
|
||||
configDir: dir,
|
||||
fetchImpl,
|
||||
sendInput: async () => true,
|
||||
socketFactory: () => ({ send() {}, close() {}, on() {} }),
|
||||
});
|
||||
const h = await createRouteTestHarness(
|
||||
(app, ctx) => registerDiscordRoutes(app, { bridge, configDir: dir, ctx, hostTitle: () => 'codeman:test' }),
|
||||
authUser ? { authUser } : undefined
|
||||
);
|
||||
return { ...h, bridge };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
_resetNotifyBuckets();
|
||||
dir = mkdtempSync(join(tmpdir(), 'discord-routes-'));
|
||||
fetchImpl = vi.fn<DiscordFetch>(
|
||||
async () => new Response(JSON.stringify({ id: '444444444444444444' }), { status: 200 })
|
||||
);
|
||||
});
|
||||
afterEach(() => {
|
||||
delete process.env.CODEMAN_MULTIUSER;
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('/api/discord', () => {
|
||||
it('starts disabled with no token', async () => {
|
||||
const { app } = await harness();
|
||||
const data = (await app.inject({ method: 'GET', url: '/api/discord' })).json().data;
|
||||
expect(data).toMatchObject({ enabled: false, hasToken: false, tokenMasked: '', gateway: 'off' });
|
||||
expect(data).not.toHaveProperty('token');
|
||||
});
|
||||
|
||||
it('saves, masks the token in every response and writes the file 0600', async () => {
|
||||
const { app, bridge } = await harness();
|
||||
const put = await app.inject({ method: 'PUT', url: '/api/discord', payload: FULL });
|
||||
expect(put.statusCode).toBe(200);
|
||||
expect(put.body).not.toContain('secret');
|
||||
expect(put.json().data).toMatchObject({ enabled: true, hasToken: true });
|
||||
const get = await app.inject({ method: 'GET', url: '/api/discord' });
|
||||
expect(get.body).not.toContain('secret');
|
||||
expect(statSync(discordConfigPath(dir)).mode & 0o777).toBe(0o600);
|
||||
expect((await readDiscordConfig(dir)).token).toBe(TOKEN);
|
||||
bridge.stop();
|
||||
});
|
||||
|
||||
it('keeps the token when only an option changes, and clears it on an empty string', async () => {
|
||||
const { app, bridge } = await harness();
|
||||
await app.inject({ method: 'PUT', url: '/api/discord', payload: FULL });
|
||||
await app.inject({
|
||||
method: 'PUT',
|
||||
url: '/api/discord',
|
||||
payload: { pingMode: 'all', onSessionDelete: 'delete', staleThreads: 'archive' },
|
||||
});
|
||||
const cfg = await readDiscordConfig(dir);
|
||||
expect(cfg).toMatchObject({ token: TOKEN, pingMode: 'all', onSessionDelete: 'delete', staleThreads: 'archive' });
|
||||
const cleared = await app.inject({ method: 'PUT', url: '/api/discord', payload: { enabled: false, token: '' } });
|
||||
expect(cleared.json().data.hasToken).toBe(false);
|
||||
bridge.stop();
|
||||
});
|
||||
|
||||
it('stores startupHint (default on) and typedHint (default off) and returns them', async () => {
|
||||
const { app, bridge } = await harness();
|
||||
const first = (await app.inject({ method: 'GET', url: '/api/discord' })).json().data;
|
||||
expect(first).toMatchObject({ startupHint: true, typedHint: false });
|
||||
const put = await app.inject({
|
||||
method: 'PUT',
|
||||
url: '/api/discord',
|
||||
payload: { typedHint: true, startupHint: false },
|
||||
});
|
||||
expect(put.json().data).toMatchObject({ startupHint: false, typedHint: true });
|
||||
bridge.stop();
|
||||
});
|
||||
|
||||
it('refuses a bad id, an incomplete enable, and unknown fields', async () => {
|
||||
const { app } = await harness();
|
||||
const bad = await app.inject({ method: 'PUT', url: '/api/discord', payload: { guildId: 'not-a-number' } });
|
||||
expect(bad.statusCode).toBe(400);
|
||||
const incomplete = await app.inject({ method: 'PUT', url: '/api/discord', payload: { enabled: true } });
|
||||
expect(incomplete.statusCode).toBe(400);
|
||||
const extra = await app.inject({ method: 'PUT', url: '/api/discord', payload: { nope: 1 } });
|
||||
expect(extra.statusCode).toBe(400);
|
||||
});
|
||||
|
||||
it('POST /test needs a saved token, then posts to the parent channel', async () => {
|
||||
const { app, bridge } = await harness();
|
||||
expect((await app.inject({ method: 'POST', url: '/api/discord/test' })).statusCode).toBe(400);
|
||||
await app.inject({ method: 'PUT', url: '/api/discord', payload: FULL });
|
||||
const res = await app.inject({ method: 'POST', url: '/api/discord/test' });
|
||||
expect(res.json().data.ok).toBe(true);
|
||||
expect(String(fetchImpl.mock.calls.at(-1)![0])).toContain('/channels/222222222222222222/messages');
|
||||
bridge.stop();
|
||||
});
|
||||
|
||||
it('is admin only in multi-user mode', async () => {
|
||||
process.env.CODEMAN_MULTIUSER = '1';
|
||||
const { app } = await harness({ username: 'bob', role: 'user' });
|
||||
expect((await app.inject({ method: 'GET', url: '/api/discord' })).statusCode).toBe(403);
|
||||
expect((await app.inject({ method: 'PUT', url: '/api/discord', payload: FULL })).statusCode).toBe(403);
|
||||
expect((await app.inject({ method: 'POST', url: '/api/discord/test' })).statusCode).toBe(403);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/sessions/:id/discord-notify', () => {
|
||||
const url = '/api/sessions/test-session-1/discord-notify';
|
||||
it('is refused while the bridge is off, and posts into the session thread once on', async () => {
|
||||
const { app, bridge } = await harness();
|
||||
const url = `/api/sessions/test-session-1/discord-notify`;
|
||||
const off = await app.inject({ method: 'POST', url, payload: { message: 'press reset' } });
|
||||
expect(off.statusCode).toBe(409);
|
||||
await app.inject({ method: 'PUT', url: '/api/discord', payload: FULL });
|
||||
const on = await app.inject({ method: 'POST', url, payload: { message: 'press reset', urgent: true } });
|
||||
expect(on.json().data.ok).toBe(true);
|
||||
const sent = fetchImpl.mock.calls.map((c) => String(c[1].body));
|
||||
expect(sent.some((b) => b.includes('press reset') && b.includes('<@333333333333333333>'))).toBe(true);
|
||||
bridge.stop();
|
||||
});
|
||||
|
||||
it('accepts a unique 8-character session id prefix (Docker exports it cut), not an ambiguous or short one', async () => {
|
||||
const { app, bridge, ctx } = await harness();
|
||||
await app.inject({ method: 'PUT', url: '/api/discord', payload: FULL });
|
||||
const ok = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/sessions/test-ses/discord-notify',
|
||||
payload: { message: 'hi' },
|
||||
});
|
||||
expect(ok.statusCode).toBe(200);
|
||||
ctx.sessions.set('test-session-2', ctx.sessions.get('test-session-1')!);
|
||||
const ambiguous = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/sessions/test-ses/discord-notify',
|
||||
payload: { message: 'hi' },
|
||||
});
|
||||
expect(ambiguous.statusCode).toBe(404);
|
||||
const short = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/sessions/test/discord-notify',
|
||||
payload: { message: 'hi' },
|
||||
});
|
||||
expect(short.statusCode).toBe(404);
|
||||
bridge.stop();
|
||||
});
|
||||
|
||||
it("does not reveal another user's session through its id prefix (multi-user)", async () => {
|
||||
process.env.CODEMAN_MULTIUSER = '1';
|
||||
const { app, bridge, ctx } = await harness({ username: 'bob', role: 'user' });
|
||||
(ctx.sessions.get('test-session-1') as unknown as { owner: string }).owner = 'alice';
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/sessions/test-ses/discord-notify',
|
||||
payload: { message: 'hi' },
|
||||
});
|
||||
expect(res.statusCode).toBe(404);
|
||||
expect(res.json().error).not.toContain('test-session-1');
|
||||
bridge.stop();
|
||||
});
|
||||
|
||||
it('lets two different deliberate messages from one tab through within 3 s (neither is lost)', async () => {
|
||||
const { app, bridge } = await harness();
|
||||
await app.inject({ method: 'PUT', url: '/api/discord', payload: FULL });
|
||||
const first = await app.inject({ method: 'POST', url, payload: { message: 'first thing' } });
|
||||
const second = await app.inject({ method: 'POST', url, payload: { message: 'second thing' } });
|
||||
expect(first.statusCode).toBe(200);
|
||||
expect(second.statusCode).toBe(200);
|
||||
expect(second.json().data.deduped).toBeUndefined();
|
||||
const sent = fetchImpl.mock.calls.map((c) => String(c[1].body));
|
||||
expect(sent.filter((b) => b.includes('first thing') || b.includes('second thing'))).toHaveLength(2);
|
||||
bridge.stop();
|
||||
});
|
||||
|
||||
it('answers 200 with data.ok false when Discord refuses the post, which the CLI turns into exit 1', async () => {
|
||||
const { app, bridge } = await harness();
|
||||
await app.inject({ method: 'PUT', url: '/api/discord', payload: FULL });
|
||||
fetchImpl.mockImplementation(async () => new Response(JSON.stringify({ code: 0 }), { status: 401 }));
|
||||
const res = await app.inject({ method: 'POST', url, payload: { message: 'help' } });
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.json().data).toMatchObject({ ok: false, status: 401 });
|
||||
bridge.stop();
|
||||
});
|
||||
|
||||
it('answers 429 RATE_LIMITED once a tab posts more than its share in a minute', async () => {
|
||||
const { app, bridge } = await harness();
|
||||
await app.inject({ method: 'PUT', url: '/api/discord', payload: FULL });
|
||||
const codes: number[] = [];
|
||||
for (let i = 0; i < 8; i++) {
|
||||
codes.push(
|
||||
(await app.inject({ method: 'POST', url, payload: { message: `ping ${i}`, urgent: true } })).statusCode
|
||||
);
|
||||
}
|
||||
expect(codes.slice(0, 6)).toEqual([200, 200, 200, 200, 200, 200]);
|
||||
expect(codes.slice(6)).toEqual([429, 429]);
|
||||
const limited = await app.inject({ method: 'POST', url, payload: { message: 'again' } });
|
||||
expect(limited.json().errorCode).toBe('RATE_LIMITED');
|
||||
expect(limited.headers['retry-after']).toBe('60');
|
||||
bridge.stop();
|
||||
});
|
||||
|
||||
it('does not spend rate-limit tokens on a request refused for its file', async () => {
|
||||
const { app, bridge } = await harness();
|
||||
await app.inject({ method: 'PUT', url: '/api/discord', payload: FULL });
|
||||
for (let i = 0; i < 8; i++) {
|
||||
const bad = await app.inject({ method: 'POST', url, payload: { message: 'x', file: 'no-such-file.txt' } });
|
||||
expect(bad.statusCode).toBe(400);
|
||||
}
|
||||
expect((await app.inject({ method: 'POST', url, payload: { message: 'still allowed' } })).statusCode).toBe(200);
|
||||
bridge.stop();
|
||||
});
|
||||
|
||||
it('rejects an empty or oversized message', async () => {
|
||||
const { app } = await harness();
|
||||
const url = `/api/sessions/test-session-1/discord-notify`;
|
||||
expect((await app.inject({ method: 'POST', url, payload: { message: '' } })).statusCode).toBe(400);
|
||||
expect((await app.inject({ method: 'POST', url, payload: { message: 'x'.repeat(2000) } })).statusCode).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe('discord-notify with a file', () => {
|
||||
let work: string;
|
||||
let outside: string;
|
||||
const url = '/api/sessions/test-session-1/discord-notify';
|
||||
|
||||
async function ready() {
|
||||
const h = await harness();
|
||||
(h.ctx.sessions.get('test-session-1') as unknown as { workingDir: string }).workingDir = work;
|
||||
await h.app.inject({ method: 'PUT', url: '/api/discord', payload: FULL });
|
||||
return h;
|
||||
}
|
||||
const post = (app: Awaited<ReturnType<typeof harness>>['app'], file: string) =>
|
||||
app.inject({ method: 'POST', url, payload: { message: 'log attached', file } });
|
||||
|
||||
beforeEach(() => {
|
||||
work = mkdtempSync(join(tmpdir(), 'discord-work-'));
|
||||
outside = mkdtempSync(join(tmpdir(), 'discord-outside-'));
|
||||
writeFileSync(join(work, 'run.log'), 'hello');
|
||||
writeFileSync(join(outside, 'secret.txt'), 'nope');
|
||||
});
|
||||
afterEach(() => {
|
||||
rmSync(work, { recursive: true, force: true });
|
||||
rmSync(outside, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('uploads a file inside the session folder as multipart', async () => {
|
||||
const { app, bridge } = await ready();
|
||||
const res = await post(app, join(work, 'run.log'));
|
||||
expect(res.json().data.ok).toBe(true);
|
||||
const form = fetchImpl.mock.calls.map((c) => c[1].body).find((b) => b instanceof FormData) as FormData;
|
||||
expect((form.get('files[0]') as File).name).toBe('run.log');
|
||||
bridge.stop();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['a relative path', () => 'run.log'],
|
||||
['a file outside the folder', () => join(outside, 'secret.txt')],
|
||||
['the folder itself', () => work],
|
||||
['a missing file', () => join(work, 'nope.log')],
|
||||
])('refuses %s', async (_n, path) => {
|
||||
const { app, bridge } = await ready();
|
||||
const res = await post(app, path());
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(fetchImpl.mock.calls.some((c) => c[1].body instanceof FormData)).toBe(false);
|
||||
bridge.stop();
|
||||
});
|
||||
|
||||
it('refuses to send when the session folder is not a directory it can probe', async () => {
|
||||
const { app, ctx, bridge } = await ready();
|
||||
(ctx.sessions.get('test-session-1') as unknown as { workingDir: string }).workingDir = join(work, 'run.log');
|
||||
const res = await post(app, join(work, 'run.log'));
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(res.json().error).toMatch(/session folder/);
|
||||
bridge.stop();
|
||||
});
|
||||
|
||||
it('probes the file before realpath: a path that is not a regular file is refused', async () => {
|
||||
const { app, bridge } = await ready();
|
||||
mkdirSync(join(work, 'dir'));
|
||||
expect((await post(app, join(work, 'dir'))).json().error).toMatch(/Not a regular file/);
|
||||
bridge.stop();
|
||||
});
|
||||
|
||||
it('refuses a symlink that points outside the folder', async () => {
|
||||
const { app, bridge } = await ready();
|
||||
mkdirSync(join(work, 'sub'));
|
||||
symlinkSync(join(outside, 'secret.txt'), join(work, 'sub', 'link.txt'));
|
||||
expect((await post(app, join(work, 'sub', 'link.txt'))).statusCode).toBe(400);
|
||||
bridge.stop();
|
||||
});
|
||||
|
||||
it('refuses a file over 8 MB', async () => {
|
||||
const { app, bridge } = await ready();
|
||||
writeFileSync(join(work, 'big.bin'), Buffer.alloc(8 * 1024 * 1024 + 1));
|
||||
const res = await post(app, join(work, 'big.bin'));
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(res.json().error).toMatch(/8 MB/);
|
||||
bridge.stop();
|
||||
});
|
||||
|
||||
it('refuses a file for a remote session', async () => {
|
||||
const { app, bridge, ctx } = await ready();
|
||||
(ctx.sessions.get('test-session-1') as unknown as { remote: object }).remote = { label: 'box' };
|
||||
expect((await post(app, join(work, 'run.log'))).statusCode).toBe(400);
|
||||
bridge.stop();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user