Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ export class AdminPoliciesController {
@Get(':orgId/policies')
@ApiOperation({ summary: 'List all policies for an organization (admin)' })
async list(@Param('orgId') orgId: string) {
return this.policiesService.findAll(orgId);
return this.policiesService.findAll({ organizationId: orgId });
}

@Post(':orgId/policies')
Expand Down
157 changes: 157 additions & 0 deletions apps/api/src/device-agent/device-registration.helpers.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
jest.mock('@db', () => ({
db: {
device: {
findUnique: jest.fn(),
findFirst: jest.fn(),
update: jest.fn(),
create: jest.fn(),
},
},
}));

import { db } from '@db';
import {
registerWithSerial,
registerWithoutSerial,
} from './device-registration.helpers';
import type { RegisterDeviceDto } from './dto/register-device.dto';

const mockDb = db as jest.Mocked<typeof db>;

const orgId = 'org_test';
const member = { id: 'mem_test' };

function makeDto(
overrides: Partial<RegisterDeviceDto> = {},
): RegisterDeviceDto {
return {
organizationId: orgId,
hostname: 'my-laptop.local',
name: 'My Laptop',
platform: 'macos',
osVersion: '15.0',
serialNumber: 'ABC123',
hardwareModel: 'MacBookPro18,1',
agentVersion: '1.0.0',
...overrides,
};
}

describe('registerWithSerial — orphan adoption', () => {
beforeEach(() => {
jest.clearAllMocks();
});

it('adopts an existing serial-less row for the same hostname+member instead of creating a duplicate', async () => {
// The bug scenario: agent first registered without a serial (e.g. cold-
// boot `system_profiler` returned empty), creating a row with
// serialNumber=null. A later registration succeeds in reading the
// serial. Without adoption, registerWithSerial would create a brand-new
// row and the old one would stay orphaned.
(mockDb.device.findUnique as jest.Mock).mockResolvedValue(null);
(mockDb.device.findFirst as jest.Mock).mockResolvedValue({
id: 'dev_orphan',
});
(mockDb.device.update as jest.Mock).mockResolvedValue({
id: 'dev_orphan',
});

const dto = makeDto();
await registerWithSerial({ member, dto });

expect(mockDb.device.findFirst).toHaveBeenCalledWith({
where: {
hostname: dto.hostname,
memberId: member.id,
organizationId: orgId,
serialNumber: null,
},
select: { id: true },
});
expect(mockDb.device.update).toHaveBeenCalledWith({
where: { id: 'dev_orphan' },
data: expect.objectContaining({
serialNumber: dto.serialNumber,
hostname: dto.hostname,
}),
});
expect(mockDb.device.create).not.toHaveBeenCalled();
});

it('creates a fresh row when no orphan exists', async () => {
(mockDb.device.findUnique as jest.Mock).mockResolvedValue(null);
(mockDb.device.findFirst as jest.Mock).mockResolvedValue(null);
(mockDb.device.create as jest.Mock).mockResolvedValue({ id: 'dev_new' });

const dto = makeDto();
await registerWithSerial({ member, dto });

expect(mockDb.device.update).not.toHaveBeenCalled();
expect(mockDb.device.create).toHaveBeenCalledWith({
data: expect.objectContaining({
serialNumber: dto.serialNumber,
memberId: member.id,
organizationId: orgId,
}),
});
});

it('updates the existing serial-match row without looking for an orphan', async () => {
// Plain re-registration of an already-known device — must not trigger
// the orphan lookup or do anything other than an in-place update.
(mockDb.device.findUnique as jest.Mock).mockResolvedValue({
id: 'dev_existing',
memberId: member.id,
});
(mockDb.device.update as jest.Mock).mockResolvedValue({
id: 'dev_existing',
});

const dto = makeDto();
await registerWithSerial({ member, dto });

expect(mockDb.device.findFirst).not.toHaveBeenCalled();
expect(mockDb.device.create).not.toHaveBeenCalled();
expect(mockDb.device.update).toHaveBeenCalledWith({
where: { id: 'dev_existing' },
data: expect.objectContaining({ hostname: dto.hostname }),
});
});

it('only adopts an orphan that belongs to the same member', async () => {
// Safety: the orphan lookup is scoped by memberId, so another member's
// serial-less row for the same hostname must not be hijacked.
(mockDb.device.findUnique as jest.Mock).mockResolvedValue(null);
(mockDb.device.findFirst as jest.Mock).mockResolvedValue(null);
(mockDb.device.create as jest.Mock).mockResolvedValue({ id: 'dev_new' });

const dto = makeDto();
await registerWithSerial({ member, dto });

const call = (mockDb.device.findFirst as jest.Mock).mock.calls[0]?.[0];
expect(call?.where.memberId).toBe(member.id);
expect(call?.where.serialNumber).toBeNull();
});
});

describe('registerWithoutSerial — unchanged behavior', () => {
beforeEach(() => {
jest.clearAllMocks();
});

it('updates the matching null-serial row when one exists', async () => {
(mockDb.device.findFirst as jest.Mock).mockResolvedValue({
id: 'dev_null',
});
(mockDb.device.update as jest.Mock).mockResolvedValue({ id: 'dev_null' });

const dto = makeDto({ serialNumber: undefined });
await registerWithoutSerial({ member, dto });

expect(mockDb.device.update).toHaveBeenCalledWith({
where: { id: 'dev_null' },
data: expect.any(Object),
});
expect(mockDb.device.create).not.toHaveBeenCalled();
});
});
27 changes: 27 additions & 0 deletions apps/api/src/device-agent/device-registration.helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,33 @@ export async function registerWithSerial({
});
}

