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
74 changes: 66 additions & 8 deletions apps/api/src/frameworks/frameworks-people-score.helper.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -187,14 +187,14 @@ describe('computePeopleScore', () => {

it('counts every member as complete when all members are exempt', async () => {
(mockDb.backgroundCheckRequest.findMany as jest.Mock).mockResolvedValue([]);
(mockDb.member.findMany as jest.Mock).mockImplementation(async (args: {
where?: { backgroundCheckExempt?: boolean };
}) => {
if (args?.where?.backgroundCheckExempt === true) {
return [{ id: 'mem_1' }, { id: 'mem_2' }];
}
return [];
});
(mockDb.member.findMany as jest.Mock).mockImplementation(
async (args: { where?: { backgroundCheckExempt?: boolean } }) => {
if (args?.where?.backgroundCheckExempt === true) {
return [{ id: 'mem_1' }, { id: 'mem_2' }];
}
return [];
},
);

const score = await computePeopleScore({
organizationId: 'org_1',
Expand All @@ -209,6 +209,64 @@ describe('computePeopleScore', () => {
expect(score).toEqual({ total: 2, completed: 2 });
});

it('does not require a background check for auditor-only members', async () => {
const auditorMembers = [
{
id: 'mem_1',
role: 'auditor',
user: { id: 'usr_1', email: 'a@example.com', role: 'auditor' },
},
{
id: 'mem_2',
role: 'owner',
user: { id: 'usr_2', email: 'b@example.com', role: 'owner' },
},
];
mockFilterComplianceMembers.mockResolvedValue(auditorMembers);
(mockDb.backgroundCheckRequest.findMany as jest.Mock).mockResolvedValue([]);
(mockDb.member.findMany as jest.Mock).mockResolvedValue([]);

const score = await computePeopleScore({
organizationId: 'org_1',
allPolicies: [],
employees: auditorMembers,
securityTrainingStepEnabled: false,
deviceAgentStepEnabled: false,
backgroundCheckStepEnabled: true,
hasHipaaFramework: false,
});

// mem_1 (auditor-only) → no BG check required → complete
// mem_2 (owner) → no BG check, not exempt → not complete
expect(score).toEqual({ total: 2, completed: 1 });
});

it('still requires a background check for members with auditor plus another role', async () => {
const mixedMembers = [
{
id: 'mem_1',
role: 'auditor,employee',
user: { id: 'usr_1', email: 'a@example.com', role: 'employee' },
},
];
mockFilterComplianceMembers.mockResolvedValue(mixedMembers);
(mockDb.backgroundCheckRequest.findMany as jest.Mock).mockResolvedValue([]);
(mockDb.member.findMany as jest.Mock).mockResolvedValue([]);

const score = await computePeopleScore({
organizationId: 'org_1',
allPolicies: [],
employees: mixedMembers,
securityTrainingStepEnabled: false,
deviceAgentStepEnabled: false,
backgroundCheckStepEnabled: true,
hasHipaaFramework: false,
});

// auditor+employee is NOT auditor-only → still requires a BG check → not complete
expect(score).toEqual({ total: 1, completed: 0 });
});

it('skips the exempt query entirely when backgroundCheckStepEnabled is false', async () => {
(mockDb.member.findMany as jest.Mock).mockClear();

Expand Down
18 changes: 17 additions & 1 deletion apps/api/src/frameworks/frameworks-people-score.helper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,20 @@ const COMPLETED_BACKGROUND_CHECK_STATUSES = [
BackgroundCheckStatus.completed_with_flags,
];

/**
* Auditor-only members are not subject to people-security requirements like
* background checks (CS-416). A member counts as auditor-only when every one of
* their roles is `auditor` — a member with `auditor` plus another role still
* carries that other role's obligations.
*/
function isAuditorOnly(role: string): boolean {
const roles = role
.split(',')
.map((r) => r.trim())
.filter(Boolean);
return roles.length > 0 && roles.every((r) => r === 'auditor');
}

interface ScorePolicy {
isRequiredToSign: boolean;
status: string;
Expand Down Expand Up @@ -106,7 +120,9 @@ export async function computePeopleScore({
? membersWithInstalledDevices.has(employee.id)
: true;
const memberRequiresBgCheck =
backgroundCheckStepEnabled && !exemptMemberIds.has(employee.id);
backgroundCheckStepEnabled &&
!exemptMemberIds.has(employee.id) &&
!isAuditorOnly(employee.role);
const hasCompletedBackgroundCheck = memberRequiresBgCheck
? membersWithCompletedBackgroundChecks.has(employee.id)
: true;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import { EmployeeDevice } from './EmployeeDevice';
import { EmployeePageHeader } from './EmployeePageHeader';
import { EmployeePolicies } from './EmployeePolicies';
import { EmployeeHipaaTraining, EmployeeTrainingVideos } from './EmployeeTraining';
import { isAuditorOnly } from './isAuditorOnly';
import { OffboardingChecklist } from './OffboardingChecklist';

type EmployeeTab =
Expand Down Expand Up @@ -74,19 +75,23 @@ export function Employee({
const pathname = usePathname();
const router = useRouter();

// CS-416: auditor-only members aren't subject to background checks, so the
// tab, header status, and content are hidden for them.
const showBackgroundCheck = backgroundCheckStepEnabled && !isAuditorOnly(employee.role);

const availableTabs: EmployeeTab[] = [
'details',
'policies',
'training',
...(hasHipaaFramework ? (['hipaa'] as EmployeeTab[]) : []),
'device',
...(backgroundCheckStepEnabled ? (['background-check'] as EmployeeTab[]) : []),
...(showBackgroundCheck ? (['background-check'] as EmployeeTab[]) : []),
...(employee.offboardDate ? (['offboarding'] as EmployeeTab[]) : []),
];

const resolveTab = (): EmployeeTab => {
if (
backgroundCheckStepEnabled &&
showBackgroundCheck &&
(searchParams.get('background_check_step') || searchParams.get('background_check_billing'))
) {
return 'background-check';
Expand Down Expand Up @@ -131,7 +136,7 @@ export function Employee({
employeeName={employee.user.name ?? 'Employee'}
orgId={orgId}
backgroundCheck={initialBackgroundCheck}
backgroundCheckStepEnabled={backgroundCheckStepEnabled}
backgroundCheckStepEnabled={showBackgroundCheck}
memberBackgroundCheckExempt={memberExempt}
/>
}
Expand All @@ -149,12 +154,10 @@ export function Employee({
<TabsTrigger value="training">Training Videos</TabsTrigger>
{hasHipaaFramework && <TabsTrigger value="hipaa">HIPAA Training</TabsTrigger>}
<TabsTrigger value="device">Device</TabsTrigger>
{backgroundCheckStepEnabled && (
{showBackgroundCheck && (
<TabsTrigger value="background-check">Background Check</TabsTrigger>
)}
{employee.offboardDate && (
<TabsTrigger value="offboarding">Offboarding</TabsTrigger>
)}
{employee.offboardDate && <TabsTrigger value="offboarding">Offboarding</TabsTrigger>}
</TabsList>
<TabsContent value="details">
<EmployeeDetails employee={employee} canEdit={canEdit} />
Expand Down Expand Up @@ -195,7 +198,7 @@ export function Employee({
/>
</TabsContent>
)}
{backgroundCheckStepEnabled && (
{showBackgroundCheck && (
<TabsContent value="background-check">
<EmployeeBackgroundCheck
employee={employee}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { describe, expect, it } from 'vitest';
import { isAuditorOnly } from './isAuditorOnly';

describe('isAuditorOnly', () => {
it('is true when the only role is auditor', () => {
expect(isAuditorOnly('auditor')).toBe(true);
});

it('is true when every role entry is auditor', () => {
expect(isAuditorOnly('auditor, auditor')).toBe(true);
});

it('is false when another role is present alongside auditor', () => {
expect(isAuditorOnly('auditor,employee')).toBe(false);
expect(isAuditorOnly('owner')).toBe(false);
});

it('is false for empty or missing roles', () => {
expect(isAuditorOnly('')).toBe(false);
expect(isAuditorOnly(null)).toBe(false);
expect(isAuditorOnly(undefined)).toBe(false);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
/**
* A member is "auditor-only" when every one of their (comma-separated) roles is
* `auditor`. Auditor-only members are external reviewers and are not subject to
* people-security requirements such as background checks (CS-416). A member with
* `auditor` plus another role still carries that other role's obligations.
*/
export function isAuditorOnly(role: string | null | undefined): boolean {
const roles = (role ?? '')
.split(',')
.map((r) => r.trim())
.filter(Boolean);
return roles.length > 0 && roles.every((r) => r === 'auditor');
}
Loading