Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
9570390
fix(api): support API key auth on upload-submission endpoint
chasprowebdev May 27, 2026
9e892fd
Merge branch 'main' into chas/upload-submission-authorization
chasprowebdev May 27, 2026
0e9333f
Merge branch 'main' into chas/upload-submission-authorization
chasprowebdev May 28, 2026
4dc1a12
fix(app): keep removed multi-select values in the dropdown
chasprowebdev May 29, 2026
2b32c45
Merge branch 'main' into chas/account-settings-multiselect
chasprowebdev May 29, 2026
c43958f
chore: merge release v3.66.0 back to main [skip ci]
github-actions[bot] May 29, 2026
b103710
fix(api): avoid registering duplicated device when the status is flip…
chasprowebdev May 29, 2026
85f0b1e
fix(device-agent): make serial number extraction more robust on agent
chasprowebdev May 29, 2026
27e926e
Merge pull request #2972 from trycompai/chas/device-duplication-bug
tofikwest May 29, 2026
424d863
fix(app): resolve small linear tasks
tofikwest May 30, 2026
4db9154
fix(app): search integrations by name
tofikwest May 30, 2026
77a961a
Merge pull request #2977 from trycompai/tofik/sast-search-heading-botid
tofikwest May 30, 2026
d8ce450
fix(app): resolve archived policy and compliance UI tasks
tofikwest May 30, 2026
b680e74
Merge pull request #2978 from trycompai/tofik/archived-policies-soa-a…
tofikwest May 30, 2026
318330e
fix(app): fit requirements table columns
tofikwest May 30, 2026
76e106f
fix(app): warn before invalidating policy acknowledgments
tofikwest May 30, 2026
b1315a0
Merge pull request #2979 from trycompai/tofik/requirements-table-over…
tofikwest May 30, 2026
0c8f722
Merge branch 'main' into tofik/warn-policy-ack-reset
tofikwest May 30, 2026
8b0b863
fix(app): prevent duplicate publish all submissions
tofikwest May 30, 2026
f618429
Merge pull request #2980 from trycompai/tofik/warn-policy-ack-reset
tofikwest May 30, 2026
3a60c08
Merge branch 'main' into chas/account-settings-multiselect
chasprowebdev Jun 1, 2026
6d85b57
Merge branch 'main' into chas/upload-submission-authorization
chasprowebdev Jun 1, 2026
14bfc31
Merge pull request #2962 from trycompai/chas/account-settings-multise…
tofikwest Jun 1, 2026
82bf071
Merge branch 'main' into chas/upload-submission-authorization
chasprowebdev Jun 1, 2026
ec6a1fa
fix(api): exclude deactivated owners from acting-user fallback
chasprowebdev Jun 1, 2026
25f4d11
Merge pull request #2932 from trycompai/chas/upload-submission-author…
tofikwest Jun 1, 2026
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
23 changes: 23 additions & 0 deletions apps/api/src/auth/acting-user.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,29 @@ describe('ActingUserResolver', () => {
);
});

it('filters out deactivated / inactive members so uploads cannot be attributed to offboarded owners', async () => {
// Regression guard — without these filters, the oldest "owner"-role
// Member would win even if the user has been deactivated/offboarded,
// attributing API-driven mutations to someone who no longer has access.
mockDb.member.findFirst.mockResolvedValueOnce({ userId: 'usr_owner' });
const req = makeReq({
userId: undefined,
isApiKey: true,
apiKeyName: 'X',
});

await resolver.resolve(req, 'org_1');

expect(mockDb.member.findFirst).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({
deactivated: false,
isActive: true,
}),
}),
);
});

