Static frontend with vendored marked/DOMPurify, bounded Markdown pipeline, same-origin mock server with scenario selection, unit, contract and CDP end-to-end tests under frontend/**.
304 lines
10 KiB
JavaScript
304 lines
10 KiB
JavaScript
/**
|
|
* Contract and Mock Server tests.
|
|
* Validates wire formats, security headers, session cookies, and scenarios against CONTRACTS.md.
|
|
*/
|
|
|
|
import { test, describe, before, after } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import http from 'node:http';
|
|
import { createMockServer, SCENARIOS } from '../dev/mock-server.js';
|
|
|
|
const TEST_PORT = 5199;
|
|
const BASE_URL = `http://127.0.0.1:${TEST_PORT}`;
|
|
|
|
describe('Mock Server and Wire Contract Tests', () => {
|
|
let server;
|
|
|
|
before(async () => {
|
|
server = createMockServer();
|
|
await new Promise((resolve) => {
|
|
server.listen(TEST_PORT, '127.0.0.1', resolve);
|
|
});
|
|
});
|
|
|
|
after(async () => {
|
|
await new Promise((resolve) => {
|
|
server.close(resolve);
|
|
});
|
|
});
|
|
|
|
test('GET / sets cw_session cookie and serves security headers', async () => {
|
|
const res = await fetch(`${BASE_URL}/`);
|
|
assert.equal(res.status, 200);
|
|
|
|
// Security headers
|
|
assert.equal(res.headers.get('x-content-type-options'), 'nosniff');
|
|
assert.equal(res.headers.get('referrer-policy'), 'no-referrer');
|
|
const csp = res.headers.get('content-security-policy');
|
|
assert.ok(csp.includes("default-src 'none'"));
|
|
assert.ok(csp.includes("script-src 'self'"));
|
|
assert.ok(csp.includes("style-src 'self'"));
|
|
assert.ok(csp.includes("connect-src 'self'"));
|
|
assert.ok(csp.includes("img-src 'none'"));
|
|
|
|
// Session cookie
|
|
const cookie = res.headers.get('set-cookie');
|
|
assert.ok(cookie, 'Set-Cookie header must be present');
|
|
assert.ok(cookie.includes('cw_session='), 'Cookie name must be cw_session');
|
|
assert.ok(cookie.includes('HttpOnly'), 'Cookie must be HttpOnly');
|
|
assert.ok(cookie.includes('SameSite=Strict'), 'Cookie must be SameSite=Strict');
|
|
});
|
|
|
|
test('POST /api/v1/auth/verify succeeds with dummy credentials', async () => {
|
|
const res = await fetch(`${BASE_URL}/api/v1/auth/verify`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'Origin': BASE_URL
|
|
},
|
|
body: JSON.stringify({
|
|
url: 'https://confluence.example.com',
|
|
pat: 'dummy-pat-token-123'
|
|
})
|
|
});
|
|
|
|
assert.equal(res.status, 200);
|
|
assert.equal(res.headers.get('cache-control'), 'no-store');
|
|
const body = await res.json();
|
|
assert.deepEqual(body, { valid: true });
|
|
});
|
|
|
|
test('POST /api/v1/auth/verify fails with 403 in 403_verify scenario', async () => {
|
|
const res = await fetch(`${BASE_URL}/api/v1/auth/verify?scenario=403_verify`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'Origin': BASE_URL
|
|
},
|
|
body: JSON.stringify({
|
|
url: 'https://confluence.example.com',
|
|
pat: 'dummy-pat-token-123'
|
|
})
|
|
});
|
|
|
|
assert.equal(res.status, 403);
|
|
const body = await res.json();
|
|
assert.ok(body.error);
|
|
assert.equal(body.error.code, 'confluence_auth_failed');
|
|
});
|
|
|
|
test('POST /api/v1/query handles normal scenario matching section 7 shared contract', async () => {
|
|
const res = await fetch(`${BASE_URL}/api/v1/query?scenario=normal`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'Origin': BASE_URL
|
|
},
|
|
body: JSON.stringify({
|
|
prompt: 'deploy service X',
|
|
credentials: {
|
|
url: 'https://approved.example.com',
|
|
pat: 'dummy-pat-123'
|
|
}
|
|
})
|
|
});
|
|
|
|
assert.equal(res.status, 200);
|
|
assert.equal(res.headers.get('cache-control'), 'no-store');
|
|
const data = await res.json();
|
|
|
|
assert.ok(data.session_id);
|
|
assert.ok(data.markdown.includes('Deployment Guide'));
|
|
assert.equal(data.pages_accessed.length, 1);
|
|
assert.equal(data.pages_accessed[0].page_id, '847291');
|
|
assert.equal(data.pages_accessed[0].space, 'OPS');
|
|
assert.ok(data.pages_accessed[0].accessed_at);
|
|
|
|
assert.equal(data.tool_history.length, 2);
|
|
assert.equal(data.tool_history[0].tool, 'confluence_search');
|
|
assert.equal(data.tool_history[1].tool, 'confluence_view');
|
|
assert.equal(data.tool_history[1].cache_hit, false);
|
|
|
|
assert.equal(data.artifacts.length, 1);
|
|
assert.equal(data.artifacts[0].name, 'checklist.md');
|
|
assert.equal(data.artifacts[0].size_bytes, 32);
|
|
assert.ok(data.artifacts[0].expires_at);
|
|
});
|
|
|
|
test('GET /api/v1/artifacts/:id downloads exact 32 bytes for checklist.md', async () => {
|
|
const res = await fetch(`${BASE_URL}/api/v1/artifacts/art-checklist-01`);
|
|
assert.equal(res.status, 200);
|
|
assert.equal(res.headers.get('content-type'), 'application/octet-stream');
|
|
assert.equal(res.headers.get('x-content-type-options'), 'nosniff');
|
|
assert.ok(res.headers.get('content-disposition').includes('attachment; filename="checklist.md"'));
|
|
|
|
const text = await res.text();
|
|
assert.equal(text, '# Checklist\n\n- Deploy service X\n');
|
|
assert.equal(Buffer.byteLength(text, 'utf-8'), 32);
|
|
});
|
|
|
|
test('GET /api/v1/artifacts/:id returns 404 in unknown_expired_download scenario', async () => {
|
|
const res = await fetch(`${BASE_URL}/api/v1/artifacts/art-checklist-01?scenario=unknown_expired_download`);
|
|
assert.equal(res.status, 404);
|
|
const body = await res.json();
|
|
assert.equal(body.error.code, 'artifact_not_found');
|
|
});
|
|
|
|
test('POST /api/v1/query handles 409 busy scenario', async () => {
|
|
const res = await fetch(`${BASE_URL}/api/v1/query?scenario=409_busy`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'Origin': BASE_URL
|
|
},
|
|
body: JSON.stringify({
|
|
prompt: 'test prompt',
|
|
credentials: { url: 'https://example.com', pat: 'dummy' }
|
|
})
|
|
});
|
|
|
|
assert.equal(res.status, 409);
|
|
const body = await res.json();
|
|
assert.equal(body.error.code, 'busy');
|
|
});
|
|
|
|
test('POST /api/v1/query handles 504 timeout scenario', async () => {
|
|
const res = await fetch(`${BASE_URL}/api/v1/query?scenario=504_timeout`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'Origin': BASE_URL
|
|
},
|
|
body: JSON.stringify({
|
|
prompt: 'test prompt',
|
|
credentials: { url: 'https://example.com', pat: 'dummy' }
|
|
})
|
|
});
|
|
|
|
assert.equal(res.status, 504);
|
|
const body = await res.json();
|
|
assert.equal(body.error.code, 'query_timeout');
|
|
});
|
|
|
|
test('POST /api/v1/query handles empty_search scenario', async () => {
|
|
const res = await fetch(`${BASE_URL}/api/v1/query?scenario=empty_search`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'Origin': BASE_URL
|
|
},
|
|
body: JSON.stringify({
|
|
prompt: 'non-existent query',
|
|
credentials: { url: 'https://example.com', pat: 'dummy' }
|
|
})
|
|
});
|
|
|
|
assert.equal(res.status, 200);
|
|
const body = await res.json();
|
|
assert.equal(body.pages_accessed.length, 0);
|
|
assert.equal(body.artifacts.length, 0);
|
|
assert.equal(body.tool_history.length, 1);
|
|
assert.equal(body.tool_history[0].result.pages.length, 0);
|
|
});
|
|
|
|
test('POST /api/v1/query handles repeated_cached_view scenario', async () => {
|
|
const res = await fetch(`${BASE_URL}/api/v1/query?scenario=repeated_cached_view`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'Origin': BASE_URL
|
|
},
|
|
body: JSON.stringify({
|
|
prompt: 'cache test',
|
|
credentials: { url: 'https://example.com', pat: 'dummy' }
|
|
})
|
|
});
|
|
|
|
assert.equal(res.status, 200);
|
|
const body = await res.json();
|
|
assert.equal(body.pages_accessed.length, 1);
|
|
assert.equal(body.tool_history.length, 2);
|
|
assert.equal(body.tool_history[0].cache_hit, false);
|
|
assert.equal(body.tool_history[1].cache_hit, true);
|
|
});
|
|
|
|
test('POST /api/v1/query handles failed_tool scenario with status="error"', async () => {
|
|
const res = await fetch(`${BASE_URL}/api/v1/query?scenario=failed_tool`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'Origin': BASE_URL
|
|
},
|
|
body: JSON.stringify({
|
|
prompt: 'failed tool test',
|
|
credentials: { url: 'https://example.com', pat: 'dummy' }
|
|
})
|
|
});
|
|
|
|
assert.equal(res.status, 200);
|
|
const body = await res.json();
|
|
const errorTool = body.tool_history.find((t) => t.status === 'error');
|
|
assert.ok(errorTool);
|
|
assert.equal(errorTool.result, null);
|
|
assert.equal(errorTool.error.code, 'page_not_found');
|
|
});
|
|
|
|
test('POST /api/v1/query handles warning_truncated_history scenario', async () => {
|
|
const res = await fetch(`${BASE_URL}/api/v1/query?scenario=warning_truncated_history`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'Origin': BASE_URL
|
|
},
|
|
body: JSON.stringify({
|
|
prompt: 'truncation test',
|
|
credentials: { url: 'https://example.com', pat: 'dummy' }
|
|
})
|
|
});
|
|
|
|
assert.equal(res.status, 200);
|
|
const body = await res.json();
|
|
assert.ok(body.warnings.length > 0);
|
|
assert.equal(body.tool_history[0].parameters_truncated, true);
|
|
assert.equal(body.tool_history[0].result_truncated, true);
|
|
});
|
|
|
|
test('Origin check rejects untrusted external origins', async () => {
|
|
const res = await fetch(`${BASE_URL}/api/v1/query`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'Origin': 'https://evil-attacker.example.com'
|
|
},
|
|
body: JSON.stringify({
|
|
prompt: 'attack',
|
|
credentials: { url: 'https://example.com', pat: 'dummy' }
|
|
})
|
|
});
|
|
|
|
assert.equal(res.status, 403);
|
|
const body = await res.json();
|
|
assert.equal(body.error.code, 'origin_denied');
|
|
});
|
|
|
|
test('Mock dev toolbar external assets served with correct headers and zero inline script/style', async () => {
|
|
// CSS asset
|
|
const cssRes = await fetch(`${BASE_URL}/dev/scenario-toolbar.css`);
|
|
assert.equal(cssRes.status, 200);
|
|
assert.ok(cssRes.headers.get('content-type').includes('text/css'));
|
|
assert.equal(cssRes.headers.get('x-content-type-options'), 'nosniff');
|
|
|
|
// JS asset
|
|
const jsRes = await fetch(`${BASE_URL}/dev/scenario-toolbar.js`);
|
|
assert.equal(jsRes.status, 200);
|
|
assert.ok(jsRes.headers.get('content-type').includes('javascript'));
|
|
assert.equal(jsRes.headers.get('x-content-type-options'), 'nosniff');
|
|
|
|
// Root HTML page must not contain inline scripts or inline style attributes
|
|
const htmlRes = await fetch(`${BASE_URL}/`);
|
|
const html = await htmlRes.text();
|
|
assert.ok(!/<script(?![^>]*src=)[^>]*>/i.test(html), 'Root HTML in dev mode must not contain inline <script>');
|
|
assert.ok(!/style\s*=\s*["'][^"']*["']/i.test(html), 'Root HTML in dev mode must not contain inline style attributes');
|
|
});
|
|
});
|