Files
worldmonitor/tests/edge-functions.test.mjs
Elie Habib 3d51a2619e fix(contact): add enterprise contact form endpoint (#1365)
* fix(contact): add dedicated enterprise contact endpoint with storage and email notifications

The enterprise contact form was posting to /api/register-interest which
only stored email for the waitlist. Name, organization, and message fields
were silently dropped and no notification was sent.

- Add api/contact.js endpoint with Turnstile, rate limiting, honeypot
- Add Convex contactMessages table and submit mutation
- Send notification email to sales@worldmonitor.app via Resend
- Sanitize email subject (strip newlines, truncate length)
- Fix cf-connecting-ip priority in register-interest.js IP detection

* chore: add contact.js to legacy endpoint allowlist

* fix(contact): harden Turnstile enforcement, surface email status, add tests

- Turnstile rejects in production when TURNSTILE_SECRET_KEY is unset
  (only allows skip in development)
- sendNotificationEmail returns boolean, response includes emailSent field
- Log error (not warn) when RESEND_API_KEY is missing in production
- Add 15 endpoint tests covering validation, Turnstile, notifications, Convex
2026-03-10 08:26:05 +04:00

130 lines
5.0 KiB
JavaScript

import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync, readdirSync, existsSync } from 'node:fs';
import { dirname, resolve, join } from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const root = resolve(__dirname, '..');
const apiDir = join(root, 'api');
const sharedDir = join(root, 'shared');
const scriptsSharedDir = join(root, 'scripts', 'shared');
// All .js files in api/ except underscore-prefixed helpers (_cors.js, _api-key.js)
const edgeFunctions = readdirSync(apiDir)
.filter((f) => f.endsWith('.js') && !f.startsWith('_'))
.map((f) => ({ name: f, path: join(apiDir, f) }));
// ALL .js files in api/ (including helpers) — used for node: built-in checks
const allApiFiles = readdirSync(apiDir)
.filter((f) => f.endsWith('.js'))
.map((f) => ({ name: f, path: join(apiDir, f) }));
describe('scripts/shared/ stays in sync with shared/', () => {
const sharedFiles = readdirSync(sharedDir).filter((f) => f.endsWith('.json') || f.endsWith('.cjs'));
for (const file of sharedFiles) {
it(`scripts/shared/${file} matches shared/${file}`, () => {
const srcPath = join(scriptsSharedDir, file);
assert.ok(existsSync(srcPath), `scripts/shared/${file} is missing — run: cp shared/${file} scripts/shared/`);
const original = readFileSync(join(sharedDir, file), 'utf8');
const copy = readFileSync(srcPath, 'utf8');
assert.strictEqual(copy, original, `scripts/shared/${file} is out of sync with shared/${file} — run: cp shared/${file} scripts/shared/`);
});
}
});
describe('Edge Function shared helpers resolve', () => {
it('_rss-allowed-domains.js re-exports shared domain list', async () => {
const mod = await import(join(apiDir, '_rss-allowed-domains.js'));
const domains = mod.default;
assert.ok(Array.isArray(domains), 'Expected default export to be an array');
assert.ok(domains.length > 200, `Expected 200+ domains, got ${domains.length}`);
assert.ok(domains.includes('feeds.bbci.co.uk'), 'Expected BBC feed domain in list');
});
});
describe('Edge Function no node: built-ins', () => {
for (const { name, path } of allApiFiles) {
it(`${name} does not import node: built-ins (unsupported in Vercel Edge Runtime)`, () => {
const src = readFileSync(path, 'utf-8');
const match = src.match(/from\s+['"]node:(\w+)['"]/);
assert.ok(
!match,
`${name}: imports node:${match?.[1]} — Vercel Edge Runtime does not support node: built-in modules. Use an edge-compatible alternative.`,
);
});
}
});
describe('Legacy api/*.js endpoint allowlist', () => {
const ALLOWED_LEGACY_ENDPOINTS = new Set([
'ais-snapshot.js',
'bootstrap.js',
'cache-purge.js',
'contact.js',
'download.js',
'fwdstart.js',
'geo.js',
'gpsjam.js',
'health.js',
'military-flights.js',
'og-story.js',
'opensky.js',
'oref-alerts.js',
'polymarket.js',
'register-interest.js',
'reverse-geocode.js',
'rss-proxy.js',
'satellites.js',
'seed-health.js',
'story.js',
'telegram-feed.js',
'version.js',
]);
const currentEndpoints = readdirSync(apiDir).filter(
(f) => f.endsWith('.js') && !f.startsWith('_'),
);
for (const file of currentEndpoints) {
it(`${file} is in the legacy endpoint allowlist`, () => {
assert.ok(
ALLOWED_LEGACY_ENDPOINTS.has(file),
`${file} is a new api/*.js endpoint not in the allowlist. ` +
'New data endpoints must use the sebuf protobuf RPC pattern ' +
'(proto definition → buf generate → handler in server/worldmonitor/{domain}/v1/ → wired in handler.ts). ' +
'If this is a non-data ops endpoint, add it to ALLOWED_LEGACY_ENDPOINTS in tests/edge-functions.test.mjs.',
);
});
}
it('allowlist has no stale entries (all listed files exist)', () => {
for (const file of ALLOWED_LEGACY_ENDPOINTS) {
assert.ok(
existsSync(join(apiDir, file)),
`${file} is in ALLOWED_LEGACY_ENDPOINTS but does not exist in api/ — remove it from the allowlist.`,
);
}
});
});
describe('Edge Function module isolation', () => {
for (const { name, path } of edgeFunctions) {
it(`${name} does not import from ../server/ (Edge Functions cannot resolve cross-directory TS)`, () => {
const src = readFileSync(path, 'utf-8');
assert.ok(
!src.includes("from '../server/"),
`${name}: imports from ../server/ — Vercel Edge Functions cannot resolve cross-directory TS imports. Inline the code or move to a same-directory .js helper.`,
);
});
it(`${name} does not import from ../src/ (Edge Functions cannot resolve TS aliases)`, () => {
const src = readFileSync(path, 'utf-8');
assert.ok(
!src.includes("from '../src/"),
`${name}: imports from ../src/ — Vercel Edge Functions cannot resolve @/ aliases or cross-directory TS. Inline the code instead.`,
);
});
}
});