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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,7 @@ describe('TaskIntegrationsController', () => {
complete: jest.fn(),
addResults: jest.fn(),
findLatestPerConnectionAndCheckByTask: jest.fn(),
findLastAttemptPerConnectionAndCheckByTask: jest.fn(),
countExceptedFailures: jest.fn(),
};
const mockCredentialVaultService = { getDecryptedCredentials: jest.fn() };
Expand Down Expand Up @@ -290,6 +291,10 @@ describe('TaskIntegrationsController', () => {
// Default: nothing excepted (no count query needed). Exception tests
// override this to the exact excepted-failure count for the run.
mockCheckRunRepository.countExceptedFailures.mockResolvedValue(0);
// Default: no last-attempt rows (tests that care set their own).
mockCheckRunRepository.findLastAttemptPerConnectionAndCheckByTask.mockResolvedValue(
[],
);
mockCredentialVaultService.getDecryptedCredentials.mockResolvedValue(
VALID_CREDS,
);
Expand Down Expand Up @@ -891,6 +896,30 @@ describe('TaskIntegrationsController', () => {
).not.toHaveBeenCalled();
});

it('returns lastAttempts (incl. held runs) so "Last ran" stays truthful (CS-753)', async () => {
// The visible runs list can be days older than the newest attempt when
// recent runs were held (they're excluded from `runs`). The endpoint
// must surface WHEN each (connection, check) last ran — timestamps only.
mockCheckRunRepository.findLatestPerConnectionAndCheckByTask.mockResolvedValue(
[],
);
const attempt = {
connectionId: 'conn_1',
checkId: 'entra_id_mfa',
lastAttemptAt: new Date('2026-07-16T06:00:00Z'),
};
mockCheckRunRepository.findLastAttemptPerConnectionAndCheckByTask.mockResolvedValue(
[attempt],
);

const response = await controller.getTaskCheckRuns('task_1', 'org_1');

expect(response.lastAttempts).toEqual([attempt]);
expect(
mockCheckRunRepository.findLastAttemptPerConnectionAndCheckByTask,
).toHaveBeenCalledWith('task_1');
});

it('bounds a run with a huge result set + logs so the payload stays small (CS-588)', async () => {
// Defense-in-depth response cap: even if a run somehow carries a large
// result/log set, the serialized response is bounded (results per
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -925,6 +925,15 @@ export class TaskIntegrationsController {
}),
);

return { runs: mappedRuns };
// WHEN each (connection, check) last ran, INCLUDING runs held as
// 'inconclusive' (which are excluded from `runs`). Timestamps only — held
// outcomes stay hidden. Without this the UI's "Last ran" froze at the last
// visible run while the daily schedule kept running (CS-753).
const lastAttempts =
await this.checkRunRepository.findLastAttemptPerConnectionAndCheckByTask(
taskId,
);

return { runs: mappedRuns, lastAttempts };
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -266,6 +266,55 @@ describe('CheckRunRepository.findLatestPerConnectionAndCheckByTask', () => {
});
});

describe('CheckRunRepository.findLastAttemptPerConnectionAndCheckByTask', () => {
const repo = new CheckRunRepository();

beforeEach(() => {
jest.clearAllMocks();
});

it('returns each group’s latest attempt timestamp, INCLUDING held runs (CS-753)', async () => {
mockGroupBy.mockResolvedValue([
{
connectionId: 'A',
checkId: 'entra_id_mfa',
_max: { createdAt: new Date('2026-07-16T06:00:00Z') },
},
]);

const attempts =
await repo.findLastAttemptPerConnectionAndCheckByTask('task_1');

expect(attempts).toEqual([
{
connectionId: 'A',
checkId: 'entra_id_mfa',
lastAttemptAt: new Date('2026-07-16T06:00:00Z'),
},
]);
// The whole point: NO status filter — a run held as 'inconclusive' still
// counts as an attempt. Disconnected connections stay excluded.
const where = mockGroupBy.mock.calls[0][0].where;
expect(where.status).toBeUndefined();
expect(where.connection).toEqual({ status: { not: 'disconnected' } });
expect(where.taskId).toBe('task_1');
});

it('drops groups without a timestamp and returns [] for a task with no runs', async () => {
mockGroupBy.mockResolvedValue([
{ connectionId: 'A', checkId: 'c', _max: { createdAt: null } },
]);
expect(
await repo.findLastAttemptPerConnectionAndCheckByTask('task_1'),
).toEqual([]);

mockGroupBy.mockResolvedValue([]);
expect(
await repo.findLastAttemptPerConnectionAndCheckByTask('task_1'),
).toEqual([]);
});
});

