chore: merge origin/main into pr-2889 (resolve cancelled/expired status conflicts)

This commit is contained in:
ephraimduncan
2026-06-25 11:14:29 +00:00
460 changed files with 33632 additions and 5002 deletions
@@ -0,0 +1,715 @@
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { createApiToken } from '@documenso/lib/server-only/public-api/create-api-token';
import { mapSecondaryIdToDocumentId } from '@documenso/lib/utils/envelope';
import { prisma } from '@documenso/prisma';
import { FieldType } from '@documenso/prisma/client';
import { seedPendingDocumentWithFullFields } from '@documenso/prisma/seed/documents';
import { seedUser } from '@documenso/prisma/seed/users';
import { type APIRequestContext, type APIResponse, expect, test } from '@playwright/test';
import type { Organisation, Team, User } from '@prisma/client';
/**
* Dynamic organisation rate-limit & quota tests — API **v1** edition.
*
* This is the v1 counterpart to `../v2/organisation-rate-limits.spec.ts`. It
* covers the feature added in `feat: add dynamic rate limits`:
* - Three counters: `api`, `document`, `email`.
* - Two enforcement stages per counter:
* 1. Windowed rate limits (`*RateLimits`) — a 429 distinguished by its
* message. NOTE: in v2 this 429 also carries `X-RateLimit-*` headers, but
* v1 does NOT surface them (the ts-rest handler drops the headers the
* middleware returns — see the windowed test), so v1 tells the windowed
* stage apart from the quota stage by the MESSAGE alone.
* 2. Monthly quota (`*Quota`) — 429 WITHOUT rate-limit headers; a `null`
* quota means unlimited and a `0` quota is a hard block.
*
* --- WHAT THIS V1 SUITE COVERS (and what it intentionally does NOT) ---
* api -> every authenticated v1 request (get-api-token-by-token). Ported
* 1:1 from the v2 suite against `GET /api/v1/documents`.
* email -> resend (`POST /api/v1/documents/:id/resend`) consumes
* `recipientsToRemind.length` SYNCHRONOUSLY (resend-document), so we
* can assert on the HTTP response rather than racing async jobs.
* IMPORTANT V1 DIVERGENCE: the v1 `resendDocument` handler catches
* EVERY error and returns a generic HTTP 500
* (`{ message: 'An error has occured while resending the document' }`)
* — it does NOT surface the org limiter's 429 / `X-RateLimit-*`
* headers like the v2 `redistribute` endpoint does. These tests
* therefore assert the v1 reality: a blocked resend returns 500 and
* the monthly counter advances exactly as documented.
* document -> INTENTIONALLY OMITTED. v1's `POST /api/v1/documents` create path
* requires S3 upload transport (createEnvelope), which the local E2E
* environment generally does not provide, so it cannot be exercised
* deterministically here. Document-counter enforcement is covered by
* the v2 suite (envelope/create).
*
* --- WHY THIS TEST IS SKIPPED IN CI ---
* CI runs E2E with `DANGEROUS_BYPASS_RATE_LIMITS=true`, which short-circuits BOTH
* the per-org assertion and the global IP limiter, making every assertion here
* meaningless. The test therefore skips itself in that mode and is intended to be
* run deliberately and locally with the bypass OFF.
*
* --- GLOBAL LIMIT AWARENESS ---
* apps/remix/server/router.ts applies a GLOBAL per-IP limiter to /api/v1/*:
* apiV1RateLimit = 100 requests / 1 minute (action `api.v1`, see rate-limits.ts).
* Every per-org limit/quota configured here is kept FAR below that ceiling (single
* digits) and the suite runs serially so the shared-IP global bucket is never the
* thing that trips. A global-limit 429 is shaped `{ error }` whereas an org-limit
* 429 is shaped `{ message }` — `expectOrgLimited()` asserts the 429 status AND
* that we hit the org limiter rather than the global one.
*/
const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
const baseUrl = `${WEBAPP_BASE_URL}/api/v1`;
// Run serially: all workers share one IP, and the global /api/v1 limiter is
// per-IP. Serial execution keeps the shared global bucket well under 100/min.
test.describe.configure({ mode: 'serial' });
// This suite is only meaningful with real rate limiting enabled. CI sets the
// bypass flag, so skip there; run it locally with the bypass turned off.
test.skip(process.env.DANGEROUS_BYPASS_RATE_LIMITS === 'true', 'Test skipped because bypass rate limits is enabled.');
const WINDOWED_LIMIT_MESSAGE = /contact support if you require higher limits/i;
const NO_QUOTA_MESSAGE = /request could not be completed at this time/i;
// ---------------------------------------------------------------------------
// Claim / usage control (direct Prisma) — mirrors recipient-count-limit.spec.ts
// ---------------------------------------------------------------------------
type RateLimitEntry = { window: `${number}${'s' | 'm' | 'h' | 'd'}`; max: number };
type ClaimLimits = {
apiRateLimits?: RateLimitEntry[];
apiQuota?: number | null;
documentRateLimits?: RateLimitEntry[];
documentQuota?: number | null;
emailRateLimits?: RateLimitEntry[];
emailQuota?: number | null;
};
const currentMonthlyPeriod = (): string => {
const now = new Date();
const month = String(now.getUTCMonth() + 1).padStart(2, '0');
return `${now.getUTCFullYear()}-${month}`;
};
const getOrganisationClaim = async (team: Team) =>
prisma.organisationClaim.findFirstOrThrow({
where: { organisation: { id: team.organisationId } },
});
/**
* Apply a clean set of limits to the org's claim. Any counter not provided is
* reset to "unlimited" (empty windows + null quota) so scenarios never leak into
* each other.
*/
const setClaimLimits = async (team: Team, limits: ClaimLimits) => {
const claim = await getOrganisationClaim(team);
await prisma.organisationClaim.update({
where: { id: claim.id },
data: {
apiRateLimits: limits.apiRateLimits ?? [],
apiQuota: limits.apiQuota === undefined ? null : limits.apiQuota,
documentRateLimits: limits.documentRateLimits ?? [],
documentQuota: limits.documentQuota === undefined ? null : limits.documentQuota,
emailRateLimits: limits.emailRateLimits ?? [],
emailQuota: limits.emailQuota === undefined ? null : limits.emailQuota,
},
});
};
/**
* Clear the monthly quota counters, the org windowed rate-limit buckets AND the
* GLOBAL /api/v1 IP bucket so a fresh scenario starts from zero.
*
* - The org windowed limiter keys its rows `ip:org:<id>`.
* - The GLOBAL limiter (apps/remix/server/router.ts -> apiV1RateLimit, 100/min
* per IP, action `api.v1`) is shared by EVERY v1 request from this test client.
* Across the suite (and especially across repeated local runs within the same
* minute) that shared bucket would otherwise fill up and trip BEFORE the org
* limit under test, producing a `{ error }` 429 instead of the org `{ message }`
* one. Since this suite runs deliberately in isolation (it skips in CI), we
* clear that bucket here so the global limiter never masks the org assertion.
*/
const resetUsage = async (organisation: Organisation) => {
const period = currentMonthlyPeriod();
await prisma.organisationMonthlyStat.updateMany({
where: { organisationId: organisation.id, period },
data: {
documentCount: 0,
emailCount: 0,
apiCount: 0,
},
});
await prisma.rateLimit.deleteMany({
where: {
OR: [{ key: `ip:org:${organisation.id}` }, { action: 'api.v1' }],
},
});
};
type MonthlyCounter = 'documentCount' | 'emailCount' | 'apiCount';
const getMonthlyStat = async (organisation: Organisation) =>
prisma.organisationMonthlyStat.findUnique({
where: {
organisationId_period: { organisationId: organisation.id, period: currentMonthlyPeriod() },
},
select: { documentCount: true, emailCount: true, apiCount: true },
});
/**
* Assert the live OrganisationMonthlyStat counter equals `expected`.
*
* The DB counter is the source of truth for quota enforcement, so checking its
* exact value (not just the HTTP response) proves the documented increment
* semantics in check-monthly-quota.ts:
* - quota === null -> unlimited: never blocks, but the request is STILL
* counted (the upsert now runs before the null return)
* - quota === 0 -> throws BEFORE increment (stays 0)
* - quota > 0 -> incremented by `count` BEFORE the over-quota check, so
* even the request that gets rejected still advances it
* - windowed limit -> trips BEFORE the quota stage, so the counter is untouched
*/
const expectMonthlyCounter = async (organisation: Organisation, counter: MonthlyCounter, expected: number) => {
const stat = await getMonthlyStat(organisation);
expect(stat?.[counter] ?? 0, `${counter} should be exactly ${expected}`).toBe(expected);
};
/**
* Sleep until just after the next windowed-limit bucket boundary.
*
* The limiter (createRateLimit -> getBucket) buckets time as
* `now - (now % windowMs)` aligned to the epoch. A windowed exhaustion test must
* land all of its MAX+1 requests inside ONE bucket; if the requests straddle a
* boundary the counter resets mid-test and the expected 429 never happens. We
* share the server's clock (same host), so aligning to a fresh bucket here makes
* the exhaustion deterministic.
*/
const alignToFreshWindowBucket = async (windowSeconds: number) => {
const windowMs = windowSeconds * 1000;
const msUntilNextBucket = windowMs - (Date.now() % windowMs);
await new Promise((resolve) => setTimeout(resolve, msUntilNextBucket + 100));
};
/**
* Guarantee at least `requiredHeadroomMs` remain in the current bucket so a burst
* of MAX+1 requests completes inside ONE window. Without this, a burst that
* happens to cross a bucket boundary would have its count reset mid-test and the
* expected 429 would never fire. Unlike `alignToFreshWindowBucket`, this only
* sleeps when we are actually near a boundary, so for long (e.g. 1m) windows it
* is almost always a no-op.
*/
const ensureWindowHeadroom = async (windowSeconds: number, requiredHeadroomMs: number) => {
const windowMs = windowSeconds * 1000;
const msLeftInBucket = windowMs - (Date.now() % windowMs);
if (msLeftInBucket < requiredHeadroomMs) {
await new Promise((resolve) => setTimeout(resolve, msLeftInBucket + 100));
}
};
// ---------------------------------------------------------------------------
// HTTP helpers
// ---------------------------------------------------------------------------
type ApiErrorBody = { message?: string; error?: string };
/**
* Non-throwing predicate: true when the response is an ORG-level 429
* (`{ message }`), not the global IP 429 (`{ error }`). Used by the preflight,
* which needs a boolean to decide whether to skip rather than fail.
*/
const isOrgLimited = async (res: APIResponse): Promise<boolean> => {
if (res.status() !== 429) {
return false;
}
const body = (await res.json().catch(() => ({}))) as ApiErrorBody;
// Global limiter returns `{ error }`; org limiter returns `{ message }`.
return body.message !== undefined && body.error === undefined;
};
/**
* Assert the response is an ORG-level 429 and return its parsed body.
*
* Checks the status code EXPLICITLY so a wrong 200/4xx fails with a clear
* "Expected 429, got <status>: <body>" message instead of an opaque
* `expected true, received false`. Also asserts the body is the org limiter's
* `{ message }` shape and not the global limiter's `{ error }` shape, so a
* global-IP 429 can never be mistaken for the org limit under test.
*/
const expectOrgLimited = async (res: APIResponse): Promise<ApiErrorBody> => {
const bodyText = await res.text();
expect(res.status(), `Expected an org 429 but got ${res.status()} with body: ${bodyText}`).toBe(429);
let body: ApiErrorBody = {};
try {
body = JSON.parse(bodyText) as ApiErrorBody;
} catch {
throw new Error(`Expected a JSON error body, got: ${bodyText}`);
}
expect(
body.message !== undefined && body.error === undefined,
`429 should be the ORG limiter ({ message }), not the global limiter ({ error }). Got: ${bodyText}`,
).toBeTruthy();
return body;
};
/**
* Assert NO org rate-limit header was surfaced — the GLOBAL /api/v1 middleware
* still stamps a single `X-RateLimit-Limit: 100`, so "no org header" means the
* value is either absent or exactly the lone global `100` (i.e. it does not
* contain a second, org-specific entry).
*
* In v1 this holds for BOTH stages: quota rejections intentionally omit
* rate-limit headers, AND windowed rejections lose theirs because the ts-rest
* handler ignores the `headers` the middleware returns (see the windowed test).
*/
const expectNoOrgRateLimitHeader = (res: APIResponse) => {
const header = res.headers()['x-ratelimit-limit'];
if (header === undefined) {
return;
}
const values = header.split(',').map((v) => v.trim());
expect(values, `Quota rejection should not add an org X-RateLimit-Limit, got "${header}"`).toEqual(['100']);
};
/** Guard against the global limiter silently masking an org assertion. */
const expectNotGlobalLimited = async (res: APIResponse) => {
if (res.status() === 429) {
const body = await res.json().catch(() => ({}));
expect(
'error' in body && !('message' in body),
'Hit the GLOBAL /api/v1 IP limiter, not the org limiter. Re-run this suite in isolation.',
).toBeFalsy();
}
};
/** Cheap read endpoint — consumes exactly one `api` counter, no document/email. */
const findDocuments = (request: APIRequestContext, token: string): Promise<APIResponse> =>
request.get(`${baseUrl}/documents?page=1&perPage=1`, {
headers: { Authorization: `Bearer ${token}` },
});
/**
* Resend (remind) the given recipients. This runs the SYNCHRONOUS email assertion
* in resend-document with `count = recipients.length`.
*
* NOTE: unlike the v2 `redistribute` endpoint, the v1 `resendDocument` handler
* wraps everything in a try/catch and returns a generic HTTP 500 on ANY error
* (including the org limiter's TOO_MANY_REQUESTS AppError). So when the email
* limit/quota is exceeded this resolves to a 500, NOT a 429.
*/
const resendDocument = (
request: APIRequestContext,
token: string,
documentId: number,
recipientIds: number[],
): Promise<APIResponse> =>
request.post(`${baseUrl}/documents/${documentId}/resend`, {
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
data: { recipients: recipientIds },
});
/**
* Assert a resend was blocked by the org email limiter.
*
* v1's handler masks the limiter's 429 as a generic HTTP 500 (see `resendDocument`
* above), so the only signal available on the HTTP layer is the 500 status. The
* accompanying `expectMonthlyCounter` assertions in each test prove WHICH stage
* blocked it (windowed leaves the counter untouched; quota advances it).
*/
const expectResendBlocked = async (res: APIResponse) => {
const bodyText = await res.text();
expect(
res.status(),
`Expected the v1 resend to be blocked (masked as HTTP 500) but got ${res.status()} with body: ${bodyText}`,
).toBe(500);
};
/**
* Seed a PENDING document with `recipientCount` NOT_SIGNED signer recipients (each
* carrying a signature field) created directly via Prisma — so no async signing
* emails are fanned out and the monthly email counter starts clean. Returns the
* legacy document id (for the resend endpoint) and the recipient ids to remind.
*/
const seedRemindableDocument = async ({
owner,
team,
recipientCount,
}: {
owner: User;
team: Team;
recipientCount: number;
}): Promise<{ documentId: number; recipientIds: number[] }> => {
const { document, recipients } = await seedPendingDocumentWithFullFields({
owner,
teamId: team.id,
recipients: Array.from(
{ length: recipientCount },
(_, i) => `rl-${Date.now()}-${i}-${Math.random().toString(36).slice(2)}@test.documenso.com`,
),
fields: [FieldType.SIGNATURE],
});
return {
documentId: mapSecondaryIdToDocumentId(document.secondaryId),
recipientIds: recipients.map((recipient) => recipient.id),
};
};
// ===========================================================================
// Tests
// ===========================================================================
test.describe('Organisation dynamic rate limits & quotas (API v1)', () => {
let user: User;
let team: Team;
let organisation: Organisation;
let token: string;
test.beforeEach(async ({ request }) => {
const seeded = await seedUser();
user = seeded.user;
team = seeded.team;
organisation = seeded.organisation;
({ token } = await createApiToken({
userId: user.id,
teamId: team.id,
tokenName: 'test-org-rate-limits',
expiresIn: null,
}));
// Preflight: the `test.skip` above only sees the PLAYWRIGHT process env. The
// value that actually matters is the env the SERVER was started with — if the
// server has `DANGEROUS_BYPASS_RATE_LIMITS=true`, every assertion here would
// fail confusingly instead of skipping. Prove enforcement is live by setting a
// quota of 0 (instant hard block) and confirming the server rejects. If it
// doesn't, the server is bypassing limits, so skip with a clear message.
await setClaimLimits(team, { apiQuota: 0 });
await resetUsage(organisation);
const preflight = await findDocuments(request, token);
const enforced = await isOrgLimited(preflight);
// Reset back to a clean slate before the real scenario runs.
await setClaimLimits(team, {});
await resetUsage(organisation);
test.skip(
!enforced,
'Server is not enforcing organisation rate limits (likely started with DANGEROUS_BYPASS_RATE_LIMITS=true). Restart the server with the flag unset/false to run this suite.',
);
});
// =========================================================================
// API counter — windowed rate limit
// =========================================================================
test.describe('api rate limit (windowed)', () => {
test('allows requests up to the limit then 429s with rate-limit headers', async ({ request }) => {
const MAX = 4;
await setClaimLimits(team, { apiRateLimits: [{ window: '1m', max: MAX }] });
await resetUsage(organisation);
// Make sure the MAX+1 request burst lands inside a single 1m bucket.
await ensureWindowHeadroom(60, 10_000);
// Each request (including these GETs) consumes one api counter.
for (let i = 0; i < MAX; i += 1) {
const res = await findDocuments(request, token);
await expectNotGlobalLimited(res);
expect(res.status(), `request #${i + 1} should be allowed`).toBe(200);
}
// The next request is over the windowed limit.
const limitedRes = await findDocuments(request, token);
const body = await expectOrgLimited(limitedRes);
// The windowed limit uses a message distinct from the global limiter — and
// in v1 the MESSAGE is the only signal we get (see note below), so it is how
// we tell a windowed rejection apart from a quota one.
expect(String(body.message)).toMatch(WINDOWED_LIMIT_MESSAGE);
// V1 DIVERGENCE: unlike v2, v1's ts-rest handler does not propagate the org
// limiter's `X-RateLimit-*` headers. `authenticatedMiddleware` returns them
// on the body object (`headers: err.headers`), which `@ts-rest/serverless`
// ignores (custom headers must be written to the `responseHeaders` Headers
// object). So only the global middleware's lone `X-RateLimit-Limit: 100`
// survives — the org `max` and `Retry-After`/`Remaining` never reach the
// client. We therefore assert no org-specific header is surfaced.
expectNoOrgRateLimitHeader(limitedRes);
// The windowed stage blocks the (MAX+1)th request before the quota upsert,
// but each of the MAX allowed requests still records usage (null quota now
// tracks instead of skipping), so the counter equals MAX.
await expectMonthlyCounter(organisation, 'apiCount', MAX);
});
test('a single allowed request succeeds when the limit is 1', async ({ request }) => {
await setClaimLimits(team, { apiRateLimits: [{ window: '1m', max: 1 }] });
await resetUsage(organisation);
// Make sure both requests land inside a single 1m bucket.
await ensureWindowHeadroom(60, 10_000);
const okRes = await findDocuments(request, token);
await expectNotGlobalLimited(okRes);
expect(okRes.status()).toBe(200);
const limitedRes = await findDocuments(request, token);
const body = await expectOrgLimited(limitedRes);
expect(String(body.message)).toMatch(WINDOWED_LIMIT_MESSAGE);
// The one allowed request is counted (null quota still tracks); the blocked
// request trips the window before the quota upsert, so the counter is 1.
await expectMonthlyCounter(organisation, 'apiCount', 1);
});
test('the windowed limit RESETS once the window elapses (429 -> wait -> 200)', async ({ request }) => {
const MAX = 2;
const WINDOW_SECONDS = 3;
await setClaimLimits(team, { apiRateLimits: [{ window: `${WINDOW_SECONDS}s`, max: MAX }] });
await resetUsage(organisation);
// Land at the start of a fresh bucket so all MAX+1 requests below fall in
// the SAME window (otherwise a mid-exhaustion boundary would reset the count).
await alignToFreshWindowBucket(WINDOW_SECONDS);
// Exhaust the window.
for (let i = 0; i < MAX; i += 1) {
const res = await findDocuments(request, token);
await expectNotGlobalLimited(res);
expect(res.status(), `request #${i + 1} should be allowed`).toBe(200);
}
// The next request is blocked by the window.
const limitedRes = await findDocuments(request, token);
await expectOrgLimited(limitedRes);
// Wait out the window using the server-provided Retry-After (plus a small
// buffer to be sure we've crossed into the next time bucket). Crucially we
// do NOT reset usage here — the limiter must recover on its own as the
// bucket rolls over.
const retryAfterHeader = limitedRes.headers()['retry-after'] ?? String(WINDOW_SECONDS);
const retryAfterSeconds = Number.parseInt(retryAfterHeader.split(',')[0]?.trim() ?? '', 10) || WINDOW_SECONDS;
await new Promise((resolve) => setTimeout(resolve, (retryAfterSeconds + 1) * 1000));
// Window has elapsed: the same org can make requests again without any
// manual intervention — the bucket rolled over on its own.
const afterReset = await findDocuments(request, token);
await expectNotGlobalLimited(afterReset);
expect(afterReset.status(), 'request after the window elapsed should be allowed').toBe(200);
});
});
// =========================================================================
// API counter — monthly quota
// =========================================================================
test.describe('api quota (monthly)', () => {
test('null quota allows unlimited requests', async ({ request }) => {
await setClaimLimits(team, { apiQuota: null });
await resetUsage(organisation);
for (let i = 0; i < 6; i += 1) {
const res = await findDocuments(request, token);
await expectNotGlobalLimited(res);
expect(res.status()).toBe(200);
}
// A null quota means "unlimited" (never blocks), but every request is now
// recorded so usage is visible on unlimited plans — so the counter is 6.
await expectMonthlyCounter(organisation, 'apiCount', 6);
});
test('exhausting the quota 429s without rate-limit headers and keeps counting', async ({ request }) => {
const QUOTA = 3;
await setClaimLimits(team, { apiQuota: QUOTA });
await resetUsage(organisation);
for (let i = 0; i < QUOTA; i += 1) {
const res = await findDocuments(request, token);
await expectNotGlobalLimited(res);
expect(res.status(), `request #${i + 1} should be within quota`).toBe(200);
}
const limitedRes = await findDocuments(request, token);
const body = await expectOrgLimited(limitedRes);
expect(String(body.message)).toMatch(NO_QUOTA_MESSAGE);
// Quota rejections deliberately omit rate-limit headers (it isn't a window).
expectNoOrgRateLimitHeader(limitedRes);
// The atomic increment runs even on the rejected request: QUOTA allowed
// requests + the one rejected request = exactly QUOTA + 1.
await expectMonthlyCounter(organisation, 'apiCount', QUOTA + 1);
});
test('quota of exactly 1 allows one request then blocks', async ({ request }) => {
await setClaimLimits(team, { apiQuota: 1 });
await resetUsage(organisation);
const okRes = await findDocuments(request, token);
await expectNotGlobalLimited(okRes);
expect(okRes.status()).toBe(200);
const limitedRes = await findDocuments(request, token);
await expectOrgLimited(limitedRes);
// One allowed + one rejected, both incremented => exactly 2.
await expectMonthlyCounter(organisation, 'apiCount', 2);
});
test('quota of 0 is a hard block with a "no quota available" message', async ({ request }) => {
await setClaimLimits(team, { apiQuota: 0 });
await resetUsage(organisation);
const limitedRes = await findDocuments(request, token);
const body = await expectOrgLimited(limitedRes);
expect(String(body.message)).toMatch(NO_QUOTA_MESSAGE);
// quota === 0 throws before the increment, so the counter stays at zero.
await expectMonthlyCounter(organisation, 'apiCount', 0);
});
});
// =========================================================================
// Email counter — windowed rate limit (via synchronous resend)
// =========================================================================
test.describe('email rate limit (windowed)', () => {
test('resend is allowed when recipient count is within the email window', async ({ request }) => {
const { documentId, recipientIds } = await seedRemindableDocument({ owner: user, team, recipientCount: 2 });
// Window allows 5/min; reminding 2 recipients is fine. Reset usage so the
// seeding above doesn't count against this window.
await setClaimLimits(team, { emailRateLimits: [{ window: '1m', max: 5 }] });
await resetUsage(organisation);
const res = await resendDocument(request, token, documentId, recipientIds);
await expectNotGlobalLimited(res);
expect(res.ok(), `resend should succeed: ${await res.text()}`).toBeTruthy();
// The windowed pass is now recorded even though the quota is null, so the
// counter advances by the batch size (recipientIds.length).
await expectMonthlyCounter(organisation, 'emailCount', recipientIds.length);
});
test('resend is blocked when recipient count exceeds the email window', async ({ request }) => {
const { documentId, recipientIds } = await seedRemindableDocument({ owner: user, team, recipientCount: 3 });
// Window only allows 2 emails per minute; reminding 3 at once exceeds it.
await setClaimLimits(team, { emailRateLimits: [{ window: '1m', max: 2 }] });
await resetUsage(organisation);
const res = await resendDocument(request, token, documentId, recipientIds);
// v1 masks the org 429 as a generic HTTP 500.
await expectResendBlocked(res);
// Windowed limit trips BEFORE the quota stage, so the counter is untouched.
await expectMonthlyCounter(organisation, 'emailCount', 0);
});
});
// =========================================================================
// Email counter — monthly quota (via synchronous resend)
// =========================================================================
test.describe('email quota (monthly)', () => {
test('resend within the remaining email quota succeeds', async ({ request }) => {
const { documentId, recipientIds } = await seedRemindableDocument({ owner: user, team, recipientCount: 2 });
await setClaimLimits(team, { emailQuota: 10 });
await resetUsage(organisation);
const res = await resendDocument(request, token, documentId, recipientIds);
await expectNotGlobalLimited(res);
expect(res.ok(), `resend should succeed: ${await res.text()}`).toBeTruthy();
// The synchronous assertion consumed exactly `recipientIds.length` of quota.
await expectMonthlyCounter(organisation, 'emailCount', recipientIds.length);
});
test('resend that would exceed the email quota is blocked', async ({ request }) => {
const { documentId, recipientIds } = await seedRemindableDocument({ owner: user, team, recipientCount: 3 });
// Quota of 2 but reminding 3 recipients in one synchronous call.
await setClaimLimits(team, { emailQuota: 2 });
await resetUsage(organisation);
const res = await resendDocument(request, token, documentId, recipientIds);
// v1 masks the org 429 as a generic HTTP 500.
await expectResendBlocked(res);
// The count (3) is added BEFORE the over-quota check throws, so the counter
// advances by the full batch even though the request was rejected.
await expectMonthlyCounter(organisation, 'emailCount', recipientIds.length);
});
test('email quota of 0 hard-blocks reminders', async ({ request }) => {
const { documentId, recipientIds } = await seedRemindableDocument({ owner: user, team, recipientCount: 1 });
await setClaimLimits(team, { emailQuota: 0 });
await resetUsage(organisation);
const res = await resendDocument(request, token, documentId, recipientIds);
// v1 masks the org 429 as a generic HTTP 500.
await expectResendBlocked(res);
// quota === 0 throws before the increment, so the counter stays at zero.
await expectMonthlyCounter(organisation, 'emailCount', 0);
});
});
// =========================================================================
// Stage interaction — quota binds before a looser window
// =========================================================================
test.describe('stage interaction', () => {
test('the quota trips before a looser windowed limit', async ({ request }) => {
const WINDOW_MAX = 50; // generous window
const QUOTA = 2; // strict quota — should bind first
await setClaimLimits(team, {
apiRateLimits: [{ window: '1m', max: WINDOW_MAX }],
apiQuota: QUOTA,
});
await resetUsage(organisation);
for (let i = 0; i < QUOTA; i += 1) {
const res = await findDocuments(request, token);
await expectNotGlobalLimited(res);
expect(res.status()).toBe(200);
}
const limitedRes = await findDocuments(request, token);
const body = await expectOrgLimited(limitedRes);
// It must be the QUOTA that bound, not the window: the message is the quota
// one (not the windowed-limit message) and there are no rate-limit headers.
expect(String(body.message)).toMatch(NO_QUOTA_MESSAGE);
expect(String(body.message)).not.toMatch(WINDOWED_LIMIT_MESSAGE);
expectNoOrgRateLimitHeader(limitedRes);
// Quota bound at QUOTA + 1; the looser window (50) was never the limiter.
await expectMonthlyCounter(organisation, 'apiCount', QUOTA + 1);
});
});
});
@@ -0,0 +1,917 @@
import fs from 'node:fs';
import path from 'node:path';
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 { EnvelopeType, RecipientRole } from '@documenso/prisma/client';
import { seedUser } from '@documenso/prisma/seed/users';
import type {
TCreateEnvelopePayload,
TCreateEnvelopeResponse,
} from '@documenso/trpc/server/envelope-router/create-envelope.types';
import { type APIRequestContext, type APIResponse, expect, test } from '@playwright/test';
import type { Organisation, Team, User } from '@prisma/client';
/**
* Dynamic organisation rate-limit & quota tests.
*
* Covers the feature added in `feat: add dynamic rate limits`:
* - Three counters: `api`, `document`, `email`.
* - Two enforcement stages per counter:
* 1. Windowed rate limits (`*RateLimits`) — 429 WITH `X-RateLimit-*` headers.
* 2. Monthly quota (`*Quota`) — 429 WITHOUT rate-limit headers; a `null`
* quota means unlimited and a `0` quota is a hard block.
*
* Where each counter is consumed:
* api -> every authenticated v2 request (get-api-token-by-token).
* document -> envelope create where type === DOCUMENT (count 1).
* email -> redistribute/remind consumes `recipientsToRemind.length`
* SYNCHRONOUSLY (resend-document), so we can assert on the HTTP
* response rather than racing async signing-email jobs.
*
* --- WHY THIS TEST IS SKIPPED IN CI ---
* CI runs E2E with `DANGEROUS_BYPASS_RATE_LIMITS=true`, which short-circuits BOTH
* the per-org assertion and the global IP limiter, making every assertion here
* meaningless. The test therefore skips itself in that mode and is intended to be
* run deliberately and locally with the bypass OFF.
*
* --- GLOBAL LIMIT AWARENESS ---
* apps/remix/server/router.ts applies a GLOBAL per-IP limiter to /api/v2/*:
* apiV2RateLimit = 100 requests / 1 minute (see rate-limits.ts).
* Every per-org limit/quota configured here is kept FAR below that ceiling (single
* digits) and the suite runs serially so the shared-IP global bucket is never the
* thing that trips. A global-limit 429 is shaped `{ error }` whereas an org-limit
* 429 is shaped `{ message }` — `expectOrgLimited()` asserts the 429 status AND
* that we hit the org limiter rather than the global one.
*/
const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
const baseUrl = `${WEBAPP_BASE_URL}/api/v2-beta`;
// Run serially: all workers share one IP, and the global /api/v2 limiter is
// per-IP. Serial execution keeps the shared global bucket well under 100/min.
test.describe.configure({ mode: 'serial' });
// This suite is only meaningful with real rate limiting enabled. CI sets the
// bypass flag, so skip there; run it locally with the bypass turned off.
test.skip(process.env.DANGEROUS_BYPASS_RATE_LIMITS === 'true', 'Test skipped because bypass rate limits is enabled.');
const examplePdfBuffer = fs.readFileSync(path.join(__dirname, '../../../../../assets/example.pdf'));
const WINDOWED_LIMIT_MESSAGE = /contact support if you require higher limits/i;
const NO_QUOTA_MESSAGE = /request could not be completed at this time/i;
// ---------------------------------------------------------------------------
// Claim / usage control (direct Prisma) — mirrors recipient-count-limit.spec.ts
// ---------------------------------------------------------------------------
type RateLimitEntry = { window: `${number}${'s' | 'm' | 'h' | 'd'}`; max: number };
type ClaimLimits = {
apiRateLimits?: RateLimitEntry[];
apiQuota?: number | null;
documentRateLimits?: RateLimitEntry[];
documentQuota?: number | null;
emailRateLimits?: RateLimitEntry[];
emailQuota?: number | null;
};
const currentMonthlyPeriod = (): string => {
const now = new Date();
const month = String(now.getUTCMonth() + 1).padStart(2, '0');
return `${now.getUTCFullYear()}-${month}`;
};
const getOrganisationClaim = async (team: Team) =>
prisma.organisationClaim.findFirstOrThrow({
where: { organisation: { id: team.organisationId } },
});
/**
* Apply a clean set of limits to the org's claim. Any counter not provided is
* reset to "unlimited" (empty windows + null quota) so scenarios never leak into
* each other.
*/
const setClaimLimits = async (team: Team, limits: ClaimLimits) => {
const claim = await getOrganisationClaim(team);
await prisma.organisationClaim.update({
where: { id: claim.id },
data: {
apiRateLimits: limits.apiRateLimits ?? [],
apiQuota: limits.apiQuota === undefined ? null : limits.apiQuota,
documentRateLimits: limits.documentRateLimits ?? [],
documentQuota: limits.documentQuota === undefined ? null : limits.documentQuota,
emailRateLimits: limits.emailRateLimits ?? [],
emailQuota: limits.emailQuota === undefined ? null : limits.emailQuota,
},
});
};
/**
* Clear the monthly quota counters, the org windowed rate-limit buckets AND the
* GLOBAL /api/v2 IP bucket so a fresh scenario starts from zero.
*
* - The org windowed limiter keys its rows `ip:org:<id>`.
* - The GLOBAL limiter (apps/remix/server/router.ts -> apiV2RateLimit, 100/min
* per IP, action `api.v2`) is shared by EVERY v2 request from this test client.
* Across the suite (and especially across repeated local runs within the same
* minute) that shared bucket would otherwise fill up and trip BEFORE the org
* limit under test, producing a `{ error }` 429 instead of the org `{ message }`
* one. Since this suite runs deliberately in isolation (it skips in CI), we
* clear that bucket here so the global limiter never masks the org assertion.
*/
const resetUsage = async (organisation: Organisation) => {
const period = currentMonthlyPeriod();
await prisma.organisationMonthlyStat.updateMany({
where: { organisationId: organisation.id, period },
data: {
documentCount: 0,
emailCount: 0,
apiCount: 0,
},
});
await prisma.rateLimit.deleteMany({
where: {
OR: [{ key: `ip:org:${organisation.id}` }, { action: 'api.v2' }],
},
});
};
type MonthlyCounter = 'documentCount' | 'emailCount' | 'apiCount';
const getMonthlyStat = async (organisation: Organisation) =>
prisma.organisationMonthlyStat.findUnique({
where: {
organisationId_period: { organisationId: organisation.id, period: currentMonthlyPeriod() },
},
select: { documentCount: true, emailCount: true, apiCount: true },
});
/**
* Assert the live OrganisationMonthlyStat counter equals `expected`.
*
* The DB counter is the source of truth for quota enforcement, so checking its
* exact value (not just the HTTP response) proves the documented increment
* semantics in check-monthly-quota.ts:
* - quota === null -> unlimited: never blocks, but the request is STILL
* counted (the upsert now runs before the null return)
* - quota === 0 -> throws BEFORE increment (stays 0)
* - quota > 0 -> incremented by `count` BEFORE the over-quota check, so
* even the request that gets rejected still advances it
* - windowed limit -> trips BEFORE the quota stage, so the counter is untouched
*/
const expectMonthlyCounter = async (organisation: Organisation, counter: MonthlyCounter, expected: number) => {
const stat = await getMonthlyStat(organisation);
expect(stat?.[counter] ?? 0, `${counter} should be exactly ${expected}`).toBe(expected);
};
/**
* Wait until a monthly counter reaches `atLeast` and then stops changing.
*
* `distribute` fans out one async signing-request email job per recipient (the
* local job runner fires them via fire-and-forget HTTP, so they complete after
* the call returns). Each job increments emailCount. We poll until the counter
* has reached the expected floor AND is stable across consecutive reads, which
* guarantees no late job will increment the counter after the caller resets
* usage — making the subsequent (synchronous) redistribute assertions exact.
*/
const waitForCounterToSettle = async (
organisation: Organisation,
counter: MonthlyCounter,
atLeast: number,
timeoutMs = 20_000,
): Promise<number> => {
const start = Date.now();
let previous = -1;
while (Date.now() - start < timeoutMs) {
const stat = await getMonthlyStat(organisation);
const current = stat?.[counter] ?? 0;
if (current >= atLeast && current === previous) {
return current;
}
previous = current;
await new Promise((resolve) => setTimeout(resolve, 500));
}
throw new Error(`Timed out waiting for ${counter} to settle at >= ${atLeast}`);
};
/**
* Sleep until just after the next windowed-limit bucket boundary.
*
* The limiter (createRateLimit -> getBucket) buckets time as
* `now - (now % windowMs)` aligned to the epoch. A windowed exhaustion test must
* land all of its MAX+1 requests inside ONE bucket; if the requests straddle a
* boundary the counter resets mid-test and the expected 429 never happens. We
* share the server's clock (same host), so aligning to a fresh bucket here makes
* the exhaustion deterministic.
*/
const alignToFreshWindowBucket = async (windowSeconds: number) => {
const windowMs = windowSeconds * 1000;
const msUntilNextBucket = windowMs - (Date.now() % windowMs);
await new Promise((resolve) => setTimeout(resolve, msUntilNextBucket + 100));
};
/**
* Guarantee at least `requiredHeadroomMs` remain in the current bucket so a burst
* of MAX+1 requests completes inside ONE window. Without this, a burst that
* happens to cross a bucket boundary would have its count reset mid-test and the
* expected 429 would never fire. Unlike `alignToFreshWindowBucket`, this only
* sleeps when we are actually near a boundary, so for long (e.g. 1m) windows it
* is almost always a no-op.
*/
const ensureWindowHeadroom = async (windowSeconds: number, requiredHeadroomMs: number) => {
const windowMs = windowSeconds * 1000;
const msLeftInBucket = windowMs - (Date.now() % windowMs);
if (msLeftInBucket < requiredHeadroomMs) {
await new Promise((resolve) => setTimeout(resolve, msLeftInBucket + 100));
}
};
// ---------------------------------------------------------------------------
// HTTP helpers
// ---------------------------------------------------------------------------
type ApiErrorBody = { message?: string; error?: string };
/**
* Non-throwing predicate: true when the response is an ORG-level 429
* (`{ message }`), not the global IP 429 (`{ error }`). Used by the preflight,
* which needs a boolean to decide whether to skip rather than fail.
*/
const isOrgLimited = async (res: APIResponse): Promise<boolean> => {
if (res.status() !== 429) {
return false;
}
const body = (await res.json().catch(() => ({}))) as ApiErrorBody;
// Global limiter returns `{ error }`; org limiter returns `{ message }`.
return body.message !== undefined && body.error === undefined;
};
/**
* Assert the response is an ORG-level 429 and return its parsed body.
*
* Checks the status code EXPLICITLY so a wrong 200/4xx fails with a clear
* "Expected 429, got <status>: <body>" message instead of an opaque
* `expected true, received false`. Also asserts the body is the org limiter's
* `{ message }` shape and not the global limiter's `{ error }` shape, so a
* global-IP 429 can never be mistaken for the org limit under test.
*/
const expectOrgLimited = async (res: APIResponse): Promise<ApiErrorBody> => {
const bodyText = await res.text();
expect(res.status(), `Expected an org 429 but got ${res.status()} with body: ${bodyText}`).toBe(429);
let body: ApiErrorBody = {};
try {
body = JSON.parse(bodyText) as ApiErrorBody;
} catch {
throw new Error(`Expected a JSON error body, got: ${bodyText}`);
}
expect(
body.message !== undefined && body.error === undefined,
`429 should be the ORG limiter ({ message }), not the global limiter ({ error }). Got: ${bodyText}`,
).toBeTruthy();
return body;
};
/**
* Assert the org windowed-limit value is present in `X-RateLimit-Limit`.
*
* Two limiters set this header: the GLOBAL /api/v2 middleware (max 100) sets it
* first, then the org limiter's AppError sets it to the org `max`. Playwright
* surfaces duplicate headers joined by ", " (e.g. "100, 4"), so we assert the
* org value is one of the comma-separated entries rather than an exact match.
*/
const expectRateLimitHeaderToInclude = (res: APIResponse, expectedMax: number) => {
const header = res.headers()['x-ratelimit-limit'] ?? '';
const values = header.split(',').map((v) => v.trim());
expect(values, `X-RateLimit-Limit "${header}" should include the org max ${expectedMax}`).toContain(
String(expectedMax),
);
};
/**
* Assert NO org rate-limit header was added — used for quota rejections, which
* intentionally omit rate-limit headers (a quota isn't a window). The GLOBAL
* middleware still stamps a single `X-RateLimit-Limit: 100`, so "no org header"
* means the value is either absent or exactly the lone global `100` (i.e. it does
* not contain a second, org-specific entry).
*/
const expectNoOrgRateLimitHeader = (res: APIResponse) => {
const header = res.headers()['x-ratelimit-limit'];
if (header === undefined) {
return;
}
const values = header.split(',').map((v) => v.trim());
expect(values, `Quota rejection should not add an org X-RateLimit-Limit, got "${header}"`).toEqual(['100']);
};
/** Guard against the global limiter silently masking an org assertion. */
const expectNotGlobalLimited = async (res: APIResponse) => {
if (res.status() === 429) {
const body = await res.json().catch(() => ({}));
expect(
'error' in body && !('message' in body),
'Hit the GLOBAL /api/v2 IP limiter, not the org limiter. Re-run this suite in isolation.',
).toBeFalsy();
}
};
/** Cheap read endpoint — consumes exactly one `api` counter, no document/email. */
const findEnvelopes = (request: APIRequestContext, token: string): Promise<APIResponse> =>
request.get(`${baseUrl}/envelope?perPage=1`, {
headers: { Authorization: `Bearer ${token}` },
});
/**
* Create a DOCUMENT envelope. Consumes one `api` counter and, when
* `type === DOCUMENT`, one `document` counter. Optionally seeds SIGNER recipients
* (each with a signature field) so the envelope can later be distributed.
*/
const createEnvelope = async (
request: APIRequestContext,
token: string,
options: { recipientCount?: number } = {},
): Promise<APIResponse> => {
const { recipientCount = 0 } = options;
const payload: TCreateEnvelopePayload = {
title: `Rate limit test ${Date.now()}-${Math.random().toString(36).slice(2)}`,
type: EnvelopeType.DOCUMENT,
recipients:
recipientCount > 0
? Array.from({ length: recipientCount }, (_, i) => ({
email: `rl-${Date.now()}-${i}-${Math.random().toString(36).slice(2)}@test.documenso.com`,
name: `Recipient ${i}`,
role: RecipientRole.SIGNER,
signingOrder: i + 1,
accessAuth: [],
actionAuth: [],
fields: [
{
type: 'SIGNATURE',
fieldMeta: { type: 'signature', overflow: 'crop' },
identifier: 0,
page: 1,
positionX: 10,
positionY: 80,
width: 20,
height: 5,
},
],
}))
: undefined,
meta: {
subject: 'Rate limit test',
message: 'Automated rate-limit test. Ignore.',
distributionMethod: 'EMAIL',
},
};
const formData = new FormData();
formData.append('payload', JSON.stringify(payload));
formData.append('files', new File([examplePdfBuffer], 'example.pdf', { type: 'application/pdf' }));
return request.post(`${baseUrl}/envelope/create`, {
headers: { Authorization: `Bearer ${token}` },
multipart: formData,
});
};
/** Distribute an envelope to all of its recipients via EMAIL. */
const distributeEnvelope = (request: APIRequestContext, token: string, envelopeId: string): Promise<APIResponse> =>
request.post(`${baseUrl}/envelope/distribute`, {
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
data: {
envelopeId,
meta: { distributionMethod: 'EMAIL', subject: 'Rate limit test', message: 'Rate limit test' },
},
});
/**
* Redistribute (remind) the given recipients. This runs the SYNCHRONOUS email
* assertion in resend-document with `count = recipients.length`, returning a 429
* directly when the email limit/quota is exceeded.
*/
const redistributeEnvelope = (
request: APIRequestContext,
token: string,
envelopeId: string,
recipientIds: number[],
): Promise<APIResponse> =>
request.post(`${baseUrl}/envelope/redistribute`, {
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
data: { envelopeId, recipients: recipientIds },
});
/**
* Build a fully-distributed envelope and return its NOT_SIGNED recipient IDs so a
* subsequent redistribute can exercise the synchronous email assertion.
*
* Setup uses a GENEROUS email quota so the async signing-request emails fanned out
* by `distribute` are counted, then waits for that counter to settle. This drains
* the background jobs BEFORE the caller resets usage, so they can't pollute
* emailCount mid-test. The caller then configures the email limit/quota under test
* and resets usage, so only the (synchronous, deterministic) redistribute counts.
*/
const seedDistributedEnvelope = async ({
request,
token,
team,
organisation,
recipientCount,
}: {
request: APIRequestContext;
token: string;
team: Team;
organisation: Organisation;
recipientCount: number;
}): Promise<{ envelopeId: string; recipientIds: number[] }> => {
await setClaimLimits(team, { emailQuota: 1000 });
await resetUsage(organisation);
const createRes = await createEnvelope(request, token, { recipientCount });
expect(createRes.ok(), `create failed: ${await createRes.text()}`).toBeTruthy();
const { id: envelopeId } = (await createRes.json()) as TCreateEnvelopeResponse;
const distributeRes = await distributeEnvelope(request, token, envelopeId);
expect(distributeRes.ok(), `distribute failed: ${await distributeRes.text()}`).toBeTruthy();
const recipients = await prisma.recipient.findMany({
where: { envelopeId },
select: { id: true },
});
// Drain the async signing-request email jobs (one per recipient) so a late job
// cannot increment emailCount after the caller's resetUsage.
await waitForCounterToSettle(organisation, 'emailCount', recipientCount);
return { envelopeId, recipientIds: recipients.map((r) => r.id) };
};
// ===========================================================================
// Tests
// ===========================================================================
test.describe('Organisation dynamic rate limits & quotas', () => {
let user: User;
let team: Team;
let organisation: Organisation;
let token: string;
test.beforeEach(async ({ request }) => {
const seeded = await seedUser();
user = seeded.user;
team = seeded.team;
organisation = seeded.organisation;
({ token } = await createApiToken({
userId: user.id,
teamId: team.id,
tokenName: 'test-org-rate-limits',
expiresIn: null,
}));
// Preflight: the `test.skip` above only sees the PLAYWRIGHT process env. The
// value that actually matters is the env the SERVER was started with — if the
// server has `DANGEROUS_BYPASS_RATE_LIMITS=true`, every assertion here would
// fail confusingly instead of skipping. Prove enforcement is live by setting a
// quota of 0 (instant hard block) and confirming the server rejects. If it
// doesn't, the server is bypassing limits, so skip with a clear message.
await setClaimLimits(team, { apiQuota: 0 });
await resetUsage(organisation);
const preflight = await findEnvelopes(request, token);
const enforced = await isOrgLimited(preflight);
// Reset back to a clean slate before the real scenario runs.
await setClaimLimits(team, {});
await resetUsage(organisation);
test.skip(
!enforced,
'Server is not enforcing organisation rate limits (likely started with DANGEROUS_BYPASS_RATE_LIMITS=true). Restart the server with the flag unset/false to run this suite.',
);
});
// =========================================================================
// API counter — windowed rate limit
// =========================================================================
test.describe('api rate limit (windowed)', () => {
test('allows requests up to the limit then 429s with rate-limit headers', async ({ request }) => {
const MAX = 4;
await setClaimLimits(team, { apiRateLimits: [{ window: '1m', max: MAX }] });
await resetUsage(organisation);
// Make sure the MAX+1 request burst lands inside a single 1m bucket.
await ensureWindowHeadroom(60, 10_000);
// Each request (including these GETs) consumes one api counter.
for (let i = 0; i < MAX; i += 1) {
const res = await findEnvelopes(request, token);
await expectNotGlobalLimited(res);
expect(res.status(), `request #${i + 1} should be allowed`).toBe(200);
}
// The next request is over the windowed limit.
const limitedRes = await findEnvelopes(request, token);
const body = await expectOrgLimited(limitedRes);
// The windowed limit uses a message distinct from the global limiter.
expect(String(body.message)).toMatch(WINDOWED_LIMIT_MESSAGE);
expectRateLimitHeaderToInclude(limitedRes, MAX);
expect(limitedRes.headers()['x-ratelimit-remaining']).toContain('0');
expect(limitedRes.headers()['retry-after']).toBeTruthy();
// The windowed stage blocks the (MAX+1)th request before the quota upsert,
// but each of the MAX allowed requests still records usage (null quota now
// tracks instead of skipping), so the counter equals MAX.
await expectMonthlyCounter(organisation, 'apiCount', MAX);
});
test('a single allowed request succeeds when the limit is 1', async ({ request }) => {
await setClaimLimits(team, { apiRateLimits: [{ window: '1m', max: 1 }] });
await resetUsage(organisation);
// Make sure both requests land inside a single 1m bucket.
await ensureWindowHeadroom(60, 10_000);
const okRes = await findEnvelopes(request, token);
await expectNotGlobalLimited(okRes);
expect(okRes.status()).toBe(200);
const limitedRes = await findEnvelopes(request, token);
const body = await expectOrgLimited(limitedRes);
expect(String(body.message)).toMatch(WINDOWED_LIMIT_MESSAGE);
// The one allowed request is counted (null quota still tracks); the blocked
// request trips the window before the quota upsert, so the counter is 1.
await expectMonthlyCounter(organisation, 'apiCount', 1);
});
test('the windowed limit RESETS once the window elapses (429 -> wait -> 200)', async ({ request }) => {
const MAX = 2;
const WINDOW_SECONDS = 3;
await setClaimLimits(team, { apiRateLimits: [{ window: `${WINDOW_SECONDS}s`, max: MAX }] });
await resetUsage(organisation);
// Land at the start of a fresh bucket so all MAX+1 requests below fall in
// the SAME window (otherwise a mid-exhaustion boundary would reset the count).
await alignToFreshWindowBucket(WINDOW_SECONDS);
// Exhaust the window.
for (let i = 0; i < MAX; i += 1) {
const res = await findEnvelopes(request, token);
await expectNotGlobalLimited(res);
expect(res.status(), `request #${i + 1} should be allowed`).toBe(200);
}
// The next request is blocked by the window.
const limitedRes = await findEnvelopes(request, token);
await expectOrgLimited(limitedRes);
// Wait out the window using the server-provided Retry-After (plus a small
// buffer to be sure we've crossed into the next time bucket). Crucially we
// do NOT reset usage here — the limiter must recover on its own as the
// bucket rolls over.
const retryAfterHeader = limitedRes.headers()['retry-after'] ?? String(WINDOW_SECONDS);
const retryAfterSeconds = Number.parseInt(retryAfterHeader.split(',')[0]?.trim() ?? '', 10) || WINDOW_SECONDS;
await new Promise((resolve) => setTimeout(resolve, (retryAfterSeconds + 1) * 1000));
// Window has elapsed: the same org can make requests again without any
// manual intervention — the bucket rolled over on its own.
const afterReset = await findEnvelopes(request, token);
await expectNotGlobalLimited(afterReset);
expect(afterReset.status(), 'request after the window elapsed should be allowed').toBe(200);
});
});
// =========================================================================
// API counter — monthly quota
// =========================================================================
test.describe('api quota (monthly)', () => {
test('null quota allows unlimited requests', async ({ request }) => {
await setClaimLimits(team, { apiQuota: null });
await resetUsage(organisation);
for (let i = 0; i < 6; i += 1) {
const res = await findEnvelopes(request, token);
await expectNotGlobalLimited(res);
expect(res.status()).toBe(200);
}
// A null quota means "unlimited" (never blocks), but every request is now
// recorded so usage is visible on unlimited plans — so the counter is 6.
await expectMonthlyCounter(organisation, 'apiCount', 6);
});
test('exhausting the quota 429s without rate-limit headers and keeps counting', async ({ request }) => {
const QUOTA = 3;
await setClaimLimits(team, { apiQuota: QUOTA });
await resetUsage(organisation);
for (let i = 0; i < QUOTA; i += 1) {
const res = await findEnvelopes(request, token);
await expectNotGlobalLimited(res);
expect(res.status(), `request #${i + 1} should be within quota`).toBe(200);
}
const limitedRes = await findEnvelopes(request, token);
const body = await expectOrgLimited(limitedRes);
expect(String(body.message)).toMatch(NO_QUOTA_MESSAGE);
// Quota rejections deliberately omit rate-limit headers (it isn't a window).
expectNoOrgRateLimitHeader(limitedRes);
// The atomic increment runs even on the rejected request: QUOTA allowed
// requests + the one rejected request = exactly QUOTA + 1.
await expectMonthlyCounter(organisation, 'apiCount', QUOTA + 1);
});
test('quota of exactly 1 allows one request then blocks', async ({ request }) => {
await setClaimLimits(team, { apiQuota: 1 });
await resetUsage(organisation);
const okRes = await findEnvelopes(request, token);
await expectNotGlobalLimited(okRes);
expect(okRes.status()).toBe(200);
const limitedRes = await findEnvelopes(request, token);
await expectOrgLimited(limitedRes);
// One allowed + one rejected, both incremented => exactly 2.
await expectMonthlyCounter(organisation, 'apiCount', 2);
});
test('quota of 0 is a hard block with a "no quota available" message', async ({ request }) => {
await setClaimLimits(team, { apiQuota: 0 });
await resetUsage(organisation);
const limitedRes = await findEnvelopes(request, token);
const body = await expectOrgLimited(limitedRes);
expect(String(body.message)).toMatch(NO_QUOTA_MESSAGE);
// quota === 0 throws before the increment, so the counter stays at zero.
await expectMonthlyCounter(organisation, 'apiCount', 0);
});
});
// =========================================================================
// Document counter — windowed rate limit
// =========================================================================
test.describe('document rate limit (windowed)', () => {
test('allows creates up to the limit then 429s with headers', async ({ request }) => {
const MAX = 3;
// Keep api unlimited so only the document stage can trip.
await setClaimLimits(team, { documentRateLimits: [{ window: '1m', max: MAX }] });
await resetUsage(organisation);
// Make sure the MAX+1 create burst lands inside a single 1m bucket.
await ensureWindowHeadroom(60, 10_000);
for (let i = 0; i < MAX; i += 1) {
const res = await createEnvelope(request, token);
await expectNotGlobalLimited(res);
expect(res.ok(), `create #${i + 1} should succeed`).toBeTruthy();
}
const limitedRes = await createEnvelope(request, token);
const body = await expectOrgLimited(limitedRes);
expect(String(body.message)).toMatch(WINDOWED_LIMIT_MESSAGE);
expectRateLimitHeaderToInclude(limitedRes, MAX);
expect(limitedRes.headers()['retry-after']).toBeTruthy();
// The (MAX+1)th create trips the window before the quota upsert, but each of
// the MAX allowed creates still records usage (null quota now tracks).
await expectMonthlyCounter(organisation, 'documentCount', MAX);
});
});
// =========================================================================
// Document counter — monthly quota
// =========================================================================
test.describe('document quota (monthly)', () => {
test('exhausting the document quota blocks further creates', async ({ request }) => {
const QUOTA = 2;
await setClaimLimits(team, { documentQuota: QUOTA });
await resetUsage(organisation);
for (let i = 0; i < QUOTA; i += 1) {
const res = await createEnvelope(request, token);
await expectNotGlobalLimited(res);
expect(res.ok(), `create #${i + 1} should be within quota`).toBeTruthy();
}
const limitedRes = await createEnvelope(request, token);
await expectOrgLimited(limitedRes);
// QUOTA successful creates + the rejected one (incremented before throwing).
await expectMonthlyCounter(organisation, 'documentCount', QUOTA + 1);
});
test('document quota of 0 hard-blocks creation', async ({ request }) => {
await setClaimLimits(team, { documentQuota: 0 });
await resetUsage(organisation);
const limitedRes = await createEnvelope(request, token);
const body = await expectOrgLimited(limitedRes);
expect(String(body.message)).toMatch(NO_QUOTA_MESSAGE);
// quota === 0 throws before the increment, so the counter stays at zero.
await expectMonthlyCounter(organisation, 'documentCount', 0);
});
test('null document quota allows creation', async ({ request }) => {
await setClaimLimits(team, { documentQuota: null });
await resetUsage(organisation);
const res = await createEnvelope(request, token);
await expectNotGlobalLimited(res);
expect(res.ok()).toBeTruthy();
// A null quota is unlimited (never blocks) but is now still recorded, so the
// single create advances the counter to 1.
await expectMonthlyCounter(organisation, 'documentCount', 1);
});
});
// =========================================================================
// Email counter — windowed rate limit (via synchronous redistribute)
// =========================================================================
test.describe('email rate limit (windowed)', () => {
test('redistribute is allowed when recipient count is within the email window', async ({ request }) => {
const { envelopeId, recipientIds } = await seedDistributedEnvelope({
request,
token,
team,
organisation,
recipientCount: 2,
});
// Window allows 5/min; reminding 2 recipients is fine. Reset usage so the
// create/distribute consumption above doesn't count against this window.
await setClaimLimits(team, { emailRateLimits: [{ window: '1m', max: 5 }] });
await resetUsage(organisation);
const res = await redistributeEnvelope(request, token, envelopeId, recipientIds);
await expectNotGlobalLimited(res);
expect(res.ok(), `redistribute should succeed: ${await res.text()}`).toBeTruthy();
// The windowed pass is now recorded even though the quota is null, so the
// counter advances by the batch size (recipientIds.length).
await expectMonthlyCounter(organisation, 'emailCount', recipientIds.length);
});
test('redistribute is blocked when recipient count exceeds the email window', async ({ request }) => {
const { envelopeId, recipientIds } = await seedDistributedEnvelope({
request,
token,
team,
organisation,
recipientCount: 3,
});
// Window only allows 2 emails per minute; reminding 3 at once exceeds it.
await setClaimLimits(team, { emailRateLimits: [{ window: '1m', max: 2 }] });
await resetUsage(organisation);
const res = await redistributeEnvelope(request, token, envelopeId, recipientIds);
const body = await expectOrgLimited(res);
expect(String(body.message)).toMatch(WINDOWED_LIMIT_MESSAGE);
expectRateLimitHeaderToInclude(res, 2);
// Windowed limit trips BEFORE the quota stage, so the counter is untouched.
await expectMonthlyCounter(organisation, 'emailCount', 0);
});
});
// =========================================================================
// Email counter — monthly quota (via synchronous redistribute)
// =========================================================================
test.describe('email quota (monthly)', () => {
test('redistribute within the remaining email quota succeeds', async ({ request }) => {
const { envelopeId, recipientIds } = await seedDistributedEnvelope({
request,
token,
team,
organisation,
recipientCount: 2,
});
await setClaimLimits(team, { emailQuota: 10 });
await resetUsage(organisation);
const res = await redistributeEnvelope(request, token, envelopeId, recipientIds);
await expectNotGlobalLimited(res);
expect(res.ok(), `redistribute should succeed: ${await res.text()}`).toBeTruthy();
// The synchronous assertion consumed exactly `recipientIds.length` of quota.
await expectMonthlyCounter(organisation, 'emailCount', recipientIds.length);
});
test('redistribute that would exceed the email quota is blocked', async ({ request }) => {
const { envelopeId, recipientIds } = await seedDistributedEnvelope({
request,
token,
team,
organisation,
recipientCount: 3,
});
// Quota of 2 but reminding 3 recipients in one synchronous call.
await setClaimLimits(team, { emailQuota: 2 });
await resetUsage(organisation);
const res = await redistributeEnvelope(request, token, envelopeId, recipientIds);
const body = await expectOrgLimited(res);
expect(String(body.message)).toMatch(NO_QUOTA_MESSAGE);
// Quota rejection carries no rate-limit headers.
expectNoOrgRateLimitHeader(res);
// The count (3) is added BEFORE the over-quota check throws, so the counter
// advances by the full batch even though the request was rejected.
await expectMonthlyCounter(organisation, 'emailCount', recipientIds.length);
});
test('email quota of 0 hard-blocks reminders', async ({ request }) => {
const { envelopeId, recipientIds } = await seedDistributedEnvelope({
request,
token,
team,
organisation,
recipientCount: 1,
});
await setClaimLimits(team, { emailQuota: 0 });
await resetUsage(organisation);
const res = await redistributeEnvelope(request, token, envelopeId, recipientIds);
const body = await expectOrgLimited(res);
expect(String(body.message)).toMatch(NO_QUOTA_MESSAGE);
// quota === 0 throws before the increment, so the counter stays at zero.
await expectMonthlyCounter(organisation, 'emailCount', 0);
});
});
// =========================================================================
// Stage interaction — quota binds before a looser window
// =========================================================================
test.describe('stage interaction', () => {
test('the quota trips before a looser windowed limit', async ({ request }) => {
const WINDOW_MAX = 50; // generous window
const QUOTA = 2; // strict quota — should bind first
await setClaimLimits(team, {
apiRateLimits: [{ window: '1m', max: WINDOW_MAX }],
apiQuota: QUOTA,
});
await resetUsage(organisation);
for (let i = 0; i < QUOTA; i += 1) {
const res = await findEnvelopes(request, token);
await expectNotGlobalLimited(res);
expect(res.status()).toBe(200);
}
const limitedRes = await findEnvelopes(request, token);
const body = await expectOrgLimited(limitedRes);
// It must be the QUOTA that bound, not the window: the message is the quota
// one (not the windowed-limit message) and there are no rate-limit headers.
expect(String(body.message)).toMatch(NO_QUOTA_MESSAGE);
expect(String(body.message)).not.toMatch(WINDOWED_LIMIT_MESSAGE);
expectNoOrgRateLimitHeader(limitedRes);
// Quota bound at QUOTA + 1; the looser window (50) was never the limiter.
await expectMonthlyCounter(organisation, 'apiCount', QUOTA + 1);
});
});
});
@@ -0,0 +1,290 @@
import fs from 'node:fs';
import path from 'node:path';
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 { DocumentStatus, EnvelopeType, FieldType, RecipientRole } from '@documenso/prisma/client';
import { seedUser } from '@documenso/prisma/seed/users';
import type {
TCreateEnvelopePayload,
TCreateEnvelopeResponse,
} from '@documenso/trpc/server/envelope-router/create-envelope.types';
import type { TDistributeEnvelopeRequest } from '@documenso/trpc/server/envelope-router/distribute-envelope.types';
import type { TCreateEnvelopeRecipientsRequest } from '@documenso/trpc/server/envelope-router/envelope-recipients/create-envelope-recipients.types';
import type { TGetEnvelopeResponse } from '@documenso/trpc/server/envelope-router/get-envelope.types';
import { type APIRequestContext, type APIResponse, 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',
});
// ---------------------------------------------------------------------------
// Shared helpers
// ---------------------------------------------------------------------------
const examplePdfBuffer = fs.readFileSync(path.join(__dirname, '../../../../../assets/example.pdf'));
/**
* Set the `recipientCount` limit on the organisation that owns the seeded team.
*
* A value of `0` means unlimited recipients are allowed.
*/
const setOrganisationRecipientCount = async (team: Team, recipientCount: number) => {
const organisationClaim = await prisma.organisationClaim.findFirstOrThrow({
where: {
organisation: {
id: team.organisationId,
},
},
});
await prisma.organisationClaim.update({
where: {
id: organisationClaim.id,
},
data: {
recipientCount,
},
});
};
const createEnvelope = async (request: APIRequestContext, authToken: string) => {
const payload: TCreateEnvelopePayload = {
type: EnvelopeType.DOCUMENT,
title: 'Recipient Count Limit Test',
};
const formData = new FormData();
formData.append('payload', JSON.stringify(payload));
formData.append('files', new File([examplePdfBuffer], 'example.pdf', { type: 'application/pdf' }));
const res = await request.post(`${baseUrl}/envelope/create`, {
headers: { Authorization: `Bearer ${authToken}` },
multipart: formData,
});
expect(res.ok()).toBeTruthy();
return (await res.json()) as TCreateEnvelopeResponse;
};
const getEnvelope = async (request: APIRequestContext, authToken: string, envelopeId: string) => {
const res = await request.get(`${baseUrl}/envelope/${envelopeId}`, {
headers: { Authorization: `Bearer ${authToken}` },
});
expect(res.ok()).toBeTruthy();
return (await res.json()) as TGetEnvelopeResponse;
};
/**
* Build an envelope with exactly `recipientCount` SIGNER recipients, each with
* their own signature field, then attempt to distribute it.
*
* Returns the raw distribute response so the caller can assert on the status.
*/
const buildAndDistributeEnvelopeWithRecipients = async ({
request,
authToken,
recipientCount,
}: {
request: APIRequestContext;
authToken: string;
recipientCount: number;
}): Promise<{ envelopeId: string; distributeRes: APIResponse }> => {
const envelope = await createEnvelope(request, authToken);
// Create N SIGNER recipients in a single request.
const recipientData = Array.from({ length: recipientCount }).map((_, index) => ({
email: `recipient-${index}-${Date.now()}-${Math.random().toString(36).slice(2)}@test.documenso.com`,
name: `Recipient ${index}`,
role: RecipientRole.SIGNER,
accessAuth: [],
actionAuth: [],
}));
const recipientsRes = await request.post(`${baseUrl}/envelope/recipient/create-many`, {
headers: { Authorization: `Bearer ${authToken}`, 'Content-Type': 'application/json' },
data: {
envelopeId: envelope.id,
data: recipientData,
} satisfies TCreateEnvelopeRecipientsRequest,
});
expect(recipientsRes.ok()).toBeTruthy();
const recipients = (await recipientsRes.json()).data;
// Resolve the envelope item ID to place fields on.
const envelopeData = await getEnvelope(request, authToken, envelope.id);
const envelopeItemId = envelopeData.envelopeItems[0].id;
// Each SIGNER must have a signature field, otherwise distribution fails for
// a reason unrelated to the recipient count.
const fieldData = recipients.map((recipient: { id: number }) => ({
recipientId: recipient.id,
envelopeItemId,
type: FieldType.SIGNATURE,
page: 1,
positionX: 100,
positionY: 100,
width: 50,
height: 50,
}));
const fieldsRes = await request.post(`${baseUrl}/envelope/field/create-many`, {
headers: { Authorization: `Bearer ${authToken}`, 'Content-Type': 'application/json' },
data: {
envelopeId: envelope.id,
data: fieldData,
},
});
expect(fieldsRes.ok()).toBeTruthy();
// Attempt to distribute the envelope.
const distributeRes = await request.post(`${baseUrl}/envelope/distribute`, {
headers: { Authorization: `Bearer ${authToken}`, 'Content-Type': 'application/json' },
data: {
envelopeId: envelope.id,
} satisfies TDistributeEnvelopeRequest,
});
return { envelopeId: envelope.id, distributeRes };
};
const expectEnvelopeStatus = async (envelopeId: string, status: DocumentStatus) => {
const envelope = await prisma.envelope.findUniqueOrThrow({
where: { id: envelopeId },
});
expect(envelope.status).toBe(status);
};
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
test.describe('Recipient count limit on distribute', () => {
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-recipient-count-limit',
expiresIn: null,
}));
});
// -----------------------------------------------------------------------
// Limit = 3. Edge cases around the boundary: 2 (under), 3 (at), 4 (over).
// -----------------------------------------------------------------------
test('should allow distribution when recipient count is below the limit', async ({ request }) => {
await setOrganisationRecipientCount(team, 3);
const { envelopeId, distributeRes } = await buildAndDistributeEnvelopeWithRecipients({
request,
authToken: token,
recipientCount: 2,
});
expect(distributeRes.ok()).toBeTruthy();
expect(distributeRes.status()).toBe(200);
await expectEnvelopeStatus(envelopeId, DocumentStatus.PENDING);
});
test('should allow distribution when recipient count is exactly at the limit', async ({ request }) => {
await setOrganisationRecipientCount(team, 3);
const { envelopeId, distributeRes } = await buildAndDistributeEnvelopeWithRecipients({
request,
authToken: token,
recipientCount: 3,
});
expect(distributeRes.ok()).toBeTruthy();
expect(distributeRes.status()).toBe(200);
await expectEnvelopeStatus(envelopeId, DocumentStatus.PENDING);
});
test('should deny distribution when recipient count is one over the limit', async ({ request }) => {
await setOrganisationRecipientCount(team, 3);
const { envelopeId, distributeRes } = await buildAndDistributeEnvelopeWithRecipients({
request,
authToken: token,
recipientCount: 4,
});
expect(distributeRes.ok()).toBeFalsy();
expect(distributeRes.status()).toBe(400);
// The envelope must remain a DRAFT — distribution was rejected.
await expectEnvelopeStatus(envelopeId, DocumentStatus.DRAFT);
});
// -----------------------------------------------------------------------
// Limit = 1. The smallest non-unlimited boundary.
// -----------------------------------------------------------------------
test('should allow distribution with a single recipient when the limit is 1', async ({ request }) => {
await setOrganisationRecipientCount(team, 1);
const { envelopeId, distributeRes } = await buildAndDistributeEnvelopeWithRecipients({
request,
authToken: token,
recipientCount: 1,
});
expect(distributeRes.ok()).toBeTruthy();
expect(distributeRes.status()).toBe(200);
await expectEnvelopeStatus(envelopeId, DocumentStatus.PENDING);
});
test('should deny distribution with two recipients when the limit is 1', async ({ request }) => {
await setOrganisationRecipientCount(team, 1);
const { envelopeId, distributeRes } = await buildAndDistributeEnvelopeWithRecipients({
request,
authToken: token,
recipientCount: 2,
});
expect(distributeRes.ok()).toBeFalsy();
expect(distributeRes.status()).toBe(400);
await expectEnvelopeStatus(envelopeId, DocumentStatus.DRAFT);
});
// -----------------------------------------------------------------------
// Limit = 0 means unlimited recipients are allowed.
// -----------------------------------------------------------------------
test('should allow distribution with many recipients when the limit is 0 (unlimited)', async ({ request }) => {
await setOrganisationRecipientCount(team, 0);
const { envelopeId, distributeRes } = await buildAndDistributeEnvelopeWithRecipients({
request,
authToken: token,
recipientCount: 10,
});
expect(distributeRes.ok()).toBeTruthy();
expect(distributeRes.status()).toBe(200);
await expectEnvelopeStatus(envelopeId, DocumentStatus.PENDING);
});
});
@@ -0,0 +1,64 @@
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { createApiToken } from '@documenso/lib/server-only/public-api/create-api-token';
import { mapSecondaryIdToDocumentId } from '@documenso/lib/utils/envelope';
import { prisma } from '@documenso/prisma';
import { SendStatus, SigningStatus } from '@documenso/prisma/client';
import { seedPendingDocument } from '@documenso/prisma/seed/documents';
import { seedUser } from '@documenso/prisma/seed/users';
import { 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',
});
test.describe('Redistribute updates recipient send status', () => {
let user: User, team: Team, token: string;
test.beforeEach(async () => {
({ user, team } = await seedUser());
({ token } = await createApiToken({
userId: user.id,
teamId: team.id,
tokenName: 'test',
expiresIn: null,
}));
});
test('marks a NOT_SENT signer as SENT after a successful resend', async ({ request }) => {
const document = await seedPendingDocument(user, team.id, ['recipient@test.documenso.com']);
const [recipient] = document.recipients;
// Simulate a recipient that is stuck at NOT_SENT on a pending document
// (e.g. the initial send did not dispatch an email for them).
await prisma.recipient.update({
where: { id: recipient.id },
data: {
sendStatus: SendStatus.NOT_SENT,
signingStatus: SigningStatus.NOT_SIGNED,
sentAt: null,
},
});
const res = await request.post(`${baseUrl}/document/redistribute`, {
headers: { Authorization: `Bearer ${token}` },
data: {
documentId: mapSecondaryIdToDocumentId(document.secondaryId),
recipients: [recipient.id],
},
});
expect(res.ok(), `redistribute should succeed: ${await res.text()}`).toBeTruthy();
const updatedRecipient = await prisma.recipient.findFirstOrThrow({
where: { id: recipient.id },
});
expect(updatedRecipient.sendStatus).toBe(SendStatus.SENT);
expect(updatedRecipient.sentAt).not.toBeNull();
});
});
@@ -0,0 +1,260 @@
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();
});
});
@@ -0,0 +1,242 @@
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 { seedCompletedDocument, seedDraftDocument, seedPendingDocument } from '@documenso/prisma/seed/documents';
import { seedTeam, seedTeamMember } from '@documenso/prisma/seed/teams';
import { seedUser } from '@documenso/prisma/seed/users';
import { expect, test } from '@playwright/test';
import { DocumentStatus, TeamMemberRole } 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 createTokenForUser = async (userId: number, teamId: number, tokenName: string) => {
const { token } = await createApiToken({
userId,
teamId,
tokenName,
expiresIn: null,
});
return token;
};
test.describe('Envelope cancel endpoint authorization', () => {
test('hides the document from an outsider attempting to cancel it', async ({ request }) => {
const { user: owner, team } = await seedUser();
const { user: recipient } = await seedUser();
const document = await seedPendingDocument(owner, team.id, [recipient]);
const { user: outsider, team: outsiderTeam } = await seedUser();
const outsiderToken = await createTokenForUser(outsider.id, outsiderTeam.id, 'outsider');
const res = await request.post(`${baseUrl}/envelope/cancel`, {
headers: { Authorization: `Bearer ${outsiderToken}` },
data: { envelopeId: document.id },
});
// Outsiders must not be able to determine whether the envelope exists.
expect(res.ok()).toBeFalsy();
expect(res.status()).toBe(404);
// The document must be untouched.
const documentInDb = await prisma.envelope.findFirstOrThrow({
where: { id: document.id },
select: { status: true },
});
expect(documentInDb.status).toBe(DocumentStatus.PENDING);
});
test('hides the document from a recipient attempting to cancel it', async ({ request }) => {
const { user: owner, team } = await seedUser();
const { user: recipient, team: recipientTeam } = await seedUser();
const document = await seedPendingDocument(owner, team.id, [recipient]);
const recipientToken = await createTokenForUser(recipient.id, recipientTeam.id, 'recipient');
const res = await request.post(`${baseUrl}/envelope/cancel`, {
headers: { Authorization: `Bearer ${recipientToken}` },
data: { envelopeId: document.id },
});
// A recipient is not a member of the document's team, so they must not be
// able to determine whether it exists via this endpoint.
expect(res.ok()).toBeFalsy();
expect(res.status()).toBe(404);
const documentInDb = await prisma.envelope.findFirstOrThrow({
where: { id: document.id },
select: { status: true },
});
expect(documentInDb.status).toBe(DocumentStatus.PENDING);
});
// Note: a non-privileged MEMBER cannot obtain an API token at all (token
// creation requires the MANAGE_TEAM permission), so the MEMBER cancellation
// restriction is covered through the UI tests in cancel-documents.spec.ts
// rather than at the API layer.
test('allows the document owner to cancel a pending document', async ({ request }) => {
const { user: owner, team } = await seedUser();
const { user: recipient } = await seedUser();
const document = await seedPendingDocument(owner, team.id, [recipient]);
const ownerToken = await createTokenForUser(owner.id, team.id, 'owner');
const res = await request.post(`${baseUrl}/envelope/cancel`, {
headers: { Authorization: `Bearer ${ownerToken}` },
data: { envelopeId: document.id },
});
expect(res.ok()).toBeTruthy();
expect(res.status()).toBe(200);
const documentInDb = await prisma.envelope.findFirstOrThrow({
where: { id: document.id },
select: { status: true, completedAt: true, deletedAt: true },
});
expect(documentInDb.status).toBe(DocumentStatus.CANCELLED);
expect(documentInDb.completedAt).not.toBeNull();
expect(documentInDb.deletedAt).toBeNull();
});
test('allows a team ADMIN to cancel a pending document they do not own', async ({ request }) => {
const { team, owner } = await seedTeam();
const adminUser = await seedTeamMember({
teamId: team.id,
role: TeamMemberRole.ADMIN,
});
const { user: recipient } = await seedUser();
const document = await seedPendingDocument(owner, team.id, [recipient]);
const adminToken = await createTokenForUser(adminUser.id, team.id, 'admin');
const res = await request.post(`${baseUrl}/envelope/cancel`, {
headers: { Authorization: `Bearer ${adminToken}` },
data: { envelopeId: document.id },
});
expect(res.ok()).toBeTruthy();
expect(res.status()).toBe(200);
const documentInDb = await prisma.envelope.findFirstOrThrow({
where: { id: document.id },
select: { status: true },
});
expect(documentInDb.status).toBe(DocumentStatus.CANCELLED);
});
test('allows a team MANAGER to cancel a pending document they do not own', async ({ request }) => {
const { team, owner } = await seedTeam();
const managerUser = await seedTeamMember({
teamId: team.id,
role: TeamMemberRole.MANAGER,
});
const { user: recipient } = await seedUser();
const document = await seedPendingDocument(owner, team.id, [recipient]);
const managerToken = await createTokenForUser(managerUser.id, team.id, 'manager');
const res = await request.post(`${baseUrl}/envelope/cancel`, {
headers: { Authorization: `Bearer ${managerToken}` },
data: { envelopeId: document.id },
});
expect(res.ok()).toBeTruthy();
expect(res.status()).toBe(200);
const documentInDb = await prisma.envelope.findFirstOrThrow({
where: { id: document.id },
select: { status: true },
});
expect(documentInDb.status).toBe(DocumentStatus.CANCELLED);
});
test('rejects cancelling a draft document', async ({ request }) => {
const { user: owner, team } = await seedUser();
const document = await seedDraftDocument(owner, team.id, []);
const ownerToken = await createTokenForUser(owner.id, team.id, 'owner-draft');
const res = await request.post(`${baseUrl}/envelope/cancel`, {
headers: { Authorization: `Bearer ${ownerToken}` },
data: { envelopeId: document.id },
});
expect(res.ok()).toBeFalsy();
expect(res.status()).toBe(400);
const documentInDb = await prisma.envelope.findFirstOrThrow({
where: { id: document.id },
select: { status: true },
});
expect(documentInDb.status).toBe(DocumentStatus.DRAFT);
});
test('rejects cancelling a completed document', async ({ request }) => {
const { user: owner, team } = await seedUser();
const { user: recipient } = await seedUser();
const document = await seedCompletedDocument(owner, team.id, [recipient]);
const ownerToken = await createTokenForUser(owner.id, team.id, 'owner-completed');
const res = await request.post(`${baseUrl}/envelope/cancel`, {
headers: { Authorization: `Bearer ${ownerToken}` },
data: { envelopeId: document.id },
});
expect(res.ok()).toBeFalsy();
expect(res.status()).toBe(400);
const documentInDb = await prisma.envelope.findFirstOrThrow({
where: { id: document.id },
select: { status: true },
});
expect(documentInDb.status).toBe(DocumentStatus.COMPLETED);
});
test('rejects double cancellation of an already cancelled document', async ({ request }) => {
const { user: owner, team } = await seedUser();
const { user: recipient } = await seedUser();
const document = await seedPendingDocument(owner, team.id, [recipient]);
const ownerToken = await createTokenForUser(owner.id, team.id, 'owner-double');
const firstRes = await request.post(`${baseUrl}/envelope/cancel`, {
headers: { Authorization: `Bearer ${ownerToken}` },
data: { envelopeId: document.id },
});
expect(firstRes.status()).toBe(200);
const secondRes = await request.post(`${baseUrl}/envelope/cancel`, {
headers: { Authorization: `Bearer ${ownerToken}` },
data: { envelopeId: document.id },
});
expect(secondRes.ok()).toBeFalsy();
expect(secondRes.status()).toBe(400);
const documentInDb = await prisma.envelope.findFirstOrThrow({
where: { id: document.id },
select: { status: true },
});
expect(documentInDb.status).toBe(DocumentStatus.CANCELLED);
});
});
@@ -0,0 +1,102 @@
import fs from 'node:fs';
import path from 'node:path';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { createEmbeddingPresignToken } from '@documenso/lib/server-only/embedding-presign/create-embedding-presign-token';
import { createApiToken } from '@documenso/lib/server-only/public-api/create-api-token';
import { seedUser } from '@documenso/prisma/seed/users';
import { expect, test } from '@playwright/test';
const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
const examplePdf = fs.readFileSync(path.join(__dirname, '../../../../../../assets/example.pdf'));
test.describe.configure({
mode: 'parallel',
});
const createPresignTokenForUser = async (userId: number, teamId: number) => {
const { token: apiToken } = await createApiToken({
userId,
teamId,
tokenName: 'file-upload-test',
expiresIn: null,
});
const { token: presignToken } = await createEmbeddingPresignToken({ apiToken });
return presignToken;
};
const buildPdfFormData = () => {
const formData = new FormData();
formData.append('file', new File([examplePdf], 'test.pdf', { type: 'application/pdf' }));
return formData;
};
test.describe('File upload endpoint authorization', () => {
test('rejects an unauthenticated upload-pdf request', async ({ request }) => {
const res = await request.post(`${WEBAPP_BASE_URL}/api/files/upload-pdf`, {
multipart: buildPdfFormData(),
});
expect(res.ok()).toBeFalsy();
expect(res.status()).toBe(401);
});
test('rejects an unauthenticated presigned-post-url request', async ({ request }) => {
const res = await request.post(`${WEBAPP_BASE_URL}/api/files/presigned-post-url`, {
headers: { 'Content-Type': 'application/json' },
data: { fileName: 'test.pdf', contentType: 'application/pdf' },
});
expect(res.ok()).toBeFalsy();
expect(res.status()).toBe(401);
});
test('rejects a presigned-post-url request with an invalid presign token', async ({ request }) => {
const res = await request.post(`${WEBAPP_BASE_URL}/api/files/presigned-post-url`, {
headers: {
'Content-Type': 'application/json',
Authorization: 'Bearer not-a-real-token',
},
data: { fileName: 'test.pdf', contentType: 'application/pdf' },
});
expect(res.ok()).toBeFalsy();
expect(res.status()).toBe(401);
});
test('rejects a presigned-post-url request with a disallowed content type', async ({ request }) => {
const { user, team } = await seedUser();
const presignToken = await createPresignTokenForUser(user.id, team.id);
const res = await request.post(`${WEBAPP_BASE_URL}/api/files/presigned-post-url`, {
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${presignToken}`,
},
data: { fileName: 'malware.exe', contentType: 'application/x-msdownload' },
});
// Authenticated, but the content type is not on the allow-list.
expect(res.ok()).toBeFalsy();
expect(res.status()).toBe(400);
});
test('allows an upload-pdf request authorized by a valid presign token', async ({ request }) => {
const { user, team } = await seedUser();
const presignToken = await createPresignTokenForUser(user.id, team.id);
const res = await request.post(`${WEBAPP_BASE_URL}/api/files/upload-pdf`, {
headers: { Authorization: `Bearer ${presignToken}` },
multipart: buildPdfFormData(),
});
expect(res.ok()).toBeTruthy();
expect(res.status()).toBe(200);
const body = await res.json();
expect(body.id).toBeDefined();
});
});