123 lines
4.0 KiB
TypeScript
123 lines
4.0 KiB
TypeScript
import { strict as assert } from 'node:assert';
|
|
import { describe, it } from 'node:test';
|
|
import { z } from 'zod';
|
|
import type { Database } from '@pig/db';
|
|
import type { Principal } from '../src/lib/auth';
|
|
import { AuthError } from '../src/lib/auth';
|
|
import { apiError, executeMutation, MutationError } from '../src/lib/mutation';
|
|
|
|
const principal: Principal = {
|
|
userId: '00000000-0000-0000-0000-000000000001',
|
|
email: 'seller@example.com',
|
|
name: 'Seller',
|
|
isPlatformAdmin: false,
|
|
teams: [{ team: 'demand', role: 'member' }],
|
|
via: 'jwt',
|
|
scopes: ['read', 'write'],
|
|
};
|
|
|
|
function fakeDatabase(events: string[], activityRows: unknown[]): Database {
|
|
const tx = {
|
|
insert: () => ({
|
|
values: async (row: unknown) => {
|
|
events.push('activity');
|
|
activityRows.push(row);
|
|
},
|
|
}),
|
|
};
|
|
return {
|
|
transaction: async (work: (transaction: unknown) => Promise<unknown>) => {
|
|
events.push('transaction');
|
|
return work(tx);
|
|
},
|
|
} as unknown as Database;
|
|
}
|
|
|
|
describe('mutation convention', () => {
|
|
it('checks capability before reading attacker-controlled input', async () => {
|
|
const events: string[] = [];
|
|
const forbidden = { ...principal, teams: [{ team: 'supply', role: 'admin' }] } as Principal;
|
|
|
|
await assert.rejects(
|
|
executeMutation(fakeDatabase(events, []), forbidden, async () => {
|
|
events.push('body');
|
|
return {};
|
|
}, {
|
|
schema: z.object({ name: z.string() }),
|
|
permission: { capability: 'deal:write', team: 'demand' },
|
|
invalidMessage: 'Invalid deal.',
|
|
async mutate() {
|
|
events.push('mutate');
|
|
return { data: {}, activity: { type: 'note', subject: 'Changed' } };
|
|
},
|
|
}),
|
|
(error: unknown) => error instanceof AuthError && error.code === 'insufficient_permission',
|
|
);
|
|
assert.deepEqual(events, []);
|
|
});
|
|
|
|
it('rejects invalid ontology input before opening a transaction', async () => {
|
|
const events: string[] = [];
|
|
const stages = ['qualification', 'legal'] as const;
|
|
|
|
await assert.rejects(
|
|
executeMutation(fakeDatabase(events, []), principal, async () => ({ stage: 'invented' }), {
|
|
schema: z.object({ stage: z.enum(stages) }),
|
|
permission: { capability: 'deal:write', team: 'demand' },
|
|
invalidMessage: 'Invalid transition.',
|
|
async mutate() {
|
|
events.push('mutate');
|
|
return { data: {}, activity: { type: 'stage_change', subject: 'Changed' } };
|
|
},
|
|
}),
|
|
(error: unknown) =>
|
|
error instanceof MutationError &&
|
|
error.code === 'invalid_request' &&
|
|
apiError(error.code, error.message, error.issues).issues?.length === 1,
|
|
);
|
|
assert.deepEqual(events, []);
|
|
});
|
|
|
|
it('writes mutation evidence in the same transaction with framework attribution', async () => {
|
|
const events: string[] = [];
|
|
const rows: unknown[] = [];
|
|
const result = await executeMutation(
|
|
fakeDatabase(events, rows),
|
|
principal,
|
|
async () => ({ stage: 'legal' }),
|
|
{
|
|
schema: z.object({ stage: z.literal('legal') }),
|
|
permission: { capability: 'deal:write', team: 'demand' },
|
|
invalidMessage: 'Invalid transition.',
|
|
async mutate() {
|
|
events.push('mutate');
|
|
const data = { id: 'deal-1' };
|
|
return {
|
|
data,
|
|
activity: {
|
|
type: 'stage_change',
|
|
subject: 'Moved to legal',
|
|
demandDealId: data.id,
|
|
},
|
|
};
|
|
},
|
|
},
|
|
);
|
|
|
|
assert.deepEqual(result, { id: 'deal-1' });
|
|
assert.deepEqual(events, ['transaction', 'mutate', 'activity']);
|
|
assert.deepEqual(rows, [
|
|
{
|
|
type: 'stage_change',
|
|
subject: 'Moved to legal',
|
|
demandDealId: 'deal-1',
|
|
actorUserId: principal.userId,
|
|
actorAgent: null,
|
|
source: 'manual',
|
|
occurredAt: (rows[0] as { occurredAt: Date }).occurredAt,
|
|
},
|
|
]);
|
|
assert.ok((rows[0] as { occurredAt: unknown }).occurredAt instanceof Date);
|
|
});
|
|
});
|