← back to Robet Site
tests/api.test.mjs
124 lines
// Zero-dependency API contract tests (node --test). Boots the server on a test
// port with a known admin password, asserts the contract, cleans up, tears down.
import { test, before, after } from 'node:test';
import assert from 'node:assert/strict';
import { spawn } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const PORT = 9799;
const BASE = `http://127.0.0.1:${PORT}`;
const ADMIN = 'Basic ' + Buffer.from('admin:testpass').toString('base64');
let srv;
before(async () => {
srv = spawn('node', ['server.js'], {
cwd: ROOT, stdio: 'ignore',
env: { ...process.env, PORT: String(PORT), ADMIN_USER: 'admin', ADMIN_PASS: 'testpass' },
});
for (let i = 0; i < 60; i++) {
try { const r = await fetch(`${BASE}/api/config`); if (r.ok) return; } catch {}
await new Promise((r) => setTimeout(r, 100));
}
throw new Error('server did not start');
});
after(() => {
srv?.kill();
try { fs.rmSync(path.join(ROOT, 'data', 'contacts.jsonl')); } catch {}
});
test('home page renders brand + OG meta', async () => {
const r = await fetch(`${BASE}/`);
assert.equal(r.status, 200);
const html = await r.text();
assert.match(html, /ROBERT/);
assert.match(html, /og:image/);
});
test('/api/config exposes brand + scheduling, no secrets', async () => {
const j = await (await fetch(`${BASE}/api/config`)).json();
assert.equal(j.brand, 'ROBERT');
assert.ok(j.scheduling && 'bookUrl' in j.scheduling, 'scheduling.bookUrl key present (standalone, no DW owner)');
assert.equal(JSON.stringify(j).includes('ADMIN_PASS'), false);
});
test('security headers present', async () => {
const r = await fetch(`${BASE}/`);
assert.ok(r.headers.get('content-security-policy'), 'CSP set');
assert.equal(r.headers.get('x-content-type-options'), 'nosniff');
assert.match(r.headers.get('content-security-policy'), /default-src 'self'/);
assert.match(r.headers.get('content-security-policy'), /frame-src 'none'/); // de-entangled: no 3rd-party embeds
});
test('static SEO assets serve', async () => {
for (const f of ['favicon.svg', 'og.png', 'robots.txt', 'sitemap.xml']) {
assert.equal((await fetch(`${BASE}/${f}`)).status, 200, `${f} should be 200`);
}
});
test('unknown route → custom 404', async () => {
const r = await fetch(`${BASE}/no-such-page-xyz`);
assert.equal(r.status, 404);
assert.match(await r.text(), /404/);
});
test('contact: honeypot + validation + rate limit', async () => {
const post = (body) => fetch(`${BASE}/api/contact`, {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body),
});
// honeypot → ok but NOT stored (and does not consume a rate hit)
const hp = await post({ email: 'bot@x.com', message: 'spam', website: 'http://x' });
assert.equal((await hp.json()).ok, true);
// invalid email → 400
assert.equal((await post({ email: 'bad', message: 'x' })).status, 400);
// valid → ok
const vr = await post({ email: 'real@example.com', message: 'hi' });
assert.equal((await vr.json()).ok, true);
// burst → 429 appears
let saw429 = false;
for (let i = 0; i < 8; i++) {
const r = await post({ email: `b${i}@x.com`, message: 'm' });
if (r.status === 429) saw429 = true;
}
assert.ok(saw429, 'rate limit should trigger 429 on burst');
});
test('homepage embeds valid JSON-LD with a booking action', async () => {
const html = await (await fetch(`${BASE}/`)).text();
const m = html.match(/<script type="application\/ld\+json">([\s\S]*?)<\/script>/);
assert.ok(m, 'ld+json block present');
const j = JSON.parse(m[1]); // throws if invalid → test fails
assert.equal(j['@type'], 'HairSalon');
assert.equal(j.potentialAction['@type'], 'ScheduleAction');
assert.match(j.potentialAction.target.urlTemplate, /robert\.agentabrams\.com/);
});
test('malformed JSON body → clean JSON error, not HTML stack', async () => {
const r = await fetch(`${BASE}/api/contact`, {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{bad json',
});
assert.equal(r.status, 400);
assert.match(r.headers.get('content-type') || '', /application\/json/);
const j = await r.json();
assert.equal(j.ok, false);
});
test('admin auth gates + key masking', async () => {
assert.equal((await fetch(`${BASE}/admin`)).status, 401); // no creds
assert.equal((await fetch(`${BASE}/admin`, { headers: { Authorization: ADMIN } })).status, 200);
// store a key, confirm only masked value comes back
await fetch(`${BASE}/api/keys`, {
method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: ADMIN },
body: JSON.stringify({ name: 'TEST_KEY', value: 'sk-secret-abcd1234' }),
});
const list = await (await fetch(`${BASE}/api/keys`, { headers: { Authorization: ADMIN } })).json();
const row = list.find((k) => k.name === 'TEST_KEY');
assert.ok(row, 'key stored');
assert.equal(JSON.stringify(list).includes('sk-secret-abcd1234'), false, 'raw value never returned');
assert.match(row.masked, /1234$/);
await fetch(`${BASE}/api/keys/TEST_KEY`, { method: 'DELETE', headers: { Authorization: ADMIN } }); // cleanup
});