// Adopt any prior serial-less registration for the same physical device
// before creating a new row. The agent's serial extraction can return
// undefined on a cold boot (e.g. macOS `system_profiler` cache not yet
// built) and a real value on a subsequent boot — without this, the second
// registration creates a duplicate while the first row stays orphaned and
// never receives another check-in (frozen at its old compliance state).
const orphan = await db.device.findFirst({
where: {
hostname: dto.hostname,
memberId: member.id,
organizationId: dto.organizationId,
serialNumber: null,
},
select: { id: true },
});

if (orphan) {
return db.device.update({
where: { id: orphan.id },
data: {
...updateData,
hostname: dto.hostname,
serialNumber: dto.serialNumber!,
},
});
}

return db.device.create({
data: {
...updateData,
Expand Down
8 changes: 7 additions & 1 deletion apps/api/src/openapi/operation-metadata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ const CORE_OPERATION_METADATA: Record<string, PublicOperationMetadata> = {
PoliciesController_getAllPolicies_v1: {
summary: 'List compliance policies',
description:
'Lists compliance policies for the organization. Use this to find a policy by name, look up a policy ID, browse drafts, or get an overview of all policies for SOC 2, ISO 27001, HIPAA, and GDPR workflows. Returns id, name, status, department, and other metadata for each policy. Pass excludeContent=true to skip the heavy TipTap content fields — recommended when you only need to identify a policy. To read or edit a single policy in detail, fetch it by ID via get-compliance-policy.',
'Lists active compliance policies by default. Use includeArchived=true to include archived rows and excludeContent=true when you only need policy metadata.',
codeSamples: [
{
lang: 'bash',
Expand All @@ -64,6 +64,12 @@ const CORE_OPERATION_METADATA: Record<string, PublicOperationMetadata> = {
source:
'curl --request GET --url "https://api.trycomp.ai/v1/policies?excludeContent=true" --header "X-API-Key: $COMP_AI_API_KEY"',
},
{
lang: 'bash',
label: 'List policies including archived',
source:
'curl --request GET --url "https://api.trycomp.ai/v1/policies?includeArchived=true" --header "X-API-Key: $COMP_AI_API_KEY"',
},
],
},
PoliciesController_createPolicy_v1: {
Expand Down
7 changes: 7 additions & 0 deletions apps/api/src/policies/dto/policy-responses.dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,13 @@ export class PolicyResponseDto {
})
isArchived: boolean;

@ApiProperty({
description: 'When the policy was archived by framework sync',
example: '2024-02-01T00:00:00.000Z',
nullable: true,
})
archivedAt?: Date;

@ApiProperty({
description: 'When the policy was created',
example: '2024-01-01T00:00:00.000Z',
Expand Down
41 changes: 38 additions & 3 deletions apps/api/src/policies/policies.controller.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ jest.mock('@db', () => ({
db: {
policy: {
findFirst: jest.fn(),
findUnique: jest.fn(),
update: jest.fn(),
},
control: {
Expand All @@ -43,6 +44,10 @@ jest.mock('@db', () => ({
findFirst: jest.fn(),
update: jest.fn(),
},
frameworkControlPolicyLink: {
deleteMany: jest.fn(),
},
$transaction: jest.fn(),
},
Frequency: {
monthly: 'monthly',
Expand Down Expand Up @@ -152,8 +157,10 @@ describe('PoliciesController', () => {

const result = await controller.getAllPolicies(orgId, mockAuthContext);

expect(policiesService.findAll).toHaveBeenCalledWith(orgId, {
expect(policiesService.findAll).toHaveBeenCalledWith({
organizationId: orgId,
excludeContent: false,
includeArchived: false,
});
expect(result).toEqual({
data: mockPolicies,
Expand All @@ -167,8 +174,10 @@ describe('PoliciesController', () => {

await controller.getAllPolicies(orgId, mockAuthContext, 'true');

expect(policiesService.findAll).toHaveBeenCalledWith(orgId, {
expect(policiesService.findAll).toHaveBeenCalledWith({
organizationId: orgId,
excludeContent: true,
includeArchived: false,
});
});

Expand All @@ -177,8 +186,22 @@ describe('PoliciesController', () => {

await controller.getAllPolicies(orgId, mockAuthContext, 'false');

expect(policiesService.findAll).toHaveBeenCalledWith(orgId, {
expect(policiesService.findAll).toHaveBeenCalledWith({
organizationId: orgId,
excludeContent: false,
includeArchived: false,
});
});

it('should pass includeArchived=true to service when query param is "true"', async () => {
mockPoliciesService.findAll.mockResolvedValue([]);

await controller.getAllPolicies(orgId, mockAuthContext, undefined, 'true');

expect(policiesService.findAll).toHaveBeenCalledWith({
organizationId: orgId,
excludeContent: false,
includeArchived: true,
});
});

Expand Down Expand Up @@ -621,7 +644,19 @@ describe('PoliciesController', () => {
describe('removePolicyControl', () => {
it('should disconnect control from policy and return success', async () => {
const { db } = require('@db');
db.policy.findUnique.mockResolvedValue({ controls: [] });
db.policy.update.mockResolvedValue({});
db.$transaction.mockImplementation(async (callback: (tx: unknown) => Promise<unknown>) =>
callback({
policy: {
findUnique: db.policy.findUnique,
update: db.policy.update,
},
frameworkControlPolicyLink: {
deleteMany: db.frameworkControlPolicyLink.deleteMany,
},
}),
);

const result = await controller.removePolicyControl(
'pol_1',
Expand Down
12 changes: 11 additions & 1 deletion apps/api/src/policies/policies.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,16 +122,26 @@ export class PoliciesController {
description:
'When true, omits `content` and `draftContent` from each policy in the response. Use this when listing policies to find one by name/ID — fetch the full content via GET /v1/policies/{id} after.',
})
@ApiQuery({
name: 'includeArchived',
required: false,
type: Boolean,
description:
'When true, includes user-archived and framework-sync-archived policies in the response. Defaults to false.',
})
@ApiExtension('x-speakeasy-mcp', { name: 'list-policies' })
@ApiResponse(GET_ALL_POLICIES_RESPONSES[200])
@ApiResponse(GET_ALL_POLICIES_RESPONSES[401])
async getAllPolicies(
@OrganizationId() organizationId: string,
@AuthContext() authContext: AuthContextType,
@Query('excludeContent') excludeContent?: string,
@Query('includeArchived') includeArchived?: string,
) {
const policies = await this.policiesService.findAll(organizationId, {
const policies = await this.policiesService.findAll({
organizationId,
excludeContent: excludeContent === 'true',
includeArchived: includeArchived === 'true',
});

return {
Expand Down
Loading
Loading