Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
545bc74
fix(api): add isActive filter to member query
chasprowebdev Jul 1, 2026
13aa3ef
fix(app): sort assignee list alphabetically in assignee dropdown
chasprowebdev Jul 1, 2026
98401f4
fix(risk-treatment): ensure live tasks filter before ranking in draft…
tofikwest Jul 2, 2026
c51b17c
fix(cloud-security): exclude per-task runs from latest scan selection
tofikwest Jul 2, 2026
70c393c
fix: address follow-up
tofikwest Jul 3, 2026
b4a5924
Merge branch 'main' into tofik/cs-702-bug-aws-cloud-security-tests
tofikwest Jul 3, 2026
cc493f0
fix(api): tie trust portal access link expiry to the grant duration
chasprowebdev Jul 3, 2026
1816d95
chore: merge release v3.97.0 back to main [skip ci]
github-actions[bot] Jul 6, 2026
e1b6c0b
Merge branch 'main' into tofik/cs-702-bug-aws-cloud-security-tests
tofikwest Jul 6, 2026
f89c323
feat(trust): add setting to show/hide the Security Questionnaire on t…
tofikwest Jul 6, 2026
1cef7fd
Merge pull request #3341 from trycompai/tofik/cs-702-bug-aws-cloud-se…
tofikwest Jul 6, 2026
a3eddac
Merge branch 'main' into chas/trust-portal-access-expiration
tofikwest Jul 6, 2026
a402487
Merge pull request #3342 from trycompai/chas/trust-portal-access-expi…
tofikwest Jul 6, 2026
01a86c3
Merge branch 'main' into chas/task-assignee
tofikwest Jul 6, 2026
7a5286b
Merge pull request #3320 from trycompai/chas/task-assignee
tofikwest Jul 6, 2026
1d3c3bd
Merge branch 'main' into tofik/trust-questionnaire-toggle
tofikwest Jul 6, 2026
662a487
Merge branch 'main' into tofik/cs-681-bug-draft-plan-suggest-links
tofikwest Jul 6, 2026
6e71c3f
fix(trust): address cubic review on the questionnaire toggle
tofikwest Jul 6, 2026
2a10daa
Merge remote-tracking branch 'origin/tofik/trust-questionnaire-toggle…
tofikwest Jul 6, 2026
eb3c097
fix(risk-treatment): harden orphan task-vector prune (CS-681 review)
tofikwest Jul 6, 2026
f555185
refactor(trust): share the public trust lookup + clarify a test name
tofikwest Jul 6, 2026
3e5ad6f
Merge pull request #3353 from trycompai/tofik/trust-questionnaire-toggle
tofikwest Jul 6, 2026
3a2bc95
Merge branch 'main' into tofik/cs-681-bug-draft-plan-suggest-links
tofikwest Jul 6, 2026
e2a66b0
Merge pull request #3340 from trycompai/tofik/cs-681-bug-draft-plan-s…
tofikwest Jul 6, 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
247 changes: 247 additions & 0 deletions apps/api/src/cloud-security/cloud-security-query.service.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,247 @@
// Regression tests for CS-702: the automated daily cloud scan appeared to
// return far fewer results than a manual scan. Root cause was in the
// latest-run lookups here — they picked the newest IntegrationCheckRun per
// connection with NO checkId scope, so the ~06:00 per-task evidence runs (a
// handful of results each) shadowed the ~05:00 full cloud-security scan
// (hundreds of results). These tests reproduce that exact ordering via a
// Prisma mock that emulates where + orderBy + distinct.

interface RunRow {
id: string;
connectionId: string;
checkId: string;
taskId: string | null;
status: string;
completedAt: Date;
durationMs: number | null;
totalChecked: number | null;
passedCount: number | null;
failedCount: number | null;
}

interface ResultRow {
id: string;
title: string;
description: string | null;
remediation: string | null;
severity: string | null;
collectedAt: Date;
checkRunId: string;
passed: boolean;
evidence: Record<string, unknown> | null;
resourceId: string | null;
resourceType: string | null;
}

// In-memory fixtures the mocked Prisma layer reads from. Set per-test.
let runRows: RunRow[] = [];
let resultRows: ResultRow[] = [];

// Emulate the slice of Prisma semantics these queries rely on: filter by the
// provided `where`, order by completedAt desc, then distinct-by-connectionId
// (Postgres DISTINCT ON keeps the first row after ordering).
function checkRunFindMany(args: {
where: {
connectionId?: { in: string[] };
checkId?: { in: string[] };
status?: { in: string[] };
};
distinct?: string[];
}): Promise<RunRow[]> {
const { where } = args;
let rows = runRows.filter((r) => {
if (where.connectionId && !where.connectionId.in.includes(r.connectionId)) {
return false;
}
if (where.checkId && !where.checkId.in.includes(r.checkId)) return false;
if (where.status && !where.status.in.includes(r.status)) return false;
return true;
});
rows = [...rows].sort(
(a, b) => b.completedAt.getTime() - a.completedAt.getTime(),
);
if (args.distinct?.includes('connectionId')) {
const seen = new Set<string>();
rows = rows.filter((r) => {
if (seen.has(r.connectionId)) return false;
seen.add(r.connectionId);
return true;
});
}
return Promise.resolve(rows);
}

