@next-buildr/payload 2.0.0 → 3.0.0-rc.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/README.md +7 -0
  2. package/UPGRADING.md +322 -0
  3. package/dist/.tsbuildinfo +1 -1
  4. package/dist/plugin/access.d.ts +7 -0
  5. package/dist/plugin/access.d.ts.map +1 -1
  6. package/dist/plugin/access.js +19 -0
  7. package/dist/plugin/collections/templates.d.ts.map +1 -1
  8. package/dist/plugin/collections/templates.js +9 -3
  9. package/dist/plugin/endpoints/context.d.ts +8 -0
  10. package/dist/plugin/endpoints/context.d.ts.map +1 -1
  11. package/dist/plugin/endpoints/forms-captcha.test.d.ts +2 -0
  12. package/dist/plugin/endpoints/forms-captcha.test.d.ts.map +1 -0
  13. package/dist/plugin/endpoints/forms-captcha.test.js +676 -0
  14. package/dist/plugin/endpoints/forms.d.ts.map +1 -1
  15. package/dist/plugin/endpoints/forms.js +110 -12
  16. package/dist/plugin/endpoints/publish.d.ts.map +1 -1
  17. package/dist/plugin/endpoints/publish.js +4 -0
  18. package/dist/plugin/endpoints/save.d.ts.map +1 -1
  19. package/dist/plugin/endpoints/save.js +3 -0
  20. package/dist/plugin/forms/captcha.d.ts +45 -0
  21. package/dist/plugin/forms/captcha.d.ts.map +1 -0
  22. package/dist/plugin/forms/captcha.js +195 -0
  23. package/dist/plugin/forms/captcha.test.d.ts +2 -0
  24. package/dist/plugin/forms/captcha.test.d.ts.map +1 -0
  25. package/dist/plugin/forms/captcha.test.js +262 -0
  26. package/dist/plugin/forms/client-ip.d.ts +8 -0
  27. package/dist/plugin/forms/client-ip.d.ts.map +1 -0
  28. package/dist/plugin/forms/client-ip.js +41 -0
  29. package/dist/plugin/forms/client-ip.test.d.ts +2 -0
  30. package/dist/plugin/forms/client-ip.test.d.ts.map +1 -0
  31. package/dist/plugin/forms/client-ip.test.js +45 -0
  32. package/dist/plugin/forms/rate-limit.d.ts +2 -0
  33. package/dist/plugin/forms/rate-limit.d.ts.map +1 -1
  34. package/dist/plugin/forms/rate-limit.js +10 -1
  35. package/dist/plugin/hooks/author-code.test.d.ts +2 -0
  36. package/dist/plugin/hooks/author-code.test.d.ts.map +1 -0
  37. package/dist/plugin/hooks/author-code.test.js +285 -0
  38. package/dist/plugin/hooks/layout-hook.d.ts +1 -1
  39. package/dist/plugin/hooks/layout-hook.d.ts.map +1 -1
  40. package/dist/plugin/hooks/layout-hook.js +12 -2
  41. package/dist/plugin/hooks/process-layout.d.ts +10 -0
  42. package/dist/plugin/hooks/process-layout.d.ts.map +1 -1
  43. package/dist/plugin/hooks/process-layout.js +94 -1
  44. package/dist/plugin/hooks/read-layout.d.ts +1 -1
  45. package/dist/plugin/hooks/read-layout.d.ts.map +1 -1
  46. package/dist/plugin/index.d.ts.map +1 -1
  47. package/dist/plugin/index.js +20 -1
  48. package/dist/plugin/options.d.ts +11 -0
  49. package/dist/plugin/options.d.ts.map +1 -1
  50. package/dist/plugin/options.js +49 -1
  51. package/dist/plugin/plugin.test.js +81 -2
  52. package/package.json +13 -11
