Merge pull request #6740 from odysseus-dev/codex/review-7011-preview-ui-20261015

fix(ui): integrate streaming, model picker and Notes updates
This commit is contained in:
Alexandre Teixeira
2026-10-29 10:29:51 +00:00
committed by GitHub
46 changed files with 2893 additions and 160 deletions
+402
View File
@@ -0,0 +1,402 @@
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: `<!DOCTYPE html>
<html>
<body>
<div id="toast"></div>
<div id="chat-history"></div>
</body>
</html>`,
});
}
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 = '<div class="body">Turn 1 text</div><div class="attach-image-preview" data-file-id="img_turn1"><img src="/api/upload/img_turn1" /></div>';
box.appendChild(user1);
const ai1 = document.createElement('div');
ai1.className = 'msg msg-ai round-holder';
ai1.innerHTML = '<div class="body">Turn 1 AI reply</div>';
box.appendChild(ai1);
// Build Turn 2: User attaches NO photo (just text)
const user2 = document.createElement('div');
user2.className = 'msg msg-user';
user2.innerHTML = '<div class="body">Turn 2 text without photo</div>';
box.appendChild(user2);
const ai2 = document.createElement('div');
ai2.className = 'msg msg-ai round-holder';
ai2.innerHTML = '<div class="body">Turn 2 AI reply</div>';
box.appendChild(ai2);
// Build Turn 3: User attaches img_turn3
const user3 = document.createElement('div');
user3.className = 'msg msg-user';
user3.innerHTML = '<div class="body">Turn 3 text</div><div class="attach-card" data-file-id="img_turn3"><img src="/api/upload/img_turn3" /></div>';
box.appendChild(user3);
const ai3 = document.createElement('div');
ai3.className = 'msg msg-ai round-holder';
ai3.innerHTML = '<div class="body">Turn 3 AI reply</div>';
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 = '<div class="attach-image-preview" data-file-id="img_deleted"><img src="/api/upload/img_deleted" /></div>';
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 = '<div class="attach-image-preview" data-file-id="img_forbidden"><img src="/api/upload/img_forbidden" /></div>';
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: '<div id="chat-history"></div>' });
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(); }
});
+67
View File
@@ -0,0 +1,67 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
const src = await readFile(new URL('../static/js/sessions.js', import.meta.url), 'utf8');
const START = '// === copy-chat header (debug trace block) ===';
const END = '// === end copy-chat header ===';
const block = src.slice(src.indexOf(START) + START.length, src.indexOf(END));
const extract = new Function(block + '\nreturn { _buildCopyChatHeader, _toolSurfaceLabel, _formatJst };');
const { _buildCopyChatHeader, _toolSurfaceLabel, _formatJst } = extract();
test('copy-chat header has exact shape and values', () => {
const header = _buildCopyChatHeader({
version: '1.0.3',
build: '20261006.1',
sourceCommit: 'a1b2c3d4e5f6',
model: 'gpt-4o',
endpointUrl: 'https://api.example.com/v1',
toolSurface: 'Ajax Spec (router)',
sessionId: 'sess-123',
copiedAt: new Date(Date.UTC(2026, 9, 13, 5, 30)),
});
const expected = [
'--- Odysseus info ---',
'Odysseus: 1.0.3 | Harness build: 20261006.1 | Commit: a1b2c3d4',
'Model: gpt-4o @ https://api.example.com/v1 | Tool surface: Ajax Spec (router)',
'Copied: 2026-10-13 14:30 JST | Session: sess-123',
'----------------------',
].join('\n');
assert.equal(header, expected);
});
test('unknown fields are never omitted', () => {
const header = _buildCopyChatHeader({});
const expected = [
'--- Odysseus info ---',
'Odysseus: unknown | Harness build: unknown | Commit: unknown',
'Model: unknown @ unknown | Tool surface: unknown',
'Copied: unknown JST | Session: unknown',
'----------------------',
].join('\n');
assert.equal(header, expected);
});
test('closing separator is exactly 22 dashes', () => {
const header = _buildCopyChatHeader({});
const lines = header.split('\n');
assert.equal(lines.length, 5);
assert.equal(lines[lines.length - 1], '----------------------');
assert.equal(lines[lines.length - 1].length, 22);
});
test('tool surface labels map modes to human labels', () => {
assert.equal(_toolSurfaceLabel('full'), 'Regular tools');
assert.equal(_toolSurfaceLabel('compact'), 'Ajax Compact');
assert.equal(_toolSurfaceLabel('spec'), 'Ajax Spec (router)');
assert.equal(_toolSurfaceLabel('none'), 'Tools off');
assert.equal(_toolSurfaceLabel(''), 'Auto');
assert.equal(_toolSurfaceLabel(undefined), 'Auto');
assert.equal(_toolSurfaceLabel(null), 'Auto');
assert.equal(_toolSurfaceLabel('weird'), 'unknown');
});
test('JST timestamp is UTC+9 with HH:MM padding', () => {
assert.equal(_formatJst(new Date(Date.UTC(2026, 0, 1, 0, 5))), '2026-01-01 09:05');
assert.equal(_formatJst(new Date(Date.UTC(2026, 11, 31, 15, 0))), '2027-01-01 00:00');
});
+62
View File
@@ -0,0 +1,62 @@
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import { chromium } from 'playwright';
const origin = process.env.ODYSSEUS_TEST_STATIC_ORIGIN || 'http://127.0.0.1:7011';
const browser = await chromium.launch({ headless: true });
try {
for (const width of [390, 900]) {
const page = await browser.newPage({ viewport: { width, height: 844 }, acceptDownloads: true });
if (!process.env.ODYSSEUS_TEST_STATIC_ORIGIN) {
await page.route(`${origin}/**`, async route => {
const url = new URL(route.request().url());
const rel = url.pathname.replace(/^\//, '');
try {
const file = new URL('../' + rel, import.meta.url);
const body = await readFile(file);
const ext = rel.split('.').pop();
const type = ext === 'css' ? 'text/css' : ext === 'js' ? 'text/javascript' : 'application/octet-stream';
return route.fulfill({ contentType: type, body });
} catch {
return route.fulfill({ status: 404, body: 'Not found' });
}
});
}
await page.goto(`${origin}/static/js/documentStats.js`);
await page.setContent('<link rel="stylesheet" href="/static/css/00-tokens.css"><div id="toast"></div><div id="chat-container"></div><div id="sidebar"></div>');
await page.evaluate(async () => {
const mod = await import('/static/js/document.js?mobile-export-regression=1');
mod.init('/api');
mod.injectFreshDoc({ id: 'export-test', title: 'Export test', language: 'markdown', version_count: 1, current_content: '# Mobile export\n\nActual document content.' });
});
await page.locator('#doc-footer-export-btn').waitFor({ state: 'visible' });
await page.evaluate(() => {
document.querySelector('.doc-editor-pane').style.setProperty('z-index', '12000', 'important');
});
await page.locator('#doc-footer-export-btn').click();
const menu = page.locator('#doc-export-menu');
await menu.waitFor({ state: 'visible' });
const bounds = await menu.boundingBox();
assert(bounds.x >= 0 && bounds.y >= 0);
assert(bounds.x + bounds.width <= width && bounds.y + bounds.height <= 844);
assert(await menu.evaluate(el => Number(getComputedStyle(el).zIndex) > 12000));
await page.screenshot({ path: `/tmp/document-export-${width}.png` });
// A second click closes the menu, then reopening must still allow export.
await page.locator('#doc-footer-export-btn').click();
assert.equal(await menu.count(), 0);
await page.locator('#doc-footer-export-btn').click();
const pending = page.waitForEvent('download');
await page.locator('#doc-export-menu button').filter({ hasText: /^Export Markdown$/ }).click();
const download = await pending;
assert(download.suggestedFilename().endsWith('.md'));
assert.equal(await download.failure(), null);
const stream = await download.createReadStream();
let content = '';
for await (const chunk of stream) content += chunk;
assert.equal(content, '# Mobile export\n\nActual document content.');
await page.close();
console.log(`PASS ${width}px: visible menu, toggle, download contents`);
}
} finally {
await browser.close();
}
+268
View File
@@ -0,0 +1,268 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import { chromium } from 'playwright';
test('Gallery and Quick Edit Model Picker: identity, privacy, and synchronization', async () => {
const browser = await chromium.launch({ headless: true });
try {
const page = await browser.newPage();
const mockModels = [
{
id: 'sdxl',
label: 'Stable Diffusion XL (Primary)',
kind: 'edit',
provider: 'Cluster A',
endpoint_id: 101,
// Non-admin view: base_url omitted
},
{
id: 'sdxl',
label: 'Stable Diffusion XL (Secondary)',
kind: 'edit',
provider: 'Cluster B',
endpoint_id: 202,
// Non-admin view: base_url omitted
},
{
id: 'qwen-image',
label: 'Qwen Image Edit',
kind: 'edit',
provider: 'Local Seneca',
endpoint_id: 303,
},
];
await page.route('http://gallery-sync.local/**', async (route) => {
const url = new URL(route.request().url());
const path = url.pathname;
if (path === '/') {
return route.fulfill({
contentType: 'text/html',
body: `<!DOCTYPE html>
<html>
<body>
<div id="gallery-settings-container">
<select id="gallery-image-model"></select>
</div>
<div id="editor-container">
<select class="ge-ai-command-model" id="ge-ai-command-model"></select>
<div id="ge-status-model"></div>
</div>
</body>
</html>`,
});
}
if (path === '/api/editor/image-models') {
return route.fulfill({
contentType: 'application/json',
body: JSON.stringify({ models: mockModels }),
});
}
if (path === '/api/editor/agent-models') {
return route.fulfill({
contentType: 'application/json',
body: JSON.stringify({ models: [] }),
});
}
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://gallery-sync.local/');
// --- Test 1: Option value encoding and distinct endpoint identities ---
const evalResult1 = await page.evaluate(async () => {
// Set legacy localStorage value that contained a private base URL
localStorage.setItem('ge-ai-command-model', 'http://private-cluster.internal/v1::qwen-image');
const { wireAIModelSelectors } = await import('/static/js/editor/ai-models.js');
wireAIModelSelectors({ container: document.body, apiBase: '' });
// Run gallery initialization
const { openGallery } = await import('/static/js/gallery.js');
try { openGallery(); } catch (_) {}
// Wait a moment for network fetch
await new Promise((r) => setTimeout(r, 80));
const gallerySel = document.getElementById('gallery-image-model');
const qeSel = document.getElementById('ge-ai-command-model');
const galleryOpts = Array.from(gallerySel.options).map((o) => ({ value: o.value, text: o.textContent }));
const qeOpts = Array.from(qeSel.options).map((o) => ({ value: o.value, text: o.textContent }));
const migratedKey = localStorage.getItem('ge-ai-command-model');
return {
galleryOpts,
qeOpts,
galleryValue: gallerySel.value,
qeValue: qeSel.value,
migratedKey,
};
});
// Check duplicate model id 'sdxl' produces distinguishable values with endpoint_id
const sdxlGalleryOpts = evalResult1.galleryOpts.filter((o) => o.value.endsWith('::sdxl'));
assert.equal(sdxlGalleryOpts.length, 2, 'Two endpoints offering sdxl produce two distinct options');
assert.equal(sdxlGalleryOpts[0].value, '101::sdxl');
assert.equal(sdxlGalleryOpts[1].value, '202::sdxl');
// Check no options contain undefined or empty prefix
for (const opt of evalResult1.galleryOpts) {
assert.equal(opt.value.startsWith('::'), false, 'Option value must not start with empty ::');
assert.equal(opt.value.includes('undefined'), false, 'Option value must not include undefined');
assert.equal(opt.value.includes('http://'), false, 'Option value must not expose raw http URL');
}
// Check legacy migration: 'http://private-cluster.internal/v1::qwen-image' was migrated to '303::qwen-image'
assert.equal(evalResult1.migratedKey, '303::qwen-image', 'Legacy key with URL migrated cleanly');
assert.equal(evalResult1.galleryValue, '303::qwen-image', 'Gallery picker selected migrated model');
assert.equal(evalResult1.qeValue, '303::qwen-image', 'Quick Edit picker selected migrated model');
// --- Test 2: Same-tab synchronization (Gallery -> Quick Edit) ---
const evalResult2 = await page.evaluate(async () => {
const gallerySel = document.getElementById('gallery-image-model');
gallerySel.value = '101::sdxl';
gallerySel.dispatchEvent(new Event('change'));
await new Promise((r) => setTimeout(r, 40));
const qeSel = document.getElementById('ge-ai-command-model');
const statusEl = document.getElementById('ge-status-model');
const savedKey = localStorage.getItem('ge-ai-command-model');
return {
qeValue: qeSel.value,
statusText: statusEl ? statusEl.textContent : '',
savedKey,
};
});
assert.equal(evalResult2.qeValue, '101::sdxl', 'Changing gallery picker updates quick-edit picker in same tab');
assert.equal(evalResult2.savedKey, '101::sdxl', 'Changing gallery picker updates localStorage');
// --- Test 3: Same-tab synchronization (Quick Edit -> Gallery) ---
const evalResult3 = await page.evaluate(async () => {
const qeSel = document.getElementById('ge-ai-command-model');
qeSel.value = '202::sdxl';
qeSel.dispatchEvent(new Event('change'));
await new Promise((r) => setTimeout(r, 40));
const gallerySel = document.getElementById('gallery-image-model');
const savedKey = localStorage.getItem('ge-ai-command-model');
return {
galleryValue: gallerySel.value,
savedKey,
};
});
assert.equal(evalResult3.galleryValue, '202::sdxl', 'Changing quick-edit picker updates gallery picker in same tab');
assert.equal(evalResult3.savedKey, '202::sdxl', 'Changing quick-edit picker updates localStorage');
// --- Test 4: Cross-tab synchronization via storage event ---
const evalResult4 = await page.evaluate(async () => {
// Simulate storage event from another tab
window.dispatchEvent(new StorageEvent('storage', {
key: 'ge-ai-command-model',
newValue: '101::sdxl',
}));
await new Promise((r) => setTimeout(r, 40));
const gallerySel = document.getElementById('gallery-image-model');
const qeSel = document.getElementById('ge-ai-command-model');
return {
galleryValue: gallerySel.value,
qeValue: qeSel.value,
};
});
assert.equal(evalResult4.galleryValue, '101::sdxl', 'Storage event updates gallery picker');
assert.equal(evalResult4.qeValue, '101::sdxl', 'Storage event updates quick-edit picker');
} finally {
await browser.close();
}
});
test('picker reconstruction owns listeners and clears invalid persisted identities', async () => {
const browser = await chromium.launch({ headless: true });
try {
const context = await browser.newContext();
let imageRequests = 0;
await context.route('http://picker-lifecycle.local/**', async route => {
const path = new URL(route.request().url()).pathname;
if (path === '/') return route.fulfill({ contentType: 'text/html', body: '<div id="ge-status-model"></div><div id="editor-container"><select id="ge-ai-command-model"></select><select id="ge-ai-inpaint"></select></div>' });
if (path.startsWith('/api/')) {
if (path.endsWith('/image-models')) imageRequests++;
return route.fulfill({ contentType: 'application/json', body: JSON.stringify({ models: path.endsWith('/image-models') ? [{ id: 'qwen-image', endpoint_id: 'alice', kind: 'edit', provider: 'A' }] : [], items: [], albums: [] }) });
}
try { return route.fulfill({ contentType: 'text/javascript', body: await readFile(new URL('..' + path, import.meta.url), 'utf8') }); }
catch { return route.fulfill({ status: 404, body: '' }); }
});
const page = await context.newPage();
await page.addInitScript(() => {
window.modelListeners = {};
const add = window.addEventListener.bind(window);
window.addEventListener = (type, ...args) => { if (['storage', 'ge-ai-command-model-change', 'ge:model-endpoints-updated'].includes(type)) window.modelListeners[type] = (window.modelListeners[type] || 0) + 1; return add(type, ...args); };
window.modelEvents = 0;
add('ge-ai-command-model-change', () => window.modelEvents++);
});
const init = async p => {
await p.goto('http://picker-lifecycle.local/');
await p.evaluate(async () => {
window.ai = await import('/static/js/editor/ai-models.js');
window.gallery = await import('/static/js/gallery.js');
window.ai.wireAIModelSelectors({ container: document.getElementById('editor-container') });
window.gallery.openGallery();
});
await p.waitForFunction(() => document.getElementById('gallery-image-model')?.options.length === 2 && document.getElementById('ge-ai-command-model')?.options.length > 2);
};
await init(page);
const initial = await page.evaluate(() => ({ ...window.modelListeners }));
for (let i = 0; i < 3; i++) {
await page.evaluate(() => { window.gallery.closeGallery(); window.oldSelect = document.getElementById('ge-ai-command-model'); window.oldValue = window.oldSelect.value; document.getElementById('editor-container').replaceChildren(); });
await page.waitForFunction(() => !document.getElementById('gallery-modal'));
await page.evaluate(() => {
document.getElementById('editor-container').innerHTML = '<select id="ge-ai-command-model"></select><select id="ge-ai-inpaint"></select>';
window.ai.wireAIModelSelectors({ container: document.getElementById('editor-container') });
window.gallery.openGallery();
});
await page.waitForFunction(() => document.getElementById('gallery-image-model')?.options.length === 2 && document.getElementById('ge-ai-command-model')?.options.length > 2);
}
assert.deepEqual(await page.evaluate(() => window.modelListeners), initial, 'no new global listeners after reopen/reconstruction');
const before = imageRequests;
await page.evaluate(() => window.dispatchEvent(new CustomEvent('ge:model-endpoints-updated')));
await page.waitForTimeout(100);
assert.equal(imageRequests - before, 1, 'only live editor refreshes; no detached control handlers');
const p2 = await context.newPage(); await init(p2);
await page.evaluate(() => { window.modelEvents = 0; window.footerUpdates = 0; new MutationObserver(records => window.footerUpdates += records.length).observe(document.getElementById('ge-status-model'), { childList: true }); const s = document.getElementById('ge-ai-command-model'); s.value = 'alice::qwen-image'; s.dispatchEvent(new Event('change')); });
await p2.waitForFunction(() => document.getElementById('gallery-image-model').value === 'alice::qwen-image');
assert.equal(await page.evaluate(() => window.modelEvents), 1);
assert.equal(await page.evaluate(() => window.footerUpdates), 1, 'exactly one footer update');
assert.match(await page.locator('#ge-status-model').textContent(), /Edit:.*qwen-image/);
assert.equal(await page.evaluate(() => window.oldSelect.value), await page.evaluate(() => window.oldValue), 'global events never update detached controls');
await init(p2);
assert.equal(await p2.locator('#gallery-image-model').inputValue(), 'alice::qwen-image');
for (const invalid of ['deleted::qwen-image', 'disabled::qwen-image', 'https://private.invalid/v1::missing']) {
await page.evaluate(value => localStorage.setItem('ge-ai-command-model', value), invalid);
await init(page);
assert.equal(await page.evaluate(() => localStorage.getItem('ge-ai-command-model')), null, invalid);
assert.equal(await page.locator('#gallery-image-model').inputValue(), '');
assert.equal(await page.locator('#ge-ai-command-model').inputValue(), '');
}
} finally { await browser.close(); }
});
+58
View File
@@ -0,0 +1,58 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { chromium } from 'playwright';
const source = readFileSync(new URL('../static/js/document.js', import.meta.url), 'utf8');
const build = source.slice(source.indexOf(' function _buildAnnotation('), source.indexOf(' // Prompt user for an instruction'));
const state = source.slice(source.indexOf(' let _pdfAnnotationDragging'), source.indexOf(' // Per-doc last-used line spacing'));
const placement = source.slice(source.indexOf(' const isBlankPageTarget'), source.indexOf(' pane.appendChild(pageWrap);', source.indexOf(' const isBlankPageTarget')));
const browser = await chromium.launch({ headless: true });
try {
for (const touch of [true, false]) {
const page = await browser.newPage({ viewport: { width: 600, height: 800 }, hasTouch: touch });
await page.setContent('<div id="page" style="position:relative;width:500px;height:700px;background:white"></div>');
await page.evaluate(({ build, state, placement }) => {
new Function(`
const activeDocId = 'test', docId = activeDocId;
const _pdfPaneAnnotationsByDoc = new Map([['test', []]]);
const annotationRefs = _pdfPaneAnnotationsByDoc.get('test');
const _pdfLastLineHeight = new Map(), _pdfLastFontSize = new Map();
let _pdfDropMode = 'check', serial = 0;
const _newAnnotationId = () => 'new-' + (++serial);
const _pushPdfUndoSnapshot = () => {};
const _schedulePdfPaneSave = () => {};
const pageWrap = document.getElementById('page');
const page = {page: 1};
${state}
${build}
${placement}
for (let i = 0; i < 2; i++) {
const {ref} = _buildAnnotation(pageWrap, {id:'a'+i, page:1, x:20+i*35, y:30, w:5, h:5, kind:'check'});
annotationRefs.push(ref);
ref._setHandlesVisible(true);
}
window.refs = annotationRefs;
`)();
}, { build, state, placement });
const grip = page.locator('[data-ann-id="a0"] [title="Drag to move"]');
const box = await grip.boundingBox();
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
await page.mouse.down();
await page.mouse.move(300, 220);
assert(await page.evaluate(() => refs.every(ref => [...ref.wrap.querySelectorAll('[title]')].every(el => el.style.display === 'none'))));
// Proximity updates must not bring controls back while dragging.
await page.evaluate(() => refs.forEach(ref => ref._setHandlesVisible(true)));
assert(await page.locator('[data-ann-id="a1"] [title="Delete annotation"]').isHidden());
await page.mouse.up();
assert.equal(await page.locator('.pdf-annotation-wrap').count(), 2);
assert(await page.locator('[data-ann-id="a1"] [title="Delete annotation"]').isVisible());
await page.locator('[data-ann-id="a1"] [title="Delete annotation"]').click();
assert.equal(await page.locator('.pdf-annotation-wrap').count(), 1);
await page.locator('#page').click({ position: { x: 100, y: 450 } });
assert.equal(await page.locator('.pdf-annotation-wrap').count(), 2);
console.log(`PASS ${touch ? 'touch' : 'mouse'}: drag controls hidden, delete only deletes, blank page still places`);
await page.close();
}
} finally {
await browser.close();
}
+252
View File
@@ -0,0 +1,252 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import { chromium } from 'playwright';
test('StreamingRenderer: rAF coalescing and lifecycle cancellation', async () => {
const browser = await chromium.launch({ headless: true });
try {
const page = await browser.newPage();
await page.route('http://streaming.test/**', async (route) => {
const path = new URL(route.request().url()).pathname;
if (path === '/') {
return route.fulfill({ contentType: 'text/html', body: '<!DOCTYPE html><html><body><div id="root"></div></body></html>' });
}
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://streaming.test/');
const result = await page.evaluate(async () => {
const container = document.getElementById('root');
// Custom mock for rAF to control frame timing
const originalRaf = window.requestAnimationFrame;
const originalCancelRaf = window.cancelAnimationFrame;
let rafId = 0;
const pendingRfs = new Map();
window.requestAnimationFrame = (cb) => {
const id = ++rafId;
pendingRfs.set(id, cb);
return id;
};
window.cancelAnimationFrame = (id) => {
pendingRfs.delete(id);
};
const stepFrames = () => {
const callbacks = Array.from(pendingRfs.values());
pendingRfs.clear();
for (const cb of callbacks) cb(performance.now());
return callbacks.length;
};
const { createStreamRenderer } = await import('/static/js/streamingRenderer.js');
const options = { render: (text) => `<p>${text}</p>` };
// Test 1: Fast bursts within a frame coalesce into a single rAF render
const host1 = document.createElement('div');
container.appendChild(host1);
const renderer1 = createStreamRenderer(host1, options);
// Initial token renders synchronously to remove spinner immediately
renderer1.update('A');
const textAfterFirst = host1.textContent;
// Now stream 5 rapid tokens in the same frame
renderer1.update('AB');
renderer1.update('ABC');
renderer1.update('ABCD');
renderer1.update('ABCDE');
// Before frame fires, only the scheduled pendingTail exists
const textBeforeFrame = host1.textContent;
const frameExecuted = stepFrames();
const textAfterFrame = host1.textContent;
// Test 2: Finalize cancels pending rAF callback and freezes remaining text
const host2 = document.createElement('div');
container.appendChild(host2);
const renderer2 = createStreamRenderer(host2, options);
renderer2.update('First');
renderer2.update('First, more rapid streaming');
const pendingBeforeFinalize = pendingRfs.size;
renderer2.finalize();
const pendingAfterFinalize = pendingRfs.size;
const textAfterFinalize = host2.textContent;
// Executing any residual frames must do nothing because cancelAnimationFrame was called
const residualSteps = stepFrames();
const textAfterResidual = host2.textContent;
// Test 3: Reset clears state and cancels pending frames
const host3 = document.createElement('div');
container.appendChild(host3);
const renderer3 = createStreamRenderer(host3, options);
renderer3.update('Start');
renderer3.update('Start and streaming tokens...');
renderer3.reset();
const pendingAfterReset = pendingRfs.size;
// Test 4: Finalized blocks (\n\n) freeze synchronously
const host4 = document.createElement('div');
container.appendChild(host4);
const renderer4 = createStreamRenderer(host4, options);
renderer4.update('Paragraph 1\n\n');
renderer4.update('Paragraph 1\n\nParagraph 2');
stepFrames();
renderer4.finalize();
// Test 5: DOM marker replacement during streaming preserves text across immediate finalize
const host5 = document.createElement('div');
container.appendChild(host5);
const renderer5 = createStreamRenderer(host5, options);
renderer5.update('First chunk\n\n');
stepFrames();
// Simulate container rewrite by thinking indicator or tool card
host5.innerHTML = '<div class="thinking">Thinking...</div>';
// Stream update on replaced DOM
renderer5.update('First chunk\n\nSecond chunk following replace');
stepFrames();
// Finalize immediately
renderer5.finalize();
const host5FinalText = host5.textContent.trim();
// Test 6: Asynchronous exception inside rAF callback falls back to degraded full render safely
const host6 = document.createElement('div');
container.appendChild(host6);
let crashTailRender = false;
let tailCrashes = 0;
const unstableOptions = {
render: (text) => {
if (crashTailRender && text === 'pending burst') {
tailCrashes++;
throw new Error('Simulated partial tail rAF render crash');
}
return `<p>${text}</p>`;
}
};
const renderer6 = createStreamRenderer(host6, unstableOptions);
renderer6.update('Stable start\n\n');
stepFrames();
crashTailRender = true;
renderer6.update('Stable start\n\npending');
renderer6.update('Stable start\n\npending burst');
// Step frame: triggers renderTail inside rAF, which catches and falls back to full render
stepFrames();
crashTailRender = false;
renderer6.finalize();
const host6FinalText = host6.textContent.trim();
// Restore
window.requestAnimationFrame = originalRaf;
window.cancelAnimationFrame = originalCancelRaf;
return {
textAfterFirst,
textBeforeFrame,
frameExecuted,
textAfterFrame,
pendingBeforeFinalize,
pendingAfterFinalize,
textAfterFinalize,
residualSteps,
textAfterResidual,
pendingAfterReset,
host4Text: host4.textContent.trim(),
host5FinalText,
host6FinalText,
tailCrashes,
};
});
assert.equal(result.tailCrashes, 1, 'partial tail error path genuinely entered');
assert.equal(result.textAfterFirst, 'A', 'First token renders synchronously for immediate responsiveness');
assert.equal(result.frameExecuted, 1, 'Multiple rapid updates coalesce into a single animation frame');
assert.equal(result.textAfterFrame, 'ABCDE', 'Coalesced tail renders full updated content');
assert.equal(result.pendingBeforeFinalize > 0, true, 'Pending rAF was queued during rapid streaming');
assert.equal(result.pendingAfterFinalize, 0, 'finalize() successfully cancels pending animation frames');
assert.equal(result.textAfterFinalize, 'First, more rapid streaming', 'finalize() rendered complete final text');
assert.equal(result.residualSteps, 0, 'No orphaned rAF callbacks executed after finalize');
assert.equal(result.pendingAfterReset, 0, 'reset() cancels pending frames');
assert.equal(result.host4Text.includes('Paragraph 1') && result.host4Text.includes('Paragraph 2'), true);
assert.equal(
result.host5FinalText.includes('First chunk') && result.host5FinalText.includes('Second chunk following replace'),
true,
'finalize() after self-healing preserves full text without erasing content'
);
assert.equal(
result.host6FinalText,
'Stable start\n\npending burst',
'rAF callback error triggers degraded full render without destroying content'
);
} finally {
await browser.close();
}
});
test('combined rAF/full-render failures preserve authoritative text and recover canonically', async () => {
const browser = await chromium.launch({ headless: true });
try {
const page = await browser.newPage();
await page.route('http://recovery.test/**', async route => {
const path = new URL(route.request().url()).pathname;
if (path === '/') return route.fulfill({ contentType: 'text/html', body: '<div id="root"></div>' });
if (path.startsWith('/api/')) return route.fulfill({ contentType: 'application/json', body: '{}' });
return route.fulfill({ contentType: 'text/javascript', body: await readFile(new URL('..' + path, import.meta.url), 'utf8') });
});
await page.goto('http://recovery.test/');
const cases = await page.evaluate(async () => {
const { createStreamRenderer } = await import('/static/js/streamingRenderer.js');
const { mdToHtml } = await import('/static/js/markdown.js');
const canonical = text => { const d = document.createElement('div'); d.innerHTML = mdToHtml(text); return d.innerHTML.replace(/>\s*\n\s*</g, '><').trim(); };
let next = 0; const pending = new Map(); const cancelled = [];
window.requestAnimationFrame = cb => { pending.set(++next, cb); return next; };
window.cancelAnimationFrame = id => { if (pending.has(id)) cancelled.push(pending.get(id)); pending.delete(id); };
const frame = () => { const callbacks = [...pending.values()]; pending.clear(); callbacks.forEach(cb => cb()); };
const results = [];
for (const recover of [false, true]) {
const el = document.createElement('div'); let broken = false; let throws = 0;
const renderer = createStreamRenderer(el, { render: text => { if (broken) { throws++; throw Error('both render paths fail'); } return mdToHtml(text); } });
renderer.update('prefix\n\ntail'); renderer.update('prefix\n\ntail plus'); broken = true;
frame(); // auditor reproduction: both tail and full fallback must actually throw
const afterFailure = el.textContent;
if (recover) broken = false;
renderer.finalize();
const expected = 'prefix\n\ntail plus';
results.push({ recover, throws, afterFailure, text: el.textContent, html: el.innerHTML, canonical: canonical(expected) });
broken = false;
renderer.update(expected + '\n\n```js\nconst a = 1;\n```\n\nEnd'); renderer.finalize();
results.at(-1).subsequentCanonical = el.innerHTML.replace(/>\s*\n\s*</g, '><').trim() === canonical(expected + '\n\n```js\nconst a = 1;\n```\n\nEnd');
}
// Broken first-token/synchronous path must also preserve text safely.
const el = document.createElement('div'); const bad = createStreamRenderer(el, { render: () => { throw Error('always broken'); } });
bad.update('<img src=x onerror=alert(1)>\n```js\nx\n'); bad.finalize();
results.push({ safeText: el.textContent, children: el.childElementCount });
// Pending callback, DOM rewrite, self-healing, then immediate finalize.
const host = document.createElement('div'); const healed = createStreamRenderer(host, { render: mdToHtml });
healed.update('A'); healed.update('AB'); host.innerHTML = 'thinking'; healed.update('ABC'); healed.update('ABCD'); healed.finalize();
const before = host.innerHTML; frame(); cancelled.forEach(cb => cb());
results.push({ healed: host.textContent, stable: before === host.innerHTML, pending: pending.size });
return results;
});
for (const result of cases.slice(0, 2)) {
assert.ok(result.throws >= 2, 'both incremental and full render actually threw');
assert.equal(result.afterFailure, 'prefix\n\ntail plus', 'complete safe text after failure');
if (result.recover) assert.equal(result.html.replace(/>\s*\n\s*</g, '><').trim(), result.canonical);
else assert.equal(result.text, 'prefix\n\ntail plus');
assert.equal(result.subsequentCanonical, true);
}
assert.equal(cases[2].safeText, '<img src=x onerror=alert(1)>\n```js\nx\n');
assert.equal(cases[2].children, 0, 'fallback never interprets HTML');
assert.deepEqual(cases[3], { healed: 'ABCD', stable: true, pending: 0 });
} finally { await browser.close(); }
});
+2 -1
View File
@@ -352,7 +352,8 @@ def test_footer_shows_active_edit_model():
assert "el.textContent = `Edit: ${label}`;" in models
# Wired both after load and on inpaint-select change.
assert "updateFooterEditModel();" in models
assert "aiInpaintSelect.addEventListener('change', () => updateFooterEditModel());" in models
assert "listen(aiInpaintSelect, 'change', () => updateFooterEditModel());" in models
assert "signal: listeners.signal" in models
def test_quick_edit_has_a_model_picker():
@@ -79,6 +79,14 @@ def test_swr_contract_is_wired_into_the_list_path():
assert "cached_rows = {} if refresh else _email_index_rows" in SRC
def test_frontend_email_library_recognizes_skip_fast_path():
lib_path = pathlib.Path(__file__).resolve().parent.parent / "static" / "js" / "emailLibrary" / "index.js"
email_lib_src = lib_path.read_text(encoding="utf-8")
assert "skipFastPath: true" in email_lib_src
assert "skipFastPath = false" in email_lib_src
assert "!skipFastPath" in email_lib_src
def test_manual_refresh_fetches_mailbox_instead_of_returning_stale_index(monkeypatch):
class EmptyMailbox:
def select(self, folder, readonly=False):
+26
View File
@@ -8,6 +8,7 @@ from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
GALLERY_JS = (ROOT / "static/js/gallery.js").read_text(encoding="utf-8")
AI_MODELS_JS = (ROOT / "static/js/editor/ai-models.js").read_text(encoding="utf-8")
def test_settings_tab_has_image_model_picker():
@@ -37,3 +38,28 @@ def test_cloud_image_models_are_listed_alongside_the_local_ones():
assert "from src.image_model_ids import looks_like_image_generation_model" in routes
assert 'image_endpoint = getattr(ep, "model_type", None) == "image"' in routes
assert "if not image_endpoint and not looks_like_image_generation_model(name):" in routes
def test_option_values_use_endpoint_id_not_base_url():
"""Neither gallery.js nor ai-models.js may expose private base URLs in option values."""
assert "${m.base_url" not in GALLERY_JS
assert "m.endpoint_id ? String(m.endpoint_id)" in GALLERY_JS
assert "${m.base_url" not in AI_MODELS_JS
assert "m.endpoint_id ? String(m.endpoint_id)" in AI_MODELS_JS
def test_two_way_event_and_storage_synchronization():
"""Gallery and Quick Edit must dispatch and listen to ge-ai-command-model-change and storage."""
assert "new CustomEvent('ge-ai-command-model-change'" in GALLERY_JS
assert "addEventListener('ge-ai-command-model-change'" in GALLERY_JS
assert "addEventListener('storage'" in GALLERY_JS
assert "new CustomEvent('ge-ai-command-model-change'" in AI_MODELS_JS
assert "addEventListener('ge-ai-command-model-change'" in AI_MODELS_JS
assert "addEventListener('storage'" in AI_MODELS_JS
def test_legacy_localstorage_migration_scrubs_urls():
"""Both pickers must detect and migrate legacy URLs without leaking them."""
assert "saved.startsWith('http://')" in GALLERY_JS
assert "saved.startsWith('http://')" in AI_MODELS_JS
@@ -0,0 +1,25 @@
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
GALLERY = ROOT / "static/js/gallery.js"
def test_editor_landing_has_an_import_button_next_to_new_canvas():
"""The editor landing must offer a direct "Import" action.
It sits in the same action row as "New canvas..." / "Browse gallery" and,
on file pick, opens the editor on the chosen raster image (``openEditor``
takes an image data URL as its first argument).
"""
src = GALLERY.read_text(encoding="utf-8")
assert 'id="gallery-editor-import"' in src, "landing Import button missing"
# Button order: New canvas → Import → Browse gallery.
new_at = src.index('id="gallery-editor-new"')
import_at = src.index('id="gallery-editor-import"')
pick_at = src.index('id="gallery-editor-pick"')
assert new_at < import_at < pick_at, "Import must sit next to New canvas"
# Wiring opens the editor on the selected file's data URL.
assert "getElementById('gallery-editor-import')?.addEventListener('click'" in src
assert "readAsDataURL(file)" in src
assert "await openEditor(reader.result, null, null, name)" in src
+45
View File
@@ -401,6 +401,51 @@ def test_url_outside_inline_code_is_still_autolinked(node_available):
assert 'href="https://example.com/page"' in html
def test_https_markdown_image_renders_inline_as_clickable_thumbnail(node_available):
# Assistant `![alt](url)` images must render inline: https-only, lazy,
# max-width-100% (via CSS), and click-to-open the full size in a new tab.
html = _run_markdown_case(
"Here is a map:\n\n"
"![Map of Sweden](https://upload.wikimedia.org/wikipedia/commons/thumb/3/32/Sweden_location_map.svg/600px-Sweden_location_map.svg.png)"
)
assert html.count('class="chat-image"') == 1
assert html.count('class="chat-inline-image"') == 1
assert 'alt="Map of Sweden"' in html
assert 'loading="lazy"' in html
assert 'decoding="async"' in html
assert 'referrerpolicy="no-referrer"' in html
assert 'target="_blank"' in html
assert 'rel="noopener noreferrer"' in html
# The fallback caption (alt + URL) is emitted so a broken load never leaves
# an empty gap; it stays hidden until the capture-phase error listener
# toggles .image-broken.
assert 'class="chat-image-fallback"' in html
# The fallback caption carries the alt text plus the URL (the `//` is
# entity-encoded so the bare-URL autolink pass can't nest another <a>).
assert "Map of Sweden — https:&#47;&#47;upload.wikimedia.org" in html
def test_http_markdown_image_is_downgraded_to_alt_text(node_available):
# Non-https image URLs must not render as an <img> (https-only policy); the
# alt text is kept as plain text so nothing is silently dropped.
html = _run_markdown_case("Look: ![a http pic](http://example.com/pic.png) done.")
assert "<img" not in html
assert 'class="chat-image"' not in html
assert "a http pic" in html
def test_javascript_scheme_image_url_is_never_rendered(node_available):
# A javascript: URL inside image syntax must be rejected outright, never
# emitted as a src/href.
html = _run_markdown_case("![x](javascript:alert(1))")
assert "<img" not in html
assert "javascript:" not in html
assert 'class="chat-image"' not in html
def test_inline_code_content_is_html_escaped(node_available):
# Inline code is now extracted before the global escape pass, so it must be
# escaped at extraction time (matching the fenced-code-block handling).
@@ -0,0 +1,415 @@
"""Regression suite for upload confinement compatibility on symlinked and ordinary roots.
Verifies:
1. Ordinary upload root.
2. Upload root that is a symlink to an authorized directory.
3. Valid file in nested upload directory.
4. Symlinked in-root directory resolving safely inside the root.
5. Symlink escaping outside the authorized root — rejected.
6. Malformed upload ID — rejected.
7. Missing upload — rejected.
8. Foreign-user upload — rejected.
9. Valid GET/HEAD parity on both ordinary and symlinked roots.
10. Cached, missing and stale thumbnail HEAD behavior (zero body, no generation).
11. No directory traversal or unauthorized file disclosure.
"""
from __future__ import annotations
import json
import os
import time
from pathlib import Path
from types import SimpleNamespace
import pytest
from fastapi import APIRouter, FastAPI
from starlette.testclient import TestClient
from src.path_confinement import canonical_root, confine, PathEscape
from src.upload_handler import UploadHandler, secure_filename
import routes.upload_routes as upload_routes_mod
import src.constants as constants
class _AuthManager:
is_configured = True
def __init__(self, admins=("admin",)):
self._admins = set(admins)
def is_admin(self, user: str | None) -> bool:
return user in self._admins
def _build_test_app(handler: UploadHandler, default_user: str | None = "alice") -> FastAPI:
test_router = APIRouter(prefix="/api/upload", tags=["uploads"])
orig_router = upload_routes_mod.router
upload_routes_mod.router = test_router
try:
upload_routes_mod.setup_upload_routes(handler)
finally:
upload_routes_mod.router = orig_router
app = FastAPI()
app.state.auth_manager = _AuthManager()
@app.middleware("http")
async def inject_user(request, call_next):
header_user = request.headers.get("x-test-user")
if header_user is not None:
request.state.current_user = header_user if header_user != "none" else None
else:
request.state.current_user = default_user
return await call_next(request)
app.include_router(test_router)
return app
def _create_fixture_env(tmp_path: Path, monkeypatch, *, symlinked_root: bool = False):
if symlinked_root:
real_uploads = tmp_path / "real_uploads"
real_uploads.mkdir(parents=True, exist_ok=True)
upload_dir = tmp_path / "symlink_uploads"
os.symlink(real_uploads, upload_dir)
storage_base = real_uploads
else:
upload_dir = tmp_path / "ordinary_uploads"
upload_dir.mkdir(parents=True, exist_ok=True)
storage_base = upload_dir
dated_dir = storage_base / "2026" / "10" / "29"
dated_dir.mkdir(parents=True, exist_ok=True)
alice_id = "a" * 32 + ".png"
bob_id = "b" * 32 + ".png"
nested_id = "c" * 32 + ".png"
# Valid PNG image bytes using PIL
from PIL import Image
import io
buf = io.BytesIO()
Image.new("RGB", (10, 10), "red").save(buf, format="PNG")
png_bytes = buf.getvalue()
alice_path = storage_base / alice_id
bob_path = storage_base / bob_id
nested_path = dated_dir / nested_id
alice_path.write_bytes(png_bytes)
bob_path.write_bytes(png_bytes)
nested_path.write_bytes(png_bytes)
index = {
f"alice:{alice_id}": {
"id": alice_id,
"path": str(alice_path),
"mime": "image/png",
"size": len(png_bytes),
"name": "alice.png",
"original_name": "alice.png",
"owner": "alice",
},
f"bob:{bob_id}": {
"id": bob_id,
"path": str(bob_path),
"mime": "image/png",
"size": len(png_bytes),
"name": "bob.png",
"original_name": "bob.png",
"owner": "bob",
},
f"alice:{nested_id}": {
"id": nested_id,
"path": str(nested_path),
"mime": "image/png",
"size": len(png_bytes),
"name": "nested.png",
"original_name": "nested.png",
"owner": "alice",
},
}
(storage_base / "uploads.json").write_text(json.dumps(index), encoding="utf-8")
monkeypatch.setattr(constants, "UPLOAD_DIR", str(upload_dir))
handler = UploadHandler(str(tmp_path), str(upload_dir))
app = _build_test_app(handler)
client = TestClient(app)
return SimpleNamespace(
upload_dir=upload_dir,
storage_base=storage_base,
alice_id=alice_id,
bob_id=bob_id,
nested_id=nested_id,
png_bytes=png_bytes,
handler=handler,
app=app,
client=client,
)
# 1. Ordinary upload root
def test_matrix_01_ordinary_upload_root(tmp_path: Path, monkeypatch):
env = _create_fixture_env(tmp_path, monkeypatch, symlinked_root=False)
get_res = env.client.get(f"/api/upload/{env.alice_id}")
assert get_res.status_code == 200
assert get_res.content == env.png_bytes
head_res = env.client.head(f"/api/upload/{env.alice_id}")
assert head_res.status_code == 200
assert len(head_res.content) == 0
# 2. Upload root that is a symlink to an authorized directory
def test_matrix_02_symlinked_upload_root(tmp_path: Path, monkeypatch):
env = _create_fixture_env(tmp_path, monkeypatch, symlinked_root=True)
get_res = env.client.get(f"/api/upload/{env.alice_id}")
assert get_res.status_code == 200
assert get_res.content == env.png_bytes
head_res = env.client.head(f"/api/upload/{env.alice_id}")
assert head_res.status_code == 200
assert len(head_res.content) == 0
# 3. Valid file in nested upload directory
def test_matrix_03_nested_upload_directory(tmp_path: Path, monkeypatch):
for symlinked in (False, True):
env = _create_fixture_env(tmp_path / f"nested_{symlinked}", monkeypatch, symlinked_root=symlinked)
get_res = env.client.get(f"/api/upload/{env.nested_id}")
assert get_res.status_code == 200
assert get_res.content == env.png_bytes
head_res = env.client.head(f"/api/upload/{env.nested_id}")
assert head_res.status_code == 200
assert len(head_res.content) == 0
# 4. Symlinked in-root directory resolving safely inside the root
def test_matrix_04_symlinked_in_root_directory_safe(tmp_path: Path, monkeypatch):
env = _create_fixture_env(tmp_path, monkeypatch, symlinked_root=False)
real_sub = env.storage_base / "real_sub"
real_sub.mkdir(parents=True, exist_ok=True)
sub_file_id = "d" * 32 + ".png"
(real_sub / sub_file_id).write_bytes(env.png_bytes)
link_sub = env.storage_base / "link_sub"
os.symlink(real_sub, link_sub)
# Register in uploads.json pointing via symlinked in-root directory
uploads_json = env.storage_base / "uploads.json"
index = json.loads(uploads_json.read_text(encoding="utf-8"))
index[f"alice:{sub_file_id}"] = {
"id": sub_file_id,
"path": str(link_sub / sub_file_id),
"mime": "image/png",
"size": len(env.png_bytes),
"name": "sub.png",
"owner": "alice",
}
uploads_json.write_text(json.dumps(index), encoding="utf-8")
get_res = env.client.get(f"/api/upload/{sub_file_id}")
assert get_res.status_code == 200
assert get_res.content == env.png_bytes
head_res = env.client.head(f"/api/upload/{sub_file_id}")
assert head_res.status_code == 200
assert len(head_res.content) == 0
# 5. Symlink escaping outside the authorized root — rejected
def test_matrix_05_symlink_escaping_outside_root_rejected(tmp_path: Path, monkeypatch):
env = _create_fixture_env(tmp_path, monkeypatch, symlinked_root=False)
outside_dir = tmp_path / "outside_store"
outside_dir.mkdir(parents=True, exist_ok=True)
secret_file = outside_dir / "secret.txt"
secret_file.write_text("CONFIDENTIAL_SYSTEM_DATA", encoding="utf-8")
# A: Direct file symlink escaping root
escape_file_id = "e" * 32 + ".txt"
os.symlink(secret_file, env.storage_base / escape_file_id)
uploads_json = env.storage_base / "uploads.json"
index = json.loads(uploads_json.read_text(encoding="utf-8"))
index[f"alice:{escape_file_id}"] = {
"id": escape_file_id,
"path": str(env.storage_base / escape_file_id),
"mime": "text/plain",
"size": len("CONFIDENTIAL_SYSTEM_DATA"),
"name": "escape.txt",
"owner": "alice",
}
uploads_json.write_text(json.dumps(index), encoding="utf-8")
get_res = env.client.get(f"/api/upload/{escape_file_id}")
assert get_res.status_code == 403
head_res = env.client.head(f"/api/upload/{escape_file_id}")
assert head_res.status_code == 403
assert len(head_res.content) == 0
# B: Directory symlink escaping root
escape_dir_link = env.storage_base / "evil_sub"
os.symlink(outside_dir, escape_dir_link)
escape_dir_id = "f" * 32 + ".txt"
index[f"alice:{escape_dir_id}"] = {
"id": escape_dir_id,
"path": str(escape_dir_link / "secret.txt"),
"mime": "text/plain",
"size": len("CONFIDENTIAL_SYSTEM_DATA"),
"name": "escape_sub.txt",
"owner": "alice",
}
uploads_json.write_text(json.dumps(index), encoding="utf-8")
get_dir = env.client.get(f"/api/upload/{escape_dir_id}")
assert get_dir.status_code == 403
head_dir = env.client.head(f"/api/upload/{escape_dir_id}")
assert head_dir.status_code == 403
assert len(head_dir.content) == 0
# 6. Malformed upload ID — rejected
def test_matrix_06_malformed_upload_id_rejected(tmp_path: Path, monkeypatch):
env = _create_fixture_env(tmp_path, monkeypatch, symlinked_root=True)
malformed_ids = [
"../../etc/passwd",
"..%2f..%2fetc%2fpasswd",
"invalid!id@specials",
"short_id.png",
"g" * 32 + ".png", # non-hex character 'g'
" " + "a" * 32 + ".png",
"a" * 31,
]
for bad_id in malformed_ids:
get_res = env.client.get(f"/api/upload/{bad_id}")
assert get_res.status_code in (400, 403, 404), f"GET failed for {bad_id}: {get_res.status_code}"
head_res = env.client.head(f"/api/upload/{bad_id}")
assert head_res.status_code == get_res.status_code, f"HEAD failed for {bad_id}: {head_res.status_code}"
assert len(head_res.content) == 0
# 7. Missing upload — rejected
def test_matrix_07_missing_upload_rejected(tmp_path: Path, monkeypatch):
env = _create_fixture_env(tmp_path, monkeypatch, symlinked_root=True)
missing_id = "1" * 32 + ".png"
get_res = env.client.get(f"/api/upload/{missing_id}")
assert get_res.status_code == 404
head_res = env.client.head(f"/api/upload/{missing_id}")
assert head_res.status_code == 404
assert len(head_res.content) == 0
# 8. Foreign-user upload — rejected
def test_matrix_08_foreign_user_upload_rejected(tmp_path: Path, monkeypatch):
env = _create_fixture_env(tmp_path, monkeypatch, symlinked_root=True)
# Bob attempts to access Alice's upload
bob_get = env.client.get(f"/api/upload/{env.alice_id}", headers={"x-test-user": "bob"})
assert bob_get.status_code == 404
bob_head = env.client.head(f"/api/upload/{env.alice_id}", headers={"x-test-user": "bob"})
assert bob_head.status_code == 404
assert len(bob_head.content) == 0
# Unauthenticated attempts
unauth_get = env.client.get(f"/api/upload/{env.alice_id}", headers={"x-test-user": "none"})
assert unauth_get.status_code in (401, 403)
unauth_head = env.client.head(f"/api/upload/{env.alice_id}", headers={"x-test-user": "none"})
assert unauth_head.status_code == unauth_get.status_code
assert len(unauth_head.content) == 0
# Admin access succeeds
admin_get = env.client.get(f"/api/upload/{env.alice_id}", headers={"x-test-user": "admin"})
assert admin_get.status_code == 200
admin_head = env.client.head(f"/api/upload/{env.alice_id}", headers={"x-test-user": "admin"})
assert admin_head.status_code == 200
assert len(admin_head.content) == 0
# 9. Valid GET/HEAD parity on both ordinary and symlinked roots
def test_matrix_09_get_head_parity(tmp_path: Path, monkeypatch):
for symlinked in (False, True):
env = _create_fixture_env(tmp_path / f"parity_{symlinked}", monkeypatch, symlinked_root=symlinked)
get_res = env.client.get(f"/api/upload/{env.alice_id}")
head_res = env.client.head(f"/api/upload/{env.alice_id}")
assert get_res.status_code == 200
assert head_res.status_code == 200
assert len(head_res.content) == 0
assert get_res.headers.get("x-content-type-options") == "nosniff"
assert head_res.headers.get("x-content-type-options") == "nosniff"
assert get_res.headers.get("content-type") == head_res.headers.get("content-type")
assert get_res.headers.get("content-length") == head_res.headers.get("content-length")
# 10. Cached, missing and stale thumbnail HEAD behavior
def test_matrix_10_thumbnail_head_behavior(tmp_path: Path, monkeypatch):
env = _create_fixture_env(tmp_path, monkeypatch, symlinked_root=True)
thumb_path = env.storage_base / ".thumbs" / f"{env.alice_id}.jpg"
from unittest.mock import patch
# Case A: Missing thumbnail
assert not thumb_path.exists()
with patch("PIL.Image.open", side_effect=AssertionError("HEAD must not decode")):
head_missing = env.client.head(f"/api/upload/{env.alice_id}?thumb=1")
assert head_missing.status_code == 200
assert len(head_missing.content) == 0
# Must NOT generate thumbnail on disk during HEAD
assert not thumb_path.exists()
# Must NOT advertise Content-Length when thumbnail is ungenerated
assert "content-length" not in head_missing.headers
# Generate thumbnail via GET
get_gen = env.client.get(f"/api/upload/{env.alice_id}?thumb=1")
assert get_gen.status_code == 200
assert thumb_path.exists()
thumb_mtime = os.path.getmtime(thumb_path)
# Case B: Cached (fresh) thumbnail
with patch("PIL.Image.open", side_effect=AssertionError("HEAD must not decode")):
head_cached = env.client.head(f"/api/upload/{env.alice_id}?thumb=1")
assert head_cached.status_code == 200
assert len(head_cached.content) == 0
assert head_cached.headers.get("content-type") == "image/jpeg"
assert "content-length" in head_cached.headers
assert head_cached.headers["content-length"] == str(len(thumb_path.read_bytes()))
# Mtime did not change
assert os.path.getmtime(thumb_path) == thumb_mtime
# Case C: Stale thumbnail (original modified after thumb)
alice_file = env.storage_base / env.alice_id
future_time = time.time() + 100
os.utime(alice_file, (future_time, future_time))
with patch("PIL.Image.open", side_effect=AssertionError("HEAD must not decode")):
head_stale = env.client.head(f"/api/upload/{env.alice_id}?thumb=1")
assert head_stale.status_code == 200
assert len(head_stale.content) == 0
# Must NOT advertise old stale Content-Length
assert "content-length" not in head_stale.headers
# Must NOT regenerate thumb during HEAD
assert os.path.getmtime(thumb_path) == thumb_mtime
# 11. No directory traversal or unauthorized file disclosure
def test_matrix_11_directory_traversal_prevention(tmp_path: Path, monkeypatch):
env = _create_fixture_env(tmp_path, monkeypatch, symlinked_root=True)
secret = tmp_path / "system_secret.txt"
secret.write_text("SENSITIVE_KEY_VALUE", encoding="utf-8")
traversal_paths = [
f"/api/upload/..%2f..%2f{secret.name}",
f"/api/upload/%2e%2e%2f%2e%2e%2f{secret.name}",
f"/api/upload/../../{secret.name}",
f"/api/upload/{env.alice_id}/../../{secret.name}",
]
for target in traversal_paths:
res = env.client.get(target)
assert res.status_code in (400, 403, 404)
assert "SENSITIVE_KEY_VALUE" not in res.text
+251
View File
@@ -0,0 +1,251 @@
"""Tests for the HTTP GET and HEAD contract on /api/upload/{file_id}.
Verifies that HEAD /api/upload/{file_id} returns identical status and response
headers to GET while transferring zero body bytes, enforcing the same
authorization and ownership checks.
"""
import json
import os
from types import SimpleNamespace
import pytest
from fastapi import FastAPI
from starlette.testclient import TestClient
class _AuthManager:
is_configured = True
def __init__(self, admins=()):
self._admins = set(admins)
def is_admin(self, user):
return user in self._admins
def _make_upload_env(tmp_path, monkeypatch):
from src.upload_handler import UploadHandler
from src import constants
import routes.upload_routes as upload_routes_mod
upload_dir = tmp_path / "uploads"
dated = upload_dir / "2026" / "06" / "02"
dated.mkdir(parents=True)
alice_id = "a" * 32 + ".png"
bob_id = "b" * 32 + ".png"
alice_path = dated / alice_id
bob_path = dated / bob_id
alice_bytes = b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01\x08\x06\x00\x00\x00\x1f\x15c4"
bob_bytes = b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x02\x00\x00\x00\x02\x08\x06\x00\x00\x00\x1f\x15c4"
alice_path.write_bytes(alice_bytes)
bob_path.write_bytes(bob_bytes)
index = {
f"alice:{alice_id}": {
"id": alice_id,
"path": str(alice_path),
"mime": "image/png",
"size": len(alice_bytes),
"name": "alice.png",
"original_name": "alice.png",
"owner": "alice",
},
f"bob:{bob_id}": {
"id": bob_id,
"path": str(bob_path),
"mime": "image/png",
"size": len(bob_bytes),
"name": "bob.png",
"original_name": "bob.png",
"owner": "bob",
},
}
(upload_dir / "uploads.json").write_text(json.dumps(index), encoding="utf-8")
monkeypatch.setattr(constants, "UPLOAD_DIR", str(upload_dir))
handler = UploadHandler(str(tmp_path), str(upload_dir))
return handler, alice_id, bob_id, upload_dir, alice_bytes
def _create_test_app(handler, current_user="alice", auth_manager=None):
from routes.upload_routes import router, setup_upload_routes
# Fresh router instance to avoid route collisions across test calls
from fastapi import APIRouter
test_router = APIRouter(prefix="/api/upload", tags=["uploads"])
import routes.upload_routes as ur
orig_router = ur.router
ur.router = test_router
try:
setup_upload_routes(handler)
finally:
ur.router = orig_router
app = FastAPI()
app.state.auth_manager = auth_manager or _AuthManager()
@app.middleware("http")
async def inject_user(request, call_next):
request.state.current_user = current_user
return await call_next(request)
app.include_router(test_router)
return app
def test_head_and_get_parity_on_valid_upload(tmp_path, monkeypatch):
handler, alice_id, _bob_id, _upload_dir, alice_bytes = _make_upload_env(tmp_path, monkeypatch)
app = _create_test_app(handler, current_user="alice")
client = TestClient(app)
# 1. GET response
get_res = client.get(f"/api/upload/{alice_id}")
assert get_res.status_code == 200
assert get_res.content == alice_bytes
assert int(get_res.headers["content-length"]) == len(alice_bytes)
assert get_res.headers["x-content-type-options"] == "nosniff"
# 2. HEAD response
head_res = client.head(f"/api/upload/{alice_id}")
assert head_res.status_code == 200
assert len(head_res.content) == 0, "HEAD response must transfer 0 body bytes"
assert head_res.headers["content-length"] == get_res.headers["content-length"]
assert head_res.headers["content-type"] == get_res.headers["content-type"]
assert head_res.headers["x-content-type-options"] == "nosniff"
def test_head_rejects_invalid_file_id(tmp_path, monkeypatch):
handler, _alice_id, _bob_id, _upload_dir, _ = _make_upload_env(tmp_path, monkeypatch)
app = _create_test_app(handler, current_user="alice")
client = TestClient(app)
get_res = client.get("/api/upload/invalid!id@bad")
assert get_res.status_code == 400
head_res = client.head("/api/upload/invalid!id@bad")
assert head_res.status_code == 400
assert len(head_res.content) == 0
def test_head_returns_404_on_missing_file(tmp_path, monkeypatch):
handler, _alice_id, _bob_id, _upload_dir, _ = _make_upload_env(tmp_path, monkeypatch)
app = _create_test_app(handler, current_user="alice")
client = TestClient(app)
missing_id = "c" * 32 + ".png"
get_res = client.get(f"/api/upload/{missing_id}")
assert get_res.status_code == 404
head_res = client.head(f"/api/upload/{missing_id}")
assert head_res.status_code == 404
assert len(head_res.content) == 0
def test_head_denies_cross_owner_without_leaking_existence(tmp_path, monkeypatch):
handler, _alice_id, bob_id, _upload_dir, _ = _make_upload_env(tmp_path, monkeypatch)
# Alice requests Bob's file
app = _create_test_app(handler, current_user="alice")
client = TestClient(app)
get_res = client.get(f"/api/upload/{bob_id}")
assert get_res.status_code == 404
head_res = client.head(f"/api/upload/{bob_id}")
assert head_res.status_code == 404
assert len(head_res.content) == 0
def test_head_denies_unauthenticated_access(tmp_path, monkeypatch):
handler, alice_id, _bob_id, _upload_dir, _ = _make_upload_env(tmp_path, monkeypatch)
# Anonymous user (None) with auth configured
app = _create_test_app(handler, current_user=None, auth_manager=_AuthManager())
client = TestClient(app)
get_res = client.get(f"/api/upload/{alice_id}")
assert get_res.status_code == 403
head_res = client.head(f"/api/upload/{alice_id}")
assert head_res.status_code == 403
assert len(head_res.content) == 0
def test_head_allows_admin_access(tmp_path, monkeypatch):
handler, _alice_id, bob_id, _upload_dir, bob_bytes = _make_upload_env(tmp_path, monkeypatch)
# Admin requests Bob's file
auth_mgr = _AuthManager(admins=["admin_user"])
app = _create_test_app(handler, current_user="admin_user", auth_manager=auth_mgr)
client = TestClient(app)
get_res = client.get(f"/api/upload/{bob_id}")
assert get_res.status_code == 200
head_res = client.head(f"/api/upload/{bob_id}")
assert head_res.status_code == 200
assert len(head_res.content) == 0
assert int(head_res.headers["content-length"]) == len(bob_bytes)
def test_head_probe_with_thumb_param(tmp_path, monkeypatch):
handler, alice_id, _bob_id, _upload_dir, _alice_bytes = _make_upload_env(tmp_path, monkeypatch)
app = _create_test_app(handler, current_user="alice")
client = TestClient(app)
# HEAD with thumb=1 on image
head_res = client.head(f"/api/upload/{alice_id}?thumb=1")
assert head_res.status_code == 200
assert len(head_res.content) == 0
@pytest.mark.parametrize("cache", ["missing", "stale", "changed", "corrupt"])
def test_thumbnail_head_omits_unknown_representation_headers(tmp_path, monkeypatch, cache):
"""Unknown JPEG generation/fallback must not advertise original/stale metadata."""
from PIL import Image
from unittest.mock import patch
handler, file_id, _, upload_dir, _ = _make_upload_env(tmp_path, monkeypatch)
source = next(upload_dir.rglob(file_id))
if cache != "corrupt":
Image.new("RGB", (12, 15), "red").save(source)
if cache in {"stale", "changed"}:
thumb = upload_dir / ".thumbs" / (file_id + ".jpg")
thumb.parent.mkdir()
Image.new("RGB", (2, 2), "blue").save(thumb)
os.utime(thumb, (1, 1))
if cache == "changed":
Image.new("RGB", (25, 30), "green").save(source)
client = TestClient(_create_test_app(handler))
with patch("PIL.Image.open", side_effect=AssertionError("HEAD must not decode")):
response = client.head(f"/api/upload/{file_id}?thumb=1")
assert response.status_code == 200
assert response.content == b""
assert response.headers["x-content-type-options"] == "nosniff"
for header in ("content-type", "content-length", "etag", "last-modified", "content-disposition"):
assert header not in response.headers, (cache, header, response.headers)
get = client.get(f"/api/upload/{file_id}?thumb=1")
assert get.status_code == 200
assert get.headers["content-type"] == ("image/png" if cache == "corrupt" else "image/jpeg")
# Successful GET creates a current cache, and HEAD can now describe it exactly.
head = client.head(f"/api/upload/{file_id}?thumb=1")
if cache != "corrupt":
for header in ("content-type", "content-length", "etag", "last-modified"):
assert head.headers[header] == get.headers[header]
else:
assert "content-type" not in head.headers
assert "content-length" not in head.headers
def test_thumbnail_head_current_cache_and_authorization(tmp_path, monkeypatch):
from PIL import Image
from unittest.mock import patch
handler, file_id, _, upload_dir, _ = _make_upload_env(tmp_path, monkeypatch)
source = next(upload_dir.rglob(file_id))
Image.new("RGB", (12, 15), "red").save(source)
client = TestClient(_create_test_app(handler))
get = client.get(f"/api/upload/{file_id}?thumb=1")
with patch("PIL.Image.open", side_effect=AssertionError("HEAD must not decode")):
head = client.head(f"/api/upload/{file_id}?thumb=1")
assert head.status_code == 200 and not head.content
assert head.headers["content-type"] == "image/jpeg"
assert head.headers["content-length"] == get.headers["content-length"]
for user, status in (("bob", 404), (None, 403)):
denied = TestClient(_create_test_app(handler, current_user=user)).head(f"/api/upload/{file_id}?thumb=1")
assert denied.status_code == status
assert "etag" not in denied.headers and "last-modified" not in denied.headers