mirror of
https://github.com/documenso/documenso.git
synced 2026-07-25 09:25:08 +10:00
f2525ae95b
Programmatically record an external rejection on behalf of a recipient who declined outside the platform. Flags the rejection as external in the audit log, optionally attributes it to a specific team member via actAsEmail, and enforces team membership and document visibility.
261 lines
9.5 KiB
TypeScript
261 lines
9.5 KiB
TypeScript
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
|
import { createApiToken } from '@documenso/lib/server-only/public-api/create-api-token';
|
|
import { prisma } from '@documenso/prisma';
|
|
import { DocumentVisibility, SigningStatus, TeamMemberRole } from '@documenso/prisma/client';
|
|
import { seedPendingDocument } from '@documenso/prisma/seed/documents';
|
|
import { seedTeam, seedTeamMember } from '@documenso/prisma/seed/teams';
|
|
import { seedUser } from '@documenso/prisma/seed/users';
|
|
import type { TRejectEnvelopeRecipientOnBehalfOfRequest } from '@documenso/trpc/server/envelope-router/envelope-recipients/reject-envelope-recipient-on-behalf-of.types';
|
|
import { type APIRequestContext, expect, test } from '@playwright/test';
|
|
import type { Team, User } from '@prisma/client';
|
|
|
|
const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
|
|
const baseUrl = `${WEBAPP_BASE_URL}/api/v2-beta`;
|
|
|
|
test.describe.configure({
|
|
mode: 'parallel',
|
|
});
|
|
|
|
const rejectRecipient = (
|
|
request: APIRequestContext,
|
|
authToken: string,
|
|
envelopeId: string,
|
|
recipientId: number,
|
|
reason: string,
|
|
actAsEmail?: string,
|
|
) => {
|
|
return request.post(`${baseUrl}/envelope/recipient/${recipientId}/reject`, {
|
|
headers: { Authorization: `Bearer ${authToken}`, 'Content-Type': 'application/json' },
|
|
data: {
|
|
envelopeId,
|
|
recipientId,
|
|
reason,
|
|
actAsEmail,
|
|
} satisfies TRejectEnvelopeRecipientOnBehalfOfRequest,
|
|
});
|
|
};
|
|
|
|
test.describe('Reject recipient on behalf of', () => {
|
|
let user: User;
|
|
let team: Team;
|
|
let token: string;
|
|
|
|
test.beforeEach(async () => {
|
|
({ user, team } = await seedUser());
|
|
({ token } = await createApiToken({
|
|
userId: user.id,
|
|
teamId: team.id,
|
|
tokenName: 'test-reject-recipient',
|
|
expiresIn: null,
|
|
}));
|
|
});
|
|
|
|
test('should reject a recipient and record an external rejection audit log', async ({ request }) => {
|
|
const envelope = await seedPendingDocument(user, team.id, ['recipient@test.documenso.com']);
|
|
const recipient = envelope.recipients[0];
|
|
|
|
const res = await rejectRecipient(request, token, envelope.id, recipient.id, 'Declined out of band');
|
|
|
|
expect(res.ok()).toBeTruthy();
|
|
expect(res.status()).toBe(200);
|
|
|
|
const updatedRecipient = await prisma.recipient.findUniqueOrThrow({
|
|
where: { id: recipient.id },
|
|
});
|
|
|
|
expect(updatedRecipient.signingStatus).toBe(SigningStatus.REJECTED);
|
|
expect(updatedRecipient.rejectionReason).toBe('Declined out of band');
|
|
|
|
const auditLog = await prisma.documentAuditLog.findFirst({
|
|
where: {
|
|
envelopeId: envelope.id,
|
|
type: 'DOCUMENT_RECIPIENT_REJECTED',
|
|
},
|
|
orderBy: { createdAt: 'desc' },
|
|
});
|
|
|
|
expect(auditLog).not.toBeNull();
|
|
|
|
const auditData = auditLog!.data as Record<string, unknown>;
|
|
|
|
expect(auditData.recipientId).toBe(recipient.id);
|
|
expect(auditData.recipientEmail).toBe(recipient.email);
|
|
expect(auditData.reason).toBe('Declined out of band');
|
|
expect(auditData.isExternal).toBe(true);
|
|
|
|
// No actAsEmail supplied - the rejection defaults to the API user.
|
|
expect(auditLog!.userId).toBe(user.id);
|
|
expect(auditLog!.email).toBe(user.email);
|
|
expect(auditData.onBehalfOfUserEmail).toBeUndefined();
|
|
});
|
|
|
|
test('should attribute the rejection to the elected team member when actAsEmail is supplied', async ({ request }) => {
|
|
const member = await seedTeamMember({ teamId: team.id });
|
|
|
|
const envelope = await seedPendingDocument(user, team.id, ['recipient@test.documenso.com']);
|
|
const recipient = envelope.recipients[0];
|
|
|
|
const res = await rejectRecipient(request, token, envelope.id, recipient.id, 'Declined out of band', member.email);
|
|
|
|
expect(res.ok()).toBeTruthy();
|
|
expect(res.status()).toBe(200);
|
|
|
|
const auditLog = await prisma.documentAuditLog.findFirstOrThrow({
|
|
where: {
|
|
envelopeId: envelope.id,
|
|
type: 'DOCUMENT_RECIPIENT_REJECTED',
|
|
},
|
|
orderBy: { createdAt: 'desc' },
|
|
});
|
|
|
|
// The audit log actor must be the elected member, not the API user.
|
|
expect(auditLog.userId).toBe(member.id);
|
|
expect(auditLog.email).toBe(member.email);
|
|
|
|
const auditData = auditLog.data as Record<string, unknown>;
|
|
|
|
expect(auditData.isExternal).toBe(true);
|
|
expect(auditData.onBehalfOfUserEmail).toBe(member.email);
|
|
});
|
|
|
|
test('should reject when actAsEmail is not a member of the team', async ({ request }) => {
|
|
// A user that exists but belongs to a different team.
|
|
const { user: outsider } = await seedUser();
|
|
|
|
const envelope = await seedPendingDocument(user, team.id, ['recipient@test.documenso.com']);
|
|
const recipient = envelope.recipients[0];
|
|
|
|
const res = await rejectRecipient(
|
|
request,
|
|
token,
|
|
envelope.id,
|
|
recipient.id,
|
|
'Declined out of band',
|
|
outsider.email,
|
|
);
|
|
|
|
expect(res.ok()).toBeFalsy();
|
|
expect(res.status()).toBe(401);
|
|
|
|
// The recipient must remain untouched.
|
|
const untouchedRecipient = await prisma.recipient.findUniqueOrThrow({
|
|
where: { id: recipient.id },
|
|
});
|
|
|
|
expect(untouchedRecipient.signingStatus).toBe(SigningStatus.NOT_SIGNED);
|
|
expect(untouchedRecipient.rejectionReason).toBeNull();
|
|
});
|
|
|
|
test('should deny rejecting a recipient that has already actioned the document', async ({ request }) => {
|
|
const envelope = await seedPendingDocument(user, team.id, ['recipient@test.documenso.com']);
|
|
const recipient = envelope.recipients[0];
|
|
|
|
// Reject once - succeeds.
|
|
const firstRes = await rejectRecipient(request, token, envelope.id, recipient.id, 'First rejection');
|
|
expect(firstRes.ok()).toBeTruthy();
|
|
|
|
// Reject again - the recipient is no longer NOT_SIGNED.
|
|
const secondRes = await rejectRecipient(request, token, envelope.id, recipient.id, 'Second rejection');
|
|
|
|
expect(secondRes.ok()).toBeFalsy();
|
|
expect(secondRes.status()).toBe(400);
|
|
|
|
// The original rejection reason must remain unchanged.
|
|
const updatedRecipient = await prisma.recipient.findUniqueOrThrow({
|
|
where: { id: recipient.id },
|
|
});
|
|
|
|
expect(updatedRecipient.rejectionReason).toBe('First rejection');
|
|
});
|
|
|
|
test('should not allow rejecting a recipient in another team', async ({ request }) => {
|
|
// Seed a separate team/user that owns the document.
|
|
const { user: otherUser, team: otherTeam } = await seedUser();
|
|
|
|
const envelope = await seedPendingDocument(otherUser, otherTeam.id, ['recipient@test.documenso.com']);
|
|
const recipient = envelope.recipients[0];
|
|
|
|
// Use the original team's token - it must not be able to reject.
|
|
const res = await rejectRecipient(request, token, envelope.id, recipient.id, 'Should not work');
|
|
|
|
expect(res.ok()).toBeFalsy();
|
|
expect(res.status()).toBe(404);
|
|
|
|
// The recipient must remain untouched.
|
|
const untouchedRecipient = await prisma.recipient.findUniqueOrThrow({
|
|
where: { id: recipient.id },
|
|
});
|
|
|
|
expect(untouchedRecipient.signingStatus).toBe(SigningStatus.NOT_SIGNED);
|
|
expect(untouchedRecipient.rejectionReason).toBeNull();
|
|
});
|
|
|
|
test('should return 404 for a non-existent recipient', async ({ request }) => {
|
|
const envelope = await seedPendingDocument(user, team.id, ['recipient@test.documenso.com']);
|
|
|
|
const res = await rejectRecipient(request, token, envelope.id, 999999999, 'No such recipient');
|
|
|
|
expect(res.ok()).toBeFalsy();
|
|
expect(res.status()).toBe(404);
|
|
});
|
|
|
|
test('should return 404 when the recipient does not belong to the supplied envelope', async ({ request }) => {
|
|
const targetEnvelope = await seedPendingDocument(user, team.id, ['recipient@test.documenso.com']);
|
|
const otherEnvelope = await seedPendingDocument(user, team.id, ['other-recipient@test.documenso.com']);
|
|
|
|
const recipient = targetEnvelope.recipients[0];
|
|
|
|
// Valid recipient ID, but paired with the wrong envelope ID.
|
|
const res = await rejectRecipient(request, token, otherEnvelope.id, recipient.id, 'Mismatched envelope');
|
|
|
|
expect(res.ok()).toBeFalsy();
|
|
expect(res.status()).toBe(404);
|
|
|
|
// The recipient must remain untouched.
|
|
const untouchedRecipient = await prisma.recipient.findUniqueOrThrow({
|
|
where: { id: recipient.id },
|
|
});
|
|
|
|
expect(untouchedRecipient.signingStatus).toBe(SigningStatus.NOT_SIGNED);
|
|
expect(untouchedRecipient.rejectionReason).toBeNull();
|
|
});
|
|
|
|
test('should enforce document visibility: manager cannot reject on an ADMIN-only document', async ({ request }) => {
|
|
// The API token belongs to a MANAGER, who cannot see ADMIN-visibility docs.
|
|
const { team: visTeam, owner } = await seedTeam();
|
|
const manager = await seedTeamMember({ teamId: visTeam.id, role: TeamMemberRole.MANAGER });
|
|
|
|
const { token: managerToken } = await createApiToken({
|
|
userId: manager.id,
|
|
teamId: visTeam.id,
|
|
tokenName: 'manager-reject-token',
|
|
expiresIn: null,
|
|
});
|
|
|
|
// ADMIN-visibility document owned by the team owner.
|
|
const envelope = await seedPendingDocument(owner, visTeam.id, ['recipient@test.documenso.com'], {
|
|
createDocumentOptions: { visibility: DocumentVisibility.ADMIN },
|
|
});
|
|
const recipient = envelope.recipients[0];
|
|
|
|
const res = await rejectRecipient(
|
|
request,
|
|
managerToken,
|
|
envelope.id,
|
|
recipient.id,
|
|
'Should be hidden by visibility',
|
|
);
|
|
|
|
// Visibility failure surfaces as not-found, matching the canonical checks.
|
|
expect(res.ok()).toBeFalsy();
|
|
expect(res.status()).toBe(404);
|
|
|
|
const untouchedRecipient = await prisma.recipient.findUniqueOrThrow({
|
|
where: { id: recipient.id },
|
|
});
|
|
|
|
expect(untouchedRecipient.signingStatus).toBe(SigningStatus.NOT_SIGNED);
|
|
expect(untouchedRecipient.rejectionReason).toBeNull();
|
|
});
|
|
});
|