diff --git a/apps/api/src/integration-platform/controllers/task-integrations.controller.spec.ts b/apps/api/src/integration-platform/controllers/task-integrations.controller.spec.ts new file mode 100644 index 0000000000..93ea32103a --- /dev/null +++ b/apps/api/src/integration-platform/controllers/task-integrations.controller.spec.ts @@ -0,0 +1,411 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { TaskIntegrationsController } from './task-integrations.controller'; +import { HybridAuthGuard } from '../../auth/hybrid-auth.guard'; +import { PermissionGuard } from '../../auth/permission.guard'; +import { ConnectionRepository } from '../repositories/connection.repository'; +import { ProviderRepository } from '../repositories/provider.repository'; +import { CheckRunRepository } from '../repositories/check-run.repository'; +import { CredentialVaultService } from '../services/credential-vault.service'; +import { OAuthCredentialsService } from '../services/oauth-credentials.service'; +import { TaskIntegrationChecksService } from '../services/task-integration-checks.service'; + +jest.mock('../../auth/auth.server', () => ({ + auth: { api: { getSession: jest.fn() } }, +})); + +jest.mock('@trycompai/auth', () => ({ + statement: { integration: ['create', 'read', 'update', 'delete'] }, + BUILT_IN_ROLE_PERMISSIONS: {}, +})); + +jest.mock('@db', () => ({ + db: { + task: { findUnique: jest.fn(), update: jest.fn() }, + }, +})); + +jest.mock('@trycompai/integration-platform', () => ({ + getManifest: jest.fn(), + getActiveManifests: jest.fn(), + runAllChecks: jest.fn(), +})); + +import { db } from '@db'; +import { getManifest, runAllChecks } from '@trycompai/integration-platform'; + +const mockedGetManifest = getManifest as jest.Mock; +const mockedRunAllChecks = runAllChecks as jest.Mock; +// Grab through the module reference to avoid the `unbound-method` lint rule. +const mockedTask = db.task as unknown as { + findUnique: jest.Mock; + update: jest.Mock; +}; +const mockTaskFindUnique = mockedTask.findUnique; +const mockTaskUpdate = mockedTask.update; + +const MANIFEST = { + id: 'aws', + name: 'Amazon Web Services', + auth: { type: 'custom' }, + checks: [ + { id: 'aws-s3-encryption', name: 'S3 — default encryption enabled' }, + ], +}; + +const VALID_CREDS = { + roleArn: 'arn:aws:iam::111111111111:role/x', + externalId: 'ext', + regions: ['us-east-1'], +}; + +function passingResult() { + return { + results: [ + { + checkId: 'aws-s3-encryption', + checkName: 'S3 — default encryption enabled', + status: 'success', + durationMs: 10, + error: undefined, + result: { + findings: [], + passingResults: [ + { + resourceType: 'aws-s3-bucket', + resourceId: 'b1', + title: 'ok', + description: 'ok', + }, + ], + summary: { totalChecked: 1 }, + logs: [], + }, + }, + ], + }; +} + +function failingResult() { + return { + results: [ + { + checkId: 'aws-s3-encryption', + checkName: 'S3 — default encryption enabled', + status: 'failed', + durationMs: 10, + error: undefined, + result: { + findings: [ + { + resourceType: 'aws-s3-bucket', + resourceId: 'b2', + title: 'no enc', + description: 'x', + severity: 'high', + remediation: 'fix', + }, + ], + passingResults: [], + summary: { totalChecked: 1 }, + logs: [], + }, + }, + ], + }; +} + +describe('TaskIntegrationsController', () => { + let controller: TaskIntegrationsController; + + const mockConnectionRepository = { + findById: jest.fn(), + findActiveByProviderAndOrg: jest.fn(), + }; + const mockProviderRepository = { findById: jest.fn() }; + const mockCheckRunRepository = { + create: jest.fn(), + complete: jest.fn(), + addResults: jest.fn(), + findLatestPerConnectionAndCheckByTask: jest.fn(), + }; + const mockCredentialVaultService = { getDecryptedCredentials: jest.fn() }; + const mockOAuthCredentialsService = { + getCredentials: jest.fn(), + checkAvailability: jest.fn(), + }; + const mockTaskIntegrationChecksService = { + disconnectCheckFromTask: jest.fn(), + reconnectCheckToTask: jest.fn(), + }; + const mockGuard = { canActivate: jest.fn().mockReturnValue(true) }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [TaskIntegrationsController], + providers: [ + { provide: ConnectionRepository, useValue: mockConnectionRepository }, + { provide: ProviderRepository, useValue: mockProviderRepository }, + { provide: CheckRunRepository, useValue: mockCheckRunRepository }, + { + provide: CredentialVaultService, + useValue: mockCredentialVaultService, + }, + { + provide: OAuthCredentialsService, + useValue: mockOAuthCredentialsService, + }, + { + provide: TaskIntegrationChecksService, + useValue: mockTaskIntegrationChecksService, + }, + ], + }) + .overrideGuard(HybridAuthGuard) + .useValue(mockGuard) + .overrideGuard(PermissionGuard) + .useValue(mockGuard) + .compile(); + + controller = module.get(TaskIntegrationsController); + jest.clearAllMocks(); + + mockTaskFindUnique.mockResolvedValue({ + id: 'task_1', + organizationId: 'org_1', + status: 'todo', + frequency: null, + }); + mockProviderRepository.findById.mockResolvedValue({ + id: 'prov_aws', + slug: 'aws', + }); + mockedGetManifest.mockReturnValue(MANIFEST); + mockCheckRunRepository.create.mockImplementation(() => + Promise.resolve({ id: 'icr_x', startedAt: new Date() }), + ); + mockCheckRunRepository.complete.mockResolvedValue({}); + mockCheckRunRepository.addResults.mockResolvedValue({}); + mockCredentialVaultService.getDecryptedCredentials.mockResolvedValue( + VALID_CREDS, + ); + mockTaskUpdate.mockResolvedValue({}); + }); + + const body = { connectionId: 'conn_1', checkId: 'aws-s3-encryption' }; + + describe('runCheckForTask (all accounts)', () => { + it('runs the check for EVERY active account of the provider', async () => { + mockConnectionRepository.findById.mockResolvedValue({ + id: 'conn_1', + organizationId: 'org_1', + providerId: 'prov_aws', + status: 'active', + }); + mockConnectionRepository.findActiveByProviderAndOrg.mockResolvedValue([ + { + id: 'conn_1', + organizationId: 'org_1', + providerId: 'prov_aws', + metadata: {}, + variables: {}, + }, + { + id: 'conn_2', + organizationId: 'org_1', + providerId: 'prov_aws', + metadata: {}, + variables: {}, + }, + ]); + mockedRunAllChecks.mockResolvedValue(passingResult()); + + const result = await controller.runCheckForTask('task_1', 'org_1', body); + + expect( + mockConnectionRepository.findActiveByProviderAndOrg, + ).toHaveBeenCalledWith('prov_aws', 'org_1'); + // One run per account. + expect(mockCheckRunRepository.create).toHaveBeenCalledTimes(2); + expect(mockedRunAllChecks).toHaveBeenCalledTimes(2); + expect(result.accountsRun).toBe(2); + expect(result.success).toBe(true); + expect(result.taskStatus).toBe('done'); + }); + + it('marks the task failed if ANY account has findings', async () => { + mockConnectionRepository.findById.mockResolvedValue({ + id: 'conn_1', + organizationId: 'org_1', + providerId: 'prov_aws', + status: 'active', + }); + mockConnectionRepository.findActiveByProviderAndOrg.mockResolvedValue([ + { + id: 'conn_1', + organizationId: 'org_1', + providerId: 'prov_aws', + metadata: {}, + variables: {}, + }, + { + id: 'conn_2', + organizationId: 'org_1', + providerId: 'prov_aws', + metadata: {}, + variables: {}, + }, + ]); + mockedRunAllChecks + .mockResolvedValueOnce(passingResult()) + .mockResolvedValueOnce(failingResult()); + + const result = await controller.runCheckForTask('task_1', 'org_1', body); + + expect(result.accountsRun).toBe(2); + expect(result.totalFindings).toBe(1); + expect(result.taskStatus).toBe('failed'); + expect(mockTaskUpdate).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ status: 'failed' }), + }), + ); + }); + + it('records a failed run for a bad account but keeps running the rest', async () => { + mockConnectionRepository.findById.mockResolvedValue({ + id: 'conn_1', + organizationId: 'org_1', + providerId: 'prov_aws', + status: 'active', + }); + mockConnectionRepository.findActiveByProviderAndOrg.mockResolvedValue([ + { + id: 'conn_1', + organizationId: 'org_1', + providerId: 'prov_aws', + metadata: {}, + variables: {}, + }, + { + id: 'conn_2', + organizationId: 'org_1', + providerId: 'prov_aws', + metadata: {}, + variables: {}, + }, + ]); + // First account has no credentials → recorded failed; second runs fine. + mockCredentialVaultService.getDecryptedCredentials + .mockResolvedValueOnce({}) + .mockResolvedValueOnce(VALID_CREDS); + mockedRunAllChecks.mockResolvedValue(passingResult()); + + const result = await controller.runCheckForTask('task_1', 'org_1', body); + + expect(mockCheckRunRepository.create).toHaveBeenCalledTimes(2); + // Only the healthy account actually executed the check. + expect(mockedRunAllChecks).toHaveBeenCalledTimes(1); + // The bad account's run was completed as failed. + expect(mockCheckRunRepository.complete).toHaveBeenCalledWith( + 'icr_x', + expect.objectContaining({ status: 'failed' }), + ); + expect(result.accountsRun).toBe(2); + expect(result.success).toBe(true); + }); + + it('rejects an inactive referenced connection (never runs a non-active account)', async () => { + mockConnectionRepository.findById.mockResolvedValue({ + id: 'conn_1', + organizationId: 'org_1', + providerId: 'prov_aws', + status: 'paused', + }); + + await expect( + controller.runCheckForTask('task_1', 'org_1', body), + ).rejects.toThrow('Connection is not active'); + expect( + mockConnectionRepository.findActiveByProviderAndOrg, + ).not.toHaveBeenCalled(); + expect(mockedRunAllChecks).not.toHaveBeenCalled(); + }); + }); + + describe('getTaskCheckRuns', () => { + it('labels each run with its account id + connection label', async () => { + mockCheckRunRepository.findLatestPerConnectionAndCheckByTask.mockResolvedValue( + [ + { + id: 'icr_1', + checkId: 'aws-s3-encryption', + checkName: 'S3', + status: 'success', + startedAt: new Date(), + completedAt: new Date(), + durationMs: 10, + totalChecked: 1, + passedCount: 1, + failedCount: 0, + errorMessage: null, + logs: [], + connectionId: 'conn_1', + createdAt: new Date(), + results: [], + connection: { + id: 'conn_1', + metadata: { connectionName: 'Production AWS' }, + provider: { slug: 'aws', name: 'AWS' }, + }, + }, + { + id: 'icr_2', + checkId: 'aws-s3-encryption', + checkName: 'S3', + status: 'success', + startedAt: new Date(), + completedAt: new Date(), + durationMs: 10, + totalChecked: 1, + passedCount: 1, + failedCount: 0, + errorMessage: null, + logs: [], + connectionId: 'conn_2', + createdAt: new Date(), + results: [], + connection: { + id: 'conn_2', + metadata: { roleArn: 'arn:aws:iam::222222222222:role/x' }, + provider: { slug: 'aws', name: 'AWS' }, + }, + }, + ], + ); + + const { runs } = await controller.getTaskCheckRuns('task_1', 'org_1'); + + expect(runs).toHaveLength(2); + expect(runs[0]).toMatchObject({ + connectionId: 'conn_1', + connectionLabel: 'Production AWS', + }); + expect(runs[1]).toMatchObject({ + connectionId: 'conn_2', + connectionLabel: 'AWS 222222222222', + }); + }); + + it('rejects a task that does not belong to the caller’s org (no cross-tenant leak)', async () => { + // Task lookup scoped to { id, organizationId } returns nothing. + mockTaskFindUnique.mockResolvedValue(null); + + await expect( + controller.getTaskCheckRuns('task_other_org', 'org_1'), + ).rejects.toThrow('Task not found'); + expect( + mockCheckRunRepository.findLatestPerConnectionAndCheckByTask, + ).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/apps/api/src/integration-platform/controllers/task-integrations.controller.ts b/apps/api/src/integration-platform/controllers/task-integrations.controller.ts index 9d758739df..64bf9ae378 100644 --- a/apps/api/src/integration-platform/controllers/task-integrations.controller.ts +++ b/apps/api/src/integration-platform/controllers/task-integrations.controller.ts @@ -27,7 +27,6 @@ import { getActiveManifests, getManifest, runAllChecks, - type CheckRunResult, } from '@trycompai/integration-platform'; import { ConnectionRepository } from '../repositories/connection.repository'; import { ProviderRepository } from '../repositories/provider.repository'; @@ -38,8 +37,22 @@ import { TaskIntegrationChecksService } from '../services/task-integration-check import { getStringValue } from '../utils/credential-utils'; import { isCheckDisabledForTask } from '../utils/disabled-task-checks'; import { getProviderSummary } from '../utils/provider-summary'; +import { getConnectionLabel } from '../utils/connection-label'; import { db } from '@db'; -import type { Prisma } from '@db'; +import type { IntegrationConnection, Prisma } from '@db'; + +/** Manifest + check types derived from the integration-platform registry. */ +type IntegrationManifest = NonNullable>; +type IntegrationCheckDef = NonNullable[number]; + +/** Outcome of running one check against one connection (account). */ +interface ConnectionCheckOutcome { + connectionId: string; + checkRunId: string; + status: 'success' | 'failed' | 'error'; + findings: number; + passing: number; +} interface TaskIntegrationCheck { integrationId: string; @@ -291,8 +304,12 @@ export class TaskIntegrationsController { @Body() body: RunCheckForTaskDto, ): Promise<{ success: boolean; - result?: CheckRunResult; error?: string; + accountsRun?: number; + totalPassing?: number; + totalFindings?: number; + /** True when at least one account could not be checked (e.g. bad creds). */ + hadErrors?: boolean; checkRunId?: string; taskStatus?: string | null; }> { @@ -307,30 +324,32 @@ export class TaskIntegrationsController { throw new HttpException('Task not found', HttpStatus.NOT_FOUND); } - // Get connection - const connection = await this.connectionRepository.findById(connectionId); - if (!connection || connection.organizationId !== organizationId) { + // The UI references one connection, but a customer may have several + // accounts connected for the same provider (e.g. multiple AWS accounts). + // Use the referenced connection only to resolve the provider, then run the + // check against EVERY active account of that provider — matching the + // scheduler, which already runs each connection. This is why running once + // checked only the first account. + const referencedConnection = + await this.connectionRepository.findById(connectionId); + if ( + !referencedConnection || + referencedConnection.organizationId !== organizationId + ) { throw new HttpException('Connection not found', HttpStatus.NOT_FOUND); } - if (connection.status !== 'active') { + // Reject inactive connections up front. This also keeps the fallback below + // safe: it can only ever contain a connection we've verified is active. + if (referencedConnection.status !== 'active') { throw new HttpException( 'Connection is not active', HttpStatus.BAD_REQUEST, ); } - // Reject runs for checks that have been disconnected from this task. - if (isCheckDisabledForTask(connection.metadata, taskId, checkId)) { - throw new HttpException( - 'This check is disconnected from the task. Reconnect it before running.', - HttpStatus.BAD_REQUEST, - ); - } - - // Get provider and manifest const provider = await this.providerRepository.findById( - connection.providerId, + referencedConnection.providerId, ); if (!provider) { throw new HttpException('Provider not found', HttpStatus.NOT_FOUND); @@ -341,72 +360,169 @@ export class TaskIntegrationsController { throw new HttpException('Manifest not found', HttpStatus.NOT_FOUND); } - // Get credentials - const credentials = - await this.credentialVaultService.getDecryptedCredentials(connectionId); - - // Validate credentials based on auth type - if (!credentials) { + const checkDef = manifest.checks?.find((c) => c.id === checkId); + if (!checkDef) { throw new HttpException( - 'No credentials found for connection', - HttpStatus.BAD_REQUEST, + `Check ${checkId} not found`, + HttpStatus.NOT_FOUND, ); } - // For OAuth, require access_token. For custom auth (like AWS), check for required fields - if (manifest.auth.type === 'oauth2' && !credentials.access_token) { - throw new HttpException( - 'No valid OAuth credentials found. Please reconnect.', - HttpStatus.BAD_REQUEST, + const activeConnections = + await this.connectionRepository.findActiveByProviderAndOrg( + provider.id, + organizationId, ); + // Never run zero accounts: if a status race leaves the active query empty, + // fall back to the referenced connection (verified active above). + const connections = + activeConnections.length > 0 ? activeConnections : [referencedConnection]; + + let totalFindings = 0; + let totalPassing = 0; + let accountsRun = 0; + let hasExecutionError = false; + let lastCheckRunId: string | undefined; + + // Sequential so each per-account run commits as it completes — a slow or + // failing account still leaves the earlier accounts' results persisted. + for (const conn of connections) { + // Respect a check that was disconnected from this task for this account. + if (isCheckDisabledForTask(conn.metadata, taskId, checkId)) { + continue; + } + const outcome = await this.runCheckForConnection({ + connection: conn, + manifest, + checkDef, + taskId, + organizationId, + }); + accountsRun += 1; + totalFindings += outcome.findings; + totalPassing += outcome.passing; + if (outcome.status === 'error') hasExecutionError = true; + lastCheckRunId = outcome.checkRunId; } - // For custom auth, the credentials are the form field values directly - if ( - manifest.auth.type === 'custom' && - Object.keys(credentials).length === 0 - ) { + if (accountsRun === 0) { throw new HttpException( - 'No valid credentials found for custom integration', + 'This check is disconnected from the task. Reconnect it before running.', HttpStatus.BAD_REQUEST, ); } - const variables = - (connection.variables as Record< - string, - string | number | boolean | string[] | undefined - >) || {}; + // Aggregate task status across ALL accounts: any finding anywhere → failed; + // else any passing result → done; else leave unchanged. This replaces the + // old single-run update and removes the previous last-writer race. + const newStatus = + totalFindings > 0 ? 'failed' : totalPassing > 0 ? 'done' : null; + + if (newStatus) { + const isTransitioningToDone = + newStatus === 'done' && task.status !== 'done'; + + let reviewDate: Date | undefined; + if (isTransitioningToDone && task.frequency) { + reviewDate = new Date(); + switch (task.frequency) { + case 'monthly': + reviewDate.setMonth(reviewDate.getMonth() + 1); + break; + case 'quarterly': + reviewDate.setMonth(reviewDate.getMonth() + 3); + break; + case 'yearly': + reviewDate.setFullYear(reviewDate.getFullYear() + 1); + break; + } + } - // Find the check definition to get the name - const checkDef = manifest.checks?.find((c) => c.id === checkId); - if (!checkDef) { - throw new HttpException( - `Check ${checkId} not found`, - HttpStatus.NOT_FOUND, + await db.task.update({ + where: { id: taskId }, + data: { + status: newStatus, + ...(reviewDate ? { reviewDate } : {}), + }, + }); + this.logger.log( + `Updated task ${taskId} status to ${newStatus} across ${accountsRun} account(s)${reviewDate ? `, next review: ${reviewDate.toISOString()}` : ''}`, ); } - // Build token refresh callback for OAuth integrations that support it - let onTokenRefresh: (() => Promise) | undefined; - if (manifest.auth.type === 'oauth2') { - const oauthConfig = manifest.auth.config; + return { + success: true, + accountsRun, + totalPassing, + totalFindings, + hadErrors: hasExecutionError, + checkRunId: lastCheckRunId, + taskStatus: newStatus, + }; + } - // Only set up refresh callback if provider supports refresh tokens - const supportsRefresh = oauthConfig.supportsRefreshToken !== false; + /** + * Run one check against ONE connection (account) and persist the run + + * results. Resilient: any failure (missing credentials, execution error) is + * recorded on the check run and returned as an outcome rather than thrown, so + * a caller looping over multiple accounts is never aborted by one bad + * account. Does NOT update task status — the caller aggregates across + * accounts. + */ + private async runCheckForConnection(params: { + connection: IntegrationConnection; + manifest: IntegrationManifest; + checkDef: IntegrationCheckDef; + taskId: string; + organizationId: string; + }): Promise { + const { connection, manifest, checkDef, taskId, organizationId } = params; + const connectionId = connection.id; + + // Create the run up front so even an account that fails credential + // validation still produces a visible (failed) run row for that account. + const checkRun = await this.checkRunRepository.create({ + connectionId, + taskId, + checkId: checkDef.id, + checkName: checkDef.name, + }); - if (supportsRefresh) { - const oauthCredentials = - await this.oauthCredentialsService.getCredentials( - provider.slug, - organizationId, - ); + try { + const credentials = + await this.credentialVaultService.getDecryptedCredentials(connectionId); + + if ( + !credentials || + (manifest.auth.type === 'oauth2' && !credentials.access_token) || + (manifest.auth.type === 'custom' && + Object.keys(credentials).length === 0) + ) { + throw new Error( + 'No valid credentials found for this connection. Reconnect the integration.', + ); + } - if (oauthCredentials) { - onTokenRefresh = async () => { - return this.credentialVaultService.refreshOAuthTokens( - connectionId, - { + const variables = + (connection.variables as Record< + string, + string | number | boolean | string[] | undefined + >) || {}; + + // Build token refresh callback for OAuth integrations that support it. + let onTokenRefresh: (() => Promise) | undefined; + if (manifest.auth.type === 'oauth2') { + const oauthConfig = manifest.auth.config; + const supportsRefresh = oauthConfig.supportsRefreshToken !== false; + if (supportsRefresh) { + const oauthCredentials = + await this.oauthCredentialsService.getCredentials( + manifest.id, + organizationId, + ); + if (oauthCredentials) { + onTokenRefresh = async () => + this.credentialVaultService.refreshOAuthTokens(connectionId, { tokenUrl: oauthConfig.tokenUrl, refreshUrl: oauthConfig.refreshUrl, clientId: oauthCredentials.clientId, @@ -414,23 +530,11 @@ export class TaskIntegrationsController { clientAuthMethod: oauthConfig.clientAuthMethod, scope: oauthCredentials.scopes.join(' '), tokenParams: oauthConfig.tokenParams, - }, - ); - }; + }); + } } } - } - - // Create check run record - const checkRun = await this.checkRunRepository.create({ - connectionId, - taskId, - checkId, - checkName: checkDef.name, - }); - try { - // Run the specific check const accessToken = getStringValue(credentials.access_token); const result = await runAllChecks({ manifest, @@ -442,7 +546,7 @@ export class TaskIntegrationsController { variables, connectionId, organizationId, - checkId, // Only run this specific check + checkId: checkDef.id, // Only run this specific check onTokenRefresh, logger: { info: (msg, data) => this.logger.log(msg, data), @@ -452,7 +556,6 @@ export class TaskIntegrationsController { }); const checkResult = result.results[0]; - if (!checkResult) { await this.checkRunRepository.complete(checkRun.id, { status: 'failed', @@ -462,12 +565,16 @@ export class TaskIntegrationsController { failedCount: 0, errorMessage: 'Check not found in manifest', }); - return { success: false, error: 'Check not found' }; + return { + connectionId, + checkRunId: checkRun.id, + status: 'error', + findings: 0, + passing: 0, + }; } - // Store individual results const resultsToStore = [ - // Passing results ...checkResult.result.passingResults.map((r) => ({ checkRunId: checkRun.id, passed: true, @@ -477,7 +584,6 @@ export class TaskIntegrationsController { description: r.description, evidence: r.evidence as Prisma.InputJsonValue, })), - // Findings (failures) ...checkResult.result.findings.map((f) => ({ checkRunId: checkRun.id, passed: false, @@ -485,12 +591,7 @@ export class TaskIntegrationsController { resourceId: f.resourceId, title: f.title, description: f.description, - severity: f.severity as - | 'info' - | 'low' - | 'medium' - | 'high' - | 'critical', + severity: f.severity, remediation: f.remediation, evidence: f.evidence as Prisma.InputJsonValue, })), @@ -500,7 +601,6 @@ export class TaskIntegrationsController { await this.checkRunRepository.addResults(resultsToStore); } - // Complete the check run await this.checkRunRepository.complete(checkRun.id, { status: checkResult.status === 'error' ? 'failed' : checkResult.status, durationMs: checkResult.durationMs, @@ -517,70 +617,36 @@ export class TaskIntegrationsController { }); this.logger.log( - `Check ${checkId} for task ${taskId}: ${checkResult.status} - ${checkResult.result.findings.length} findings, ${checkResult.result.passingResults.length} passing`, + `Check ${checkDef.id} for task ${taskId} (connection ${connectionId}): ${checkResult.status} - ${checkResult.result.findings.length} findings, ${checkResult.result.passingResults.length} passing`, ); - // Update task status based on check results - const hasFindings = checkResult.result.findings.length > 0; - const hasPassing = checkResult.result.passingResults.length > 0; - const newStatus = hasFindings ? 'failed' : hasPassing ? 'done' : null; - - if (newStatus) { - // Only update review date if transitioning to done from a different status - const isTransitioningToDone = - newStatus === 'done' && task.status !== 'done'; - - // Calculate next review date based on frequency - let reviewDate: Date | undefined; - if (isTransitioningToDone && task.frequency) { - reviewDate = new Date(); - switch (task.frequency) { - case 'monthly': - reviewDate.setMonth(reviewDate.getMonth() + 1); - break; - case 'quarterly': - reviewDate.setMonth(reviewDate.getMonth() + 3); - break; - case 'yearly': - reviewDate.setFullYear(reviewDate.getFullYear() + 1); - break; - } - } - - await db.task.update({ - where: { id: taskId }, - data: { - status: newStatus, - ...(reviewDate ? { reviewDate } : {}), - }, - }); - this.logger.log( - `Updated task ${taskId} status to ${newStatus}${reviewDate ? `, next review: ${reviewDate.toISOString()}` : ''}`, - ); - } - return { - success: true, - result: checkResult, + connectionId, checkRunId: checkRun.id, - taskStatus: newStatus, + status: checkResult.status, + findings: checkResult.result.findings.length, + passing: checkResult.result.passingResults.length, }; } catch (error) { - // Mark run as failed await this.checkRunRepository.complete(checkRun.id, { status: 'failed', - durationMs: Date.now() - checkRun.startedAt!.getTime(), + durationMs: checkRun.startedAt + ? Date.now() - checkRun.startedAt.getTime() + : 0, totalChecked: 0, passedCount: 0, failedCount: 0, errorMessage: error instanceof Error ? error.message : String(error), }); - - this.logger.error(`Failed to run check: ${error}`); + this.logger.error( + `Failed to run check ${checkDef.id} for connection ${connectionId}: ${error}`, + ); return { - success: false, - error: error instanceof Error ? error.message : String(error), + connectionId, checkRunId: checkRun.id, + status: 'error', + findings: 0, + passing: 0, }; } } @@ -639,12 +705,29 @@ export class TaskIntegrationsController { @RequirePermission('integration', 'read') async getTaskCheckRuns( @Param('taskId') taskId: string, + @OrganizationId() organizationId: string, @Query('limit') limit?: string, ) { - const runs = await this.checkRunRepository.findByTask( - taskId, - limit ? parseInt(limit, 10) : 10, - ); + // Tenant scoping: confirm the task belongs to the caller's org before + // returning its check runs. The runs carry account ids, connection labels, + // and logs — without this an arbitrary taskId would leak cross-tenant data. + const task = await db.task.findUnique({ + where: { id: taskId, organizationId }, + select: { id: true }, + }); + if (!task) { + throw new HttpException('Task not found', HttpStatus.NOT_FOUND); + } + + // Latest run per (connection, check) is guaranteed present — so a customer + // with multiple accounts always sees every account, never just the most + // recently re-run one. `connectionId` + `connectionLabel` let the UI show + // which account each run belongs to. + const runs = + await this.checkRunRepository.findLatestPerConnectionAndCheckByTask( + taskId, + { historyPerGroup: limit ? parseInt(limit, 10) : 5 }, + ); return { runs: runs.map((run) => { @@ -663,6 +746,8 @@ export class TaskIntegrationsController { failedCount: run.failedCount, errorMessage: run.errorMessage, logs: run.logs, + connectionId: run.connectionId, + connectionLabel: getConnectionLabel(run.connection), provider: { slug: provider?.slug, name: provider?.name, diff --git a/apps/api/src/integration-platform/repositories/check-run.repository.spec.ts b/apps/api/src/integration-platform/repositories/check-run.repository.spec.ts new file mode 100644 index 0000000000..a2f5eaf816 --- /dev/null +++ b/apps/api/src/integration-platform/repositories/check-run.repository.spec.ts @@ -0,0 +1,207 @@ +jest.mock('@db', () => ({ + db: { + integrationCheckRun: { + groupBy: jest.fn(), + findMany: jest.fn(), + }, + }, +})); + +import { db } from '@db'; +import { CheckRunRepository } from './check-run.repository'; + +// Grab through the module reference to avoid the `unbound-method` lint rule +// that fires when extracting an instance method from an object literal. +const mockedCheckRun = db.integrationCheckRun as unknown as { + groupBy: jest.Mock; + findMany: jest.Mock; +}; +const mockGroupBy = mockedCheckRun.groupBy; +const mockFindMany = mockedCheckRun.findMany; + +function makeRun(opts: { + id: string; + connectionId: string; + checkId?: string; + createdAt: string; +}) { + return { + id: opts.id, + connectionId: opts.connectionId, + checkId: opts.checkId ?? 'aws-s3-encryption', + createdAt: new Date(opts.createdAt), + results: [], + connection: { + id: opts.connectionId, + metadata: {}, + provider: { slug: 'aws' }, + }, + }; +} + +describe('CheckRunRepository.findLatestPerConnectionAndCheckByTask', () => { + const repo = new CheckRunRepository(); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('returns nothing when the task has no runs', async () => { + mockGroupBy.mockResolvedValue([]); + const result = await repo.findLatestPerConnectionAndCheckByTask('task_1'); + expect(result).toEqual([]); + expect(mockFindMany).not.toHaveBeenCalled(); + }); + + it('GUARANTEES every account’s latest run even when one account dominates the recent window', async () => { + // 3 accounts (A, B, C) each ran the same check once; A was then re-run many + // times most recently. A flat "newest N rows" limit would bury B and C. + mockGroupBy.mockResolvedValue([ + { + connectionId: 'A', + checkId: 'aws-s3-encryption', + _max: { createdAt: new Date('2026-06-09T15:00:00Z') }, + }, + { + connectionId: 'B', + checkId: 'aws-s3-encryption', + _max: { createdAt: new Date('2026-06-01T09:00:00Z') }, + }, + { + connectionId: 'C', + checkId: 'aws-s3-encryption', + _max: { createdAt: new Date('2026-06-01T08:00:00Z') }, + }, + ]); + + const latestPerGroup = [ + makeRun({ + id: 'rA', + connectionId: 'A', + createdAt: '2026-06-09T15:00:00Z', + }), + makeRun({ + id: 'rB', + connectionId: 'B', + createdAt: '2026-06-01T09:00:00Z', + }), + makeRun({ + id: 'rC', + connectionId: 'C', + createdAt: '2026-06-01T08:00:00Z', + }), + ]; + // Recent window is dominated by account A's burst of re-runs. + const recentWindow = [ + makeRun({ + id: 'rA', + connectionId: 'A', + createdAt: '2026-06-09T15:00:00Z', + }), + makeRun({ + id: 'rA2', + connectionId: 'A', + createdAt: '2026-06-09T14:00:00Z', + }), + makeRun({ + id: 'rA3', + connectionId: 'A', + createdAt: '2026-06-09T13:00:00Z', + }), + makeRun({ + id: 'rA4', + connectionId: 'A', + createdAt: '2026-06-09T12:00:00Z', + }), + makeRun({ + id: 'rA5', + connectionId: 'A', + createdAt: '2026-06-09T11:00:00Z', + }), + ]; + + // First findMany = OR-of-tuples (latest per group); second = recent window. + mockFindMany.mockImplementation((args: { where?: { OR?: unknown } }) => + Promise.resolve(args.where?.OR ? latestPerGroup : recentWindow), + ); + + const result = await repo.findLatestPerConnectionAndCheckByTask('task_1'); + const connectionIds = new Set(result.map((r) => r.connectionId)); + + expect(connectionIds.has('A')).toBe(true); + expect(connectionIds.has('B')).toBe(true); + expect(connectionIds.has('C')).toBe(true); + // Newest-first ordering preserved. + expect(result[0].id).toBe('rA'); + // Deduped by id (rA appears in both the latest set and the recent window). + expect(result.filter((r) => r.id === 'rA')).toHaveLength(1); + }); + + it('excludes disconnected connections in both queries', async () => { + mockGroupBy.mockResolvedValue([ + { + connectionId: 'A', + checkId: 'c', + _max: { createdAt: new Date('2026-06-09T15:00:00Z') }, + }, + ]); + mockFindMany.mockResolvedValue([ + makeRun({ + id: 'rA', + connectionId: 'A', + createdAt: '2026-06-09T15:00:00Z', + }), + ]); + + await repo.findLatestPerConnectionAndCheckByTask('task_1'); + + for (const call of mockFindMany.mock.calls) { + expect(call[0].where.connection).toEqual({ + status: { not: 'disconnected' }, + }); + } + }); + + it('clamps an oversized historyPerGroup to the cap (no unbounded read)', async () => { + mockGroupBy.mockResolvedValue([ + { + connectionId: 'A', + checkId: 'c', + _max: { createdAt: new Date('2026-06-09T15:00:00Z') }, + }, + ]); + mockFindMany.mockResolvedValue([]); + + await repo.findLatestPerConnectionAndCheckByTask('task_1', { + historyPerGroup: 100000, + }); + + // recent-window query = the findMany WITHOUT an OR clause. + const recentCall = mockFindMany.mock.calls.find((c) => !c[0].where.OR); + expect(recentCall?.[0].take).toBe(1 * 50); // groups.length(1) * MAX(50) + }); + + it('falls back to the default for an invalid historyPerGroup (NaN/negative)', async () => { + mockGroupBy.mockResolvedValue([ + { + connectionId: 'A', + checkId: 'c', + _max: { createdAt: new Date('2026-06-09T15:00:00Z') }, + }, + ]); + mockFindMany.mockResolvedValue([]); + + await repo.findLatestPerConnectionAndCheckByTask('task_1', { + historyPerGroup: Number.NaN, + }); + let recentCall = mockFindMany.mock.calls.find((c) => !c[0].where.OR); + expect(recentCall?.[0].take).toBe(1 * 5); // default 5 + + mockFindMany.mockClear(); + await repo.findLatestPerConnectionAndCheckByTask('task_1', { + historyPerGroup: -10, + }); + recentCall = mockFindMany.mock.calls.find((c) => !c[0].where.OR); + expect(recentCall?.[0].take).toBe(1 * 5); // default 5 + }); +}); diff --git a/apps/api/src/integration-platform/repositories/check-run.repository.ts b/apps/api/src/integration-platform/repositories/check-run.repository.ts index b5a535412c..b66fec86e4 100644 --- a/apps/api/src/integration-platform/repositories/check-run.repository.ts +++ b/apps/api/src/integration-platform/repositories/check-run.repository.ts @@ -1,6 +1,10 @@ import { Injectable } from '@nestjs/common'; import { db } from '@db'; -import type { IntegrationRunStatus, Prisma } from '@db'; +import type { Prisma } from '@db'; + +/** Default / hard cap for run-history depth per (connection, check) group. */ +const DEFAULT_HISTORY_PER_GROUP = 5; +const MAX_HISTORY_PER_GROUP = 50; export interface CreateCheckRunDto { connectionId: string; @@ -149,6 +153,89 @@ export class CheckRunRepository { }); } + /** + * Get check runs for a task, GUARANTEEING every (connection, check) group's + * most recent run is included (plus a bounded history tail). + * + * Why not just `findByTask` with a row limit: checks run once per connected + * account, so a customer with multiple AWS accounts has one run per account. + * A flat "newest N rows" limit can silently drop an account whose latest run + * predates a burst of re-runs on a busier account — that account then + * vanishes from the task UI. Here we first establish the per-group maxima via + * `groupBy`, fetch each group's latest run explicitly (completeness), then add + * a recent window for history. Used by the task UI so manual and scheduled + * runs both surface every account. + */ + async findLatestPerConnectionAndCheckByTask( + taskId: string, + { + historyPerGroup = DEFAULT_HISTORY_PER_GROUP, + }: { historyPerGroup?: number } = {}, + ) { + // `historyPerGroup` can originate from a user-supplied `?limit=` query param + // (parsed with parseInt → possibly NaN/negative/huge). Clamp it so it never + // produces an invalid `take` (500) or an unbounded, expensive read. + const perGroup = + Number.isInteger(historyPerGroup) && historyPerGroup > 0 + ? Math.min(historyPerGroup, MAX_HISTORY_PER_GROUP) + : DEFAULT_HISTORY_PER_GROUP; + + const include = { + results: true, + connection: { include: { provider: true } }, + } as const; + + const where = { + taskId, + connection: { status: { not: 'disconnected' } }, + } satisfies Prisma.IntegrationCheckRunWhereInput; + + // Every (connection, check) group that has runs for this task, with its + // most recent run timestamp. + const groups = await db.integrationCheckRun.groupBy({ + by: ['connectionId', 'checkId'], + where, + _max: { createdAt: true }, + }); + if (groups.length === 0) return []; + + // Completeness guarantee: explicitly fetch each group's latest run. + const tuples = groups.flatMap((g) => + g._max.createdAt + ? [ + { + connectionId: g.connectionId, + checkId: g.checkId, + createdAt: g._max.createdAt, + }, + ] + : [], + ); + const latest = tuples.length + ? await db.integrationCheckRun.findMany({ + where: { ...where, OR: tuples }, + include, + }) + : []; + + // History tail: a bounded recent window. Combined with the guaranteed + // latest set, every account shows its current result and recently-active + // accounts also show past runs. + const recent = await db.integrationCheckRun.findMany({ + where, + include, + orderBy: { createdAt: 'desc' }, + take: groups.length * perGroup, + }); + + // Merge + dedupe by id, newest-first (preserves the /runs ordering contract). + const byId = new Map(); + for (const run of [...latest, ...recent]) byId.set(run.id, run); + return Array.from(byId.values()).sort( + (a, b) => b.createdAt.getTime() - a.createdAt.getTime(), + ); + } + /** * Get the latest check run for a specific check on a task */ diff --git a/apps/api/src/integration-platform/repositories/connection.repository.ts b/apps/api/src/integration-platform/repositories/connection.repository.ts index 260d202b43..c15fc5a798 100644 --- a/apps/api/src/integration-platform/repositories/connection.repository.ts +++ b/apps/api/src/integration-platform/repositories/connection.repository.ts @@ -88,6 +88,28 @@ export class ConnectionRepository { }); } + /** + * All active connections for a single provider in an org. Used to run a + * check against every connected account (e.g. each AWS account a customer + * has connected) rather than only the first one. + */ + async findActiveByProviderAndOrg( + providerId: string, + organizationId: string, + ): Promise { + return db.integrationConnection.findMany({ + where: { + providerId, + organizationId, + status: 'active', + }, + include: { + provider: true, + }, + orderBy: { createdAt: 'asc' }, + }); + } + async create(data: CreateConnectionDto): Promise { return db.integrationConnection.create({ data: { diff --git a/apps/api/src/integration-platform/utils/connection-label.spec.ts b/apps/api/src/integration-platform/utils/connection-label.spec.ts new file mode 100644 index 0000000000..08168fc605 --- /dev/null +++ b/apps/api/src/integration-platform/utils/connection-label.spec.ts @@ -0,0 +1,79 @@ +import { getConnectionLabel } from './connection-label'; + +describe('getConnectionLabel', () => { + it('prefers the customer-set connection name', () => { + expect( + getConnectionLabel({ + id: 'conn1234abcd', + metadata: { + connectionName: 'Production AWS', + accountId: '123456789012', + }, + }), + ).toBe('Production AWS'); + }); + + it('trims a padded connection name', () => { + expect( + getConnectionLabel({ + id: 'conn1234abcd', + metadata: { connectionName: ' Prod ' }, + }), + ).toBe('Prod'); + }); + + it('falls back to "AWS "', () => { + expect( + getConnectionLabel({ + id: 'conn1234abcd', + metadata: { accountId: '123456789012' }, + }), + ).toBe('AWS 123456789012'); + }); + + it('derives the account id from a commercial role ARN', () => { + expect( + getConnectionLabel({ + id: 'conn1234abcd', + metadata: { + roleArn: 'arn:aws:iam::953349023881:role/CompSecurityAudit', + }, + }), + ).toBe('AWS 953349023881'); + }); + + it('derives the account id from a GovCloud role ARN', () => { + expect( + getConnectionLabel({ + id: 'conn1234abcd', + metadata: { roleArn: 'arn:aws-us-gov:iam::633779453318:role/x' }, + }), + ).toBe('AWS 633779453318'); + }); + + it('falls back to an id slice when metadata is empty', () => { + // slice(4, 12) of 'conn1234abcd' → '1234abcd' + expect(getConnectionLabel({ id: 'conn1234abcd', metadata: {} })).toBe( + 'Account 1234abcd', + ); + }); + + it('handles missing or non-object metadata', () => { + expect(getConnectionLabel({ id: 'conn1234abcd' })).toBe('Account 1234abcd'); + expect(getConnectionLabel({ id: 'conn1234abcd', metadata: null })).toBe( + 'Account 1234abcd', + ); + expect( + getConnectionLabel({ id: 'conn1234abcd', metadata: 'not-an-object' }), + ).toBe('Account 1234abcd'); + }); + + it('ignores a blank connection name and falls through', () => { + expect( + getConnectionLabel({ + id: 'conn1234abcd', + metadata: { connectionName: ' ', accountId: '123456789012' }, + }), + ).toBe('AWS 123456789012'); + }); +}); diff --git a/apps/api/src/integration-platform/utils/connection-label.ts b/apps/api/src/integration-platform/utils/connection-label.ts new file mode 100644 index 0000000000..e8964e2d36 --- /dev/null +++ b/apps/api/src/integration-platform/utils/connection-label.ts @@ -0,0 +1,33 @@ +/** + * Human-readable label for an integration connection (one customer account). + * + * Mirrors the frontend `getConnectionDisplayLabel` + * (apps/app/.../integrations/[slug]/components/connection-display.ts) so the + * label is consistent with the account picker. Reads only `metadata` (an + * unencrypted Json column) — never credentials — so it is safe to compute + * server-side without decrypting anything. + * + * Precedence: customer-set connection name → `AWS ` → + * `AWS ` → `Account ` fallback. + */ +export function getConnectionLabel(connection: { + id: string; + metadata?: unknown; +}): string { + const meta = + connection.metadata && typeof connection.metadata === 'object' + ? (connection.metadata as Record) + : {}; + + if (typeof meta.connectionName === 'string' && meta.connectionName.trim()) { + return meta.connectionName.trim(); + } + if (typeof meta.accountId === 'string' && meta.accountId) { + return `AWS ${meta.accountId}`; + } + if (typeof meta.roleArn === 'string') { + const arnMatch = meta.roleArn.match(/arn:(?:aws|aws-us-gov):iam::(\d{12})/); + if (arnMatch) return `AWS ${arnMatch[1]}`; + } + return `Account ${connection.id.slice(4, 12)}`; +} diff --git a/apps/api/src/people/people-invite.service.spec.ts b/apps/api/src/people/people-invite.service.spec.ts index 05e5cd77c8..c35c4bf780 100644 --- a/apps/api/src/people/people-invite.service.spec.ts +++ b/apps/api/src/people/people-invite.service.spec.ts @@ -309,7 +309,11 @@ describe('PeopleInviteService', () => { }); expect(results[0].success).toBe(true); - expect(results[0].emailSent).toBe(true); + // No portal invite was requested (sendPortalEmail omitted = false), so no + // email is sent and emailSent is not surfaced (the UI only warns on an + // actual send failure, never on an intentional skip). + expect(mockTriggerEmail).not.toHaveBeenCalled(); + expect(results[0].emailSent).toBeUndefined(); expect(mockDb.member.create).toHaveBeenCalledWith( expect.objectContaining({ data: expect.objectContaining({ @@ -437,9 +441,13 @@ describe('PeopleInviteService', () => { const results = await service.inviteMembers({ ...baseParams, - invites: [{ email: 'emp@example.com', roles: ['employee'] }], + invites: [ + { email: 'emp@example.com', roles: ['employee'], sendPortalEmail: true }, + ], }); + // A portal email was requested but the send failed — the member is still + // added and emailSent: false signals the UI to offer a resend. expect(results[0].success).toBe(true); expect(results[0].emailSent).toBe(false); }); @@ -545,6 +553,46 @@ describe('PeopleInviteService', () => { expect(mockInviteEmail).not.toHaveBeenCalled(); }); + // Regression: unchecking "Send portal invite email" when adding an + // employee via "+ Add User" must add the member WITHOUT emailing them. + // Previously the else-branch still sent an InviteEmail with a portal link. + it('employee only with portal UNchecked: adds member silently, sends no email', async () => { + (mockDb.organization.findUnique as jest.Mock).mockResolvedValue({ + name: 'Test Org', + }); + (mockDb.user.findFirst as jest.Mock).mockResolvedValue(null); + (mockDb.user.create as jest.Mock).mockResolvedValue({ + id: 'usr_emp', + email: 'emp@example.com', + }); + (mockDb.member.findFirst as jest.Mock).mockResolvedValue(null); + (mockDb.member.create as jest.Mock).mockResolvedValue({ id: 'mem_emp' }); + ( + mockDb.employeeTrainingVideoCompletion.createMany as jest.Mock + ).mockResolvedValue({ count: 5 }); + + const results = await service.inviteMembers({ + ...baseParams, + invites: [ + { + email: 'emp@example.com', + roles: ['employee'], + sendPortalEmail: false, + }, + ], + }); + + // Member is still created... + expect(results[0].success).toBe(true); + expect(mockDb.member.create).toHaveBeenCalled(); + // ...but NO email of any kind goes out when the portal invite is off. + expect(mockTriggerEmail).not.toHaveBeenCalled(); + expect(mockInvitePortalEmail).not.toHaveBeenCalled(); + expect(mockInviteEmail).not.toHaveBeenCalled(); + // And no false "could not be sent" warning leaks to the UI. + expect(results[0].emailSent).toBeUndefined(); + }); + it('admin only (no portal): sends app email without portal link', async () => { setupNewUserInvite(); diff --git a/apps/api/src/people/people-invite.service.ts b/apps/api/src/people/people-invite.service.ts index 094b91a1db..0ed1f2586a 100644 --- a/apps/api/src/people/people-invite.service.ts +++ b/apps/api/src/people/people-invite.service.ts @@ -106,7 +106,10 @@ export class PeopleInviteService { results.push({ email: invite.email, success: true, - emailSent: result.emailSent, + // Only surface email status when we actually attempted to send, so + // the UI's "invite email could not be sent" warning never fires for + // an intentional skip (portal invite unchecked). + ...(shouldSendPortalEmail ? { emailSent: result.emailSent } : {}), }); } else { await this.inviteWithCheck({ @@ -208,10 +211,12 @@ export class PeopleInviteService { await this.createTrainingVideoEntries(member.id, organizationId); } - // Send invite email (non-fatal) - let emailSent = true; - try { - if (sendPortalEmail) { + // Send the portal invite email only when requested (non-fatal). When the + // admin opts out ("Send portal invite email" unchecked) we add the member + // silently and send no email at all. + let emailSent = false; + if (sendPortalEmail) { + try { const inviteLink = this.buildPortalUrl(organizationId); await triggerEmail({ to: email, @@ -222,20 +227,14 @@ export class PeopleInviteService { email, }), }); - } else { - const inviteLink = this.buildPortalUrl(organizationId); - await triggerEmail({ - to: email, - subject: `You've been invited to join ${organization.name} on Comp AI`, - react: InviteEmail({ organizationName: organization.name, inviteLink }), - }); + emailSent = true; + } catch (emailErr) { + emailSent = false; + this.logger.error( + `Portal invite email failed after member was added: ${email}`, + emailErr instanceof Error ? emailErr.message : 'Unknown error', + ); } - } catch (emailErr) { - emailSent = false; - this.logger.error( - `Invite email failed after member was added: ${email}`, - emailErr instanceof Error ? emailErr.message : 'Unknown error', - ); } return { emailSent }; diff --git a/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/TaskIntegrationChecks.tsx b/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/TaskIntegrationChecks.tsx index 2d7b7b19d7..21539b9968 100644 --- a/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/TaskIntegrationChecks.tsx +++ b/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/TaskIntegrationChecks.tsx @@ -45,7 +45,8 @@ import { useCallback, useEffect, useMemo, useRef, useState, type ChangeEvent } f import { toast } from 'sonner'; import type { StoredCheckRun, TaskIntegrationCheck } from '../hooks/useIntegrationChecks'; import { useIntegrationChecks } from '../hooks/useIntegrationChecks'; -import { EvidenceJsonView } from './EvidenceJsonView'; +import { summarizeLatestPerAccount } from './check-run-grouping'; +import { AccountRunGroups } from './check-run-history'; interface TaskIntegrationChecksProps { taskId: string; @@ -458,19 +459,18 @@ export function TaskIntegrationChecks({
{connectedChecks.map((check) => { const checkRuns = runsByCheck[check.checkId] || []; - const latestRun = checkRuns[0]; // Already sorted by createdAt desc + // A check runs once per connected account; summarize the latest + // run of EACH account so the header reflects all of them, not + // just the most recently run one. + const summary = summarizeLatestPerAccount(checkRuns); const isRunning = runningCheck === check.checkId; const isExpanded = expandedCheck === check.checkId; const needsConfig = check.needsConfiguration; const monitorName = getMonitorDisplayName(check); - // Determine status from latest run - const hasFailed = latestRun - ? latestRun.status === 'failed' || latestRun.failedCount > 0 - : false; - const hasSucceeded = latestRun - ? latestRun.status === 'success' && latestRun.failedCount === 0 - : false; + // Status across all accounts' latest runs. + const hasFailed = summary.hasFailed; + const hasSucceeded = summary.hasSucceeded; const dotColor = needsConfig ? 'bg-warning shadow-[0_0_8px_hsl(var(--warning)/0.4)]' @@ -480,8 +480,8 @@ export function TaskIntegrationChecks({ ? 'bg-primary shadow-[0_0_8px_rgba(0,77,64,0.4)]' : 'bg-muted-foreground'; - const lastRan = latestRun - ? formatDistanceToNow(new Date(latestRun.completedAt || latestRun.createdAt), { + const lastRan = summary.lastRunAt + ? formatDistanceToNow(new Date(summary.lastRunAt), { addSuffix: true, }) : null; @@ -555,14 +555,17 @@ export function TaskIntegrationChecks({ ) : lastRan ? (

Last ran {lastRan} - {latestRun && ( + + • {summary.passed} passed + {summary.failed > 0 && ( + + , {summary.failed} issues + + )} + + {summary.accountCount > 1 && ( - • {latestRun.passedCount} passed - {latestRun.failedCount > 0 && ( - - , {latestRun.failedCount} issues - - )} + · {summary.accountCount} accounts )}

@@ -704,9 +707,8 @@ export function TaskIntegrationChecks({ >
-
@@ -953,283 +955,6 @@ export function TaskIntegrationChecks({ ); } -// Group runs by date for display -function GroupedCheckRuns({ - runs, - maxRuns = 5, - organizationName, -}: { - runs: StoredCheckRun[]; - maxRuns?: number; - organizationName: string; -}) { - const [showAll, setShowAll] = useState(false); - - // Get the runs to display (limited or all) - const displayRuns = showAll ? runs : runs.slice(0, maxRuns); - const hasMore = runs.length > maxRuns; - - // Build grouped display from displayRuns - const displayGrouped = useMemo((): Record => { - const groups: Record = {}; - - displayRuns.forEach((run) => { - const date = new Date(run.createdAt).toLocaleDateString('en-US', { - month: 'short', - day: 'numeric', - year: 'numeric', - }); - if (!groups[date]) { - groups[date] = []; - } - groups[date].push(run); - }); - - return groups; - }, [displayRuns]); - - if (runs.length === 0) { - return

No runs yet

; - } - - let runIndex = 0; - - return ( -
- {Object.entries(displayGrouped).map(([date, dateRuns]) => ( -
-

- {date} -

-
- {dateRuns.map((run: StoredCheckRun) => { - const isLatest = runIndex === 0; - runIndex++; - return ( - - ); - })} -
-
- ))} - - {hasMore && ( - - )} -
- ); -} - -// Individual check run item with expandable details -function CheckRunItem({ - run, - isLatest, - organizationName, -}: { - run: StoredCheckRun; - isLatest: boolean; - organizationName: string; -}) { - const [expanded, setExpanded] = useState(isLatest); - - const timeAgo = formatDistanceToNow(new Date(run.createdAt), { addSuffix: true }); - const hasFailed = run.status === 'failed' || run.failedCount > 0; - const hasError = run.status === 'failed' && run.errorMessage; - - const findings = run.results.filter((r) => !r.passed); - const passing = run.results.filter((r) => r.passed); - - const statusColor = hasError ? 'text-destructive' : hasFailed ? 'text-warning' : 'text-primary'; - - const statusText = hasError ? 'Error' : hasFailed ? 'Issues Found' : 'Passed'; - - return ( -
- - -
-
-
- {/* Error */} - {run.errorMessage && ( -
-

{run.errorMessage}

-
- )} - - {/* Findings */} - {findings.length > 0 && ( -
- {findings.slice(0, 3).map((finding) => ( -
-
-

{finding.title}

- {finding.description && ( -

{finding.description}

- )} - {finding.remediation && ( -

{finding.remediation}

- )} -
- - {finding.resourceId} - - {finding.severity && ( - - {finding.severity} - - )} -
-
- {finding.evidence && Object.keys(finding.evidence).length > 0 && ( -
- - View Evidence - - -
- )} -
- ))} - {findings.length > 3 && ( -

- +{findings.length - 3} more issues -

- )} -
- )} - - {/* Passing Results - always show when there are passing results */} - {passing.length > 0 && ( -
- - ✓ {passing.length} passed - -
- {passing.slice(0, 3).map((result) => ( -
-
-

{result.title}

- {result.description && ( -

{result.description}

- )} - - {result.resourceId} - -
- {result.evidence && Object.keys(result.evidence).length > 0 && ( -
- - View Evidence - - -
- )} -
- ))} - {passing.length > 3 && ( -

- +{passing.length - 3} more passed -

- )} -
-
- )} - - {/* Logs */} - {run.logs && run.logs.length > 0 && ( -
- Logs -
-                  {run.logs.map((log, i) => (
-                    
- - [{new Date(log.timestamp).toLocaleTimeString()}] - {' '} - {log.message} -
- ))} -
-
- )} -
-
-
-
- ); -} - // Empty state when no integrations are connected - matches AutomationEmptyState style function IntegrationEmptyState({ disconnectedChecks, diff --git a/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/check-run-grouping.test.ts b/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/check-run-grouping.test.ts new file mode 100644 index 0000000000..1816b21cb2 --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/check-run-grouping.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, it } from 'vitest'; +import type { StoredCheckRun } from '../hooks/useIntegrationChecks'; +import { + groupRunsByConnection, + summarizeLatestPerAccount, +} from './check-run-grouping'; + +const makeRun = ( + overrides: Partial & { + id: string; + connectionId: string; + }, +): StoredCheckRun => { + const createdAt = overrides.createdAt ?? '2026-06-09T00:00:00.000Z'; + return { + checkId: 'aws-s3-encryption', + checkName: 'S3 — default encryption enabled', + status: 'success', + startedAt: createdAt, + durationMs: 100, + totalChecked: 1, + passedCount: 0, + failedCount: 0, + connectionLabel: `AWS ${overrides.connectionId}`, + provider: { slug: 'aws', name: 'AWS' }, + results: [], + ...overrides, + // completedAt tracks createdAt (as in real runs) unless explicitly set. + completedAt: overrides.completedAt ?? createdAt, + createdAt, + }; +}; + +describe('groupRunsByConnection', () => { + it('groups runs by account, preserving order across and within accounts', () => { + const runs = [ + makeRun({ id: 'r1', connectionId: 'A', createdAt: '2026-06-09T12:00:00Z' }), + makeRun({ id: 'r2', connectionId: 'B', createdAt: '2026-06-09T11:00:00Z' }), + makeRun({ id: 'r3', connectionId: 'A', createdAt: '2026-06-09T10:00:00Z' }), + ]; + const groups = groupRunsByConnection(runs); + expect(groups.map((g) => g.connectionId)).toEqual(['A', 'B']); + expect(groups[0].runs.map((r) => r.id)).toEqual(['r1', 'r3']); + expect(groups[1].runs.map((r) => r.id)).toEqual(['r2']); + }); + + it('uses the connection label, falling back when absent', () => { + const groups = groupRunsByConnection([ + makeRun({ id: 'r1', connectionId: 'A', connectionLabel: 'Production AWS' }), + makeRun({ id: 'r2', connectionId: 'B', connectionLabel: '' }), + ]); + expect(groups[0].label).toBe('Production AWS'); + expect(groups[1].label).toBe('Account'); + }); +}); + +describe('summarizeLatestPerAccount', () => { + it('sums the latest run of each account', () => { + const runs = [ + // Account A: latest is newest (12:00) with 30 passed. + makeRun({ id: 'a1', connectionId: 'A', passedCount: 30, createdAt: '2026-06-09T12:00:00Z' }), + makeRun({ id: 'a0', connectionId: 'A', passedCount: 5, createdAt: '2026-06-09T08:00:00Z' }), + // Account B: latest has 2 findings. + makeRun({ + id: 'b1', + connectionId: 'B', + status: 'failed', + passedCount: 10, + failedCount: 2, + createdAt: '2026-06-09T11:00:00Z', + }), + ]; + const summary = summarizeLatestPerAccount(runs); + expect(summary.accountCount).toBe(2); + expect(summary.passed).toBe(40); // 30 (A latest) + 10 (B latest), NOT A's older 5 + expect(summary.failed).toBe(2); + expect(summary.hasFailed).toBe(true); + expect(summary.hasSucceeded).toBe(true); // A passed + expect(summary.lastRunAt).toBe('2026-06-09T12:00:00Z'); + }); + + it('is empty for no runs', () => { + const summary = summarizeLatestPerAccount([]); + expect(summary).toEqual({ + accountCount: 0, + passed: 0, + failed: 0, + lastRunAt: null, + hasFailed: false, + hasSucceeded: false, + }); + }); +}); diff --git a/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/check-run-grouping.ts b/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/check-run-grouping.ts new file mode 100644 index 0000000000..7ff52e945b --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/check-run-grouping.ts @@ -0,0 +1,83 @@ +import type { StoredCheckRun } from '../hooks/useIntegrationChecks'; + +export interface AccountRunGroup { + connectionId: string; + label: string; + /** This account's runs, newest-first. */ + runs: StoredCheckRun[]; +} + +/** + * Group check runs by the account (connection) they ran against. Checks run + * once per connected account, so this is how the UI shows every account's + * results when a customer has more than one (e.g. multiple AWS accounts). + * + * Input is expected newest-first; the output preserves that order both across + * accounts (ordered by each account's most recent run) and within each account. + */ +export function groupRunsByConnection( + runs: StoredCheckRun[], +): AccountRunGroup[] { + const groups = new Map(); + for (const run of runs) { + const existing = groups.get(run.connectionId); + if (existing) { + existing.runs.push(run); + } else { + groups.set(run.connectionId, { + connectionId: run.connectionId, + label: run.connectionLabel || 'Account', + runs: [run], + }); + } + } + return Array.from(groups.values()); +} + +export interface RunsSummary { + accountCount: number; + passed: number; + failed: number; + lastRunAt: string | null; + hasFailed: boolean; + hasSucceeded: boolean; +} + +/** + * Aggregate the latest run per account for the card header — so a multi-account + * check shows totals across all accounts, not just the most recently run one. + */ +export function summarizeLatestPerAccount( + runs: StoredCheckRun[], +): RunsSummary { + const groups = groupRunsByConnection(runs); + let passed = 0; + let failed = 0; + let hasFailed = false; + let hasSucceeded = false; + let lastRunAt: string | null = null; + + for (const group of groups) { + const latest = group.runs[0]; // newest-first + if (!latest) continue; + passed += latest.passedCount; + failed += latest.failedCount; + if (latest.status === 'failed' || latest.failedCount > 0) hasFailed = true; + if (latest.status === 'success' && latest.failedCount === 0) { + hasSucceeded = true; + } + const at = latest.completedAt || latest.createdAt; + if (at && (!lastRunAt || new Date(at) > new Date(lastRunAt))) { + lastRunAt = at; + } + } + + return { + accountCount: groups.length, + passed, + failed, + lastRunAt, + hasFailed, + hasSucceeded, + }; +} diff --git a/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/check-run-history.tsx b/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/check-run-history.tsx new file mode 100644 index 0000000000..fa98e045b8 --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/check-run-history.tsx @@ -0,0 +1,347 @@ +'use client'; + +import { cn } from '@/lib/utils'; +import { Badge } from '@trycompai/ui/badge'; +import { formatDistanceToNow } from 'date-fns'; +import { ChevronDown } from 'lucide-react'; +import { useMemo, useState } from 'react'; +import type { StoredCheckRun } from '../hooks/useIntegrationChecks'; +import { groupRunsByConnection } from './check-run-grouping'; +import { EvidenceJsonView } from './EvidenceJsonView'; + +/** + * Run history for a check, grouped by the account (connection) it ran against. + * + * With a single account this renders identically to the previous date-grouped + * history (no account header). With multiple accounts — e.g. a customer who + * connected several AWS accounts — each account gets its own labelled section + * showing that account's runs, so the one check surfaces results + logs for + * every account. Both manual and scheduled runs flow through here. + */ +export function AccountRunGroups({ + runs, + organizationName, +}: { + runs: StoredCheckRun[]; + organizationName: string; +}) { + const groups = useMemo(() => groupRunsByConnection(runs), [runs]); + + if (groups.length <= 1) { + return ; + } + + return ( +
+ {groups.map((group) => { + const latest = group.runs[0]; + const hasFailed = latest + ? latest.status === 'failed' || latest.failedCount > 0 + : false; + return ( +
+
+ + + {group.label} + + {latest && ( + + {latest.passedCount} passed + {latest.failedCount > 0 ? `, ${latest.failedCount} issues` : ''} + + )} +
+ +
+ ); + })} +
+ ); +} + +// Group runs by date for display +export function GroupedCheckRuns({ + runs, + maxRuns = 5, + organizationName, +}: { + runs: StoredCheckRun[]; + maxRuns?: number; + organizationName: string; +}) { + const [showAll, setShowAll] = useState(false); + + // Get the runs to display (limited or all) + const displayRuns = showAll ? runs : runs.slice(0, maxRuns); + const hasMore = runs.length > maxRuns; + + // Build grouped display from displayRuns + const displayGrouped = useMemo((): Record => { + const groups: Record = {}; + + displayRuns.forEach((run) => { + const date = new Date(run.createdAt).toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric', + }); + if (!groups[date]) { + groups[date] = []; + } + groups[date].push(run); + }); + + return groups; + }, [displayRuns]); + + if (runs.length === 0) { + return

No runs yet

; + } + + let runIndex = 0; + + return ( +
+ {Object.entries(displayGrouped).map(([date, dateRuns]) => ( +
+

+ {date} +

+
+ {dateRuns.map((run: StoredCheckRun) => { + const isLatest = runIndex === 0; + runIndex++; + return ( + + ); + })} +
+
+ ))} + + {hasMore && ( + + )} +
+ ); +} + +// Individual check run item with expandable details +export function CheckRunItem({ + run, + isLatest, + organizationName, +}: { + run: StoredCheckRun; + isLatest: boolean; + organizationName: string; +}) { + const [expanded, setExpanded] = useState(isLatest); + + const timeAgo = formatDistanceToNow(new Date(run.createdAt), { addSuffix: true }); + const hasFailed = run.status === 'failed' || run.failedCount > 0; + const hasError = run.status === 'failed' && run.errorMessage; + + const findings = run.results.filter((r) => !r.passed); + const passing = run.results.filter((r) => r.passed); + + const statusColor = hasError ? 'text-destructive' : hasFailed ? 'text-warning' : 'text-primary'; + + const statusText = hasError ? 'Error' : hasFailed ? 'Issues Found' : 'Passed'; + + return ( +
+ + +
+
+
+ {/* Error */} + {run.errorMessage && ( +
+

{run.errorMessage}

+
+ )} + + {/* Findings */} + {findings.length > 0 && ( +
+ {findings.slice(0, 3).map((finding) => ( +
+
+

{finding.title}

+ {finding.description && ( +

{finding.description}

+ )} + {finding.remediation && ( +

{finding.remediation}

+ )} +
+ + {finding.resourceId} + + {finding.severity && ( + + {finding.severity} + + )} +
+
+ {finding.evidence && Object.keys(finding.evidence).length > 0 && ( +
+ + View Evidence + + +
+ )} +
+ ))} + {findings.length > 3 && ( +

+ +{findings.length - 3} more issues +

+ )} +
+ )} + + {/* Passing Results - always show when there are passing results */} + {passing.length > 0 && ( +
+ + ✓ {passing.length} passed + +
+ {passing.slice(0, 3).map((result) => ( +
+
+

{result.title}

+ {result.description && ( +

{result.description}

+ )} + + {result.resourceId} + +
+ {result.evidence && Object.keys(result.evidence).length > 0 && ( +
+ + View Evidence + + +
+ )} +
+ ))} + {passing.length > 3 && ( +

+ +{passing.length - 3} more passed +

+ )} +
+
+ )} + + {/* Logs */} + {run.logs && run.logs.length > 0 && ( +
+ Logs +
+                  {run.logs.map((log, i) => (
+                    
+ + [{new Date(log.timestamp).toLocaleTimeString()}] + {' '} + {log.message} +
+ ))} +
+
+ )} +
+
+
+
+ ); +} diff --git a/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/hooks/useIntegrationChecks.ts b/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/hooks/useIntegrationChecks.ts index 3c85c3c171..064c39b4d9 100644 --- a/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/hooks/useIntegrationChecks.ts +++ b/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/hooks/useIntegrationChecks.ts @@ -32,6 +32,10 @@ interface StoredCheckRun { passedCount: number; failedCount: number; errorMessage?: string; + /** The connection (account) this run belongs to — checks run once per account. */ + connectionId: string; + /** Human-readable account label (e.g. "AWS 123456789012" or a custom name). */ + connectionLabel: string; logs?: Array<{ level: string; message: string;