openvibers/openvibe.community Commits

Tests: SSR, /api/pastes proxy, OAuth client and SEO against mocked Live and Network

OP OpenVibers committed 18 Sept 2026 · 66a34f4ecb · 8 files, +675 −0

npm test runs each *.test.js in its own process. The mocks are in-process HTTP servers: a
Live stand-in with paste fixtures that echoes anything else, and a Network stand-in with a
real RS256 key pair behind /api/.well-known/jwks and /oauth/token.

Changes

ddiff --git a/test/auth.test.js b/test/auth.test.js
nnew file mode 100644
iindex 0000000..d213840
---- /dev/null
++++ b/test/auth.test.js
@@@ -0,0 +1,112 @@
++'use strict';
++/** The OAuth client: login (incl. silent), callback, logout, me, refresh, and next sanitising. */
++const assert = require('assert');
++const { boot, check, done } = require('./helpers/app');
++
++(async () => {
++ const t = await boot();
++ const cookieNamed = (r, name) => r.setCookies.find((c) => c.startsWith(`${name}=`));
++
++ await check('GET /auth/login builds the Network authorize URL and sets the state cookie', async () => {
++ const r = await t.get('/auth/login?next=/pastes%3Fsort%3Dviews');
++ assert.strictEqual(r.status, 302);
++ const u = new URL(r.headers.get('location'));
++ assert.strictEqual(u.origin, t.network.url);
++ assert.strictEqual(u.pathname, '/oauth/authorize');
++ assert.strictEqual(u.searchParams.get('client_id'), 'community');
++ assert.strictEqual(u.searchParams.get('redirect_uri'), 'https://openvibe.community/auth/callback');
++ assert.strictEqual(u.searchParams.get('response_type'), 'code');
++ assert.strictEqual(u.searchParams.get('scope'), 'profile theme');
++ assert.strictEqual(u.searchParams.get('prompt'), null);
++ assert.ok(/^[0-9a-f]{32}$/.test(u.searchParams.get('state')));
++ const state = cookieNamed(r, 'ov_oauth_state');
++ assert.ok(state && /HttpOnly/i.test(state) && /Path=\/auth/.test(state) && /Secure/.test(state) && !/Domain=/i.test(state), 'state cookie: httpOnly, /auth, secure, host-only');
++ assert.ok(state.includes(u.searchParams.get('state')));
++ assert.strictEqual(decodeURIComponent(cookieNamed(r, 'ov_oauth_next').split(';')[0].split('=').slice(1).join('=')), '/pastes?sort=views');
++ });
++
++ await check('silent=1 adds prompt=none; login_required bounces back to next + ?sso=none', async () => {
++ const r = await t.get('/auth/login?silent=1&next=/p/amber-fox-42');
++ const u = new URL(r.headers.get('location'));
++ assert.strictEqual(u.searchParams.get('prompt'), 'none');
++ assert.ok(cookieNamed(r, 'ov_oauth_silent'));
++ const back = await t.get(`/auth/callback?error=login_required&state=${u.searchParams.get('state')}`);
++ assert.strictEqual(back.status, 302);
++ assert.strictEqual(back.headers.get('location'), '/p/amber-fox-42?sso=none');
++ assert.ok(!t.jar.has('ov_token'), 'no session was created');
++ });
++
++ await check('?sso=none is appended correctly to a next that already has a query or a Network URL', async () => {
++ const { withParam, sanitizeNext } = require('../server/auth/routes');
++ const cfg = require('../server/config');
++ assert.strictEqual(withParam('/pastes?sort=views', 'sso', 'none'), '/pastes?sort=views&sso=none');
++ assert.strictEqual(withParam('/pastes#x', 'sso', 'none'), '/pastes?sso=none#x');
++ assert.strictEqual(sanitizeNext('/my', cfg), '/my');
++ assert.strictEqual(sanitizeNext('//evil.example/x', cfg), '/');
++ assert.strictEqual(sanitizeNext('https://evil.example/x', cfg), '/');
++ assert.strictEqual(sanitizeNext('https://openvibe.community/pastes', cfg), 'https://openvibe.community/pastes');
++ assert.strictEqual(sanitizeNext(`${t.network.url}/sso/next?step=2`.replace(/^http:/, 'https:'), cfg), `${t.network.url}/sso/next?step=2`.replace(/^http:/, 'https:'));
++ assert.strictEqual(sanitizeNext('javascript:alert(1)', cfg), '/');
++ });
++
++ await check('callback exchanges the code, sets host-only ov_token (JS-readable) + ov_refresh + ov_sso_hint=account, redirects to next', async () => {
++ const login = await t.get('/auth/login?next=/my');
++ const state = new URL(login.headers.get('location')).searchParams.get('state');
++ const cb = await t.get(`/auth/callback?code=good-code&state=${state}`);
++ assert.strictEqual(cb.status, 302, cb.text);
++ assert.strictEqual(cb.headers.get('location'), '/my');
++ const tok = cookieNamed(cb, 'ov_token');
++ assert.ok(tok && !/HttpOnly/i.test(tok) && /SameSite=Lax/i.test(tok) && /Secure/.test(tok) && !/Domain=/i.test(tok) && /Path=\//.test(tok));
++ const ref = cookieNamed(cb, 'ov_refresh');
++ assert.ok(ref && /HttpOnly/i.test(ref) && /Path=\/auth/.test(ref));
++ const hint = cookieNamed(cb, 'ov_sso_hint');
++ assert.ok(hint && hint.startsWith('ov_sso_hint=account') && !/HttpOnly/i.test(hint) && /Max-Age=31536000/.test(hint));
++ const grant = t.network.grants.pop();
++ assert.strictEqual(grant.grant_type, 'authorization_code');
++ assert.strictEqual(grant.client_id, 'community');
++ assert.strictEqual(grant.redirect_uri, 'https://openvibe.community/auth/callback');
++ });
++
++ await check('/auth/me verifies the cookie offline via the Network JWKS', async () => {
++ const me = await t.get('/auth/me');
++ assert.strictEqual(me.status, 200);
++ const body = me.json();
++ assert.strictEqual(body.user.username, 'alex');
++ assert.strictEqual(body.user.exp, undefined);
++ assert.ok(body.expires_at > Date.now());
++ const bad = await t.get('/auth/me', { cookies: ['ov_token=garbage'], headers: { authorization: 'Bearer garbage' } });
++ assert.strictEqual(bad.status, 401);
++ });
++
++ await check('callback rejects a missing or mismatched state', async () => {
++ const r = await t.get('/auth/callback?code=good-code&state=deadbeef');
++ assert.strictEqual(r.status, 400);
++ });
++
++ await check('POST /auth/refresh rotates tokens; a rejected refresh clears the session', async () => {
++ t.jar.set('ov_refresh', 'refresh-1');
++ const ok = await t.get('/auth/refresh', { method: 'POST' });
++ assert.strictEqual(ok.status, 200);
++ assert.strictEqual(ok.json().user.username, 'alex');
++ assert.strictEqual(t.jar.get('ov_refresh'), 'refresh-2');
++ t.jar.set('ov_refresh', 'stale');
++ const no = await t.get('/auth/refresh', { method: 'POST' });
++ assert.strictEqual(no.status, 401);
++ assert.ok(!t.jar.has('ov_token'));
++ });
++
++ await check('logout clears the session, sets ov_sso_hint=guest and honours a Network next', async () => {
++ t.jar.set('ov_token', 'x'); t.jar.set('ov_refresh', 'refresh-2');
++ const next = `${t.network.url}/logout?everywhere=1`.replace(/^http:/, 'https:');
++ const r = await t.get(`/auth/logout?next=${encodeURIComponent(next)}`);
++ assert.strictEqual(r.status, 302);
++ assert.strictEqual(r.headers.get('location'), next);
++ assert.ok(cookieNamed(r, 'ov_sso_hint').startsWith('ov_sso_hint=guest'));
++ assert.ok(!t.jar.has('ov_token') && !t.jar.has('ov_refresh'));
++ const evil = await t.get('/auth/logout?next=https://evil.example/');
++ assert.strictEqual(evil.headers.get('location'), '/');
++ });
++
++ await t.close();
++ done();
++})();
ddiff --git a/test/helpers/app.js b/test/helpers/app.js
nnew file mode 100644
iindex 0000000..8320edf
---- /dev/null
++++ b/test/helpers/app.js
@@@ -0,0 +1,56 @@
++'use strict';
++/** Boots the mocks, points the app at them, and returns a tiny HTTP client that keeps cookies. */
++const http = require('http');
++const mockLive = require('./mock-live');
++const mockNetwork = require('./mock-network');
++
++async function boot() {
++ const liveSrv = await mockLive.start();
++ const netSrv = await mockNetwork.start();
++ process.env.NODE_ENV = 'test';
++ process.env.BASE_URL = 'https://openvibe.community';
++ process.env.OV_LIVE_INTERNAL_URL = liveSrv.url;
++ process.env.OV_LIVE_URL = 'https://openvibe.live';
++ process.env.OV_MEDIA_URL = 'https://openvibe.media';
++ process.env.OV_NETWORK_URL = netSrv.url;
++ process.env.OV_NETWORK_INTERNAL_URL = netSrv.url;
++ process.env.OV_OAUTH_CLIENT_ID = 'community';
++ process.env.OV_OAUTH_CLIENT_SECRET = 'shh';
++ process.env.OV_OAUTH_REDIRECT_URI = 'https://openvibe.community/auth/callback';
++ process.env.COOKIE_SECURE = 'true';
++ process.env.TRUST_PROXY = '1';
++ for (const k of Object.keys(require.cache)) if (k.includes('/server/')) delete require.cache[k];
++ const { createApp } = require('../../server/app');
++ const app = createApp();
++ const server = await new Promise((resolve) => { const s = http.createServer(app); s.listen(0, '127.0.0.1', () => resolve(s)); });
++ const base = `http://127.0.0.1:${server.address().port}`;
++
++ const jar = new Map();
++ async function get(path, opts = {}) {
++ const headers = { ...(opts.headers || {}) };
++ const cookies = [...jar.entries()].map(([k, v]) => `${k}=${v}`).concat(opts.cookies || []);
++ if (cookies.length) headers.cookie = cookies.join('; ');
++ const res = await fetch(base + path, { method: opts.method || 'GET', headers, body: opts.body, redirect: 'manual', duplex: opts.body && typeof opts.body.pipe === 'function' ? 'half' : undefined });
++ for (const sc of res.headers.getSetCookie ? res.headers.getSetCookie() : []) {
++ const [pair, ...attrs] = sc.split(';');
++ const [k, v] = pair.split('=');
++ const expired = attrs.some((a) => /max-age=0|expires=thu, 01 jan 1970/i.test(a.trim()));
++ if (expired) jar.delete(k.trim()); else jar.set(k.trim(), v);
++ }
++ const text = await res.text();
++ return { status: res.status, headers: res.headers, text, setCookies: res.headers.getSetCookie ? res.headers.getSetCookie() : [], json() { return JSON.parse(text); } };
++ }
++ return {
++ app, base, get, jar, live: liveSrv, network: netSrv,
++ close: async () => { await new Promise((r) => server.close(r)); await liveSrv.close(); await netSrv.close(); },
++ };
++}
++
++let failures = 0;
++async function check(name, fn) {
++ try { await fn(); console.log(' ✓', name); }
++ catch (e) { failures++; console.log(' ✗', name, '\n ', (e.stack || String(e)).split('\n').slice(0, 4).join('\n ')); }
++}
++function done() { console.log(failures ? `\n${failures} failed` : '\nall passed'); process.exit(failures ? 1 : 0); }
++
++module.exports = { boot, check, done };
ddiff --git a/test/helpers/mock-live.js b/test/helpers/mock-live.js
nnew file mode 100644
iindex 0000000..8b47f59
---- /dev/null
++++ b/test/helpers/mock-live.js
@@@ -0,0 +1,75 @@
++'use strict';
++/**
++ * A stand-in for OpenVibe.Live's /api/pastes surface: enough of the list / get / by-user /
++ * create endpoints to render every page, plus an echo of whatever else arrives so the proxy
++ * tests can see exactly what was forwarded.
++ */
++const http = require('http');
++
++const FIXTURES = [
++ { id: 1, slug: 'amber-fox-42', user_id: 7, username: 'alex', display_name: 'Alex', avatar_url: '/data/avatars/alex.png', type: 'paste', title: 'Hello world in JavaScript', content: 'const greet = (name) => {\n return `Hello, ${name}!`;\n};\nconsole.log(greet("OpenVibe"));\n', language: 'javascript', visibility: 'public', views: 120, likes: 3, created_at: '2026-09-15 10:00:00', updated_at: '2026-09-15 10:00:00' },
++ { id: 2, slug: 'blue-lake-17', user_id: 9, username: 'sam', display_name: 'Sam', type: 'paste', title: 'nginx snippet', content: 'server {\n listen 80;\n}\n', language: 'nginx', visibility: 'public', views: 900, likes: 0, created_at: '2026-09-14 09:00:00', updated_at: '2026-09-14 09:00:00' },
++ { id: 3, slug: 'cold-ridge-88', user_id: 7, username: 'alex', display_name: 'Alex', type: 'paste', title: 'python helper', content: 'def add(a, b):\n return a + b\n', language: 'python', visibility: 'public', views: 15, likes: 1, created_at: '2026-09-13 08:00:00', updated_at: '2026-09-13 08:00:00' },
++ { id: 4, slug: 'dark-owl-55', user_id: 7, username: 'alex', display_name: 'Alex', type: 'paste', title: 'secret notes', content: 'unlisted <script>alert(1)</script>', language: 'text', visibility: 'unlisted', views: 2, likes: 0, created_at: '2026-09-12 08:00:00', updated_at: '2026-09-12 08:00:00' },
++ { id: 5, slug: 'fair-moon-23', user_id: 9, username: 'sam', display_name: 'Sam', type: 'screenshot', title: 'Desktop shot', content: 'my desktop', language: 'text', visibility: 'public', screenshot_url: '/p/fair-moon-23/screenshot', views: 40, likes: 0, created_at: '2026-09-11 08:00:00', updated_at: '2026-09-11 08:00:00' },
++ { id: 6, slug: 'grim-vale-61', user_id: 7, username: 'alex', display_name: 'Alex', type: 'paste', title: 'private thing', content: 'private', language: 'text', visibility: 'private', views: 0, likes: 0, created_at: '2026-09-10 08:00:00', updated_at: '2026-09-10 08:00:00' },
++];
++
++function start() {
++ const calls = [];
++ const server = http.createServer((req, res) => {
++ const chunks = [];
++ req.on('data', (c) => chunks.push(c));
++ req.on('end', () => {
++ const body = Buffer.concat(chunks);
++ const url = new URL(req.url, 'http://live');
++ const call = { method: req.method, path: url.pathname, query: Object.fromEntries(url.searchParams), headers: req.headers, body };
++ calls.push(call);
++ const auth = req.headers.authorization || '';
++ const signedIn = auth === 'Bearer good-token' || auth.startsWith('Bearer eyJ');
++ const json = (status, obj) => { res.writeHead(status, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(obj)); };
++ const publicOnly = FIXTURES.filter((p) => p.visibility === 'public');
++
++ if (req.method === 'GET' && url.pathname === '/api/pastes') {
++ let rows = publicOnly;
++ if (url.searchParams.get('type')) rows = rows.filter((p) => p.type === url.searchParams.get('type'));
++ const q = url.searchParams.get('search');
++ if (q) rows = rows.filter((p) => (p.title + p.content).toLowerCase().includes(q.toLowerCase()));
++ const limit = Number(url.searchParams.get('limit') || 50), offset = Number(url.searchParams.get('offset') || 0);
++ return json(200, { pastes: rows.slice(offset, offset + limit).map((p) => ({ ...p, content: p.type === 'paste' ? p.content.slice(0, 300) : null })), total: rows.length, limit, offset });
++ }
++ let m;
++ if (req.method === 'GET' && (m = url.pathname.match(/^\/api\/pastes\/by-user\/([^/]+)$/))) {
++ const username = decodeURIComponent(m[1]);
++ if (!FIXTURES.some((p) => p.username === username)) return json(404, { error: 'User not found' });
++ const rows = FIXTURES.filter((p) => p.username === username && (signedIn || p.visibility === 'public'));
++ return json(200, { pastes: rows, total: rows.length, username });
++ }
++ if (req.method === 'GET' && (m = url.pathname.match(/^\/api\/pastes\/([^/]+)$/))) {
++ const p = FIXTURES.find((x) => x.slug === decodeURIComponent(m[1]));
++ if (!p || (p.visibility === 'private' && !signedIn)) return json(404, { error: 'Paste not found' });
++ if (p.slug === 'burned') return json(410, { error: 'burned' });
++ return json(200, { paste: { ...p, views: p.views + (url.searchParams.get('no_view') === '1' ? 0 : 1) } });
++ }
++ if (req.method === 'POST' && url.pathname === '/api/pastes') {
++ let parsed = {}; try { parsed = JSON.parse(body.toString('utf8')); } catch { /* */ }
++ if (!parsed.content) return json(400, { error: 'Content is required' });
++ if (parsed.title === 'slow down') return json(429, { error: 'Please wait 30s', cooldown: 30 });
++ return json(201, { id: 99, slug: 'new-paste-99', url: '/p/new-paste-99', paste: { slug: 'new-paste-99', ...parsed, user_id: signedIn ? 7 : null } });
++ }
++ if (req.method === 'POST' && url.pathname === '/api/pastes/screenshot') {
++ return json(201, { paste: { slug: 'shot-100' }, url: '/p/shot-100', received: body.length, contentType: req.headers['content-type'] || null });
++ }
++ // Everything else: echo, so proxy tests can inspect the forwarded request.
++ json(200, { echo: true, method: req.method, path: url.pathname, query: call.query, headers: req.headers, body: body.toString('utf8') });
++ });
++ });
++ return new Promise((resolve) => server.listen(0, '127.0.0.1', () => resolve({
++ url: `http://127.0.0.1:${server.address().port}`,
++ calls,
++ fixtures: FIXTURES,
++ close: () => new Promise((r) => server.close(r)),
++ })));
++}
++
++module.exports = { start, FIXTURES };
ddiff --git a/test/helpers/mock-network.js b/test/helpers/mock-network.js
nnew file mode 100644
iindex 0000000..9770332
---- /dev/null
++++ b/test/helpers/mock-network.js
@@@ -0,0 +1,41 @@
++'use strict';
++/** A stand-in for OpenVibe.Network: JWKS + the token endpoint, with a real RS256 key pair. */
++const http = require('http');
++const crypto = require('crypto');
++const jwt = require('jsonwebtoken');
++
++function start() {
++ const { publicKey, privateKey } = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 });
++ const publicPem = publicKey.export({ type: 'spki', format: 'pem' });
++ const privatePem = privateKey.export({ type: 'pkcs8', format: 'pem' });
++ const grants = [];
++ let issuer = 'http://network.test';
++ const server = http.createServer((req, res) => {
++ const chunks = [];
++ req.on('data', (c) => chunks.push(c));
++ req.on('end', () => {
++ const json = (status, obj) => { res.writeHead(status, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(obj)); };
++ if (req.url === '/api/.well-known/jwks') return json(200, { public_key: publicPem, algorithm: 'RS256' });
++ if (req.url === '/oauth/token' && req.method === 'POST') {
++ let body = {}; try { body = JSON.parse(Buffer.concat(chunks).toString('utf8')); } catch { /* */ }
++ grants.push(body);
++ if (body.client_secret !== 'shh') return json(401, { error: 'invalid_client' });
++ if (body.grant_type === 'authorization_code' && body.code !== 'good-code') return json(400, { error: 'invalid_grant' });
++ if (body.grant_type === 'refresh_token' && body.refresh_token !== 'refresh-1') return json(400, { error: 'invalid_grant' });
++ return json(200, { access_token: sign({ id: 7, username: 'alex', display_name: 'Alex', role: 'user' }), refresh_token: 'refresh-2', token_type: 'Bearer', expires_in: 86400 });
++ }
++ if (req.url === '/oauth/revoke') return json(200, { ok: true });
++ json(404, { error: 'not found' });
++ });
++ });
++ function sign(claims, opts = {}) {
++ return jwt.sign({ sub: claims.id, ...claims }, privatePem, { algorithm: 'RS256', issuer, expiresIn: '1h', ...opts });
++ }
++ return new Promise((resolve) => server.listen(0, '127.0.0.1', () => {
++ const url = `http://127.0.0.1:${server.address().port}`;
++ issuer = url;
++ resolve({ url, grants, sign, publicPem, close: () => new Promise((r) => server.close(r)) });
++ }));
++}
++
++module.exports = { start };
ddiff --git a/test/proxy.test.js b/test/proxy.test.js
nnew file mode 100644
iindex 0000000..8721a08
---- /dev/null
++++ b/test/proxy.test.js
@@@ -0,0 +1,83 @@
++'use strict';
++/** /api/pastes/* is a transparent proxy to Live that forwards the visitor's identity and address. */
++const assert = require('assert');
++const { Readable } = require('stream');
++const { boot, check, done } = require('./helpers/app');
++
++(async () => {
++ const t = await boot();
++
++ await check('GET forwards path, query, the ov_token cookie as Bearer and the client address', async () => {
++ const r = await t.get('/api/pastes/echo/thing?limit=5&x=y', { cookies: ['ov_token=cookie-jwt'], headers: { 'x-forwarded-for': '203.0.113.9' } });
++ assert.strictEqual(r.status, 200);
++ const e = r.json();
++ assert.strictEqual(e.path, '/api/pastes/echo/thing');
++ assert.deepStrictEqual(e.query, { limit: '5', x: 'y' });
++ assert.strictEqual(e.headers.authorization, 'Bearer cookie-jwt');
++ assert.strictEqual(e.headers['x-forwarded-for'], '203.0.113.9');
++ assert.strictEqual(e.headers.cookie, undefined, 'cookies never leave this site');
++ });
++
++ await check('Authorization header wins over the cookie; anonymous calls carry no Authorization', async () => {
++ const r = await t.get('/api/pastes/echo/x', { cookies: ['ov_token=cookie-jwt'], headers: { authorization: 'Bearer header-jwt' } });
++ assert.strictEqual(r.json().headers.authorization, 'Bearer header-jwt');
++ const anon = await t.get('/api/pastes/echo/x');
++ assert.strictEqual(anon.json().headers.authorization, undefined);
++ });
++
++ await check('JSON POST body reaches Live byte-for-byte with its content-type', async () => {
++ const body = JSON.stringify({ title: 't', content: 'hello', language: 'text' });
++ const r = await t.get('/api/pastes', { method: 'POST', body, headers: { 'content-type': 'application/json' }, cookies: ['ov_token=good-token'] });
++ assert.strictEqual(r.status, 201);
++ assert.strictEqual(r.json().slug, 'new-paste-99');
++ const call = t.live.calls.filter((c) => c.method === 'POST' && c.path === '/api/pastes').pop();
++ assert.strictEqual(call.body.toString(), body);
++ assert.strictEqual(call.headers['content-type'], 'application/json');
++ assert.strictEqual(call.headers.authorization, 'Bearer good-token');
++ });
++
++ await check('multipart screenshot upload streams through untouched', async () => {
++ const boundary = 'xxBOUNDARYxx';
++ const png = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0, 1, 2, 3, 255, 254]);
++ const body = Buffer.concat([
++ Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="title"\r\n\r\nShot\r\n--${boundary}\r\nContent-Disposition: form-data; name="screenshot"; filename="a.png"\r\nContent-Type: image/png\r\n\r\n`),
++ png, Buffer.from(`\r\n--${boundary}--\r\n`),
++ ]);
++ const r = await t.get('/api/pastes/screenshot', { method: 'POST', body: Readable.from([body]), headers: { 'content-type': `multipart/form-data; boundary=${boundary}`, 'content-length': String(body.length) } });
++ assert.strictEqual(r.status, 201);
++ assert.strictEqual(r.json().received, body.length);
++ const call = t.live.calls.filter((c) => c.path === '/api/pastes/screenshot').pop();
++ assert.ok(call.body.equals(body), 'body identical');
++ assert.strictEqual(call.headers['content-type'], `multipart/form-data; boundary=${boundary}`);
++ });
++
++ await check('upstream status codes and error bodies pass through (404, 429)', async () => {
++ const nf = await t.get('/api/pastes/does-not-exist');
++ assert.strictEqual(nf.status, 404);
++ assert.deepStrictEqual(nf.json(), { error: 'Paste not found' });
++ const rl = await t.get('/api/pastes', { method: 'POST', body: JSON.stringify({ title: 'slow down', content: 'x' }), headers: { 'content-type': 'application/json' } });
++ assert.strictEqual(rl.status, 429);
++ assert.strictEqual(rl.json().cooldown, 30);
++ });
++
++ await check('DELETE and the copy/like sub-routes are forwarded with the method intact', async () => {
++ const del = await t.get('/api/pastes/amber-fox-42', { method: 'DELETE', cookies: ['ov_token=good-token'] });
++ assert.strictEqual(del.json().method, 'DELETE');
++ const copy = await t.get('/api/pastes/amber-fox-42/copy', { method: 'POST', body: '{}', headers: { 'content-type': 'application/json' } });
++ assert.strictEqual(copy.json().path, '/api/pastes/amber-fox-42/copy');
++ });
++
++ await check('an unreachable Live answers 502 JSON instead of hanging', async () => {
++ const { createApp } = require('../server/app');
++ const http = require('http');
++ const app = createApp({ liveUrl: 'http://127.0.0.1:1' });
++ const srv = await new Promise((resolve) => { const s = http.createServer(app); s.listen(0, '127.0.0.1', () => resolve(s)); });
++ const res = await fetch(`http://127.0.0.1:${srv.address().port}/api/pastes`);
++ assert.strictEqual(res.status, 502);
++ assert.deepStrictEqual(await res.json(), { error: 'Could not reach the paste service' });
++ await new Promise((r) => srv.close(r));
++ });
++
++ await t.close();
++ done();
++})();
ddiff --git a/test/run.js b/test/run.js
nnew file mode 100644
iindex 0000000..07aa8ea
---- /dev/null
++++ b/test/run.js
@@@ -0,0 +1,56 @@
++#!/usr/bin/env node
++/**
++ * Runs every test in test/ — the files named *.test.js — each in its own process, and fails
++ * if any of them fails. They use in-process mocks of OpenVibe.Live and OpenVibe.Network only;
++ * none of them needs the network or a running site.
++ *
++ * npm test # everything
++ * npm test -- seo proxy # only files whose name contains one of the words
++ */
++'use strict';
++const { spawn } = require('child_process');
++const fs = require('fs');
++const path = require('path');
++
++const filters = process.argv.slice(2).filter((a) => !a.startsWith('-'));
++const files = fs.readdirSync(__dirname)
++ .filter((f) => f.endsWith('.test.js'))
++ .filter((f) => !filters.length || filters.some((w) => f.includes(w)))
++ .sort();
++const TIMEOUT_MS = 60000;
++
++function runOne(file) {
++ return new Promise((resolve) => {
++ const started = Date.now();
++ const child = spawn(process.execPath, [path.join(__dirname, file)], {
++ cwd: path.join(__dirname, '..'),
++ env: { ...process.env, NODE_ENV: 'test' },
++ stdio: ['ignore', 'pipe', 'pipe'],
++ });
++ let output = '';
++ child.stdout.on('data', (c) => { output += c; });
++ child.stderr.on('data', (c) => { output += c; });
++ const timer = setTimeout(() => { output += `\n[run] timed out after ${TIMEOUT_MS}ms`; child.kill('SIGKILL'); }, TIMEOUT_MS);
++ child.on('close', (code, signal) => {
++ clearTimeout(timer);
++ resolve({ file, ok: code === 0, code: code ?? signal, ms: Date.now() - started, output });
++ });
++ });
++}
++
++(async () => {
++ if (!files.length) { console.error('no test files matched'); process.exit(1); }
++ const results = [];
++ for (const f of files) {
++ const r = await runOne(f);
++ results.push(r);
++ console.log(`${r.ok ? '✓' : '✗'} ${r.file.padEnd(32)} ${String(r.ms).padStart(6)}ms`);
++ }
++ const failed = results.filter((r) => !r.ok);
++ for (const r of failed) {
++ console.log(`\n── ${r.file} (exit ${r.code}) ──`);
++ console.log(r.output.split('\n').slice(-60).join('\n'));
++ }
++ console.log(`\n${results.length - failed.length}/${results.length} test files passed`);
++ process.exit(failed.length ? 1 : 0);
++})();
ddiff --git a/test/seo.test.js b/test/seo.test.js
nnew file mode 100644
iindex 0000000..56dbcbf
---- /dev/null
++++ b/test/seo.test.js
@@@ -0,0 +1,62 @@
++'use strict';
++/** robots.txt, sitemap.xml, feed.xml, and the static-asset cache policy. */
++const assert = require('assert');
++const { boot, check, done } = require('./helpers/app');
++
++(async () => {
++ const t = await boot();
++
++ await check('robots.txt allows crawling, blocks the private routes, points at the sitemap', async () => {
++ const r = await t.get('/robots.txt');
++ assert.strictEqual(r.status, 200);
++ assert.ok(r.headers.get('content-type').startsWith('text/plain'));
++ assert.ok(r.text.includes('User-agent: *\nAllow: /'));
++ for (const d of ['/api/', '/auth/', '/my', '/new']) assert.ok(r.text.includes(`Disallow: ${d}`));
++ assert.ok(r.text.includes('Sitemap: https://openvibe.community/sitemap.xml'));
++ });
++
++ await check('sitemap.xml lists home, /pastes and every recent public paste — never unlisted/private', async () => {
++ const r = await t.get('/sitemap.xml');
++ assert.strictEqual(r.status, 200);
++ assert.ok(r.headers.get('content-type').includes('xml'));
++ assert.ok(r.text.startsWith('<?xml version="1.0" encoding="UTF-8"?>'));
++ assert.ok(r.text.includes('<loc>https://openvibe.community/</loc>'));
++ assert.ok(r.text.includes('<loc>https://openvibe.community/pastes</loc>'));
++ assert.ok(r.text.includes('<loc>https://openvibe.community/p/amber-fox-42</loc>'));
++ assert.ok(r.text.includes('<lastmod>2026-09-15</lastmod>'));
++ assert.ok(r.text.includes('/p/fair-moon-23'));
++ assert.ok(!r.text.includes('dark-owl-55') && !r.text.includes('grim-vale-61'));
++ });
++
++ await check('feed.xml is a valid-looking RSS of the latest pastes', async () => {
++ const r = await t.get('/feed.xml');
++ assert.strictEqual(r.status, 200);
++ assert.ok(r.text.includes('<rss version="2.0"'));
++ assert.ok(r.text.includes('<link>https://openvibe.community/p/amber-fox-42</link>'));
++ assert.ok(r.text.includes('<dc:creator'));
++ });
++
++ await check('assets: hashed ?v= is immutable, everything else revalidates; the page references the hashed URLs', async () => {
++ const home = await t.get('/');
++ const m = home.text.match(/\/css\/community\.css\?v=([0-9a-f]{10})/);
++ assert.ok(m, 'hashed css url in page');
++ const hashed = await t.get(`/css/community.css?v=${m[1]}`);
++ assert.strictEqual(hashed.headers.get('cache-control'), 'public, max-age=31536000, immutable');
++ const plain = await t.get('/css/community.css');
++ assert.strictEqual(plain.headers.get('cache-control'), 'no-cache');
++ const fav = await t.get('/favicon.svg');
++ assert.strictEqual(fav.status, 200);
++ const og = await t.get('/og-default.png');
++ assert.strictEqual(og.status, 200);
++ });
++
++ await check('security headers are present on pages', async () => {
++ const r = await t.get('/');
++ assert.ok(r.headers.get('content-security-policy').includes("script-src 'self' 'unsafe-inline' https://openvibe.network"));
++ assert.strictEqual(r.headers.get('x-content-type-options'), 'nosniff');
++ assert.strictEqual(r.headers.get('x-powered-by'), null);
++ });
++
++ await t.close();
++ done();
++})();
ddiff --git a/test/ssr.test.js b/test/ssr.test.js
nnew file mode 100644
iindex 0000000..7830e1a
---- /dev/null
++++ b/test/ssr.test.js
@@@ -0,0 +1,190 @@
++'use strict';
++/** Server-rendered pages: home, browse, paste (SEO head + body), my, new, errors. */
++const assert = require('assert');
++const { boot, check, done } = require('./helpers/app');
++
++(async () => {
++ const t = await boot();
++ const has = (html, s, msg) => assert.ok(html.includes(s), msg || `expected to find: ${s}`);
++
++ await check('home renders hero, latest + trending pastes, CTA and roadmap with full SEO head', async () => {
++ const r = await t.get('/');
++ assert.strictEqual(r.status, 200);
++ has(r.text, '<html lang="en"');
++ has(r.text, '<title>OpenVibe.Community — the people of OpenVibe</title>');
++ has(r.text, '<link rel="canonical" href="https://openvibe.community/">');
++ has(r.text, '<meta property="og:type" content="website">');
++ has(r.text, '<meta name="twitter:card" content="summary">');
++ has(r.text, '"@type":"WebSite"');
++ has(r.text, 'https://openvibe.network/shared/theme-loader.js');
++ has(r.text, 'https://openvibe.network/shared/navbar.js');
++ has(r.text, 'https://openvibe.network/shared/footer.js');
++ has(r.text, '<h1>The people of OpenVibe</h1>');
++ has(r.text, '<h2>Latest pastes</h2>');
++ has(r.text, '<h2>Most viewed</h2>');
++ has(r.text, 'id="coming"');
++ has(r.text, 'href="/p/amber-fox-42"');
++ // Most-viewed puts the 900-view nginx paste first.
++ const trendingAt = r.text.indexOf('id="trending"');
++ assert.ok(r.text.indexOf('blue-lake-17', trendingAt) < r.text.indexOf('amber-fox-42', trendingAt));
++ // Unlisted/private fixtures never show on shared pages.
++ assert.ok(!r.text.includes('dark-owl-55') && !r.text.includes('grim-vale-61'));
++ // Navbar/footer init payload matches the shared-chrome contract.
++ const cfg = JSON.parse(r.text.match(/window\.__OV_PAGE = (.*);\n/)[1]);
++ assert.strictEqual(cfg.navbar.service, 'community');
++ assert.deepStrictEqual(cfg.navbar.links.map((l) => l.href), ['/pastes', '/new']);
++ assert.strictEqual(cfg.navbar.menu.after[0].href, '/my');
++ assert.strictEqual(cfg.navbar.history.type, 'page');
++ assert.strictEqual(cfg.navbar.silentLogin, 'https://openvibe.community/auth/login?silent=1&next={url}');
++ assert.strictEqual(cfg.footer.mount, '#ov-footer');
++ assert.strictEqual(cfg.footer.variant, 'full');
++ // Verbiage: never "free"/"no cost".
++ assert.ok(!/\bfree\b(?! speech)/i.test(r.text.replace(/<script[\s\S]*?<\/script>/g, '')), 'no "free" claims outside "free speech"');
++ });
++
++ await check('paste page: SEO head, Article JSON-LD with author/datePublished, highlighted body, actions, related', async () => {
++ const r = await t.get('/p/amber-fox-42');
++ assert.strictEqual(r.status, 200);
++ has(r.text, '<title>Hello world in JavaScript · OpenVibe.Community</title>');
++ has(r.text, '<link rel="canonical" href="https://openvibe.community/p/amber-fox-42">');
++ has(r.text, '<meta name="robots" content="index,follow">');
++ has(r.text, '<meta property="og:type" content="article">');
++ has(r.text, 'article:published_time');
++ const ld = r.text.match(/<script type="application\/ld\+json">(.*?)<\/script>/g).map((s) => JSON.parse(s.replace(/<script[^>]*>|<\/script>/g, '')));
++ const article = ld.find((o) => o['@type'] === 'Article');
++ assert.ok(article, 'Article JSON-LD present');
++ assert.strictEqual(article.author.name, 'Alex');
++ assert.strictEqual(article.datePublished, '2026-09-15T10:00:00.000Z');
++ assert.strictEqual(article.hasPart.programmingLanguage, 'javascript');
++ assert.ok(ld.some((o) => o['@type'] === 'BreadcrumbList'));
++ has(r.text, 'class="hljs-keyword">const</span>', 'server-side highlighted');
++ has(r.text, 'href="/p/amber-fox-42/raw"');
++ has(r.text, 'href="/p/amber-fox-42/download"');
++ has(r.text, 'data-copy-content="amber-fox-42"');
++ has(r.text, 'twitter.com/intent/tweet');
++ has(r.text, '<h2>More like this</h2>');
++ has(r.text, 'https://openvibe.live/data/avatars/alex.png', 'relative Live avatar made absolute');
++ const cfg = JSON.parse(r.text.match(/window\.__OV_PAGE = (.*);\n/)[1]);
++ assert.strictEqual(cfg.navbar.history.type, 'paste');
++ assert.strictEqual(cfg.navbar.history.title, 'Hello world in JavaScript');
++ assert.strictEqual(cfg.footer.variant, 'compact');
++ // The view was counted by Live (the SSR fetch had no no_view flag).
++ const call = t.live.calls.find((c) => c.path === '/api/pastes/amber-fox-42');
++ assert.strictEqual(call.query.no_view, undefined);
++ });
++
++ await check('unlisted paste is noindex and its content is escaped', async () => {
++ const r = await t.get('/p/dark-owl-55');
++ assert.strictEqual(r.status, 200);
++ has(r.text, '<meta name="robots" content="noindex,follow">');
++ has(r.text, '&lt;script&gt;alert(1)&lt;/script&gt;');
++ assert.ok(!r.text.includes('<script>alert(1)</script>'));
++ });
++
++ await check('screenshot paste uses the Media image as og:image and ImageObject JSON-LD', async () => {
++ const r = await t.get('/p/fair-moon-23');
++ has(r.text, '<meta property="og:image" content="https://openvibe.media/p/fair-moon-23/screenshot">');
++ has(r.text, '<meta name="twitter:card" content="summary_large_image">');
++ has(r.text, '"@type":"ImageObject"');
++ has(r.text, '<img src="https://openvibe.media/p/fair-moon-23/screenshot"');
++ });
++
++ await check('private paste 404s for anonymous visitors and renders for its owner', async () => {
++ const anon = await t.get('/p/grim-vale-61');
++ assert.strictEqual(anon.status, 404);
++ has(anon.text, 'Paste not found');
++ const token = t.network.sign({ id: 7, username: 'alex', display_name: 'Alex' });
++ const owner = await t.get('/p/grim-vale-61', { cookies: [`ov_token=${token}`] });
++ assert.strictEqual(owner.status, 200);
++ has(owner.text, 'data-delete="grim-vale-61"', 'owner sees delete');
++ const call = t.live.calls.filter((c) => c.path === '/api/pastes/grim-vale-61').pop();
++ assert.strictEqual(call.headers.authorization, `Bearer ${token}`, 'visitor token forwarded to Live');
++ });
++
++ await check('raw / screenshot bounce to Media; download serves the text as an attachment without a view', async () => {
++ const raw = await t.get('/p/amber-fox-42/raw');
++ assert.strictEqual(raw.status, 302);
++ assert.strictEqual(raw.headers.get('location'), 'https://openvibe.media/p/amber-fox-42/raw');
++ const shot = await t.get('/p/fair-moon-23/screenshot');
++ assert.strictEqual(shot.headers.get('location'), 'https://openvibe.media/p/fair-moon-23/screenshot');
++ const dl = await t.get('/p/amber-fox-42/download');
++ assert.strictEqual(dl.status, 200);
++ assert.strictEqual(dl.headers.get('content-disposition'), 'attachment; filename="amber-fox-42.js"');
++ assert.ok(dl.text.startsWith('const greet'));
++ const call = t.live.calls.filter((c) => c.path === '/api/pastes/amber-fox-42').pop();
++ assert.strictEqual(call.query.no_view, '1');
++ });
++
++ await check('browse: search passes through to Live, views sort and language filter work, search pages are noindex', async () => {
++ const all = await t.get('/pastes');
++ assert.strictEqual(all.status, 200);
++ has(all.text, '<link rel="canonical" href="https://openvibe.community/pastes">');
++ has(all.text, 'href="/p/blue-lake-17"');
++ const q = await t.get('/pastes?q=nginx');
++ has(q.text, '<meta name="robots" content="noindex,follow">');
++ has(q.text, 'href="/p/blue-lake-17"');
++ assert.ok(!q.text.includes('href="/p/amber-fox-42"'));
++ const call = t.live.calls.filter((c) => c.path === '/api/pastes' && c.query.search).pop();
++ assert.strictEqual(call.query.search, 'nginx');
++ const views = await t.get('/pastes?sort=views');
++ const res = views.text.slice(views.text.indexOf('id="results"'));
++ assert.ok(res.indexOf('blue-lake-17') < res.indexOf('amber-fox-42') && res.indexOf('amber-fox-42') < res.indexOf('cold-ridge-88'));
++ const py = await t.get('/pastes?lang=python');
++ has(py.text, 'href="/p/cold-ridge-88"');
++ assert.ok(!py.text.includes('href="/p/amber-fox-42"'));
++ has(py.text, '<option value="python" selected>');
++ const old = await t.get('/pastes/amber-fox-42');
++ assert.strictEqual(old.status, 301);
++ });
++
++ await check('/my redirects anonymous visitors to sign-in and lists the signed-in user\'s pastes (incl. unlisted/private)', async () => {
++ const anon = await t.get('/my');
++ assert.strictEqual(anon.status, 302);
++ assert.strictEqual(anon.headers.get('location'), '/auth/login?next=%2Fmy');
++ const token = t.network.sign({ id: 7, username: 'alex', display_name: 'Alex' });
++ const mine = await t.get('/my', { cookies: [`ov_token=${token}`] });
++ assert.strictEqual(mine.status, 200);
++ has(mine.text, '<meta name="robots" content="noindex,nofollow">');
++ for (const slug of ['amber-fox-42', 'cold-ridge-88', 'dark-owl-55', 'grim-vale-61']) has(mine.text, `href="/p/${slug}"`);
++ assert.ok(!mine.text.includes('blue-lake-17'));
++ const call = t.live.calls.filter((c) => c.path === '/api/pastes/by-user/alex').pop();
++ assert.strictEqual(call.headers.authorization, `Bearer ${token}`);
++ });
++
++ await check('/new renders the form (private option only when signed in) and the no-JS post creates through Live', async () => {
++ const anon = await t.get('/new');
++ assert.strictEqual(anon.status, 200);
++ has(anon.text, 'name="content"');
++ assert.ok(!anon.text.includes('value="private"'));
++ const token = t.network.sign({ id: 7, username: 'alex', display_name: 'Alex' });
++ const signed = await t.get('/new?fork=amber-fox-42', { cookies: [`ov_token=${token}`] });
++ has(signed.text, 'value="private"');
++ has(signed.text, 'Fork of Hello world in JavaScript');
++ const body = new URLSearchParams({ title: 'via form', language: 'python', content: 'print(1)', visibility: 'public' }).toString();
++ const post = await t.get('/new', { method: 'POST', body, headers: { 'content-type': 'application/x-www-form-urlencoded' }, cookies: [`ov_token=${token}`] });
++ assert.strictEqual(post.status, 303);
++ assert.strictEqual(post.headers.get('location'), '/p/new-paste-99');
++ const call = t.live.calls.filter((c) => c.method === 'POST' && c.path === '/api/pastes').pop();
++ assert.strictEqual(call.headers.authorization, `Bearer ${token}`);
++ assert.deepStrictEqual(JSON.parse(call.body.toString()).content, 'print(1)');
++ const empty = await t.get('/new', { method: 'POST', body: 'title=x', headers: { 'content-type': 'application/x-www-form-urlencoded' } });
++ assert.strictEqual(empty.status, 400);
++ has(empty.text, 'content is empty');
++ const limited = await t.get('/new', { method: 'POST', body: new URLSearchParams({ title: 'slow down', content: 'x' }).toString(), headers: { 'content-type': 'application/x-www-form-urlencoded' } });
++ assert.strictEqual(limited.status, 429);
++ has(limited.text, 'Please wait 30s');
++ });
++
++ await check('404 page and API 404 JSON; health', async () => {
++ const r = await t.get('/nope');
++ assert.strictEqual(r.status, 404);
++ has(r.text, 'Page not found');
++ const a = await t.get('/api/nope');
++ assert.strictEqual(a.status, 404);
++ assert.deepStrictEqual(a.json(), { error: 'Not found' });
++ assert.strictEqual((await t.get('/api/health')).json().service, 'openvibe-community');
++ });
++
++ await t.close();
++ done();
++})();