403 lines
13 KiB
TypeScript
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> & 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');
|
|
}
|
|
});
|
|
});
|