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
19 changes: 19 additions & 0 deletions apps/api/src/isms/isms-audit-control.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ jest.mock('@db', () => {
update: jest.fn(),
delete: jest.fn(),
},
ismsAuditFinding: { updateMany: jest.fn() },
$executeRaw: jest.fn(),
$transaction: jest.fn((cb: (tx: unknown) => unknown) => cb(db)),
};
Expand Down Expand Up @@ -133,6 +134,7 @@ describe('IsmsAuditControlService', () => {
id: 'ac_1',
documentId: 'doc_1',
controlKey: 'clause_4_1_context',
controlRef: 'Clause 4.1 Context',
});
(mockDb.ismsAuditControl.delete as jest.Mock).mockResolvedValue({});

Expand All @@ -146,5 +148,22 @@ describe('IsmsAuditControlService', () => {
where: { id: 'ac_1' },
});
});

it('labels linked label-less findings with the row reference before deleting', async () => {
(mockDb.ismsAuditControl.findFirst as jest.Mock).mockResolvedValue({
id: 'ac_1',
documentId: 'doc_1',
controlKey: null,
controlRef: 'A.8.13 Backup',
});
(mockDb.ismsAuditControl.delete as jest.Mock).mockResolvedValue({});

await service.remove({ controlId: 'ac_1', organizationId: 'org_1' });

expect(mockDb.ismsAuditFinding.updateMany).toHaveBeenCalledWith({
where: { controlId: 'ac_1', clauseOrControl: null },
data: { clauseOrControl: 'A.8.13 Backup' },
});
});
});
});
8 changes: 7 additions & 1 deletion apps/api/src/isms/isms-audit-control.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,13 @@ export class IsmsAuditControlService {
await lockDocument(tx, control.documentId);
await invalidateApprovalIfNeeded({ tx, documentId: control.documentId });
// Findings linked to this row survive: the FK is SET NULL and each
// finding keeps its own clauseOrControl text as the frozen label.
// finding keeps its clauseOrControl text as the frozen label. A linked
// finding created without one (possible via the API) inherits the row's
// reference now, so no finding is left unlabelled by the deletion.
await tx.ismsAuditFinding.updateMany({
where: { controlId, clauseOrControl: null },
data: { clauseOrControl: control.controlRef },
});
await tx.ismsAuditControl.delete({ where: { id: controlId } });
});
return { success: true };
Expand Down
88 changes: 88 additions & 0 deletions apps/api/src/isms/isms-audit-finding.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,10 +98,50 @@ describe('IsmsAuditFindingService', () => {
).rejects.toThrow(NotFoundException);
expect(mockDb.ismsAuditControl.findFirst).toHaveBeenCalledWith({
where: { id: 'ac_other', auditId: 'aud_1' },
select: { id: true, controlRef: true },
});
expect(mockDb.ismsAuditFinding.create).not.toHaveBeenCalled();
});

it('inherits the clause label from the linked control when none is given', async () => {
(mockDb.ismsAuditControl.findFirst as jest.Mock).mockResolvedValue({
id: 'ac_1',
controlRef: 'Clause 9.1 Monitoring',
});

await service.create({
...args,
dto: { ...args.dto, controlId: 'ac_1' },
});

expect(mockDb.ismsAuditFinding.create).toHaveBeenCalledWith({
data: expect.objectContaining({
controlId: 'ac_1',
clauseOrControl: 'Clause 9.1 Monitoring',
}),
});
});

it('keeps an explicitly provided clause label over the linked control', async () => {
(mockDb.ismsAuditControl.findFirst as jest.Mock).mockResolvedValue({
id: 'ac_1',
controlRef: 'Clause 9.1 Monitoring',
});

await service.create({
...args,
dto: {
...args.dto,
controlId: 'ac_1',
clauseOrControl: 'Clause 9.1(b)',
},
});

expect(mockDb.ismsAuditFinding.create).toHaveBeenCalledWith({
data: expect.objectContaining({ clauseOrControl: 'Clause 9.1(b)' }),
});
});

