147 lines
4.3 KiB
TypeScript
147 lines
4.3 KiB
TypeScript
|
|
/**
|
||
|
|
* 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<string, { data: unknown; returnvalue: unknown; state: string }>();
|
||
|
|
|
||
|
|
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<typeof vi.fn>).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);
|
||
|
|
});
|
||
|
|
});
|