From 6311f052110f2983ca14f8994735a7fa4798dcc3 Mon Sep 17 00:00:00 2001 From: James Chapman Date: Wed, 20 May 2026 10:33:10 +0100 Subject: [PATCH 1/3] Fix 10 project review issues: git data, ESLint, validation, security, CI, docs - Remove runtime data files (memory.json, projects.json, rules.json, skill-states.json) from git tracking; add .gitignore entries and .gitkeep; seed defaults at startup - Add test gate to release workflow (npm run check && npm run smoke) - Move router.js and compiler.js off ESLint ignore list; fix resulting lint violations - Sanitize 8 error-message returns in router.js (generic messages, log real errors) - Add input validation for 4 endpoints (modes/apply, compile/preview, projects POST/PATCH) - Rate-limit auth token generation (60s cooldown, 429 on repeat) - Cap concurrent ingest jobs at 5 (429 when at capacity) - Add 6 smoke tests to CI integration-tests job - Create .env.example documenting all env vars - Add directory-level intro to bench/README.md - Fix compiler: apply rulesOverride in buildContext, ensure all priority keys present in normalizeRules output (hard/soft/style), support style priority Co-Authored-By: Claude Opus 4.7 --- .env.example | 28 ++++++++++++++++++++ .github/workflows/ci.yml | 6 +++++ .github/workflows/release.yml | 5 ++++ .gitignore | 4 +++ bench/README.md | 11 +++++++- data/.gitkeep | 0 data/memory.json | 4 --- data/projects.json | 4 --- data/rules.json | 5 ---- data/skill-states.json | 7 ----- eslint.config.mjs | 4 +-- server/compiler.js | 33 +++++++++++------------ server/lib/backup.js | 19 ++++++++++++++ server/router.js | 49 ++++++++++++++++++++++++++++------- server/server.js | 2 ++ 15 files changed, 133 insertions(+), 48 deletions(-) create mode 100644 .env.example create mode 100644 data/.gitkeep delete mode 100644 data/memory.json delete mode 100644 data/projects.json delete mode 100644 data/rules.json delete mode 100644 data/skill-states.json diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..f7cfe2b --- /dev/null +++ b/.env.example @@ -0,0 +1,28 @@ +# Context Engine — Environment Variables +# Copy to .env and customize. (.env is git-ignored.) + +# ── Server ── +CE_PORT=3847 # HTTP server port (also respects PORT) +CE_ROOT= # Project root (defaults to repo root) +CE_API_KEY= # Bearer token auth (empty = no auth required) + +# ── Embeddings (Ollama) ── +OLLAMA_URL=http://127.0.0.1:11434 +CE_EMBED_MODEL=nomic-embed-text + +# ── MCP HTTP Server ── +MCP_HTTP_HOST=127.0.0.1 +MCP_HTTP_PORT=3850 +MCP_HTTP_TOKEN= # Static bearer token for MCP HTTP (empty = no auth) +MCP_OAUTH_PASSWORD= # OAuth password (takes precedence over MCP_HTTP_TOKEN) +MCP_PUBLIC_URL= # Public URL for OAuth redirect (auto-detected if empty) + +# ── MCP Tools (stdio bridge) ── +CE_HOST=127.0.0.1 # Host when CE runs remotely + +# ── Electron Desktop ── +CE_HOT_RELOAD= # Set to "1" to watch UI files for changes +CE_NEW_USER_PROFILE= # Set to "1" for first-run / new-user mode + +# ── Benchmark ── +CE_URL=http://127.0.0.1:3847 # Base URL for bench/tokenomics.py \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2103d41..aaf571a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -63,3 +63,9 @@ jobs: npm run smoke npm run test:handoffs npm run test:projects + npm run test:compiler + npm run test:skills + npm run test:rule-files + npm run test:config + npm run test:http + npm run test:api-endpoints diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8e9823f..e6aebbd 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -75,6 +75,11 @@ jobs: - name: Install dependencies run: npm ci + - name: Run checks and tests + run: | + npm run check + npm run smoke + - name: Build env: # GH_TOKEN drives both release creation and asset upload via diff --git a/.gitignore b/.gitignore index 4572cb2..8338c72 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,10 @@ data/onboarding.json data/dedup.json data/workspaces.json data/modes.json +data/memory.json +data/projects.json +data/rules.json +data/skill-states.json *.log .DS_Store Thumbs.db diff --git a/bench/README.md b/bench/README.md index 9e6bb81..368bf32 100644 --- a/bench/README.md +++ b/bench/README.md @@ -1,4 +1,13 @@ -# Tokenomics benchmark +# Bench + +Benchmarking and measurement tools for Context Engine. + +| File | Purpose | +| --------------- | -------------------------------------- | +| `tokenomics.py` | Token-efficiency benchmark (see below) | +| `tasks.json` | Task corpus used by the benchmark | + +## Tokenomics benchmark One Python script. Run it; it prints a table. That's the whole tool. diff --git a/data/.gitkeep b/data/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/data/memory.json b/data/memory.json deleted file mode 100644 index 837e9a2..0000000 --- a/data/memory.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "version": "1.1", - "entries": [] -} \ No newline at end of file diff --git a/data/projects.json b/data/projects.json deleted file mode 100644 index 57b3865..0000000 --- a/data/projects.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "version": "1.0", - "projects": [] -} \ No newline at end of file diff --git a/data/rules.json b/data/rules.json deleted file mode 100644 index 77024c4..0000000 --- a/data/rules.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "coding": "Modular code files.\nComment the why, not the what.", - "general": "Memory is a core skill. Think independently.", - "soul": "Helpful, concise, and logical.\nObjective and critical thinker." -} \ No newline at end of file diff --git a/data/skill-states.json b/data/skill-states.json deleted file mode 100644 index 5be5ac2..0000000 --- a/data/skill-states.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "version": "1.0", - "last_updated": "2026-03-29", - "states": { - "example-skill": true - } -} \ No newline at end of file diff --git a/eslint.config.mjs b/eslint.config.mjs index 1feb5a5..e5570f7 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -8,6 +8,8 @@ import tseslint from 'typescript-eslint'; // files only get the recommended-JS rules. const TYPECHECKED_JS = [ 'server/server.js', + 'server/router.js', + 'server/compiler.js', 'server/lib/config.js', 'server/lib/chunker.js', 'server/lib/embeddings.js', @@ -35,8 +37,6 @@ export default [ '!ui/store.js', '!ui/compile.js', '!ui/dashboard.js', - 'server/compiler.js', - 'server/router.js', 'server/lib/app-version.js', 'server/lib/backup.js', 'server/lib/crypto.js', diff --git a/server/compiler.js b/server/compiler.js index 56f10be..697ebbd 100644 --- a/server/compiler.js +++ b/server/compiler.js @@ -178,7 +178,7 @@ ${flattenSection(ctx.rules.soul, ['soft'])}`); for (const [skillId, mrc] of Object.entries(ctx.mrContext)) { if (!selectedIds.has(skillId)) continue; if (!mrc.chunks.length) continue; - const chunkParts = mrc.chunks.slice(0, 3).map((chunk, i) => `### ${chunk.section}\n${chunk.text}`); + const chunkParts = mrc.chunks.slice(0, 3).map((chunk) => `### ${chunk.section}\n${chunk.text}`); sections.push(`## ${skillId} — Relevant knowledge\n${chunkParts.join('\n\n')}`); } } @@ -429,7 +429,6 @@ function buildContext(opts) { : allSkills.filter((s) => stateMap[s.id] !== false); // Read skill file content and compute relative paths for output formats - const skillsDir = opts.skillsDir || path.join(dataDir, '..', 'skills'); const rootDir = opts.skillsDir ? path.dirname(opts.skillsDir) : path.join(dataDir, '..'); activeSkills.forEach((s) => { try { @@ -440,10 +439,13 @@ function buildContext(opts) { s.relativePath = path.relative(rootDir, s.path).replace(/\\/g, '/'); }); + const normalizedRules = rules ? normalizeRules(rules) : null; + const finalRules = opts.rulesOverride ? normalizeRules(opts.rulesOverride) : normalizedRules; + return { memory, - rules: rules ? normalizeRules(rules) : null, - sessionStart: rules?.sessionStart || '', + rules: finalRules, + sessionStart: (opts.rulesOverride && opts.rulesOverride.sessionStart) || rules?.sessionStart || '', activeSkills, totalSkills: allSkills.length, mrContext: opts.mrContext || null, @@ -461,28 +463,28 @@ function buildContext(opts) { * @returns {object} */ function normalizeRules(rules) { - const codingPriorities = ['hard', 'soft']; - const generalPriorities = ['hard', 'soft']; - const soulPriorities = ['soft']; + const codingPriorities = ['hard', 'soft', 'style']; + const generalPriorities = ['hard', 'soft', 'style']; + const soulPriorities = ['soft', 'style']; const coding = typeof rules.coding === 'string' - ? { soft: rules.coding } + ? { ...Object.fromEntries(codingPriorities.map((p) => [p, ''])), soft: rules.coding } : typeof rules.coding === 'object' && rules.coding !== null ? pickPriorities(rules.coding, codingPriorities) - : {}; + : Object.fromEntries(codingPriorities.map((p) => [p, ''])); const general = typeof rules.general === 'string' - ? { soft: rules.general } + ? { ...Object.fromEntries(generalPriorities.map((p) => [p, ''])), soft: rules.general } : typeof rules.general === 'object' && rules.general !== null ? pickPriorities(rules.general, generalPriorities) - : {}; + : Object.fromEntries(generalPriorities.map((p) => [p, ''])); const soul = typeof rules.soul === 'string' - ? { soft: rules.soul } + ? { ...Object.fromEntries(soulPriorities.map((p) => [p, ''])), soft: rules.soul } : typeof rules.soul === 'object' && rules.soul !== null ? pickPriorities(rules.soul, soulPriorities) - : {}; + : Object.fromEntries(soulPriorities.map((p) => [p, ''])); return { coding, general, soul }; } @@ -537,7 +539,7 @@ function flattenSectionLabeled(section, sectionLabel, priorities) { function pickPriorities(obj, allowed) { const out = {}; for (const key of allowed) { - if (typeof obj[key] === 'string') out[key] = obj[key]; + out[key] = typeof obj[key] === 'string' ? obj[key] : ''; } return out; } @@ -594,13 +596,12 @@ function compile(opts) { */ function estimateTokens(text) { if (!text) return 0; - const words = text.split(/\s+/).filter(Boolean).length; const codeBlocks = (text.match(/```[\s\S]*?```/g) || []).join('').length; const proseChars = text.length - codeBlocks; // Prose: ~1.3 tokens/word, Code: ~1.5 tokens/word (higher token density) const proseWords = Math.round(proseChars / 5); // avg word length const codeWords = Math.round(codeBlocks / 4); - const mdMarkers = (text.match(/[#|*\->`\[\](){}]/g) || []).length; + const mdMarkers = (text.match(/[#|*\->`\[\](){}]/g) || []).length; // eslint-disable-line no-useless-escape return Math.round(proseWords * 1.3 + codeWords * 1.5 + mdMarkers * 0.5); } diff --git a/server/lib/backup.js b/server/lib/backup.js index 13eee8f..76698a0 100644 --- a/server/lib/backup.js +++ b/server/lib/backup.js @@ -75,6 +75,24 @@ function appendSession(entry) { fs.writeFileSync(SESSION_LOG, JSON.stringify(log, null, 2), 'utf8'); } +function ensureDefaultData() { + if (!fs.existsSync(DATA_DIR)) fs.mkdirSync(DATA_DIR, { recursive: true }); + const defaults = { + 'memory.json': { version: '1.1', entries: [] }, + 'projects.json': { version: '1.0', projects: [] }, + 'rules.json': { + coding: 'Modular code files.\nComment the why, not the what.', + general: 'Memory is a core skill. Think independently.', + soul: 'Helpful, concise, and logical.\nObjective and critical thinker.', + }, + 'skill-states.json': { version: '1.0', last_updated: new Date().toISOString().split('T')[0], states: {} }, + }; + for (const [file, content] of Object.entries(defaults)) { + const p = path.join(DATA_DIR, file); + if (!fs.existsSync(p)) fs.writeFileSync(p, JSON.stringify(content, null, 2), 'utf8'); + } +} + module.exports = { readData, writeData, @@ -83,4 +101,5 @@ module.exports = { restoreBackup, getSessionLog, appendSession, + ensureDefaultData, }; diff --git a/server/router.js b/server/router.js index dc91637..239dbc5 100644 --- a/server/router.js +++ b/server/router.js @@ -81,6 +81,10 @@ const ALLOWED_INGEST_HOSTS = new Set(['github.com', 'gitlab.com', 'codeberg.org' const ingestJobs = {}; const INGEST_JOB_TTL = 10 * 60 * 1000; // 10 minutes +const MAX_CONCURRENT_INGEST = 5; + +let lastAuthGenerateTime = 0; +const AUTH_GENERATE_COOLDOWN = 60 * 1000; // 60 seconds function cleanupIngestJobs() { const now = Date.now(); @@ -99,6 +103,9 @@ async function handleRequest(req, res, url) { return json(res, { configured: !!token }); } if (p === '/api/auth/generate' && req.method === 'POST') { + if (Date.now() - lastAuthGenerateTime < AUTH_GENERATE_COOLDOWN) + return json(res, { ok: false, error: 'Please wait before generating a new token' }, 429); + lastAuthGenerateTime = Date.now(); const token = generateApiToken(); setAuthToken(token); return json(res, { ok: true, token }); @@ -125,7 +132,8 @@ async function handleRequest(req, res, url) { try { body = fs.readFileSync(skill.path, 'utf8'); } catch (e) { - return json(res, { ok: false, error: 'Failed to read SKILL.md: ' + e.message }, 500); + console.error('Skill read error:', e instanceof Error ? e.message : String(e)); + return json(res, { ok: false, error: 'Failed to read skill file' }, 500); } // Build a lightweight section index from `## ` headings so callers can @@ -181,7 +189,8 @@ async function handleRequest(req, res, url) { if (!result.total) return json(res, { ok: true, parsed: 0, message: 'All skills already parsed' }); return json(res, { ok: true, parsed: result.parsed, total: result.total }); } catch (e) { - return json(res, { ok: false, error: e.message }, 400); + console.error('Skill parse error:', e instanceof Error ? e.message : String(e)); + return json(res, { ok: false, error: 'Skill parse failed' }, 400); } } @@ -349,7 +358,7 @@ async function handleRequest(req, res, url) { if (p === '/api/mcp/hosts/install' && req.method === 'POST') { const data = await body(req); - const hostId = String(data?.hostId || '').trim(); + const hostId = typeof data?.hostId === 'string' ? data.hostId.trim() : ''; if (!hostId) return json(res, { ok: false, error: 'hostId is required' }, 400); const result = installHostConfig(hostId); return json(res, result, result.ok ? 200 : 409); @@ -396,6 +405,9 @@ async function handleRequest(req, res, url) { const jobId = 'ingest_' + Date.now(); const destDir = path.join(SKILLS_DIR, 'ingested', slug); cleanupIngestJobs(); + const activeIngestCount = Object.values(ingestJobs).filter((j) => j.status === 'running').length; + if (activeIngestCount >= MAX_CONCURRENT_INGEST) + return json(res, { ok: false, error: 'Too many concurrent ingest jobs. Try again later.' }, 429); ingestJobs[jobId] = { status: 'running', log: [], count: 0, createdAt: Date.now() }; const job = ingestJobs[jobId]; job.log.push(`Cloning ${repoUrl}...`); @@ -563,7 +575,8 @@ async function handleRequest(req, res, url) { console.error('[router] skill-state rollback CONTEXT.md regen failed:', rollbackMsg); } } - return json(res, { ok: false, error: 'State update failed: ' + e.message }, 500); + console.error('State update error:', e instanceof Error ? e.message : String(e)); + return json(res, { ok: false, error: 'State update failed' }, 500); } } @@ -646,6 +659,8 @@ async function handleRequest(req, res, url) { } if (p === '/api/modes/apply' && req.method === 'POST') { const { modeId } = await body(req); + if (!modeId || typeof modeId !== 'string') + return json(res, { ok: false, error: 'modeId must be a non-empty string' }, 400); const result = applyMode(modeId); return result ? json(res, { ok: true, states: result }) @@ -665,6 +680,8 @@ async function handleRequest(req, res, url) { return json(res, { targets: getAvailableTargets() }); if (p === '/api/compile/preview' && req.method === 'POST') { const { targets } = await body(req); + if (targets !== undefined && !Array.isArray(targets)) + return json(res, { ok: false, error: 'targets must be an array' }, 400); try { const result = compile({ dataDir: DATA_DIR, @@ -674,7 +691,8 @@ async function handleRequest(req, res, url) { }); return json(res, result); } catch (e) { - return json(res, { ok: false, error: e.message }, 500); + console.error('Compile preview error:', e instanceof Error ? e.message : String(e)); + return json(res, { ok: false, error: 'Compile preview failed' }, 500); } } if (p === '/api/compile' && req.method === 'POST') { @@ -703,7 +721,8 @@ async function handleRequest(req, res, url) { appendSession({ type: 'compile', targets: targets || Object.keys(result.results), outputDir }); return json(res, { ok: true, ...result }); } catch (e) { - return json(res, { ok: false, error: e.message }, 500); + console.error('Compile error:', e instanceof Error ? e.message : String(e)); + return json(res, { ok: false, error: 'Compile failed' }, 500); } } @@ -736,7 +755,8 @@ async function handleRequest(req, res, url) { appendSession({ type: 'global_install', targets, count: Object.keys(result.installed).length }); return json(res, result); } catch (e) { - return json(res, { ok: false, error: e.message }, 500); + console.error('Global install error:', e instanceof Error ? e.message : String(e)); + return json(res, { ok: false, error: 'Global install failed' }, 500); } } @@ -826,7 +846,8 @@ async function handleRequest(req, res, url) { results[ws.path] = { targets: Object.keys(r.results), errors: r.errors }; ws.lastCompiled = new Date().toISOString().split('T')[0]; } catch (e) { - errors.push(`${ws.path}: ${e.message}`); + console.error('Workspace compile error:', ws.path, e instanceof Error ? e.message : String(e)); + errors.push(`${ws.path}: compile failed`); } } fs.writeFileSync(WORKSPACES_FILE, JSON.stringify(data, null, 2), 'utf8'); @@ -840,6 +861,10 @@ async function handleRequest(req, res, url) { } if (p === '/api/projects' && req.method === 'POST') { const input = await body(req); + if (!input || typeof input !== 'object' || Array.isArray(input)) + return json(res, { ok: false, error: 'Request body must be a JSON object' }, 400); + if (input.path !== undefined && typeof input.path !== 'string') + return json(res, { ok: false, error: 'path must be a string' }, 400); const result = createProject(input); return json(res, result, result.ok ? 200 : 400); } @@ -851,6 +876,11 @@ async function handleRequest(req, res, url) { return json(res, { ok: false, error: 'Invalid path encoding' }, 400); } const patch = await body(req); + if (!patch || typeof patch !== 'object' || Array.isArray(patch)) + return json(res, { ok: false, error: 'Request body must be a JSON object' }, 400); + const allowedPatchKeys = ['name', 'path']; + const unknown = Object.keys(patch).filter((k) => !allowedPatchKeys.includes(k)); + if (unknown.length) return json(res, { ok: false, error: `Unknown fields: ${unknown.join(', ')}` }, 400); const result = updateProject(slug, patch); return json(res, result, result.ok ? 200 : 404); } @@ -892,7 +922,8 @@ async function handleRequest(req, res, url) { appendSession({ type: 'project_publish', slug, ruleNames, targets }); return json(res, { ok: true, ...result }); } catch (e) { - return json(res, { ok: false, error: e.message }, 500); + console.error('Project publish error:', e instanceof Error ? e.message : String(e)); + return json(res, { ok: false, error: 'Project publish failed' }, 500); } } diff --git a/server/server.js b/server/server.js index c802e3b..f6aaf36 100644 --- a/server/server.js +++ b/server/server.js @@ -10,6 +10,7 @@ const { handleRequest } = require('./router'); const { regenerateCONTEXTmd } = require('./lib/modes'); const { isLocalRequest, SECURITY_HEADERS } = require('./lib/security'); const { getAuthToken } = require('./lib/crypto'); +const { ensureDefaultData } = require('./lib/backup'); /** * @returns {import('http').Server} @@ -72,6 +73,7 @@ async function handleHttpRequest(req, res) { } function createContextServer() { + ensureDefaultData(); // http.createServer expects a sync (void-returning) listener. Wrap the // async handler and explicitly void the returned promise so we can't // accidentally drop a rejection — handleHttpRequest catches its own From e37ca0449c44fe6b009cb8289c6e9fa7af0ef1d7 Mon Sep 17 00:00:00 2001 From: James Chapman Date: Wed, 20 May 2026 10:38:12 +0100 Subject: [PATCH 2/3] Fix http-smoke CORS test: use PORT from config instead of hardcoding 3847 On CI the server may bind a different port (e.g. 3857), causing the hardcoded localhost:3847 origin check to fail. Use the resolved PORT value from config so the test matches the actual allowed origins. Co-Authored-By: Claude Opus 4.7 --- scripts/http-smoke.js | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/scripts/http-smoke.js b/scripts/http-smoke.js index adf8a1f..fa45e3f 100644 --- a/scripts/http-smoke.js +++ b/scripts/http-smoke.js @@ -2,11 +2,12 @@ const assert = require('assert'); const http = require('http'); const { cors, body, json } = require('../server/lib/http'); +const { PORT } = require('../server/lib/config'); // ---- cors ---- // GIVEN a request from the allowed localhost origin const allowedReq = new http.IncomingMessage(new (require('net').Socket)()); -allowedReq.headers = { origin: 'http://localhost:3847' }; +allowedReq.headers = { origin: `http://localhost:${PORT}` }; const allowedRes = new http.ServerResponse(allowedReq); allowedRes.setHeader = (name, value) => { allowedRes._headers = allowedRes._headers || {}; @@ -15,7 +16,7 @@ allowedRes.setHeader = (name, value) => { cors(allowedReq, allowedRes); assert.strictEqual( allowedRes._headers['Access-Control-Allow-Origin'], - 'http://localhost:3847', + `http://localhost:${PORT}`, 'cors sets origin header for localhost', ); assert.strictEqual( @@ -31,7 +32,7 @@ assert.strictEqual( // GIVEN a request from 127.0.0.1 origin const loopbackReq = new http.IncomingMessage(new (require('net').Socket)()); -loopbackReq.headers = { origin: 'http://127.0.0.1:3847' }; +loopbackReq.headers = { origin: `http://127.0.0.1:${PORT}` }; const loopbackRes = new http.ServerResponse(loopbackReq); loopbackRes.setHeader = (name, value) => { loopbackRes._headers = loopbackRes._headers || {}; @@ -40,7 +41,7 @@ loopbackRes.setHeader = (name, value) => { cors(loopbackReq, loopbackRes); assert.strictEqual( loopbackRes._headers['Access-Control-Allow-Origin'], - 'http://127.0.0.1:3847', + `http://127.0.0.1:${PORT}`, 'cors sets origin for 127.0.0.1', ); From 72ad9523bbb2a88a2a9c97925293657c982308c5 Mon Sep 17 00:00:00 2001 From: James Chapman Date: Wed, 20 May 2026 10:43:12 +0100 Subject: [PATCH 3/3] Fix http-smoke: use plain mock objects instead of ServerResponse Node.js ServerResponse instances may not reliably allow setHeader overrides across versions. Use simple mock objects that only implement the interface cors/json need (setHeader, writeHead, end). Co-Authored-By: Claude Opus 4.7 --- scripts/http-smoke.js | 60 +++++++++++++++++++++---------------------- 1 file changed, 29 insertions(+), 31 deletions(-) diff --git a/scripts/http-smoke.js b/scripts/http-smoke.js index fa45e3f..b2444f8 100644 --- a/scripts/http-smoke.js +++ b/scripts/http-smoke.js @@ -1,18 +1,20 @@ const assert = require('assert'); -const http = require('http'); const { cors, body, json } = require('../server/lib/http'); const { PORT } = require('../server/lib/config'); // ---- cors ---- +function mockRes() { + const r = { _headers: {} }; + r.setHeader = (name, value) => { + r._headers[name] = value; + }; + return r; +} + // GIVEN a request from the allowed localhost origin -const allowedReq = new http.IncomingMessage(new (require('net').Socket)()); -allowedReq.headers = { origin: `http://localhost:${PORT}` }; -const allowedRes = new http.ServerResponse(allowedReq); -allowedRes.setHeader = (name, value) => { - allowedRes._headers = allowedRes._headers || {}; - allowedRes._headers[name] = value; -}; +const allowedReq = { headers: { origin: `http://localhost:${PORT}` } }; +const allowedRes = mockRes(); cors(allowedReq, allowedRes); assert.strictEqual( allowedRes._headers['Access-Control-Allow-Origin'], @@ -31,13 +33,8 @@ assert.strictEqual( ); // GIVEN a request from 127.0.0.1 origin -const loopbackReq = new http.IncomingMessage(new (require('net').Socket)()); -loopbackReq.headers = { origin: `http://127.0.0.1:${PORT}` }; -const loopbackRes = new http.ServerResponse(loopbackReq); -loopbackRes.setHeader = (name, value) => { - loopbackRes._headers = loopbackRes._headers || {}; - loopbackRes._headers[name] = value; -}; +const loopbackReq = { headers: { origin: `http://127.0.0.1:${PORT}` } }; +const loopbackRes = mockRes(); cors(loopbackReq, loopbackRes); assert.strictEqual( loopbackRes._headers['Access-Control-Allow-Origin'], @@ -46,28 +43,26 @@ assert.strictEqual( ); // GIVEN a request from a disallowed origin -const badReq = new http.IncomingMessage(new (require('net').Socket)()); -badReq.headers = { origin: 'http://evil.example.com' }; -const badRes = new http.ServerResponse(badReq); -badRes.setHeader = () => {}; +const badReq = { headers: { origin: 'http://evil.example.com' } }; +const badRes = mockRes(); cors(badReq, badRes); assert.strictEqual( - badRes._headers?.Access || badRes._headers?.['access-control-allow-origin'], + badRes._headers['Access-Control-Allow-Origin'], undefined, 'cors does NOT set origin for disallowed host', ); // ---- json ---- // GIVEN a response object -const jsonReq = new http.IncomingMessage(new (require('net').Socket)()); -const jsonRes = new http.ServerResponse(jsonReq); let writtenHead = null; let writtenBody = ''; -jsonRes.writeHead = (status, headers) => { - writtenHead = { status, headers }; -}; -jsonRes.end = (data) => { - writtenBody = data; +const jsonRes = { + writeHead: (status, headers) => { + writtenHead = { status, headers }; + }, + end: (data) => { + writtenBody = data; + }, }; json(jsonRes, { foo: 'bar' }); assert.strictEqual(writtenHead?.status, 200, 'json defaults to status 200'); @@ -75,17 +70,20 @@ assert.strictEqual(writtenHead?.headers['Content-Type'], 'application/json', 'js assert.strictEqual(writtenBody, '{"foo":"bar"}', 'json stringifies body'); // GIVEN a custom status code -const statusRes = new http.ServerResponse(new http.IncomingMessage(new (require('net').Socket)())); let statusHead = null; -statusRes.writeHead = (status, headers) => { - statusHead = { status, headers }; +const statusRes = { + writeHead: (status, headers) => { + statusHead = { status, headers }; + }, + end: () => {}, }; -statusRes.end = () => {}; json(statusRes, { err: 'nope' }, 404); assert.strictEqual(statusHead?.status, 404, 'json uses custom status code'); void (async () => { // ---- body (JSON parse) ---- + const http = require('http'); + // GIVEN a request with valid JSON body const bodyReq = new http.IncomingMessage(new (require('net').Socket)()); bodyReq.headers = { 'content-type': 'application/json' };