284 lines
9.0 KiB
TypeScript
284 lines
9.0 KiB
TypeScript
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
import request from 'supertest';
|
|
import { createHmac } from 'node:crypto';
|
|
import app from '../app.js';
|
|
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(),
|
|
}));
|
|
|
|
vi.mock('../lib/httpClient.js', () => ({
|
|
requestJson: vi.fn(),
|
|
}));
|
|
|
|
import {
|
|
recordDelivery,
|
|
markDone,
|
|
markFailed,
|
|
markProcessing,
|
|
} from '../db/ingest_events.dao.js';
|
|
import { createNotes } from '../db/notes.dao.js';
|
|
import { requestJson } from '../lib/httpClient.js';
|
|
import { resetSlackCaches } from '../services/slack.service.js';
|
|
import type { NoteInput } from '../types/domain.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 mockRequestJson = vi.mocked(requestJson);
|
|
|
|
const SIGNING_SECRET = 'slack-signing-secret';
|
|
const BOT_TOKEN = 'xoxb-test-token';
|
|
|
|
const reactionEvent = (overrides: Record<string, unknown> = {}): string =>
|
|
JSON.stringify({
|
|
type: 'event_callback',
|
|
event_id: 'Ev0PIN1',
|
|
team_id: 'T999',
|
|
event: {
|
|
type: 'reaction_added',
|
|
user: 'U_REACTOR',
|
|
reaction: 'pushpin',
|
|
item_user: 'U_AUTHOR',
|
|
item: { type: 'message', channel: 'C555', ts: '1700000000.000100' },
|
|
...overrides,
|
|
},
|
|
});
|
|
|
|
const sign = (body: string, timestamp: string, secret = SIGNING_SECRET): string =>
|
|
`v0=${createHmac('sha256', secret).update(`v0:${timestamp}:${body}`).digest('hex')}`;
|
|
|
|
const postEvent = (body: string) => {
|
|
const timestamp = Math.floor(Date.now() / 1000).toString();
|
|
return request(app)
|
|
.post('/v1/webhooks/slack')
|
|
.set('Content-Type', 'application/json')
|
|
.set('x-slack-request-timestamp', timestamp)
|
|
.set('x-slack-signature', sign(body, timestamp))
|
|
.send(body);
|
|
};
|
|
|
|
/** Routes a Slack Web API call by method name to a canned response. */
|
|
const slackApi = (responses: Record<string, unknown>): void => {
|
|
mockRequestJson.mockImplementation(async (url: string) => {
|
|
const method = url.split('/api/')[1] ?? '';
|
|
if (!(method in responses)) {
|
|
throw new Error(`Unexpected Slack method: ${method}`);
|
|
}
|
|
return responses[method];
|
|
});
|
|
};
|
|
|
|
const historyWith = (text: string, user = 'U_AUTHOR') => ({
|
|
ok: true,
|
|
messages: [{ text, user, ts: '1700000000.000100' }],
|
|
});
|
|
|
|
const userNamed = (displayName: string) => ({
|
|
ok: true,
|
|
user: { id: 'U_AUTHOR', name: 'fallback', profile: { display_name: displayName } },
|
|
});
|
|
|
|
const notesWritten = (): NoteInput[] => {
|
|
const call = mockCreateNotes.mock.calls[0];
|
|
return call ? call[0] : [];
|
|
};
|
|
|
|
/**
|
|
* Exercises the seam the unit tests cannot: the provider registry wiring that
|
|
* carries a Slack reaction from the signed request through normalization, the
|
|
* ack, the queue, and enrichment into an actual note insert.
|
|
*/
|
|
describe('Slack reaction capture, end to end', () => {
|
|
const originalSecret = process.env.SLACK_SIGNING_SECRET;
|
|
const originalToken = process.env.SLACK_BOT_TOKEN;
|
|
const originalReaction = process.env.SLACK_CAPTURE_REACTION;
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
resetSlackCaches();
|
|
process.env.SLACK_SIGNING_SECRET = SIGNING_SECRET;
|
|
process.env.SLACK_BOT_TOKEN = BOT_TOKEN;
|
|
delete process.env.SLACK_CAPTURE_REACTION;
|
|
configureQueue({ baseDelayMs: 0, maxAttempts: 2 });
|
|
mockRecordDelivery.mockResolvedValue(42);
|
|
mockCreateNotes.mockResolvedValue([]);
|
|
});
|
|
|
|
afterEach(async () => {
|
|
await drain();
|
|
const restore = (key: string, value: string | undefined): void => {
|
|
if (value === undefined) delete process.env[key];
|
|
else process.env[key] = value;
|
|
};
|
|
restore('SLACK_SIGNING_SECRET', originalSecret);
|
|
restore('SLACK_BOT_TOKEN', originalToken);
|
|
restore('SLACK_CAPTURE_REACTION', originalReaction);
|
|
});
|
|
|
|
it('should turn a pinned message into a note with fetched text and a resolved author', async () => {
|
|
slackApi({
|
|
'conversations.history': historyWith('Deploys are <https://ex.co|scary>'),
|
|
'users.info': userNamed('Jane Doe'),
|
|
});
|
|
|
|
const res = await postEvent(reactionEvent());
|
|
expect(res.status).toBe(200);
|
|
|
|
await drain();
|
|
|
|
const notes = notesWritten();
|
|
expect(notes).toHaveLength(1);
|
|
expect(notes[0].text).toBe('Deploys are scary');
|
|
expect(notes[0].author).toBe('Jane Doe');
|
|
expect(notes[0].sourceMeta).toMatchObject({
|
|
provider: 'slack',
|
|
externalId: 'Ev0PIN1',
|
|
channelId: 'C555',
|
|
authorHandle: 'Jane Doe',
|
|
});
|
|
expect(mockMarkProcessing).toHaveBeenCalledWith(42);
|
|
expect(mockMarkDone).toHaveBeenCalledWith(42);
|
|
});
|
|
|
|
/** A placeholder reaching the database would be a visible bug on a sticky. */
|
|
it('should never persist the placeholder text', async () => {
|
|
slackApi({
|
|
'conversations.history': historyWith('real message body'),
|
|
'users.info': userNamed('Jane Doe'),
|
|
});
|
|
|
|
await postEvent(reactionEvent());
|
|
await drain();
|
|
|
|
expect(notesWritten()[0].text).not.toMatch(/pending/i);
|
|
expect(notesWritten()[0].sourceMeta).not.toHaveProperty('needsMessageText');
|
|
});
|
|
|
|
it('should drop the note when the message cannot be fetched', async () => {
|
|
slackApi({
|
|
'conversations.history': { ok: true, messages: [] },
|
|
'conversations.replies': { ok: true, messages: [] },
|
|
});
|
|
|
|
await postEvent(reactionEvent());
|
|
await drain();
|
|
|
|
expect(notesWritten()).toEqual([]);
|
|
expect(mockMarkDone).toHaveBeenCalledWith(42);
|
|
});
|
|
|
|
it('should omit the note count from the ack because enrichment runs after it', async () => {
|
|
slackApi({
|
|
'conversations.history': historyWith('anything'),
|
|
'users.info': userNamed('Jane Doe'),
|
|
});
|
|
|
|
const res = await postEvent(reactionEvent());
|
|
|
|
expect(res.body).toEqual({ accepted: true });
|
|
});
|
|
|
|
it('should ignore a reaction that is not the configured one', async () => {
|
|
const res = await postEvent(reactionEvent({ reaction: 'tada' }));
|
|
|
|
expect(res.status).toBe(200);
|
|
await drain();
|
|
|
|
expect(mockRequestJson).not.toHaveBeenCalled();
|
|
expect(notesWritten()).toEqual([]);
|
|
});
|
|
|
|
it('should honor a custom capture reaction from the environment', async () => {
|
|
process.env.SLACK_CAPTURE_REACTION = 'sticky';
|
|
slackApi({
|
|
'conversations.history': historyWith('captured by custom emoji'),
|
|
'users.info': userNamed('Jane Doe'),
|
|
});
|
|
|
|
await postEvent(reactionEvent({ reaction: 'sticky' }));
|
|
await drain();
|
|
|
|
expect(notesWritten()[0].text).toBe('captured by custom emoji');
|
|
});
|
|
|
|
it('should answer the url_verification handshake', async () => {
|
|
const res = await postEvent(
|
|
JSON.stringify({ type: 'url_verification', challenge: 'abc123' })
|
|
);
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(res.body).toEqual({ challenge: 'abc123' });
|
|
});
|
|
|
|
it('should reject a delivery whose signature does not match', async () => {
|
|
const body = reactionEvent();
|
|
const timestamp = Math.floor(Date.now() / 1000).toString();
|
|
|
|
const res = await request(app)
|
|
.post('/v1/webhooks/slack')
|
|
.set('Content-Type', 'application/json')
|
|
.set('x-slack-request-timestamp', timestamp)
|
|
.set('x-slack-signature', sign(body, timestamp, 'wrong-secret'))
|
|
.send(body);
|
|
|
|
expect(res.status).toBe(401);
|
|
expect(mockRecordDelivery).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should stop at the dedup layer when Slack redelivers an event', async () => {
|
|
mockRecordDelivery.mockResolvedValue(null);
|
|
|
|
const res = await postEvent(reactionEvent());
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(res.body).toEqual({ duplicate: true });
|
|
await drain();
|
|
expect(mockCreateNotes).not.toHaveBeenCalled();
|
|
});
|
|
|
|
/** A failed enrichment must leave the delivery failed, not silently done. */
|
|
it('should mark the delivery failed when the Slack API rejects the fetch', async () => {
|
|
mockRequestJson.mockResolvedValue({ ok: false, error: 'channel_not_found' });
|
|
|
|
await postEvent(reactionEvent());
|
|
await drain();
|
|
|
|
expect(mockCreateNotes).not.toHaveBeenCalled();
|
|
expect(mockMarkFailed).toHaveBeenCalledWith(42, expect.stringContaining('channel_not_found'));
|
|
expect(mockMarkDone).not.toHaveBeenCalled();
|
|
});
|
|
|
|
/** A missing channel is settled; retrying it only delays the queue. */
|
|
it('should not retry a permanent Slack failure', async () => {
|
|
mockRequestJson.mockResolvedValue({ ok: false, error: 'channel_not_found' });
|
|
|
|
await postEvent(reactionEvent());
|
|
await drain();
|
|
|
|
expect(mockRequestJson).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('should retry a rate-limited Slack failure until the attempt cap', async () => {
|
|
mockRequestJson.mockResolvedValue({ ok: false, error: 'ratelimited' });
|
|
|
|
await postEvent(reactionEvent());
|
|
await drain();
|
|
|
|
expect(mockRequestJson).toHaveBeenCalledTimes(2);
|
|
});
|
|
});
|