@@ -0,0 +1,676 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { createRegistryMeta, p, s, } from '@next-buildr/core';
3
+ import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
4
+ import { CAPTCHA_ACTION, CAPTCHA_FIELD, CAPTCHA_TYPE, captchaCData, createCaptchaGate, } from '../forms/captcha.js';
5
+ import { createMemoryRateLimiter } from '../forms/rate-limit.js';
6
+ import { buildrPlugin } from '../index.js';
7
+ import { resolveOptions } from '../options.js';
8
+ import { meta as baseMeta, boot } from './endpoints.test-kit.js';
9
+ import { formsEndpoint } from './forms.js';
10
+ const FORM = 'form000001';
11
+ const FIELD = 'field00001';
12
+ const CAP = 'captcha001';
13
+ const SECRET = 'private-captcha-secret';
14
+ const TOKEN = 'private-visitor-token';
15
+ const component = (type, props = {}) => ({
16
+ type,
17
+ version: 1,
18
+ label: type,
19
+ category: 'forms',
20
+ props,
21
+ contentCategories: ['flow'],
22
+ styles: { groups: [] },
23
+ runtime: 'shared',
24
+ });
25
+ const meta = createRegistryMeta({
26
+ components: [
27
+ ...baseMeta.list(),
28
+ { ...component('buildr/form'), slots: { default: {} } },
29
+ component(CAPTCHA_TYPE),
30
+ {
31
+ ...component('test/field', { name: p.text({ label: 'Name', default: '' }) }),
32
+ formField: { valueType: 'string', nameProp: 'name' },
33
+ },
34
+ ],
35
+ });
36
+ const layout = (captcha = true, name = 'message') => ({
37
+ schemaVersion: 1,
38
+ root: 'root',
39
+ components: {
40
+ 'buildr/page': 1,
41
+ 'buildr/form': 1,
42
+ 'test/field': 1,
43
+ ...(captcha ? { [CAPTCHA_TYPE]: 1 } : {}),
44
+ },
45
+ nodes: {
46
+ root: { id: 'root', type: 'buildr/page', slots: { default: [FORM] } },
47
+ [FORM]: {
48
+ id: FORM,
49
+ type: 'buildr/form',
50
+ slots: { default: [FIELD, ...(captcha ? [CAP] : [])] },
51
+ },
52
+ [FIELD]: { id: FIELD, type: 'test/field', props: { name: s(name) } },
53
+ ...(captcha ? { [CAP]: { id: CAP, type: CAPTCHA_TYPE } } : {}),
54
+ },
55
+ });
56
+ let h;
57
+ let env;
58
+ let counter = 0;
59
+ const siteverifyFetch = vi.fn();
60
+ const verify = vi.fn();
61
+ const allowed = { allowed: true, retryAfterSeconds: 0 };
62
+ const hit = vi.fn(async (_key) => allowed);
63
+ const publish = async (doc = layout(), extra = {}) => h.payload.create({
64
+ collection: 'pages',
65
+ data: {
66
+ title: 'Captcha',
67
+ slug: `captcha-${++counter}`,
68
+ layout: doc,
69
+ _status: 'published',
70
+ ...extra,
71
+ },
72
+ });
73
+ const bind = (ref, form = FORM, hostname = 'example.com') => verify.mockResolvedValue({
74
+ ok: true,
75
+ hostname,
76
+ action: CAPTCHA_ACTION,
77
+ cdata: captchaCData(ref, form),
78
+ });
79
+ const submit = async (id, values = { message: 'hello', [CAPTCHA_FIELD]: TOKEN }, options = {}) => {
80
+ const request = new Request(`http://localhost/api/buildr/forms/${options.collection ?? 'pages'}/${id}/${options.node ?? FORM}`, {
81
+ method: 'POST',
82
+ headers: {
83
+ 'content-type': options.html ? 'application/x-www-form-urlencoded' : 'application/json',
84
+ 'trusted-ip': '192.0.2.1',
85
+ referer: 'http://localhost/contact?x=1',
86
+ },
87
+ body: options.html
88
+ ? new URLSearchParams(Object.entries(values).map(([key, value]) => [key, String(value)]))
89
+ : JSON.stringify(values),
90
+ });
91
+ if (options.repeated) {
92
+ const form = new FormData();
93
+ form.append('message', 'hello');
94
+ form.append(CAPTCHA_FIELD, TOKEN);
95
+ form.append(CAPTCHA_FIELD, TOKEN);
96
+ Object.defineProperty(request, 'formData', { value: async () => form });
97
+ }
98
+ const req = Object.assign(request, {
99
+ payload: h.payload,
100
+ routeParams: {
101
+ collection: options.collection ?? 'pages',
102
+ id: String(id),
103
+ nodeId: options.node ?? FORM,
104
+ },
105
+ searchParams: new URLSearchParams(),
106
+ });
107
+ const fresh = options.plugin === 'fresh'
108
+ ? await buildrPlugin({
109
+ registry: { meta },
110
+ collections: { pages: { context: 'page' } },
111
+ forms: {
112
+ enabled: true,
113
+ clientIp: { header: 'trusted-ip' },
114
+ rateLimiter: options.hostLimiter ? { hit } : undefined,
115
+ rateLimit: { limit: 1, windowMs: 60000 },
116
+ captcha: { secretKey: SECRET, hostnames: ['example.com'] },
117
+ },
118
+ })({
119
+ collections: [{ slug: 'pages', fields: [], versions: { drafts: true } }],
120
+ })
121
+ : undefined;
122
+ const endpoint = fresh
123
+ ? fresh.endpoints?.find((entry) => entry.path === '/buildr/forms/:collection/:id/:nodeId')
124
+ : options.plugin
125
+ ? h.payload.config.endpoints.find((entry) => entry.path === '/buildr/forms/:collection/:id/:nodeId')
126
+ : formsEndpoint(env);
127
+ if (endpoint === undefined)
128
+ throw new Error('Missing forms endpoint');
129
+ const response = await endpoint.handler(req);
130
+ const text = await response.text();
131
+ expect(text).not.toContain(SECRET);
132
+ expect(JSON.stringify([...response.headers])).not.toContain(SECRET);
133
+ return { response, status: response.status, body: text ? JSON.parse(text) : undefined };
134
+ };
135
+ const count = async () => (await h.payload.find({ collection: 'buildr-form-submissions', pagination: false })).docs;
136
+ beforeAll(async () => {
137
+ vi.stubGlobal('fetch', siteverifyFetch);
138
+ h = await boot('forms-captcha', {
139
+ registry: { meta },
140
+ collections: { pages: { context: 'page', templates: true } },
141
+ forms: {
142
+ enabled: true,
143
+ clientIp: { header: 'trusted-ip' },
144
+ captcha: { secretKey: SECRET, hostnames: ['example.com'], maxInFlight: 1 },
145
+ rateLimit: { limit: 100, windowMs: 60_000 },
146
+ },
147
+ });
148
+ });
149
+ afterAll(() => {
150
+ h.close();
151
+ vi.unstubAllGlobals();
152
+ });
153
+ beforeEach(() => {
154
+ vi.restoreAllMocks();
155
+ verify.mockReset();
156
+ siteverifyFetch.mockReset();
157
+ hit.mockReset().mockResolvedValue(allowed);
158
+ env = {
159
+ options: resolveOptions({
160
+ registry: { meta },
161
+ collections: { pages: { context: 'page', templates: true } },
162
+ forms: {
163
+ enabled: true,
164
+ clientIp: { header: 'trusted-ip' },
165
+ captcha: { secretKey: SECRET, hostnames: ['example.com'], maxInFlight: 1 },
166
+ },
167
+ }),
168
+ captcha: {
169
+ verifier: { verify },
170
+ gate: createCaptchaGate(1),
171
+ rateLimiter: { hit },
172
+ hostnames: ['example.com'],
173
+ },
174
+ rateLimiter: { hit },
175
+ };
176
+ });
177
+ describe('published form captcha endpoint', () => {
178
+ it('uses the host limiter with a separate captcha namespace', async () => {
179
+ const page = await publish();
180
+ siteverifyFetch.mockResolvedValue(Response.json({ success: false }));
181
+ expect((await submit(page.id, undefined, { plugin: 'fresh', hostLimiter: true })).status).toBe(422);
182
+ expect(hit).toHaveBeenCalledTimes(2);
183
+ expect(hit.mock.calls[0]?.[0]).not.toMatch(/^captcha:/);
184
+ expect(hit.mock.calls[1]?.[0]).toMatch(/^captcha:/);
185
+ });
186
+ it('uses an independent memory limiter when no host limiter is supplied', async () => {
187
+ const page = await publish();
188
+ siteverifyFetch.mockResolvedValue(Response.json({ success: false }));
189
+ expect((await submit(page.id, undefined, { plugin: 'fresh' })).status).toBe(422);
190
+ expect(hit).not.toHaveBeenCalled();
191
+ expect(siteverifyFetch).toHaveBeenCalledOnce();
192
+ });
193
+ it('logs and returns 503 when the verifier returns undefined', async () => {
194
+ const page = await publish();
195
+ const log = vi.spyOn(h.payload.logger, 'error').mockImplementation(() => { });
196
+ verify.mockResolvedValueOnce(undefined);
197
+ expect((await submit(page.id)).status).toBe(503);
198
+ expect(log).toHaveBeenCalledExactlyOnceWith(expect.objectContaining({ reason: 'unavailable' }));
199
+ });
200
+ it('does not evict the captcha IP counter with invented form keys at a frozen clock', async () => {
201
+ const page = await publish();
202
+ const limiter = createMemoryRateLimiter({ limit: 1, windowMs: 60000, now: () => 123 });
203
+ const captcha = env.captcha;
204
+ if (!captcha)
205
+ throw new Error('Missing captcha');
206
+ env = {
207
+ ...env,
208
+ rateLimiter: limiter,
209
+ captcha: {
210
+ ...captcha,
211
+ rateLimiter: createMemoryRateLimiter({
212
+ limit: 1,
213
+ windowMs: 60000,
214
+ now: () => 123,
215
+ failClosed: true,
216
+ }),
217
+ },
218
+ };
219
+ verify.mockResolvedValue({ ok: false, reason: 'invalid' });
220
+ expect((await submit(page.id)).status).toBe(422);
221
+ for (let i = 0; i < 10001; i++) {
222
+ expect((await submit(page.id, { _hp: 'spam' }, { node: `invented${i}` })).status).toBe(200);
223
+ }
224
+ expect((await submit(page.id)).status).toBe(429);
225
+ expect(verify).toHaveBeenCalledOnce();
226
+ });
227
+ it.each(['invalid', 'expired-or-reused'])('logs built-in %s once at warn before onInit using the live request logger', async (reason) => {
228
+ const page = await publish();
229
+ const warn = vi.spyOn(h.payload.logger, 'warn').mockImplementation(() => { });
230
+ const error = vi.spyOn(h.payload.logger, 'error').mockImplementation(() => { });
231
+ siteverifyFetch.mockResolvedValueOnce(Response.json({
232
+ success: false,
233
+ 'error-codes': [reason === 'invalid' ? 'invalid-input-response' : 'timeout-or-duplicate'],
234
+ }));
235
+ expect((await submit(page.id, undefined, { plugin: 'fresh' })).status).toBe(422);
236
+ expect(warn).toHaveBeenCalledOnce();
237
+ expect(error).not.toHaveBeenCalled();
238
+ });
239
+ it('wires the built-in verifier and safe configuration logger through the plugin endpoint', async () => {
240
+ const page = await publish();
241
+ const log = vi.spyOn(h.payload.logger, 'error').mockImplementation(() => { });
242
+ siteverifyFetch.mockResolvedValueOnce(Response.json({ success: false, 'error-codes': ['invalid-input-secret', SECRET, TOKEN] }));
243
+ expect((await submit(page.id, undefined, { plugin: true })).status).toBe(503);
244
+ expect(log).toHaveBeenCalledWith(expect.objectContaining({ reason: 'configuration', codes: ['invalid-input-secret'] }));
245
+ siteverifyFetch.mockResolvedValueOnce(Response.json({
246
+ success: true,
247
+ hostname: 'EXAMPLE.COM',
248
+ action: CAPTCHA_ACTION,
249
+ cdata: captchaCData(`pages:${page.id}`, FORM),
250
+ }));
251
+ expect((await submit(page.id, undefined, { plugin: true })).status).toBe(200);
252
+ expect(siteverifyFetch).toHaveBeenCalledTimes(2);
253
+ expect(verify).not.toHaveBeenCalled();
254
+ expect(JSON.stringify(log.mock.calls)).not.toContain(SECRET);
255
+ expect(JSON.stringify(log.mock.calls)).not.toContain(TOKEN);
256
+ });
257
+ it('verifies one token with a 5s signal, stores and emails only author data, and logs no secrets or tokens', async () => {
258
+ const page = await publish();
259
+ bind(`pages:${page.id}`);
260
+ const controller = new AbortController();
261
+ const timeout = vi.spyOn(AbortSignal, 'timeout').mockReturnValue(controller.signal);
262
+ const logger = vi.spyOn(h.payload.logger, 'error').mockImplementation(() => { });
263
+ const mail = vi.spyOn(h.payload, 'sendEmail').mockResolvedValue(undefined);
264
+ env = {
265
+ ...env,
266
+ options: {
267
+ ...env.options,
268
+ forms: {
269
+ ...env.options.forms,
270
+ notifyTo: ['ops@example.com'],
271
+ notifyAllowlist: ['@example.com'],
272
+ },
273
+ },
274
+ };
275
+ expect((await submit(page.id)).status).toBe(200);
276
+ expect(timeout).toHaveBeenCalledWith(5000);
277
+ expect(verify).toHaveBeenCalledExactlyOnceWith({ token: TOKEN, signal: controller.signal });
278
+ const records = await count();
279
+ const stored = records.find((record) => record.form.documentId === String(page.id));
280
+ expect(stored).toMatchObject({ data: { message: 'hello' } });
281
+ expect(JSON.stringify(stored)).not.toContain(TOKEN);
282
+ expect(JSON.stringify(stored)).not.toContain(SECRET);
283
+ expect(mail).toHaveBeenCalledOnce();
284
+ expect(JSON.stringify(mail.mock.calls)).not.toContain(TOKEN);
285
+ expect(JSON.stringify(logger.mock.calls)).not.toContain(TOKEN);
286
+ expect(JSON.stringify(logger.mock.calls)).not.toContain(SECRET);
287
+ });
288
+ it.each(['invalid', 'expired-or-reused', 'unavailable'])('fails closed for verifier reason %s', async (reason) => {
289
+ const page = await publish();
290
+ const before = (await count()).length;
291
+ const logger = vi.spyOn(h.payload.logger, 'error').mockImplementation(() => { });
292
+ verify.mockResolvedValue({ ok: false, reason });
293
+ const sent = await submit(page.id);
294
+ expect(sent.status).toBe(reason === 'unavailable' ? 503 : 422);
295
+ expect(sent.body).toEqual(reason === 'unavailable'
296
+ ? { error: 'Verification is unavailable. Try again later.', code: 'captcha-unavailable' }
297
+ : {
298
+ errors: { [CAPTCHA_FIELD]: 'The verification failed. Try again.' },
299
+ codes: { [CAPTCHA_FIELD]: 'captcha' },
300
+ });
301
+ expect((await count()).length).toBe(before);
302
+ expect(JSON.stringify(logger.mock.calls)).not.toContain(TOKEN);
303
+ expect(JSON.stringify(logger.mock.calls)).not.toContain(SECRET);
304
+ });
305
+ it.each([undefined, null, 42, '', ['one', 'two'], 'a b', 'valid\n', '\u00e9', 'x'.repeat(2049)])('rejects a missing or malformed token without verification: %j', async (token) => {
306
+ const page = await publish();
307
+ expect((await submit(page.id, { message: 'hello', [CAPTCHA_FIELD]: token })).status).toBe(422);
308
+ expect(verify).not.toHaveBeenCalled();
309
+ expect(hit).toHaveBeenCalledOnce();
310
+ });
311
+ it('rejects repeated token fields on the HTML path', async () => {
312
+ const page = await publish();
313
+ const sent = await submit(page.id, {}, { html: true, repeated: true });
314
+ expect(sent.status).toBe(303);
315
+ expect(sent.response.headers.get('location')).toBe('/contact?x=1&buildr-form=invalid');
316
+ expect(verify).not.toHaveBeenCalled();
317
+ });
318
+ it('fails closed without server captcha configuration', async () => {
319
+ const page = await publish();
320
+ env = { ...env, captcha: undefined };
321
+ expect((await submit(page.id)).status).toBe(503);
322
+ expect(verify).not.toHaveBeenCalled();
323
+ });
324
+ it.each([
325
+ { hostname: 'evil.example' },
326
+ { action: 'other' },
327
+ { cdata: '' },
328
+ { hostname: undefined },
329
+ { action: 123 },
330
+ { cdata: {} },
331
+ ])('rejects mismatched or malformed ok results: %j', async (override) => {
332
+ const page = await publish();
333
+ bind(`pages:${page.id}`);
334
+ verify.mockResolvedValue({
335
+ ok: true,
336
+ hostname: 'example.com',
337
+ action: CAPTCHA_ACTION,
338
+ cdata: captchaCData(`pages:${page.id}`, FORM),
339
+ ...override,
340
+ });
341
+ expect((await submit(page.id)).status).toBe(422);
342
+ });
343
+ it('normalises verified hostname case', async () => {
344
+ const page = await publish();
345
+ bind(`pages:${page.id}`, FORM, 'EXAMPLE.COM');
346
+ expect((await submit(page.id)).status).toBe(200);
347
+ });
348
+ it('rejects form A binding on form B and another document or template binding', async () => {
349
+ const doc = layout();
350
+ const second = 'form000002';
351
+ const two = {
352
+ ...doc,
353
+ nodes: {
354
+ ...doc.nodes,
355
+ root: { id: 'root', type: 'buildr/page', slots: { default: [FORM, second] } },
356
+ [second]: { id: second, type: 'buildr/form', slots: { default: ['captcha002'] } },
357
+ captcha002: { id: 'captcha002', type: CAPTCHA_TYPE },
358
+ },
359
+ };
360
+ const page = await publish(two);
361
+ bind(`pages:${page.id}`);
362
+ expect((await submit(page.id, { [CAPTCHA_FIELD]: TOKEN }, { node: second })).status).toBe(422);
363
+ const other = await publish();
364
+ expect((await submit(other.id)).status).toBe(422);
365
+ bind('buildr-templates:123');
366
+ expect((await submit(page.id)).status).toBe(422);
367
+ });
368
+ it('pages sharing a template and the template endpoint share the canonical digest', async () => {
369
+ const template = await h.payload.create({
370
+ collection: 'buildr-templates',
371
+ data: {
372
+ title: 'Protected template',
373
+ targetCollection: 'pages',
374
+ layout: layout(),
375
+ _status: 'published',
376
+ },
377
+ });
378
+ const one = await publish(undefined, { template: template.id, layout: undefined });
379
+ const two = await publish(undefined, { template: template.id, layout: undefined });
380
+ const ref = `buildr-templates:${template.id}`;
381
+ bind(ref);
382
+ expect((await submit(one.id)).status).toBe(200);
383
+ expect((await submit(two.id)).status).toBe(200);
384
+ expect((await submit(template.id, undefined, { collection: 'buildr-templates' })).status).toBe(200);
385
+ expect(captchaCData(ref, FORM)).not.toBe(captchaCData(`pages:${one.id}`, FORM));
386
+ expect(verify).toHaveBeenCalledTimes(3);
387
+ });
388
+ it('keeps schema and captcha requirement from one snapshot across verification', async () => {
389
+ const page = await publish();
390
+ const lookup = vi.spyOn(h.payload, 'findByID');
391
+ verify.mockImplementation(async () => {
392
+ // A later read would now see another schema and no challenge.
393
+ lookup.mockResolvedValue({
394
+ id: page.id,
395
+ _status: 'published',
396
+ layout: layout(false, 'other'),
397
+ });
398
+ return {
399
+ ok: true,
400
+ hostname: 'example.com',
401
+ action: CAPTCHA_ACTION,
402
+ cdata: captchaCData(`pages:${page.id}`, FORM),
403
+ };
404
+ });
405
+ expect((await submit(page.id)).status).toBe(200);
406
+ expect(lookup).toHaveBeenCalledOnce();
407
+ });
408
+ it('leaves no-captcha forms unchanged, including unknown buildr-captcha submissions', async () => {
409
+ const page = await publish(layout(false));
410
+ expect((await submit(page.id, { message: 'hello' })).status).toBe(200);
411
+ const extra = await submit(page.id);
412
+ expect(extra.status).toBe(422);
413
+ expect(extra.body.codes).toEqual({ [CAPTCHA_FIELD]: 'unknown' });
414
+ expect(verify).not.toHaveBeenCalled();
415
+ expect(hit).toHaveBeenCalledTimes(2);
416
+ });
417
+ it.each([true, false])('rejects the reserved author field even with captcha=%s', async (captcha) => {
418
+ const page = await publish(layout(captcha, CAPTCHA_FIELD));
419
+ const logger = vi.spyOn(h.payload.logger, 'error').mockImplementation(() => { });
420
+ expect((await submit(page.id)).status).toBe(503);
421
+ expect(verify).not.toHaveBeenCalled();
422
+ expect(logger).toHaveBeenCalledWith(expect.objectContaining({ reason: 'configuration' }));
423
+ });
424
+ it.each([true, false])('redirects HTML verification errors (unavailable=%s)', async (unavailable) => {
425
+ const page = await publish();
426
+ verify.mockResolvedValue({ ok: false, reason: unavailable ? 'unavailable' : 'invalid' });
427
+ const sent = await submit(page.id, undefined, { html: true });
428
+ expect(sent.status).toBe(303);
429
+ expect(sent.response.headers.get('location')).toBe('/contact?x=1&buildr-form=invalid');
430
+ });
431
+ it('honours the honeypot and both rate limit checks before verification', async () => {
432
+ const page = await publish();
433
+ expect((await submit(page.id, { _hp: 'spam' })).status).toBe(200);
434
+ expect(verify).not.toHaveBeenCalled();
435
+ hit.mockResolvedValueOnce({ allowed: false, retryAfterSeconds: 10 });
436
+ const first = await submit(page.id);
437
+ expect(first.status).toBe(429);
438
+ expect(first.response.headers.get('retry-after')).toBe('10');
439
+ expect(verify).not.toHaveBeenCalled();
440
+ hit
441
+ .mockResolvedValueOnce(allowed)
442
+ .mockResolvedValueOnce({ allowed: false, retryAfterSeconds: 12 });
443
+ expect((await submit(page.id)).status).toBe(429);
444
+ expect(verify).not.toHaveBeenCalled();
445
+ const hash = createHash('sha256')
446
+ .update(`${h.payload.secret}:192.0.2.1`)
447
+ .digest('hex')
448
+ .slice(0, 32);
449
+ expect(hit.mock.calls.at(-1)?.[0]).toBe(hash);
450
+ expect(hit.mock.calls.at(-2)?.[0]).toBe(`${hash}:pages:${page.id}:${FORM}`);
451
+ });
452
+ it('uses the shared unknown IP bucket without fallback when the configured source fails', async () => {
453
+ const page = await publish();
454
+ bind(`pages:${page.id}`);
455
+ env = {
456
+ ...env,
457
+ options: {
458
+ ...env.options,
459
+ forms: {
460
+ ...env.options.forms,
461
+ clientIp: () => {
462
+ throw new Error(TOKEN);
463
+ },
464
+ },
465
+ },
466
+ };
467
+ expect((await submit(page.id)).status).toBe(200);
468
+ expect(hit.mock.calls.at(-1)?.[0]).toBe(createHash('sha256').update(`${h.payload.secret}:unknown`).digest('hex').slice(0, 32));
469
+ });
470
+ it('caps in-flight work without an extra call and releases on success and throws', async () => {
471
+ const page = await publish();
472
+ let finish = () => { };
473
+ verify.mockImplementationOnce(() => new Promise((resolve) => {
474
+ finish = resolve;
475
+ }));
476
+ const pending = submit(page.id);
477
+ await vi.waitFor(() => expect(verify).toHaveBeenCalledOnce());
478
+ expect((await submit(page.id)).status).toBe(503);
479
+ expect(verify).toHaveBeenCalledOnce();
480
+ finish({
481
+ ok: true,
482
+ hostname: 'example.com',
483
+ action: CAPTCHA_ACTION,
484
+ cdata: captchaCData(`pages:${page.id}`, FORM),
485
+ });
486
+ expect((await pending).status).toBe(200);
487
+ verify.mockRejectedValueOnce(new Error(`${SECRET} ${TOKEN}`));
488
+ expect((await submit(page.id)).status).toBe(503);
489
+ bind(`pages:${page.id}`);
490
+ expect((await submit(page.id)).status).toBe(200);
491
+ });
492
+ it('races a never-settling verifier, releases at the deadline and discards late results', async () => {
493
+ const page = await publish();
494
+ const controller = new AbortController();
495
+ const timeout = vi.spyOn(AbortSignal, 'timeout').mockImplementationOnce((ms) => {
496
+ vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] });
497
+ setTimeout(() => controller.abort(), ms);
498
+ return controller.signal;
499
+ });
500
+ let finish = () => { };
501
+ verify.mockImplementationOnce(() => new Promise((resolve) => {
502
+ finish = resolve;
503
+ }));
504
+ const pending = submit(page.id);
505
+ while (verify.mock.calls.length === 0)
506
+ await new Promise((resolve) => setImmediate(resolve));
507
+ expect(timeout).toHaveBeenCalledWith(5000);
508
+ await vi.advanceTimersByTimeAsync(4999);
509
+ expect(controller.signal.aborted).toBe(false);
510
+ await vi.advanceTimersByTimeAsync(1);
511
+ expect(controller.signal.aborted).toBe(true);
512
+ vi.useRealTimers();
513
+ expect((await pending).status).toBe(503);
514
+ bind(`pages:${page.id}`);
515
+ expect((await submit(page.id)).status).toBe(200);
516
+ const before = (await count()).length;
517
+ const log = vi.spyOn(h.payload.logger, 'error');
518
+ const warn = vi.spyOn(h.payload.logger, 'warn');
519
+ finish({
520
+ ok: true,
521
+ hostname: 'example.com',
522
+ action: CAPTCHA_ACTION,
523
+ cdata: captchaCData(`pages:${page.id}`, FORM),
524
+ });
525
+ await Promise.resolve();
526
+ expect((await count()).length).toBe(before);
527
+ expect(log).not.toHaveBeenCalled();
528
+ expect(warn).not.toHaveBeenCalled();
529
+ });
530
+ });
531
+ describe('published graph configuration checks', () => {
532
+ // These fixtures bypass publication validation to exercise fail-closed handling of corrupt storage.
533
+ const stored = (doc, id = 123) => vi
534
+ .spyOn(h.payload, 'findByID')
535
+ .mockResolvedValue({ id, _status: 'published', layout: doc });
536
+ it('requires hidden and conditional captchas too', async () => {
537
+ const doc = layout();
538
+ stored({
539
+ ...doc,
540
+ nodes: {
541
+ ...doc.nodes,
542
+ [CAP]: {
543
+ id: CAP,
544
+ type: CAPTCHA_TYPE,
545
+ styles: { base: { visibility: { hidden: true } } },
546
+ visibleIf: s(false),
547
+ },
548
+ },
549
+ });
550
+ expect((await submit(123, { message: 'hello' })).status).toBe(422);
551
+ expect(verify).not.toHaveBeenCalled();
552
+ });
553
+ it('rejects multiple captchas in the nearest form', async () => {
554
+ const doc = layout();
555
+ stored({
556
+ ...doc,
557
+ nodes: {
558
+ ...doc.nodes,
559
+ [FORM]: { id: FORM, type: 'buildr/form', slots: { default: [CAP, 'captcha002'] } },
560
+ captcha002: { id: 'captcha002', type: CAPTCHA_TYPE },
561
+ },
562
+ });
563
+ expect((await submit(123)).status).toBe(503);
564
+ expect(verify).not.toHaveBeenCalled();
565
+ });
566
+ it.each([true, false])('rejects a captcha-bearing ancestor even when the inner form captcha=%s', async (innerCaptcha) => {
567
+ const doc = layout();
568
+ const inner = 'form000002';
569
+ stored({
570
+ ...doc,
571
+ nodes: {
572
+ ...doc.nodes,
573
+ [FORM]: { id: FORM, type: 'buildr/form', slots: { default: [CAP, inner] } },
574
+ [inner]: {
575
+ id: inner,
576
+ type: 'buildr/form',
577
+ slots: { default: innerCaptcha ? ['captcha002'] : [] },
578
+ },
579
+ captcha002: { id: 'captcha002', type: CAPTCHA_TYPE },
580
+ },
581
+ });
582
+ expect((await submit(123, {}, { node: inner })).status).toBe(503);
583
+ expect(verify).not.toHaveBeenCalled();
584
+ });
585
+ it('keeps nested-form captchas out of the outer form requirement', async () => {
586
+ const doc = layout(false);
587
+ stored({
588
+ ...doc,
589
+ components: { ...doc.components, [CAPTCHA_TYPE]: 1 },
590
+ nodes: {
591
+ ...doc.nodes,
592
+ [FORM]: { id: FORM, type: 'buildr/form', slots: { default: [FIELD, 'form000002'] } },
593
+ form000002: { id: 'form000002', type: 'buildr/form', slots: { default: [CAP] } },
594
+ [CAP]: { id: CAP, type: CAPTCHA_TYPE },
595
+ },
596
+ });
597
+ expect((await submit(123, { message: 'hello' })).status).toBe(200);
598
+ expect(verify).not.toHaveBeenCalled();
599
+ });
600
+ it('handles corrupt graph edges without duplicating a captcha or hanging', async () => {
601
+ const doc = layout();
602
+ stored({
603
+ ...doc,
604
+ nodes: {
605
+ ...doc.nodes,
606
+ [FORM]: {
607
+ id: FORM,
608
+ type: 'buildr/form',
609
+ slots: { default: [FIELD, CAP, CAP, 'dangling01', FORM] },
610
+ },
611
+ },
612
+ });
613
+ bind('pages:123');
614
+ expect((await submit(123)).status).toBe(200);
615
+ expect(verify).toHaveBeenCalledOnce();
616
+ });
617
+ it.each(['pages', 'buildr-templates'])('answers 404 without verification when canonical reference is null: %s', async (collection) => {
618
+ stored(layout(), undefined);
619
+ // Explicit undefined would trigger the helper default; replace the row without an id instead.
620
+ vi.mocked(h.payload.findByID).mockResolvedValue({
621
+ _status: 'published',
622
+ layout: layout(),
623
+ });
624
+ expect((await submit(123, undefined, { collection })).status).toBe(404);
625
+ expect(verify).not.toHaveBeenCalled();
626
+ });
627
+ it('excludes the captcha token from the 200 author-field limit', async () => {
628
+ const doc = layout();
629
+ const fields = Array.from({ length: 200 }, (_, i) => ({
630
+ id: `field${String(i).padStart(5, '0')}`,
631
+ type: 'test/field',
632
+ props: { name: s(`f${i}`) },
633
+ }));
634
+ stored({
635
+ ...doc,
636
+ nodes: {
637
+ root: doc.nodes['root'],
638
+ [FORM]: {
639
+ id: FORM,
640
+ type: 'buildr/form',
641
+ slots: { default: [...fields.map((node) => node.id), CAP] },
642
+ },
643
+ ...Object.fromEntries(fields.map((node) => [node.id, node])),
644
+ [CAP]: { id: CAP, type: CAPTCHA_TYPE },
645
+ },
646
+ });
647
+ bind('pages:123');
648
+ const values = Object.fromEntries(fields.map((_, i) => [`f${i}`, 'value']));
649
+ expect((await submit(123, { ...values, [CAPTCHA_FIELD]: TOKEN })).status).toBe(200);
650
+ expect(verify).toHaveBeenCalledOnce();
651
+ expect((await submit(123, { ...values, extra: 'value', [CAPTCHA_FIELD]: TOKEN })).status).toBe(422);
652
+ expect(verify).toHaveBeenCalledOnce();
653
+ });
654
+ it('rejects a reserved author field even beyond schema derivation limits', async () => {
655
+ const doc = layout(false);
656
+ const fields = Array.from({ length: 201 }, (_, i) => ({
657
+ id: `field${String(i).padStart(5, '0')}`,
658
+ type: 'test/field',
659
+ props: { name: s(i === 200 ? CAPTCHA_FIELD : `f${i}`) },
660
+ }));
661
+ stored({
662
+ ...doc,
663
+ nodes: {
664
+ root: doc.nodes['root'],
665
+ [FORM]: {
666
+ id: FORM,
667
+ type: 'buildr/form',
668
+ slots: { default: fields.map((node) => node.id) },
669
+ },
670
+ ...Object.fromEntries(fields.map((node) => [node.id, node])),
671
+ },
672
+ });
673
+ expect((await submit(123, {})).status).toBe(503);
674
+ expect(verify).not.toHaveBeenCalled();
675
+ });
676
+ });
@@ -1 +1 @@
1
- {"version":3,"file":"forms.d.ts","sourceRoot":"","sources":["../../../src/plugin/endpoints/forms.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,QAAQ,EAAkB,MAAM,SAAS,CAAC;AAOxD,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,cAAc,CAAC;AAGhD,8DAA8D;AAC9D,eAAO,MAAM,SAAS,gBAAgB,CAAC;AACvC,uEAAuE;AACvE,eAAO,MAAM,cAAc,QAAQ,CAAC;AACpC,2EAA2E;AAC3E,eAAO,MAAM,cAAc,QAAY,CAAC;AA2ExC;;;;;GAKG;AACH,eAAO,MAAM,aAAa,QAAS,WAAW,KAAG,QAyH/C,CAAC"}
1
+ {"version":3,"file":"forms.d.ts","sourceRoot":"","sources":["../../../src/plugin/endpoints/forms.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,QAAQ,EAAkB,MAAM,SAAS,CAAC;AAmBxD,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,cAAc,CAAC;AAGhD,8DAA8D;AAC9D,eAAO,MAAM,SAAS,gBAAgB,CAAC;AACvC,uEAAuE;AACvE,eAAO,MAAM,cAAc,QAAQ,CAAC;AACpC,2EAA2E;AAC3E,eAAO,MAAM,cAAc,QAAY,CAAC;AA0ExC;;;;;GAKG;AACH,eAAO,MAAM,aAAa,QAAS,WAAW,KAAG,QA4N/C,CAAC"}