/** * Security regression: AI email-draft jobs are bound to the requesting * user + port. A foreign caller who knows the jobId must NOT receive the * drafted subject/body. */ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { ForbiddenError, ValidationError } from '@/lib/errors'; // Mock the queue. Each test sets up a fresh per-test job map. const fakeJobs = new Map(); vi.mock('@/lib/queue', () => ({ getQueue: () => ({ add: vi.fn(async (_name: string, data: unknown, opts: { jobId: string }) => { fakeJobs.set(opts.jobId, { data, returnvalue: null, state: 'completed' }); return { id: opts.jobId }; }), getJob: vi.fn(async (id: string) => { const j = fakeJobs.get(id); if (!j) return null; return { data: j.data, returnvalue: j.returnvalue, getState: async () => j.state, }; }), }), })); // Mock interest/client lookups so requestEmailDraft doesn't hit the DB. vi.mock('@/lib/db', () => ({ db: { query: { interests: { findFirst: vi.fn(async ({ where: _w }) => ({ id: 'iA', portId: 'pA' })), }, clients: { findFirst: vi.fn(async ({ where: _w }) => ({ id: 'cA', portId: 'pA' })), }, }, }, })); beforeEach(() => { fakeJobs.clear(); vi.clearAllMocks(); }); describe('email-draft job binding', () => { it('rejects readers with a different userId', async () => { const { requestEmailDraft, getEmailDraftResult } = await import('@/lib/services/email-draft.service'); const { jobId } = await requestEmailDraft('user-A', { interestId: 'iA', clientId: 'cA', portId: 'pA', context: 'follow_up', }); // Wire in a completed return value so a successful path would otherwise // produce a result. fakeJobs.get(jobId)!.returnvalue = { subject: 'leak', body: 'leak', generatedAt: new Date().toISOString(), }; await expect(getEmailDraftResult(jobId, { userId: 'user-B', portId: 'pA' })).rejects.toThrow( ForbiddenError, ); }); it('rejects readers with a different portId', async () => { const { requestEmailDraft, getEmailDraftResult } = await import('@/lib/services/email-draft.service'); const { jobId } = await requestEmailDraft('user-A', { interestId: 'iA', clientId: 'cA', portId: 'pA', context: 'follow_up', }); fakeJobs.get(jobId)!.returnvalue = { subject: 'leak', body: 'leak', generatedAt: new Date().toISOString(), }; await expect(getEmailDraftResult(jobId, { userId: 'user-A', portId: 'pB' })).rejects.toThrow( ForbiddenError, ); }); it('returns drafted content to the original requester', async () => { const { requestEmailDraft, getEmailDraftResult } = await import('@/lib/services/email-draft.service'); const { jobId } = await requestEmailDraft('user-A', { interestId: 'iA', clientId: 'cA', portId: 'pA', context: 'follow_up', }); fakeJobs.get(jobId)!.returnvalue = { subject: 'subject-A', body: 'body-A', generatedAt: new Date().toISOString(), }; const result = await getEmailDraftResult(jobId, { userId: 'user-A', portId: 'pA' }); expect(result?.subject).toBe('subject-A'); expect(result?.body).toBe('body-A'); }); it('jobId is a UUID, not a sequential integer', async () => { const { requestEmailDraft } = await import('@/lib/services/email-draft.service'); const { jobId } = await requestEmailDraft('user-A', { interestId: 'iA', clientId: 'cA', portId: 'pA', context: 'follow_up', }); // Crude UUID-shape check: 8-4-4-4-12 hex. expect(jobId).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i); }); it('rejects requests whose interest is not in the supplied port', async () => { const { db } = await import('@/lib/db'); (db.query.interests.findFirst as ReturnType).mockResolvedValueOnce(null); const { requestEmailDraft } = await import('@/lib/services/email-draft.service'); await expect( requestEmailDraft('user-A', { interestId: 'foreign-interest', clientId: 'cA', portId: 'pA', context: 'follow_up', }), ).rejects.toThrow(ValidationError); }); });