mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-10-30 17:52:26 +01:00
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:
@@ -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(); }
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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(); }
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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(); }
|
||||
});
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
@@ -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 `` 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"
|
||||
""
|
||||
)
|
||||
|
||||
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://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:  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(")")
|
||||
|
||||
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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user