diff --git a/apps/api/Dockerfile b/apps/api/Dockerfile index 79482f9685..63b08082f5 100644 --- a/apps/api/Dockerfile +++ b/apps/api/Dockerfile @@ -1,8 +1,13 @@ # Use Node.js runtime for production FROM node:20-slim -# Install required packages (wget for healthcheck, curl/bash for bun install, libc compat for prisma) -RUN apt-get update && apt-get install -y --no-install-recommends wget curl openssl && rm -rf /var/lib/apt/lists/* +# Install required packages: +# - wget for healthcheck, curl/bash for bun install, openssl for prisma +# - fontconfig + fonts-dejavu-core so sharp/librsvg can render text in the +# browser-automation screenshot overlay (node:*-slim ships with zero fonts) +RUN apt-get update && apt-get install -y --no-install-recommends \ + wget curl openssl fontconfig fonts-dejavu-core \ + && rm -rf /var/lib/apt/lists/* WORKDIR /app diff --git a/apps/api/Dockerfile.multistage b/apps/api/Dockerfile.multistage index 90e1c5f38d..1830d7c426 100644 --- a/apps/api/Dockerfile.multistage +++ b/apps/api/Dockerfile.multistage @@ -84,8 +84,11 @@ RUN groupadd --system nestjs && useradd --system --gid nestjs --create-home nest WORKDIR /app RUN chown nestjs:nestjs /app -# Install runtime dependencies + AWS RDS CA certificate bundle -RUN apt-get update && apt-get install -y --no-install-recommends wget ca-certificates \ +# Install runtime dependencies + AWS RDS CA certificate bundle. +# fontconfig + fonts-dejavu-core are needed so sharp/librsvg can render text +# in the browser-automation screenshot overlay (node:*-slim has no fonts). +RUN apt-get update && apt-get install -y --no-install-recommends \ + wget ca-certificates fontconfig fonts-dejavu-core \ && wget -q -O /usr/local/share/aws-rds-ca-bundle.pem https://truststore.pki.rds.amazonaws.com/global/global-bundle.pem \ && apt-get clean && rm -rf /var/lib/apt/lists/* diff --git a/apps/api/src/auth/hybrid-auth.guard.ts b/apps/api/src/auth/hybrid-auth.guard.ts index e8a4adc15b..9b65b1defa 100644 --- a/apps/api/src/auth/hybrid-auth.guard.ts +++ b/apps/api/src/auth/hybrid-auth.guard.ts @@ -218,6 +218,9 @@ export class HybridAuthGuard implements CanActivate { request.authType = 'session'; request.isApiKey = false; request.isServiceToken = false; + request.sessionId = sessionData.id; + request.sessionDeviceAgent = + (sessionData as Record).deviceAgent === true; // Resolve isPlatformAdmin from the User.role column (via better-auth session), // not from the member relation. This ensures the flag is set regardless of // org membership or skipOrgCheck. diff --git a/apps/api/src/auth/types.ts b/apps/api/src/auth/types.ts index 4e7933d53e..2e0b604d7d 100644 --- a/apps/api/src/auth/types.ts +++ b/apps/api/src/auth/types.ts @@ -16,6 +16,8 @@ export interface AuthenticatedRequest extends Request { memberDepartment?: Departments; // Member department for visibility filtering (only available for session auth) apiKeyScopes?: string[]; // Scopes for API key auth (empty = legacy full access) impersonatedBy?: string; // User ID of the admin who initiated impersonation (only set during impersonation sessions) + sessionId?: string; // Session ID (only set for session auth) + sessionDeviceAgent?: boolean; // Whether the session is a device-agent session (only set for session auth) } export interface AuthContext { diff --git a/apps/api/src/browserbase/screenshot-overlay.ts b/apps/api/src/browserbase/screenshot-overlay.ts index 04e8805942..27d09b3c80 100644 --- a/apps/api/src/browserbase/screenshot-overlay.ts +++ b/apps/api/src/browserbase/screenshot-overlay.ts @@ -115,8 +115,10 @@ function buildBannerSvg(args: BannerArgs): string { const { width, height, instruction, sourceUrl, timestamp } = args; const rowFontSize = 12; const labelFontSize = 9; - const fontFamily = - '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif'; + // Explicit Linux-safe font stack. The production container only ships + // DejaVu Sans (via fonts-dejavu-core); Apple/Segoe/Roboto aren't available + // so librsvg would render .notdef glyphs ("□ □ □") on servers. + const fontFamily = '"DejaVu Sans", sans-serif'; const w = Math.floor(width); const h = Math.floor(height); diff --git a/apps/api/src/device-agent/device-agent-auth.service.spec.ts b/apps/api/src/device-agent/device-agent-auth.service.spec.ts index e8c3c5f199..c5e3c4e484 100644 --- a/apps/api/src/device-agent/device-agent-auth.service.spec.ts +++ b/apps/api/src/device-agent/device-agent-auth.service.spec.ts @@ -4,6 +4,7 @@ import { UnauthorizedException, } from '@nestjs/common'; import { DeviceAgentAuthService } from './device-agent-auth.service'; +import { CheckResultDto } from './dto/check-in.dto'; // Mock dependencies jest.mock('@db', () => ({ @@ -19,6 +20,9 @@ jest.mock('@db', () => ({ update: jest.fn(), findMany: jest.fn(), }, + session: { + delete: jest.fn(), + }, }, Prisma: { InputJsonValue: {}, @@ -40,15 +44,24 @@ jest.mock('./device-agent-kv', () => ({ }, })); +jest.mock('./device-agent-session.helper', () => ({ + createDeviceAgentSession: jest.fn(), +})); + import { db } from '@db'; import { auth } from '../auth/auth.server'; import { deviceAgentRedisClient } from './device-agent-kv'; +import { createDeviceAgentSession } from './device-agent-session.helper'; const mockDb = db as jest.Mocked; const mockAuth = auth as jest.Mocked; const mockKv = deviceAgentRedisClient as jest.Mocked< typeof deviceAgentRedisClient >; +const mockCreateDeviceAgentSession = + createDeviceAgentSession as jest.MockedFunction< + typeof createDeviceAgentSession + >; describe('DeviceAgentAuthService', () => { let service: DeviceAgentAuthService; @@ -62,7 +75,6 @@ describe('DeviceAgentAuthService', () => { it('should generate an auth code and store it in KV', async () => { (mockAuth.api.getSession as unknown as jest.Mock).mockResolvedValue({ user: { id: 'user-1' }, - session: { token: 'raw-session-token' }, }); const headers = new Headers(); @@ -77,7 +89,6 @@ describe('DeviceAgentAuthService', () => { expect(mockKv.set).toHaveBeenCalledWith( expect.stringMatching(/^device-auth:/), expect.objectContaining({ - sessionToken: 'raw-session-token', userId: 'user-1', state: 'test-state', }), @@ -96,29 +107,57 @@ describe('DeviceAgentAuthService', () => { }); describe('exchangeCode', () => { - it('should return session token for valid code', async () => { - (mockKv.getdel as jest.Mock).mockResolvedValue({ - sessionToken: 'session-123', - userId: 'user-1', - state: 'state-1', + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('calls createDeviceAgentSession with stored userId and returns its token', async () => { + (mockKv.getdel as jest.Mock).mockResolvedValueOnce({ + userId: 'usr_1', + state: 'state-abc', createdAt: Date.now(), }); + mockCreateDeviceAgentSession.mockResolvedValueOnce({ + sessionId: 'sess_new', + token: 'tok_new', + expiresAt: new Date(Date.now() + 365 * 24 * 60 * 60 * 1000), + }); - const result = await service.exchangeCode({ code: 'valid-code' }); + const result = await service.exchangeCode({ code: 'code-abc' }); + expect(mockKv.getdel).toHaveBeenCalledWith('device-auth:code-abc'); + expect(mockCreateDeviceAgentSession).toHaveBeenCalledWith({ + userId: 'usr_1', + }); expect(result).toEqual({ - session_token: 'session-123', - user_id: 'user-1', + session_token: 'tok_new', + user_id: 'usr_1', }); - expect(mockKv.getdel).toHaveBeenCalledWith('device-auth:valid-code'); }); - it('should throw UnauthorizedException for invalid/expired code', async () => { - (mockKv.getdel as jest.Mock).mockResolvedValue(null); + it('throws UnauthorizedException with correct message for invalid/expired code', async () => { + (mockKv.getdel as jest.Mock).mockResolvedValueOnce(null); await expect( service.exchangeCode({ code: 'invalid-code' }), ).rejects.toThrow(UnauthorizedException); + await expect( + service.exchangeCode({ code: 'invalid-code' }), + ).rejects.toThrow('Invalid or expired authorization code'); + }); + + it('propagates rejection from createDeviceAgentSession without swallowing', async () => { + (mockKv.getdel as jest.Mock).mockResolvedValue({ + userId: 'usr_1', + state: 'state-xyz', + createdAt: Date.now(), + }); + const helperError = new Error('session creation failed'); + mockCreateDeviceAgentSession.mockRejectedValueOnce(helperError); + + await expect( + service.exchangeCode({ code: 'code-xyz' }), + ).rejects.toThrow(helperError); }); }); @@ -166,7 +205,11 @@ describe('DeviceAgentAuthService', () => { (mockDb.member.findFirst as jest.Mock).mockResolvedValue(null); await expect( - service.registerDevice({ userId: 'user-1', dto: baseDto }), + service.registerDevice({ + userId: 'user-1', + sessionId: 'ses-1', + dto: baseDto, + }), ).rejects.toThrow(ForbiddenException); }); @@ -175,10 +218,18 @@ describe('DeviceAgentAuthService', () => { id: 'member-1', }); (mockDb.device.findFirst as jest.Mock).mockResolvedValue(null); - (mockDb.device.create as jest.Mock).mockResolvedValue({ id: 'dev-1' }); + (mockDb.device.create as jest.Mock).mockResolvedValue({ + id: 'dev-1', + agentSessionId: null, + }); + (mockDb.device.update as jest.Mock).mockResolvedValue({ + id: 'dev-1', + agentSessionId: 'ses-1', + }); const result = await service.registerDevice({ userId: 'user-1', + sessionId: 'ses-1', dto: baseDto, }); @@ -198,10 +249,21 @@ describe('DeviceAgentAuthService', () => { id: 'member-1', }); (mockDb.device.findUnique as jest.Mock).mockResolvedValue(null); - (mockDb.device.create as jest.Mock).mockResolvedValue({ id: 'dev-2' }); + (mockDb.device.create as jest.Mock).mockResolvedValue({ + id: 'dev-2', + agentSessionId: null, + }); + (mockDb.device.update as jest.Mock).mockResolvedValue({ + id: 'dev-2', + agentSessionId: 'ses-1', + }); const dto = { ...baseDto, serialNumber: 'ABC123' }; - const result = await service.registerDevice({ userId: 'user-1', dto }); + const result = await service.registerDevice({ + userId: 'user-1', + sessionId: 'ses-1', + dto, + }); expect(result).toEqual({ deviceId: 'dev-2' }); }); @@ -214,12 +276,22 @@ describe('DeviceAgentAuthService', () => { id: 'dev-existing', memberId: 'member-1', }); - (mockDb.device.update as jest.Mock).mockResolvedValue({ - id: 'dev-existing', - }); + (mockDb.device.update as jest.Mock) + .mockResolvedValueOnce({ + id: 'dev-existing', + agentSessionId: null, + }) + .mockResolvedValueOnce({ + id: 'dev-existing', + agentSessionId: 'ses-1', + }); const dto = { ...baseDto, serialNumber: 'ABC123' }; - const result = await service.registerDevice({ userId: 'user-1', dto }); + const result = await service.registerDevice({ + userId: 'user-1', + sessionId: 'ses-1', + dto, + }); expect(result).toEqual({ deviceId: 'dev-existing' }); expect(mockDb.device.update).toHaveBeenCalled(); @@ -237,10 +309,19 @@ describe('DeviceAgentAuthService', () => { (mockDb.device.findFirst as jest.Mock).mockResolvedValue(null); (mockDb.device.create as jest.Mock).mockResolvedValue({ id: 'dev-fallback', + agentSessionId: null, + }); + (mockDb.device.update as jest.Mock).mockResolvedValue({ + id: 'dev-fallback', + agentSessionId: 'ses-1', }); const dto = { ...baseDto, serialNumber: 'GENERIC-SERIAL' }; - const result = await service.registerDevice({ userId: 'user-1', dto }); + const result = await service.registerDevice({ + userId: 'user-1', + sessionId: 'ses-1', + dto, + }); expect(result).toEqual({ deviceId: 'dev-fallback' }); expect(mockDb.device.create).toHaveBeenCalledWith({ @@ -251,10 +332,138 @@ describe('DeviceAgentAuthService', () => { }); }); + describe('registerDevice (device-agent session linkage)', () => { + const baseDto = { + name: 'My Mac', + hostname: 'macbook.local', + platform: 'macos' as const, + osVersion: '14.0', + organizationId: 'org-1', + }; + + it('fresh device: links new session and does not delete stale session', async () => { + (mockDb.member.findFirst as jest.Mock).mockResolvedValue({ + id: 'member-1', + }); + (mockDb.device.findFirst as jest.Mock).mockResolvedValue(null); + (mockDb.device.create as jest.Mock).mockResolvedValue({ + id: 'dev_1', + agentSessionId: null, + }); + (mockDb.device.update as jest.Mock).mockResolvedValue({ + id: 'dev_1', + agentSessionId: 'ses_new', + }); + + const result = await service.registerDevice({ + userId: 'user-1', + sessionId: 'ses_new', + dto: baseDto, + }); + + expect(result).toEqual({ deviceId: 'dev_1' }); + expect(mockDb.device.update).toHaveBeenCalledWith({ + where: { id: 'dev_1' }, + data: { agentSessionId: 'ses_new' }, + }); + expect(mockDb.session.delete).not.toHaveBeenCalled(); + }); + + it('reinstall: deletes stale session and links new session', async () => { + (mockDb.member.findFirst as jest.Mock).mockResolvedValue({ + id: 'member-1', + }); + (mockDb.device.findFirst as jest.Mock).mockResolvedValue(null); + (mockDb.device.create as jest.Mock).mockResolvedValue({ + id: 'dev_1', + agentSessionId: 'ses_stale', + }); + (mockDb.session.delete as jest.Mock).mockResolvedValue({ id: 'ses_stale' }); + (mockDb.device.update as jest.Mock).mockResolvedValue({ + id: 'dev_1', + agentSessionId: 'ses_new', + }); + + await service.registerDevice({ + userId: 'user-1', + sessionId: 'ses_new', + dto: baseDto, + }); + + expect(mockDb.session.delete).toHaveBeenCalledWith({ + where: { id: 'ses_stale' }, + }); + expect(mockDb.device.update).toHaveBeenCalledWith({ + where: { id: 'dev_1' }, + data: { agentSessionId: 'ses_new' }, + }); + }); + + it('same-session re-registration: does not delete the session when the device is already linked to the same session', async () => { + (mockDb.member.findFirst as jest.Mock).mockResolvedValue({ + id: 'member-1', + }); + (mockDb.device.findFirst as jest.Mock).mockResolvedValue(null); + (mockDb.device.create as jest.Mock).mockResolvedValue({ + id: 'dev_1', + agentSessionId: 'ses_same', + }); + (mockDb.device.update as jest.Mock).mockResolvedValue({ + id: 'dev_1', + agentSessionId: 'ses_same', + }); + + await service.registerDevice({ + userId: 'user-1', + sessionId: 'ses_same', + dto: baseDto, + }); + + expect(mockDb.session.delete).not.toHaveBeenCalled(); + expect(mockDb.device.update).toHaveBeenCalledWith({ + where: { id: 'dev_1' }, + data: { agentSessionId: 'ses_same' }, + }); + }); + + it('idempotent reinstall: swallows P2025 when stale session already gone', async () => { + (mockDb.member.findFirst as jest.Mock).mockResolvedValue({ + id: 'member-1', + }); + (mockDb.device.findFirst as jest.Mock).mockResolvedValue(null); + (mockDb.device.create as jest.Mock).mockResolvedValue({ + id: 'dev_1', + agentSessionId: 'ses_stale', + }); + const p2025Error = Object.assign(new Error('Record not found'), { + code: 'P2025', + }); + (mockDb.session.delete as jest.Mock).mockRejectedValue(p2025Error); + (mockDb.device.update as jest.Mock).mockResolvedValue({ + id: 'dev_1', + agentSessionId: 'ses_new', + }); + + await expect( + service.registerDevice({ + userId: 'user-1', + sessionId: 'ses_new', + dto: baseDto, + }), + ).resolves.toEqual({ deviceId: 'dev_1' }); + + expect(mockDb.device.update).toHaveBeenCalledWith({ + where: { id: 'dev_1' }, + data: { agentSessionId: 'ses_new' }, + }); + }); + }); + describe('checkIn', () => { it('should update device compliance fields', async () => { (mockDb.device.findFirst as jest.Mock).mockResolvedValue({ id: 'dev-1', + agentSessionId: 'ses-1', diskEncryptionEnabled: false, antivirusEnabled: false, passwordPolicySet: false, @@ -267,6 +476,8 @@ describe('DeviceAgentAuthService', () => { const result = await service.checkIn({ userId: 'user-1', + sessionId: 'ses-1', + sessionDeviceAgent: true, dto: { deviceId: 'dev-1', checks: [ @@ -314,6 +525,8 @@ describe('DeviceAgentAuthService', () => { await expect( service.checkIn({ userId: 'user-1', + sessionId: 'ses-1', + sessionDeviceAgent: true, dto: { deviceId: 'dev-missing', checks: [ @@ -331,6 +544,7 @@ describe('DeviceAgentAuthService', () => { it('should set isCompliant to false when not all checks pass', async () => { (mockDb.device.findFirst as jest.Mock).mockResolvedValue({ id: 'dev-1', + agentSessionId: 'ses-1', diskEncryptionEnabled: false, antivirusEnabled: false, passwordPolicySet: false, @@ -343,6 +557,8 @@ describe('DeviceAgentAuthService', () => { const result = await service.checkIn({ userId: 'user-1', + sessionId: 'ses-1', + sessionDeviceAgent: true, dto: { deviceId: 'dev-1', checks: [ @@ -364,6 +580,267 @@ describe('DeviceAgentAuthService', () => { }); }); + describe('checkIn (silent upgrade)', () => { + const baseChecks: CheckResultDto[] = [ + { + checkType: 'disk_encryption', + passed: true, + checkedAt: new Date().toISOString(), + }, + { + checkType: 'antivirus', + passed: true, + checkedAt: new Date().toISOString(), + }, + { + checkType: 'password_policy', + passed: true, + checkedAt: new Date().toISOString(), + }, + { + checkType: 'screen_lock', + passed: true, + checkedAt: new Date().toISOString(), + }, + ]; + + it('fresh device (agentSessionId: null) → upgrade fires, delete NOT called, token returned', async () => { + (mockDb.device.findFirst as jest.Mock).mockResolvedValue({ + id: 'dev-1', + agentSessionId: null, + diskEncryptionEnabled: false, + antivirusEnabled: false, + passwordPolicySet: false, + screenLockEnabled: false, + checkDetails: {}, + }); + (mockDb.device.update as jest.Mock).mockResolvedValue({ id: 'dev-1' }); + mockCreateDeviceAgentSession.mockResolvedValueOnce({ + sessionId: 'ses_new', + token: 'tok_new', + expiresAt: new Date(Date.now() + 365 * 24 * 60 * 60 * 1000), + }); + + const result = await service.checkIn({ + userId: 'user-1', + sessionId: 'ses_old_web', + sessionDeviceAgent: false, + dto: { deviceId: 'dev-1', checks: baseChecks }, + }); + + expect(result.upgradedSessionToken).toBe('tok_new'); + expect(mockCreateDeviceAgentSession).toHaveBeenCalledWith({ + userId: 'user-1', + }); + expect(mockDb.session.delete).not.toHaveBeenCalled(); + expect(mockDb.device.update).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: 'dev-1' }, + data: expect.objectContaining({ agentSessionId: 'ses_new' }), + }), + ); + }); + + it('device with stale link → stale session deleted, new session minted, agentSessionId updated', async () => { + (mockDb.device.findFirst as jest.Mock).mockResolvedValue({ + id: 'dev-1', + agentSessionId: 'ses_stale', + diskEncryptionEnabled: false, + antivirusEnabled: false, + passwordPolicySet: false, + screenLockEnabled: false, + checkDetails: {}, + }); + (mockDb.session.delete as jest.Mock).mockResolvedValue({ id: 'ses_stale' }); + (mockDb.device.update as jest.Mock).mockResolvedValue({ id: 'dev-1' }); + mockCreateDeviceAgentSession.mockResolvedValueOnce({ + sessionId: 'ses_new', + token: 'tok_new', + expiresAt: new Date(Date.now() + 365 * 24 * 60 * 60 * 1000), + }); + + const result = await service.checkIn({ + userId: 'user-1', + sessionId: 'ses_old_web', + sessionDeviceAgent: false, + dto: { deviceId: 'dev-1', checks: baseChecks }, + }); + + expect(mockDb.session.delete).toHaveBeenCalledWith({ + where: { id: 'ses_stale' }, + }); + expect(result.upgradedSessionToken).toBe('tok_new'); + expect(mockCreateDeviceAgentSession).toHaveBeenCalledWith({ + userId: 'user-1', + }); + expect(mockDb.device.update).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: 'dev-1' }, + data: expect.objectContaining({ agentSessionId: 'ses_new' }), + }), + ); + }); + + it('stale delete throws P2025 → method still resolves, new session still minted', async () => { + (mockDb.device.findFirst as jest.Mock).mockResolvedValue({ + id: 'dev-1', + agentSessionId: 'ses_stale', + diskEncryptionEnabled: false, + antivirusEnabled: false, + passwordPolicySet: false, + screenLockEnabled: false, + checkDetails: {}, + }); + const p2025Error = Object.assign(new Error('Record not found'), { + code: 'P2025', + }); + (mockDb.session.delete as jest.Mock).mockRejectedValueOnce(p2025Error); + (mockDb.device.update as jest.Mock).mockResolvedValue({ id: 'dev-1' }); + mockCreateDeviceAgentSession.mockResolvedValueOnce({ + sessionId: 'ses_new', + token: 'tok_new', + expiresAt: new Date(Date.now() + 365 * 24 * 60 * 60 * 1000), + }); + + const result = await service.checkIn({ + userId: 'user-1', + sessionId: 'ses_old_web', + sessionDeviceAgent: false, + dto: { deviceId: 'dev-1', checks: baseChecks }, + }); + + expect(result.upgradedSessionToken).toBe('tok_new'); + expect(mockCreateDeviceAgentSession).toHaveBeenCalledWith({ + userId: 'user-1', + }); + expect(mockDb.device.update).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: 'dev-1' }, + data: expect.objectContaining({ agentSessionId: 'ses_new' }), + }), + ); + }); + + it('device-agent session already linked → no upgrade, no extra update', async () => { + (mockDb.device.findFirst as jest.Mock).mockResolvedValue({ + id: 'dev-1', + agentSessionId: 'ses_new', + diskEncryptionEnabled: true, + antivirusEnabled: true, + passwordPolicySet: true, + screenLockEnabled: true, + checkDetails: {}, + }); + (mockDb.device.update as jest.Mock).mockResolvedValue({ id: 'dev-1' }); + + const result = await service.checkIn({ + userId: 'user-1', + sessionId: 'ses_new', + sessionDeviceAgent: true, + dto: { deviceId: 'dev-1', checks: baseChecks }, + }); + + expect(result.upgradedSessionToken).toBeUndefined(); + expect(mockCreateDeviceAgentSession).not.toHaveBeenCalled(); + expect(mockDb.device.update).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: 'dev-1' }, + data: expect.not.objectContaining({ agentSessionId: expect.anything() }), + }), + ); + }); + + it('device-agent session, device not yet linked → backfill agentSessionId', async () => { + (mockDb.device.findFirst as jest.Mock).mockResolvedValue({ + id: 'dev-1', + agentSessionId: null, + diskEncryptionEnabled: false, + antivirusEnabled: false, + passwordPolicySet: false, + screenLockEnabled: false, + checkDetails: {}, + }); + (mockDb.device.update as jest.Mock).mockResolvedValue({ id: 'dev-1' }); + + const result = await service.checkIn({ + userId: 'user-1', + sessionId: 'ses_new', + sessionDeviceAgent: true, + dto: { deviceId: 'dev-1', checks: baseChecks }, + }); + + expect(result.upgradedSessionToken).toBeUndefined(); + expect(mockCreateDeviceAgentSession).not.toHaveBeenCalled(); + expect(mockDb.device.update).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: 'dev-1' }, + data: expect.objectContaining({ agentSessionId: 'ses_new' }), + }), + ); + }); + }); + + describe('revokeAgentAccess', () => { + it('happy path: deletes session for device in org', async () => { + (mockDb.device.findFirst as jest.Mock).mockResolvedValue({ + id: 'dev_1', + agentSessionId: 'ses_new', + }); + (mockDb.session.delete as jest.Mock).mockResolvedValue({ id: 'ses_new' }); + + await expect( + service.revokeAgentAccess({ organizationId: 'org_1', deviceId: 'dev_1' }), + ).resolves.toBeUndefined(); + + expect(mockDb.device.findFirst).toHaveBeenCalledWith({ + where: { id: 'dev_1', organizationId: 'org_1' }, + select: { id: true, agentSessionId: true }, + }); + expect(mockDb.session.delete).toHaveBeenCalledWith({ + where: { id: 'ses_new' }, + }); + }); + + it('idempotent: resolves without calling session.delete when agentSessionId is null', async () => { + (mockDb.device.findFirst as jest.Mock).mockResolvedValue({ + id: 'dev_1', + agentSessionId: null, + }); + + await expect( + service.revokeAgentAccess({ organizationId: 'org_1', deviceId: 'dev_1' }), + ).resolves.toBeUndefined(); + + expect(mockDb.session.delete).not.toHaveBeenCalled(); + }); + + it('throws NotFoundException when device not found in org', async () => { + (mockDb.device.findFirst as jest.Mock).mockResolvedValue(null); + + await expect( + service.revokeAgentAccess({ organizationId: 'org_1', deviceId: 'dev_missing' }), + ).rejects.toThrow(NotFoundException); + await expect( + service.revokeAgentAccess({ organizationId: 'org_1', deviceId: 'dev_missing' }), + ).rejects.toThrow('Device not found'); + }); + + it('P2025 race: swallows error when session already deleted', async () => { + (mockDb.device.findFirst as jest.Mock).mockResolvedValue({ + id: 'dev_1', + agentSessionId: 'ses_gone', + }); + const p2025Error = Object.assign(new Error('Record not found'), { + code: 'P2025', + }); + (mockDb.session.delete as jest.Mock).mockRejectedValue(p2025Error); + + await expect( + service.revokeAgentAccess({ organizationId: 'org_1', deviceId: 'dev_1' }), + ).resolves.toBeUndefined(); + }); + }); + describe('getDeviceStatus', () => { it('should return all devices when no deviceId specified', async () => { const devices = [ diff --git a/apps/api/src/device-agent/device-agent-auth.service.ts b/apps/api/src/device-agent/device-agent-auth.service.ts index c81682e026..4af0026a3b 100644 --- a/apps/api/src/device-agent/device-agent-auth.service.ts +++ b/apps/api/src/device-agent/device-agent-auth.service.ts @@ -9,6 +9,7 @@ import { randomBytes } from 'node:crypto'; import { db, Prisma } from '@db'; import { auth } from '../auth/auth.server'; import { deviceAgentRedisClient } from './device-agent-kv'; +import { createDeviceAgentSession } from './device-agent-session.helper'; import { registerWithSerial, registerWithoutSerial, @@ -17,7 +18,6 @@ import { RegisterDeviceDto } from './dto/register-device.dto'; import { CheckInDto } from './dto/check-in.dto'; interface StoredAuthCode { - sessionToken: string; userId: string; state: string; createdAt: number; @@ -52,7 +52,6 @@ export class DeviceAgentAuthService { await deviceAgentRedisClient.set( `device-auth:${code}`, { - sessionToken: session.session.token, userId: session.user.id, state, createdAt: Date.now(), @@ -72,8 +71,10 @@ export class DeviceAgentAuthService { throw new UnauthorizedException('Invalid or expired authorization code'); } + const session = await createDeviceAgentSession({ userId: stored.userId }); + return { - session_token: stored.sessionToken, + session_token: session.token, user_id: stored.userId, }; } @@ -100,9 +101,11 @@ export class DeviceAgentAuthService { async registerDevice({ userId, + sessionId, dto, }: { userId: string; + sessionId: string; dto: RegisterDeviceDto; }) { const member = await db.member.findFirst({ @@ -121,10 +124,38 @@ export class DeviceAgentAuthService { ? await registerWithSerial({ member, dto }) : await registerWithoutSerial({ member, dto }); + if (device.agentSessionId && device.agentSessionId !== sessionId) { + try { + await db.session.delete({ where: { id: device.agentSessionId } }); + } catch (err) { + if ((err as { code?: string }).code !== 'P2025') { + this.logger.error( + `Failed to delete stale agent session ${device.agentSessionId} for device ${device.id}: ${err}`, + ); + throw err; + } + } + } + + await db.device.update({ + where: { id: device.id }, + data: { agentSessionId: sessionId }, + }); + return { deviceId: device.id }; } - async checkIn({ userId, dto }: { userId: string; dto: CheckInDto }) { + async checkIn({ + userId, + sessionId, + sessionDeviceAgent, + dto, + }: { + userId: string; + sessionId: string; + sessionDeviceAgent: boolean; + dto: CheckInDto; + }) { const device = await db.device.findFirst({ where: { id: dto.deviceId, @@ -164,6 +195,29 @@ export class DeviceAgentAuthService { checkFields.passwordPolicySet && checkFields.screenLockEnabled; + let upgradedSessionToken: string | undefined; + let sessionIdToLink: string | undefined; + + if (!sessionDeviceAgent) { + if (device.agentSessionId) { + try { + await db.session.delete({ where: { id: device.agentSessionId } }); + } catch (err) { + if ((err as { code?: string }).code !== 'P2025') { + this.logger.error( + `Failed to delete stale agent session ${device.agentSessionId} for device ${device.id}: ${err}`, + ); + throw err; + } + } + } + const upgraded = await createDeviceAgentSession({ userId }); + upgradedSessionToken = upgraded.token; + sessionIdToLink = upgraded.sessionId; + } else if (device.agentSessionId !== sessionId) { + sessionIdToLink = sessionId; + } + await db.device.update({ where: { id: dto.deviceId }, data: { @@ -172,15 +226,49 @@ export class DeviceAgentAuthService { isCompliant, lastCheckIn: new Date(), ...(dto.agentVersion ? { agentVersion: dto.agentVersion } : {}), + ...(sessionIdToLink !== undefined ? { agentSessionId: sessionIdToLink } : {}), }, }); return { isCompliant, nextCheckIn: new Date(Date.now() + 60 * 60 * 1000).toISOString(), + ...(upgradedSessionToken ? { upgradedSessionToken } : {}), }; } + async revokeAgentAccess({ + organizationId, + deviceId, + }: { + organizationId: string; + deviceId: string; + }): Promise { + const device = await db.device.findFirst({ + where: { id: deviceId, organizationId }, + select: { id: true, agentSessionId: true }, + }); + + if (!device) { + throw new NotFoundException('Device not found'); + } + + if (!device.agentSessionId) { + return; + } + + try { + await db.session.delete({ where: { id: device.agentSessionId } }); + } catch (err) { + if ((err as { code?: string }).code !== 'P2025') { + this.logger.error( + `Failed to revoke agent session ${device.agentSessionId} for device ${device.id}: ${err}`, + ); + throw err; + } + } + } + async getDeviceStatus({ userId, deviceId, diff --git a/apps/api/src/device-agent/device-agent-session.helper.spec.ts b/apps/api/src/device-agent/device-agent-session.helper.spec.ts new file mode 100644 index 0000000000..1310e664e7 --- /dev/null +++ b/apps/api/src/device-agent/device-agent-session.helper.spec.ts @@ -0,0 +1,50 @@ +import { createDeviceAgentSession, DEVICE_AGENT_SESSION_TTL_MS } from './device-agent-session.helper'; + +const createSessionMock = jest.fn(); + +jest.mock('../auth/auth.server', () => ({ + auth: { + $context: Promise.resolve({ + internalAdapter: { + createSession: (...args: unknown[]) => createSessionMock(...args), + }, + }), + }, +})); + +const FIXED_NOW = new Date('2026-04-22T00:00:00.000Z'); + +describe('createDeviceAgentSession', () => { + beforeEach(() => { + jest.useFakeTimers().setSystemTime(FIXED_NOW); + createSessionMock.mockReset(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('calls better-auth internalAdapter.createSession with deviceAgent=true and 1-year expiry', async () => { + createSessionMock.mockResolvedValue({ + id: 'ses_123', + token: 'tok_abc', + userId: 'usr_1', + expiresAt: new Date(FIXED_NOW.getTime() + DEVICE_AGENT_SESSION_TTL_MS), + }); + + const result = await createDeviceAgentSession({ userId: 'usr_1' }); + + expect(createSessionMock).toHaveBeenCalledTimes(1); + const [userId, dontRememberMe, override, overrideAll] = createSessionMock.mock.calls[0]; + expect(userId).toBe('usr_1'); + expect(dontRememberMe).toBe(false); + expect(override).toMatchObject({ deviceAgent: true }); + expect(override.expiresAt).toEqual(new Date(FIXED_NOW.getTime() + DEVICE_AGENT_SESSION_TTL_MS)); + expect(overrideAll).toBe(true); + expect(result).toEqual({ + sessionId: 'ses_123', + token: 'tok_abc', + expiresAt: new Date(FIXED_NOW.getTime() + DEVICE_AGENT_SESSION_TTL_MS), + }); + }); +}); diff --git a/apps/api/src/device-agent/device-agent-session.helper.ts b/apps/api/src/device-agent/device-agent-session.helper.ts new file mode 100644 index 0000000000..55a2f97af9 --- /dev/null +++ b/apps/api/src/device-agent/device-agent-session.helper.ts @@ -0,0 +1,43 @@ +import { auth } from '../auth/auth.server'; + +/** One year in milliseconds. */ +export const DEVICE_AGENT_SESSION_TTL_MS = 365 * 24 * 60 * 60 * 1000; + +export interface CreatedDeviceAgentSession { + sessionId: string; + token: string; + expiresAt: Date; +} + +/** + * Create a dedicated long-lived session for a device agent. + * + * Delegates to better-auth's internal session adapter so `databaseHooks`, + * the organization plugin's `activeOrganizationId` setter, multiSession + * tracking, and any secondary storage all run as they do for normal logins. + * + * We pass `overrideAll: true` because better-auth's default path in + * internal-adapter.mjs explicitly overwrites `expiresAt` with the config + * default unless `overrideAll` is set. + */ +export async function createDeviceAgentSession({ + userId, +}: { + userId: string; +}): Promise { + const ctx = await auth.$context; + const expiresAt = new Date(Date.now() + DEVICE_AGENT_SESSION_TTL_MS); + + const session = await ctx.internalAdapter.createSession( + userId, + false, + { expiresAt, deviceAgent: true }, + true, + ); + + return { + sessionId: session.id, + token: session.token, + expiresAt: session.expiresAt, + }; +} diff --git a/apps/api/src/device-agent/device-agent.controller.ts b/apps/api/src/device-agent/device-agent.controller.ts index 25d3f95d0a..e0975296f7 100644 --- a/apps/api/src/device-agent/device-agent.controller.ts +++ b/apps/api/src/device-agent/device-agent.controller.ts @@ -1,14 +1,18 @@ import { Body, Controller, + Delete, Get, Head, + HttpCode, + HttpStatus, Param, Post, Query, Req, Response, StreamableFile, + UnauthorizedException, UseGuards, } from '@nestjs/common'; import { @@ -27,7 +31,7 @@ import { PermissionGuard } from '../auth/permission.guard'; import { Public } from '../auth/public.decorator'; import { RequirePermission } from '../auth/require-permission.decorator'; import { SkipOrgCheck } from '../auth/skip-org-check.decorator'; -import type { AuthContext as AuthContextType } from '../auth/types'; +import type { AuthContext as AuthContextType, AuthenticatedRequest } from '../auth/types'; import { DeviceAgentAuthService } from './device-agent-auth.service'; import { DeviceAgentService } from './device-agent.service'; import { AuthCodeDto } from './dto/auth-code.dto'; @@ -130,18 +134,36 @@ export class DeviceAgentController { @SkipOrgCheck() @ApiOperation({ summary: 'Register a device agent' }) async registerDevice( + @Req() req: AuthenticatedRequest, @UserId() userId: string, @Body() dto: RegisterDeviceDto, ) { - return this.deviceAgentAuthService.registerDevice({ userId, dto }); + const { sessionId } = req; + if (!sessionId) { + throw new UnauthorizedException('Session ID missing from request'); + } + return this.deviceAgentAuthService.registerDevice({ userId, sessionId, dto }); } @Post('check-in') @UseGuards(HybridAuthGuard) @SkipOrgCheck() @ApiOperation({ summary: 'Submit a device check-in' }) - async checkIn(@UserId() userId: string, @Body() dto: CheckInDto) { - return this.deviceAgentAuthService.checkIn({ userId, dto }); + async checkIn( + @Req() req: AuthenticatedRequest, + @UserId() userId: string, + @Body() dto: CheckInDto, + ) { + const { sessionId, sessionDeviceAgent } = req; + if (!sessionId) { + throw new UnauthorizedException('Session ID missing from request'); + } + return this.deviceAgentAuthService.checkIn({ + userId, + sessionId, + sessionDeviceAgent: sessionDeviceAgent ?? false, + dto, + }); } @Get('status') @@ -217,4 +239,19 @@ export class DeviceAgentController { return new StreamableFile(stream); } + + @Delete('sessions/:deviceId') + @UseGuards(HybridAuthGuard, PermissionGuard) + @RequirePermission('member', 'update') + @HttpCode(HttpStatus.NO_CONTENT) + @ApiOperation({ summary: 'Revoke a device agent session' }) + async revokeAgentAccess( + @OrganizationId() organizationId: string, + @Param('deviceId') deviceId: string, + ) { + await this.deviceAgentAuthService.revokeAgentAccess({ + organizationId, + deviceId, + }); + } } diff --git a/apps/api/src/frameworks/framework-versioning/form-type-normalize.ts b/apps/api/src/frameworks/framework-versioning/form-type-normalize.ts new file mode 100644 index 0000000000..c54a3c77cc --- /dev/null +++ b/apps/api/src/frameworks/framework-versioning/form-type-normalize.ts @@ -0,0 +1,24 @@ +/** + * EvidenceFormType has `@map` directives that expose hyphenated labels at the + * DB level (e.g. `"infrastructure-inventory"`) while the Prisma client uses + * underscored names (e.g. `"infrastructure_inventory"`). + * + * The one-shot backfill data migration + * (20260423121434_backfill_framework_versions) serialized doc types via + * `to_jsonb(ct."documentTypes"::text[])`, which renders the DB @map'd form. + * So every backfilled v1.0.0 manifest stored hyphens. Newer manifests built + * through the TS manifest builder store underscores (what Prisma client + * returns). + * + * Any code that passes `formType` from a manifest into the Prisma client + * must normalize first, otherwise Prisma throws + * `PrismaClientValidationError: Invalid value for argument `formType`` on + * the hyphen form. + * + * Every `@map` in the schema just swaps `_` → `-`, so hyphens-to-underscores + * covers all existing values and any future ones CX adds — no hardcoded list + * to keep in sync. + */ +export function normalizeFormType(value: string): string { + return value.replace(/-/g, '_'); +} diff --git a/apps/api/src/frameworks/framework-versioning/framework-diff.ts b/apps/api/src/frameworks/framework-versioning/framework-diff.ts index 97b491faa2..0901263eb6 100644 --- a/apps/api/src/frameworks/framework-versioning/framework-diff.ts +++ b/apps/api/src/frameworks/framework-versioning/framework-diff.ts @@ -1,3 +1,4 @@ +import { normalizeFormType } from './form-type-normalize'; import type { FrameworkManifest, ManifestControl, @@ -67,6 +68,15 @@ function sanitizeManifestEdges(m: FrameworkManifest): FrameworkManifest { requirementIds: c.requirementIds.filter((id) => reqIds.has(id)), policyIds: c.policyIds.filter((id) => policyIds.has(id)), taskIds: c.taskIds.filter((id) => taskIds.has(id)), + // Normalize formTypes to the Prisma-client form (underscored). Backfilled + // v1.0.0 manifests stored DB-mapped hyphen forms; collapsing both forms + // to the canonical name here means the diff doesn't spuriously report + // an add+remove for identical types, and downstream callers never see + // the hyphenated shape. Preserve undefined-ness when the input didn't + // carry documentTypes at all (older manifest shape). + ...(c.documentTypes === undefined + ? {} + : { documentTypes: c.documentTypes.map(normalizeFormType) }), })), }; } diff --git a/apps/api/src/frameworks/framework-versioning/framework-rollback.service.ts b/apps/api/src/frameworks/framework-versioning/framework-rollback.service.ts index 4e941f2fb7..f376d4fcfe 100644 --- a/apps/api/src/frameworks/framework-versioning/framework-rollback.service.ts +++ b/apps/api/src/frameworks/framework-versioning/framework-rollback.service.ts @@ -1,6 +1,7 @@ import { BadRequestException, ForbiddenException, Injectable, NotFoundException } from '@nestjs/common'; import { db, Prisma, Frequency, Departments } from '@db'; import { lockOrganizationForSync } from './org-advisory-lock'; +import { normalizeFormType } from './form-type-normalize'; import type { UndoPayload } from './undo-payload.types'; export interface RollbackParams { @@ -129,7 +130,9 @@ async function replayUndo( } for (const d of cdtDeleted) { await tx.controlDocumentType.create({ - data: { controlId: d.controlId, formType: d.formType as never }, + // Defensive normalization — older undo payloads may have stored the + // DB-mapped hyphen form before the sync-apply normalization was added. + data: { controlId: d.controlId, formType: normalizeFormType(d.formType) as never }, }); } diff --git a/apps/api/src/frameworks/framework-versioning/framework-sync-apply.ts b/apps/api/src/frameworks/framework-versioning/framework-sync-apply.ts index b75a9e62d4..5effc7a9ab 100644 --- a/apps/api/src/frameworks/framework-versioning/framework-sync-apply.ts +++ b/apps/api/src/frameworks/framework-versioning/framework-sync-apply.ts @@ -2,6 +2,7 @@ import { Prisma, Frequency, Departments, type FrameworkInstance, type FrameworkV import { diffManifests } from './framework-diff'; import { isControlEdited, isPolicyEdited, isTaskEdited } from './framework-drift'; import { buildCrossFrameworkRefs } from './cross-framework-refs'; +import { normalizeFormType } from './form-type-normalize'; import type { FrameworkManifest } from './manifest.types'; import type { UndoPayload, SyncSummary } from './undo-payload.types'; @@ -325,14 +326,15 @@ export async function applySync( for (const edge of diff.controlDocumentTypeEdges.added) { const ctlInst = ctlByTemplate.get(edge.controlTemplateId); if (!ctlInst) continue; + const formType = normalizeFormType(edge.formType); // Idempotent create: skip if already present (shared-entity case). const existing = await tx.controlDocumentType.findUnique({ - where: { controlId_formType: { controlId: ctlInst.id, formType: edge.formType as never } }, + where: { controlId_formType: { controlId: ctlInst.id, formType: formType as never } }, select: { id: true }, }); if (existing) continue; const created = await tx.controlDocumentType.create({ - data: { controlId: ctlInst.id, formType: edge.formType as never }, + data: { controlId: ctlInst.id, formType: formType as never }, }); undo.controlDocumentTypes.created.push(created.id); summary.controlDocumentTypesAdded += 1; @@ -340,13 +342,14 @@ export async function applySync( for (const edge of diff.controlDocumentTypeEdges.removed) { const ctlInst = ctlByTemplate.get(edge.controlTemplateId); if (!ctlInst) continue; + const formType = normalizeFormType(edge.formType); const existing = await tx.controlDocumentType.findUnique({ - where: { controlId_formType: { controlId: ctlInst.id, formType: edge.formType as never } }, + where: { controlId_formType: { controlId: ctlInst.id, formType: formType as never } }, select: { id: true }, }); if (!existing) continue; await tx.controlDocumentType.delete({ where: { id: existing.id } }); - undo.controlDocumentTypes.deleted.push({ controlId: ctlInst.id, formType: edge.formType }); + undo.controlDocumentTypes.deleted.push({ controlId: ctlInst.id, formType }); summary.controlDocumentTypesArchived += 1; } diff --git a/apps/api/test/__mocks__/better-auth-node.ts b/apps/api/test/__mocks__/better-auth-node.ts new file mode 100644 index 0000000000..1e6b7fb7b6 --- /dev/null +++ b/apps/api/test/__mocks__/better-auth-node.ts @@ -0,0 +1,3 @@ +// Stub for better-auth/node — not needed in test context +export const toNodeHandler = jest.fn(); +export const fromNodeHeaders = jest.fn(); diff --git a/apps/api/test/__mocks__/better-auth-plugins.ts b/apps/api/test/__mocks__/better-auth-plugins.ts new file mode 100644 index 0000000000..183e34a770 --- /dev/null +++ b/apps/api/test/__mocks__/better-auth-plugins.ts @@ -0,0 +1,7 @@ +// Stub for better-auth/plugins — not needed in test context +export const organization = jest.fn(() => ({})); +export const bearer = jest.fn(() => ({})); +export const admin = jest.fn(() => ({})); +export const magicLink = jest.fn(() => ({})); +export const multiSession = jest.fn(() => ({})); +export const emailOTP = jest.fn(() => ({})); diff --git a/apps/api/test/__mocks__/db.ts b/apps/api/test/__mocks__/db.ts new file mode 100644 index 0000000000..2f14b2e75a --- /dev/null +++ b/apps/api/test/__mocks__/db.ts @@ -0,0 +1,31 @@ +/** + * Stub for @db and @trycompai/db — returns a Proxy-based mockDb. + * Any table / method access returns a jest.fn() so untouched entities + * don't crash with "cannot read properties of undefined". Tests that need + * specific return values still should override with their own jest.mock(). + */ +function createTableMock() { + return new Proxy( + {}, + { + get: () => jest.fn(), + }, + ); +} + +export const db: Record = new Proxy( + {}, + { + get: () => createTableMock(), + }, +); + +export const Prisma = { + PrismaClientKnownRequestError: class PrismaClientKnownRequestError extends Error { + code: string; + constructor(message: string, { code }: { code: string }) { + super(message); + this.code = code; + } + }, +}; diff --git a/apps/api/test/__mocks__/nestjs-better-auth.ts b/apps/api/test/__mocks__/nestjs-better-auth.ts new file mode 100644 index 0000000000..dc8b1c5fc5 --- /dev/null +++ b/apps/api/test/__mocks__/nestjs-better-auth.ts @@ -0,0 +1,21 @@ +/** + * Minimal stub for @thallesp/nestjs-better-auth used in e2e tests. + * The real module imports better-auth/node (pure ESM) which Jest can't + * process in CJS mode. Since auth.server is already mocked, we only + * need AuthModule.forRoot to return a valid NestJS dynamic module. + */ +import { Module, DynamicModule } from '@nestjs/common'; + +@Module({}) +class BetterAuthModuleStub { + static forRoot(_opts: unknown): DynamicModule { + return { + module: BetterAuthModuleStub, + imports: [], + providers: [], + exports: [], + }; + } +} + +export { BetterAuthModuleStub as AuthModule }; diff --git a/apps/api/test/__mocks__/trycompai-auth.ts b/apps/api/test/__mocks__/trycompai-auth.ts new file mode 100644 index 0000000000..b2406c0df5 --- /dev/null +++ b/apps/api/test/__mocks__/trycompai-auth.ts @@ -0,0 +1,20 @@ +/** + * Stub for @trycompai/auth. + * The real package imports better-auth/plugins/access (ESM .mjs). + * We only need the RBAC constants used by PermissionGuard. + */ +export const PRIVILEGED_ROLES = ['owner', 'admin', 'auditor']; +export const RESTRICTED_ROLES = ['employee', 'contractor']; + +// Minimal createAccessControl / role stubs (not needed for these tests) +export const createAccessControl = () => ({}); +export const ac = {}; +export const allRoles = {}; +export const statement: Record = { + app: ['create', 'read', 'update', 'delete'], + member: ['create', 'read', 'update', 'delete'], + // extend if future tests need more resources +}; +export const BUILT_IN_ROLE_PERMISSIONS = {}; + +export type AccessControl = Record; diff --git a/apps/api/test/device-agent.e2e-spec.ts b/apps/api/test/device-agent.e2e-spec.ts new file mode 100644 index 0000000000..c778623adf --- /dev/null +++ b/apps/api/test/device-agent.e2e-spec.ts @@ -0,0 +1,555 @@ +/** + * Device-agent long-lived session — end-to-end integration tests. + * + * Strategy + * -------- + * - `@trycompai/db` → mocked at module level (mockDb) + * - `../src/auth/auth.server` → mocked at module level (mockGetSession, + * mockHasPermission, mockInternalCreateSession) + * - `../src/device-agent/device-agent-kv` → mocked (mockRedis) + * + * We spin up a real NestJS test-application using DeviceAgentModule so the + * full guard/controller/service chain executes. Per-test setup overrides the + * mock return values to cover every scenario. + */ + +import { + INestApplication, + ValidationPipe, + VersioningType, +} from '@nestjs/common'; +import { Test } from '@nestjs/testing'; +import request from 'supertest'; +import type { App } from 'supertest/types'; + +// --------------------------------------------------------------------------- +// Module-level mock handles — must be declared before jest.mock calls +// --------------------------------------------------------------------------- + +const mockGetSession = jest.fn(); +const mockHasPermission = jest.fn(); +const mockInternalCreateSession = jest.fn(); + +jest.mock('../src/auth/auth.server', () => ({ + auth: { + api: { + getSession: (...args: unknown[]) => mockGetSession(...args), + hasPermission: (...args: unknown[]) => mockHasPermission(...args), + }, + $context: Promise.resolve({ + internalAdapter: { + createSession: (...args: unknown[]) => + mockInternalCreateSession(...args), + }, + }), + handler: jest.fn(), + options: {}, + }, + isTrustedOrigin: jest.fn().mockResolvedValue(true), + getTrustedOrigins: jest.fn().mockReturnValue([]), +})); + +const mockDevice = { + findFirst: jest.fn(), + findUnique: jest.fn(), + update: jest.fn(), + create: jest.fn(), + findMany: jest.fn(), +}; + +const mockMember = { + findFirst: jest.fn(), + findMany: jest.fn(), +}; + +const mockSession = { + delete: jest.fn(), +}; + +const mockApiKey = { + findFirst: jest.fn(), +}; + +const mockOrganization = { + findUnique: jest.fn(), +}; + +const mockDb = { + device: mockDevice, + member: mockMember, + session: mockSession, + apiKey: mockApiKey, + organization: mockOrganization, +}; + +jest.mock('@trycompai/db', () => ({ + db: mockDb, + Prisma: { + PrismaClientKnownRequestError: class PrismaClientKnownRequestError extends Error { + code: string; + constructor(message: string, { code }: { code: string }) { + super(message); + this.code = code; + } + }, + }, +})); + +const mockRedis = { + set: jest.fn(), + get: jest.fn(), + getdel: jest.fn(), + del: jest.fn(), +}; + +jest.mock('../src/device-agent/device-agent-kv', () => ({ + deviceAgentRedisClient: mockRedis, +})); + +// --------------------------------------------------------------------------- +// Import after mocks are set up +// --------------------------------------------------------------------------- + +import { DeviceAgentModule } from '../src/device-agent/device-agent.module'; + +// --------------------------------------------------------------------------- +// Fixed test fixtures +// --------------------------------------------------------------------------- + +const USER_ID = 'usr_test_001'; +const ORG_ID = 'org_test_001'; +const MEMBER_ID = 'mem_test_001'; +const DEVICE_ID = 'dev_test_001'; +const SESSION_ID_NEW = 'ses_new_001'; +const SESSION_TOKEN_NEW = 'tok_new_001'; +const SESSION_ID_OLD = 'ses_old_001'; +const SESSION_ID_UPGRADED = 'ses_upgraded_001'; +const SESSION_TOKEN_UPGRADED = 'tok_upgraded_001'; +const AGENT_SESSION_ID = 'ses_agent_001'; + +/** Returns a minimal better-auth session with deviceAgent=true */ +function makeAgentSession(overrides: Record = {}) { + return { + user: { id: USER_ID, email: 'test@example.com', role: null }, + session: { + id: SESSION_ID_NEW, + activeOrganizationId: ORG_ID, + deviceAgent: true, + ...overrides, + }, + }; +} + +/** Returns a minimal better-auth session with deviceAgent=false (legacy web) */ +function makeLegacySession(overrides: Record = {}) { + return { + user: { id: USER_ID, email: 'test@example.com', role: null }, + session: { + id: SESSION_ID_OLD, + activeOrganizationId: ORG_ID, + deviceAgent: false, + ...overrides, + }, + }; +} + +function makeMember(role = 'owner') { + return { + id: MEMBER_ID, + role, + department: null, + userId: USER_ID, + organizationId: ORG_ID, + deactivated: false, + }; +} + +function makeDevice(overrides: Record = {}) { + return { + id: DEVICE_ID, + memberId: MEMBER_ID, + organizationId: ORG_ID, + agentSessionId: null, + name: 'Test Mac', + hostname: 'test-mac.local', + platform: 'macos', + osVersion: '14.0', + serialNumber: 'SN001', + diskEncryptionEnabled: true, + antivirusEnabled: true, + passwordPolicySet: true, + screenLockEnabled: true, + isCompliant: true, + checkDetails: {}, + lastCheckIn: null, + installedAt: new Date(), + agentVersion: null, + hardwareModel: null, + ...overrides, + }; +} + +const CHECKIN_BODY = { + deviceId: DEVICE_ID, + checks: [ + { + checkType: 'disk_encryption', + passed: true, + checkedAt: new Date().toISOString(), + details: { method: 'fde', raw: 'ok', message: 'encrypted' }, + }, + ], +}; + +// --------------------------------------------------------------------------- +// Test suite +// --------------------------------------------------------------------------- + +describe('DeviceAgent (e2e)', () => { + let app: INestApplication; + + beforeEach(async () => { + // Reset all mocks before each test + jest.clearAllMocks(); + + const moduleRef = await Test.createTestingModule({ + imports: [DeviceAgentModule], + }).compile(); + + app = moduleRef.createNestApplication(); + + // Mirror the global pipe + versioning from main.ts + app.useGlobalPipes( + new ValidationPipe({ + whitelist: true, + forbidNonWhitelisted: true, + transform: true, + transformOptions: { enableImplicitConversion: true }, + }), + ); + app.enableVersioning({ + type: VersioningType.URI, + defaultVersion: '1', + }); + + await app.init(); + }); + + afterEach(async () => { + await app.close(); + }); + + // ------------------------------------------------------------------------- + // 1. exchange-code — happy path + // ------------------------------------------------------------------------- + + describe('POST /v1/device-agent/exchange-code', () => { + it('returns session_token and user_id, calls createSession with deviceAgent:true', async () => { + const CODE = 'valid_code_abc'; + + // Redis returns the stored auth code + mockRedis.getdel.mockResolvedValueOnce({ + userId: USER_ID, + state: 'some-state', + createdAt: Date.now(), + }); + + // internalAdapter.createSession returns the new session + mockInternalCreateSession.mockResolvedValueOnce({ + id: SESSION_ID_NEW, + token: SESSION_TOKEN_NEW, + expiresAt: new Date(Date.now() + 365 * 24 * 60 * 60 * 1000), + }); + + const res = await request(app.getHttpServer()) + .post('/v1/device-agent/exchange-code') + .send({ code: CODE }) + .expect(201); + + expect(res.body).toMatchObject({ + session_token: SESSION_TOKEN_NEW, + user_id: USER_ID, + }); + + expect(mockInternalCreateSession).toHaveBeenCalledTimes(1); + + // Verify deviceAgent:true was passed as part of the extra data arg + const callArgs = mockInternalCreateSession.mock.calls[0] as unknown[]; + // Signature: createSession(userId, undefined, { expiresAt, deviceAgent }, overrideAll) + const extraData = callArgs[2] as Record; + expect(extraData).toMatchObject({ deviceAgent: true }); + }); + + it('returns 401 for an invalid or expired code', async () => { + mockRedis.getdel.mockResolvedValueOnce(null); + + await request(app.getHttpServer()) + .post('/v1/device-agent/exchange-code') + .send({ code: 'stale' }) + .expect(401); + }); + }); + + // ------------------------------------------------------------------------- + // 2. register — links agentSessionId, no stale-session delete for fresh device + // ------------------------------------------------------------------------- + + describe('POST /v1/device-agent/register', () => { + it('links agentSessionId; does NOT delete session for fresh device', async () => { + // Guard: agent session (deviceAgent:true) + mockGetSession.mockResolvedValue(makeAgentSession()); + mockMember.findFirst + // 1st call: HybridAuthGuard member lookup (skipOrgCheck path is active here + // but register itself calls findFirst to verify org membership) + .mockResolvedValueOnce(makeMember()) + // 2nd call: registerDevice membership check + .mockResolvedValueOnce(makeMember()); + + // The registration helpers call device.findUnique then device.create + mockDevice.findUnique.mockResolvedValueOnce(null); // no existing device + const freshDevice = makeDevice({ agentSessionId: null }); + mockDevice.create.mockResolvedValueOnce(freshDevice); + mockDevice.update.mockResolvedValueOnce({ + ...freshDevice, + agentSessionId: SESSION_ID_NEW, + }); + + const res = await request(app.getHttpServer()) + .post('/v1/device-agent/register') + .set('Authorization', 'Bearer x') + .send({ + name: 'Test Mac', + hostname: 'test-mac.local', + platform: 'macos', + osVersion: '14.0', + organizationId: ORG_ID, + serialNumber: 'SN001', + }) + .expect(201); + + expect(res.body).toMatchObject({ deviceId: DEVICE_ID }); + + // agentSessionId linked to the session from the guard + expect(mockDevice.update).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ agentSessionId: SESSION_ID_NEW }), + }), + ); + + // No stale session to delete + expect(mockSession.delete).not.toHaveBeenCalled(); + }); + + it('deletes stale agentSession when device already has a different one', async () => { + mockGetSession.mockResolvedValue(makeAgentSession()); + mockMember.findFirst + .mockResolvedValueOnce(makeMember()) + .mockResolvedValueOnce(makeMember()); + + // Device already exists with a different agentSessionId + const existingDevice = makeDevice({ agentSessionId: 'ses_stale_old' }); + mockDevice.findUnique.mockResolvedValueOnce({ + id: DEVICE_ID, + memberId: MEMBER_ID, + }); + mockDevice.update + // first update call is from registerWithSerial + .mockResolvedValueOnce(existingDevice) + // second update call links new session + .mockResolvedValueOnce({ ...existingDevice, agentSessionId: SESSION_ID_NEW }); + mockSession.delete.mockResolvedValueOnce({}); + + await request(app.getHttpServer()) + .post('/v1/device-agent/register') + .set('Authorization', 'Bearer x') + .send({ + name: 'Test Mac', + hostname: 'test-mac.local', + platform: 'macos', + osVersion: '14.0', + organizationId: ORG_ID, + serialNumber: 'SN001', + }) + .expect(201); + + expect(mockSession.delete).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: 'ses_stale_old' }, + }), + ); + }); + }); + + // ------------------------------------------------------------------------- + // 3. check-in — already a device-agent session → no upgrade + // ------------------------------------------------------------------------- + + describe('POST /v1/device-agent/check-in (deviceAgent=true)', () => { + it('returns isCompliant + nextCheckIn without upgradedSessionToken', async () => { + mockGetSession.mockResolvedValue(makeAgentSession()); + + mockDevice.findFirst.mockResolvedValueOnce(makeDevice()); + mockDevice.update.mockResolvedValueOnce(makeDevice()); + + const res = await request(app.getHttpServer()) + .post('/v1/device-agent/check-in') + .set('Authorization', 'Bearer x') + .send(CHECKIN_BODY) + .expect(201); + + expect(res.body).toMatchObject({ + isCompliant: expect.any(Boolean), + nextCheckIn: expect.any(String), + }); + expect(res.body.upgradedSessionToken).toBeUndefined(); + + // No new session should be created + expect(mockInternalCreateSession).not.toHaveBeenCalled(); + }); + + it('backfills agentSessionId when device.agentSessionId differs from session.id', async () => { + // Device has a different (or null) agentSessionId + mockGetSession.mockResolvedValue(makeAgentSession()); + + mockDevice.findFirst.mockResolvedValueOnce( + makeDevice({ agentSessionId: 'ses_old_different' }), + ); + mockDevice.update.mockResolvedValueOnce( + makeDevice({ agentSessionId: SESSION_ID_NEW }), + ); + + const res = await request(app.getHttpServer()) + .post('/v1/device-agent/check-in') + .set('Authorization', 'Bearer x') + .send(CHECKIN_BODY) + .expect(201); + + // No upgrade token — this is just a backfill + expect(res.body.upgradedSessionToken).toBeUndefined(); + + // The update should write the current sessionId + expect(mockDevice.update).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ agentSessionId: SESSION_ID_NEW }), + }), + ); + }); + }); + + // ------------------------------------------------------------------------- + // 4. check-in — legacy web session → silent upgrade + // ------------------------------------------------------------------------- + + describe('POST /v1/device-agent/check-in (silent upgrade, deviceAgent=false)', () => { + it('returns upgradedSessionToken and calls createSession with deviceAgent:true', async () => { + mockGetSession.mockResolvedValue(makeLegacySession()); + + // createDeviceAgentSession will call internalAdapter.createSession + mockInternalCreateSession.mockResolvedValueOnce({ + id: SESSION_ID_UPGRADED, + token: SESSION_TOKEN_UPGRADED, + expiresAt: new Date(Date.now() + 365 * 24 * 60 * 60 * 1000), + }); + + mockDevice.findFirst.mockResolvedValueOnce(makeDevice()); + mockDevice.update.mockResolvedValueOnce( + makeDevice({ agentSessionId: SESSION_ID_UPGRADED }), + ); + + const res = await request(app.getHttpServer()) + .post('/v1/device-agent/check-in') + .set('Authorization', 'Bearer x') + .send(CHECKIN_BODY) + .expect(201); + + expect(res.body).toMatchObject({ + isCompliant: expect.any(Boolean), + nextCheckIn: expect.any(String), + upgradedSessionToken: SESSION_TOKEN_UPGRADED, + }); + + expect(mockInternalCreateSession).toHaveBeenCalledTimes(1); + + const callArgs = mockInternalCreateSession.mock.calls[0] as unknown[]; + const extraData = callArgs[2] as Record; + expect(extraData).toMatchObject({ deviceAgent: true }); + + // Device update should link to the new session id + expect(mockDevice.update).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + agentSessionId: SESSION_ID_UPGRADED, + }), + }), + ); + }); + }); + + // ------------------------------------------------------------------------- + // 5. revoke — admin succeeds (204), employee fails (403), wrong-org (404) + // ------------------------------------------------------------------------- + + describe('DELETE /v1/device-agent/sessions/:deviceId', () => { + it('admin: returns 204 and deletes the agent session', async () => { + // Guard resolves session for admin user with active org + mockGetSession.mockResolvedValue( + makeAgentSession({ activeOrganizationId: ORG_ID }), + ); + // Permission check: admin passes + mockHasPermission.mockResolvedValue({ success: true }); + // HybridAuthGuard member lookup + mockMember.findFirst.mockResolvedValueOnce(makeMember('owner')); + + // Device found in org + mockDevice.findFirst.mockResolvedValueOnce( + makeDevice({ agentSessionId: AGENT_SESSION_ID }), + ); + mockSession.delete.mockResolvedValueOnce({}); + + await request(app.getHttpServer()) + .delete(`/v1/device-agent/sessions/${DEVICE_ID}`) + .set('Authorization', 'Bearer x') + .expect(204); + + expect(mockSession.delete).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: AGENT_SESSION_ID }, + }), + ); + }); + + it('employee: returns 403, never deletes session', async () => { + mockGetSession.mockResolvedValue( + makeAgentSession({ activeOrganizationId: ORG_ID }), + ); + // PermissionGuard denies + mockHasPermission.mockResolvedValue({ success: false }); + mockMember.findFirst.mockResolvedValueOnce(makeMember('employee')); + + await request(app.getHttpServer()) + .delete(`/v1/device-agent/sessions/${DEVICE_ID}`) + .set('Authorization', 'Bearer x') + .expect(403); + + expect(mockSession.delete).not.toHaveBeenCalled(); + }); + + it('wrong-org: returns 404 when device belongs to a different org', async () => { + mockGetSession.mockResolvedValue( + makeAgentSession({ activeOrganizationId: ORG_ID }), + ); + mockHasPermission.mockResolvedValue({ success: true }); + mockMember.findFirst.mockResolvedValueOnce(makeMember('owner')); + + // Simulate device not found for caller's org + mockDevice.findFirst.mockResolvedValueOnce(null); + + await request(app.getHttpServer()) + .delete(`/v1/device-agent/sessions/${DEVICE_ID}`) + .set('Authorization', 'Bearer x') + .expect(404); + + expect(mockSession.delete).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/apps/api/test/jest-e2e.json b/apps/api/test/jest-e2e.json index e9d912f3e3..4a1cb5e6c4 100644 --- a/apps/api/test/jest-e2e.json +++ b/apps/api/test/jest-e2e.json @@ -1,9 +1,28 @@ { "moduleFileExtensions": ["js", "json", "ts"], - "rootDir": ".", + "rootDir": "..", "testEnvironment": "node", "testRegex": ".e2e-spec.ts$", + "testPathIgnorePatterns": ["/node_modules/"], "transform": { "^.+\\.(t|j)s$": "ts-jest" + }, + "moduleNameMapper": { + "^@db$": "/test/__mocks__/db.ts", + "^@/(.*)$": "/src/$1", + "^@trycompai/auth$": "/test/__mocks__/trycompai-auth.ts", + "^@trycompai/company$": "/../../packages/company/src/index.ts", + "^@trycompai/db$": "/test/__mocks__/db.ts", + "^@trycompai/email$": "/../../packages/email/index.ts", + "^@trycompai/integration-platform$": "/../../packages/integration-platform/src/index.ts", + "^@trycompai/utils/(.*)$": "/../../packages/utils/src/$1.ts", + "^@thallesp/nestjs-better-auth$": "/test/__mocks__/nestjs-better-auth.ts", + "^better-auth/node$": "/test/__mocks__/better-auth-node.ts", + "^better-auth/plugins$": "/test/__mocks__/better-auth-plugins.ts" + }, + "globals": { + "ts-jest": { + "tsconfig": "/tsconfig.json" + } } } diff --git a/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/EmployeeTasks.test.tsx b/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/EmployeeTasks.test.tsx index 40a72f0fa7..9524993ebb 100644 --- a/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/EmployeeTasks.test.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/EmployeeTasks.test.tsx @@ -91,6 +91,7 @@ function makeDevice(overrides: Partial = {}): DeviceWithChecks source: 'device_agent', complianceStatus: 'compliant', daysSinceLastCheckIn: 0, + hasActiveAgentSession: false, ...overrides, }; } diff --git a/apps/app/src/app/(app)/[orgId]/people/[employeeId]/page.tsx b/apps/app/src/app/(app)/[orgId]/people/[employeeId]/page.tsx index c011bb9f05..170d0a9d27 100644 --- a/apps/app/src/app/(app)/[orgId]/people/[employeeId]/page.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/[employeeId]/page.tsx @@ -283,6 +283,9 @@ const getMemberDevice = async ( }, }, }, + agentSession: { + select: { expiresAt: true }, + }, }, orderBy: { installedAt: 'desc' }, }); @@ -320,5 +323,8 @@ const getMemberDevice = async ( source: 'device_agent' as const, complianceStatus, daysSinceLastCheckIn: daysSinceCheckIn(device.lastCheckIn), + hasActiveAgentSession: + !!device.agentSession && + device.agentSession.expiresAt.getTime() > Date.now(), }; }; diff --git a/apps/app/src/app/(app)/[orgId]/people/all/components/compute-device-status-map.test.ts b/apps/app/src/app/(app)/[orgId]/people/all/components/compute-device-status-map.test.ts index bcd67ac7d1..70a45ce9ec 100644 --- a/apps/app/src/app/(app)/[orgId]/people/all/components/compute-device-status-map.test.ts +++ b/apps/app/src/app/(app)/[orgId]/people/all/components/compute-device-status-map.test.ts @@ -26,6 +26,7 @@ function makeAgentDevice(overrides: Partial = {}): DeviceWithC source: 'device_agent', complianceStatus: 'compliant', daysSinceLastCheckIn: 0, + hasActiveAgentSession: false, ...overrides, }; } diff --git a/apps/app/src/app/(app)/[orgId]/people/devices/components/DeviceAgentDevicesList.test.tsx b/apps/app/src/app/(app)/[orgId]/people/devices/components/DeviceAgentDevicesList.test.tsx index 8cec5285dc..5f9c6f5f56 100644 --- a/apps/app/src/app/(app)/[orgId]/people/devices/components/DeviceAgentDevicesList.test.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/devices/components/DeviceAgentDevicesList.test.tsx @@ -42,6 +42,7 @@ function makeDevice(overrides: Partial = {}): DeviceWithChecks source: 'device_agent', complianceStatus: 'compliant', daysSinceLastCheckIn: 0, + hasActiveAgentSession: false, ...overrides, }; } diff --git a/apps/app/src/app/(app)/[orgId]/people/devices/components/DeviceComplianceChart.test.tsx b/apps/app/src/app/(app)/[orgId]/people/devices/components/DeviceComplianceChart.test.tsx index dd8dd07924..5b5e0f059b 100644 --- a/apps/app/src/app/(app)/[orgId]/people/devices/components/DeviceComplianceChart.test.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/devices/components/DeviceComplianceChart.test.tsx @@ -50,6 +50,7 @@ function makeAgentDevice(overrides: Partial = {}): DeviceWithC source: 'device_agent', complianceStatus: 'compliant', daysSinceLastCheckIn: 0, + hasActiveAgentSession: false, ...overrides, }; } diff --git a/apps/app/src/app/(app)/[orgId]/people/devices/components/DeviceDetails.test.tsx b/apps/app/src/app/(app)/[orgId]/people/devices/components/DeviceDetails.test.tsx index 2dbde9cc58..a66e4d6345 100644 --- a/apps/app/src/app/(app)/[orgId]/people/devices/components/DeviceDetails.test.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/devices/components/DeviceDetails.test.tsx @@ -4,6 +4,12 @@ import { describe, expect, it, vi } from 'vitest'; import type { DeviceWithChecks } from '../types'; import { DeviceDetails } from './DeviceDetails'; +vi.mock('./RevokeAgentAccessDialog', () => ({ + RevokeAgentAccessDialog: ({ deviceId }: { deviceId: string; deviceName: string }) => ( +
revoke {deviceId}
+ ), +})); + function makeDevice(overrides: Partial = {}): DeviceWithChecks { return { id: 'dev_1', @@ -27,6 +33,7 @@ function makeDevice(overrides: Partial = {}): DeviceWithChecks source: 'device_agent', complianceStatus: 'compliant', daysSinceLastCheckIn: 0, + hasActiveAgentSession: false, ...overrides, }; } @@ -136,3 +143,32 @@ describe('DeviceDetails compliance badge', () => { ).not.toBeInTheDocument(); }); }); + +describe('DeviceDetails revoke action', () => { + it('renders the RevokeAgentAccessDialog for device_agent with an active agent session', () => { + render( + , + ); + expect(screen.getByTestId('revoke-dialog')).toBeInTheDocument(); + }); + + it('does not render when hasActiveAgentSession is false', () => { + render( + , + ); + expect(screen.queryByTestId('revoke-dialog')).toBeNull(); + }); + + it('does not render for fleet-sourced devices', () => { + render( + , + ); + expect(screen.queryByTestId('revoke-dialog')).toBeNull(); + }); +}); diff --git a/apps/app/src/app/(app)/[orgId]/people/devices/components/DeviceDetails.tsx b/apps/app/src/app/(app)/[orgId]/people/devices/components/DeviceDetails.tsx index 3d74ac8368..b4da2e907c 100644 --- a/apps/app/src/app/(app)/[orgId]/people/devices/components/DeviceDetails.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/devices/components/DeviceDetails.tsx @@ -18,6 +18,7 @@ import { import { ArrowLeft, Information } from '@trycompai/design-system/icons'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@trycompai/ui/tooltip'; import type { DeviceWithChecks } from '../types'; +import { RevokeAgentAccessDialog } from './RevokeAgentAccessDialog'; const CHECK_FIELDS = [ { key: 'diskEncryptionEnabled' as const, dbKey: 'disk_encryption', label: 'Disk Encryption' }, @@ -120,7 +121,12 @@ export const DeviceDetails = ({ device, onClose }: DeviceDetailsProps) => { {device.hardwareModel ? ` \u2022 ${device.hardwareModel}` : ''} - +
+ {device.source === 'device_agent' && device.hasActiveAgentSession && ( + + )} + +
diff --git a/apps/app/src/app/(app)/[orgId]/people/devices/components/RevokeAgentAccessDialog.test.tsx b/apps/app/src/app/(app)/[orgId]/people/devices/components/RevokeAgentAccessDialog.test.tsx new file mode 100644 index 0000000000..5c17524fad --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/people/devices/components/RevokeAgentAccessDialog.test.tsx @@ -0,0 +1,101 @@ +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { + setMockPermissions, + ADMIN_PERMISSIONS, + mockHasPermission, +} from '@/test-utils/mocks/permissions'; + +vi.mock('@/hooks/use-permissions', () => ({ + usePermissions: () => ({ + permissions: {}, + hasPermission: mockHasPermission, + }), +})); + +const revokeMock = vi.fn(); +vi.mock('../hooks/useRevokeAgentAccess', () => ({ + useRevokeAgentAccess: () => ({ revokeAgentAccess: revokeMock }), +})); + +vi.mock('sonner', () => ({ + toast: { success: vi.fn(), error: vi.fn() }, +})); + +// Mock design-system AlertDialog components +vi.mock('@trycompai/design-system', () => ({ + AlertDialog: ({ children, open }: any) => + open ?
{children}
:
{children}
, + AlertDialogTrigger: ({ children }: any) => ( +
{children}
+ ), + AlertDialogContent: ({ children }: any) => ( +
{children}
+ ), + AlertDialogHeader: ({ children }: any) =>
{children}
, + AlertDialogTitle: ({ children }: any) =>

{children}

, + AlertDialogDescription: ({ children }: any) =>

{children}

, + AlertDialogFooter: ({ children }: any) =>
{children}
, + AlertDialogCancel: ({ children, onClick }: any) => ( + + ), + AlertDialogAction: ({ children, onClick, disabled }: any) => ( + + ), + Button: ({ children, onClick, disabled, ...props }: any) => ( + + ), +})); + +import { toast } from 'sonner'; +import { RevokeAgentAccessDialog } from './RevokeAgentAccessDialog'; + +describe('RevokeAgentAccessDialog', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('does not render the button for users without member:update', () => { + setMockPermissions({}); + render(); + expect(screen.queryByRole('button', { name: /revoke agent access/i })).toBeNull(); + }); + + it('renders the button for admins and calls revoke on confirm', async () => { + setMockPermissions(ADMIN_PERMISSIONS); + revokeMock.mockResolvedValue(undefined); + + render(); + + fireEvent.click(screen.getByRole('button', { name: /revoke agent access/i })); + fireEvent.click(await screen.findByRole('button', { name: /^revoke$/i })); + + await waitFor(() => { + expect(revokeMock).toHaveBeenCalledWith('dev_1'); + }); + + await waitFor(() => { + expect(toast.success).toHaveBeenCalled(); + }); + }); + + it('surfaces a destructive toast on error', async () => { + setMockPermissions(ADMIN_PERMISSIONS); + revokeMock.mockRejectedValue(new Error('forbidden')); + + render(); + + fireEvent.click(screen.getByRole('button', { name: /revoke agent access/i })); + fireEvent.click(await screen.findByRole('button', { name: /^revoke$/i })); + + await waitFor(() => { + expect(toast.error).toHaveBeenCalledWith( + expect.stringMatching(/could not revoke/i), + ); + }); + }); +}); diff --git a/apps/app/src/app/(app)/[orgId]/people/devices/components/RevokeAgentAccessDialog.tsx b/apps/app/src/app/(app)/[orgId]/people/devices/components/RevokeAgentAccessDialog.tsx new file mode 100644 index 0000000000..825a2db18e --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/people/devices/components/RevokeAgentAccessDialog.tsx @@ -0,0 +1,77 @@ +'use client'; + +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + Button, +} from '@trycompai/design-system'; +import { useState } from 'react'; +import { toast } from 'sonner'; +import { usePermissions } from '@/hooks/use-permissions'; +import { useRevokeAgentAccess } from '../hooks/useRevokeAgentAccess'; + +interface RevokeAgentAccessDialogProps { + deviceId: string; + deviceName: string; +} + +export function RevokeAgentAccessDialog({ + deviceId, + deviceName, +}: RevokeAgentAccessDialogProps) { + const { hasPermission } = usePermissions(); + const { revokeAgentAccess } = useRevokeAgentAccess(); + const [open, setOpen] = useState(false); + const [isSubmitting, setIsSubmitting] = useState(false); + + if (!hasPermission('member', 'update')) { + return null; + } + + async function handleConfirm() { + setIsSubmitting(true); + try { + await revokeAgentAccess(deviceId); + toast.success('Agent access revoked.'); + setOpen(false); + } catch (err) { + toast.error( + `Could not revoke agent access: ${err instanceof Error ? err.message : 'Unknown error'}`, + ); + } finally { + setIsSubmitting(false); + } + } + + return ( + <> + + + + + Revoke agent access on {deviceName}? + + The agent on this device will sign out on its next check-in. The user + will need to sign in again to resume reporting compliance. This does + not affect the user's web sessions. + + + + Cancel + + Revoke + + + + + + ); +} diff --git a/apps/app/src/app/(app)/[orgId]/people/devices/hooks/useRevokeAgentAccess.test.tsx b/apps/app/src/app/(app)/[orgId]/people/devices/hooks/useRevokeAgentAccess.test.tsx new file mode 100644 index 0000000000..81f89df6a3 --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/people/devices/hooks/useRevokeAgentAccess.test.tsx @@ -0,0 +1,45 @@ +import React from 'react'; +import { renderHook, act } from '@testing-library/react'; +import { SWRConfig } from 'swr'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { useRevokeAgentAccess } from './useRevokeAgentAccess'; + +const deleteMock = vi.fn(); +vi.mock('@/lib/api-client', () => ({ + apiClient: { delete: (url: string) => deleteMock(url) }, +})); + +function wrapper({ children }: { children: React.ReactNode }) { + return new Map() }}>{children}; +} + +describe('useRevokeAgentAccess', () => { + beforeEach(() => { + deleteMock.mockReset(); + }); + + it('DELETEs the revoke endpoint for the given deviceId', async () => { + deleteMock.mockResolvedValue({ status: 204 }); + const { result } = renderHook(() => useRevokeAgentAccess(), { wrapper }); + + await act(async () => { + await result.current.revokeAgentAccess('dev_1'); + }); + + expect(deleteMock).toHaveBeenCalledWith('/v1/device-agent/sessions/dev_1'); + }); + + it('throws when the API returns an error', async () => { + deleteMock.mockResolvedValue({ error: 'Forbidden', status: 403 }); + const { result } = renderHook(() => useRevokeAgentAccess(), { wrapper }); + + await expect(result.current.revokeAgentAccess('dev_1')).rejects.toThrow('Forbidden'); + }); + + it('throws on network-level rejection', async () => { + deleteMock.mockRejectedValue(new Error('network error')); + const { result } = renderHook(() => useRevokeAgentAccess(), { wrapper }); + + await expect(result.current.revokeAgentAccess('dev_1')).rejects.toThrow('network error'); + }); +}); diff --git a/apps/app/src/app/(app)/[orgId]/people/devices/hooks/useRevokeAgentAccess.ts b/apps/app/src/app/(app)/[orgId]/people/devices/hooks/useRevokeAgentAccess.ts new file mode 100644 index 0000000000..bdbb6c2eff --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/people/devices/hooks/useRevokeAgentAccess.ts @@ -0,0 +1,26 @@ +'use client'; + +import { useCallback } from 'react'; +import { useSWRConfig } from 'swr'; +import { apiClient } from '@/lib/api-client'; + +export function useRevokeAgentAccess() { + const { mutate } = useSWRConfig(); + + const revokeAgentAccess = useCallback( + async (deviceId: string) => { + const response = await apiClient.delete( + `/v1/device-agent/sessions/${deviceId}`, + ); + if (response.error) { + throw new Error(response.error); + } + await mutate( + (key) => Array.isArray(key) && key[0] === 'people-agent-devices', + ); + }, + [mutate], + ); + + return { revokeAgentAccess }; +} diff --git a/apps/app/src/app/(app)/[orgId]/people/devices/lib/devices-csv.test.ts b/apps/app/src/app/(app)/[orgId]/people/devices/lib/devices-csv.test.ts index 9e3946fb96..9069e85ae3 100644 --- a/apps/app/src/app/(app)/[orgId]/people/devices/lib/devices-csv.test.ts +++ b/apps/app/src/app/(app)/[orgId]/people/devices/lib/devices-csv.test.ts @@ -30,6 +30,7 @@ function makeDevice(overrides: Partial = {}): DeviceWithChecks source: 'device_agent', complianceStatus: 'compliant', daysSinceLastCheckIn: 0, + hasActiveAgentSession: false, ...overrides, }; } diff --git a/apps/app/src/app/(app)/[orgId]/people/devices/types/index.ts b/apps/app/src/app/(app)/[orgId]/people/devices/types/index.ts index 919b408540..149a5573f0 100644 --- a/apps/app/src/app/(app)/[orgId]/people/devices/types/index.ts +++ b/apps/app/src/app/(app)/[orgId]/people/devices/types/index.ts @@ -39,6 +39,8 @@ export interface DeviceWithChecks { complianceStatus: DeviceComplianceStatus; /** Whole days since last check-in, or null when never synced. */ daysSinceLastCheckIn: number | null; + /** True iff the device has an agent session whose expiresAt is in the future. */ + hasActiveAgentSession: boolean; } export interface FleetPolicy { diff --git a/apps/app/src/app/api/people/agent-devices/route.test.ts b/apps/app/src/app/api/people/agent-devices/route.test.ts index adc808f799..d61d763aeb 100644 --- a/apps/app/src/app/api/people/agent-devices/route.test.ts +++ b/apps/app/src/app/api/people/agent-devices/route.test.ts @@ -116,4 +116,25 @@ describe('GET /api/people/agent-devices', () => { expect(body.data[0].complianceStatus).toBe('stale'); expect(body.data[0].daysSinceLastCheckIn).toBeNull(); }); + + it('returns hasActiveAgentSession=true for devices with an unexpired linked session, false otherwise', async () => { + const future = new Date(FIXED_NOW.getTime() + 60 * 60 * 1000); // 1 hour ahead + const past = new Date(FIXED_NOW.getTime() - 60 * 60 * 1000); // 1 hour ago + + mockedFindMany.mockResolvedValue([ + deviceRow({ id: 'dev_active', agentSession: { expiresAt: future } }), + deviceRow({ id: 'dev_none', agentSession: null }), + deviceRow({ id: 'dev_expired', agentSession: { expiresAt: past } }), + ]); + + const res = await GET(); + const body = await res.json(); + const byId = Object.fromEntries( + body.data.map((d: { id: string }) => [d.id, d]), + ); + + expect(byId['dev_active'].hasActiveAgentSession).toBe(true); + expect(byId['dev_none'].hasActiveAgentSession).toBe(false); + expect(byId['dev_expired'].hasActiveAgentSession).toBe(false); + }); }); diff --git a/apps/app/src/app/api/people/agent-devices/route.ts b/apps/app/src/app/api/people/agent-devices/route.ts index 682840fd61..286459fa76 100644 --- a/apps/app/src/app/api/people/agent-devices/route.ts +++ b/apps/app/src/app/api/people/agent-devices/route.ts @@ -30,6 +30,7 @@ export async function GET() { user: { select: { name: true, email: true } }, }, }, + agentSession: { select: { expiresAt: true } }, }, orderBy: { installedAt: 'desc' }, }); @@ -64,6 +65,8 @@ export async function GET() { source: 'device_agent' as const, complianceStatus, daysSinceLastCheckIn: daysSinceCheckIn(device.lastCheckIn), + hasActiveAgentSession: + !!device.agentSession && device.agentSession.expiresAt.getTime() > Date.now(), }; }); diff --git a/apps/app/src/test-utils/mocks/auth.ts b/apps/app/src/test-utils/mocks/auth.ts index 7903209d1e..2df6690459 100644 --- a/apps/app/src/test-utils/mocks/auth.ts +++ b/apps/app/src/test-utils/mocks/auth.ts @@ -44,6 +44,7 @@ export const createMockSession = (overrides?: Partial): Session => ({ userAgent: 'test-agent', activeOrganizationId: 'org_test123', impersonatedBy: null, + deviceAgent: false, ...overrides, }); diff --git a/bun.lock b/bun.lock index d3c4f52d4d..0a775b615e 100644 --- a/bun.lock +++ b/bun.lock @@ -561,6 +561,7 @@ "tailwindcss": "^4.1.4", "typescript": "^5.9.3", "vite": "^6.3.5", + "vitest": "^3.2.4", }, }, "packages/docs": { diff --git a/packages/db/prisma/migrations/20260422174224_device_agent_session/migration.sql b/packages/db/prisma/migrations/20260422174224_device_agent_session/migration.sql new file mode 100644 index 0000000000..07c1de624d --- /dev/null +++ b/packages/db/prisma/migrations/20260422174224_device_agent_session/migration.sql @@ -0,0 +1,17 @@ +-- AlterTable +ALTER TABLE "Device" ADD COLUMN "agentSessionId" TEXT; + +-- AlterTable +ALTER TABLE "Session" ADD COLUMN "deviceAgent" BOOLEAN NOT NULL DEFAULT false; + +-- CreateIndex +CREATE INDEX "Device_agentSessionId_idx" ON "Device"("agentSessionId"); + +-- CreateIndex +CREATE INDEX "Session_userId_idx" ON "Session"("userId"); + +-- CreateIndex +CREATE INDEX "Session_deviceAgent_idx" ON "Session"("deviceAgent"); + +-- AddForeignKey +ALTER TABLE "Device" ADD CONSTRAINT "Device_agentSessionId_fkey" FOREIGN KEY ("agentSessionId") REFERENCES "Session"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/packages/db/prisma/schema/auth.prisma b/packages/db/prisma/schema/auth.prisma index 5e1c09327f..05502d9017 100644 --- a/packages/db/prisma/schema/auth.prisma +++ b/packages/db/prisma/schema/auth.prisma @@ -56,9 +56,15 @@ model Session { userId String activeOrganizationId String? impersonatedBy String? - user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + deviceAgent Boolean @default(false) + deviceAgentFor Device[] @relation("DeviceAgentSession") + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@unique([token]) + @@index([userId]) + @@index([deviceAgent]) } model Account { diff --git a/packages/db/prisma/schema/device.prisma b/packages/db/prisma/schema/device.prisma index 443ed14b0f..68d5af86f8 100644 --- a/packages/db/prisma/schema/device.prisma +++ b/packages/db/prisma/schema/device.prisma @@ -12,6 +12,9 @@ model Device { organizationId String organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) + agentSessionId String? + agentSession Session? @relation("DeviceAgentSession", fields: [agentSessionId], references: [id], onDelete: SetNull) + isCompliant Boolean @default(false) diskEncryptionEnabled Boolean @default(false) antivirusEnabled Boolean @default(false) @@ -30,6 +33,7 @@ model Device { @@index([memberId]) @@index([organizationId]) @@index([isCompliant]) + @@index([agentSessionId]) } enum DevicePlatform { diff --git a/packages/device-agent/package.json b/packages/device-agent/package.json index 9d42ce47aa..336075e7e6 100644 --- a/packages/device-agent/package.json +++ b/packages/device-agent/package.json @@ -16,7 +16,9 @@ "package:mac": "electron-builder --mac --config electron-builder.config.js", "package:win": "electron-builder --win --config electron-builder.config.js", "package:linux": "electron-builder --linux --config electron-builder.config.js", - "package:all": "electron-builder --mac --win --linux --config electron-builder.config.js" + "package:all": "electron-builder --mac --win --linux --config electron-builder.config.js", + "test": "vitest run", + "test:watch": "vitest" }, "dependencies": { "@trycompai/design-system": "*", @@ -36,6 +38,7 @@ "react-dom": "^19.1.0", "tailwindcss": "^4.1.4", "typescript": "^5.9.3", - "vite": "^6.3.5" + "vite": "^6.3.5", + "vitest": "^3.2.4" } } diff --git a/packages/device-agent/src/main/reporter.spec.ts b/packages/device-agent/src/main/reporter.spec.ts new file mode 100644 index 0000000000..d9a6cf4e66 --- /dev/null +++ b/packages/device-agent/src/main/reporter.spec.ts @@ -0,0 +1,77 @@ +import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest'; + +const getAuthMock = vi.fn(); +const setAuthMock = vi.fn(); +const getApiUrlMock = vi.fn().mockReturnValue('https://api.example.test'); + +vi.mock('./store', () => ({ + getAuth: () => getAuthMock(), + setAuth: (a: unknown) => setAuthMock(a), + getApiUrl: () => getApiUrlMock(), +})); +vi.mock('./logger', () => ({ log: vi.fn() })); + +describe('reportCheckResults silent upgrade', () => { + beforeEach(() => { + getAuthMock.mockReset(); + setAuthMock.mockReset(); + globalThis.fetch = vi.fn() as unknown as typeof fetch; + }); + + it('swaps the token when the first response returns upgradedSessionToken', async () => { + getAuthMock.mockReturnValue({ + sessionToken: 'old_tok', + cookieName: 'better-auth.session_token', + userId: 'usr_1', + organizations: [ + { organizationId: 'org_1', organizationName: 'A', deviceId: 'dev_1' }, + { organizationId: 'org_2', organizationName: 'B', deviceId: 'dev_2' }, + ], + }); + ((globalThis.fetch as unknown) as Mock) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ isCompliant: true, nextCheckIn: '', upgradedSessionToken: 'new_tok' }), + }) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ isCompliant: true, nextCheckIn: '' }), + }); + + const { reportCheckResults } = await import('./reporter'); + await reportCheckResults([]); + + const calls = ((globalThis.fetch as unknown) as Mock).mock.calls; + // First call uses old token + expect((calls[0][1] as RequestInit).headers).toMatchObject({ Authorization: 'Bearer old_tok' }); + // Second call uses new token (proves we swapped mid-loop) + expect((calls[1][1] as RequestInit).headers).toMatchObject({ Authorization: 'Bearer new_tok' }); + // Auth was persisted + expect(setAuthMock).toHaveBeenCalledWith( + expect.objectContaining({ sessionToken: 'new_tok' }), + ); + }); + + it('aborts further check-ins if persisting the upgraded token fails', async () => { + getAuthMock.mockReturnValue({ + sessionToken: 'old_tok', + cookieName: 'better-auth.session_token', + userId: 'usr_1', + organizations: [ + { organizationId: 'org_1', organizationName: 'A', deviceId: 'dev_1' }, + { organizationId: 'org_2', organizationName: 'B', deviceId: 'dev_2' }, + ], + }); + setAuthMock.mockImplementation(() => { throw new Error('disk full'); }); + ((globalThis.fetch as unknown) as Mock).mockResolvedValueOnce({ + ok: true, + json: async () => ({ isCompliant: true, nextCheckIn: '', upgradedSessionToken: 'new_tok' }), + }); + + const { reportCheckResults } = await import('./reporter'); + const result = await reportCheckResults([]); + + expect(result.allSucceeded).toBe(false); + expect(((globalThis.fetch as unknown) as Mock).mock.calls).toHaveLength(1); + }); +}); diff --git a/packages/device-agent/src/main/reporter.ts b/packages/device-agent/src/main/reporter.ts index 1dacce0ce1..64ebd1983f 100644 --- a/packages/device-agent/src/main/reporter.ts +++ b/packages/device-agent/src/main/reporter.ts @@ -1,7 +1,7 @@ import { AGENT_VERSION, API_ROUTES } from '../shared/constants'; import type { CheckInRequest, CheckInResponse, CheckResult } from '../shared/types'; import { log } from './logger'; -import { getApiUrl, getAuth } from './store'; +import { getApiUrl, getAuth, setAuth } from './store'; export interface ReportResult { allSucceeded: boolean; @@ -15,6 +15,10 @@ export interface ReportResult { /** * Sends compliance check results to the NestJS API for ALL registered organizations. * The same check results are reported to each org's device record. + * + * If the API returns `upgradedSessionToken` on any check-in, the token is persisted + * via setAuth and used for all subsequent iterations. If persistence fails, the loop + * aborts immediately to avoid further check-ins with a stale on-disk token. */ export async function reportCheckResults(checks: CheckResult[]): Promise { const auth = getAuth(); @@ -24,7 +28,7 @@ export async function reportCheckResults(checks: CheckResult[]): Promise ({ + safeStorage: { + encryptString: (s: string) => encryptStringMock(s), + decryptString: (b: Buffer) => decryptStringMock(b), + isEncryptionAvailable: () => isEncryptionAvailableMock(), + }, +})); + +describe('secureStoreToken / secureReadToken', () => { + beforeEach(() => { + encryptStringMock.mockReset(); + decryptStringMock.mockReset(); + isEncryptionAvailableMock.mockReset(); + }); + + it('encrypts with safeStorage when available and marks the blob encrypted', async () => { + isEncryptionAvailableMock.mockReturnValue(true); + encryptStringMock.mockReturnValue(Buffer.from('enc-bytes')); + const { secureStoreToken } = await import('./secure-storage'); + const blob = secureStoreToken('tok_abc'); + expect(blob).toEqual({ encrypted: true, value: 'ZW5jLWJ5dGVz' }); // base64 of 'enc-bytes' + expect(encryptStringMock).toHaveBeenCalledWith('tok_abc'); + }); + + it('falls back to plaintext when safeStorage is unavailable', async () => { + isEncryptionAvailableMock.mockReturnValue(false); + const { secureStoreToken } = await import('./secure-storage'); + const blob = secureStoreToken('tok_abc'); + expect(blob).toEqual({ encrypted: false, value: 'tok_abc' }); + expect(encryptStringMock).not.toHaveBeenCalled(); + }); + + it('decrypts encrypted blobs via safeStorage', async () => { + decryptStringMock.mockReturnValue('tok_abc'); + const { secureReadToken } = await import('./secure-storage'); + const value = secureReadToken({ encrypted: true, value: 'ZW5jLWJ5dGVz' }); + expect(value).toBe('tok_abc'); + expect(decryptStringMock).toHaveBeenCalledWith(Buffer.from('enc-bytes')); + }); + + it('reads plaintext blobs when not encrypted', async () => { + const { secureReadToken } = await import('./secure-storage'); + const value = secureReadToken({ encrypted: false, value: 'tok_abc' }); + expect(value).toBe('tok_abc'); + }); + + it('returns null when blob is null/undefined', async () => { + const { secureReadToken } = await import('./secure-storage'); + expect(secureReadToken(null)).toBeNull(); + expect(secureReadToken(undefined)).toBeNull(); + }); + + it('returns null when decryption fails (corrupted blob)', async () => { + decryptStringMock.mockImplementation(() => { throw new Error('decrypt failed'); }); + const { secureReadToken } = await import('./secure-storage'); + const value = secureReadToken({ encrypted: true, value: 'YmFk' }); + expect(value).toBeNull(); + }); +}); diff --git a/packages/device-agent/src/main/secure-storage.ts b/packages/device-agent/src/main/secure-storage.ts new file mode 100644 index 0000000000..b7e3f71f03 --- /dev/null +++ b/packages/device-agent/src/main/secure-storage.ts @@ -0,0 +1,28 @@ +import { safeStorage } from 'electron'; + +export interface SecureTokenBlob { + encrypted: boolean; + /** base64 when encrypted, raw string when not. */ + value: string; +} + +export function secureStoreToken(token: string): SecureTokenBlob { + if (safeStorage.isEncryptionAvailable()) { + const buf = safeStorage.encryptString(token); + return { encrypted: true, value: buf.toString('base64') }; + } + return { encrypted: false, value: token }; +} + +export function secureReadToken( + blob: SecureTokenBlob | null | undefined, +): string | null { + if (!blob) return null; + if (!blob.encrypted) return blob.value; + try { + const buf = Buffer.from(blob.value, 'base64'); + return safeStorage.decryptString(buf); + } catch { + return null; + } +} diff --git a/packages/device-agent/src/main/store.ts b/packages/device-agent/src/main/store.ts index 169b2ab09f..f46cfc7f83 100644 --- a/packages/device-agent/src/main/store.ts +++ b/packages/device-agent/src/main/store.ts @@ -1,12 +1,28 @@ import { app } from 'electron'; import Store from 'electron-store'; import type { CheckResult, StoredAuth } from '../shared/types'; +import type { SecureTokenBlob } from './secure-storage'; +import { secureReadToken, secureStoreToken } from './secure-storage'; declare const __PORTAL_URL__: string; declare const __API_URL__: string; +interface PersistedAuth { + sessionTokenBlob: SecureTokenBlob; + cookieName: string; + userId: string; + organizations: StoredAuth['organizations']; +} + +interface LegacyPlaintextAuth { + sessionToken: string; + cookieName: string; + userId: string; + organizations: StoredAuth['organizations']; +} + interface StoreSchema { - auth: StoredAuth | null; + auth: PersistedAuth | LegacyPlaintextAuth | null; portalUrl: string; apiUrl: string; lastCheckResults: CheckResult[]; @@ -42,12 +58,55 @@ if (store.get('apiUrl') !== defaultApiUrl) { store.set('apiUrl', defaultApiUrl); } +function isPersistedAuth(value: unknown): value is PersistedAuth { + return ( + typeof value === 'object' && + value !== null && + 'sessionTokenBlob' in value && + typeof (value as { sessionTokenBlob: unknown }).sessionTokenBlob === 'object' + ); +} + export function getAuth(): StoredAuth | null { - return store.get('auth'); + const raw = store.get('auth'); + if (!raw) return null; + + if (isPersistedAuth(raw)) { + const token = secureReadToken(raw.sessionTokenBlob); + if (!token) return null; + return { + sessionToken: token, + cookieName: raw.cookieName, + userId: raw.userId, + organizations: raw.organizations, + }; + } + + // Migrate legacy plaintext auth on first read. + const legacy = raw as LegacyPlaintextAuth; + const migrated: PersistedAuth = { + sessionTokenBlob: secureStoreToken(legacy.sessionToken), + cookieName: legacy.cookieName, + userId: legacy.userId, + organizations: legacy.organizations, + }; + store.set('auth', migrated); + return { + sessionToken: legacy.sessionToken, + cookieName: legacy.cookieName, + userId: legacy.userId, + organizations: legacy.organizations, + }; } export function setAuth(auth: StoredAuth): void { - store.set('auth', auth); + const persisted: PersistedAuth = { + sessionTokenBlob: secureStoreToken(auth.sessionToken), + cookieName: auth.cookieName, + userId: auth.userId, + organizations: auth.organizations, + }; + store.set('auth', persisted); } export function clearAuth(): void { diff --git a/packages/device-agent/src/shared/types.ts b/packages/device-agent/src/shared/types.ts index dfd04cf654..298159a5b6 100644 --- a/packages/device-agent/src/shared/types.ts +++ b/packages/device-agent/src/shared/types.ts @@ -47,6 +47,12 @@ export interface CheckInRequest { export interface CheckInResponse { isCompliant: boolean; nextCheckIn: string; + /** + * Present when the API silently upgraded the session from a generic session + * to a dedicated device-agent session. The agent must persist this token and + * use it for all subsequent requests. + */ + upgradedSessionToken?: string; } export interface DeviceStatus { diff --git a/packages/device-agent/vitest.config.ts b/packages/device-agent/vitest.config.ts new file mode 100644 index 0000000000..aacc4e15d9 --- /dev/null +++ b/packages/device-agent/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + environment: 'node', + include: ['src/**/*.spec.ts'], + }, +});