it('picks the OLDEST owner deterministically (orderBy createdAt asc)', async () => {
// Determinism matters — re-running the same automation should always
// attribute to the same user, even if newer owners are added/removed.
Expand Down
11 changes: 8 additions & 3 deletions apps/api/src/auth/acting-user.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,21 +91,26 @@ export class ActingUserResolver {
}

/**
* Find the oldest owner of an organization. Oldest is deterministic and
* stable: removing a recently-added owner doesn't change the attribution
* Find the oldest ACTIVE owner of an organization. Oldest is deterministic
* and stable: removing a recently-added owner doesn't change the attribution
* target, removing the oldest one just promotes the next one. Matches the
* pattern used elsewhere (e.g. tasks/task-notifier.service.ts).
* pattern used elsewhere (e.g. tasks/tasks.service.ts:getApiKeyActorUserId).
*
* Member.role is a comma-separated string (e.g. "owner,admin"), so we use
* Prisma's `contains` filter — same query shape as the 19+ other owner
* lookups in this codebase.
*
* `deactivated: false` + `isActive: true` excludes offboarded owners so we
* don't attribute new mutations to a user who no longer has org access.
*/
private async findOrgOwnerUserId(
organizationId: string,
): Promise<string | null> {
const owner = await db.member.findFirst({
where: {
organizationId,
deactivated: false,
isActive: true,
role: { contains: 'owner' },
},
orderBy: { createdAt: 'asc' },
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
69 changes: 64 additions & 5 deletions apps/api/src/evidence-forms/evidence-forms.controller.spec.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,16 @@
import { Test, TestingModule } from '@nestjs/testing';
import { BadRequestException } from '@nestjs/common';
import { EvidenceFormsController } from './evidence-forms.controller';
import { EvidenceFormsService } from './evidence-forms.service';
import { ActingUserResolver } from '../auth/acting-user.service';
import { HybridAuthGuard } from '../auth/hybrid-auth.guard';
import { PermissionGuard } from '../auth/permission.guard';
import type { AuthContext as AuthContextType } from '../auth/types';
import type {
AuthContext as AuthContextType,
AuthenticatedRequest,
} from '../auth/types';

jest.mock('@db', () => ({ db: {} }));

jest.mock('../auth/auth.server', () => ({
auth: { api: { getSession: jest.fn() } },
Expand Down Expand Up @@ -61,10 +68,17 @@ describe('EvidenceFormsController', () => {
userRoles: ['admin'],
};

const mockActingUser = {
resolve: jest.fn(),
};

beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [EvidenceFormsController],
providers: [{ provide: EvidenceFormsService, useValue: mockService }],
providers: [
{ provide: EvidenceFormsService, useValue: mockService },
{ provide: ActingUserResolver, useValue: mockActingUser },
],
})
.overrideGuard(HybridAuthGuard)
.useValue(mockGuard)
Expand Down Expand Up @@ -298,26 +312,71 @@ describe('EvidenceFormsController', () => {
});

describe('uploadSubmission', () => {
it('should call service.uploadSubmission with correct params', async () => {
it('should call service.uploadSubmission with the session userId', async () => {
const body = { fileUrl: 'https://example.com/file.pdf' };
const mockResult = { id: 'sub_upload' };
mockService.uploadSubmission.mockResolvedValue(mockResult);
mockActingUser.resolve.mockResolvedValue({
userId: 'user_1',
source: 'session',
});

const req = {} as AuthenticatedRequest;
const result = await controller.uploadSubmission(
'org_1',
mockAuthContext,
'security-awareness',
body,
req,
);

expect(result).toEqual(mockResult);
expect(mockActingUser.resolve).toHaveBeenCalledWith(req, 'org_1');
expect(service.uploadSubmission).toHaveBeenCalledWith({
organizationId: 'org_1',
formType: 'security-awareness',
authContext: mockAuthContext,
userId: 'user_1',
payload: body,
});
});

it('should attribute to the org owner when called with API key auth', async () => {
const body = { fileUrl: 'https://example.com/file.pdf' };
const mockResult = { id: 'sub_upload' };
mockService.uploadSubmission.mockResolvedValue(mockResult);
mockActingUser.resolve.mockResolvedValue({
userId: 'owner_user',
source: 'org-owner-fallback',
callerLabel: 'via API key "CI"',
});

const req = {} as AuthenticatedRequest;
await controller.uploadSubmission('org_1', 'meeting', body, req);

expect(service.uploadSubmission).toHaveBeenCalledWith({
organizationId: 'org_1',
formType: 'meeting',
userId: 'owner_user',
payload: body,
});
});

it('should throw BadRequestException when no owner can be resolved', async () => {
mockActingUser.resolve.mockResolvedValue({
userId: null,
source: 'org-owner-fallback',
callerLabel: 'via API key',
});

await expect(
controller.uploadSubmission(
'org_1',
'meeting',
{ fileUrl: 'x' },
{} as AuthenticatedRequest,
),
).rejects.toBeInstanceOf(BadRequestException);
expect(service.uploadSubmission).not.toHaveBeenCalled();
});
});

describe('reviewSubmission', () => {
Expand Down
Loading
Loading