Files
kongruity/backend/tests/slack.routes.test.ts
2026-08-02 06:13:01 -04:00

403 lines
13 KiB
TypeScript

import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import request from 'supertest';
import { createHmac } from 'node:crypto';
import express, { type NextFunction, type Request, type Response } from 'express';
import { configureQueue, drain } from '../lib/queue.js';
vi.mock('../db/ingest_events.dao.js', () => ({
recordDelivery: vi.fn(),
markProcessing: vi.fn(),
markDone: vi.fn(),
markFailed: vi.fn(),
resetStaleProcessing: vi.fn(),
}));
vi.mock('../db/notes.dao.js', () => ({
createNotes: vi.fn(),
getAllNotes: vi.fn(),
streamAllNotes: vi.fn(),
}));
import {
recordDelivery,
markDone,
markFailed,
markProcessing,
} from '../db/ingest_events.dao.js';
import { createNotes } from '../db/notes.dao.js';
import slackRouter from '../routes/slack.routes.js';
const mockRecordDelivery = vi.mocked(recordDelivery);
const mockCreateNotes = vi.mocked(createNotes);
const mockMarkDone = vi.mocked(markDone);
const mockMarkFailed = vi.mocked(markFailed);
const mockMarkProcessing = vi.mocked(markProcessing);
const SECRET = 'slack-test-secret';
// Mirrors app.ts: the raw parser must claim the body so the bytes Slack signed
// survive to verifySignature.
const app = express();
app.use('/v1/slack', express.raw({ type: '*/*', limit: '2mb' }), slackRouter);
app.use((err: Error, _req: Request, res: Response, _next: NextFunction) => {
console.error(`Unhandled error: ${err.message}`);
if (res.headersSent) return;
res.status(500).json({ error: 'Internal server error' });
});
const FIELDS: Record<string, string> = {
command: '/sticky',
text: 'Deploys are scary',
user_id: 'U123',
user_name: 'jane',
team_id: 'T999',
channel_id: 'C555',
channel_name: 'retro',
trigger_id: 'trg_1',
};
const encode = (fields: Record<string, string>): string =>
new URLSearchParams(fields).toString();
const sign = (body: string, timestamp: string, secret = SECRET): string =>
`v0=${createHmac('sha256', secret).update(`v0:${timestamp}:${body}`).digest('hex')}`;
const now = (): string => Math.floor(Date.now() / 1000).toString();
const postRaw = (body: string, signature: string, timestamp: string) =>
request(app)
.post('/v1/slack/commands')
.set('Content-Type', 'application/x-www-form-urlencoded')
.set('x-slack-request-timestamp', timestamp)
.set('x-slack-signature', signature)
.send(body);
/** Signs whatever it sends, so the happy path is the default. */
const postCommand = (overrides: Record<string, string | undefined> = {}) => {
const fields: Record<string, string> = { ...FIELDS };
for (const [key, value] of Object.entries(overrides)) {
if (value === undefined) {
delete fields[key];
} else {
fields[key] = value;
}
}
const body = encode(fields);
const timestamp = now();
return postRaw(body, sign(body, timestamp), timestamp);
};
describe('POST /v1/slack/commands', () => {
const originalSecret = process.env.SLACK_SIGNING_SECRET;
beforeEach(() => {
vi.clearAllMocks();
process.env.SLACK_SIGNING_SECRET = SECRET;
configureQueue({ baseDelayMs: 0, maxAttempts: 2 });
mockRecordDelivery.mockResolvedValue(42);
mockCreateNotes.mockResolvedValue([]);
});
afterEach(async () => {
await drain();
if (originalSecret === undefined) {
delete process.env.SLACK_SIGNING_SECRET;
} else {
process.env.SLACK_SIGNING_SECRET = originalSecret;
}
});
it('should confirm a valid command with ephemeral text', async () => {
const res = await postCommand();
expect(res.status).toBe(200);
expect(res.body).toEqual({
response_type: 'ephemeral',
text: 'Added to Kongruity: "Deploys are scary"',
});
});
it('should create the note behind the ack', async () => {
await postCommand();
await drain();
expect(mockCreateNotes).toHaveBeenCalledOnce();
const [notes] = mockCreateNotes.mock.calls[0];
expect(notes).toHaveLength(1);
expect(notes[0].id).toBe('slack_trg_1');
expect(notes[0].text).toBe('Deploys are scary');
expect(notes[0].author).toBe('jane');
});
it('should record provenance from the slash command fields', async () => {
await postCommand();
await drain();
const [notes] = mockCreateNotes.mock.calls[0];
expect(notes[0].sourceMeta).toMatchObject({
provider: 'slack',
externalId: 'trg_1',
channelId: 'C555',
channelName: 'retro',
authorHandle: 'jane',
authorUserId: 'U123',
teamId: 'T999',
via: 'slash-command',
});
expect(typeof notes[0].sourceMeta?.receivedAt).toBe('string');
});
it('should omit provenance keys whose source field is absent', async () => {
await postCommand({ channel_name: undefined, team_id: undefined });
await drain();
const [notes] = mockCreateNotes.mock.calls[0];
const meta = notes[0].sourceMeta ?? {};
expect('channelName' in meta).toBe(false);
expect('teamId' in meta).toBe(false);
});
it('should respond before the insert finishes', async () => {
let finishInsert: () => void = () => {};
mockCreateNotes.mockImplementation(
() => new Promise((resolve) => {
finishInsert = () => resolve([]);
})
);
const res = await postCommand();
expect(res.status).toBe(200);
expect(mockMarkDone).not.toHaveBeenCalled();
finishInsert();
await drain();
expect(mockMarkDone).toHaveBeenCalledWith(42);
});
it('should mark the delivery done once the insert succeeds', async () => {
await postCommand();
await drain();
expect(mockMarkProcessing).toHaveBeenCalledWith(42);
expect(mockMarkDone).toHaveBeenCalledWith(42);
expect(mockMarkFailed).not.toHaveBeenCalled();
});
it('should mark the delivery failed when the insert keeps failing', async () => {
mockCreateNotes.mockRejectedValue(new Error('connection refused'));
await postCommand();
await drain();
expect(mockMarkFailed).toHaveBeenCalledWith(42, 'connection refused');
expect(mockMarkDone).not.toHaveBeenCalled();
});
it('should apply cleanSlackText to the stored note', async () => {
await postCommand({
text: 'Ping <@U9|dan> in <#C1|ops> about <https://ex.com|the doc> &amp; ship',
});
await drain();
const [notes] = mockCreateNotes.mock.calls[0];
expect(notes[0].text).toBe('Ping @dan in #ops about the doc & ship');
});
it('should echo the cleaned text rather than the raw mrkdwn', async () => {
const res = await postCommand({ text: 'read <https://ex.com|the doc>' });
expect(res.body.text).toBe('Added to Kongruity: "read the doc"');
});
it('should truncate a long echo so it does not flood the channel', async () => {
const long = 'a'.repeat(400);
const res = await postCommand({ text: long });
expect(res.status).toBe(200);
expect(res.body.text).toContain('…');
expect(res.body.text.length).toBeLessThan(160);
});
it('should store the whole note even when the echo is truncated', async () => {
const long = 'a'.repeat(400);
await postCommand({ text: long });
await drain();
const [notes] = mockCreateNotes.mock.calls[0];
expect(notes[0].text).toBe(long);
});
it('should fall back to a synthetic external id when trigger_id is absent', async () => {
await postCommand({ trigger_id: undefined });
expect(mockRecordDelivery).toHaveBeenCalledOnce();
const [input] = mockRecordDelivery.mock.calls[0];
expect(input.provider).toBe('slack');
expect(input.externalId).toMatch(/^T999:U123:\d+$/);
});
it('should truncate the note id to the 64 character column width', async () => {
await postCommand({ trigger_id: 'trg_'.padEnd(120, 'x') });
await drain();
const [notes] = mockCreateNotes.mock.calls[0];
expect(notes[0].id).toHaveLength(64);
expect(notes[0].id.startsWith('slack_trg_x')).toBe(true);
});
it('should drop a duplicate trigger_id without enqueueing work', async () => {
mockRecordDelivery.mockResolvedValue(null);
const res = await postCommand();
await drain();
expect(res.status).toBe(200);
expect(res.body).toEqual({ response_type: 'ephemeral', text: 'Already captured.' });
expect(mockCreateNotes).not.toHaveBeenCalled();
expect(mockMarkProcessing).not.toHaveBeenCalled();
});
it('should return usage when the text is empty', async () => {
const res = await postCommand({ text: '' });
expect(res.status).toBe(200);
expect(res.body).toEqual({
response_type: 'ephemeral',
text: 'Usage: /sticky <your note>',
});
expect(mockRecordDelivery).not.toHaveBeenCalled();
});
it('should return usage when the text is only whitespace', async () => {
const res = await postCommand({ text: ' ' });
expect(res.status).toBe(200);
expect(res.body.text).toBe('Usage: /sticky <your note>');
expect(mockRecordDelivery).not.toHaveBeenCalled();
});
/**
* Markup can be non-empty before cleaning and empty after it. Recording the
* delivery first would consume the trigger_id that dedups a Slack retry.
*/
it('should return usage without recording a delivery when the text cleans away to nothing', async () => {
const res = await postCommand({ text: '<>' });
expect(res.status).toBe(200);
expect(res.body.text).toBe('Usage: /sticky <your note>');
expect(mockRecordDelivery).not.toHaveBeenCalled();
expect(mockCreateNotes).not.toHaveBeenCalled();
});
it('should reject an unrecognized command name', async () => {
const res = await postCommand({ command: '/todo' });
expect(res.status).toBe(200);
expect(res.body).toEqual({
response_type: 'ephemeral',
text: 'Unknown command /todo.',
});
expect(mockRecordDelivery).not.toHaveBeenCalled();
});
it('should fall back to the user id when no user_name is sent', async () => {
await postCommand({ user_name: undefined });
await drain();
const [notes] = mockCreateNotes.mock.calls[0];
expect(notes[0].author).toBe('U123');
});
it('should fall back to "unknown" when the sender is unidentified', async () => {
await postCommand({ user_name: undefined, user_id: undefined });
await drain();
const [notes] = mockCreateNotes.mock.calls[0];
expect(notes[0].author).toBe('unknown');
});
it('should reject a signature computed with the wrong secret', async () => {
const body = encode(FIELDS);
const timestamp = now();
const res = await postRaw(body, sign(body, timestamp, 'wrong-secret'), timestamp);
expect(res.status).toBe(401);
expect(res.body).toEqual({ error: 'Invalid signature' });
expect(mockRecordDelivery).not.toHaveBeenCalled();
});
it('should reject a body altered after signing', async () => {
const body = encode(FIELDS);
const timestamp = now();
const signature = sign(body, timestamp);
const tampered = body.replace('scary', 'great');
const res = await postRaw(tampered, signature, timestamp);
expect(res.status).toBe(401);
expect(mockRecordDelivery).not.toHaveBeenCalled();
});
it('should reject a request with no signature header', async () => {
const body = encode(FIELDS);
const res = await request(app)
.post('/v1/slack/commands')
.set('Content-Type', 'application/x-www-form-urlencoded')
.set('x-slack-request-timestamp', now())
.send(body);
expect(res.status).toBe(401);
expect(mockRecordDelivery).not.toHaveBeenCalled();
});
it('should reject a replayed request whose timestamp is outside the tolerance', async () => {
const body = encode(FIELDS);
const stale = (Math.floor(Date.now() / 1000) - 3600).toString();
const res = await postRaw(body, sign(body, stale), stale);
expect(res.status).toBe(401);
expect(mockRecordDelivery).not.toHaveBeenCalled();
});
it('should return 500 when no signing secret is configured', async () => {
delete process.env.SLACK_SIGNING_SECRET;
const res = await postCommand();
expect(res.status).toBe(500);
expect(res.body).toEqual({ error: 'Slack is not configured' });
expect(mockRecordDelivery).not.toHaveBeenCalled();
});
it('should return 500 when recording the delivery fails', async () => {
mockRecordDelivery.mockRejectedValue(new Error('connection refused'));
const res = await postCommand();
expect(res.status).toBe(500);
expect(mockCreateNotes).not.toHaveBeenCalled();
});
it('should answer every user-facing outcome with ephemeral JSON', async () => {
const responses = await Promise.all([
postCommand(),
postCommand({ text: '' }),
postCommand({ command: '/todo' }),
]);
for (const res of responses) {
expect(res.status).toBe(200);
expect(res.headers['content-type']).toMatch(/application\/json/);
expect(res.body.response_type).toBe('ephemeral');
expect(typeof res.body.text).toBe('string');
}
});
});