1
36import { describe, expect, it } from "vitest";
37import { readFileSync } from "node:fs";
38import { fileURLToPath } from "node:url";
39import { WEBHOOK_RATE_PER_MINUTE, webhookRateKey } from "@factory/shared";
40import { checkLiveApp, apiFetch } from "./helpers/app.js";
41
42const REPO_ROOT = fileURLToPath(new URL("../..", import.meta.url));
43const live = await checkLiveApp();
44const itLive = it.skipIf(!live);
45const PASSWORD = "Str0ngPass!x9";
46
47async function j(res: Response): Promise<Record<string, unknown>> {
48 try {
49 return (await res.json()) as Record<string, unknown>;
50 } catch {
51 return {};
52 }
53}
54function asErr(body: Record<string, unknown>): { code?: string; details?: { retryAfterSeconds?: number; reason?: string }[] } {
55 return (body.error ?? {}) as { code?: string; details?: { retryAfterSeconds?: number; reason?: string }[] };
56}
57
58
59
60
61describe("rate-limits / the two new buckets (constants + key shapes)", () => {
62 it("incoming-webhook bucket: 60/min, keyed per webhookId", () => {
63
64
65
66 expect(WEBHOOK_RATE_PER_MINUTE).toBe(60);
67 const k1 = webhookRateKey("id-one");
68 const k2 = webhookRateKey("id-two");
69 expect(k1).not.toBe(k2);
70 expect(k1).toContain("id-one");
71 expect(k1).toMatch(/^auto:webhook-rate:/);
72 });
73
74 it("public-form bucket: 5/min per IP per form (stricter than the spec's 20 — kept)", () => {
75
76
77
78
79 const src = readFileSync(REPO_ROOT + "/apps/api/src/modules/forms/rate-limit.ts", "utf8");
80 expect(src).toMatch(/export const FORM_SUBMIT_LIMIT = 5/);
81 expect(src).toMatch(/export const FORM_RL_WINDOW_MS = 60_000/);
82
83
84 expect(src).toMatch(/`form:rl:\$\{slug\}:\$\{ip\}`/);
85 });
86});
87
88
89
90
91
92describe("rate-limits / global buckets (env defaults, documented delta vs spec)", () => {
93 it("the API env schema ships the canonical defaults (100/10/600)", () => {
94 const envSrc = readFileSync(REPO_ROOT + "/apps/api/src/config/env.ts", "utf8");
95 expect(envSrc).toMatch(/RATE_LIMIT_API:.*default\(100\)/);
96 expect(envSrc).toMatch(/RATE_LIMIT_AUTH:.*default\(10\)/);
97 expect(envSrc).toMatch(/RATE_LIMIT_API_KEY:.*default\(600\)/);
98 });
99});
100
101
102
103
104describe("rate-limits / live incoming-webhook 60/min", () => {
105 itLive(
106 "61st unsigned request to a fresh active webhook is 429 WEBHOOK_RATE_LIMITED (the first 60 are not 429)",
107 async () => {
108
109
110 const email = `secrlw_${Date.now()}_${Math.floor(Math.random() * 1e6)}@example.com`;
111 const reg = await apiFetch("/auth/register", {
112 method: "POST",
113 body: JSON.stringify({ name: "RL", email, password: PASSWORD })
114 });
115 if (reg.status !== 201) throw new Error(`register ${reg.status}: ${await reg.text()}`);
116 const login = await apiFetch("/auth/login", {
117 method: "POST",
118 body: JSON.stringify({ email, password: PASSWORD })
119 });
120 const lb = (await login.json()) as { accessToken: string };
121 const H = { authorization: `Bearer ${lb.accessToken}` };
122 const wl = await apiFetch("/workspaces", { headers: H });
123 const wlb = (await wl.json()) as { data: { id: string }[] };
124 const wid = wlb.data[0]!.id;
125
126 const au = await apiFetch(`/workspaces/${wid}/automations`, {
127 method: "POST",
128 headers: H,
129 body: JSON.stringify({ name: "RL Probe", triggers: [{ type: "WEBHOOK", config: {} }] })
130 });
131 const auText = await au.text();
132 expect(au.status, `automation create ${auText}`).toBeLessThan(300);
133 const aub = JSON.parse(auText) as { id: string; triggers: { type: string; config: { webhookId: string } }[] };
134 const webhookId = aub.triggers.find((t) => t.type === "WEBHOOK")!.config.webhookId;
135 expect(webhookId).toMatch(/^[a-f0-9]{24}$/);
136
137 const act = await apiFetch(`/automations/${aub.id}/status`, {
138 method: "POST",
139 headers: H,
140 body: JSON.stringify({ status: "ACTIVE" })
141 });
142 expect(act.status, "activate automation").toBe(200);
143
144
145
146
147
148 const base = `${process.env.SEC_API_URL ?? "http://localhost:4000"}/api/v1/webhooks/incoming/${webhookId}`;
149 let seen429 = 0;
150 let first429At = -1;
151 let code = "";
152 let lastBody: Record<string, unknown> = {};
153 for (let i = 1; i <= 61; i++) {
154 const res = await fetch(base, {
155 method: "POST",
156 headers: { "content-type": "application/json" },
157 body: JSON.stringify({ i, nonce: Math.random() }),
158 signal: AbortSignal.timeout(15_000)
159 });
160 code = String(res.status);
161 if (res.status === 429) {
162 seen429++;
163 if (first429At === -1) {
164 first429At = i;
165 lastBody = (await res.json()) as Record<string, unknown>;
166 } else {
167 await res.text();
168 }
169 } else {
170 await res.text();
171
172
173 expect([400, 401, 404, 413], `request ${i}: under-limit must be a signature/validation 4xx`).toContain(res.status);
174 }
175 }
176 expect(seen429, "the 61st request must be 429").toBeGreaterThanOrEqual(1);
177 expect(first429At, "the 429 starts at the 61st (60 allowed)").toBe(61);
178 const err = asErr(lastBody);
179 expect(err.code, "canonical 429 code").toBe("WEBHOOK_RATE_LIMITED");
180 expect(err.details?.[0]?.retryAfterSeconds).toBe(60);
181 void code;
182 },
183 { timeout: 300_000 }
184 );
185});
186
187
188
189
190describe("rate-limits / live public-form 5/min per IP per form", () => {
191 itLive(
192 "the 6th submission to a fresh published form is 429 RATE_LIMITED (the first 5 are 201) + Retry-After",
193 async () => {
194
195
196
197 const email = `secrlf_${Date.now()}_${Math.floor(Math.random() * 1e6)}@example.com`;
198 const reg = await apiFetch("/auth/register", {
199 method: "POST",
200 body: JSON.stringify({ name: "RL", email, password: PASSWORD })
201 });
202 if (reg.status !== 201) throw new Error(`register ${reg.status}: ${await reg.text()}`);
203 const login = await apiFetch("/auth/login", {
204 method: "POST",
205 body: JSON.stringify({ email, password: PASSWORD })
206 });
207 const lb = (await login.json()) as { accessToken: string };
208 const H = { authorization: `Bearer ${lb.accessToken}` };
209 const wl = await apiFetch("/workspaces", { headers: H });
210 const wid = ((await wl.json()) as { data: { id: string }[] }).data[0]!.id;
211
212 const page = await apiFetch(`/workspaces/${wid}/pages`, {
213 method: "POST",
214 headers: H,
215 body: JSON.stringify({ title: "RL DB", type: "database" })
216 });
217 const pageText = await page.text();
218 expect(page.status, `database page create ${pageText}`).toBeLessThan(300);
219 const pageB = JSON.parse(pageText) as { id: string; databaseId: string };
220
221 const form = await apiFetch(`/databases/${pageB.databaseId}/forms`, {
222 method: "POST",
223 headers: H,
224 body: JSON.stringify({
225 name: "RL Form",
226 questions: [{ type: "SHORT_TEXT", label: "Contact", propertyId: null }]
227 })
228 });
229 const formText = await form.text();
230 expect(form.status, `form create ${formText}`).toBeLessThan(300);
231 const formB = JSON.parse(formText) as { id: string };
232
233 const pub = await apiFetch(`/forms/${formB.id}`, {
234 method: "PATCH",
235 headers: H,
236 body: JSON.stringify({ status: "PUBLISHED" })
237 });
238 const pubText = await pub.text();
239 expect(pub.status, `publish ${pubText}`).toBeLessThan(300);
240 const slug = (JSON.parse(pubText) as { slug: string }).slug;
241 expect(slug).toMatch(/^[a-z0-9-]+$/);
242
243
244 let got429At = -1;
245 let retryAfterHeader: string | null = null;
246 let retryAfterSeconds: number | undefined;
247 for (let i = 1; i <= 6; i++) {
248 const res = await fetch(
249 `${process.env.SEC_API_URL ?? "http://localhost:4000"}/api/v1/public/forms/${slug}/submit`,
250 {
251 method: "POST",
252 headers: { "content-type": "application/json" },
253 body: JSON.stringify({ answers: {} }),
254 signal: AbortSignal.timeout(15_000)
255 }
256 );
257 if (res.status === 201) {
258 await res.text();
259 } else if (res.status === 429) {
260 got429At = i;
261 retryAfterHeader = res.headers.get("retry-after");
262 const body = (await res.json()) as Record<string, unknown>;
263 retryAfterSeconds = asErr(body).details?.[0]?.retryAfterSeconds;
264 expect(asErr(body).code).toBe("RATE_LIMITED");
265 } else {
266 const body = await res.text();
267 throw new Error(`submission ${i}: expected 201/429, got ${res.status}: ${body}`);
268 }
269 }
270 expect(got429At, "the 6th submission must be 429 (5/min)").toBe(6);
271 expect(retryAfterHeader, "the 429 carries Retry-After").toBe("60");
272 expect(retryAfterSeconds, "the 429 body carries retryAfterSeconds").toBe(60);
273 },
274 { timeout: 300_000 }
275 );
276});
277