it('rejects an owner that is not an active org member', async () => {
(mockDb.member.findFirst as jest.Mock).mockResolvedValue(null);

Expand Down Expand Up @@ -147,6 +187,54 @@ describe('IsmsAuditFindingService', () => {
expect(data.reference).toBeUndefined();
});

it('inherits the clause label when linking a control to an unlabelled finding', async () => {
(mockDb.ismsAuditFinding.findFirst as jest.Mock).mockResolvedValue({
id: 'af_1',
auditId: 'aud_1',
documentId: 'doc_1',
clauseOrControl: null,
});
(mockDb.ismsAuditControl.findFirst as jest.Mock).mockResolvedValue({
id: 'ac_1',
controlRef: 'A.5.15 Access control',
});

await service.update({
findingId: 'af_1',
organizationId: 'org_1',
dto: { controlId: 'ac_1' },
});

const { data } = (mockDb.ismsAuditFinding.update as jest.Mock).mock
.calls[0][0];
expect(data.controlId).toBe('ac_1');
expect(data.clauseOrControl).toBe('A.5.15 Access control');
});

it('keeps an existing clause label when relinking (labels are user-owned)', async () => {
(mockDb.ismsAuditFinding.findFirst as jest.Mock).mockResolvedValue({
id: 'af_1',
auditId: 'aud_1',
documentId: 'doc_1',
clauseOrControl: 'Clause 9.1 (Monitoring)',
});
(mockDb.ismsAuditControl.findFirst as jest.Mock).mockResolvedValue({
id: 'ac_2',
controlRef: 'A.8.13 Backup',
});

await service.update({
findingId: 'af_1',
organizationId: 'org_1',
dto: { controlId: 'ac_2' },
});

const { data } = (mockDb.ismsAuditFinding.update as jest.Mock).mock
.calls[0][0];
expect(data.controlId).toBe('ac_2');
expect(data.clauseOrControl).toBe('Clause 9.1 (Monitoring)');
});

it('unlinks the related control with null', async () => {
await service.update({
findingId: 'af_1',
Expand Down
43 changes: 32 additions & 11 deletions apps/api/src/isms/isms-audit-finding.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ export class IsmsAuditFindingService {
documentId,
Comment thread
tofikwest marked this conversation as resolved.
organizationId,
});
const controlId = await this.resolveControl({
const control = await this.resolveControl({
controlId: dto.controlId,
auditId: audit.id,
});
Expand All @@ -54,8 +54,12 @@ export class IsmsAuditFindingService {
documentId: audit.documentId,
reference: await this.nextReference({ tx, auditId: audit.id }),
type: dto.type,
controlId: controlId ?? null,
clauseOrControl: dto.clauseOrControl?.trim() || null,
controlId: control?.id ?? null,
// A linked finding without its own clause text inherits the row's
// reference, so unlinking (or deleting the row) never leaves the
// finding unlabelled in the generated document.
clauseOrControl:
dto.clauseOrControl?.trim() || control?.controlRef || null,
description: dto.description,
ownerMemberId: ownerMemberId ?? null,
dueDate: dueDate ?? null,
Expand All @@ -77,10 +81,17 @@ export class IsmsAuditFindingService {
dto: UpdateAuditFindingInput;
}) {
const finding = await this.requireFinding({ findingId, organizationId });
const controlId = await this.resolveControl({
const control = await this.resolveControl({
controlId: dto.controlId,
auditId: finding.auditId,
});
// Same inheritance on link change: when this update links a control and
// the finding's effective clause text is empty, derive it from the row.
const requestedClause =
dto.clauseOrControl === undefined
? finding.clauseOrControl
: dto.clauseOrControl?.trim() || null;
const derivedClause = requestedClause ?? control?.controlRef ?? null;
Comment thread
tofikwest marked this conversation as resolved.
const ownerMemberId = await this.resolveMember({
memberId: dto.ownerMemberId,
organizationId,
Expand All @@ -95,11 +106,11 @@ export class IsmsAuditFindingService {
where: { id: findingId },
data: {
type: dto.type ?? undefined,
controlId: dto.controlId === undefined ? undefined : controlId,
controlId: dto.controlId === undefined ? undefined : (control?.id ?? null),
clauseOrControl:
dto.clauseOrControl === undefined
dto.clauseOrControl === undefined && control === undefined
? undefined
: dto.clauseOrControl?.trim() || null,
: derivedClause,
description: dto.description ?? undefined,
ownerMemberId:
dto.ownerMemberId === undefined ? undefined : ownerMemberId,
Expand Down Expand Up @@ -131,7 +142,12 @@ export class IsmsAuditFindingService {
return { success: true };
}

/** Next "F-NN" for the audit. Max + 1, so a deleted reference never recurs. */
/**
* Next "F-NN" for the audit: max + 1 over the surviving rows, so deleting an
* older finding never reissues its number (deleting the newest frees it —
* acceptable: findings are draft rows until published, and every published
* version freezes its own findings in the version contentSnapshot).
*/
private async nextReference({
tx,
auditId,
Expand All @@ -150,24 +166,29 @@ export class IsmsAuditFindingService {
return `F-${String(maxSequence + 1).padStart(2, '0')}`;
}

/** The linked Controls Tested row must belong to the SAME audit. */
/**
* The linked Controls Tested row must belong to the SAME audit. Returns the
* row (id + reference) so callers can inherit the clause label; undefined =
* link untouched, null = explicitly unlinked.
*/
private async resolveControl({
controlId,
auditId,
}: {
controlId: string | null | undefined;
auditId: string;
}): Promise<string | null | undefined> {
}): Promise<{ id: string; controlRef: string } | null | undefined> {
if (controlId === undefined) return undefined;
const trimmed = controlId?.trim();
if (!trimmed) return null;
const control = await db.ismsAuditControl.findFirst({
where: { id: trimmed, auditId },
select: { id: true, controlRef: true },
});
if (!control) {
throw new NotFoundException('Audit control row not found in this audit');
}
return trimmed;
return control;
}

private async resolveMember({
Expand Down
46 changes: 46 additions & 0 deletions apps/api/src/isms/isms-audit.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ jest.mock('@db', () => {
},
ismsAudit: {
findFirst: jest.fn(),
findUniqueOrThrow: jest.fn(),
findMany: jest.fn(),
create: jest.fn(),
update: jest.fn(),
Expand Down Expand Up @@ -137,17 +138,62 @@ describe('IsmsAuditService', () => {
).rejects.toThrow(BadRequestException);
expect(mockDb.ismsAudit.create).not.toHaveBeenCalled();
});

it('rejects a planned end date before the start date', async () => {
(mockDb.ismsDocument.findFirst as jest.Mock).mockResolvedValue({
id: 'doc_1',
});
await expect(
service.create({
...args,
dto: { plannedStartDate: '2026-05-20', plannedEndDate: '2026-05-15' },
}),
).rejects.toThrow(BadRequestException);
expect(mockDb.ismsAudit.create).not.toHaveBeenCalled();
});
});

describe('update', () => {
beforeEach(() => {
(mockDb.ismsAudit.findFirst as jest.Mock).mockResolvedValue({
id: 'aud_1',
documentId: 'doc_1',
plannedStartDate: null,
plannedEndDate: null,
});
(mockDb.ismsAudit.update as jest.Mock).mockResolvedValue({});
});

it('rejects an end date before the STORED start date (re-read under the document lock)', async () => {
(mockDb.ismsAudit.findUniqueOrThrow as jest.Mock).mockResolvedValue({
plannedStartDate: new Date('2026-05-15T00:00:00.000Z'),
plannedEndDate: null,
});

await expect(
service.update({
auditId: 'aud_1',
organizationId: 'org_1',
dto: { plannedEndDate: '2026-05-10' },
}),
).rejects.toThrow(BadRequestException);
expect(mockDb.ismsAudit.update).not.toHaveBeenCalled();
});

it('allows fixing an inverted schedule by moving both dates together', async () => {
(mockDb.ismsAudit.findUniqueOrThrow as jest.Mock).mockResolvedValue({
plannedStartDate: new Date('2026-06-10T00:00:00.000Z'),
plannedEndDate: new Date('2026-05-01T00:00:00.000Z'),
});

await service.update({
auditId: 'aud_1',
organizationId: 'org_1',
dto: { plannedStartDate: '2026-06-01', plannedEndDate: '2026-06-05' },
});
expect(mockDb.ismsAudit.update).toHaveBeenCalled();
});

it('updates status and conclusion, leaving omitted fields untouched', async () => {
await service.update({
auditId: 'aud_1',
Expand Down
Loading
Loading