function checkResultFindMany(args: {
where: { checkRunId: { in: string[] } };
}): Promise<ResultRow[]> {
const ids = args.where.checkRunId.in;
return Promise.resolve(resultRows.filter((r) => ids.includes(r.checkRunId)));
}

const dbMock = {
integrationConnection: { findMany: jest.fn() },
integration: { findMany: jest.fn() },
integrationCheckRun: { findMany: jest.fn(checkRunFindMany) },
integrationCheckResult: { findMany: jest.fn(checkResultFindMany) },
};

jest.mock('@db', () => ({ db: dbMock }));
jest.mock('@trycompai/integration-platform', () => ({ getManifest: jest.fn() }));
jest.mock('./evidence-sanitizer', () => ({
sanitizeEvidence: (v: unknown) => v,
}));
jest.mock('./check-definition.utils', () => ({
resolveCheckKey: jest.fn(() => 'check-key'),
}));
jest.mock('./cloud-security-query.legacy', () => ({
getLegacyFindings: jest.fn().mockResolvedValue([]),
}));
jest.mock('./finding-exceptions', () => ({
loadActiveExceptionSet: jest.fn().mockResolvedValue({ size: 0 }),
}));

import { getManifest } from '@trycompai/integration-platform';
import { CloudSecurityQueryService } from './cloud-security-query.service';

const ORG_ID = 'org_test';
const CONNECTION_ID = 'icn_aws';
const SCAN_AT = new Date('2026-07-02T05:00:00Z'); // full scan
const TASK_AT = new Date('2026-07-02T06:00:00Z'); // per-task run — NEWER

const awsConnection = {
id: CONNECTION_ID,
organizationId: ORG_ID,
provider: { slug: 'aws', name: 'AWS' },
metadata: {},
variables: {},
status: 'active',
lastSyncAt: TASK_AT,
createdAt: SCAN_AT,
updatedAt: TASK_AT,
};

// The 05:00 full scan: many results, taskId null, checkId 'aws-security-scan'.
const scanRun: RunRow = {
id: 'run_scan',
connectionId: CONNECTION_ID,
checkId: 'aws-security-scan',
taskId: null,
status: 'success',
completedAt: SCAN_AT,
durationMs: 5000,
totalChecked: 300,
passedCount: 250,
failedCount: 50,
};

// The 06:00 per-task evidence run: a handful of results, taskId set, checkId
// is a manifest check id. This is the run that used to shadow the scan.
const taskRun: RunRow = {
id: 'run_task',
connectionId: CONNECTION_ID,
checkId: 'aws-iam-account-security',
taskId: 'tsk_iam',
status: 'failed',
completedAt: TASK_AT,
durationMs: 120,
totalChecked: 3,
passedCount: 1,
failedCount: 2,
};

function scanResult(id: string): ResultRow {
return {
id,
title: `scan-${id}`,
description: null,
remediation: null,
severity: 'medium',
collectedAt: SCAN_AT,
checkRunId: 'run_scan',
passed: false,
evidence: {},
resourceId: `res-${id}`,
resourceType: 'AwsResource',
};
}

const taskResult: ResultRow = {
id: 'task_only',
title: 'task-only',
description: null,
remediation: null,
severity: 'high',
collectedAt: TASK_AT,
checkRunId: 'run_task',
passed: false,
evidence: {},
resourceId: 'res-task',
resourceType: 'AwsResource',
};

describe('CloudSecurityQueryService — latest-run scoping (CS-702)', () => {
let service: CloudSecurityQueryService;

beforeEach(() => {
jest.clearAllMocks();
service = new CloudSecurityQueryService();

// Both runs exist for the same connection; the per-task run is newer.
runRows = [scanRun, taskRun];
resultRows = [
scanResult('a'),
scanResult('b'),
scanResult('c'),
taskResult,
];

dbMock.integrationConnection.findMany.mockResolvedValue([awsConnection]);
dbMock.integration.findMany.mockResolvedValue([]);
dbMock.integrationCheckRun.findMany.mockImplementation(checkRunFindMany);
dbMock.integrationCheckResult.findMany.mockImplementation(
checkResultFindMany,
);
(getManifest as jest.Mock).mockReturnValue({
supportsMultipleConnections: true,
variables: [],
checks: [],
});
});

it('getFindings returns the full-scan results, not the newer per-task run', async () => {
const findings = await service.getFindings(ORG_ID);

// The three scan findings — and none from the per-task run.
expect(findings).toHaveLength(3);
expect(findings.map((f) => f.id).sort()).toEqual(['a', 'b', 'c']);
expect(findings.every((f) => f.checkId === 'aws-security-scan')).toBe(true);
expect(findings.some((f) => f.id === 'task_only')).toBe(false);
});

it('getProviders reports the full-scan summary, not the per-task run summary', async () => {
const providers = await service.getProviders(ORG_ID);

const aws = providers.find((p) => p.id === CONNECTION_ID);
expect(aws?.latestRun).toEqual(
expect.objectContaining({
totalChecked: 300,
passedCount: 250,
failedCount: 50,
}),
);
});

it('scopes the latest-run lookup to cloud-security scan checkIds', async () => {
await service.getFindings(ORG_ID);

expect(dbMock.integrationCheckRun.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({
checkId: {
in: ['aws-security-scan', 'gcp-security-scan', 'azure-security-scan'],
},
}),
}),
);
});
});
19 changes: 19 additions & 0 deletions apps/api/src/cloud-security/cloud-security-query.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,19 @@ export type { CloudFinding, CloudProvider, CloudProviderLatestRun };

