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
199 changes: 199 additions & 0 deletions apps/api/src/policies/policies.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -242,6 +242,205 @@ describe('PoliciesService', () => {
});
});

describe('acceptChanges', () => {
const buildPendingPolicy = (overrides: Record<string, unknown> = {}) => ({
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<unknown>) => {
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';
Expand Down
24 changes: 24 additions & 0 deletions apps/api/src/policies/policies.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.',
);
Comment thread
Marfuen marked this conversation as resolved.
}
}
throw new BadRequestException('No pending version to approve');
}

Expand Down Expand Up @@ -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');
}

Expand Down
11 changes: 9 additions & 2 deletions apps/api/src/trigger/policies/update-policy-helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 } });
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
Expand All @@ -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(
<PolicyAlerts policy={stalePolicy} isPendingApproval={true} />,
);
expect(container.innerHTML).toBe('');
});
});

describe('auditor permissions (no update)', () => {
Expand All @@ -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' },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -68,8 +68,19 @@ vi.mock('../hooks/useAuditLogs', () => ({

// Mock child components to isolate testing
vi.mock('./PolicyAlerts', () => ({
PolicyAlerts: ({ policy }: { policy: unknown }) => (
<div data-testid="policy-alerts">{policy ? 'alerts' : 'no-alerts'}</div>
PolicyAlerts: ({
policy,
isPendingApproval,
}: {
policy: unknown;
isPendingApproval: boolean;
}) => (
<div
data-testid="policy-alerts"
data-pending={String(isPendingApproval)}
>
{policy ? 'alerts' : 'no-alerts'}
</div>
),
}));

Expand Down Expand Up @@ -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(
<PolicyPageTabs
{...defaultProps}
policy={policy}
isPendingApproval={true}
/>,
);
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(
<PolicyPageTabs
{...defaultProps}
policy={stalePolicy}
isPendingApproval={true}
/>,
);
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(
<PolicyPageTabs
{...defaultProps}
policy={policy}
isPendingApproval={false}
/>,
);
expect(screen.getByTestId('policy-alerts')).toHaveAttribute(
'data-pending',
'false',
);
});
});
});
Loading
Loading