Building Slack integration
This commit is contained in:
402
backend/tests/slack.routes.test.ts
Normal file
402
backend/tests/slack.routes.test.ts
Normal file
@@ -0,0 +1,402 @@
|
||||
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> & 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');
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user