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 = { 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 => 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 = {}) => { const fields: Record = { ...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 & 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 ' }); 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 ', }); 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 '); 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 '); 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'); } }); });