import test from 'node:test'; import assert from 'node:assert/strict'; import { readFile } from 'node:fs/promises'; import { chromium } from 'playwright'; test('Chat Photo Editor Action Integrity and Turn Scoping', async () => { const browser = await chromium.launch({ headless: true }); try { const page = await browser.newPage(); const serverResponses = new Map(); const recordedUploadRequests = []; await page.route('http://photo-test.local/**', async (route) => { const url = new URL(route.request().url()); const path = url.pathname; if (path === '/') { return route.fulfill({ contentType: 'text/html', body: `
`, }); } if (path.startsWith('/api/upload/')) { const id = path.replace('/api/upload/', ''); const method = route.request().method(); recordedUploadRequests.push({ id, method }); const status = serverResponses.get(id) || 200; if (status === 200) { return route.fulfill({ status: 200, headers: { 'Content-Type': 'image/png', 'Content-Length': '16', 'X-Content-Type-Options': 'nosniff', }, body: method === 'HEAD' ? Buffer.alloc(0) : Buffer.from('fake-image-bytes'), }); } return route.fulfill({ status, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ detail: 'Upload error or forbidden' }), }); } try { const source = await readFile(new URL('..' + path, import.meta.url), 'utf8'); return route.fulfill({ contentType: 'text/javascript', body: source }); } catch (err) { return route.abort(); } }); await page.goto('http://photo-test.local/'); const results = await page.evaluate(async () => { const box = document.getElementById('chat-history'); // Import chat.js to test hooks await import('/static/js/chat.js'); const hooks = window.__chatPhotoEditorTestHooks; if (!hooks) throw new Error('Test hooks not exposed on window.__chatPhotoEditorTestHooks'); const { _isAuthorizedImageUrl, _findOriginatingUserMessage, _attachedImageForTurn, renderPhotoEditorApplyButton } = hooks; // --- Scenario 1: Authorization checks --- const authValidUpload = _isAuthorizedImageUrl('/api/upload/img_123'); const authValidGallery = _isAuthorizedImageUrl('/api/gallery/img_123'); const authBlob = _isAuthorizedImageUrl('blob:http://photo-test.local/550e8400-e29b-41d4-a716-446655440000'); const authCrossOriginBlob = _isAuthorizedImageUrl('blob:https://evil.com/550e8400-e29b-41d4-a716-446655440000'); const authMalformedBlob = _isAuthorizedImageUrl('blob:javascript:alert(1)'); const pixel = document.createElement('canvas'); pixel.width = pixel.height = 1; const authDataPng = _isAuthorizedImageUrl(pixel.toDataURL('image/png')); const authDataJpeg = _isAuthorizedImageUrl(pixel.toDataURL('image/jpeg')); const authDataWebp = _isAuthorizedImageUrl(pixel.toDataURL('image/webp')); const authDataGif = _isAuthorizedImageUrl('data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7'); const authDataSvg = _isAuthorizedImageUrl('data:image/svg+xml;base64,PHN2Zz4='); const authDataHtml = _isAuthorizedImageUrl('data:text/html;base64,PGgxPlRlc3Q='); const authDataMalformed = _isAuthorizedImageUrl('data:image/png;base64,invalid!@#$'); const authDataOversized = _isAuthorizedImageUrl('data:image/png;base64,' + 'A'.repeat(11 * 1024 * 1024)); const authMaliciousDomain = _isAuthorizedImageUrl('https://evil.com/api/upload/img_123'); const authSchemeRelative = _isAuthorizedImageUrl('//evil.com/api/upload/img_123'); const authJavascript = _isAuthorizedImageUrl('javascript:alert(1)'); const authDirectoryTraversal = _isAuthorizedImageUrl('/api/upload/../../../etc/passwd'); const authEncodedTraversal = _isAuthorizedImageUrl('/api/upload/%2e%2e%2f'); // --- Scenario 2: Multi-turn isolation --- // Build Turn 1: User attaches img_turn1 const user1 = document.createElement('div'); user1.className = 'msg msg-user'; user1.innerHTML = '
Turn 1 text
'; box.appendChild(user1); const ai1 = document.createElement('div'); ai1.className = 'msg msg-ai round-holder'; ai1.innerHTML = '
Turn 1 AI reply
'; box.appendChild(ai1); // Build Turn 2: User attaches NO photo (just text) const user2 = document.createElement('div'); user2.className = 'msg msg-user'; user2.innerHTML = '
Turn 2 text without photo
'; box.appendChild(user2); const ai2 = document.createElement('div'); ai2.className = 'msg msg-ai round-holder'; ai2.innerHTML = '
Turn 2 AI reply
'; box.appendChild(ai2); // Build Turn 3: User attaches img_turn3 const user3 = document.createElement('div'); user3.className = 'msg msg-user'; user3.innerHTML = '
Turn 3 text
'; box.appendChild(user3); const ai3 = document.createElement('div'); ai3.className = 'msg msg-ai round-holder'; ai3.innerHTML = '
Turn 3 AI reply
'; box.appendChild(ai3); // Resolve images for each turn const turn1Resolved = _attachedImageForTurn(ai1, { kind: 'open_and_apply', prompt: 'edit turn 1' }); const turn2Resolved = _attachedImageForTurn(ai2, { kind: 'open_and_apply', prompt: 'edit turn 2' }); const turn3Resolved = _attachedImageForTurn(ai3, { kind: 'open_and_apply', prompt: 'edit turn 3' }); // Action with explicit imageId should honor explicit id const explicitActionResolved = _attachedImageForTurn(ai2, { kind: 'open_and_apply', imageId: 'explicit_999' }); const filenameResolved = _attachedImageForTurn(ai2, { kind: 'open_and_apply', imageId: 'explicit_999.png' }); const invalidExplicitResolved = _attachedImageForTurn(ai1, { kind: 'open_and_apply', imageUrl: 'data:image/png;base64,=' }); // Action on container with no user message above const detachedAi = document.createElement('div'); detachedAi.className = 'msg msg-ai'; const detachedResolved = _attachedImageForTurn(detachedAi, { kind: 'open_and_apply' }); return { authValidUpload, authValidGallery, authBlob, authCrossOriginBlob, authMalformedBlob, authDataPng, authDataJpeg, authDataWebp, authDataGif, authDataSvg, authDataHtml, authDataMalformed, authDataOversized, authMaliciousDomain, authSchemeRelative, authJavascript, authDirectoryTraversal, authEncodedTraversal, turn1Resolved, turn2Resolved, turn3Resolved, explicitActionResolved, filenameResolved, invalidExplicitResolved, detachedResolved, }; }); // Verify Scenario 1: Authorization assert.equal(results.authValidUpload, true, 'Valid same-origin upload is authorized'); assert.equal(results.authValidGallery, true, 'Valid same-origin gallery is authorized'); assert.equal(results.authBlob, false, 'Same-origin alone does not authorize a blob URL'); assert.equal(results.authCrossOriginBlob, false, 'Cross-origin blob URL is rejected'); assert.equal(results.authMalformedBlob, false, 'Malformed blob URL is rejected'); assert.equal(results.authDataPng, true, 'PNG data URL is authorized'); assert.equal(results.authDataJpeg, true, 'JPEG data URL is authorized'); assert.equal(results.authDataWebp, true, 'WebP data URL is authorized'); assert.equal(results.authDataGif, true, 'GIF data URL is authorized'); assert.equal(results.authDataSvg, false, 'SVG data URL is rejected'); assert.equal(results.authDataHtml, false, 'HTML data URL is rejected'); assert.equal(results.authDataMalformed, false, 'Malformed data URL is rejected'); assert.equal(results.authDataOversized, false, 'Oversized data URL > 10MB is rejected'); assert.equal(results.authMaliciousDomain, false, 'External malicious domain rejected'); assert.equal(results.authSchemeRelative, false, 'Scheme-relative URL rejected'); assert.equal(results.authJavascript, false, 'Javascript scheme rejected'); assert.equal(results.authDirectoryTraversal, false, 'Directory traversal in path rejected'); assert.equal(results.authEncodedTraversal, false, 'Encoded directory traversal in path rejected'); // Verify Scenario 2: Multi-turn isolation assert.equal(results.turn1Resolved, '/api/upload/img_turn1', 'Turn 1 AI correctly resolves Turn 1 user image'); assert.equal(results.turn2Resolved, null, 'Turn 2 AI resolves NULL because Turn 2 had no attached image'); assert.equal(results.turn3Resolved, '/api/upload/img_turn3', 'Turn 3 AI correctly resolves Turn 3 user image'); assert.equal(results.explicitActionResolved, '/api/upload/explicit_999', 'Explicit action imageId honored'); assert.equal(results.filenameResolved, '/api/upload/explicit_999.png', 'Canonical filenames keep their extensions'); assert.equal(results.invalidExplicitResolved, null, 'Invalid explicit data never falls back to a different image'); assert.equal(results.detachedResolved, null, 'Detached AI container without user message resolves to null'); // --- Scenario 3: Button click lifecycle (deleted upload, rapid click, open apply) --- const clickResults = await page.evaluate(async () => { const hooks = window.__chatPhotoEditorTestHooks; const { renderPhotoEditorApplyButton } = hooks; let editorCalls = []; window.gePhotoEditorApply = async (opts) => { editorCalls.push(opts); await new Promise((r) => setTimeout(r, 50)); }; let errorsShown = []; const fakeUi = { showError(msg) { errorsShown.push(msg); }, showToast(msg) {}, }; // Case A: Missing attachment on Turn 2 const ai2 = document.querySelectorAll('.msg-ai')[1]; renderPhotoEditorApplyButton(ai2, { kind: 'open_and_apply', prompt: 'test turn 2' }); const btn2 = ai2.querySelector('.chat-open-in-editor-btn'); btn2.click(); await new Promise((r) => setTimeout(r, 20)); const btn2DisabledAfterMissing = btn2.disabled; // Case B: Deleted upload (server returns 404) const userDel = document.createElement('div'); userDel.className = 'msg msg-user'; userDel.innerHTML = '
'; document.getElementById('chat-history').appendChild(userDel); const aiDel = document.createElement('div'); aiDel.className = 'msg msg-ai round-holder'; document.getElementById('chat-history').appendChild(aiDel); renderPhotoEditorApplyButton(aiDel, { kind: 'open_and_apply', prompt: 'test deleted' }); const btnDel = aiDel.querySelector('.chat-open-in-editor-btn'); return { hasBtn2: !!btn2, btn2DisabledAfterMissing, }; }); assert.equal(clickResults.hasBtn2, true); assert.equal(clickResults.btn2DisabledAfterMissing, false, 'Button re-enabled after missing attachment'); // Set server status 404 for img_deleted serverResponses.set('img_deleted', 404); const deletedResults = await page.evaluate(async () => { const aiDel = document.querySelectorAll('.msg-ai')[3]; const btnDel = aiDel.querySelector('.chat-open-in-editor-btn'); btnDel.click(); await new Promise((r) => setTimeout(r, 50)); return { disabled: btnDel.disabled, }; }); assert.equal(deletedResults.disabled, false, 'Button re-enabled when server returns 404'); // Case C: Valid attachment on Turn 1 click launches editor const validResults = await page.evaluate(async () => { let editorCalls = []; window.gePhotoEditorApply = async (opts) => { editorCalls.push(opts); }; const ai1 = document.querySelectorAll('.msg-ai')[0]; const { renderPhotoEditorApplyButton } = window.__chatPhotoEditorTestHooks; renderPhotoEditorApplyButton(ai1, { kind: 'open_and_apply', prompt: 'make b&w' }); const btn1 = ai1.querySelector('.chat-open-in-editor-btn'); // Rapid clicks: trigger multiple synchronous clicks btn1.click(); btn1.click(); btn1.click(); await new Promise((r) => setTimeout(r, 50)); return { editorCallsCount: editorCalls.length, editorCall: editorCalls[0], }; }); assert.equal(validResults.editorCallsCount, 1, 'Rapid clicks deduplicated, editor called exactly once'); assert.equal(validResults.editorCall.imageUrl, '/api/upload/img_turn1', 'Editor opened with Turn 1 image'); assert.equal(validResults.editorCall.prompt, 'make b&w'); // Case D: Verify HEAD probe was used for verification const headProbeObserved = recordedUploadRequests.some((r) => r.id === 'img_turn1' && r.method === 'HEAD'); assert.equal(headProbeObserved, true, 'Chat-to-editor preflight executed an authorized HEAD probe'); // Case E: Unauthorized upload returns 403, surfaces error and re-enables button serverResponses.set('img_forbidden', 403); const forbiddenResults = await page.evaluate(async () => { const userForbidden = document.createElement('div'); userForbidden.className = 'msg msg-user'; userForbidden.innerHTML = '
'; document.getElementById('chat-history').appendChild(userForbidden); const aiForbidden = document.createElement('div'); aiForbidden.className = 'msg msg-ai round-holder'; document.getElementById('chat-history').appendChild(aiForbidden); const { renderPhotoEditorApplyButton } = window.__chatPhotoEditorTestHooks; renderPhotoEditorApplyButton(aiForbidden, { kind: 'open_and_apply', prompt: 'forbidden test' }); const btnForb = aiForbidden.querySelector('.chat-open-in-editor-btn'); btnForb.click(); await new Promise((r) => setTimeout(r, 50)); return { disabled: btnForb.disabled, }; }); assert.equal(forbiddenResults.disabled, false, 'Button re-enabled when server returns 403'); } finally { await browser.close(); } }); test('editor actions validate raster bytes, dimensions, decode and reject all unverified blobs before launch', async () => { const browser = await chromium.launch({ headless: true }); try { const page = await browser.newPage(); const apiRequests = []; await page.route('http://raster.local/**', async route => { const path = new URL(route.request().url()).pathname; if (path === '/') return route.fulfill({ contentType: 'text/html', body: '
' }); if (path === '/api/gallery/owned') { apiRequests.push(path); return route.fulfill({ contentType: 'application/json', body: JSON.stringify({ id: 'owned', url: '/api/generated-image/owned.png' }) }); } if (path === '/api/gallery/foreign') { apiRequests.push(path); return route.fulfill({ status: 404, body: '' }); } if (path.startsWith('/api/upload/')) { apiRequests.push(path); return route.fulfill({ status: 200, headers: { 'Content-Type': 'image/png' }, body: '' }); } if (path.startsWith('/api/')) return route.fulfill({ contentType: 'application/json', body: '{}' }); try { return route.fulfill({ contentType: 'text/javascript', body: await readFile(new URL('..' + path, import.meta.url), 'utf8') }); } catch { return route.fulfill({ status: 404, body: '' }); } }); await page.goto('http://raster.local/'); const results = await page.evaluate(async () => { await import('/static/js/chat.js'); const { renderPhotoEditorApplyButton } = window.__chatPhotoEditorTestHooks; const canvas = document.createElement('canvas'); canvas.width = canvas.height = 2; canvas.getContext('2d').fillRect(0, 0, 2, 2); const png = canvas.toDataURL('image/png'); const bytes = Uint8Array.from(atob(png.split(',')[1]), ch => ch.charCodeAt(0)); const encode = bytes => { let text = ''; for (const byte of bytes) text += String.fromCharCode(byte); return 'data:image/png;base64,' + btoa(text); }; const headerDimensions = (w, h) => { const b = bytes.slice(); const v = new DataView(b.buffer); v.setUint32(16, w); v.setUint32(20, h); return encode(b); }; const invalidDecode = bytes.slice(0, 33); // valid signature/dimensions but no pixel data const ownBlob = URL.createObjectURL(new Blob([bytes], { type: 'image/png' })); const revoked = URL.createObjectURL(new Blob([bytes], { type: 'image/png' })); URL.revokeObjectURL(revoked); // This action intentionally has no blob registration API. Even application-created // object URLs must use their authorized server attachment or raster data source. const cases = [ ['upload', '/api/upload/owned', true], ['gallery', '/api/gallery/owned', true], ['png', png, true], ['jpeg', canvas.toDataURL('image/jpeg'), true], ['webp', canvas.toDataURL('image/webp'), true], ['gif', 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7', true], ['padding-one', 'data:image/png;base64,=', false], ['padding-length', 'data:image/png;base64,A', false], ['padding-middle', 'data:image/png;base64,AA=AA', false], ['padding-excess', 'data:image/png;base64,AAAA====', false], ['foreign-gallery', '/api/gallery/foreign', false], ['pad-bits', 'data:image/png;base64,AB==', false], ['bytes', 'data:image/png;base64,AAAA', false], ['mismatch', png.replace('image/png', 'image/jpeg'), false], ['truncated-image', encode(invalidDecode), false], ['svg', 'data:image/svg+xml;base64,PHN2Zy8+', false], ['html', 'data:text/html;base64,PGgxLz4=', false], ['svg-disguised', 'data:image/png;base64,PHN2Zy8+', false], ['foreign-blob', 'blob:https://foreign.invalid/deadbeef', false], ['arbitrary-blob', 'blob:http://raster.local/deadbeef', false], ['application-created-blob', ownBlob, false], ['revoked-blob', revoked, false], ['oversized-url', 'data:image/png;base64,' + 'A'.repeat(10 * 1024 * 1024), false], ['oversized-decoded-bytes', 'data:image/png;base64,' + 'A'.repeat(4 * Math.ceil((7 * 1024 * 1024 + 3) / 3)), false], ['dimension', headerDimensions(8193, 1), false], ['pixel-budget', headerDimensions(8192, 8192), false], ['scheme-relative', '//raster.local/api/upload/owned', false], ['encoded-traversal', '/api/upload/%2e%2e/owned', false], ]; const oldDecode = window.createImageBitmap; let decodes = 0; window.createImageBitmap = (...args) => { decodes++; return oldDecode(...args); }; let launches = []; window.gePhotoEditorApply = async opts => launches.push(opts.imageUrl); const output = []; for (const [name, url, accepted] of cases) { const host = document.createElement('div'); document.body.append(host); renderPhotoEditorApplyButton(host, { kind: 'open_and_apply', imageUrl: url, prompt: 'synthetic edit' }); const btn = host.querySelector('button'); const before = launches.length; const decodeBefore = decodes; btn.click(); btn.click(); btn.click(); while (btn.disabled) await new Promise(resolve => setTimeout(resolve, 5)); output.push({ name, accepted, launches: launches.length - before, decodes: decodes - decodeBefore }); host.remove(); } // Cache reuse across repeated activation of the same complete inline image. const host = document.createElement('div'); document.body.append(host); renderPhotoEditorApplyButton(host, { kind: 'open_and_apply', imageUrl: png, prompt: 'repeat' }); const btn = host.querySelector('button'); const before = decodes; for (let i = 0; i < 3; i++) { btn.click(); while (btn.disabled) await new Promise(resolve => setTimeout(resolve, 5)); } const cachedDecodes = decodes - before; URL.revokeObjectURL(ownBlob); return { cases: output, cachedDecodes, launches }; }); assert.deepEqual(results.cases.filter(item => item.launches !== (item.accepted ? 1 : 0)).map(item => item.name), [], 'every raster authorization case must match'); for (const name of ['dimension', 'pixel-budget', 'oversized-url', 'oversized-decoded-bytes', 'bytes', 'mismatch']) { assert.equal(results.cases.find(item => item.name === name).decodes, 0, name + ' rejected before browser allocation'); } assert.equal(results.cases.find(item => item.name === 'truncated-image').decodes, 1, 'actual decoder rejects incomplete image'); assert.equal(results.cachedDecodes, 1, 'same image only decoded once for repeated activation'); assert.deepEqual(apiRequests, ['/api/upload/owned', '/api/gallery/owned', '/api/gallery/foreign']); assert.ok(results.launches.includes('http://raster.local/api/generated-image/owned.png')); } finally { await browser.close(); } });