describe('CheckRunRepository.countExceptedFailures', () => {
const repo = new CheckRunRepository();

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -266,6 +266,39 @@ export class CheckRunRepository {
);
}

/**
* WHEN each (connection, check) last ran for a task — INCLUDING runs held as
* `inconclusive`. Timestamps only: no status, no counts, no results, so held
* outcomes stay hidden.
*
* `findLatestPerConnectionAndCheckByTask` above excludes held runs, which is
* right for RESULTS — but using that list for the "Last ran" label froze the
* label at the last visible run while a scheduled check kept running (and
* being held) every day. Customers read that as "the schedule stopped"
* (CS-753). This gives the UI the true last-attempt time per group.
*/
async findLastAttemptPerConnectionAndCheckByTask(taskId: string) {
const groups = await db.integrationCheckRun.groupBy({
by: ['connectionId', 'checkId'],
where: {
taskId,
connection: { status: { not: 'disconnected' } },
},
_max: { createdAt: true },
});
return groups.flatMap((g) =>
g._max.createdAt
? [
{
connectionId: g.connectionId,
checkId: g.checkId,
lastAttemptAt: g._max.createdAt,
},
]
: [],
);
}

/**
* Count a run's FAILING results whose resourceId is under an active exception.
* Lets the task UI compute the effective (non-excepted) failure count exactly
Expand Down
83 changes: 83 additions & 0 deletions apps/api/src/trust-portal/trust-portal.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -378,6 +378,28 @@ export class TrustPortalController {
@Put('settings/faqs')
@RequirePermission('trust', 'update')
@ApiOperation({ summary: 'Update trust portal FAQs' })
// organizationId comes from the authenticated context (@OrganizationId), so the
// body only carries faqs. Without an explicit @ApiBody the generated spec has no
// request body and the MCP tool can't send FAQs at all. (CS-752)
@ApiBody({
schema: {
type: 'object',
required: ['faqs'],
properties: {
faqs: {
type: 'array',
items: {
type: 'object',
required: ['question', 'answer'],
properties: {
question: { type: 'string' },
answer: { type: 'string' },
},
},
},
},
},
})
async updateFaqs(
@OrganizationId() organizationId: string,
@Body() body: { faqs: Array<{ question: string; answer: string }> },
Expand Down Expand Up @@ -551,6 +573,27 @@ export class TrustPortalController {
@ApiOperation({
summary: 'Update trust portal overview section',
})
// Mirrors UpdateTrustOverviewSchema plus the organizationId the handler reads
// from the body. Without an explicit @ApiBody the generated OpenAPI spec has
// no request body, so the MCP tool sends none, organizationId arrives
// undefined, and assertOrganizationAccess 400s for API-key callers. (CS-752)
@ApiBody({
schema: {
type: 'object',
required: ['organizationId'],
properties: {
organizationId: {
type: 'string',
description:
"Organization that owns the trust portal. Must match the authenticated API key's organization.",
example: 'org_6914cd0e16e4c7dccbb54426',
},
overviewTitle: { type: 'string', maxLength: 200, nullable: true },
overviewContent: { type: 'string', maxLength: 10000, nullable: true },
showOverview: { type: 'boolean' },
},
},
})
@ApiResponse({
status: HttpStatus.OK,
description: 'Overview updated successfully',
Expand Down Expand Up @@ -585,6 +628,25 @@ export class TrustPortalController {
@ApiOperation({
summary: 'Create a custom link for trust portal',
})
// Mirrors CreateCustomLinkSchema plus the organizationId the handler reads
// from the body (see updateOverview for why the explicit body matters). (CS-752)
@ApiBody({
schema: {
type: 'object',
required: ['organizationId', 'title', 'url'],
properties: {
organizationId: {
type: 'string',
description:
"Organization that owns the trust portal. Must match the authenticated API key's organization.",
example: 'org_6914cd0e16e4c7dccbb54426',
},
title: { type: 'string', minLength: 1, maxLength: 100 },
description: { type: 'string', maxLength: 500, nullable: true },
url: { type: 'string', format: 'uri', maxLength: 2000 },
},
},
})
@ApiResponse({
status: HttpStatus.CREATED,
description: 'Custom link created successfully',
Expand Down Expand Up @@ -647,6 +709,27 @@ export class TrustPortalController {
@ApiOperation({
summary: 'Reorder custom links',
})
// Mirrors ReorderCustomLinksSchema plus the organizationId the handler reads
// from the body (see updateOverview for why the explicit body matters). (CS-752)
@ApiBody({
schema: {
type: 'object',
required: ['organizationId', 'linkIds'],
properties: {
organizationId: {
type: 'string',
description:
"Organization that owns the trust portal. Must match the authenticated API key's organization.",
example: 'org_6914cd0e16e4c7dccbb54426',
},
linkIds: {
type: 'array',
items: { type: 'string' },
description: 'Custom link IDs in the desired display order.',
},
},
},
})
@ApiResponse({
status: HttpStatus.OK,
description: 'Custom links reordered successfully',
Expand Down
Loading
Loading