Infrastructure build to support third-party app integrations
This commit is contained in:
@@ -1,19 +1,23 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import type { Cluster, EmbeddingMap } from '../types/domain.js';
|
||||
|
||||
type StreamEvent = {
|
||||
type: string;
|
||||
delta?: { type: string; text: string };
|
||||
};
|
||||
|
||||
const { streamMock, mockEmbeddings } = vi.hoisted(() => {
|
||||
const embeddings = new Map([
|
||||
const embeddings = new Map<string, number[]>([
|
||||
['note_001', [1.0, 0.0, 0.0]],
|
||||
['note_002', [0.0, 1.0, 0.0]],
|
||||
]);
|
||||
return { streamMock: vi.fn(), mockEmbeddings: embeddings };
|
||||
return { streamMock: vi.fn(), mockEmbeddings: embeddings as EmbeddingMap };
|
||||
});
|
||||
|
||||
vi.mock('@anthropic-ai/sdk', () => {
|
||||
return {
|
||||
default: class MockAnthropic {
|
||||
constructor() {
|
||||
this.messages = { stream: streamMock };
|
||||
}
|
||||
messages = { stream: streamMock };
|
||||
},
|
||||
};
|
||||
});
|
||||
@@ -29,16 +33,16 @@ const MOCK_NOTES = [
|
||||
{ id: 'note_002', text: 'Export fails' },
|
||||
];
|
||||
|
||||
const MOCK_CLUSTERS = [
|
||||
const MOCK_CLUSTERS: Cluster[] = [
|
||||
{ label: 'Auth Issues', noteIds: ['note_001'] },
|
||||
{ label: 'Export Issues', noteIds: ['note_002'] },
|
||||
];
|
||||
|
||||
// Splits text into several text_delta events so the service is exercised
|
||||
// against a genuinely incremental stream rather than one whole payload.
|
||||
const textEvents = (text, pieces = 4) => {
|
||||
const textEvents = (text: string, pieces = 4): StreamEvent[] => {
|
||||
const size = Math.max(1, Math.ceil(text.length / pieces));
|
||||
const events = [];
|
||||
const events: StreamEvent[] = [];
|
||||
for (let i = 0; i < text.length; i += size) {
|
||||
events.push({
|
||||
type: 'content_block_delta',
|
||||
@@ -48,8 +52,8 @@ const textEvents = (text, pieces = 4) => {
|
||||
return events;
|
||||
};
|
||||
|
||||
const mockStreamOf = (text) => {
|
||||
const events = [
|
||||
const mockStreamOf = (text: string): void => {
|
||||
const events: StreamEvent[] = [
|
||||
{ type: 'message_start' },
|
||||
...textEvents(text),
|
||||
{ type: 'message_stop' },
|
||||
@@ -61,7 +65,7 @@ const mockStreamOf = (text) => {
|
||||
}));
|
||||
};
|
||||
|
||||
const mockStreamThrowing = (err) => {
|
||||
const mockStreamThrowing = (err: Error): void => {
|
||||
streamMock.mockImplementation(() => ({
|
||||
async *[Symbol.asyncIterator]() {
|
||||
throw err;
|
||||
@@ -152,11 +156,17 @@ describe('clusterNotes service', () => {
|
||||
});
|
||||
|
||||
it('should throw a validation error when a note is missing from clusters', async () => {
|
||||
const incompleteClusters = [
|
||||
const incompleteClusters: Cluster[] = [
|
||||
{ label: 'Auth Issues', noteIds: ['note_001'] },
|
||||
];
|
||||
mockStreamOf(JSON.stringify(incompleteClusters));
|
||||
|
||||
await expect(clusterNotes(MOCK_NOTES)).rejects.toThrow('Cluster validation failed');
|
||||
});
|
||||
|
||||
it('should throw when the API returns a JSON array of the wrong shape', async () => {
|
||||
mockStreamOf(JSON.stringify([{ name: 'Auth Issues', ids: ['note_001'] }]));
|
||||
|
||||
await expect(clusterNotes(MOCK_NOTES)).rejects.toThrow('unexpected shape');
|
||||
});
|
||||
});
|
||||
@@ -2,6 +2,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import { Readable } from 'node:stream';
|
||||
import app from '../app.js';
|
||||
import type { Cluster, ClusterResponse, Note } from '../types/domain.js';
|
||||
|
||||
vi.mock('../db/notes.dao.js', () => ({
|
||||
getAllNotes: vi.fn(),
|
||||
@@ -15,18 +16,24 @@ vi.mock('../services/clustering.service.js', () => ({
|
||||
import { getAllNotes, streamAllNotes } from '../db/notes.dao.js';
|
||||
import { clusterNotes } from '../services/clustering.service.js';
|
||||
|
||||
const MOCK_NOTES = [
|
||||
const mockGetAllNotes = vi.mocked(getAllNotes);
|
||||
const mockStreamAllNotes = vi.mocked(streamAllNotes);
|
||||
const mockClusterNotes = vi.mocked(clusterNotes);
|
||||
|
||||
const MOCK_NOTES: Note[] = [
|
||||
{ id: 'note_001', text: 'Login flow feels confusing', x: 193, y: 191, author: 'user_5', color: 'yellow' },
|
||||
{ id: 'note_002', text: 'Login flow is broken on mobile', x: 214, y: 281, author: 'user_9', color: 'yellow' },
|
||||
{ id: 'note_003', text: 'Export takes too long', x: 798, y: 211, author: 'user_2', color: 'green' },
|
||||
];
|
||||
|
||||
const MOCK_CLUSTERS = [
|
||||
const MOCK_CLUSTERS: Cluster[] = [
|
||||
{ label: 'Login Issues', noteIds: ['note_001', 'note_002'] },
|
||||
{ label: 'Export Problems', noteIds: ['note_003'] },
|
||||
];
|
||||
|
||||
const rowStream = (rows) => Readable.from(rows, { objectMode: true });
|
||||
const MOCK_RESULT: ClusterResponse = { clusters: MOCK_CLUSTERS, score: 0.09 };
|
||||
|
||||
const rowStream = (rows: Note[]): Readable => Readable.from(rows, { objectMode: true });
|
||||
|
||||
describe('GET /v1/notes', () => {
|
||||
|
||||
@@ -35,7 +42,7 @@ describe('GET /v1/notes', () => {
|
||||
});
|
||||
|
||||
it('should return 200 and an array of notes', async () => {
|
||||
streamAllNotes.mockResolvedValue(rowStream(MOCK_NOTES));
|
||||
mockStreamAllNotes.mockResolvedValue(rowStream(MOCK_NOTES));
|
||||
|
||||
const res = await request(app).get('/v1/notes');
|
||||
|
||||
@@ -45,7 +52,7 @@ describe('GET /v1/notes', () => {
|
||||
});
|
||||
|
||||
it('should send JSON incrementally rather than buffering the row set', async () => {
|
||||
streamAllNotes.mockResolvedValue(rowStream(MOCK_NOTES));
|
||||
mockStreamAllNotes.mockResolvedValue(rowStream(MOCK_NOTES));
|
||||
|
||||
const res = await request(app).get('/v1/notes');
|
||||
|
||||
@@ -54,7 +61,7 @@ describe('GET /v1/notes', () => {
|
||||
});
|
||||
|
||||
it('should return an empty array when there are no notes', async () => {
|
||||
streamAllNotes.mockResolvedValue(rowStream([]));
|
||||
mockStreamAllNotes.mockResolvedValue(rowStream([]));
|
||||
|
||||
const res = await request(app).get('/v1/notes');
|
||||
|
||||
@@ -63,7 +70,7 @@ describe('GET /v1/notes', () => {
|
||||
});
|
||||
|
||||
it('should return notes with expected properties', async () => {
|
||||
streamAllNotes.mockResolvedValue(rowStream(MOCK_NOTES));
|
||||
mockStreamAllNotes.mockResolvedValue(rowStream(MOCK_NOTES));
|
||||
|
||||
const res = await request(app).get('/v1/notes');
|
||||
const note = res.body[0];
|
||||
@@ -77,7 +84,7 @@ describe('GET /v1/notes', () => {
|
||||
});
|
||||
|
||||
it('should return 500 when the database query fails', async () => {
|
||||
streamAllNotes.mockRejectedValue(new Error('connection refused'));
|
||||
mockStreamAllNotes.mockRejectedValue(new Error('connection refused'));
|
||||
|
||||
const res = await request(app).get('/v1/notes');
|
||||
|
||||
@@ -94,7 +101,7 @@ describe('GET /v1/notes', () => {
|
||||
this.destroy(new Error('connection lost'));
|
||||
},
|
||||
});
|
||||
streamAllNotes.mockResolvedValue(failing);
|
||||
mockStreamAllNotes.mockResolvedValue(failing);
|
||||
|
||||
await expect(request(app).get('/v1/notes')).rejects.toThrow();
|
||||
});
|
||||
@@ -107,21 +114,21 @@ describe('POST /v1/notes/cluster', () => {
|
||||
});
|
||||
|
||||
it('should return 200 and clustered results', async () => {
|
||||
getAllNotes.mockResolvedValue(MOCK_NOTES);
|
||||
clusterNotes.mockResolvedValue(MOCK_CLUSTERS);
|
||||
mockGetAllNotes.mockResolvedValue(MOCK_NOTES);
|
||||
mockClusterNotes.mockResolvedValue(MOCK_RESULT);
|
||||
|
||||
const res = await request(app).post('/v1/notes/cluster');
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual(MOCK_CLUSTERS);
|
||||
expect(res.body).toEqual(MOCK_RESULT);
|
||||
});
|
||||
|
||||
it('should return clusters with the expected shape (label, noteIds)', async () => {
|
||||
getAllNotes.mockResolvedValue(MOCK_NOTES);
|
||||
clusterNotes.mockResolvedValue(MOCK_CLUSTERS);
|
||||
mockGetAllNotes.mockResolvedValue(MOCK_NOTES);
|
||||
mockClusterNotes.mockResolvedValue(MOCK_RESULT);
|
||||
|
||||
const res = await request(app).post('/v1/notes/cluster');
|
||||
const cluster = res.body[0];
|
||||
const cluster = res.body.clusters[0];
|
||||
|
||||
expect(cluster).toHaveProperty('label');
|
||||
expect(cluster).toHaveProperty('noteIds');
|
||||
@@ -130,23 +137,23 @@ describe('POST /v1/notes/cluster', () => {
|
||||
});
|
||||
|
||||
it('should pass the loaded notes to clusterNotes', async () => {
|
||||
getAllNotes.mockResolvedValue(MOCK_NOTES);
|
||||
clusterNotes.mockResolvedValue(MOCK_CLUSTERS);
|
||||
mockGetAllNotes.mockResolvedValue(MOCK_NOTES);
|
||||
mockClusterNotes.mockResolvedValue(MOCK_RESULT);
|
||||
|
||||
await request(app).post('/v1/notes/cluster');
|
||||
|
||||
expect(clusterNotes).toHaveBeenCalledOnce();
|
||||
expect(clusterNotes).toHaveBeenCalledWith(
|
||||
expect(mockClusterNotes).toHaveBeenCalledOnce();
|
||||
expect(mockClusterNotes).toHaveBeenCalledWith(
|
||||
MOCK_NOTES,
|
||||
expect.objectContaining({ signal: expect.any(AbortSignal) })
|
||||
);
|
||||
});
|
||||
|
||||
it('should pass a signal that is not aborted while the request is open', async () => {
|
||||
getAllNotes.mockResolvedValue(MOCK_NOTES);
|
||||
clusterNotes.mockImplementation(async (_notes, { signal }) => {
|
||||
expect(signal.aborted).toBe(false);
|
||||
return MOCK_CLUSTERS;
|
||||
mockGetAllNotes.mockResolvedValue(MOCK_NOTES);
|
||||
mockClusterNotes.mockImplementation(async (_notes, options = {}) => {
|
||||
expect(options.signal?.aborted).toBe(false);
|
||||
return MOCK_RESULT;
|
||||
});
|
||||
|
||||
const res = await request(app).post('/v1/notes/cluster');
|
||||
@@ -155,8 +162,8 @@ describe('POST /v1/notes/cluster', () => {
|
||||
});
|
||||
|
||||
it('should return 500 when clusterNotes (API call) fails', async () => {
|
||||
getAllNotes.mockResolvedValue(MOCK_NOTES);
|
||||
clusterNotes.mockRejectedValue(new Error('LLM API error'));
|
||||
mockGetAllNotes.mockResolvedValue(MOCK_NOTES);
|
||||
mockClusterNotes.mockRejectedValue(new Error('LLM API error'));
|
||||
|
||||
const res = await request(app).post('/v1/notes/cluster');
|
||||
|
||||
@@ -166,7 +173,7 @@ describe('POST /v1/notes/cluster', () => {
|
||||
});
|
||||
|
||||
it('should return 500 when the database query fails', async () => {
|
||||
getAllNotes.mockRejectedValue(new Error('connection refused'));
|
||||
mockGetAllNotes.mockRejectedValue(new Error('connection refused'));
|
||||
|
||||
const res = await request(app).post('/v1/notes/cluster');
|
||||
|
||||
170
backend/tests/crypto.test.ts
Normal file
170
backend/tests/crypto.test.ts
Normal file
@@ -0,0 +1,170 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import {
|
||||
encryptSecret,
|
||||
decryptSecret,
|
||||
hashApiKey,
|
||||
safeEquals,
|
||||
} from '../lib/crypto.js';
|
||||
|
||||
const base64Key = (bytes: number): string => randomBytes(bytes).toString('base64');
|
||||
|
||||
const VALID_KEY = base64Key(32);
|
||||
|
||||
/** Rewrites one `iv:tag:payload` segment, flipping every bit of its first byte. */
|
||||
const corruptSegment = (ciphertext: string, index: number): string => {
|
||||
const parts = ciphertext.split(':');
|
||||
const bytes = Buffer.from(parts[index], 'base64');
|
||||
bytes[0] = bytes[0] ^ 0xff;
|
||||
parts[index] = bytes.toString('base64');
|
||||
return parts.join(':');
|
||||
};
|
||||
|
||||
describe('encryptSecret / decryptSecret', () => {
|
||||
const originalKey = process.env.TOKEN_ENCRYPTION_KEY;
|
||||
|
||||
beforeEach(() => {
|
||||
process.env.TOKEN_ENCRYPTION_KEY = VALID_KEY;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (originalKey === undefined) {
|
||||
delete process.env.TOKEN_ENCRYPTION_KEY;
|
||||
} else {
|
||||
process.env.TOKEN_ENCRYPTION_KEY = originalKey;
|
||||
}
|
||||
});
|
||||
|
||||
it('should round-trip a plaintext secret', () => {
|
||||
const plaintext = 'lin_oauth_abc123';
|
||||
|
||||
expect(decryptSecret(encryptSecret(plaintext))).toBe(plaintext);
|
||||
});
|
||||
|
||||
it('should round-trip an empty string and multi-byte characters', () => {
|
||||
expect(decryptSecret(encryptSecret(''))).toBe('');
|
||||
expect(decryptSecret(encryptSecret('clé—😀'))).toBe('clé—😀');
|
||||
});
|
||||
|
||||
it('should emit three base64 segments', () => {
|
||||
const parts = encryptSecret('token').split(':');
|
||||
|
||||
expect(parts).toHaveLength(3);
|
||||
expect(Buffer.from(parts[0], 'base64')).toHaveLength(12);
|
||||
expect(Buffer.from(parts[1], 'base64')).toHaveLength(16);
|
||||
});
|
||||
|
||||
it('should produce different ciphertext for the same plaintext each time', () => {
|
||||
const first = encryptSecret('same-secret');
|
||||
const second = encryptSecret('same-secret');
|
||||
|
||||
expect(first).not.toBe(second);
|
||||
expect(first.split(':')[0]).not.toBe(second.split(':')[0]);
|
||||
expect(decryptSecret(first)).toBe('same-secret');
|
||||
expect(decryptSecret(second)).toBe('same-secret');
|
||||
});
|
||||
|
||||
it('should throw when the ciphertext payload is tampered with', () => {
|
||||
const tampered = corruptSegment(encryptSecret('payload-under-attack'), 2);
|
||||
|
||||
expect(() => decryptSecret(tampered)).toThrow();
|
||||
});
|
||||
|
||||
it('should throw when the auth tag is tampered with', () => {
|
||||
const tampered = corruptSegment(encryptSecret('tag-under-attack'), 1);
|
||||
|
||||
expect(() => decryptSecret(tampered)).toThrow();
|
||||
});
|
||||
|
||||
it('should throw when the iv is tampered with', () => {
|
||||
const tampered = corruptSegment(encryptSecret('iv-under-attack'), 0);
|
||||
|
||||
expect(() => decryptSecret(tampered)).toThrow();
|
||||
});
|
||||
|
||||
it('should throw for a ciphertext that is not in iv:tag:payload form', () => {
|
||||
expect(() => decryptSecret('not-a-ciphertext')).toThrow(
|
||||
/iv:tag:payload/
|
||||
);
|
||||
expect(() => decryptSecret('only:two')).toThrow(/iv:tag:payload/);
|
||||
expect(() => decryptSecret('a:b:c:d')).toThrow(/iv:tag:payload/);
|
||||
expect(() => decryptSecret('')).toThrow(/iv:tag:payload/);
|
||||
});
|
||||
|
||||
it('should throw when decrypting under a different key', () => {
|
||||
const ciphertext = encryptSecret('bound-to-one-key');
|
||||
process.env.TOKEN_ENCRYPTION_KEY = base64Key(32);
|
||||
|
||||
expect(() => decryptSecret(ciphertext)).toThrow();
|
||||
});
|
||||
|
||||
it('should throw when TOKEN_ENCRYPTION_KEY is missing', () => {
|
||||
delete process.env.TOKEN_ENCRYPTION_KEY;
|
||||
|
||||
expect(() => encryptSecret('anything')).toThrow(/not set/);
|
||||
expect(() => decryptSecret('a:b:c')).toThrow(/not set/);
|
||||
});
|
||||
|
||||
it('should throw when TOKEN_ENCRYPTION_KEY is empty', () => {
|
||||
process.env.TOKEN_ENCRYPTION_KEY = '';
|
||||
|
||||
expect(() => encryptSecret('anything')).toThrow(/not set/);
|
||||
});
|
||||
|
||||
it('should throw when the key decodes to the wrong byte length', () => {
|
||||
process.env.TOKEN_ENCRYPTION_KEY = base64Key(16);
|
||||
expect(() => encryptSecret('anything')).toThrow(/32 bytes, got 16/);
|
||||
|
||||
process.env.TOKEN_ENCRYPTION_KEY = base64Key(48);
|
||||
expect(() => encryptSecret('anything')).toThrow(/32 bytes, got 48/);
|
||||
});
|
||||
|
||||
it('should read the key at call time rather than at import time', () => {
|
||||
process.env.TOKEN_ENCRYPTION_KEY = base64Key(31);
|
||||
expect(() => encryptSecret('anything')).toThrow(/got 31/);
|
||||
|
||||
process.env.TOKEN_ENCRYPTION_KEY = VALID_KEY;
|
||||
expect(decryptSecret(encryptSecret('recovered'))).toBe('recovered');
|
||||
});
|
||||
});
|
||||
|
||||
describe('hashApiKey', () => {
|
||||
it('should be stable for the same input', () => {
|
||||
expect(hashApiKey('kg_live_abc')).toBe(hashApiKey('kg_live_abc'));
|
||||
});
|
||||
|
||||
it('should differ for different inputs', () => {
|
||||
expect(hashApiKey('kg_live_abc')).not.toBe(hashApiKey('kg_live_abd'));
|
||||
expect(hashApiKey('')).not.toBe(hashApiKey(' '));
|
||||
});
|
||||
|
||||
it('should return a 64-character lowercase hex digest', () => {
|
||||
expect(hashApiKey('kg_live_abc')).toMatch(/^[0-9a-f]{64}$/);
|
||||
});
|
||||
|
||||
it('should match the plain sha256 digest of the input', () => {
|
||||
expect(hashApiKey('')).toBe(
|
||||
'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('safeEquals', () => {
|
||||
it('should return true for identical strings', () => {
|
||||
expect(safeEquals('kg_live_abc', 'kg_live_abc')).toBe(true);
|
||||
expect(safeEquals('', '')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for different strings of equal length', () => {
|
||||
expect(safeEquals('kg_live_abc', 'kg_live_abd')).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for strings of differing lengths without throwing', () => {
|
||||
expect(safeEquals('short', 'a-much-longer-value')).toBe(false);
|
||||
expect(safeEquals('', 'x')).toBe(false);
|
||||
});
|
||||
|
||||
it('should be case sensitive', () => {
|
||||
expect(safeEquals('Secret', 'secret')).toBe(false);
|
||||
});
|
||||
});
|
||||
241
backend/tests/httpClient.test.ts
Normal file
241
backend/tests/httpClient.test.ts
Normal file
@@ -0,0 +1,241 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { requestJson, isHttpRequestError, parseRetryAfter } from '../lib/httpClient.js';
|
||||
|
||||
const URL_UNDER_TEST = 'https://api.example.com/v1/token';
|
||||
|
||||
const jsonResponse = (
|
||||
body: unknown,
|
||||
status = 200,
|
||||
headers: Record<string, string> = {}
|
||||
): Response =>
|
||||
new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { 'content-type': 'application/json', ...headers },
|
||||
});
|
||||
|
||||
/** Never settles on its own; only the per-attempt timeout or the caller's signal ends it. */
|
||||
const hangUntilAborted = (init?: RequestInit): Promise<Response> =>
|
||||
new Promise<Response>((_resolve, reject) => {
|
||||
init?.signal?.addEventListener('abort', () => {
|
||||
reject(init.signal?.reason ?? new Error('aborted'));
|
||||
});
|
||||
});
|
||||
|
||||
// Backoff is kept at a single millisecond so retries are asserted by call count, never by clock.
|
||||
const fast = { maxAttempts: 3, baseDelayMs: 1 } as const;
|
||||
|
||||
describe('requestJson', () => {
|
||||
let fetchMock: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
fetchMock = vi.fn();
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('should resolve and parse a 200 JSON body', async () => {
|
||||
fetchMock.mockResolvedValueOnce(jsonResponse({ access_token: 'abc', expires_in: 3600 }));
|
||||
|
||||
const result = await requestJson<{ access_token: string; expires_in: number }>(
|
||||
URL_UNDER_TEST,
|
||||
fast
|
||||
);
|
||||
|
||||
expect(result).toEqual({ access_token: 'abc', expires_in: 3600 });
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should retry a 429 with a small Retry-After and then succeed', async () => {
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(jsonResponse({ error: 'slow down' }, 429, { 'retry-after': '0' }))
|
||||
.mockResolvedValueOnce(jsonResponse({ ok: true }));
|
||||
|
||||
const result = await requestJson<{ ok: boolean }>(URL_UNDER_TEST, fast);
|
||||
|
||||
expect(result).toEqual({ ok: true });
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('should honor an HTTP-date Retry-After on a 429', async () => {
|
||||
const httpDate = new Date(Date.now() + 500).toUTCString();
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(jsonResponse({}, 429, { 'retry-after': httpDate }))
|
||||
.mockResolvedValueOnce(jsonResponse({ ok: true }));
|
||||
|
||||
const result = await requestJson<{ ok: boolean }>(URL_UNDER_TEST, fast);
|
||||
|
||||
expect(result).toEqual({ ok: true });
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('should retry a 500 up to maxAttempts and then throw', async () => {
|
||||
fetchMock.mockImplementation(() => Promise.resolve(jsonResponse({ error: 'boom' }, 500)));
|
||||
|
||||
await expect(requestJson(URL_UNDER_TEST, fast)).rejects.toThrow(/status 500/);
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('should throw immediately on a 400 without retrying', async () => {
|
||||
fetchMock.mockResolvedValueOnce(jsonResponse({ error: 'invalid_grant' }, 400));
|
||||
|
||||
await expect(requestJson(URL_UNDER_TEST, fast)).rejects.toThrow();
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should throw immediately on a 401 without retrying', async () => {
|
||||
fetchMock.mockResolvedValueOnce(jsonResponse({ error: 'unauthorized' }, 401));
|
||||
|
||||
await expect(requestJson(URL_UNDER_TEST, fast)).rejects.toThrow();
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should treat a per-attempt timeout as retryable', async () => {
|
||||
fetchMock
|
||||
.mockImplementationOnce((_url: string, init?: RequestInit) => hangUntilAborted(init))
|
||||
.mockResolvedValueOnce(jsonResponse({ ok: true }));
|
||||
|
||||
const result = await requestJson<{ ok: boolean }>(URL_UNDER_TEST, {
|
||||
...fast,
|
||||
timeoutMs: 1,
|
||||
});
|
||||
|
||||
expect(result).toEqual({ ok: true });
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('should exhaust attempts when every attempt times out', async () => {
|
||||
fetchMock.mockImplementation((_url: string, init?: RequestInit) => hangUntilAborted(init));
|
||||
|
||||
const error = await requestJson(URL_UNDER_TEST, { ...fast, maxAttempts: 2, timeoutMs: 1 })
|
||||
.then(() => null)
|
||||
.catch((err: unknown) => err);
|
||||
|
||||
expect(isHttpRequestError(error)).toBe(true);
|
||||
expect(isHttpRequestError(error) && error.kind).toBe('timeout');
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('should identify the url, status and attempt count in the thrown message', async () => {
|
||||
fetchMock.mockResolvedValueOnce(jsonResponse({ error: 'invalid_grant' }, 400));
|
||||
|
||||
const error = await requestJson(URL_UNDER_TEST, fast)
|
||||
.then(() => null)
|
||||
.catch((err: unknown) => err);
|
||||
|
||||
expect(isHttpRequestError(error)).toBe(true);
|
||||
const message = error instanceof Error ? error.message : '';
|
||||
expect(message).toContain(URL_UNDER_TEST);
|
||||
expect(message).toContain('status 400');
|
||||
expect(message).toContain('1 attempt(s)');
|
||||
expect(isHttpRequestError(error) && error.status).toBe(400);
|
||||
});
|
||||
|
||||
it('should distinguish an HTTP failure from a programming error', async () => {
|
||||
fetchMock.mockResolvedValueOnce(jsonResponse({}, 400));
|
||||
|
||||
const httpError = await requestJson(URL_UNDER_TEST, fast)
|
||||
.then(() => null)
|
||||
.catch((err: unknown) => err);
|
||||
|
||||
expect(isHttpRequestError(httpError)).toBe(true);
|
||||
expect(isHttpRequestError(new TypeError('bad call'))).toBe(false);
|
||||
});
|
||||
|
||||
it('should reject a 2xx whose body is not valid JSON', async () => {
|
||||
fetchMock.mockResolvedValueOnce(new Response('<html>maintenance</html>', { status: 200 }));
|
||||
|
||||
const error = await requestJson(URL_UNDER_TEST, fast)
|
||||
.then(() => null)
|
||||
.catch((err: unknown) => err);
|
||||
|
||||
expect(isHttpRequestError(error) && error.kind).toBe('invalid-body');
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should resolve undefined for a bodiless 204', async () => {
|
||||
fetchMock.mockResolvedValueOnce(new Response(null, { status: 204 }));
|
||||
|
||||
await expect(requestJson(URL_UNDER_TEST, fast)).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('should retry a transport error and then succeed', async () => {
|
||||
fetchMock
|
||||
.mockRejectedValueOnce(new TypeError('fetch failed'))
|
||||
.mockResolvedValueOnce(jsonResponse({ ok: true }));
|
||||
|
||||
const result = await requestJson<{ ok: boolean }>(URL_UNDER_TEST, fast);
|
||||
|
||||
expect(result).toEqual({ ok: true });
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('should not call fetch when the caller signal is already aborted', async () => {
|
||||
fetchMock.mockResolvedValue(jsonResponse({ ok: true }));
|
||||
|
||||
const error = await requestJson(URL_UNDER_TEST, { ...fast, signal: AbortSignal.abort() })
|
||||
.then(() => null)
|
||||
.catch((err: unknown) => err);
|
||||
|
||||
expect(isHttpRequestError(error) && error.kind).toBe('aborted');
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should abort without retrying when the caller signal fires mid-flight', async () => {
|
||||
const controller = new AbortController();
|
||||
fetchMock.mockImplementation((_url: string, init?: RequestInit) => {
|
||||
queueMicrotask(() => controller.abort());
|
||||
return hangUntilAborted(init);
|
||||
});
|
||||
|
||||
const error = await requestJson(URL_UNDER_TEST, { ...fast, signal: controller.signal })
|
||||
.then(() => null)
|
||||
.catch((err: unknown) => err);
|
||||
|
||||
expect(isHttpRequestError(error) && error.kind).toBe('aborted');
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should reject a non-positive maxAttempts', async () => {
|
||||
await expect(requestJson(URL_UNDER_TEST, { maxAttempts: 0 })).rejects.toThrow(TypeError);
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseRetryAfter', () => {
|
||||
it('should read the delay-seconds form', () => {
|
||||
expect(parseRetryAfter('30')).toBe(30_000);
|
||||
expect(parseRetryAfter('0')).toBe(0);
|
||||
});
|
||||
|
||||
it('should read the HTTP-date form as a delay from now', () => {
|
||||
const delay = parseRetryAfter(new Date(Date.now() + 30_000).toUTCString());
|
||||
|
||||
expect(delay).not.toBeNull();
|
||||
expect(delay).toBeGreaterThan(25_000);
|
||||
expect(delay).toBeLessThanOrEqual(30_000);
|
||||
});
|
||||
|
||||
it('should clamp a past HTTP-date to zero', () => {
|
||||
expect(parseRetryAfter(new Date(Date.now() - 60_000).toUTCString())).toBe(0);
|
||||
});
|
||||
|
||||
it('should cap a hostile delay-seconds value', () => {
|
||||
expect(parseRetryAfter('86400')).toBe(60_000);
|
||||
});
|
||||
|
||||
it('should cap a hostile HTTP-date value', () => {
|
||||
expect(parseRetryAfter(new Date(Date.now() + 86_400_000).toUTCString())).toBe(60_000);
|
||||
});
|
||||
|
||||
it('should return null for a missing or unintelligible header', () => {
|
||||
expect(parseRetryAfter(null)).toBeNull();
|
||||
expect(parseRetryAfter(' ')).toBeNull();
|
||||
expect(parseRetryAfter('soon')).toBeNull();
|
||||
});
|
||||
});
|
||||
158
backend/tests/ingest.routes.test.ts
Normal file
158
backend/tests/ingest.routes.test.ts
Normal file
@@ -0,0 +1,158 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import app from '../app.js';
|
||||
import type { IntegrationRow } from '../types/integration.js';
|
||||
import type { Note, NoteInput } from '../types/domain.js';
|
||||
|
||||
vi.mock('../db/integrations.dao.js', () => ({
|
||||
findByApiKeyHash: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../db/notes.dao.js', () => ({
|
||||
createNotes: vi.fn(),
|
||||
getAllNotes: vi.fn(),
|
||||
streamAllNotes: vi.fn(),
|
||||
}));
|
||||
|
||||
import { findByApiKeyHash } from '../db/integrations.dao.js';
|
||||
import { createNotes } from '../db/notes.dao.js';
|
||||
|
||||
const mockFindByApiKeyHash = vi.mocked(findByApiKeyHash);
|
||||
const mockCreateNotes = vi.mocked(createNotes);
|
||||
|
||||
const INTEGRATION: IntegrationRow = {
|
||||
id: 7,
|
||||
provider: 'rest',
|
||||
externalWorkspaceId: 'acme',
|
||||
displayName: 'Acme',
|
||||
scopes: [],
|
||||
tokenExpiresAt: null,
|
||||
};
|
||||
|
||||
const INPUT: NoteInput[] = [
|
||||
{ id: 'note_100', text: 'Retro: deploys are scary', author: 'user_1' },
|
||||
];
|
||||
|
||||
const STORED: Note[] = [
|
||||
{ id: 'note_100', text: 'Retro: deploys are scary', x: 0, y: 0, author: 'user_1', color: 'yellow' },
|
||||
];
|
||||
|
||||
const post = (body: object, key = 'secret-key') =>
|
||||
request(app).post('/v1/notes').set('Authorization', `Bearer ${key}`).send(body);
|
||||
|
||||
describe('POST /v1/notes', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockFindByApiKeyHash.mockResolvedValue(INTEGRATION);
|
||||
mockCreateNotes.mockResolvedValue(STORED);
|
||||
});
|
||||
|
||||
it('should insert notes and return 201 with the stored rows', async () => {
|
||||
const res = await post({ notes: INPUT });
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body).toEqual({ inserted: 1, notes: STORED });
|
||||
expect(mockCreateNotes).toHaveBeenCalledWith(INPUT);
|
||||
});
|
||||
|
||||
it('should report the inserted count from the database, not the request', async () => {
|
||||
mockCreateNotes.mockResolvedValue([]);
|
||||
|
||||
const res = await post({ notes: INPUT });
|
||||
|
||||
expect(res.body.inserted).toBe(0);
|
||||
});
|
||||
|
||||
it('should reject a body with no notes key', async () => {
|
||||
const res = await post({});
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(mockCreateNotes).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should reject notes that is not an array', async () => {
|
||||
const res = await post({ notes: 'nope' });
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('should reject an empty notes array', async () => {
|
||||
const res = await post({ notes: [] });
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(mockCreateNotes).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should reject a note missing text', async () => {
|
||||
const res = await post({ notes: [{ id: 'a', author: 'user_1' }] });
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('should reject a note whose x is not a number', async () => {
|
||||
const res = await post({ notes: [{ ...INPUT[0], x: 'left' }] });
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('should reject a batch over the per-request limit', async () => {
|
||||
const many = Array.from({ length: 5001 }, (_, i) => ({
|
||||
id: `note_${i}`,
|
||||
text: 'text',
|
||||
author: 'user_1',
|
||||
}));
|
||||
|
||||
const res = await post({ notes: many });
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(mockCreateNotes).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return 401 when no Authorization header is sent', async () => {
|
||||
const res = await request(app).post('/v1/notes').send({ notes: INPUT });
|
||||
|
||||
expect(res.status).toBe(401);
|
||||
expect(mockCreateNotes).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return 401 for a non-bearer scheme', async () => {
|
||||
const res = await request(app)
|
||||
.post('/v1/notes')
|
||||
.set('Authorization', 'Basic abc123')
|
||||
.send({ notes: INPUT });
|
||||
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it('should return 401 for an unknown key without revealing why', async () => {
|
||||
mockFindByApiKeyHash.mockResolvedValue(null);
|
||||
|
||||
const res = await post({ notes: INPUT });
|
||||
|
||||
expect(res.status).toBe(401);
|
||||
expect(res.body).toEqual({ error: 'Unauthorized' });
|
||||
});
|
||||
|
||||
it('should not send the raw key to the database', async () => {
|
||||
await post({ notes: INPUT }, 'plaintext-key');
|
||||
|
||||
const [hash] = mockFindByApiKeyHash.mock.calls[0];
|
||||
expect(hash).not.toContain('plaintext-key');
|
||||
expect(hash).toMatch(/^[0-9a-f]{64}$/);
|
||||
});
|
||||
|
||||
it('should return 500 when the insert fails', async () => {
|
||||
mockCreateNotes.mockRejectedValue(new Error('connection refused'));
|
||||
|
||||
const res = await post({ notes: INPUT });
|
||||
|
||||
expect(res.status).toBe(500);
|
||||
});
|
||||
|
||||
it('should leave the existing GET /v1/notes route reachable', async () => {
|
||||
const res = await request(app).get('/v1/notes');
|
||||
|
||||
expect(res.status).not.toBe(404);
|
||||
});
|
||||
});
|
||||
142
backend/tests/ingest_events.dao.test.ts
Normal file
142
backend/tests/ingest_events.dao.test.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
const { mockQuery, mockConnect } = vi.hoisted(() => ({
|
||||
mockQuery: vi.fn(),
|
||||
mockConnect: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../db/index.js', () => ({
|
||||
query: mockQuery,
|
||||
getPool: () => ({ connect: mockConnect }),
|
||||
}));
|
||||
|
||||
import {
|
||||
recordDelivery,
|
||||
markProcessing,
|
||||
markDone,
|
||||
markFailed,
|
||||
resetStaleProcessing,
|
||||
} from '../db/ingest_events.dao.js';
|
||||
|
||||
describe('ingest_events.dao', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('recordDelivery', () => {
|
||||
it('should return the new row id for a delivery that has not been seen', async () => {
|
||||
mockQuery.mockResolvedValue({ rows: [{ id: 42 }] });
|
||||
|
||||
const result = await recordDelivery({ provider: 'slack', externalId: 'evt_1' });
|
||||
|
||||
expect(result).toBe(42);
|
||||
});
|
||||
|
||||
it('should return null when the delivery conflicts with an existing row', async () => {
|
||||
mockQuery.mockResolvedValue({ rows: [] });
|
||||
|
||||
const result = await recordDelivery({ provider: 'slack', externalId: 'evt_1' });
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should insert with ON CONFLICT DO NOTHING on the provider and external id', async () => {
|
||||
mockQuery.mockResolvedValue({ rows: [{ id: 1 }] });
|
||||
|
||||
await recordDelivery({ provider: 'linear', externalId: 'evt_2' });
|
||||
|
||||
const [sql] = mockQuery.mock.calls[0] as [string, unknown[]];
|
||||
expect(sql).toContain('INSERT INTO ingest_events');
|
||||
expect(sql).toContain('ON CONFLICT (provider, external_id) DO NOTHING');
|
||||
expect(sql).toContain('RETURNING id');
|
||||
});
|
||||
|
||||
it('should bind the provider, external id and integration id', async () => {
|
||||
mockQuery.mockResolvedValue({ rows: [{ id: 7 }] });
|
||||
|
||||
await recordDelivery({ provider: 'github', externalId: 'evt_3', integrationId: 12 });
|
||||
|
||||
const [, params] = mockQuery.mock.calls[0] as [string, unknown[]];
|
||||
expect(params).toEqual(['github', 'evt_3', 12]);
|
||||
});
|
||||
|
||||
it('should bind null when no integration id is supplied', async () => {
|
||||
mockQuery.mockResolvedValue({ rows: [{ id: 8 }] });
|
||||
|
||||
await recordDelivery({ provider: 'rest', externalId: 'evt_4' });
|
||||
|
||||
const [, params] = mockQuery.mock.calls[0] as [string, unknown[]];
|
||||
expect(params[2]).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('markProcessing', () => {
|
||||
it('should set the status to processing and increment attempts', async () => {
|
||||
mockQuery.mockResolvedValue({ rows: [] });
|
||||
|
||||
await markProcessing(5);
|
||||
|
||||
const [sql, params] = mockQuery.mock.calls[0] as [string, unknown[]];
|
||||
expect(sql).toContain("status = 'processing'");
|
||||
expect(sql).toContain('attempts = attempts + 1');
|
||||
expect(params).toEqual([5]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('markDone', () => {
|
||||
it('should set the status to done and clear the last error', async () => {
|
||||
mockQuery.mockResolvedValue({ rows: [] });
|
||||
|
||||
await markDone(9);
|
||||
|
||||
const [sql, params] = mockQuery.mock.calls[0] as [string, unknown[]];
|
||||
expect(sql).toContain("status = 'done'");
|
||||
expect(sql).toContain('last_error = NULL');
|
||||
expect(params).toEqual([9]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('markFailed', () => {
|
||||
it('should store the failure message against the row', async () => {
|
||||
mockQuery.mockResolvedValue({ rows: [] });
|
||||
|
||||
await markFailed(3, 'normalizer threw');
|
||||
|
||||
const [sql, params] = mockQuery.mock.calls[0] as [string, unknown[]];
|
||||
expect(sql).toContain("status = 'failed'");
|
||||
expect(sql).toContain('last_error = $2');
|
||||
expect(params).toEqual([3, 'normalizer threw']);
|
||||
});
|
||||
|
||||
it('should truncate an over-long error to 2000 characters', async () => {
|
||||
mockQuery.mockResolvedValue({ rows: [] });
|
||||
|
||||
await markFailed(3, 'x'.repeat(5000));
|
||||
|
||||
const [, params] = mockQuery.mock.calls[0] as [string, unknown[]];
|
||||
expect(params[1]).toBe('x'.repeat(2000));
|
||||
});
|
||||
});
|
||||
|
||||
describe('resetStaleProcessing', () => {
|
||||
it('should return the number of rows returned to pending', async () => {
|
||||
mockQuery.mockResolvedValue({ rows: [], rowCount: 4 });
|
||||
|
||||
const result = await resetStaleProcessing(30_000);
|
||||
|
||||
expect(result).toBe(4);
|
||||
const [sql, params] = mockQuery.mock.calls[0] as [string, unknown[]];
|
||||
expect(sql).toContain("SET status = 'pending'");
|
||||
expect(sql).toContain("status = 'processing'");
|
||||
expect(params).toEqual(['30000']);
|
||||
});
|
||||
|
||||
it('should return 0 when the driver reports a null row count', async () => {
|
||||
mockQuery.mockResolvedValue({ rows: [], rowCount: null });
|
||||
|
||||
const result = await resetStaleProcessing(30_000);
|
||||
|
||||
expect(result).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
271
backend/tests/integrations.dao.test.ts
Normal file
271
backend/tests/integrations.dao.test.ts
Normal file
@@ -0,0 +1,271 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
|
||||
const { mockQuery, mockConnect } = vi.hoisted(() => ({
|
||||
mockQuery: vi.fn(),
|
||||
mockConnect: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../db/index.js', () => ({
|
||||
query: mockQuery,
|
||||
getPool: () => ({ connect: mockConnect }),
|
||||
}));
|
||||
|
||||
import {
|
||||
findByApiKeyHash,
|
||||
findByProviderWorkspace,
|
||||
upsertInstall,
|
||||
updateTokens,
|
||||
getAccessToken,
|
||||
getRefreshToken,
|
||||
getSigningSecret,
|
||||
} from '../db/integrations.dao.js';
|
||||
import { encryptSecret } from '../lib/crypto.js';
|
||||
|
||||
const TEST_KEY = Buffer.alloc(32, 0x2b).toString('base64');
|
||||
|
||||
const RAW_ROW = {
|
||||
id: 3,
|
||||
provider: 'slack',
|
||||
external_workspace_id: 'T123',
|
||||
display_name: 'Acme',
|
||||
api_key_hash: 'abc123',
|
||||
access_token_ciphertext: 'iv:tag:payload',
|
||||
refresh_token_ciphertext: 'iv:tag:payload',
|
||||
signing_secret_ciphertext: 'iv:tag:payload',
|
||||
token_expires_at: new Date('2030-01-01T00:00:00.000Z'),
|
||||
scopes: ['channels:history'],
|
||||
};
|
||||
|
||||
describe('integrations.dao', () => {
|
||||
let originalKey: string | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
originalKey = process.env.TOKEN_ENCRYPTION_KEY;
|
||||
process.env.TOKEN_ENCRYPTION_KEY = TEST_KEY;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (originalKey === undefined) {
|
||||
delete process.env.TOKEN_ENCRYPTION_KEY;
|
||||
} else {
|
||||
process.env.TOKEN_ENCRYPTION_KEY = originalKey;
|
||||
}
|
||||
});
|
||||
|
||||
describe('findByApiKeyHash', () => {
|
||||
it('should return the integration mapped to camelCase fields', async () => {
|
||||
mockQuery.mockResolvedValue({ rows: [RAW_ROW] });
|
||||
|
||||
const result = await findByApiKeyHash('abc123');
|
||||
|
||||
expect(result).toEqual({
|
||||
id: 3,
|
||||
provider: 'slack',
|
||||
externalWorkspaceId: 'T123',
|
||||
displayName: 'Acme',
|
||||
scopes: ['channels:history'],
|
||||
tokenExpiresAt: new Date('2030-01-01T00:00:00.000Z'),
|
||||
});
|
||||
const [sql, params] = mockQuery.mock.calls[0] as [string, unknown[]];
|
||||
expect(sql).toContain('WHERE api_key_hash = $1');
|
||||
expect(params).toEqual(['abc123']);
|
||||
});
|
||||
|
||||
it('should not expose any ciphertext or token field on the returned row', async () => {
|
||||
mockQuery.mockResolvedValue({ rows: [RAW_ROW] });
|
||||
|
||||
const result = await findByApiKeyHash('abc123');
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result).not.toHaveProperty('access_token_ciphertext');
|
||||
expect(result).not.toHaveProperty('refresh_token_ciphertext');
|
||||
expect(result).not.toHaveProperty('signing_secret_ciphertext');
|
||||
expect(result).not.toHaveProperty('accessToken');
|
||||
expect(result).not.toHaveProperty('api_key_hash');
|
||||
expect(Object.keys(result ?? {}).sort()).toEqual([
|
||||
'displayName',
|
||||
'externalWorkspaceId',
|
||||
'id',
|
||||
'provider',
|
||||
'scopes',
|
||||
'tokenExpiresAt',
|
||||
]);
|
||||
});
|
||||
|
||||
it('should default scopes to an empty array when the column is null', async () => {
|
||||
mockQuery.mockResolvedValue({ rows: [{ ...RAW_ROW, scopes: null }] });
|
||||
|
||||
const result = await findByApiKeyHash('abc123');
|
||||
|
||||
expect(result?.scopes).toEqual([]);
|
||||
});
|
||||
|
||||
it('should return null when no integration matches the hash', async () => {
|
||||
mockQuery.mockResolvedValue({ rows: [] });
|
||||
|
||||
const result = await findByApiKeyHash('nope');
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('findByProviderWorkspace', () => {
|
||||
it('should bind the provider and external workspace id', async () => {
|
||||
mockQuery.mockResolvedValue({ rows: [RAW_ROW] });
|
||||
|
||||
const result = await findByProviderWorkspace('slack', 'T123');
|
||||
|
||||
expect(result?.id).toBe(3);
|
||||
const [sql, params] = mockQuery.mock.calls[0] as [string, unknown[]];
|
||||
expect(sql).toContain('WHERE provider = $1 AND external_workspace_id = $2');
|
||||
expect(params).toEqual(['slack', 'T123']);
|
||||
});
|
||||
|
||||
it('should return null when the workspace has no integration', async () => {
|
||||
mockQuery.mockResolvedValue({ rows: [] });
|
||||
|
||||
const result = await findByProviderWorkspace('slack', 'T999');
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('upsertInstall', () => {
|
||||
it('should encrypt the signing secret before binding it', async () => {
|
||||
mockQuery.mockResolvedValue({ rows: [RAW_ROW] });
|
||||
|
||||
await upsertInstall({
|
||||
provider: 'slack',
|
||||
externalWorkspaceId: 'T123',
|
||||
displayName: 'Acme',
|
||||
signingSecret: 'super-secret',
|
||||
});
|
||||
|
||||
const [sql, params] = mockQuery.mock.calls[0] as [string, unknown[]];
|
||||
expect(sql).toContain('INSERT INTO integrations');
|
||||
expect(sql).toContain('ON CONFLICT (provider, external_workspace_id) DO UPDATE SET');
|
||||
const stored = params[4];
|
||||
expect(typeof stored).toBe('string');
|
||||
expect(stored).not.toBe('super-secret');
|
||||
expect(String(stored).split(':')).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('should bind null when no signing secret is supplied', async () => {
|
||||
mockQuery.mockResolvedValue({ rows: [RAW_ROW] });
|
||||
|
||||
await upsertInstall({ provider: 'slack', externalWorkspaceId: 'T123' });
|
||||
|
||||
const [, params] = mockQuery.mock.calls[0] as [string, unknown[]];
|
||||
expect(params[4]).toBeNull();
|
||||
expect(params[2]).toBeNull();
|
||||
expect(params[3]).toBeNull();
|
||||
expect(params[5]).toEqual([]);
|
||||
});
|
||||
|
||||
it('should return the public row for the upserted integration', async () => {
|
||||
mockQuery.mockResolvedValue({ rows: [RAW_ROW] });
|
||||
|
||||
const result = await upsertInstall({ provider: 'slack', externalWorkspaceId: 'T123' });
|
||||
|
||||
expect(result.externalWorkspaceId).toBe('T123');
|
||||
expect(result).not.toHaveProperty('signing_secret_ciphertext');
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateTokens', () => {
|
||||
it('should encrypt the access token rather than storing it in plaintext', async () => {
|
||||
mockQuery.mockResolvedValue({ rows: [] });
|
||||
|
||||
await updateTokens({ id: 3, accessToken: 'at-plain', refreshToken: 'rt-plain' });
|
||||
|
||||
const [sql, params] = mockQuery.mock.calls[0] as [string, unknown[]];
|
||||
expect(sql).toContain('access_token_ciphertext = $2');
|
||||
expect(params[0]).toBe(3);
|
||||
expect(params[1]).not.toBe('at-plain');
|
||||
expect(String(params[1]).split(':')).toHaveLength(3);
|
||||
expect(params[2]).not.toBe('rt-plain');
|
||||
});
|
||||
|
||||
it('should bind null for an absent refresh token and expiry', async () => {
|
||||
mockQuery.mockResolvedValue({ rows: [] });
|
||||
|
||||
await updateTokens({ id: 3, accessToken: 'at-plain' });
|
||||
|
||||
const [, params] = mockQuery.mock.calls[0] as [string, unknown[]];
|
||||
expect(params[2]).toBeNull();
|
||||
expect(params[3]).toBeNull();
|
||||
});
|
||||
|
||||
it('should bind the supplied expiry date', async () => {
|
||||
mockQuery.mockResolvedValue({ rows: [] });
|
||||
const expiresAt = new Date('2031-05-05T10:00:00.000Z');
|
||||
|
||||
await updateTokens({ id: 3, accessToken: 'at-plain', expiresAt });
|
||||
|
||||
const [, params] = mockQuery.mock.calls[0] as [string, unknown[]];
|
||||
expect(params[3]).toEqual(expiresAt);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAccessToken', () => {
|
||||
it('should decrypt the stored ciphertext back to the original token', async () => {
|
||||
mockQuery.mockResolvedValue({ rows: [{ value: encryptSecret('at-plain') }] });
|
||||
|
||||
const result = await getAccessToken(3);
|
||||
|
||||
expect(result).toBe('at-plain');
|
||||
const [sql, params] = mockQuery.mock.calls[0] as [string, unknown[]];
|
||||
expect(sql).toContain('SELECT access_token_ciphertext AS value');
|
||||
expect(params).toEqual([3]);
|
||||
});
|
||||
|
||||
it('should return null when no access token is stored', async () => {
|
||||
mockQuery.mockResolvedValue({ rows: [{ value: null }] });
|
||||
|
||||
const result = await getAccessToken(3);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null when the integration does not exist', async () => {
|
||||
mockQuery.mockResolvedValue({ rows: [] });
|
||||
|
||||
const result = await getAccessToken(404);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getRefreshToken', () => {
|
||||
it('should decrypt the refresh token column', async () => {
|
||||
mockQuery.mockResolvedValue({ rows: [{ value: encryptSecret('rt-plain') }] });
|
||||
|
||||
const result = await getRefreshToken(3);
|
||||
|
||||
expect(result).toBe('rt-plain');
|
||||
const [sql] = mockQuery.mock.calls[0] as [string, unknown[]];
|
||||
expect(sql).toContain('SELECT refresh_token_ciphertext AS value');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getSigningSecret', () => {
|
||||
it('should decrypt the signing secret column', async () => {
|
||||
mockQuery.mockResolvedValue({ rows: [{ value: encryptSecret('shh') }] });
|
||||
|
||||
const result = await getSigningSecret(3);
|
||||
|
||||
expect(result).toBe('shh');
|
||||
const [sql] = mockQuery.mock.calls[0] as [string, unknown[]];
|
||||
expect(sql).toContain('SELECT signing_secret_ciphertext AS value');
|
||||
});
|
||||
|
||||
it('should return null when no signing secret is stored', async () => {
|
||||
mockQuery.mockResolvedValue({ rows: [{ value: null }] });
|
||||
|
||||
const result = await getSigningSecret(3);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
340
backend/tests/normalizers.test.ts
Normal file
340
backend/tests/normalizers.test.ts
Normal file
@@ -0,0 +1,340 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
NormalizationError,
|
||||
isNoteInputArray,
|
||||
normalizeLinear,
|
||||
normalizeRest,
|
||||
} from '../config/normalizers.js';
|
||||
import { normalize, hasNormalizer } from '../services/normalize.service.js';
|
||||
import type { NoteInput } from '../types/domain.js';
|
||||
|
||||
const note = (overrides: Record<string, unknown> = {}): Record<string, unknown> => ({
|
||||
id: 'n1',
|
||||
text: 'Deploys are scary',
|
||||
author: 'kim',
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const sourceMeta = (input: NoteInput): Record<string, unknown> => {
|
||||
expect(input.sourceMeta).toBeDefined();
|
||||
return input.sourceMeta as Record<string, unknown>;
|
||||
};
|
||||
|
||||
const linearPayload = (
|
||||
data: Record<string, unknown> = {}
|
||||
): Record<string, unknown> => ({
|
||||
action: 'create',
|
||||
type: 'Comment',
|
||||
data: {
|
||||
id: 'cmt_9f2',
|
||||
body: 'Retros keep surfacing the same deploy pain',
|
||||
url: 'https://linear.app/acme/issue/ENG-42#comment-cmt_9f2',
|
||||
user: { name: 'dana' },
|
||||
...data,
|
||||
},
|
||||
});
|
||||
|
||||
describe('isNoteInputArray', () => {
|
||||
it('should accept an empty array', () => {
|
||||
expect(isNoteInputArray([])).toBe(true);
|
||||
});
|
||||
|
||||
it('should accept an array of well-formed notes', () => {
|
||||
expect(isNoteInputArray([note(), note({ id: 'n2', x: 1, y: 2, color: '#fff' })])).toBe(true);
|
||||
});
|
||||
|
||||
it('should reject a non-array', () => {
|
||||
expect(isNoteInputArray(undefined)).toBe(false);
|
||||
expect(isNoteInputArray(null)).toBe(false);
|
||||
expect(isNoteInputArray('notes')).toBe(false);
|
||||
expect(isNoteInputArray({ 0: note(), length: 1 })).toBe(false);
|
||||
});
|
||||
|
||||
it('should reject an element that is not an object', () => {
|
||||
expect(isNoteInputArray([note(), 'nope'])).toBe(false);
|
||||
expect(isNoteInputArray([null])).toBe(false);
|
||||
});
|
||||
|
||||
it('should reject an element missing a required field', () => {
|
||||
expect(isNoteInputArray([note({ id: undefined })])).toBe(false);
|
||||
expect(isNoteInputArray([note({ text: undefined })])).toBe(false);
|
||||
expect(isNoteInputArray([note({ author: undefined })])).toBe(false);
|
||||
});
|
||||
|
||||
it('should reject an element whose required field has the wrong type', () => {
|
||||
expect(isNoteInputArray([note({ id: 7 })])).toBe(false);
|
||||
expect(isNoteInputArray([note({ text: { body: 'hi' } })])).toBe(false);
|
||||
expect(isNoteInputArray([note({ author: ['kim'] })])).toBe(false);
|
||||
});
|
||||
|
||||
it('should reject an element with empty strings', () => {
|
||||
expect(isNoteInputArray([note({ id: '' })])).toBe(false);
|
||||
expect(isNoteInputArray([note({ text: '' })])).toBe(false);
|
||||
expect(isNoteInputArray([note({ author: '' })])).toBe(false);
|
||||
});
|
||||
|
||||
it('should reject an element whose optional field has the wrong type', () => {
|
||||
expect(isNoteInputArray([note({ x: '10' })])).toBe(false);
|
||||
expect(isNoteInputArray([note({ y: null })])).toBe(false);
|
||||
expect(isNoteInputArray([note({ color: 0xffffff })])).toBe(false);
|
||||
});
|
||||
|
||||
it('should accept an element whose optional fields are undefined', () => {
|
||||
expect(isNoteInputArray([note({ x: undefined, y: undefined, color: undefined })])).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('normalizeRest', () => {
|
||||
it('should return the supplied notes with provenance attached', () => {
|
||||
const result = normalizeRest({
|
||||
batchId: 'batch_7',
|
||||
notes: [note(), note({ id: 'n2', text: 'Standups run long' })],
|
||||
});
|
||||
|
||||
expect(result.externalId).toBe('batch_7');
|
||||
expect(result.notes).toHaveLength(2);
|
||||
expect(result.notes[0].id).toBe('n1');
|
||||
expect(result.notes[0].text).toBe('Deploys are scary');
|
||||
expect(result.notes[0].author).toBe('kim');
|
||||
expect(sourceMeta(result.notes[0])).toMatchObject({
|
||||
provider: 'rest',
|
||||
externalId: 'batch_7',
|
||||
});
|
||||
expect(sourceMeta(result.notes[1]).externalId).toBe('batch_7');
|
||||
});
|
||||
|
||||
it('should record an ISO receivedAt timestamp in provenance', () => {
|
||||
const result = normalizeRest({ notes: [note()] });
|
||||
const receivedAt = String(sourceMeta(result.notes[0]).receivedAt);
|
||||
|
||||
expect(new Date(receivedAt).toISOString()).toBe(receivedAt);
|
||||
});
|
||||
|
||||
it('should preserve optional positional and color fields', () => {
|
||||
const result = normalizeRest({
|
||||
notes: [note({ x: 12, y: -3, color: '#ffcc00' })],
|
||||
});
|
||||
|
||||
expect(result.notes[0]).toMatchObject({ x: 12, y: -3, color: '#ffcc00' });
|
||||
});
|
||||
|
||||
it('should generate a non-empty externalId when batchId is absent', () => {
|
||||
const result = normalizeRest({ notes: [note()] });
|
||||
|
||||
expect(result.externalId.length).toBeGreaterThan(0);
|
||||
expect(sourceMeta(result.notes[0]).externalId).toBe(result.externalId);
|
||||
});
|
||||
|
||||
it('should generate a distinct externalId per call', () => {
|
||||
const first = normalizeRest({ notes: [note()] });
|
||||
const second = normalizeRest({ notes: [note()] });
|
||||
|
||||
expect(first.externalId).not.toBe(second.externalId);
|
||||
});
|
||||
|
||||
it('should ignore an empty batchId in favour of a generated one', () => {
|
||||
const result = normalizeRest({ batchId: '', notes: [note()] });
|
||||
|
||||
expect(result.externalId.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should ignore a non-string batchId in favour of a generated one', () => {
|
||||
const result = normalizeRest({ batchId: 42, notes: [note()] });
|
||||
|
||||
expect(result.externalId).not.toBe('42');
|
||||
expect(result.externalId.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should accept an empty notes array', () => {
|
||||
const result = normalizeRest({ batchId: 'batch_empty', notes: [] });
|
||||
|
||||
expect(result).toEqual({ externalId: 'batch_empty', notes: [] });
|
||||
});
|
||||
|
||||
it('should throw when notes is missing', () => {
|
||||
expect(() => normalizeRest({ batchId: 'batch_7' })).toThrow(NormalizationError);
|
||||
expect(() => normalizeRest({})).toThrow(/"notes" array/);
|
||||
});
|
||||
|
||||
it('should throw when notes is not an array', () => {
|
||||
expect(() => normalizeRest({ notes: 'one note' })).toThrow(NormalizationError);
|
||||
expect(() => normalizeRest({ notes: { id: 'n1' } })).toThrow(NormalizationError);
|
||||
});
|
||||
|
||||
it('should throw when a note is missing text', () => {
|
||||
expect(() => normalizeRest({ notes: [note({ text: undefined })] })).toThrow(
|
||||
NormalizationError
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw when a note id is not a string', () => {
|
||||
expect(() => normalizeRest({ notes: [note({ id: 99 })] })).toThrow(NormalizationError);
|
||||
});
|
||||
|
||||
it('should throw when a note carries empty strings', () => {
|
||||
expect(() => normalizeRest({ notes: [note({ text: '' })] })).toThrow(NormalizationError);
|
||||
expect(() => normalizeRest({ notes: [note({ id: '' })] })).toThrow(NormalizationError);
|
||||
expect(() => normalizeRest({ notes: [note({ author: '' })] })).toThrow(NormalizationError);
|
||||
});
|
||||
|
||||
it('should throw when one note in an otherwise valid batch is invalid', () => {
|
||||
expect(() => normalizeRest({ notes: [note(), note({ id: 'n2', author: '' })] })).toThrow(
|
||||
NormalizationError
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw for a non-object payload', () => {
|
||||
expect(() => normalizeRest(null)).toThrow(/not an object/);
|
||||
expect(() => normalizeRest(undefined)).toThrow(NormalizationError);
|
||||
expect(() => normalizeRest('notes')).toThrow(NormalizationError);
|
||||
expect(() => normalizeRest([note()])).toThrow(/not an object/);
|
||||
});
|
||||
|
||||
it('should overwrite any caller-supplied sourceMeta with real provenance', () => {
|
||||
const result = normalizeRest({
|
||||
batchId: 'batch_7',
|
||||
notes: [note({ sourceMeta: { provider: 'slack', externalId: 'spoofed' } })],
|
||||
});
|
||||
|
||||
expect(sourceMeta(result.notes[0])).toMatchObject({
|
||||
provider: 'rest',
|
||||
externalId: 'batch_7',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('normalizeLinear', () => {
|
||||
it('should map a comment webhook to a single note', () => {
|
||||
const result = normalizeLinear(linearPayload());
|
||||
|
||||
expect(result.externalId).toBe('cmt_9f2');
|
||||
expect(result.notes).toHaveLength(1);
|
||||
expect(result.notes[0].id).toBe('linear_cmt_9f2');
|
||||
expect(result.notes[0].text).toBe('Retros keep surfacing the same deploy pain');
|
||||
expect(result.notes[0].author).toBe('dana');
|
||||
});
|
||||
|
||||
it('should attach provenance including provider, externalId and permalink', () => {
|
||||
const meta = sourceMeta(normalizeLinear(linearPayload()).notes[0]);
|
||||
|
||||
expect(meta).toMatchObject({
|
||||
provider: 'linear',
|
||||
externalId: 'cmt_9f2',
|
||||
permalink: 'https://linear.app/acme/issue/ENG-42#comment-cmt_9f2',
|
||||
authorHandle: 'dana',
|
||||
});
|
||||
expect(typeof meta.receivedAt).toBe('string');
|
||||
});
|
||||
|
||||
it('should fall back to an unknown author when the user is missing or unnamed', () => {
|
||||
expect(normalizeLinear(linearPayload({ user: undefined })).notes[0].author).toBe('unknown');
|
||||
expect(normalizeLinear(linearPayload({ user: null })).notes[0].author).toBe('unknown');
|
||||
expect(normalizeLinear(linearPayload({ user: {} })).notes[0].author).toBe('unknown');
|
||||
expect(normalizeLinear(linearPayload({ user: { name: '' } })).notes[0].author).toBe('unknown');
|
||||
expect(normalizeLinear(linearPayload({ user: 'dana' })).notes[0].author).toBe('unknown');
|
||||
});
|
||||
|
||||
it('should omit the permalink when url is absent', () => {
|
||||
const meta = sourceMeta(normalizeLinear(linearPayload({ url: undefined })).notes[0]);
|
||||
|
||||
expect(meta.permalink).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should throw when data.id is missing', () => {
|
||||
expect(() => normalizeLinear(linearPayload({ id: undefined }))).toThrow(NormalizationError);
|
||||
expect(() => normalizeLinear(linearPayload({ id: '' }))).toThrow(/missing data.id/);
|
||||
expect(() => normalizeLinear(linearPayload({ id: 42 }))).toThrow(NormalizationError);
|
||||
});
|
||||
|
||||
it('should throw when data itself is missing or not an object', () => {
|
||||
expect(() => normalizeLinear({ action: 'create', type: 'Comment' })).toThrow(
|
||||
/not an object/
|
||||
);
|
||||
expect(() => normalizeLinear({ data: 'cmt_9f2' })).toThrow(NormalizationError);
|
||||
expect(() => normalizeLinear({ data: [] })).toThrow(NormalizationError);
|
||||
});
|
||||
|
||||
it('should return zero notes but keep the externalId when the body is empty', () => {
|
||||
expect(normalizeLinear(linearPayload({ body: '' }))).toEqual({
|
||||
externalId: 'cmt_9f2',
|
||||
notes: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('should return zero notes but keep the externalId when the body is absent', () => {
|
||||
expect(normalizeLinear(linearPayload({ body: undefined }))).toEqual({
|
||||
externalId: 'cmt_9f2',
|
||||
notes: [],
|
||||
});
|
||||
expect(normalizeLinear(linearPayload({ body: null }))).toEqual({
|
||||
externalId: 'cmt_9f2',
|
||||
notes: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('should throw for a non-object payload', () => {
|
||||
expect(() => normalizeLinear(null)).toThrow(/not an object/);
|
||||
expect(() => normalizeLinear('cmt_9f2')).toThrow(NormalizationError);
|
||||
expect(() => normalizeLinear(7)).toThrow(NormalizationError);
|
||||
expect(() => normalizeLinear([linearPayload()])).toThrow(/not an object/);
|
||||
});
|
||||
|
||||
it('should truncate a generated note id to 64 characters', () => {
|
||||
const longId = 'x'.repeat(400);
|
||||
const result = normalizeLinear(linearPayload({ id: longId }));
|
||||
|
||||
expect(result.externalId).toBe(longId);
|
||||
expect(result.notes[0].id).toHaveLength(64);
|
||||
expect(result.notes[0].id).toBe(`linear_${longId}`.slice(0, 64));
|
||||
expect(sourceMeta(result.notes[0]).externalId).toBe(longId);
|
||||
});
|
||||
|
||||
it('should produce notes accepted by the note input guard', () => {
|
||||
expect(isNoteInputArray(normalizeLinear(linearPayload()).notes)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('normalize', () => {
|
||||
it('should delegate rest deliveries to the rest normalizer', () => {
|
||||
const result = normalize('rest', { batchId: 'batch_7', notes: [note()] });
|
||||
|
||||
expect(result.externalId).toBe('batch_7');
|
||||
expect(result.notes[0]).toMatchObject({ id: 'n1', author: 'kim' });
|
||||
expect(sourceMeta(result.notes[0])).toMatchObject({
|
||||
provider: 'rest',
|
||||
externalId: 'batch_7',
|
||||
});
|
||||
});
|
||||
|
||||
it('should delegate linear deliveries to the linear normalizer', () => {
|
||||
const result = normalize('linear', linearPayload());
|
||||
|
||||
expect(result.externalId).toBe('cmt_9f2');
|
||||
expect(result.notes[0].id).toBe('linear_cmt_9f2');
|
||||
});
|
||||
|
||||
it('should propagate normalizer failures unchanged', () => {
|
||||
expect(() => normalize('rest', {})).toThrow(NormalizationError);
|
||||
expect(() => normalize('linear', {})).toThrow(NormalizationError);
|
||||
});
|
||||
|
||||
it('should throw for a provider with no registered normalizer', () => {
|
||||
expect(() => normalize('github', {})).toThrow(NormalizationError);
|
||||
expect(() => normalize('github', {})).toThrow(/No normalizer registered/);
|
||||
expect(() => normalize('jira', {})).toThrow(/No normalizer registered/);
|
||||
expect(() => normalize('slack', {})).toThrow(/No normalizer registered/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('hasNormalizer', () => {
|
||||
it('should be true for providers with a normalizer', () => {
|
||||
expect(hasNormalizer('rest')).toBe(true);
|
||||
expect(hasNormalizer('linear')).toBe(true);
|
||||
});
|
||||
|
||||
it('should be false for providers awaiting an integration', () => {
|
||||
expect(hasNormalizer('github')).toBe(false);
|
||||
expect(hasNormalizer('jira')).toBe(false);
|
||||
expect(hasNormalizer('slack')).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { Readable } from 'node:stream';
|
||||
import type { Note, NoteInput } from '../types/domain.js';
|
||||
|
||||
const { mockQuery, mockConnect } = vi.hoisted(() => ({
|
||||
mockQuery: vi.fn(),
|
||||
@@ -19,7 +20,7 @@ import {
|
||||
createNotes,
|
||||
} from '../db/notes.dao.js';
|
||||
|
||||
const MOCK_ROWS = [
|
||||
const MOCK_ROWS: Note[] = [
|
||||
{ id: 'note_001', text: 'Login flow feels confusing', x: 193, y: 191, author: 'user_5', color: 'yellow' },
|
||||
{ id: 'note_002', text: 'Login flow is broken on mobile', x: 214, y: 281, author: 'user_9', color: 'yellow' },
|
||||
];
|
||||
@@ -58,7 +59,7 @@ describe('notes.dao', () => {
|
||||
});
|
||||
|
||||
describe('streamAllNotes', () => {
|
||||
const mockClient = (rows) => {
|
||||
const mockClient = (rows: Note[]) => {
|
||||
const release = vi.fn();
|
||||
const client = {
|
||||
release,
|
||||
@@ -72,8 +73,8 @@ describe('notes.dao', () => {
|
||||
mockClient(MOCK_ROWS);
|
||||
|
||||
const stream = await streamAllNotes();
|
||||
const received = [];
|
||||
for await (const row of stream) received.push(row);
|
||||
const received: Note[] = [];
|
||||
for await (const row of stream) received.push(row as Note);
|
||||
|
||||
expect(received).toEqual(MOCK_ROWS);
|
||||
});
|
||||
@@ -82,7 +83,7 @@ describe('notes.dao', () => {
|
||||
const { release } = mockClient(MOCK_ROWS);
|
||||
|
||||
const stream = await streamAllNotes();
|
||||
for await (const _row of stream) { /* drain */ }
|
||||
for await (const row of stream) void row;
|
||||
|
||||
expect(release).toHaveBeenCalledOnce();
|
||||
});
|
||||
@@ -133,25 +134,40 @@ describe('notes.dao', () => {
|
||||
|
||||
describe('createNote', () => {
|
||||
it('should insert a note and return it', async () => {
|
||||
const input = { id: 'note_003', text: 'Export fails', x: 100, y: 200, author: 'user_1', color: 'blue' };
|
||||
const input: NoteInput = { id: 'note_003', text: 'Export fails', x: 100, y: 200, author: 'user_1', color: 'blue' };
|
||||
mockQuery.mockResolvedValue({ rows: [input] });
|
||||
|
||||
const result = await createNote(input);
|
||||
|
||||
expect(result).toEqual(input);
|
||||
expect(mockQuery).toHaveBeenCalledOnce();
|
||||
const [sql, params] = mockQuery.mock.calls[0];
|
||||
const [sql, params] = mockQuery.mock.calls[0] as [string, unknown[]];
|
||||
expect(sql).toContain('INSERT INTO notes');
|
||||
expect(params).toEqual(['note_003', 'Export fails', 100, 200, 'user_1', 'blue']);
|
||||
expect(params).toEqual(['note_003', 'Export fails', 100, 200, 'user_1', 'blue', '{}']);
|
||||
});
|
||||
|
||||
it('should serialize provenance into source_meta', async () => {
|
||||
const input: NoteInput = {
|
||||
id: 'note_005',
|
||||
text: 'From Slack',
|
||||
author: 'user_3',
|
||||
sourceMeta: { provider: 'slack', externalId: 'msg_1' },
|
||||
};
|
||||
mockQuery.mockResolvedValue({ rows: [input] });
|
||||
|
||||
await createNote(input);
|
||||
|
||||
const params = mockQuery.mock.calls[0][1] as unknown[];
|
||||
expect(params[6]).toBe('{"provider":"slack","externalId":"msg_1"}');
|
||||
});
|
||||
|
||||
it('should use defaults for missing x, y, and color', async () => {
|
||||
const input = { id: 'note_004', text: 'Needs fixing', author: 'user_2' };
|
||||
const input: NoteInput = { id: 'note_004', text: 'Needs fixing', author: 'user_2' };
|
||||
mockQuery.mockResolvedValue({ rows: [{ ...input, x: 0, y: 0, color: 'yellow' }] });
|
||||
|
||||
await createNote(input);
|
||||
|
||||
const params = mockQuery.mock.calls[0][1];
|
||||
const params = mockQuery.mock.calls[0][1] as unknown[];
|
||||
expect(params[2]).toBe(0);
|
||||
expect(params[3]).toBe(0);
|
||||
expect(params[5]).toBe('yellow');
|
||||
@@ -166,9 +182,10 @@ describe('notes.dao', () => {
|
||||
|
||||
expect(result).toEqual(MOCK_ROWS);
|
||||
expect(mockQuery).toHaveBeenCalledOnce();
|
||||
const [sql, params] = mockQuery.mock.calls[0];
|
||||
const [sql, params] = mockQuery.mock.calls[0] as [string, unknown[]];
|
||||
expect(sql).toContain('INSERT INTO notes');
|
||||
expect(params).toHaveLength(12);
|
||||
expect(sql).toContain('ON CONFLICT (id) DO NOTHING');
|
||||
expect(params).toHaveLength(14);
|
||||
});
|
||||
|
||||
it('should return an empty array without querying when given no notes', async () => {
|
||||
@@ -179,20 +196,20 @@ describe('notes.dao', () => {
|
||||
});
|
||||
|
||||
it('should split large inputs into multiple statements under the bind-parameter limit', async () => {
|
||||
const many = Array.from({ length: 2500 }, (_, i) => ({
|
||||
const many: NoteInput[] = Array.from({ length: 2500 }, (_, i) => ({
|
||||
id: `note_${i}`,
|
||||
text: `text ${i}`,
|
||||
author: 'user_1',
|
||||
}));
|
||||
mockQuery.mockImplementation(async (_sql, params) => ({
|
||||
rows: new Array(params.length / 6).fill(null).map((_, i) => ({ i })),
|
||||
mockQuery.mockImplementation(async (_sql: string, params: unknown[]) => ({
|
||||
rows: new Array(params.length / 7).fill(null).map((_, i) => ({ i })),
|
||||
}));
|
||||
|
||||
const result = await createNotes(many);
|
||||
|
||||
expect(mockQuery).toHaveBeenCalledTimes(3);
|
||||
expect(result).toHaveLength(2500);
|
||||
for (const [, params] of mockQuery.mock.calls) {
|
||||
for (const [, params] of mockQuery.mock.calls as [string, unknown[]][]) {
|
||||
expect(params.length).toBeLessThan(65535);
|
||||
}
|
||||
});
|
||||
284
backend/tests/oauth.service.test.ts
Normal file
284
backend/tests/oauth.service.test.ts
Normal file
@@ -0,0 +1,284 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
|
||||
const {
|
||||
mockRequestJson,
|
||||
mockFindByProviderWorkspace,
|
||||
mockGetAccessToken,
|
||||
mockGetRefreshToken,
|
||||
mockUpdateTokens,
|
||||
} = vi.hoisted(() => ({
|
||||
mockRequestJson: vi.fn(),
|
||||
mockFindByProviderWorkspace: vi.fn(),
|
||||
mockGetAccessToken: vi.fn(),
|
||||
mockGetRefreshToken: vi.fn(),
|
||||
mockUpdateTokens: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../lib/httpClient.js', () => ({
|
||||
requestJson: mockRequestJson,
|
||||
}));
|
||||
|
||||
vi.mock('../db/integrations.dao.js', () => ({
|
||||
findByProviderWorkspace: mockFindByProviderWorkspace,
|
||||
getAccessToken: mockGetAccessToken,
|
||||
getRefreshToken: mockGetRefreshToken,
|
||||
updateTokens: mockUpdateTokens,
|
||||
}));
|
||||
|
||||
import {
|
||||
buildAuthorizeUrl,
|
||||
exchangeCode,
|
||||
refreshAccessToken,
|
||||
getValidAccessToken,
|
||||
OAuthError,
|
||||
} from '../services/oauth.service.js';
|
||||
import type { IntegrationRow } from '../types/integration.js';
|
||||
|
||||
const ENV_KEYS = [
|
||||
'LINEAR_CLIENT_ID',
|
||||
'LINEAR_CLIENT_SECRET',
|
||||
'SLACK_CLIENT_ID',
|
||||
'SLACK_CLIENT_SECRET',
|
||||
] as const;
|
||||
|
||||
const integration = (overrides: Partial<IntegrationRow> = {}): IntegrationRow => ({
|
||||
id: 11,
|
||||
provider: 'linear',
|
||||
externalWorkspaceId: 'ws_1',
|
||||
displayName: 'Acme',
|
||||
scopes: ['read'],
|
||||
tokenExpiresAt: null,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe('oauth.service', () => {
|
||||
const originalEnv = new Map<string, string | undefined>();
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
for (const key of ENV_KEYS) originalEnv.set(key, process.env[key]);
|
||||
process.env.LINEAR_CLIENT_ID = 'client-id-1';
|
||||
process.env.LINEAR_CLIENT_SECRET = 'client-secret-1';
|
||||
process.env.SLACK_CLIENT_ID = 'slack-id';
|
||||
process.env.SLACK_CLIENT_SECRET = 'slack-secret';
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
for (const key of ENV_KEYS) {
|
||||
const value = originalEnv.get(key);
|
||||
if (value === undefined) delete process.env[key];
|
||||
else process.env[key] = value;
|
||||
}
|
||||
});
|
||||
|
||||
describe('buildAuthorizeUrl', () => {
|
||||
it('should build an authorize url against the provider endpoint with all required params', () => {
|
||||
const url = new URL(
|
||||
buildAuthorizeUrl('linear', { redirectUri: 'https://app.test/cb', state: 'st_1' })
|
||||
);
|
||||
|
||||
expect(`${url.origin}${url.pathname}`).toBe('https://linear.app/oauth/authorize');
|
||||
expect(url.searchParams.get('client_id')).toBe('client-id-1');
|
||||
expect(url.searchParams.get('redirect_uri')).toBe('https://app.test/cb');
|
||||
expect(url.searchParams.get('response_type')).toBe('code');
|
||||
expect(url.searchParams.get('state')).toBe('st_1');
|
||||
expect(url.searchParams.get('scope')).toBe('read');
|
||||
});
|
||||
|
||||
it('should join multiple configured scopes with spaces', () => {
|
||||
const url = new URL(
|
||||
buildAuthorizeUrl('slack', { redirectUri: 'https://app.test/cb', state: 'st_2' })
|
||||
);
|
||||
|
||||
expect(url.searchParams.get('scope')).toBe('channels:history reactions:read users:read');
|
||||
});
|
||||
|
||||
it('should throw an OAuthError when client credentials are not configured', () => {
|
||||
delete process.env.LINEAR_CLIENT_SECRET;
|
||||
|
||||
expect(() =>
|
||||
buildAuthorizeUrl('linear', { redirectUri: 'https://app.test/cb', state: 'st_1' })
|
||||
).toThrow(OAuthError);
|
||||
});
|
||||
|
||||
it('should throw an OAuthError for a provider without OAuth support', () => {
|
||||
expect(() =>
|
||||
buildAuthorizeUrl('rest', { redirectUri: 'https://app.test/cb', state: 'st_1' })
|
||||
).toThrow(/does not support OAuth/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('exchangeCode', () => {
|
||||
it('should post a form-encoded authorization_code grant to the token endpoint', async () => {
|
||||
mockRequestJson.mockResolvedValue({ access_token: 'at_1' });
|
||||
|
||||
await exchangeCode('linear', { code: 'code_1', redirectUri: 'https://app.test/cb' });
|
||||
|
||||
const [url, init] = mockRequestJson.mock.calls[0] as [string, RequestInit];
|
||||
expect(url).toBe('https://api.linear.app/oauth/token');
|
||||
expect(init.method).toBe('POST');
|
||||
expect(init.headers).toMatchObject({
|
||||
'content-type': 'application/x-www-form-urlencoded',
|
||||
});
|
||||
|
||||
const form = new URLSearchParams(String(init.body));
|
||||
expect(form.get('grant_type')).toBe('authorization_code');
|
||||
expect(form.get('code')).toBe('code_1');
|
||||
expect(form.get('redirect_uri')).toBe('https://app.test/cb');
|
||||
expect(form.get('client_id')).toBe('client-id-1');
|
||||
expect(form.get('client_secret')).toBe('client-secret-1');
|
||||
});
|
||||
|
||||
it('should map the token response into a TokenSet', async () => {
|
||||
mockRequestJson.mockResolvedValue({
|
||||
access_token: 'at_1',
|
||||
refresh_token: 'rt_1',
|
||||
expires_in: 3600,
|
||||
scope: 'read write',
|
||||
});
|
||||
|
||||
const before = Date.now();
|
||||
const tokens = await exchangeCode('linear', {
|
||||
code: 'code_1',
|
||||
redirectUri: 'https://app.test/cb',
|
||||
});
|
||||
|
||||
expect(tokens.accessToken).toBe('at_1');
|
||||
expect(tokens.refreshToken).toBe('rt_1');
|
||||
expect(tokens.scopes).toEqual(['read', 'write']);
|
||||
expect(tokens.expiresAt).toBeInstanceOf(Date);
|
||||
expect(tokens.expiresAt?.getTime()).toBeGreaterThanOrEqual(before + 3_600_000);
|
||||
expect(tokens.expiresAt?.getTime()).toBeLessThanOrEqual(Date.now() + 3_600_000);
|
||||
});
|
||||
|
||||
it('should leave expiresAt undefined and scopes empty when the response omits them', async () => {
|
||||
mockRequestJson.mockResolvedValue({ access_token: 'at_1' });
|
||||
|
||||
const tokens = await exchangeCode('linear', {
|
||||
code: 'code_1',
|
||||
redirectUri: 'https://app.test/cb',
|
||||
});
|
||||
|
||||
expect(tokens.expiresAt).toBeUndefined();
|
||||
expect(tokens.scopes).toEqual([]);
|
||||
});
|
||||
|
||||
it('should throw an OAuthError when the payload carries no usable access token', async () => {
|
||||
mockRequestJson.mockResolvedValue({ token_type: 'bearer' });
|
||||
|
||||
await expect(
|
||||
exchangeCode('linear', { code: 'code_1', redirectUri: 'https://app.test/cb' })
|
||||
).rejects.toThrow(OAuthError);
|
||||
});
|
||||
|
||||
it('should throw an OAuthError when credentials are missing', async () => {
|
||||
delete process.env.LINEAR_CLIENT_ID;
|
||||
|
||||
await expect(
|
||||
exchangeCode('linear', { code: 'code_1', redirectUri: 'https://app.test/cb' })
|
||||
).rejects.toThrow(/LINEAR_CLIENT_ID/);
|
||||
expect(mockRequestJson).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('refreshAccessToken', () => {
|
||||
it('should post a refresh_token grant carrying the stored refresh token', async () => {
|
||||
mockRequestJson.mockResolvedValue({ access_token: 'at_2' });
|
||||
|
||||
const tokens = await refreshAccessToken('linear', 'rt_1');
|
||||
|
||||
expect(tokens.accessToken).toBe('at_2');
|
||||
const [, init] = mockRequestJson.mock.calls[0] as [string, RequestInit];
|
||||
const form = new URLSearchParams(String(init.body));
|
||||
expect(form.get('grant_type')).toBe('refresh_token');
|
||||
expect(form.get('refresh_token')).toBe('rt_1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getValidAccessToken', () => {
|
||||
it('should return the stored access token when it is not near expiry', async () => {
|
||||
mockFindByProviderWorkspace.mockResolvedValue(
|
||||
integration({ tokenExpiresAt: new Date(Date.now() + 3_600_000) })
|
||||
);
|
||||
mockGetAccessToken.mockResolvedValue('at_stored');
|
||||
|
||||
const result = await getValidAccessToken('linear', 'ws_1');
|
||||
|
||||
expect(result).toBe('at_stored');
|
||||
expect(mockRequestJson).not.toHaveBeenCalled();
|
||||
expect(mockUpdateTokens).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return the stored access token when no expiry is recorded', async () => {
|
||||
mockFindByProviderWorkspace.mockResolvedValue(integration());
|
||||
mockGetAccessToken.mockResolvedValue('at_stored');
|
||||
|
||||
const result = await getValidAccessToken('linear', 'ws_1');
|
||||
|
||||
expect(result).toBe('at_stored');
|
||||
expect(mockGetAccessToken).toHaveBeenCalledWith(11);
|
||||
});
|
||||
|
||||
it('should refresh and persist the new token when expiry is within the refresh margin', async () => {
|
||||
mockFindByProviderWorkspace.mockResolvedValue(
|
||||
integration({ tokenExpiresAt: new Date(Date.now() + 30_000) })
|
||||
);
|
||||
mockGetRefreshToken.mockResolvedValue('rt_1');
|
||||
mockRequestJson.mockResolvedValue({
|
||||
access_token: 'at_refreshed',
|
||||
refresh_token: 'rt_2',
|
||||
expires_in: 3600,
|
||||
});
|
||||
|
||||
const result = await getValidAccessToken('linear', 'ws_1');
|
||||
|
||||
expect(result).toBe('at_refreshed');
|
||||
expect(mockUpdateTokens).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: 11, accessToken: 'at_refreshed', refreshToken: 'rt_2' })
|
||||
);
|
||||
expect(mockGetAccessToken).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should refresh when the token has already expired', async () => {
|
||||
mockFindByProviderWorkspace.mockResolvedValue(
|
||||
integration({ tokenExpiresAt: new Date(Date.now() - 1_000) })
|
||||
);
|
||||
mockGetRefreshToken.mockResolvedValue('rt_1');
|
||||
mockRequestJson.mockResolvedValue({ access_token: 'at_refreshed' });
|
||||
|
||||
const result = await getValidAccessToken('linear', 'ws_1');
|
||||
|
||||
expect(result).toBe('at_refreshed');
|
||||
});
|
||||
|
||||
it('should throw an OAuthError when the workspace has no integration', async () => {
|
||||
mockFindByProviderWorkspace.mockResolvedValue(null);
|
||||
|
||||
await expect(getValidAccessToken('linear', 'ws_missing')).rejects.toThrow(OAuthError);
|
||||
await expect(getValidAccessToken('linear', 'ws_missing')).rejects.toThrow(
|
||||
/No linear integration for workspace ws_missing/
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw an OAuthError when a refresh is needed but no refresh token is stored', async () => {
|
||||
mockFindByProviderWorkspace.mockResolvedValue(
|
||||
integration({ tokenExpiresAt: new Date(Date.now() + 1_000) })
|
||||
);
|
||||
mockGetRefreshToken.mockResolvedValue(null);
|
||||
|
||||
await expect(getValidAccessToken('linear', 'ws_1')).rejects.toThrow(
|
||||
/no refresh token is stored/
|
||||
);
|
||||
expect(mockUpdateTokens).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should throw an OAuthError when no access token is stored', async () => {
|
||||
mockFindByProviderWorkspace.mockResolvedValue(integration());
|
||||
mockGetAccessToken.mockResolvedValue(null);
|
||||
|
||||
await expect(getValidAccessToken('linear', 'ws_1')).rejects.toThrow(
|
||||
/No access token stored for linear/
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
215
backend/tests/queue.test.ts
Normal file
215
backend/tests/queue.test.ts
Normal file
@@ -0,0 +1,215 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { enqueue, size, drain, configureQueue } from '../lib/queue.js';
|
||||
|
||||
type Deferred = { promise: Promise<void>; resolve: () => void };
|
||||
|
||||
const deferred = (): Deferred => {
|
||||
let resolve!: () => void;
|
||||
const promise = new Promise<void>((res) => {
|
||||
resolve = res;
|
||||
});
|
||||
return { promise, resolve };
|
||||
};
|
||||
|
||||
// A macrotask boundary: enough for the queue to pick up work it deferred.
|
||||
const flush = (): Promise<void> =>
|
||||
new Promise((resolve) => {
|
||||
setTimeout(resolve, 0);
|
||||
});
|
||||
|
||||
describe('queue', () => {
|
||||
let errors: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
beforeEach(() => {
|
||||
configureQueue({ maxAttempts: 3, baseDelayMs: 0 });
|
||||
errors = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await drain();
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('should run jobs one at a time in FIFO order', async () => {
|
||||
const order: string[] = [];
|
||||
const record = (id: string) => async (): Promise<void> => {
|
||||
order.push(`${id}:start`);
|
||||
await Promise.resolve();
|
||||
order.push(`${id}:end`);
|
||||
};
|
||||
|
||||
enqueue('a', record('a'));
|
||||
enqueue('b', record('b'));
|
||||
enqueue('c', record('c'));
|
||||
|
||||
await drain();
|
||||
|
||||
expect(order).toEqual([
|
||||
'a:start', 'a:end',
|
||||
'b:start', 'b:end',
|
||||
'c:start', 'c:end',
|
||||
]);
|
||||
});
|
||||
|
||||
it('should not throw synchronously when a job throws synchronously', async () => {
|
||||
const thrower = (): Promise<void> => {
|
||||
throw new Error('sync boom');
|
||||
};
|
||||
|
||||
expect(() => enqueue('sync-thrower', thrower)).not.toThrow();
|
||||
|
||||
await drain();
|
||||
|
||||
expect(errors).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should retry a failing job up to the configured cap and then give up', async () => {
|
||||
configureQueue({ maxAttempts: 4 });
|
||||
let attempts = 0;
|
||||
|
||||
enqueue('always-failing', async () => {
|
||||
attempts += 1;
|
||||
throw new Error('boom');
|
||||
});
|
||||
|
||||
await drain();
|
||||
|
||||
expect(attempts).toBe(4);
|
||||
expect(errors).toHaveBeenCalledOnce();
|
||||
expect(String(errors.mock.calls[0]?.[0])).toContain('always-failing');
|
||||
});
|
||||
|
||||
it('should stop retrying as soon as an attempt succeeds', async () => {
|
||||
let attempts = 0;
|
||||
|
||||
enqueue('flaky', async () => {
|
||||
attempts += 1;
|
||||
if (attempts < 2) throw new Error('transient');
|
||||
});
|
||||
|
||||
await drain();
|
||||
|
||||
expect(attempts).toBe(2);
|
||||
expect(errors).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should keep running later jobs after one fails permanently', async () => {
|
||||
const completed: string[] = [];
|
||||
|
||||
enqueue('doomed', async () => {
|
||||
throw new Error('boom');
|
||||
});
|
||||
enqueue('survivor', async () => {
|
||||
completed.push('survivor');
|
||||
});
|
||||
|
||||
await drain();
|
||||
|
||||
expect(completed).toEqual(['survivor']);
|
||||
});
|
||||
|
||||
it('should grow the backoff delay between attempts', async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.spyOn(Math, 'random').mockReturnValue(0);
|
||||
configureQueue({ maxAttempts: 4, baseDelayMs: 100 });
|
||||
|
||||
let attempts = 0;
|
||||
enqueue('retrying', async () => {
|
||||
attempts += 1;
|
||||
throw new Error('boom');
|
||||
});
|
||||
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(attempts).toBe(1);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(99);
|
||||
expect(attempts).toBe(1);
|
||||
await vi.advanceTimersByTimeAsync(2);
|
||||
expect(attempts).toBe(2);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(198);
|
||||
expect(attempts).toBe(2);
|
||||
await vi.advanceTimersByTimeAsync(2);
|
||||
expect(attempts).toBe(3);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(398);
|
||||
expect(attempts).toBe(3);
|
||||
await vi.advanceTimersByTimeAsync(2);
|
||||
expect(attempts).toBe(4);
|
||||
});
|
||||
|
||||
it('should apply jitter within the backoff window', async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.spyOn(Math, 'random').mockReturnValue(0.75);
|
||||
configureQueue({ maxAttempts: 2, baseDelayMs: 100 });
|
||||
|
||||
let attempts = 0;
|
||||
enqueue('jittered', async () => {
|
||||
attempts += 1;
|
||||
throw new Error('boom');
|
||||
});
|
||||
|
||||
await vi.advanceTimersByTimeAsync(174);
|
||||
expect(attempts).toBe(1);
|
||||
await vi.advanceTimersByTimeAsync(2);
|
||||
expect(attempts).toBe(2);
|
||||
});
|
||||
|
||||
it('should resolve drain() only once in-flight work has finished', async () => {
|
||||
const gate = deferred();
|
||||
let finished = false;
|
||||
|
||||
enqueue('blocked', async () => {
|
||||
await gate.promise;
|
||||
finished = true;
|
||||
});
|
||||
|
||||
await flush();
|
||||
expect(finished).toBe(false);
|
||||
|
||||
gate.resolve();
|
||||
await drain();
|
||||
|
||||
expect(finished).toBe(true);
|
||||
});
|
||||
|
||||
it('should resolve every concurrent drain() caller', async () => {
|
||||
const gate = deferred();
|
||||
enqueue('blocked', () => gate.promise);
|
||||
|
||||
const waiters = Promise.all([drain(), drain(), drain()]);
|
||||
gate.resolve();
|
||||
|
||||
await expect(waiters).resolves.toEqual([undefined, undefined, undefined]);
|
||||
});
|
||||
|
||||
it('should resolve drain() immediately when the queue is idle', async () => {
|
||||
const winner = await Promise.race([
|
||||
drain().then(() => 'drained'),
|
||||
new Promise<string>((resolve) => {
|
||||
setTimeout(() => resolve('timer'), 0);
|
||||
}),
|
||||
]);
|
||||
|
||||
expect(winner).toBe('drained');
|
||||
});
|
||||
|
||||
it('should report the pending count excluding the job in flight', async () => {
|
||||
const gate = deferred();
|
||||
|
||||
enqueue('blocked', () => gate.promise);
|
||||
enqueue('second', async () => {});
|
||||
enqueue('third', async () => {});
|
||||
|
||||
expect(size()).toBe(3);
|
||||
|
||||
await flush();
|
||||
expect(size()).toBe(2);
|
||||
|
||||
gate.resolve();
|
||||
await drain();
|
||||
|
||||
expect(size()).toBe(0);
|
||||
});
|
||||
});
|
||||
304
backend/tests/signatures.test.ts
Normal file
304
backend/tests/signatures.test.ts
Normal file
@@ -0,0 +1,304 @@
|
||||
import { describe, it, expect, afterEach, vi } from 'vitest';
|
||||
import { createHmac } from 'node:crypto';
|
||||
import type { IncomingHttpHeaders } from 'node:http';
|
||||
import { verifySignature } from '../lib/signatures.js';
|
||||
import { providers } from '../config/providers.js';
|
||||
import type { ProviderSlug } from '../types/integration.js';
|
||||
|
||||
const SECRET = 'top-secret-signing-key';
|
||||
const RAW_BODY = Buffer.from(JSON.stringify({ action: 'create', id: 'c1' }), 'utf8');
|
||||
const OTHER_BODY = Buffer.from(JSON.stringify({ action: 'remove', id: 'c1' }), 'utf8');
|
||||
|
||||
const hmacHex = (payload: string | Buffer, secret = SECRET): string =>
|
||||
createHmac('sha256', secret).update(payload).digest('hex');
|
||||
|
||||
const verify = (
|
||||
provider: ProviderSlug,
|
||||
headers: IncomingHttpHeaders,
|
||||
rawBody: Buffer = RAW_BODY,
|
||||
toleranceSeconds?: number
|
||||
) => verifySignature({ provider, rawBody, headers, secret: SECRET, toleranceSeconds });
|
||||
|
||||
const nowSeconds = (): string => Math.floor(Date.now() / 1000).toString();
|
||||
|
||||
const slackHeaders = (rawBody: Buffer, timestamp: string): IncomingHttpHeaders => ({
|
||||
'x-slack-request-timestamp': timestamp,
|
||||
'x-slack-signature': `v0=${hmacHex(`v0:${timestamp}:${rawBody.toString('utf8')}`)}`,
|
||||
});
|
||||
|
||||
describe('verifySignature: linear-sha256', () => {
|
||||
it('should accept a correct bare hex digest of the raw body', () => {
|
||||
expect(verify('linear', { 'linear-signature': hmacHex(RAW_BODY) })).toEqual({ ok: true });
|
||||
});
|
||||
|
||||
it('should read the first value when the header arrives as an array', () => {
|
||||
expect(verify('linear', { 'linear-signature': [hmacHex(RAW_BODY)] })).toEqual({ ok: true });
|
||||
});
|
||||
|
||||
it('should reject a signature computed over a different body', () => {
|
||||
expect(verify('linear', { 'linear-signature': hmacHex(OTHER_BODY) })).toEqual({
|
||||
ok: false,
|
||||
reason: 'mismatch',
|
||||
});
|
||||
});
|
||||
|
||||
it('should reject a signature computed with a different secret', () => {
|
||||
expect(verify('linear', { 'linear-signature': hmacHex(RAW_BODY, 'wrong') })).toEqual({
|
||||
ok: false,
|
||||
reason: 'mismatch',
|
||||
});
|
||||
});
|
||||
|
||||
it('should report a missing header', () => {
|
||||
expect(verify('linear', {})).toEqual({ ok: false, reason: 'missing' });
|
||||
expect(verify('linear', { 'linear-signature': '' })).toEqual({
|
||||
ok: false,
|
||||
reason: 'missing',
|
||||
});
|
||||
});
|
||||
|
||||
it('should report a malformed header', () => {
|
||||
expect(verify('linear', { 'linear-signature': `sha256=${hmacHex(RAW_BODY)}` })).toEqual({
|
||||
ok: false,
|
||||
reason: 'malformed',
|
||||
});
|
||||
expect(verify('linear', { 'linear-signature': 'deadbeef' })).toEqual({
|
||||
ok: false,
|
||||
reason: 'malformed',
|
||||
});
|
||||
expect(verify('linear', { 'linear-signature': hmacHex(RAW_BODY).toUpperCase() })).toEqual({
|
||||
ok: false,
|
||||
reason: 'malformed',
|
||||
});
|
||||
});
|
||||
|
||||
it('should be case sensitive about the header name only', () => {
|
||||
expect(verify('linear', { 'LINEAR-SIGNATURE': hmacHex(RAW_BODY) })).toEqual({
|
||||
ok: false,
|
||||
reason: 'missing',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('verifySignature: slack-v0', () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('should accept a correct v0 signature over the timestamped base string', () => {
|
||||
const timestamp = nowSeconds();
|
||||
|
||||
expect(verify('slack', slackHeaders(RAW_BODY, timestamp))).toEqual({ ok: true });
|
||||
});
|
||||
|
||||
it('should accept a signature generated against a frozen clock', () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2026-08-01T12:00:00.000Z'));
|
||||
const headers = slackHeaders(RAW_BODY, nowSeconds());
|
||||
|
||||
expect(verify('slack', headers)).toEqual({ ok: true });
|
||||
});
|
||||
|
||||
it('should reject a signature computed over a different body', () => {
|
||||
const timestamp = nowSeconds();
|
||||
|
||||
expect(verify('slack', slackHeaders(OTHER_BODY, timestamp))).toEqual({
|
||||
ok: false,
|
||||
reason: 'mismatch',
|
||||
});
|
||||
});
|
||||
|
||||
it('should reject a signature bound to a different timestamp', () => {
|
||||
const timestamp = nowSeconds();
|
||||
const headers = {
|
||||
...slackHeaders(RAW_BODY, timestamp),
|
||||
'x-slack-request-timestamp': (Number(timestamp) - 1).toString(),
|
||||
};
|
||||
|
||||
expect(verify('slack', headers)).toEqual({ ok: false, reason: 'mismatch' });
|
||||
});
|
||||
|
||||
it('should report a missing signature header', () => {
|
||||
expect(verify('slack', { 'x-slack-request-timestamp': nowSeconds() })).toEqual({
|
||||
ok: false,
|
||||
reason: 'missing',
|
||||
});
|
||||
});
|
||||
|
||||
it('should report a missing timestamp header', () => {
|
||||
const { 'x-slack-signature': signature } = slackHeaders(RAW_BODY, nowSeconds());
|
||||
|
||||
expect(verify('slack', { 'x-slack-signature': signature })).toEqual({
|
||||
ok: false,
|
||||
reason: 'missing',
|
||||
});
|
||||
});
|
||||
|
||||
it('should report a malformed signature header', () => {
|
||||
const timestamp = nowSeconds();
|
||||
|
||||
expect(
|
||||
verify('slack', { ...slackHeaders(RAW_BODY, timestamp), 'x-slack-signature': 'v1=abc' })
|
||||
).toEqual({ ok: false, reason: 'malformed' });
|
||||
expect(
|
||||
verify('slack', {
|
||||
...slackHeaders(RAW_BODY, timestamp),
|
||||
'x-slack-signature': hmacHex(RAW_BODY),
|
||||
})
|
||||
).toEqual({ ok: false, reason: 'malformed' });
|
||||
});
|
||||
|
||||
it('should reject a timestamp older than the tolerance window', () => {
|
||||
const stale = (Math.floor(Date.now() / 1000) - 301).toString();
|
||||
|
||||
expect(verify('slack', slackHeaders(RAW_BODY, stale))).toEqual({
|
||||
ok: false,
|
||||
reason: 'stale',
|
||||
});
|
||||
});
|
||||
|
||||
it('should reject a timestamp too far in the future', () => {
|
||||
const future = (Math.floor(Date.now() / 1000) + 301).toString();
|
||||
|
||||
expect(verify('slack', slackHeaders(RAW_BODY, future))).toEqual({
|
||||
ok: false,
|
||||
reason: 'stale',
|
||||
});
|
||||
});
|
||||
|
||||
it('should reject a request that goes stale while the clock advances', () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2026-08-01T12:00:00.000Z'));
|
||||
const headers = slackHeaders(RAW_BODY, nowSeconds());
|
||||
|
||||
expect(verify('slack', headers)).toEqual({ ok: true });
|
||||
|
||||
vi.advanceTimersByTime(301_000);
|
||||
|
||||
expect(verify('slack', headers)).toEqual({ ok: false, reason: 'stale' });
|
||||
});
|
||||
|
||||
it('should honor an explicit tolerance', () => {
|
||||
const old = (Math.floor(Date.now() / 1000) - 600).toString();
|
||||
|
||||
expect(verify('slack', slackHeaders(RAW_BODY, old), RAW_BODY, 900)).toEqual({ ok: true });
|
||||
expect(verify('slack', slackHeaders(RAW_BODY, old), RAW_BODY, 60)).toEqual({
|
||||
ok: false,
|
||||
reason: 'stale',
|
||||
});
|
||||
});
|
||||
|
||||
it('should reject a non-numeric timestamp as stale', () => {
|
||||
expect(
|
||||
verify('slack', {
|
||||
'x-slack-request-timestamp': 'yesterday',
|
||||
'x-slack-signature': `v0=${hmacHex('v0:yesterday:x')}`,
|
||||
})
|
||||
).toEqual({ ok: false, reason: 'stale' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('verifySignature: github-sha256', () => {
|
||||
it('should accept a correct sha256-prefixed signature', () => {
|
||||
expect(verify('github', { 'x-hub-signature-256': `sha256=${hmacHex(RAW_BODY)}` })).toEqual({
|
||||
ok: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should reject a signature computed over a different body', () => {
|
||||
expect(verify('github', { 'x-hub-signature-256': `sha256=${hmacHex(OTHER_BODY)}` })).toEqual({
|
||||
ok: false,
|
||||
reason: 'mismatch',
|
||||
});
|
||||
});
|
||||
|
||||
it('should report a missing header', () => {
|
||||
expect(verify('github', {})).toEqual({ ok: false, reason: 'missing' });
|
||||
});
|
||||
|
||||
it('should report a malformed header', () => {
|
||||
expect(verify('github', { 'x-hub-signature-256': hmacHex(RAW_BODY) })).toEqual({
|
||||
ok: false,
|
||||
reason: 'malformed',
|
||||
});
|
||||
expect(verify('github', { 'x-hub-signature-256': `sha1=${hmacHex(RAW_BODY)}` })).toEqual({
|
||||
ok: false,
|
||||
reason: 'malformed',
|
||||
});
|
||||
});
|
||||
|
||||
it('should reject a truncated but correctly prefixed signature', () => {
|
||||
expect(
|
||||
verify('github', { 'x-hub-signature-256': `sha256=${hmacHex(RAW_BODY).slice(0, 32)}` })
|
||||
).toEqual({ ok: false, reason: 'mismatch' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('verifySignature: none', () => {
|
||||
it('should accept rest and jira without any header', () => {
|
||||
expect(verify('rest', {})).toEqual({ ok: true });
|
||||
expect(verify('jira', {})).toEqual({ ok: true });
|
||||
});
|
||||
|
||||
it('should accept the none scheme even when a garbage header is present', () => {
|
||||
expect(verify('rest', { 'linear-signature': 'nonsense' })).toEqual({ ok: true });
|
||||
});
|
||||
|
||||
it('should cover every configured provider with a known scheme', () => {
|
||||
const schemes = Object.values(providers).map((config) => config.signatureScheme);
|
||||
|
||||
expect(new Set(schemes)).toEqual(
|
||||
new Set(['none', 'linear-sha256', 'slack-v0', 'github-sha256'])
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('verifySignature: hostile input', () => {
|
||||
const garbage: readonly string[] = [
|
||||
'',
|
||||
' ',
|
||||
'v0=',
|
||||
'sha256=',
|
||||
':::',
|
||||
'v0=zzzz',
|
||||
'%%%%',
|
||||
'0'.repeat(10_000),
|
||||
'\u0000\u0000',
|
||||
'null',
|
||||
];
|
||||
|
||||
it('should never throw for any provider and any garbage header value', () => {
|
||||
const slugs = Object.keys(providers) as ProviderSlug[];
|
||||
|
||||
for (const slug of slugs) {
|
||||
for (const value of garbage) {
|
||||
const headers: IncomingHttpHeaders = {
|
||||
'linear-signature': value,
|
||||
'x-hub-signature-256': value,
|
||||
'x-slack-signature': value,
|
||||
'x-slack-request-timestamp': value,
|
||||
};
|
||||
|
||||
expect(() => verify(slug, headers)).not.toThrow();
|
||||
expect(typeof verify(slug, headers).ok).toBe('boolean');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('should never throw for an empty body or empty header set', () => {
|
||||
const slugs = Object.keys(providers) as ProviderSlug[];
|
||||
|
||||
for (const slug of slugs) {
|
||||
expect(() => verify(slug, {}, Buffer.alloc(0))).not.toThrow();
|
||||
}
|
||||
});
|
||||
|
||||
it('should tolerate array-valued and duplicated headers', () => {
|
||||
expect(() =>
|
||||
verify('slack', {
|
||||
'x-slack-signature': ['v0=abc', 'v0=def'],
|
||||
'x-slack-request-timestamp': [nowSeconds(), 'garbage'],
|
||||
})
|
||||
).not.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -1,11 +1,11 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { Readable } from 'node:stream';
|
||||
import { Readable, type Transform } from 'node:stream';
|
||||
import { pipeline } from 'node:stream/promises';
|
||||
import { batch, jsonArray } from '../lib/streams.js';
|
||||
|
||||
const collect = async (source, transform) => {
|
||||
const out = [];
|
||||
await pipeline(source, transform, async (results) => {
|
||||
const collect = async <T>(source: Readable, transform: Transform): Promise<T[]> => {
|
||||
const out: T[] = [];
|
||||
await pipeline(source, transform, async (results: AsyncIterable<T>) => {
|
||||
for await (const item of results) out.push(item);
|
||||
});
|
||||
return out;
|
||||
@@ -15,7 +15,7 @@ describe('batch', () => {
|
||||
it('should group items into fixed-size arrays', async () => {
|
||||
const source = Readable.from([1, 2, 3, 4], { objectMode: true });
|
||||
|
||||
const result = await collect(source, batch(2));
|
||||
const result = await collect<number[]>(source, batch<number>(2));
|
||||
|
||||
expect(result).toEqual([[1, 2], [3, 4]]);
|
||||
});
|
||||
@@ -23,7 +23,7 @@ describe('batch', () => {
|
||||
it('should flush a partial trailing batch', async () => {
|
||||
const source = Readable.from([1, 2, 3, 4, 5], { objectMode: true });
|
||||
|
||||
const result = await collect(source, batch(2));
|
||||
const result = await collect<number[]>(source, batch<number>(2));
|
||||
|
||||
expect(result).toEqual([[1, 2], [3, 4], [5]]);
|
||||
});
|
||||
@@ -31,7 +31,7 @@ describe('batch', () => {
|
||||
it('should emit nothing for an empty source', async () => {
|
||||
const source = Readable.from([], { objectMode: true });
|
||||
|
||||
const result = await collect(source, batch(3));
|
||||
const result = await collect<number[]>(source, batch<number>(3));
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
@@ -43,8 +43,8 @@ describe('batch', () => {
|
||||
});
|
||||
|
||||
describe('jsonArray', () => {
|
||||
const serialize = async (items) => {
|
||||
const chunks = await collect(
|
||||
const serialize = async (items: unknown[]): Promise<string> => {
|
||||
const chunks = await collect<string>(
|
||||
Readable.from(items, { objectMode: true }),
|
||||
jsonArray()
|
||||
);
|
||||
@@ -74,7 +74,7 @@ describe('jsonArray', () => {
|
||||
});
|
||||
|
||||
it('should propagate serialization errors', async () => {
|
||||
const circular = {};
|
||||
const circular: Record<string, unknown> = {};
|
||||
circular.self = circular;
|
||||
|
||||
await expect(serialize([circular])).rejects.toThrow();
|
||||
253
backend/tests/webhooks.routes.test.ts
Normal file
253
backend/tests/webhooks.routes.test.ts
Normal file
@@ -0,0 +1,253 @@
|
||||
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(),
|
||||
}));
|
||||
|
||||
import {
|
||||
recordDelivery,
|
||||
markDone,
|
||||
markFailed,
|
||||
markProcessing,
|
||||
} from '../db/ingest_events.dao.js';
|
||||
import { createNotes } from '../db/notes.dao.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 = 'linear-test-secret';
|
||||
|
||||
// Deliberately irregular spacing: if any middleware parsed and re-serialized
|
||||
// this body, the signature computed over these exact bytes would not verify.
|
||||
const RAW_BODY = '{"action":"create", "type":"Comment","data":{"id":"cmt_1","body":"Deploys are scary","url":"https://linear.app/c/1","user":{"name":"jane"}} }';
|
||||
|
||||
const sign = (body: string, secret = SECRET): string =>
|
||||
createHmac('sha256', secret).update(body).digest('hex');
|
||||
|
||||
const postLinear = (body: string, signature: string) =>
|
||||
request(app)
|
||||
.post('/v1/webhooks/linear')
|
||||
.set('Content-Type', 'application/json')
|
||||
.set('linear-signature', signature)
|
||||
.send(body);
|
||||
|
||||
describe('POST /v1/webhooks/:provider', () => {
|
||||
const originalSecret = process.env.LINEAR_SIGNING_SECRET;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
process.env.LINEAR_SIGNING_SECRET = SECRET;
|
||||
configureQueue({ baseDelayMs: 0, maxAttempts: 2 });
|
||||
mockRecordDelivery.mockResolvedValue(42);
|
||||
mockCreateNotes.mockResolvedValue([]);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await drain();
|
||||
if (originalSecret === undefined) {
|
||||
delete process.env.LINEAR_SIGNING_SECRET;
|
||||
} else {
|
||||
process.env.LINEAR_SIGNING_SECRET = originalSecret;
|
||||
}
|
||||
});
|
||||
|
||||
it('should accept a correctly signed delivery', async () => {
|
||||
const res = await postLinear(RAW_BODY, sign(RAW_BODY));
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({ accepted: true, notes: 1 });
|
||||
});
|
||||
|
||||
it('should preserve the exact request bytes for signature verification', async () => {
|
||||
// The signature is over RAW_BODY verbatim. This passing is the regression
|
||||
// test for express.raw being mounted ahead of express.json in app.ts.
|
||||
const res = await postLinear(RAW_BODY, sign(RAW_BODY));
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it('should insert the normalized note after responding', async () => {
|
||||
await postLinear(RAW_BODY, sign(RAW_BODY));
|
||||
await drain();
|
||||
|
||||
expect(mockCreateNotes).toHaveBeenCalledOnce();
|
||||
const [notes] = mockCreateNotes.mock.calls[0];
|
||||
expect(notes).toHaveLength(1);
|
||||
expect(notes[0].text).toBe('Deploys are scary');
|
||||
expect(notes[0].author).toBe('jane');
|
||||
expect(notes[0].id).toBe('linear_cmt_1');
|
||||
});
|
||||
|
||||
it('should record provenance on the ingested note', async () => {
|
||||
await postLinear(RAW_BODY, sign(RAW_BODY));
|
||||
await drain();
|
||||
|
||||
const [notes] = mockCreateNotes.mock.calls[0];
|
||||
expect(notes[0].sourceMeta).toMatchObject({
|
||||
provider: 'linear',
|
||||
externalId: 'cmt_1',
|
||||
permalink: 'https://linear.app/c/1',
|
||||
});
|
||||
});
|
||||
|
||||
it('should mark the delivery done once the insert succeeds', async () => {
|
||||
await postLinear(RAW_BODY, sign(RAW_BODY));
|
||||
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 postLinear(RAW_BODY, sign(RAW_BODY));
|
||||
await drain();
|
||||
|
||||
expect(mockMarkFailed).toHaveBeenCalledWith(42, 'connection refused');
|
||||
expect(mockMarkDone).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should respond without waiting for the insert to finish', async () => {
|
||||
let finishInsert: () => void = () => {};
|
||||
mockCreateNotes.mockImplementation(
|
||||
() => new Promise((resolve) => {
|
||||
finishInsert = () => resolve([]);
|
||||
})
|
||||
);
|
||||
|
||||
const res = await postLinear(RAW_BODY, sign(RAW_BODY));
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(mockMarkDone).not.toHaveBeenCalled();
|
||||
|
||||
finishInsert();
|
||||
await drain();
|
||||
|
||||
expect(mockMarkDone).toHaveBeenCalledWith(42);
|
||||
});
|
||||
|
||||
it('should drop a redelivery without enqueueing work', async () => {
|
||||
mockRecordDelivery.mockResolvedValue(null);
|
||||
|
||||
const res = await postLinear(RAW_BODY, sign(RAW_BODY));
|
||||
await drain();
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({ duplicate: true });
|
||||
expect(mockCreateNotes).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should reject a signature computed with the wrong secret', async () => {
|
||||
const res = await postLinear(RAW_BODY, sign(RAW_BODY, 'wrong-secret'));
|
||||
|
||||
expect(res.status).toBe(401);
|
||||
expect(mockRecordDelivery).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should reject a delivery whose body was altered after signing', async () => {
|
||||
const signature = sign(RAW_BODY);
|
||||
const tampered = RAW_BODY.replace('Deploys are scary', 'Deploys are fine');
|
||||
|
||||
const res = await postLinear(tampered, signature);
|
||||
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it('should reject a delivery with no signature header', async () => {
|
||||
const res = await request(app)
|
||||
.post('/v1/webhooks/linear')
|
||||
.set('Content-Type', 'application/json')
|
||||
.send(RAW_BODY);
|
||||
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it('should return 500 when no signing secret is configured', async () => {
|
||||
delete process.env.LINEAR_SIGNING_SECRET;
|
||||
|
||||
const res = await postLinear(RAW_BODY, sign(RAW_BODY));
|
||||
|
||||
expect(res.status).toBe(500);
|
||||
expect(mockRecordDelivery).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return 500 when recording the delivery fails', async () => {
|
||||
mockRecordDelivery.mockRejectedValue(new Error('connection refused'));
|
||||
|
||||
const res = await postLinear(RAW_BODY, sign(RAW_BODY));
|
||||
|
||||
expect(res.status).toBe(500);
|
||||
expect(mockCreateNotes).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return 404 for an unregistered provider', async () => {
|
||||
const res = await request(app)
|
||||
.post('/v1/webhooks/notion')
|
||||
.set('Content-Type', 'application/json')
|
||||
.send('{}');
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it('should return 501 for a provider with no ingestion mapping yet', async () => {
|
||||
process.env.GITHUB_WEBHOOK_SECRET = 'gh-secret';
|
||||
const body = '{"action":"created"}';
|
||||
const signature = `sha256=${createHmac('sha256', 'gh-secret').update(body).digest('hex')}`;
|
||||
|
||||
const res = await request(app)
|
||||
.post('/v1/webhooks/github')
|
||||
.set('Content-Type', 'application/json')
|
||||
.set('x-hub-signature-256', signature)
|
||||
.send(body);
|
||||
|
||||
delete process.env.GITHUB_WEBHOOK_SECRET;
|
||||
|
||||
expect(res.status).toBe(501);
|
||||
});
|
||||
|
||||
it('should return 400 for a body that is not valid JSON', async () => {
|
||||
const body = 'not json at all';
|
||||
|
||||
const res = await postLinear(body, sign(body));
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('should return 400 when the payload is missing the fields the provider guarantees', async () => {
|
||||
const body = '{"action":"create","data":{}}';
|
||||
|
||||
const res = await postLinear(body, sign(body));
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(mockRecordDelivery).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should answer a Slack url_verification handshake without a signature', async () => {
|
||||
const res = await request(app)
|
||||
.post('/v1/webhooks/slack')
|
||||
.set('Content-Type', 'application/json')
|
||||
.send('{"type":"url_verification","challenge":"abc123"}');
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({ challenge: 'abc123' });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user