diff --git a/apps/api/src/policies/policies.service.spec.ts b/apps/api/src/policies/policies.service.spec.ts index 35bd93ab33..8642a55bc4 100644 --- a/apps/api/src/policies/policies.service.spec.ts +++ b/apps/api/src/policies/policies.service.spec.ts @@ -242,6 +242,205 @@ describe('PoliciesService', () => { }); }); + describe('acceptChanges', () => { + const buildPendingPolicy = (overrides: Record = {}) => ({ + id: 'pol_1', + organizationId: 'org_abc', + pendingVersionId: 'ver_1', + approverId: 'mem_approver', + frequency: 'yearly', + ...overrides, + }); + + const mockTransactionTx = () => { + db.$transaction.mockImplementation( + async (callback: (tx: unknown) => Promise) => { + const tx = { + policyVersion: { update: db.policyVersion.update }, + policy: { update: db.policy.update }, + }; + return callback(tx); + }, + ); + }; + + it('publishes the pending version on a successful approve', async () => { + const pendingVersion = { + id: 'ver_1', + version: 2, + content: [{ type: 'paragraph' }], + }; + db.policy.findUnique.mockResolvedValueOnce(buildPendingPolicy()); + db.policyVersion.findUnique.mockResolvedValueOnce(pendingVersion); + db.member.findFirst.mockResolvedValueOnce({ id: 'mem_caller' }); + mockTransactionTx(); + + const result = await service.acceptChanges( + 'pol_1', + 'org_abc', + { approverId: 'mem_approver' }, + 'usr_caller', + ); + + expect(result).toEqual({ versionId: 'ver_1', version: 2 }); + expect(db.policyVersion.update).toHaveBeenCalledWith({ + where: { id: 'ver_1' }, + data: { publishedById: 'mem_caller' }, + }); + const policyUpdateArg = db.policy.update.mock.calls[0][0]; + expect(policyUpdateArg.data.status).toBe('published'); + expect(policyUpdateArg.data.currentVersionId).toBe('ver_1'); + expect(policyUpdateArg.data.pendingVersionId).toBeNull(); + expect(policyUpdateArg.data.approverId).toBeNull(); + expect(policyUpdateArg.data.signedBy).toEqual([]); + }); + + it('succeeds when called via session impersonation — caller userId differs from approverId', async () => { + // Simulates an admin impersonating the assigned approver: + // the impersonated session's userId belongs to the approver, but + // the authorization check only requires the body-supplied approverId + // to match policy.approverId — which it does. + const pendingVersion = { + id: 'ver_1', + version: 2, + content: [], + }; + db.policy.findUnique.mockResolvedValueOnce(buildPendingPolicy()); + db.policyVersion.findUnique.mockResolvedValueOnce(pendingVersion); + db.member.findFirst.mockResolvedValueOnce({ id: 'mem_impersonated' }); + mockTransactionTx(); + + const result = await service.acceptChanges( + 'pol_1', + 'org_abc', + { approverId: 'mem_approver' }, + 'usr_impersonated', + ); + + expect(result).toEqual({ versionId: 'ver_1', version: 2 }); + expect(db.policyVersion.update).toHaveBeenCalledWith({ + where: { id: 'ver_1' }, + data: { publishedById: 'mem_impersonated' }, + }); + }); + + it('rejects when the body approverId does not match the assigned approver', async () => { + db.policy.findUnique.mockResolvedValueOnce(buildPendingPolicy()); + + await expect( + service.acceptChanges('pol_1', 'org_abc', { approverId: 'mem_wrong' }), + ).rejects.toThrow(/only the assigned approver/i); + + expect(db.$transaction).not.toHaveBeenCalled(); + }); + + it('self-heals stale approverId when no pending version exists', async () => { + const orgId = 'org_abc'; + const approverId = 'mem_approver'; + const stalePolicy = { + id: 'pol_1', + organizationId: orgId, + pendingVersionId: null, + approverId, + }; + db.policy.findUnique.mockResolvedValueOnce(stalePolicy); + db.policy.update.mockResolvedValueOnce({ ...stalePolicy, approverId: null }); + + await expect( + service.acceptChanges('pol_1', orgId, { approverId }), + ).rejects.toThrow(/no pending changes/i); + + expect(db.policy.update).toHaveBeenCalledWith({ + where: { id: 'pol_1' }, + data: { approverId: null }, + }); + expect(db.$transaction).not.toHaveBeenCalled(); + }); + + it('throws without mutating when the policy has no approval state at all', async () => { + const orgId = 'org_abc'; + const cleanPolicy = { + id: 'pol_1', + organizationId: orgId, + pendingVersionId: null, + approverId: null, + }; + db.policy.findUnique.mockResolvedValueOnce(cleanPolicy); + + await expect( + service.acceptChanges('pol_1', orgId, { approverId: 'mem_x' }), + ).rejects.toThrow(/no pending version/i); + + expect(db.policy.update).not.toHaveBeenCalled(); + }); + }); + + describe('denyChanges', () => { + it('reverts to draft on a successful deny when never published', async () => { + db.policy.findUnique.mockResolvedValueOnce({ + id: 'pol_1', + organizationId: 'org_abc', + pendingVersionId: 'ver_1', + approverId: 'mem_approver', + lastPublishedAt: null, + }); + db.policy.update.mockResolvedValueOnce({}); + + const result = await service.denyChanges('pol_1', 'org_abc', { + approverId: 'mem_approver', + }); + + expect(result).toEqual({ status: 'draft' }); + expect(db.policy.update).toHaveBeenCalledWith({ + where: { id: 'pol_1' }, + data: { + status: 'draft', + pendingVersionId: null, + approverId: null, + }, + }); + }); + + it('reverts to published on a successful deny when previously published', async () => { + db.policy.findUnique.mockResolvedValueOnce({ + id: 'pol_1', + organizationId: 'org_abc', + pendingVersionId: 'ver_2', + approverId: 'mem_approver', + lastPublishedAt: new Date('2026-01-01'), + }); + db.policy.update.mockResolvedValueOnce({}); + + const result = await service.denyChanges('pol_1', 'org_abc', { + approverId: 'mem_approver', + }); + + expect(result).toEqual({ status: 'published' }); + }); + + it('self-heals stale approverId when no pending version exists', async () => { + const orgId = 'org_abc'; + const approverId = 'mem_approver'; + const stalePolicy = { + id: 'pol_1', + organizationId: orgId, + pendingVersionId: null, + approverId, + }; + db.policy.findUnique.mockResolvedValueOnce(stalePolicy); + db.policy.update.mockResolvedValueOnce({ ...stalePolicy, approverId: null }); + + await expect( + service.denyChanges('pol_1', orgId, { approverId }), + ).rejects.toThrow(/no pending changes/i); + + expect(db.policy.update).toHaveBeenCalledWith({ + where: { id: 'pol_1' }, + data: { approverId: null }, + }); + }); + }); + describe('createVersion', () => { const organizationId = 'org_123'; const policyId = 'pol_1'; diff --git a/apps/api/src/policies/policies.service.ts b/apps/api/src/policies/policies.service.ts index 4fd8358246..9d3bc27a90 100644 --- a/apps/api/src/policies/policies.service.ts +++ b/apps/api/src/policies/policies.service.ts @@ -1029,6 +1029,21 @@ export class PoliciesService { } if (!policy.pendingVersionId) { + if (policy.approverId) { + if (policy.approverId !== dto.approverId) { + throw new BadRequestException( + 'Only the assigned approver can accept changes', + ); + } + await db.policy.update({ + where: { id: policyId }, + data: { approverId: null }, + }); + throw new BadRequestException( + 'This policy has no pending changes to approve. The stale approval request has been cleared — please ask the policy owner to re-submit if a new approval is needed.', + ); + } + } throw new BadRequestException('No pending version to approve'); } @@ -1090,6 +1105,15 @@ export class PoliciesService { } if (!policy.pendingVersionId) { + if (policy.approverId) { + await db.policy.update({ + where: { id: policyId }, + data: { approverId: null }, + }); + throw new BadRequestException( + 'This policy has no pending changes to deny. The stale approval request has been cleared — please ask the policy owner to re-submit if a new approval is needed.', + ); + } throw new BadRequestException('No pending version to deny'); } diff --git a/apps/api/src/trigger/policies/update-policy-helpers.ts b/apps/api/src/trigger/policies/update-policy-helpers.ts index c4fd5fec66..b5bc8bea51 100644 --- a/apps/api/src/trigger/policies/update-policy-helpers.ts +++ b/apps/api/src/trigger/policies/update-policy-helpers.ts @@ -275,11 +275,18 @@ export async function updatePolicyInDatabase( } await db.$transaction(async (tx) => { - // Clear version references first to avoid FK constraint issues during deletion + // Clear version references first to avoid FK constraint issues during deletion. + // Clear approverId alongside pendingVersionId so the two fields never diverge + // — any lingering approverId without a pending version produces the inconsistent + // state behind CS-254/260/261 ("No pending version to approve"). if (policy.versions.length > 0) { await tx.policy.update({ where: { id: policyId }, - data: { currentVersionId: null, pendingVersionId: null }, + data: { + currentVersionId: null, + pendingVersionId: null, + approverId: null, + }, }); await tx.policyVersion.deleteMany({ where: { policyId } }); } diff --git a/apps/app/src/app/(app)/[orgId]/policies/[policyId]/components/PolicyAlerts.test.tsx b/apps/app/src/app/(app)/[orgId]/policies/[policyId]/components/PolicyAlerts.test.tsx index d02034e5d0..c620ad73e2 100644 --- a/apps/app/src/app/(app)/[orgId]/policies/[policyId]/components/PolicyAlerts.test.tsx +++ b/apps/app/src/app/(app)/[orgId]/policies/[policyId]/components/PolicyAlerts.test.tsx @@ -128,6 +128,7 @@ describe('PolicyAlerts', () => { const pendingPolicy = { ...basePolicy, approverId: 'other-member', + pendingVersionId: 'ver-1', approver: { id: 'other-member', user: { name: 'Other User', email: 'other@test.com' }, @@ -138,6 +139,22 @@ describe('PolicyAlerts', () => { ); expect(screen.getByText('Pending approval')).toBeInTheDocument(); }); + + it('does not render pending approval notice when pendingVersionId is null (stale approverId)', () => { + const stalePolicy = { + ...basePolicy, + approverId: 'other-member', + pendingVersionId: null, + approver: { + id: 'other-member', + user: { name: 'Other User', email: 'other@test.com' }, + }, + }; + const { container } = render( + , + ); + expect(container.innerHTML).toBe(''); + }); }); describe('auditor permissions (no update)', () => { @@ -163,6 +180,7 @@ describe('PolicyAlerts', () => { const pendingPolicy = { ...basePolicy, approverId: 'other-member', + pendingVersionId: 'ver-1', approver: { id: 'other-member', user: { name: 'Other User', email: 'other@test.com' }, diff --git a/apps/app/src/app/(app)/[orgId]/policies/[policyId]/components/PolicyPageTabs.test.tsx b/apps/app/src/app/(app)/[orgId]/policies/[policyId]/components/PolicyPageTabs.test.tsx index 794992d8c0..e8e3fca5f7 100644 --- a/apps/app/src/app/(app)/[orgId]/policies/[policyId]/components/PolicyPageTabs.test.tsx +++ b/apps/app/src/app/(app)/[orgId]/policies/[policyId]/components/PolicyPageTabs.test.tsx @@ -68,8 +68,19 @@ vi.mock('../hooks/useAuditLogs', () => ({ // Mock child components to isolate testing vi.mock('./PolicyAlerts', () => ({ - PolicyAlerts: ({ policy }: { policy: unknown }) => ( -
{policy ? 'alerts' : 'no-alerts'}
+ PolicyAlerts: ({ + policy, + isPendingApproval, + }: { + policy: unknown; + isPendingApproval: boolean; + }) => ( +
+ {policy ? 'alerts' : 'no-alerts'} +
), })); @@ -239,4 +250,67 @@ describe('PolicyPageTabs', () => { ).not.toBeInTheDocument(); }); }); + + describe('isPendingApproval derivation', () => { + beforeEach(() => { + setMockPermissions(ADMIN_PERMISSIONS); + }); + + it('is true only when both approverId and pendingVersionId are set', () => { + const policy = { + ...basePolicy, + approverId: 'mem-1', + pendingVersionId: 'ver-1', + }; + render( + , + ); + expect(screen.getByTestId('policy-alerts')).toHaveAttribute( + 'data-pending', + 'true', + ); + }); + + it('is false when approverId is set but pendingVersionId is null (inconsistent state)', () => { + const stalePolicy = { + ...basePolicy, + approverId: 'mem-1', + pendingVersionId: null, + }; + render( + , + ); + expect(screen.getByTestId('policy-alerts')).toHaveAttribute( + 'data-pending', + 'false', + ); + }); + + it('is false when pendingVersionId is set but approverId is null', () => { + const policy = { + ...basePolicy, + approverId: null, + pendingVersionId: 'ver-1', + }; + render( + , + ); + expect(screen.getByTestId('policy-alerts')).toHaveAttribute( + 'data-pending', + 'false', + ); + }); + }); }); diff --git a/apps/app/src/app/(app)/[orgId]/policies/[policyId]/components/PolicyPageTabs.tsx b/apps/app/src/app/(app)/[orgId]/policies/[policyId]/components/PolicyPageTabs.tsx index ed311e64d9..6d284a21b6 100644 --- a/apps/app/src/app/(app)/[orgId]/policies/[policyId]/components/PolicyPageTabs.tsx +++ b/apps/app/src/app/(app)/[orgId]/policies/[policyId]/components/PolicyPageTabs.tsx @@ -128,8 +128,12 @@ export function PolicyPageTabs({ return JSON.stringify(draftContent) !== JSON.stringify(publishedContent); }, [policy]); - // Derive isPendingApproval from current policy data - const isPendingApproval = policy ? !!policy.approverId : initialIsPendingApproval; + // Derive isPendingApproval from current policy data — both fields must be set + // to treat the policy as pending. A stale approverId with no pendingVersionId + // is an inconsistent state that should not trigger approval UI. + const isPendingApproval = policy + ? !!policy.approverId && !!policy.pendingVersionId + : initialIsPendingApproval; const isDeleteDialogOpen = searchParams.get('delete-policy') === 'true'; const tabFromUrl = searchParams.get('tab') || 'overview'; diff --git a/apps/app/src/app/(app)/[orgId]/policies/[policyId]/page.tsx b/apps/app/src/app/(app)/[orgId]/policies/[policyId]/page.tsx index e0ba67813b..9bfe75f8f7 100644 --- a/apps/app/src/app/(app)/[orgId]/policies/[policyId]/page.tsx +++ b/apps/app/src/app/(app)/[orgId]/policies/[policyId]/page.tsx @@ -78,7 +78,7 @@ export default async function PolicyDetails({ ? activityRes.data.data : []; const versions = versionsRes.data?.data?.versions ?? []; - const isPendingApproval = !!policy?.approverId; + const isPendingApproval = !!policy?.approverId && !!policy?.pendingVersionId; // Check feature flag for AI policy editor const session = await auth.api.getSession({ diff --git a/packages/integration-platform/src/manifests/vercel/__tests__/app-availability.test.ts b/packages/integration-platform/src/manifests/vercel/__tests__/app-availability.test.ts new file mode 100644 index 0000000000..e37eeca22c --- /dev/null +++ b/packages/integration-platform/src/manifests/vercel/__tests__/app-availability.test.ts @@ -0,0 +1,150 @@ +import { describe, expect, it } from 'bun:test'; +import { appAvailabilityCheck } from '../checks/app-availability'; +import type { CheckContext, CheckVariableValues } from '../../../types'; +import type { + VercelDeployment, + VercelDeploymentsResponse, + VercelProject, + VercelProjectsResponse, +} from '../types'; + +const makeProject = (id: string, name: string): VercelProject => ({ + id, + name, + accountId: 'acc_1', + createdAt: 0, + updatedAt: 0, +}); + +const makeReadyDeployment = (): VercelDeployment => ({ + uid: 'dpl_1', + name: 'd', + url: 'd.vercel.app', + state: 'READY', + type: 'LAMBDAS', + created: Date.now(), + createdAt: Date.now(), + creator: { uid: 'u' }, +}); + +interface RunResult { + passedResourceIds: string[]; + failedResourceIds: string[]; + checkedProjectIds: string[]; +} + +async function runWithVariables( + projects: VercelProject[], + variables: CheckVariableValues | undefined, +): Promise { + const checkedProjectIds: string[] = []; + const passed: string[] = []; + const failed: string[] = []; + + const ctx: CheckContext = { + accessToken: 'tok', + credentials: {}, + variables, + connectionId: 'conn_1', + organizationId: 'org_1', + metadata: { oauth: { team: { id: 'team_1', name: 'Team' } } }, + log: () => {}, + pass: (result) => { + passed.push(result.resourceId); + }, + fail: (result) => { + failed.push(result.resourceId); + }, + fetch: (async (path: string): Promise => { + if (path === '/v9/projects?teamId=team_1' || path === '/v9/projects') { + return { projects } satisfies VercelProjectsResponse as unknown as T; + } + if (path.startsWith('/v6/deployments')) { + const url = new URL(path, 'https://api.vercel.com'); + const projectId = url.searchParams.get('projectId') ?? ''; + checkedProjectIds.push(projectId); + return { + deployments: [makeReadyDeployment()], + } satisfies VercelDeploymentsResponse as unknown as T; + } + throw new Error(`Unexpected fetch: ${path}`); + }) as CheckContext['fetch'], + fetchAllPages: (async () => []) as CheckContext['fetchAllPages'], + graphql: (async () => ({})) as CheckContext['graphql'], + } as CheckContext; + + await appAvailabilityCheck.run(ctx); + return { passedResourceIds: passed, failedResourceIds: failed, checkedProjectIds }; +} + +describe('appAvailabilityCheck filter behaviour', () => { + const projects = [ + makeProject('prj_a', 'a'), + makeProject('prj_b', 'b'), + makeProject('prj_c', 'c'), + ]; + + it('checks all projects when no filter is configured', async () => { + const result = await runWithVariables(projects, undefined); + expect(result.checkedProjectIds.sort()).toEqual(['prj_a', 'prj_b', 'prj_c']); + }); + + it('checks all projects when mode is "all" with a selection', async () => { + const result = await runWithVariables(projects, { + project_filter_mode: 'all', + filtered_projects: ['prj_a'], + }); + expect(result.checkedProjectIds.sort()).toEqual(['prj_a', 'prj_b', 'prj_c']); + }); + + it('checks only selected projects in include mode', async () => { + const result = await runWithVariables(projects, { + project_filter_mode: 'include', + filtered_projects: ['prj_a', 'prj_c'], + }); + expect(result.checkedProjectIds.sort()).toEqual(['prj_a', 'prj_c']); + }); + + it('skips selected projects in exclude mode', async () => { + const result = await runWithVariables(projects, { + project_filter_mode: 'exclude', + filtered_projects: ['prj_b'], + }); + expect(result.checkedProjectIds.sort()).toEqual(['prj_a', 'prj_c']); + }); + + it('falls back to all projects when include mode has no selection', async () => { + const result = await runWithVariables(projects, { + project_filter_mode: 'include', + filtered_projects: [], + }); + expect(result.checkedProjectIds.sort()).toEqual(['prj_a', 'prj_b', 'prj_c']); + }); + + it('emits a filter-applied evidence pass recording the active mode', async () => { + const result = await runWithVariables(projects, { + project_filter_mode: 'exclude', + filtered_projects: ['prj_b'], + }); + expect(result.passedResourceIds).toContain('project-filter'); + }); + + it('does not emit a filter-applied pass when no projects are returned', async () => { + const result = await runWithVariables([], { + project_filter_mode: 'exclude', + filtered_projects: ['prj_anything'], + }); + expect(result.passedResourceIds).not.toContain('project-filter'); + expect(result.failedResourceIds).toContain('projects'); + }); + + it('fails when filter resolves to zero scoped projects', async () => { + const result = await runWithVariables(projects, { + project_filter_mode: 'include', + filtered_projects: ['prj_does_not_exist'], + }); + expect(result.failedResourceIds).toContain('project-filter'); + expect(result.checkedProjectIds).toEqual([]); + expect(result.passedResourceIds).not.toContain('project-filter'); + }); +}); diff --git a/packages/integration-platform/src/manifests/vercel/__tests__/monitoring-alerting.test.ts b/packages/integration-platform/src/manifests/vercel/__tests__/monitoring-alerting.test.ts new file mode 100644 index 0000000000..c1ecfdb6a0 --- /dev/null +++ b/packages/integration-platform/src/manifests/vercel/__tests__/monitoring-alerting.test.ts @@ -0,0 +1,122 @@ +import { describe, expect, it } from 'bun:test'; +import { monitoringAlertingCheck } from '../checks/monitoring-alerting'; +import type { CheckContext, CheckVariableValues } from '../../../types'; +import type { + VercelDeployment, + VercelDeploymentsResponse, + VercelProject, + VercelProjectsResponse, +} from '../types'; + +const makeProject = (id: string, name: string): VercelProject => ({ + id, + name, + accountId: 'acc_1', + createdAt: 0, + updatedAt: 0, +}); + +const makeDeployment = (state: VercelDeployment['state']): VercelDeployment => ({ + uid: 'dpl_' + state, + name: state, + url: `${state}.vercel.app`, + state, + type: 'LAMBDAS', + created: Date.now(), + createdAt: Date.now(), + creator: { uid: 'u' }, +}); + +async function runWithVariables( + projects: VercelProject[], + variables: CheckVariableValues | undefined, +): Promise<{ + checkedProjectIds: string[]; + passedResourceIds: string[]; + failedResourceIds: string[]; +}> { + const checkedProjectIds: string[] = []; + const passed: string[] = []; + const failed: string[] = []; + + const ctx: CheckContext = { + accessToken: 'tok', + credentials: {}, + variables, + connectionId: 'conn_1', + organizationId: 'org_1', + metadata: { oauth: { team: { id: 'team_1', name: 'Team' } } }, + log: () => {}, + pass: (result) => { + passed.push(result.resourceId); + }, + fail: (result) => { + failed.push(result.resourceId); + }, + fetch: (async (path: string): Promise => { + if (path.startsWith('/v9/projects')) { + return { projects } satisfies VercelProjectsResponse as unknown as T; + } + if (path.startsWith('/v6/deployments')) { + const url = new URL(path, 'https://api.vercel.com'); + const projectId = url.searchParams.get('projectId') ?? ''; + checkedProjectIds.push(projectId); + return { + deployments: [makeDeployment('READY')], + } satisfies VercelDeploymentsResponse as unknown as T; + } + throw new Error(`Unexpected fetch: ${path}`); + }) as CheckContext['fetch'], + fetchAllPages: (async () => []) as CheckContext['fetchAllPages'], + graphql: (async () => ({})) as CheckContext['graphql'], + } as CheckContext; + + await monitoringAlertingCheck.run(ctx); + return { checkedProjectIds, passedResourceIds: passed, failedResourceIds: failed }; +} + +describe('monitoringAlertingCheck filter behaviour', () => { + const projects = [ + makeProject('prj_a', 'a'), + makeProject('prj_b', 'b'), + makeProject('prj_c', 'c'), + ]; + + it('defaults to all projects', async () => { + const result = await runWithVariables(projects, undefined); + expect(result.checkedProjectIds.sort()).toEqual(['prj_a', 'prj_b', 'prj_c']); + }); + + it('honours include mode', async () => { + const result = await runWithVariables(projects, { + project_filter_mode: 'include', + filtered_projects: ['prj_b'], + }); + expect(result.checkedProjectIds).toEqual(['prj_b']); + }); + + it('honours exclude mode', async () => { + const result = await runWithVariables(projects, { + project_filter_mode: 'exclude', + filtered_projects: ['prj_a'], + }); + expect(result.checkedProjectIds.sort()).toEqual(['prj_b', 'prj_c']); + }); + + it('emits a filter-applied evidence pass', async () => { + const result = await runWithVariables(projects, { + project_filter_mode: 'include', + filtered_projects: ['prj_b'], + }); + expect(result.passedResourceIds).toContain('project-filter'); + }); + + it('fails when filter resolves to zero scoped projects', async () => { + const result = await runWithVariables(projects, { + project_filter_mode: 'exclude', + filtered_projects: ['prj_a', 'prj_b', 'prj_c'], + }); + expect(result.failedResourceIds).toContain('project-filter'); + expect(result.checkedProjectIds).toEqual([]); + }); +}); diff --git a/packages/integration-platform/src/manifests/vercel/__tests__/variables.test.ts b/packages/integration-platform/src/manifests/vercel/__tests__/variables.test.ts new file mode 100644 index 0000000000..e5f2ea6d95 --- /dev/null +++ b/packages/integration-platform/src/manifests/vercel/__tests__/variables.test.ts @@ -0,0 +1,103 @@ +import { describe, expect, it } from 'bun:test'; +import { filteredProjectsVariable, parseVercelProjectFilter } from '../variables'; +import type { VariableFetchContext } from '../../../types'; + +describe('parseVercelProjectFilter', () => { + it('returns mode="all" and empty set when no variables are stored', () => { + const result = parseVercelProjectFilter(undefined); + expect(result.mode).toBe('all'); + expect(result.selectedIds.size).toBe(0); + }); + + it('returns mode="all" when project_filter_mode is missing', () => { + const result = parseVercelProjectFilter({ filtered_projects: ['prj_1'] }); + expect(result.mode).toBe('all'); + expect(result.selectedIds.has('prj_1')).toBe(true); + }); + + it('returns mode="include" with selected ids', () => { + const result = parseVercelProjectFilter({ + project_filter_mode: 'include', + filtered_projects: ['prj_1', 'prj_2'], + }); + expect(result.mode).toBe('include'); + expect(result.selectedIds.has('prj_1')).toBe(true); + expect(result.selectedIds.has('prj_2')).toBe(true); + expect(result.selectedIds.size).toBe(2); + }); + + it('returns mode="exclude" with selected ids', () => { + const result = parseVercelProjectFilter({ + project_filter_mode: 'exclude', + filtered_projects: ['prj_x'], + }); + expect(result.mode).toBe('exclude'); + expect(result.selectedIds.has('prj_x')).toBe(true); + }); + + it('falls back to mode="all" on unknown mode strings', () => { + const result = parseVercelProjectFilter({ + project_filter_mode: 'whatever', + filtered_projects: ['prj_1'], + }); + expect(result.mode).toBe('all'); + }); + + it('treats non-array filtered_projects as empty selection', () => { + const result = parseVercelProjectFilter({ + project_filter_mode: 'include', + filtered_projects: 'prj_1' as unknown as string[], + }); + expect(result.mode).toBe('include'); + expect(result.selectedIds.size).toBe(0); + }); +}); + +describe('filteredProjectsVariable.fetchOptions', () => { + it('paginates through all pages using pagination.next cursor', async () => { + const requestedUrls: string[] = []; + const ctx: VariableFetchContext = { + accessToken: 'tok', + graphql: (async () => ({})) as VariableFetchContext['graphql'], + fetchAllPages: (async () => []) as VariableFetchContext['fetchAllPages'], + fetch: (async (path: string): Promise => { + requestedUrls.push(path); + if (path.includes('until=100')) { + return { + projects: [ + { id: 'prj_2', name: 'bbb', accountId: 'a', createdAt: 0, updatedAt: 0 }, + ], + pagination: { count: 1, next: null, prev: null }, + } as unknown as T; + } + return { + projects: [ + { id: 'prj_1', name: 'aaa', accountId: 'a', createdAt: 0, updatedAt: 0 }, + ], + pagination: { count: 1, next: 100, prev: null }, + } as unknown as T; + }) as VariableFetchContext['fetch'], + }; + const options = await filteredProjectsVariable.fetchOptions!(ctx); + expect(options.map((o) => o.value).sort()).toEqual(['prj_1', 'prj_2']); + expect(requestedUrls.length).toBe(2); + expect(requestedUrls[0]).toContain('limit=100'); + expect(requestedUrls[1]).toContain('until=100'); + }); + + it('stops when pagination is missing or next is null', async () => { + const ctx: VariableFetchContext = { + accessToken: 'tok', + graphql: (async () => ({})) as VariableFetchContext['graphql'], + fetchAllPages: (async () => []) as VariableFetchContext['fetchAllPages'], + fetch: (async (_path: string): Promise => + ({ + projects: [ + { id: 'prj_1', name: 'a', accountId: 'x', createdAt: 0, updatedAt: 0 }, + ], + }) as unknown as T) as VariableFetchContext['fetch'], + }; + const options = await filteredProjectsVariable.fetchOptions!(ctx); + expect(options).toEqual([{ value: 'prj_1', label: 'a' }]); + }); +}); diff --git a/packages/integration-platform/src/manifests/vercel/checks/app-availability.ts b/packages/integration-platform/src/manifests/vercel/checks/app-availability.ts index ad26fd1935..39f85dcffc 100644 --- a/packages/integration-platform/src/manifests/vercel/checks/app-availability.ts +++ b/packages/integration-platform/src/manifests/vercel/checks/app-availability.ts @@ -1,5 +1,11 @@ import { TASK_TEMPLATES } from '../../../task-mappings'; import type { CheckContext, IntegrationCheck } from '../../../types'; +import { + applyVercelProjectFilter, + filteredProjectsVariable, + parseVercelProjectFilter, + projectFilterModeVariable, +} from '../variables'; import type { VercelDeploymentsResponse, VercelProject, @@ -18,7 +24,7 @@ export const appAvailabilityCheck: IntegrationCheck = { name: 'App Availability', description: 'Verify Vercel projects have active, healthy deployments', taskMapping: TASK_TEMPLATES.appAvailability, - variables: [], + variables: [projectFilterModeVariable, filteredProjectsVariable], run: async (ctx: CheckContext) => { ctx.log('Starting Vercel App Availability check'); @@ -66,10 +72,44 @@ export const appAvailabilityCheck: IntegrationCheck = { return; } + const filter = parseVercelProjectFilter(ctx.variables); + const scopedProjects = applyVercelProjectFilter(projects, filter); + if (filter.mode !== 'all' && scopedProjects.length === 0) { + ctx.fail({ + title: 'Project filter matched no projects', + resourceType: 'vercel', + resourceId: 'project-filter', + severity: 'medium', + description: `Filter mode "${filter.mode}" with ${filter.selectedIds.size} selected project(s) resolved to zero projects in scope. This may indicate deleted or renamed projects.`, + remediation: 'Open the Configure sheet for this automation and review the selected projects.', + evidence: { + filterMode: filter.mode, + selectedProjectIds: Array.from(filter.selectedIds), + availableProjectIds: projects.map((p) => p.id), + }, + }); + return; + } + ctx.log( + `Project filter mode=${filter.mode}, scoped ${scopedProjects.length} of ${projects.length} projects`, + ); + ctx.pass({ + title: 'Project filter applied', + resourceType: 'vercel', + resourceId: 'project-filter', + description: `Mode: ${filter.mode}. Projects in scope: ${scopedProjects.length}/${projects.length}.`, + evidence: { + filterMode: filter.mode, + selectedProjectIds: Array.from(filter.selectedIds), + scopedProjectIds: scopedProjects.map((p) => p.id), + totalProjectCount: projects.length, + }, + }); + // Transient states where Vercel keeps the previous READY deployment serving traffic const transitionalStates = new Set(['BUILDING', 'QUEUED', 'INITIALIZING']); - for (const project of projects.slice(0, 10)) { + for (const project of scopedProjects.slice(0, 10)) { try { const params = new URLSearchParams({ projectId: project.id, limit: '1', target: 'production' }); if (teamId) params.set('teamId', teamId); diff --git a/packages/integration-platform/src/manifests/vercel/checks/monitoring-alerting.ts b/packages/integration-platform/src/manifests/vercel/checks/monitoring-alerting.ts index 88001240c7..374cf4caad 100644 --- a/packages/integration-platform/src/manifests/vercel/checks/monitoring-alerting.ts +++ b/packages/integration-platform/src/manifests/vercel/checks/monitoring-alerting.ts @@ -1,5 +1,11 @@ import { TASK_TEMPLATES } from '../../../task-mappings'; import type { CheckContext, IntegrationCheck } from '../../../types'; +import { + applyVercelProjectFilter, + filteredProjectsVariable, + parseVercelProjectFilter, + projectFilterModeVariable, +} from '../variables'; import type { VercelDeployment, VercelDeploymentsResponse, @@ -19,7 +25,7 @@ export const monitoringAlertingCheck: IntegrationCheck = { name: 'Monitoring & Alerting Review', description: 'Verify webhooks and notifications are configured for deployment monitoring', taskMapping: TASK_TEMPLATES.monitoringAlerting, - variables: [], + variables: [projectFilterModeVariable, filteredProjectsVariable], run: async (ctx: CheckContext) => { ctx.log('Starting Vercel Monitoring & Alerting check'); @@ -62,12 +68,46 @@ export const monitoringAlertingCheck: IntegrationCheck = { return; } + const filter = parseVercelProjectFilter(ctx.variables); + const scopedProjects = applyVercelProjectFilter(projects, filter); + if (filter.mode !== 'all' && scopedProjects.length === 0) { + ctx.fail({ + title: 'Project filter matched no projects', + resourceType: 'vercel', + resourceId: 'project-filter', + severity: 'medium', + description: `Filter mode "${filter.mode}" with ${filter.selectedIds.size} selected project(s) resolved to zero projects in scope. This may indicate deleted or renamed projects.`, + remediation: 'Open the Configure sheet for this automation and review the selected projects.', + evidence: { + filterMode: filter.mode, + selectedProjectIds: Array.from(filter.selectedIds), + availableProjectIds: projects.map((p) => p.id), + }, + }); + return; + } + ctx.log( + `Project filter mode=${filter.mode}, scoped ${scopedProjects.length} of ${projects.length} projects`, + ); + ctx.pass({ + title: 'Project filter applied', + resourceType: 'vercel', + resourceId: 'project-filter', + description: `Mode: ${filter.mode}. Projects in scope: ${scopedProjects.length}/${projects.length}.`, + evidence: { + filterMode: filter.mode, + selectedProjectIds: Array.from(filter.selectedIds), + scopedProjectIds: scopedProjects.map((p) => p.id), + totalProjectCount: projects.length, + }, + }); + // Check recent deployments for failures ctx.log('Checking recent deployments...'); const recentDeployments: VercelDeployment[] = []; const failedDeployments: VercelDeployment[] = []; - for (const project of projects.slice(0, 10)) { + for (const project of scopedProjects.slice(0, 10)) { // Check first 10 projects try { const params = new URLSearchParams({ projectId: project.id, limit: '10' }); @@ -116,7 +156,7 @@ export const monitoringAlertingCheck: IntegrationCheck = { resourceId: 'recent-failures', description: 'No failed or canceled deployments detected in the reviewed projects.', evidence: { - reviewedProjects: projects.length, + reviewedProjects: scopedProjects.length, }, }); } @@ -136,7 +176,8 @@ export const monitoringAlertingCheck: IntegrationCheck = { }, projects: { total: projects.length, - names: projects.map((p) => p.name), + scoped: scopedProjects.length, + names: scopedProjects.map((p) => p.name), }, deployments: { recentTotal: recentDeployments.length, diff --git a/packages/integration-platform/src/manifests/vercel/variables.ts b/packages/integration-platform/src/manifests/vercel/variables.ts new file mode 100644 index 0000000000..d51034dc31 --- /dev/null +++ b/packages/integration-platform/src/manifests/vercel/variables.ts @@ -0,0 +1,102 @@ +import type { CheckVariable, CheckVariableValues } from '../../types'; +import type { VercelProject, VercelProjectsResponse } from './types'; + +export type VercelProjectFilterMode = 'all' | 'include' | 'exclude'; + +export interface VercelProjectFilter { + mode: VercelProjectFilterMode; + selectedIds: Set; +} + +const VALID_MODES: ReadonlySet = new Set([ + 'all', + 'include', + 'exclude', +]); + +export function parseVercelProjectFilter( + variables: CheckVariableValues | undefined, +): VercelProjectFilter { + const rawMode = variables?.project_filter_mode; + const mode: VercelProjectFilterMode = + typeof rawMode === 'string' && VALID_MODES.has(rawMode) + ? (rawMode as VercelProjectFilterMode) + : 'all'; + + const rawSelected = variables?.filtered_projects; + const selectedIds = new Set( + Array.isArray(rawSelected) ? (rawSelected.filter((v) => typeof v === 'string') as string[]) : [], + ); + + return { mode, selectedIds }; +} + +export function applyVercelProjectFilter>( + projects: T[], + filter: VercelProjectFilter, +): T[] { + if (filter.mode === 'all' || filter.selectedIds.size === 0) { + return projects; + } + if (filter.mode === 'include') { + return projects.filter((p) => filter.selectedIds.has(p.id)); + } + return projects.filter((p) => !filter.selectedIds.has(p.id)); +} + +export const projectFilterModeVariable: CheckVariable = { + id: 'project_filter_mode', + label: 'Projects to check', + helpText: + 'Choose which Vercel projects this automation checks. Pick "Only selected" or "Exclude selected" to narrow the scope.', + type: 'select', + required: false, + default: 'all', + options: [ + { value: 'all', label: 'All projects' }, + { value: 'include', label: 'Only selected projects' }, + { value: 'exclude', label: 'Exclude selected projects' }, + ], +}; + +export const filteredProjectsVariable: CheckVariable = { + id: 'filtered_projects', + label: 'Projects', + helpText: + 'Select projects to include or exclude based on the mode above. Ignored when mode is "All projects".', + type: 'multi-select', + required: false, + placeholder: 'Select projects…', + fetchOptions: async (ctx) => { + // OAuth token is installation-scoped (one installation = one team or personal account), + // so /v9/projects returns projects visible to this connection without an explicit teamId. + // Vercel paginates with a cursor-based `pagination.next` timestamp (pass as `until`); + // we loop until `next` is null to collect every page. + const seen = new Map(); + const MAX_PAGES = 20; + let until: number | null | undefined; + for (let page = 0; page < MAX_PAGES; page++) { + const params = new URLSearchParams({ limit: '100' }); + if (typeof until === 'number') { + params.set('until', String(until)); + } + const response = await ctx.fetch( + `/v9/projects?${params.toString()}`, + ); + const projects = response.projects ?? []; + for (const project of projects) { + if (!seen.has(project.id)) { + seen.set(project.id, project.name); + } + } + const next = response.pagination?.next; + if (typeof next !== 'number') { + break; + } + until = next; + } + return Array.from(seen.entries()) + .map(([value, label]) => ({ value, label })) + .sort((a, b) => a.label.localeCompare(b.label)); + }, +};