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
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
import { render, screen } from '@testing-library/react';
import type { ReactNode } from 'react';
import { describe, expect, it, vi } from 'vitest';

import type { DeviceWithChecks, FleetPolicy, Host } from '../../devices/types';

// Radix Tabs renders non-active content with `hidden`. For device-tab tests we
// stub Tabs/TabsContent to always render children so assertions work without
// user interaction.
vi.mock('@trycompai/design-system', async (importOriginal) => {
const mod = await importOriginal<typeof import('@trycompai/design-system')>();
return {
...mod,
Tabs: ({ children }: { children: ReactNode }) => <div>{children}</div>,
TabsList: ({ children }: { children: ReactNode }) => <div>{children}</div>,
TabsTrigger: ({ children }: { children: ReactNode }) => <div>{children}</div>,
TabsContent: ({ children, value }: { children: ReactNode; value: string }) => (
<div data-tab={value}>{children}</div>
),
};
});

// PolicyItem pulls in heavy UI modules; none of its behaviour matters here.
vi.mock('../../devices/components/PolicyItem', () => ({
PolicyItem: () => null,
}));

// Server actions invoked from download handlers — never triggered in these tests.
vi.mock('../actions/download-training-certificate', () => ({
downloadTrainingCertificate: vi.fn(),
}));
vi.mock('../actions/download-hipaa-certificate', () => ({
downloadHipaaCertificate: vi.fn(),
}));

import { EmployeeTasks } from './EmployeeTasks';

const baseEmployee = {
id: 'mem_1',
userId: 'usr_1',
organizationId: 'org_1',
role: 'employee',
department: null,
isActive: true,
deactivated: false,
fleetDmLabelId: null,
createdAt: new Date(),
updatedAt: new Date(),
user: {
id: 'usr_1',
name: 'Jane Doe',
email: 'jane@example.com',
emailVerified: true,
image: null,
role: 'user',
createdAt: new Date(),
updatedAt: new Date(),
banned: false,
banReason: null,
banExpires: null,
},
} as unknown as Parameters<typeof EmployeeTasks>[0]['employee'];

const baseOrganization = {
id: 'org_1',
name: 'Test Org',
securityTrainingStepEnabled: true,
deviceAgentStepEnabled: true,
} as unknown as Parameters<typeof EmployeeTasks>[0]['organization'];

function makeDevice(overrides: Partial<DeviceWithChecks> = {}): DeviceWithChecks {
return {
id: 'dev_1',
name: 'Jane MacBook',
hostname: 'jane-mbp',
platform: 'macos',
osVersion: '14.0',
serialNumber: 'SN1',
hardwareModel: 'MBP',
isCompliant: true,
diskEncryptionEnabled: true,
antivirusEnabled: true,
passwordPolicySet: true,
screenLockEnabled: true,
checkDetails: null,
lastCheckIn: new Date().toISOString(),
agentVersion: '1.0.0',
installedAt: new Date().toISOString(),
memberId: 'mem_1',
user: { name: 'Jane', email: 'jane@example.com' },
source: 'device_agent',
complianceStatus: 'compliant',
daysSinceLastCheckIn: 0,
...overrides,
};
}

function renderWithDevice(memberDevice: DeviceWithChecks | null) {
return render(
<EmployeeTasks
employee={baseEmployee}
policies={[]}
trainingVideos={[]}
host={null as unknown as Host}
fleetPolicies={[] as FleetPolicy[]}
organization={baseOrganization}
memberDevice={memberDevice}
hasHipaaFramework={false}
hipaaCompletedAt={null}
/>,
);
}

