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: 1 addition & 1 deletion apps/api/src/soa/soa.controller.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ describe('SOAController', () => {
batchSearchSOAQuestions: jest.fn(),
processSOAQuestionWithContent: jest.fn(),
saveAnswersToDatabase: jest.fn(),
updateConfigurationWithResults: jest.fn(),
countAnsweredAnswers: jest.fn(),
updateDocumentAfterAutoFill: jest.fn(),
createDocument: jest.fn(),
ensureSetup: jest.fn(),
Expand Down
15 changes: 5 additions & 10 deletions apps/api/src/soa/soa.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -261,17 +261,12 @@ export class SOAController {
userId,
);

// Update configuration with results
await this.soaService.updateConfigurationWithResults(
configuration.id,
questions,
successfulResults,
// Update document answered count from this org's persisted answers,
// scoped to the document's configured questions.
const answeredCount = await this.soaService.countAnsweredAnswers(
dto.documentId,
questions.map((q) => q.id),
);

// Update document
const answeredCount = successfulResults.filter(
(r) => r.isApplicable !== null,
).length;
await this.soaService.updateDocumentAfterAutoFill(
dto.documentId,
questions.length,
Expand Down
258 changes: 248 additions & 10 deletions apps/api/src/soa/soa.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
import { db } from '@db';
import { SOAService } from './soa.service';
import { generateSOAExportFile } from './utils/export-generator';
import { checkIfFullyRemote } from './utils/soa-storage';

jest.mock('@db', () => ({
db: {
Expand All @@ -18,6 +19,7 @@ jest.mock('@db', () => ({
findFirst: jest.fn(),
findUnique: jest.fn(),
create: jest.fn(),
update: jest.fn(),
},
sOADocument: {
findFirst: jest.fn(),
Expand All @@ -28,6 +30,7 @@ jest.mock('@db', () => ({
user: { findUnique: jest.fn() },
organization: { findUnique: jest.fn() },
sOAAnswer: { findFirst: jest.fn(), create: jest.fn(), update: jest.fn() },
$transaction: jest.fn((ops: unknown[]) => Promise.all(ops)),
},
}));

Expand All @@ -43,13 +46,14 @@ jest.mock('./utils/soa-answer-parser', () => ({
parseAndProcessSOAAnswer: jest.fn(),
createDefaultYesResult: jest.fn(),
createFullyRemoteResult: jest.fn(),
isPhysicalSecurityControl: jest.fn(),
isPhysicalSecurityControl: jest.fn(
(closure: string) => typeof closure === 'string' && closure.startsWith('7.'),
),
}));
jest.mock('./utils/soa-storage', () => ({
saveAnswersToDatabase: jest.fn(),
updateConfigurationWithResults: jest.fn(),
updateDocumentAfterAutoFill: jest.fn(),
getAnsweredCountFromConfiguration: jest.fn(),
countAnsweredAnswers: jest.fn(),
updateDocumentAnsweredCount: jest.fn(),
checkIfFullyRemote: jest.fn(),
}));
Expand All @@ -59,6 +63,7 @@ jest.mock('./utils/export-generator', () => ({

const mockDb = jest.mocked(db);
const mockGenerateSOAExportFile = jest.mocked(generateSOAExportFile);
const mockCheckIfFullyRemote = jest.mocked(checkIfFullyRemote);

describe('SOAService', () => {
let service: SOAService;
Expand Down Expand Up @@ -487,13 +492,14 @@ describe('SOAService', () => {
);
});

it('maps document data and delegates to generateSOAExportFile', async () => {
it('sources applicability + justification from this org\'s answers, not the shared configuration', async () => {
const generated = {
fileBuffer: Buffer.from('pdf'),
mimeType: 'application/pdf',
filename: 'statement-of-applicability-iso-27001-v2.pdf',
};
mockGenerateSOAExportFile.mockReturnValue(generated);
mockCheckIfFullyRemote.mockResolvedValue(false);
(mockDb.sOADocument.findFirst as jest.Mock).mockResolvedValue({
id: 'doc-1',
organizationId: 'org-1',
Expand All @@ -506,6 +512,8 @@ describe('SOAService', () => {
version: 2,
framework: { name: 'ISO 27001' },
configuration: {
// The shared configuration must NOT drive applicability/justification.
// These stale values would previously bleed into every org's export.
questions: [
{
id: 'q-1',
Expand All @@ -515,7 +523,7 @@ describe('SOAService', () => {
title: 'Control title',
control_objective: 'Objective',
isApplicable: true,
justification: 'Mapped justification',
justification: 'Another org shared-config justification',
},
},
{
Expand All @@ -531,7 +539,14 @@ describe('SOAService', () => {
email: 'approver@example.com',
},
},
answers: [{ questionId: 'q-2', answer: 'Fallback answer' }],
// This org's own answers.
answers: [
{
questionId: 'q-1',
answer: 'Our own justification',
isApplicable: false,
},
],
});

const result = await service.exportDocument(dto);
Expand All @@ -545,10 +560,11 @@ describe('SOAService', () => {
closure: 'A.5',
title: 'Control title',
control_objective: 'Objective',
isApplicable: true,
justification: 'Mapped justification',
// From the org's own answer — NOT the shared config's `true`.
isApplicable: false,
justification: 'Our own justification',
},
answer: null,
answer: 'Our own justification',
},
{
id: 'q-2',
Expand All @@ -557,10 +573,11 @@ describe('SOAService', () => {
closure: null,
title: null,
control_objective: null,
// No answer for this org → blank, not another org's data.
isApplicable: null,
justification: null,
},
answer: 'Fallback answer',
answer: null,
},
],
'ISO 27001',
Expand All @@ -578,5 +595,226 @@ describe('SOAService', () => {
);
expect(result).toEqual(generated);
});

it('forces Not Applicable on physical-security (7.x) controls for a fully remote org', async () => {
const generated = {
fileBuffer: Buffer.from('pdf'),
mimeType: 'application/pdf',
filename: 'statement-of-applicability-iso-27001-v1.pdf',
};
mockGenerateSOAExportFile.mockReturnValue(generated);
mockCheckIfFullyRemote.mockResolvedValue(true);
(mockDb.sOADocument.findFirst as jest.Mock).mockResolvedValue({
id: 'doc-1',
organizationId: 'org-1',
preparedBy: 'Comp AI',
answeredQuestions: 2,
totalQuestions: 2,
approvedAt: null,
declinedAt: null,
status: 'completed',
version: 1,
framework: { name: 'ISO 27001' },
configuration: {
questions: [
{
id: 'q-phys',
text: 'Physical entry',
columnMapping: {
closure: '7.2',
title: 'Physical entry',
control_objective: 'obj',
isApplicable: true,
justification: 'another org shared-config text',
},
},
{
id: 'q-other',
text: 'Policies',
columnMapping: {
closure: '5.1',
title: 'Policies',
control_objective: 'obj',
isApplicable: true,
justification: 'another org shared-config text',
},
},
],
},
approver: null,
answers: [
// A stale justification persisted before the org went remote — must
// NOT leak into the forced Not Applicable output.
{
questionId: 'q-phys',
answer: 'We maintain physical access controls at our office',
isApplicable: true,
},
{ questionId: 'q-other', answer: 'Our own 5.1 justification', isApplicable: true },
],
});

await service.exportDocument(dto);

const passedQuestions = mockGenerateSOAExportFile.mock.calls[0][0];
const phys = passedQuestions.find((q) => q.id === 'q-phys');
const other = passedQuestions.find((q) => q.id === 'q-other');

// Fully remote + 7.x → forced Not Applicable with the remote justification,
// overriding the stale persisted answer.
expect(phys?.columnMapping.isApplicable).toBe(false);
expect(phys?.columnMapping.justification).toBe(
'This control is not applicable as our organization operates fully remotely.',
);
expect(phys?.answer).toBe(
'This control is not applicable as our organization operates fully remotely.',
);
// Non-physical controls are unaffected and use the org's own answer.
expect(other?.columnMapping.isApplicable).toBe(true);
expect(other?.columnMapping.justification).toBe('Our own 5.1 justification');
});
});

describe('saveAnswer', () => {
const baseDto = {
organizationId: 'org-1',
documentId: 'doc-1',
questionId: 'q-1',
};
const userId = 'user-1';

beforeEach(() => {
(mockDb.sOADocument.findFirst as jest.Mock).mockResolvedValue({
id: 'doc-1',
totalQuestions: 5,
configuration: { questions: [{ id: 'q-1' }] },
});
(mockDb.sOAAnswer.findFirst as jest.Mock).mockResolvedValue(null);
(mockDb.sOAAnswer.create as jest.Mock).mockResolvedValue({ id: 'ans-1' });
});

it('throws NotFoundException when document not found', async () => {
(mockDb.sOADocument.findFirst as jest.Mock).mockResolvedValue(null);
await expect(
service.saveAnswer(
{ ...baseDto, isApplicable: true, justification: 'x' },
userId,
),
).rejects.toThrow(NotFoundException);
});

it('rejects a question that is not in the document configuration', async () => {
await expect(
service.saveAnswer(
{
...baseDto,
questionId: 'q-not-in-config',
isApplicable: true,
justification: 'x',
},
userId,
),
).rejects.toThrow(BadRequestException);
expect(mockDb.sOAAnswer.create).not.toHaveBeenCalled();
});

it('requires a justification when a control is not applicable', async () => {
await expect(
service.saveAnswer(
{ ...baseDto, isApplicable: false, justification: ' ' },
userId,
),
).rejects.toThrow(BadRequestException);
expect(mockDb.sOAAnswer.create).not.toHaveBeenCalled();
});

it('does not retire the previous answer when validation fails', async () => {
// A rejected save must leave the prior answer intact — validation runs
// before any write, and the retire + create happen in one transaction.
(mockDb.sOAAnswer.findFirst as jest.Mock).mockResolvedValue({
id: 'ans-prev',
answerVersion: 1,
isApplicable: true,
answer: 'Previously applicable',
});

await expect(
service.saveAnswer(
{ ...baseDto, isApplicable: false, justification: '' },
userId,
),
).rejects.toThrow(BadRequestException);

expect(mockDb.sOAAnswer.update).not.toHaveBeenCalled();
expect(mockDb.$transaction).not.toHaveBeenCalled();
expect(mockDb.sOAAnswer.create).not.toHaveBeenCalled();
});

it('preserves the existing applicability when isApplicable is omitted', async () => {
(mockDb.sOAAnswer.findFirst as jest.Mock).mockResolvedValue({
id: 'ans-prev',
answerVersion: 1,
isApplicable: false,
answer: 'Prior justification',
});

// A partial edit that only sends a justification must not wipe the Yes/No.
await service.saveAnswer(
{ ...baseDto, justification: 'Updated justification' },
userId,
);

expect(mockDb.sOAAnswer.create).toHaveBeenCalledWith({
data: expect.objectContaining({
isApplicable: false,
answer: 'Updated justification',
}),
});
});

it('persists applicability + justification on the answer', async () => {
await service.saveAnswer(
{
...baseDto,
isApplicable: false,
justification: 'Not applicable because reasons',
},
userId,
);

expect(mockDb.sOAAnswer.create).toHaveBeenCalledWith({
data: expect.objectContaining({
documentId: 'doc-1',
questionId: 'q-1',
answer: 'Not applicable because reasons',
isApplicable: false,
status: 'manual',
isLatestAnswer: true,
}),
});
});

it('keeps the justification for applicable (YES) answers', async () => {
await service.saveAnswer(
{ ...baseDto, isApplicable: true, justification: 'We do this' },
userId,
);

expect(mockDb.sOAAnswer.create).toHaveBeenCalledWith({
data: expect.objectContaining({
answer: 'We do this',
isApplicable: true,
}),
});
});

it('never writes per-organization data to the shared configuration', async () => {
await service.saveAnswer(
{ ...baseDto, isApplicable: true, justification: 'We do this' },
userId,
);

expect(mockDb.sOAFrameworkConfiguration.update).not.toHaveBeenCalled();
});
});
});
Loading
Loading