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
113 changes: 111 additions & 2 deletions apps/api/src/frameworks/frameworks-scores.helper.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,13 @@ jest.mock('@db', () => ({
member: { findMany: jest.fn() },
onboarding: { findUnique: jest.fn() },
organization: { findUnique: jest.fn() },
frameworkInstance: { findFirst: jest.fn() },
frameworkInstance: { findFirst: jest.fn(), findMany: jest.fn() },
employeeTrainingVideoCompletion: { findMany: jest.fn() },
device: { findMany: jest.fn() },
fleetPolicyResult: { findMany: jest.fn() },
evidenceSubmission: { groupBy: jest.fn() },
finding: { findMany: jest.fn() },
sOADocument: { findFirst: jest.fn() },
},
}));

Expand All @@ -20,7 +21,12 @@ jest.mock('../utils/compliance-filters', () => ({

import { db } from '@db';
import { filterComplianceMembers } from '../utils/compliance-filters';
import { getOverviewScores } from './frameworks-scores.helper';
import {
computeFrameworkComplianceScore,
getOverviewScores,
} from './frameworks-scores.helper';

const SIX_MONTHS_MS = 6 * 30 * 24 * 60 * 60 * 1000;

const mockDb = db as jest.Mocked<typeof db>;
const mockFilterComplianceMembers =
Expand All @@ -42,6 +48,8 @@ describe('frameworks-scores.helper', () => {
(mockDb.fleetPolicyResult.findMany as jest.Mock).mockResolvedValue([]);
(mockDb.evidenceSubmission.groupBy as jest.Mock).mockResolvedValue([]);
(mockDb.finding.findMany as jest.Mock).mockResolvedValue([]);
(mockDb.frameworkInstance.findMany as jest.Mock).mockResolvedValue([]);
((mockDb as any).sOADocument.findFirst as jest.Mock).mockResolvedValue(null);
});

it('requires installed device for people completion when device agent step is enabled', async () => {
Expand Down Expand Up @@ -240,6 +248,107 @@ describe('frameworks-scores.helper', () => {
});
});

describe('computeFrameworkComplianceScore', () => {
it('returns 0 when the framework has no artifacts', () => {
expect(
computeFrameworkComplianceScore({ controls: [] }, [], []),
).toBe(0);
});

it('returns 100 when every artifact across the framework is complete', () => {
const framework = {
controls: [
{
id: 'c1',
policies: [{ id: 'p1', status: 'published' }],
controlDocumentTypes: [],
},
],
};
const tasks = [
{ id: 't1', status: 'done', controls: [{ id: 'c1' }] },
];
expect(computeFrameworkComplianceScore(framework, tasks, [])).toBe(100);
});

it('weights every artifact equally instead of treating partial controls as 0%', () => {
const framework = {
controls: [
{
id: 'c1',
policies: [{ id: 'p1', status: 'published' }],
controlDocumentTypes: [{ formType: 'access_control_policy' }],
},
{
id: 'c2',
policies: [{ id: 'p2', status: 'draft' }],
controlDocumentTypes: [],
},
],
};
const tasks = [
{ id: 't1', status: 'done', controls: [{ id: 'c1' }] },
{ id: 't2', status: 'todo', controls: [{ id: 'c2' }] },
];
// 5 unique artifacts (2 policies, 2 tasks, 1 doc type), 2 completed → 40%
// The old binary-completion implementation would have returned 0%
// because no control is fully satisfied.
expect(computeFrameworkComplianceScore(framework, tasks, [])).toBe(40);
});

it('only treats a document as completed when its latest submission is within 6 months', () => {
const framework = {
controls: [
{
id: 'c1',
policies: [],
controlDocumentTypes: [
{ formType: 'access_control_policy' },
{ formType: 'incident_response_plan' },
],
},
],
};
const recent = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
const stale = new Date(Date.now() - SIX_MONTHS_MS - 24 * 60 * 60 * 1000);
const submissions = [
{ formType: 'access_control_policy', submittedAt: recent },
{ formType: 'incident_response_plan', submittedAt: stale },
];
expect(computeFrameworkComplianceScore(framework, [], submissions)).toBe(
50,
);
});

it('deduplicates artifacts shared across controls', () => {
const framework = {
controls: [
{
id: 'c1',
policies: [{ id: 'p1', status: 'published' }],
controlDocumentTypes: [{ formType: 'access_control_policy' }],
},
{
id: 'c2',
policies: [{ id: 'p1', status: 'published' }],
controlDocumentTypes: [{ formType: 'access_control_policy' }],
},
],
};
const sharedTask = {
id: 't1',
status: 'done',
controls: [{ id: 'c1' }, { id: 'c2' }],
};
// Without dedup: 6 artifacts (2 policies, 2 tasks, 2 docs), 4 completed → 67%
// With dedup: 2 unique artifacts (1 policy, 1 task), 2 completed; 1 unmet doc → 67%
// Wait: 1 policy (done) + 1 task (done) + 1 doc (no submission, not fresh) = 2/3 = 67%
expect(computeFrameworkComplianceScore(framework, [sharedTask], [])).toBe(
67,
);
});
});

it('skips security training requirement when security training step is disabled', async () => {
const members: Array<{
id: string;
Expand Down
104 changes: 50 additions & 54 deletions apps/api/src/frameworks/frameworks-scores.helper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -327,74 +327,70 @@ interface EvidenceSubmissionForScoring {
submittedAt: Date | string;
}

function hasAnyArtifact(
control: ControlForScoring,
export function computeFrameworkComplianceScore(
framework: FrameworkWithControlsForScoring,
tasks: TaskWithControls[],
): boolean {
const policies = control.policies ?? [];
const documentTypes = control.controlDocumentTypes ?? [];
const controlTasks = tasks.filter((t) =>
t.controls.some((c) => c.id === control.id),
);
return (
policies.length > 0 || controlTasks.length > 0 || documentTypes.length > 0
);
}
evidenceSubmissions: EvidenceSubmissionForScoring[] = [],
): number {
const controls = framework.controls ?? [];
if (controls.length === 0) return 0;

function isControlCompleted(
control: ControlForScoring,
tasks: TaskWithControls[],
evidenceSubmissions: EvidenceSubmissionForScoring[],
): boolean {
const policies = control.policies ?? [];
const documentTypes = control.controlDocumentTypes ?? [];
const controlTasks = tasks.filter((t) =>
t.controls.some((c) => c.id === control.id),
);
const controlIds = new Set(controls.map((c) => c.id));

const policiesComplete =
policies.length === 0 ||
policies.every((p) => p.status === 'published');
// Bubble up artifact-based progress: count every unique policy, task and
// document type across the framework, then weight each artifact equally.
// Previously this was binary on full-control satisfaction, so a control
// with one in-progress task pulled the whole framework to 0%.
const policiesById = new Map<string, { id: string; status: string }>();
for (const control of controls) {
for (const policy of control.policies ?? []) {
policiesById.set(policy.id, policy);
}
}

const tasksComplete =
controlTasks.length === 0 ||
controlTasks.every(
(t) => t.status === 'done' || t.status === 'not_relevant',
);
const tasksById = new Map<string, TaskWithControls>();
for (const task of tasks) {
if (task.controls.some((c) => controlIds.has(c.id))) {
tasksById.set(task.id, task);
}
}

let documentsComplete = true;
if (documentTypes.length > 0) {
const documentFormTypes = new Set<string>();
for (const control of controls) {
for (const dt of control.controlDocumentTypes ?? []) {
documentFormTypes.add(dt.formType);
}
}

const totalArtifacts =
policiesById.size + tasksById.size + documentFormTypes.size;
if (totalArtifacts === 0) return 0;

const policiesCompleted = Array.from(policiesById.values()).filter(
(p) => p.status === 'published',
).length;
const tasksCompleted = Array.from(tasksById.values()).filter(
(t) => t.status === 'done' || t.status === 'not_relevant',
).length;

let documentsCompleted = 0;
if (documentFormTypes.size > 0 && evidenceSubmissions.length > 0) {
const sorted = [...evidenceSubmissions].sort(
(a, b) =>
new Date(b.submittedAt).getTime() - new Date(a.submittedAt).getTime(),
);
const now = Date.now();
for (const dt of documentTypes) {
const latest = sorted.find((es) => es.formType === dt.formType);
for (const formType of documentFormTypes) {
const latest = sorted.find((es) => es.formType === formType);
if (
!latest ||
now - new Date(latest.submittedAt).getTime() > SIX_MONTHS_MS
latest &&
now - new Date(latest.submittedAt).getTime() <= SIX_MONTHS_MS
) {
documentsComplete = false;
break;
documentsCompleted++;
}
}
}

return policiesComplete && tasksComplete && documentsComplete;
}

export function computeFrameworkComplianceScore(
framework: FrameworkWithControlsForScoring,
tasks: TaskWithControls[],
evidenceSubmissions: EvidenceSubmissionForScoring[] = [],
): number {
const controls = (framework.controls ?? []).filter((c) =>
hasAnyArtifact(c, tasks),
);
if (controls.length === 0) return 0;
const completed = controls.filter((c) =>
isControlCompleted(c, tasks, evidenceSubmissions),
).length;
return Math.round((completed / controls.length) * 100);
const completed = policiesCompleted + tasksCompleted + documentsCompleted;
return Math.round((completed / totalArtifacts) * 100);
}
Loading