const CLOUD_PROVIDER_SLUGS = ['aws', 'gcp', 'azure'] as const;

// The cloud-security scan persists exactly one run per connection under this
// coarse, run-level checkId (`storeFindings` in cloud-security.service.ts). The
// SAME connection also accumulates OTHER IntegrationCheckRun rows on different
// schedules — per-task evidence checks (checkId = manifest check id, taskId set,
// written ~06:00 UTC) and the on-connect "All Checks (Auto)" run (checkId
// 'all'), each holding only a handful of results. The latest-run lookups below
// MUST scope to these scan runs; otherwise a later per-task run shadows the
// full daily scan and the Cloud Tests dashboard shows a fraction of the
// findings (CS-702).
const CLOUD_SCAN_CHECK_IDS = CLOUD_PROVIDER_SLUGS.map(
(slug) => `${slug}-security-scan`,
);

/** Extract project ID from a GCP resource path like //iam.googleapis.com/projects/my-proj/... */
function extractProjectIdFromResource(
resourceId: string | null,
Expand Down Expand Up @@ -222,6 +235,9 @@ export class CloudSecurityQueryService {
const runs = await db.integrationCheckRun.findMany({
where: {
connectionId: { in: connectionIds },
// Only the full cloud-security scan run — not later per-task / 'all'
// runs on the same connection (see CLOUD_SCAN_CHECK_IDS).
checkId: { in: CLOUD_SCAN_CHECK_IDS },
status: { in: ['success', 'failed'] },
},
orderBy: { completedAt: 'desc' },
Expand Down Expand Up @@ -285,6 +301,9 @@ export class CloudSecurityQueryService {
const latestRuns = await db.integrationCheckRun.findMany({
where: {
connectionId: { in: connectionIds },
// Only the full cloud-security scan run — not later per-task / 'all'
// runs on the same connection (see CLOUD_SCAN_CHECK_IDS).
checkId: { in: CLOUD_SCAN_CHECK_IDS },
status: { in: ['success', 'failed'] },
},
orderBy: { completedAt: 'desc' },
Expand Down
2 changes: 1 addition & 1 deletion apps/api/src/email/templates/access-reclaim.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ export const AccessReclaimEmail = ({
<br />
<Section>
<Text className="text-[12px] leading-[24px] text-[#666666]">
This link will expire in 24 hours. Your grant expires on:{' '}
This link will remain valid until your access expires on:{' '}
<strong>
{expiresAt.toLocaleDateString('en-US', {
year: 'numeric',
Expand Down
2 changes: 1 addition & 1 deletion apps/api/src/people/utils/member-queries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ export class MemberQueries {
return db.member.findMany({
where: {
organizationId,
...(includeDeactivated ? {} : { deactivated: false }),
...(includeDeactivated ? {} : { deactivated: false, isActive: true }),
...(filters?.onboardAfter || filters?.onboardBefore
? {
onboardDate: {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { z } from 'zod';

export const UpdateSecurityQuestionnaireSchema = z.object({
enabled: z.boolean(),
});

export type UpdateSecurityQuestionnaireDto = z.infer<
typeof UpdateSecurityQuestionnaireSchema
>;
31 changes: 31 additions & 0 deletions apps/api/src/trust-portal/trust-access.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -680,6 +680,37 @@ export class TrustAccessController {
return this.trustAccessService.getPublicOverview(friendlyUrl);
}

@Get(':friendlyUrl/security-questionnaire')
@HttpCode(HttpStatus.OK)
@ApiOperation({
summary: 'Get Security Questionnaire visibility for a trust portal',
description:
"Whether the org offers the AI-assisted Security Questionnaire on its public trust portal. Defaults to enabled when the portal can't be resolved.",
})
@ApiParam({
name: 'friendlyUrl',
description: 'Trust Portal friendly URL or Organization ID',
})
@ApiResponse({
status: HttpStatus.OK,
description: 'Security Questionnaire visibility retrieved successfully',
schema: {
type: 'object',
properties: {
enabled: { type: 'boolean' },
},
},
})
async getPublicSecurityQuestionnaire(
@Param('friendlyUrl') friendlyUrl: string,
) {
const enabled =
await this.trustAccessService.getPublicSecurityQuestionnaireEnabled(
friendlyUrl,
);
return { enabled };
}

@Get(':friendlyUrl/custom-links')
@HttpCode(HttpStatus.OK)
@ApiOperation({
Expand Down
Loading
Loading