/** * E2E Test for Chat Mode CSRF Enforcement * * The chat blueprint is registered behind Flask-WTF's CSRFProtect with * no exemption (app_factory.py). Every state-mutating chat endpoint * (POST / PATCH / DELETE) must reject requests that lack a valid * X-CSRFToken header. The chat.js client always sends the header (via * `getCsrfToken()`); this test exercises the negative path — a request * from the same logged-in session, but without the CSRF header — and * asserts the server refuses it. * * Verified here: * - POST /api/chat/sessions without X-CSRFToken → 400 (CSRF rejection). * - POST .../messages without X-CSRFToken → 400. * - PATCH .../sessions/ without X-CSRFToken → 400. * - The SAME requests, with a valid CSRF token, succeed. * * No LLM required. * * Prerequisites: Web server on http://127.0.0.1:5000 (override BASE_URL). */ const puppeteer = require('puppeteer'); const AuthHelper = require('../auth_helper'); const { getPuppeteerLaunchOptions } = require('../puppeteer_config'); const fs = require('fs'); const path = require('path'); const BASE_URL = process.env.BASE_URL || 'http://127.0.0.1:5000'; const isCI = !!process.env.CI; const TIMEOUTS = { navigation: isCI ? 60000 : 30000, selector: isCI ? 30000 : 10000, }; const SCREENSHOTS_DIR = path.join(__dirname, '..', 'screenshots'); async function snap(page, name) { if (!fs.existsSync(SCREENSHOTS_DIR)) { fs.mkdirSync(SCREENSHOTS_DIR, { recursive: true }); } try { await page.screenshot({ path: path.join( SCREENSHOTS_DIR, `chat_csrf_${name}_${Date.now()}.png` ), fullPage: true, }); } catch (_) {} } async function getCsrf(page) { return page.evaluate(() => { const m = document.querySelector('meta[name="csrf-token"]'); return m ? m.content : ''; }); } /** Make an API call from the page. `csrf=null` deliberately omits the header. */ async function api(page, csrfOuter, urlOuter, methodOuter = 'GET', bodyOuter = null) { return page.evaluate( async ({ url, method, body, csrf }) => { const headers = { 'Content-Type': 'application/json' }; if (csrf) headers['X-CSRFToken'] = csrf; const opts = { method, headers }; if (body !== null) opts.body = JSON.stringify(body); const r = await fetch(url, opts); let data = null; try { data = await r.json(); } catch (_) {} return { ok: r.ok, status: r.status, data }; }, { url: urlOuter, method: methodOuter, body: bodyOuter, csrf: csrfOuter } ); } async function run() { console.log(`Running chat CSRF-enforcement tests (CI mode: ${isCI})`); const browser = await puppeteer.launch(getPuppeteerLaunchOptions()); const page = await browser.newPage(); await page.setViewport({ width: 1280, height: 800 }); if (isCI) { page.setDefaultTimeout(60000); page.setDefaultNavigationTimeout(60000); } page.on('pageerror', (e) => console.log('PAGE ERROR:', e.message)); page.on('console', (m) => { if (m.type() === 'error') console.log('BROWSER ERROR:', m.text()); }); const auth = new AuthHelper(page, BASE_URL); let passed = 0; let failed = 0; try { await auth.ensureAuthenticated(); await page.goto(`${BASE_URL}/chat/`, { waitUntil: 'domcontentloaded', timeout: TIMEOUTS.navigation, }); await page.waitForSelector('.ldr-chat-container', { timeout: TIMEOUTS.selector, }); const csrf = await getCsrf(page); if (!csrf) throw new Error('Could not obtain CSRF token from chat page'); // Test 1: POST /api/chat/sessions without CSRF must be rejected. console.log('Test 1: POST sessions without CSRF rejected'); try { const r = await api(page, null, '/api/chat/sessions', 'POST', { initial_query: 'should not land', }); // Flask-WTF returns 400 for CSRF rejection by default. if (r.status !== 400) { throw new Error( `Expected 400 CSRF rejection, got status=${r.status} body=${JSON.stringify(r.data)}` ); } console.log('PASSED'); passed++; } catch (e) { console.log(`FAILED: ${e.message}`); await snap(page, 'create_no_csrf'); failed++; } // Now create a real session WITH CSRF so we can hit PATCH/POST-messages. const created = await api(page, csrf, '/api/chat/sessions', 'POST', { initial_query: 'csrf-test seed', }); if (!created.ok || !created.data?.success) { throw new Error( `CSRF-bearing session creation failed: ${created.status} ${JSON.stringify(created.data)}` ); } const sessionId = created.data.session_id; // Test 2: PATCH the session without CSRF. console.log('Test 2: PATCH session without CSRF rejected'); try { const r = await api( page, null, `/api/chat/sessions/${sessionId}`, 'PATCH', { title: 'should not land' } ); if (r.status !== 400) { throw new Error( `Expected 400 CSRF rejection, got status=${r.status} body=${JSON.stringify(r.data)}` ); } console.log('PASSED'); passed++; } catch (e) { console.log(`FAILED: ${e.message}`); await snap(page, 'patch_no_csrf'); failed++; } // Test 3: POST a message without CSRF. console.log('Test 3: POST message without CSRF rejected'); try { const r = await api( page, null, `/api/chat/sessions/${sessionId}/messages`, 'POST', { content: 'should not land', trigger_research: false } ); if (r.status !== 400) { throw new Error( `Expected 400 CSRF rejection, got status=${r.status} body=${JSON.stringify(r.data)}` ); } console.log('PASSED'); passed++; } catch (e) { console.log(`FAILED: ${e.message}`); await snap(page, 'message_no_csrf'); failed++; } // Test 4: same POST message WITH CSRF succeeds — proves it's the // CSRF check that gates the others, not a session/auth bug. console.log('Test 4: same POST message with CSRF succeeds'); try { const r = await api( page, csrf, `/api/chat/sessions/${sessionId}/messages`, 'POST', { content: 'with csrf', trigger_research: false } ); if (!r.ok || !r.data?.success) { throw new Error( `Expected success with CSRF, got status=${r.status} body=${JSON.stringify(r.data)}` ); } console.log('PASSED'); passed++; } catch (e) { console.log(`FAILED: ${e.message}`); await snap(page, 'message_with_csrf'); failed++; } } catch (e) { console.log(`Test suite error: ${e.message}`); failed++; } finally { await browser.close(); } console.log('-'.repeat(50)); console.log(`Chat CSRF Enforcement Tests — passed: ${passed}, failed: ${failed}`); console.log('-'.repeat(50)); if (failed > 0) process.exit(1); } run().catch((e) => { console.error('Test runner error:', e); process.exit(1); });