Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions apps/api/src/isms/isms-document-control.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ jest.mock('@db', () => {
createMany: jest.fn(),
deleteMany: jest.fn(),
},
// Advisory lock taken by invalidateApprovalIfNeeded (serializes vs approve).
$executeRaw: jest.fn(),
// Run the callback with the same mock as the transaction client.
$transaction: jest.fn((cb: (tx: unknown) => unknown) => cb(db)),
};
Expand Down
2 changes: 2 additions & 0 deletions apps/api/src/isms/isms-narrative.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ jest.mock('@db', () => {
findUnique: jest.fn(),
update: jest.fn(),
},
// Advisory lock taken by invalidateApprovalIfNeeded (serializes vs approve).
$executeRaw: jest.fn(),
// Run the callback with the same mock as the transaction client.
$transaction: jest.fn((cb: (tx: unknown) => unknown) => cb(db)),
};
Expand Down
62 changes: 62 additions & 0 deletions apps/api/src/isms/utils/approval.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { invalidateApprovalIfNeeded } from './approval';

/**
* invalidateApprovalIfNeeded is the central serialization point: every ISMS
* content-mutation path calls it, and it must take the per-document advisory lock
* (so edits serialize against approve()) BEFORE reading/altering status.
*/
function makeTx() {
return {
$executeRaw: jest.fn().mockResolvedValue(0),
ismsDocument: {
findUnique: jest.fn(),
update: jest.fn().mockResolvedValue({}),
},
};
}

describe('invalidateApprovalIfNeeded', () => {
it('acquires the per-document advisory lock before reading status', async () => {
const tx = makeTx();
tx.ismsDocument.findUnique.mockResolvedValue({ status: 'draft' });

await invalidateApprovalIfNeeded({
tx: tx as never,
documentId: 'doc_1',
});

expect(tx.$executeRaw).toHaveBeenCalledTimes(1);
// Lock is taken before the status read (serializes vs approve()).
expect(tx.$executeRaw.mock.invocationCallOrder[0]).toBeLessThan(
tx.ismsDocument.findUnique.mock.invocationCallOrder[0],
);
});

it('reverts an approved document to draft (clearing approver + approvedAt)', async () => {
const tx = makeTx();
tx.ismsDocument.findUnique.mockResolvedValue({ status: 'approved' });

await invalidateApprovalIfNeeded({
tx: tx as never,
documentId: 'doc_1',
});

expect(tx.ismsDocument.update).toHaveBeenCalledWith({
where: { id: 'doc_1' },
data: { status: 'draft', approvedAt: null, approverId: null },
});
});

it('is a no-op for a non-approved document (but still locked)', async () => {
const tx = makeTx();
tx.ismsDocument.findUnique.mockResolvedValue({ status: 'needs_review' });

await invalidateApprovalIfNeeded({
tx: tx as never,
documentId: 'doc_1',
});

expect(tx.$executeRaw).toHaveBeenCalledTimes(1);
expect(tx.ismsDocument.update).not.toHaveBeenCalled();
});
});
12 changes: 12 additions & 0 deletions apps/api/src/isms/utils/approval.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,21 @@
import type { Prisma } from '@db';
import { lockDocument } from './document-lock';

/**
* Editing an approved ISMS document invalidates its sign-off: revert it to draft
* so the change must be re-approved (mirrors policy approval invalidation). Only
* touches the document when it is currently `approved`; a no-op otherwise. Must
* run inside the same transaction as the content write so a failed write does not
* leave the document reverted to draft without the new content.
*
* Every content-mutation path (register create/update/delete, narrative save,
* control link add/remove, regenerate) calls this, so acquiring the per-document
* advisory lock here centrally serializes ALL edits against approve() — which
* takes the same lock. Without it, an edit could interleave while approve() is
* loading + freezing the version under READ COMMITTED, leaving an approved version
* that omits the edit (or an edit that never invalidated the approval). The lock
* is transaction-scoped and re-entrant, so create paths that already take it for
* position allocation are unaffected.
*/
export async function invalidateApprovalIfNeeded({
tx,
Expand All @@ -14,6 +24,8 @@ export async function invalidateApprovalIfNeeded({
tx: Prisma.TransactionClient;
documentId: string;
}): Promise<void> {
await lockDocument(tx, documentId);

const document = await tx.ismsDocument.findUnique({
where: { id: documentId },
select: { status: true },
Expand Down
Loading