describe('EmployeeTasks device compliance badge', () => {
it('shows "Compliant" badge when complianceStatus is compliant', () => {
renderWithDevice(makeDevice({ complianceStatus: 'compliant' }));
expect(screen.getByText('Compliant')).toBeInTheDocument();
expect(screen.queryByText('Non-Compliant')).not.toBeInTheDocument();
});

it('shows "Non-Compliant" badge when complianceStatus is non_compliant', () => {
renderWithDevice(
makeDevice({
complianceStatus: 'non_compliant',
isCompliant: false,
diskEncryptionEnabled: false,
}),
);
expect(screen.getByText('Non-Compliant')).toBeInTheDocument();
});

it('shows "Stale (Nd)" badge and em-dash check badges when complianceStatus is stale', () => {
renderWithDevice(
makeDevice({
complianceStatus: 'stale',
daysSinceLastCheckIn: 12,
}),
);
expect(screen.getByText('Stale (12d)')).toBeInTheDocument();
expect(screen.queryByText('Compliant')).not.toBeInTheDocument();
expect(screen.queryByText('Non-Compliant')).not.toBeInTheDocument();
expect(screen.queryByText('Pass')).not.toBeInTheDocument();
expect(screen.queryByText('Fail')).not.toBeInTheDocument();
// One per check (4 checks)
expect(screen.getAllByText('—').length).toBe(4);
});

it('shows plain "Stale" when daysSinceLastCheckIn is null', () => {
renderWithDevice(
makeDevice({
complianceStatus: 'stale',
daysSinceLastCheckIn: null,
lastCheckIn: null,
}),
);
expect(screen.getByText('Stale')).toBeInTheDocument();
});

it('sets stale badge title tooltip based on daysSinceLastCheckIn', () => {
const { rerender } = renderWithDevice(
makeDevice({ complianceStatus: 'stale', daysSinceLastCheckIn: 9 }),
);
expect(screen.getByText('Stale (9d)').closest('[title]')?.getAttribute('title')).toBe(
'No check-in in 9 days',
);

rerender(
<EmployeeTasks
employee={baseEmployee}
policies={[]}
trainingVideos={[]}
host={null as unknown as Host}
fleetPolicies={[] as FleetPolicy[]}
organization={baseOrganization}
memberDevice={makeDevice({
complianceStatus: 'stale',
daysSinceLastCheckIn: null,
lastCheckIn: null,
})}
hasHipaaFramework={false}
hipaaCompletedAt={null}
/>,
);
expect(screen.getByText('Stale').closest('[title]')?.getAttribute('title')).toBe(
'No check-ins recorded',
);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,30 @@ const PLATFORM_LABELS: Record<string, string> = {
linux: 'Linux',
};

function staleLabel(daysSinceLastCheckIn: number | null): string {
return daysSinceLastCheckIn === null ? 'Stale' : `Stale (${daysSinceLastCheckIn}d)`;
}

function staleTitle(daysSinceLastCheckIn: number | null): string {
return daysSinceLastCheckIn === null
? 'No check-ins recorded'
: `No check-in in ${daysSinceLastCheckIn} days`;
}

function DeviceComplianceBadge({ device }: { device: DeviceWithChecks }) {
if (device.complianceStatus === 'stale') {
return (
<Badge variant="secondary" title={staleTitle(device.daysSinceLastCheckIn)}>
{staleLabel(device.daysSinceLastCheckIn)}
</Badge>
);
}
if (device.complianceStatus === 'compliant') {
return <Badge variant="default">Compliant</Badge>;
}
return <Badge variant="destructive">Non-Compliant</Badge>;
}

export const EmployeeTasks = ({
employee,
policies,
Expand Down Expand Up @@ -340,15 +364,14 @@ export const EmployeeTasks = ({
{memberDevice.hardwareModel ? ` \u2022 ${memberDevice.hardwareModel}` : ''}
</Text>
</div>
<Badge variant={memberDevice.isCompliant ? 'default' : 'destructive'}>
{memberDevice.isCompliant ? 'Compliant' : 'Non-Compliant'}
</Badge>
<DeviceComplianceBadge device={memberDevice} />
</div>
</CardHeader>
<CardContent>
<div className="space-y-2">
{CHECK_FIELDS.map(({ key, dbKey, label }) => {
const isFleetUnsupported = memberDevice.source === 'fleet' && key !== 'diskEncryptionEnabled';
const isStale = memberDevice.complianceStatus === 'stale';
const passed = memberDevice[key];
const details = memberDevice.checkDetails?.[dbKey];
return (
Expand All @@ -358,7 +381,7 @@ export const EmployeeTasks = ({
>
<div>
<span className="text-sm font-medium">{label}</span>
{!isFleetUnsupported && details?.message && (
{!isFleetUnsupported && !isStale && details?.message && (
<p className="text-muted-foreground text-xs">
{details.message}
</p>
Expand All @@ -368,14 +391,21 @@ export const EmployeeTasks = ({
Not tracked by Fleet
</p>
)}
{details?.exception && (
{!isFleetUnsupported && !isStale && details?.exception && (
<p className="text-amber-600 dark:text-amber-400 text-xs mt-0.5">
{details.exception}
</p>
)}
</div>
{isFleetUnsupported ? (
<Badge variant="outline">N/A</Badge>
) : isStale ? (
<Badge
variant="secondary"
title={`${label} — unknown (device is stale)`}
>
</Badge>
) : (
<Badge variant={passed ? 'default' : 'destructive'}>
{passed ? 'Pass' : 'Fail'}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ import { useMemo } from 'react';
import { useAgentDevices } from '../../devices/hooks/useAgentDevices';
import { useFleetHosts } from '../../devices/hooks/useFleetHosts';
import { buildDisplayItems, filterDisplayItems } from './filter-members';
import { computeDeviceStatusMap } from './compute-device-status-map';
import { MemberRow } from './MemberRow';
import { PendingInvitationRow } from './PendingInvitationRow';
import type { MemberWithUser, TaskCompletion, TeamMembersData } from './TeamMembers';
Expand Down Expand Up @@ -72,35 +73,10 @@ export function TeamMembersClient({
const { fleetHosts, isLoading: isFleetHostsLoading } = useFleetHosts();
const isDeviceStatusLoading = isAgentDevicesLoading || isFleetHostsLoading;

const deviceStatusMap = useMemo(() => {
const map: Record<string, 'compliant' | 'non-compliant' | 'not-installed'> =
{};
const complianceSet = new Set(complianceMemberIds);
for (const id of complianceSet) {
map[id] = 'not-installed';
}

const agentComplianceByMember = new Map<string, boolean>();
for (const d of agentDevices) {
if (!d.memberId || !complianceSet.has(d.memberId)) continue;
const prev = agentComplianceByMember.get(d.memberId);
agentComplianceByMember.set(d.memberId, (prev ?? true) && d.isCompliant);
}
for (const [memberId, allCompliant] of agentComplianceByMember) {
map[memberId] = allCompliant ? 'compliant' : 'non-compliant';
}

for (const host of fleetHosts) {
if (!host.member_id || !complianceSet.has(host.member_id)) continue;
if (agentComplianceByMember.has(host.member_id)) continue;
const isCompliant = host.policies.every((p) => p.response === 'pass');
if (map[host.member_id] !== 'non-compliant') {
map[host.member_id] = isCompliant ? 'compliant' : 'non-compliant';
}
}

return map;
}, [agentDevices, fleetHosts, complianceMemberIds]);
const deviceStatusMap = useMemo(
() => computeDeviceStatusMap({ agentDevices, fleetHosts, complianceMemberIds }),
[agentDevices, fleetHosts, complianceMemberIds],
);
const router = useRouter();
const [searchQuery, setSearchQuery] = useState('');
const [roleFilter, setRoleFilter] = useState('');
Expand Down
Loading
Loading