mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-10-27 08:12:22 +01:00
Merge current dev and refresh PR #6741 from the 7011 integration
This commit is contained in:
@@ -1,10 +1,10 @@
|
||||
{
|
||||
"digest": "9868d50a542b7ad1",
|
||||
"digest": "f7e60e4a567c46dc",
|
||||
"elements": {
|
||||
"app-shell": {
|
||||
"app-loader": "f04ad5bf6312d53f",
|
||||
"attach-strip": "a6474951e2481214",
|
||||
"body": "58df17f0afa190b9",
|
||||
"body": "4956f449e6a30e93",
|
||||
"chat-container": "b00a7c6be4e107b1",
|
||||
"chat-context-pill": "dd905b87245a9a71",
|
||||
"chat-history": "e65bdc51f4d1675f",
|
||||
@@ -23,8 +23,8 @@
|
||||
"input-icon-btn": "3d1c773acd84a825",
|
||||
"memory-close-btn": "c3f4ba99098a6e4b",
|
||||
"memory-list": "da9a37a3de3c1a31",
|
||||
"memory-modal": "5efc81537eab4526",
|
||||
"memory-modal-content": "3cadc8581361436e",
|
||||
"memory-modal": "e2f97b123dc9bc55",
|
||||
"memory-modal-content": "5c5fe055c711e642",
|
||||
"memory-modal-header": "a8c822fe8c6d20e7",
|
||||
"memory-search-input": "8b0fbb33401750d4",
|
||||
"memory-toolbar-btn": "9f985efa7c896f90",
|
||||
@@ -42,7 +42,7 @@
|
||||
"rail-new-chat": "f8f1c2f6be426ab9",
|
||||
"reasoning-effort-btn": "2a07cdd8ec2b4d26",
|
||||
"rename-session-modal": "9345624780009d14",
|
||||
"root": "820190c144fda419",
|
||||
"root": "a9f7ace2c1cefb24",
|
||||
"save-custom-preset": "443da70ef5fe9b88",
|
||||
"scroll-bottom-btn": "24602d72e2949991",
|
||||
"search-input": "53b94f3e5fdb384d",
|
||||
@@ -54,7 +54,7 @@
|
||||
"settings-btn-sm": "c31bcb9bdfbae10d",
|
||||
"settings-fallbacks": "e339280590935fe3",
|
||||
"settings-logs-tab": "e470140373597143",
|
||||
"settings-modal": "5efc81537eab4526",
|
||||
"settings-modal": "e2f97b123dc9bc55",
|
||||
"settings-nav-search": "f4484b2980f2ccc5",
|
||||
"settings-row": "de568724268cec6e",
|
||||
"settings-select": "a96e8430b0ec6668",
|
||||
@@ -69,7 +69,7 @@
|
||||
"theme-grid": "4d4b2aeb8952cc6f",
|
||||
"theme-io-btn": "859f222c79a8c721",
|
||||
"theme-modal": "0a65ca916656cdbb",
|
||||
"theme-popup": "3aa65e6f313bbc65",
|
||||
"theme-popup": "fb86cc5f81d0aaa6",
|
||||
"theme-tabs": "0cc4987f39876dce",
|
||||
"toast": "c60c6a60d4982005",
|
||||
"tool-indicator": "0dee45eaca8b703a",
|
||||
@@ -83,7 +83,7 @@
|
||||
"#agent-indicator": "09a1b8d94b1487ee",
|
||||
"#agent-indicator.active": "67e2ad3fa48acd3b",
|
||||
"#compare-model-overlay .modal-body": "0b1ee357313551b9",
|
||||
"#compare-model-overlay .modal-content": "56883147d181bd9c",
|
||||
"#compare-model-overlay .modal-content": "9b2145292f439a88",
|
||||
"#cookbook-modal .cookbook-body": "20bd8a7268cee788",
|
||||
"#cookbook-modal .hwfit-cached-list.cookbook-serve-models-just-loaded > .doclib-card": "48f6787a3df3f19e",
|
||||
"#cookbook-modal .modal-content": "0c4e1e686df0990e",
|
||||
@@ -101,7 +101,7 @@
|
||||
"#email-lib-modal .email-lib-sync-status": "6d1a0374a16cf7f4",
|
||||
"#email-lib-modal .email-reader-atts": "c5d68ecda3c14bb8",
|
||||
"#email-lib-modal .email-reader-header": "85afca11c8cbd38e",
|
||||
"#email-lib-modal .modal-content": "d8b8fff38c613c95",
|
||||
"#email-lib-modal .modal-content": "262753f287410717",
|
||||
"#email-lib-settings-btn": "9ae15bf04b6a22ce",
|
||||
"#email-unread-dot.sidebar-notif-dot": "c80f1e03592983b6",
|
||||
"#export-compact-btn": "7d0325a38e51134b",
|
||||
@@ -686,44 +686,44 @@
|
||||
},
|
||||
"variants": {
|
||||
"app-shell": {
|
||||
"desktop-dark-comfortable": "65b23a35b5a34126",
|
||||
"desktop-dark-compact": "1bac3a0aa4467555",
|
||||
"desktop-dark-spacious": "944583b1854fecbb",
|
||||
"desktop-light-comfortable": "d279432f8c120b58",
|
||||
"desktop-light-compact": "aa768c34ba3c7abc",
|
||||
"desktop-light-spacious": "b74f53e95bfa1d82",
|
||||
"laptop-dark-comfortable": "d98d16f3d60ed275",
|
||||
"laptop-dark-compact": "c3eaf3c4278a0120",
|
||||
"laptop-dark-spacious": "a085fe0f7d9eb66c",
|
||||
"laptop-light-comfortable": "0d7c64f324bebe87",
|
||||
"laptop-light-compact": "da50bf03b0f1be58",
|
||||
"laptop-light-spacious": "a6789fd05a66bf5a",
|
||||
"phone-dark-comfortable": "21b94f88da549eb5",
|
||||
"phone-dark-compact": "b9aa7def2b85414e",
|
||||
"phone-dark-spacious": "21b94f88da549eb5",
|
||||
"phone-light-comfortable": "4a0cf45db775ea7d",
|
||||
"phone-light-compact": "f7fbcb94d99c8730",
|
||||
"phone-light-spacious": "4a0cf45db775ea7d",
|
||||
"tablet-dark-comfortable": "e18256ce58398f11",
|
||||
"tablet-dark-compact": "3cf3e52a10313d44",
|
||||
"tablet-dark-spacious": "e18256ce58398f11",
|
||||
"tablet-light-comfortable": "c2a45155c391f26c",
|
||||
"tablet-light-compact": "9fa7dc03dd02b723",
|
||||
"tablet-light-spacious": "c2a45155c391f26c"
|
||||
"desktop-dark-comfortable": "0c9123117135e362",
|
||||
"desktop-dark-compact": "c694f5d5118ac0e2",
|
||||
"desktop-dark-spacious": "90a67613e8cfdf5d",
|
||||
"desktop-light-comfortable": "17727a9945ccc77b",
|
||||
"desktop-light-compact": "2a887c30d231e357",
|
||||
"desktop-light-spacious": "94b4666734b87625",
|
||||
"laptop-dark-comfortable": "26d334b139b653f2",
|
||||
"laptop-dark-compact": "fea30bad6824357d",
|
||||
"laptop-dark-spacious": "4fdb28deaaaec870",
|
||||
"laptop-light-comfortable": "c7fc986a7e70aac8",
|
||||
"laptop-light-compact": "147645431229ddff",
|
||||
"laptop-light-spacious": "d513c285d30c8289",
|
||||
"phone-dark-comfortable": "551761344bec8d09",
|
||||
"phone-dark-compact": "293d66518c3ec994",
|
||||
"phone-dark-spacious": "551761344bec8d09",
|
||||
"phone-light-comfortable": "cb41acd3f9af3578",
|
||||
"phone-light-compact": "dbef6e9e373e9c10",
|
||||
"phone-light-spacious": "cb41acd3f9af3578",
|
||||
"tablet-dark-comfortable": "0f682621b4ead393",
|
||||
"tablet-dark-compact": "e6f3fece5e716a2c",
|
||||
"tablet-dark-spacious": "0f682621b4ead393",
|
||||
"tablet-light-comfortable": "6e398df9ca69c58b",
|
||||
"tablet-light-compact": "dca75981c8675bfa",
|
||||
"tablet-light-spacious": "6e398df9ca69c58b"
|
||||
},
|
||||
"bench": {
|
||||
"desktop-dark-comfortable": "f9fa9d54db5ad45a",
|
||||
"desktop-dark-compact": "7603a804cb2e23a2",
|
||||
"desktop-dark-spacious": "b0c69306349fc078",
|
||||
"desktop-light-comfortable": "d3e98c4c1e90686f",
|
||||
"desktop-light-compact": "a04ae70088bc8b35",
|
||||
"desktop-light-spacious": "4aa01a125f541429",
|
||||
"laptop-dark-comfortable": "025e60ebe84e0f98",
|
||||
"laptop-dark-compact": "53d6555bb7a6b5bb",
|
||||
"laptop-dark-spacious": "c124d9ce1633d027",
|
||||
"laptop-light-comfortable": "e8328c45f5127bb0",
|
||||
"laptop-light-compact": "dc2da3534534c888",
|
||||
"laptop-light-spacious": "abe5483724c987e0",
|
||||
"desktop-dark-comfortable": "a9709cb47eec7544",
|
||||
"desktop-dark-compact": "bfab96cb72fd6faf",
|
||||
"desktop-dark-spacious": "25469178b443428a",
|
||||
"desktop-light-comfortable": "2166794d643fae4c",
|
||||
"desktop-light-compact": "81d8ab64407b1d9a",
|
||||
"desktop-light-spacious": "d6d19f33476665af",
|
||||
"laptop-dark-comfortable": "57169e64e1fb5324",
|
||||
"laptop-dark-compact": "6ac903cd8a711bcf",
|
||||
"laptop-dark-spacious": "a47535332e9c5de7",
|
||||
"laptop-light-comfortable": "1e2b381de3556fac",
|
||||
"laptop-light-compact": "6dbfb465121949c1",
|
||||
"laptop-light-spacious": "1b8bfcd4bc4ed214",
|
||||
"phone-dark-comfortable": "67ca3c78e6b9495a",
|
||||
"phone-dark-compact": "ad6f3a9f5c8d81cd",
|
||||
"phone-dark-spacious": "1e6f66e56519ba5d",
|
||||
|
||||
@@ -10,19 +10,22 @@ async function canvasPoint(page, xRatio, yRatio) {
|
||||
test('brush presets and shared stroke controls remain reusable', async ({ page }) => {
|
||||
await openBlankEditor(page);
|
||||
await selectTool(page, 'brush');
|
||||
// Spacing / Smoothing / Blend and the pen-pressure toggles now live behind
|
||||
// the brush caret ("v") in the options strip instead of the retired "...".
|
||||
await page.locator('#ge-brush-more').click();
|
||||
await expect(page.locator('#ge-brush-spacing')).toBeVisible();
|
||||
await page.locator('#ge-brush-spacing').fill('28');
|
||||
await page.locator('#ge-brush-smoothing').fill('52');
|
||||
await page.locator('#ge-brush-blend').selectOption('multiply');
|
||||
// The pen-pressure and preset rows live behind the options-bar overflow ('...').
|
||||
await page.locator('#ge-options-more').click();
|
||||
await page.locator('.ge-pressure-option').filter({ hasText: 'Opacity' }).locator('.toggle-slider').click();
|
||||
await expect(page.locator('#ge-pressure-opacity')).toBeChecked();
|
||||
await page.locator('.ge-pressure-option').filter({ hasText: 'Flow' }).locator('.toggle-slider').click();
|
||||
await expect(page.locator('#ge-pressure-flow')).not.toBeChecked();
|
||||
await page.keyboard.press('Escape');
|
||||
|
||||
page.once('dialog', dialog => dialog.accept('My Detail Brush'));
|
||||
await page.locator('#ge-brush-preset-save').click();
|
||||
await expect(page.locator('#ge-brush-preset option', { hasText: 'My Detail Brush' })).toHaveCount(1);
|
||||
|
||||
await page.locator('#ge-brush-more').click();
|
||||
await page.locator('#ge-brush-spacing').fill('5');
|
||||
await page.locator('#ge-brush-preset').selectOption({ label: 'My Detail Brush' });
|
||||
await expect(page.locator('#ge-brush-spacing')).toHaveValue('28');
|
||||
@@ -36,8 +39,10 @@ test('long sampled strokes stay responsive and undo atomically', async ({ page }
|
||||
await openBlankEditor(page);
|
||||
await selectTool(page, 'brush');
|
||||
await page.locator('.ge-size-slider').fill('700');
|
||||
await page.locator('#ge-brush-more').click();
|
||||
await page.locator('#ge-brush-spacing').fill('8');
|
||||
await page.locator('#ge-brush-smoothing').fill('65');
|
||||
await page.keyboard.press('Escape');
|
||||
const activeLayerId = (await editorState(page)).activeLayerId;
|
||||
const thumb = page.locator(`.ge-layer-item[data-layer-id="${activeLayerId}"] .ge-layer-inline-thumb`);
|
||||
const thumbBefore = await thumb.evaluate(canvas => canvas.toDataURL());
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
#!/usr/bin/env node
|
||||
// Quick Edit benchmark v2 grader (gpt-5.4 vision). Reads the runner output
|
||||
// (quickedit-bench.json) plus the saved before/after PNGs, scores each case
|
||||
// 0-3, and writes quickedit-bench-grades.json with per-case scores and
|
||||
// per-category averages. If an eye-scores JSON is provided (id->score), it
|
||||
// merges the human (eye) score, averages eye+gpt, and flags disagreements.
|
||||
//
|
||||
// Usage:
|
||||
// OUT=/tmp/opencode/qe-bench \
|
||||
// EYE=/path/eye-scores.json \
|
||||
// node quickedit-bench-grade.js
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const https = require('node:https');
|
||||
|
||||
const OUT = process.env.OUT || '/tmp/opencode/qe-bench';
|
||||
const MANIFEST = process.env.MANIFEST || path.join(OUT, 'quickedit-bench.json');
|
||||
const EYE = process.env.EYE || '';
|
||||
const MODEL = process.env.GRADE_MODEL || 'gpt-5.4';
|
||||
const KEY_FILE = process.env.OPENAI_KEY_FILE || '/home/pewds/.config/tiger/openai_user_20261009.key';
|
||||
|
||||
function getKey() {
|
||||
if (process.env.OPENAI_API_KEY) return process.env.OPENAI_API_KEY.trim();
|
||||
return fs.readFileSync(KEY_FILE, 'utf8').trim();
|
||||
}
|
||||
const KEY = getKey();
|
||||
const URL = 'https://api.openai.com/v1/chat/completions';
|
||||
function b64(p) { return fs.readFileSync(p).toString('base64'); }
|
||||
|
||||
function call(messages) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const body = JSON.stringify({ model: MODEL, messages, max_completion_tokens: 2000 });
|
||||
const req = https.request(URL, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${KEY}`, 'Content-Length': Buffer.byteLength(body) },
|
||||
}, (res) => {
|
||||
let d = ''; res.on('data', (c) => (d += c));
|
||||
res.on('end', () => { try { resolve(JSON.parse(d)); } catch (e) { reject(new Error('bad json: ' + d.slice(0, 300))); } });
|
||||
});
|
||||
req.on('error', reject);
|
||||
req.write(body); req.end();
|
||||
});
|
||||
}
|
||||
|
||||
function rubric(prompt) {
|
||||
return [
|
||||
'You are a STRICT photo-editing grader. You are given the BEFORE image and the AFTER image.',
|
||||
`The user asked: "${prompt}".`,
|
||||
'Score the AFTER versus the BEFORE on an integer 0-3:',
|
||||
'0 = worse than the original, or broken/ugly/artifacts; 1 = did something but weak, irrelevant, or barely visible; 2 = decent, clearly visible and natural; 3 = what a good retoucher would produce.',
|
||||
'Judge harshly. If a face/eyes/lips/hair are smeared, distorted, blown out, plastic, or lost natural texture, score 0. If highlights are clipped or the image is washed out, cap at 1.',
|
||||
'Reply with ONLY JSON: {"score": <int>, "worse": <bool>, "artifacts": "<short>", "reason": "<short>"}.',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
(async () => {
|
||||
const manifest = JSON.parse(fs.readFileSync(MANIFEST, 'utf8'));
|
||||
const eye = EYE && fs.existsSync(EYE) ? JSON.parse(fs.readFileSync(EYE, 'utf8')) : {};
|
||||
const out = [];
|
||||
for (const c of manifest.results) {
|
||||
const before = path.join(OUT, c.tag + '-before.png');
|
||||
const after = path.join(OUT, c.tag + '-after.png');
|
||||
let json = null, raw = null;
|
||||
if (fs.existsSync(before) && fs.existsSync(after)) {
|
||||
const content = [
|
||||
{ type: 'text', text: rubric(c.prompt) },
|
||||
{ type: 'text', text: 'BEFORE:' },
|
||||
{ type: 'image_url', image_url: { url: 'data:image/png;base64,' + b64(before) } },
|
||||
{ type: 'text', text: 'AFTER:' },
|
||||
{ type: 'image_url', image_url: { url: 'data:image/png;base64,' + b64(after) } },
|
||||
];
|
||||
for (let attempt = 0; attempt < 3 && !json; attempt++) {
|
||||
try {
|
||||
const res = await call([{ role: 'user', content }]);
|
||||
raw = res.choices && res.choices[0] && res.choices[0].message && res.choices[0].message.content;
|
||||
if (!raw && res.error) raw = 'API error: ' + JSON.stringify(res.error);
|
||||
const m = String(raw || '').match(/\{[\s\S]*\}/);
|
||||
if (m) { try { json = JSON.parse(m[0]); } catch (_) {} }
|
||||
} catch (e) { raw = String(e.message || e); }
|
||||
}
|
||||
} else {
|
||||
raw = 'missing before/after images';
|
||||
}
|
||||
const gpt = json && typeof json.score === 'number' ? json.score : null;
|
||||
const eyeScore = typeof eye[c.id] === 'number' ? eye[c.id] : null;
|
||||
const combined = (gpt != null && eyeScore != null) ? (gpt + eyeScore) / 2 : (gpt != null ? gpt : eyeScore);
|
||||
out.push({ id: c.id, cat: c.cat, prompt: c.prompt, ms: c.ms, ok: c.ok, gpt, eye: eyeScore, avg: combined, disagree: (gpt != null && eyeScore != null && Math.abs(gpt - eyeScore) >= 2), artifacts: json && json.artifacts, reason: json && json.reason });
|
||||
console.log(`GRADE #${c.id} [${c.cat}] gpt=${gpt} eye=${eyeScore} avg=${combined} ${c.prompt}`);
|
||||
}
|
||||
|
||||
const byCat = {};
|
||||
for (const r of out) {
|
||||
if (typeof r.avg !== 'number') continue;
|
||||
(byCat[r.cat] = byCat[r.cat] || []).push(r.avg);
|
||||
}
|
||||
const cats = {};
|
||||
for (const [k, v] of Object.entries(byCat)) cats[k] = +(v.reduce((a, b) => a + b, 0) / v.length).toFixed(2);
|
||||
const scored = out.filter(r => typeof r.avg === 'number');
|
||||
const overall = scored.length ? +(scored.reduce((a, b) => a + b.avg, 0) / scored.length).toFixed(2) : null;
|
||||
const disagreements = out.filter(r => r.disagree).map(r => r.id);
|
||||
|
||||
fs.writeFileSync(path.join(OUT, 'quickedit-bench-grades.json'), JSON.stringify({ model: MODEL, overall, byCat, disagreements, results: out }, null, 2));
|
||||
console.log(`BENCH_GRADES overall=${overall}/3 byCat=${JSON.stringify(cats)} disagreements=${JSON.stringify(disagreements)} -> ${path.join(OUT, 'quickedit-bench-grades.json')}`);
|
||||
})().catch((e) => { console.error('FAIL', e); process.exit(1); });
|
||||
@@ -0,0 +1,393 @@
|
||||
#!/usr/bin/env node
|
||||
// Quick Edit benchmark v2 runner (standalone; not a Playwright spec).
|
||||
//
|
||||
// For each fixed prompt: import a fixed photo, run the editor agent, and
|
||||
// record the executed op trace, latency (ms), undo-group count (history.mark
|
||||
// delta), new layers, and before/after screenshots. Grading (eye + gpt-5.4)
|
||||
// happens separately on the saved images (see quickedit-bench-grade.js).
|
||||
//
|
||||
// Usage:
|
||||
// BASE_URL=http://127.0.0.1:7011 SESSION_FILE=/path/sessions.json \
|
||||
// NODE_PATH=/path/node_modules node quickedit-bench.js
|
||||
//
|
||||
// CATS=structural,looks limit to categories
|
||||
// PROMPTS=1,2,3 limit to ids
|
||||
// OUT=/tmp/opencode/qe-bench
|
||||
// PHOTOS=/tmp/opencode where the fixed jpg fixtures live
|
||||
// HEADFUL=1
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const { chromium } = require('@playwright/test');
|
||||
|
||||
const BASE = process.env.BASE_URL || 'http://127.0.0.1:7011';
|
||||
const OUT = process.env.OUT || '/tmp/opencode/qe-bench';
|
||||
const PHOTOS = process.env.PHOTOS || '/tmp/opencode';
|
||||
const SESSION_FILE = process.env.SESSION_FILE || '/home/pewds/odysseus-cookbook-fresh/data/sessions.json';
|
||||
|
||||
// photo fixtures (in PHOTOS/): real-person.jpg pic-64.jpg pic-1015.jpg
|
||||
// pic-1018.jpg real-sky.jpg real-city.jpg real-mountainlake.jpg
|
||||
// real-beachsunset.jpg old-faded.jpg food.jpg night.jpg pic-1016.jpg
|
||||
// pic-1027.jpg demo-street.jpg
|
||||
const P = {
|
||||
portrait: 'real-person.jpg',
|
||||
portrait2: 'pic-64.jpg',
|
||||
fjord: 'pic-1015.jpg',
|
||||
hills: 'pic-1018.jpg',
|
||||
sky: 'real-sky.jpg',
|
||||
city: 'real-city.jpg',
|
||||
lake: 'real-mountainlake.jpg',
|
||||
sunset: 'real-beachsunset.jpg',
|
||||
old: 'old-faded.jpg',
|
||||
canyon: 'pic-1016.jpg',
|
||||
face: 'pic-1027.jpg',
|
||||
street: 'demo-street.jpg',
|
||||
};
|
||||
|
||||
const CASES = [
|
||||
// ---- structural (30) ----
|
||||
{ id: 1, cat: 'structural', photo: P.sky, prompt: 'crop to a square' },
|
||||
{ id: 2, cat: 'structural', photo: P.sky, prompt: 'make this 16:9' },
|
||||
{ id: 3, cat: 'structural', photo: P.portrait, prompt: 'crop to 9:16 for a story' },
|
||||
{ id: 4, cat: 'structural', photo: P.fjord, prompt: 'crop to 4:5' },
|
||||
{ id: 5, cat: 'structural', photo: P.city, prompt: 'trim the empty borders' },
|
||||
{ id: 6, cat: 'structural', photo: P.hills, prompt: 'resize to 1920 wide' },
|
||||
{ id: 7, cat: 'structural', photo: P.fjord, prompt: 'resize to 1080 tall' },
|
||||
{ id: 8, cat: 'structural', photo: P.hills, prompt: 'rotate the whole image 90 degrees' },
|
||||
{ id: 9, cat: 'structural', photo: P.city, prompt: 'rotate the canvas 180' },
|
||||
{ id: 10, cat: 'structural', photo: P.fjord, prompt: 'flip the canvas horizontally' },
|
||||
{ id: 11, cat: 'structural', photo: P.sky, prompt: 'flip the whole image vertically' },
|
||||
{ id: 12, cat: 'structural', photo: P.hills, prompt: 'rotate only this layer 90 degrees' },
|
||||
{ id: 13, cat: 'structural', photo: P.city, prompt: 'flip this layer horizontally' },
|
||||
{ id: 14, cat: 'structural', photo: P.sky, prompt: 'add the title "SUMMER"' },
|
||||
{ id: 15, cat: 'structural', photo: P.fjord, setup: 'text', prompt: 'make the text bigger' },
|
||||
{ id: 16, cat: 'structural', photo: P.city, setup: 'text', prompt: 'make the text smaller' },
|
||||
{ id: 17, cat: 'structural', photo: P.hills, setup: 'text', prompt: 'make the title red' },
|
||||
{ id: 18, cat: 'structural', photo: P.sky, prompt: 'align the layer to the centre', setup: 'align' },
|
||||
{ id: 19, cat: 'structural', photo: P.fjord, prompt: 'align the layer to the left', setup: 'align' },
|
||||
{ id: 20, cat: 'structural', photo: P.city, prompt: 'align the layer to the right', setup: 'align' },
|
||||
{ id: 21, cat: 'structural', photo: P.sky, setup: 'text', prompt: 'add a drop shadow to the title' },
|
||||
{ id: 22, cat: 'structural', photo: P.fjord, setup: 'text', prompt: 'add a stroke to the layer' },
|
||||
{ id: 23, cat: 'structural', photo: P.city, prompt: 'add a colour overlay' },
|
||||
{ id: 24, cat: 'structural', photo: P.sky, prompt: 'fill the white area with red' },
|
||||
{ id: 25, cat: 'structural', photo: P.sky, prompt: 'flood fill the sky with blue' },
|
||||
{ id: 26, cat: 'structural', photo: P.hills, prompt: 'add a new layer' },
|
||||
{ id: 27, cat: 'structural', photo: P.fjord, prompt: 'duplicate this layer' },
|
||||
{ id: 28, cat: 'structural', photo: P.city, prompt: 'flatten the image' },
|
||||
{ id: 29, cat: 'structural', photo: P.sky, prompt: 'merge the layer down' },
|
||||
{ id: 30, cat: 'structural', photo: P.hills, prompt: 'delete this layer' },
|
||||
|
||||
// ---- looks / adjustments (25) ----
|
||||
{ id: 31, cat: 'looks', photo: P.fjord, prompt: 'make it brighter' },
|
||||
{ id: 32, cat: 'looks', photo: P.hills, prompt: 'make it darker' },
|
||||
{ id: 33, cat: 'looks', photo: P.city, prompt: 'warm it up' },
|
||||
{ id: 34, cat: 'looks', photo: P.portrait, prompt: 'make it cooler' },
|
||||
{ id: 35, cat: 'looks', photo: P.fjord, prompt: 'make it black and white' },
|
||||
{ id: 36, cat: 'looks', photo: P.old, prompt: 'make it sepia' },
|
||||
{ id: 37, cat: 'looks', photo: P.hills, prompt: 'make it punchy' },
|
||||
{ id: 38, cat: 'looks', photo: P.fjord, prompt: 'make it vivid and colourful' },
|
||||
{ id: 39, cat: 'looks', photo: P.city, prompt: 'make it dramatic' },
|
||||
{ id: 40, cat: 'looks', photo: P.hills, prompt: 'make it moody and cinematic' },
|
||||
{ id: 41, cat: 'looks', photo: P.old, prompt: 'make it vintage' },
|
||||
{ id: 42, cat: 'looks', photo: P.old, prompt: 'give it a faded film look' },
|
||||
{ id: 43, cat: 'looks', photo: P.city, prompt: 'increase the contrast' },
|
||||
{ id: 44, cat: 'looks', photo: P.fjord, prompt: 'lower the contrast' },
|
||||
{ id: 45, cat: 'looks', photo: P.hills, prompt: 'sharpen it' },
|
||||
{ id: 46, cat: 'looks', photo: P.portrait, prompt: 'soften it' },
|
||||
{ id: 47, cat: 'looks', photo: P.fjord, prompt: 'blur it slightly' },
|
||||
{ id: 48, cat: 'looks', photo: P.city, prompt: 'add a vignette' },
|
||||
{ id: 49, cat: 'looks', photo: P.hills, prompt: 'dehaze it' },
|
||||
{ id: 50, cat: 'looks', photo: P.fjord, prompt: 'make it HDR' },
|
||||
{ id: 51, cat: 'looks', photo: P.city, prompt: 'auto enhance' },
|
||||
{ id: 52, cat: 'looks', photo: P.hills, prompt: 'give it a teal and orange look' },
|
||||
{ id: 53, cat: 'looks', photo: P.fjord, prompt: 'golden hour' },
|
||||
{ id: 54, cat: 'looks', photo: P.old, prompt: 'clean it up' },
|
||||
{ id: 55, cat: 'looks', photo: P.city, prompt: 'make it look professional' },
|
||||
|
||||
// ---- portrait (10) ----
|
||||
{ id: 56, cat: 'portrait', photo: P.portrait, prompt: 'smooth the skin' },
|
||||
{ id: 57, cat: 'portrait', photo: P.portrait, prompt: 'whiten the teeth' },
|
||||
{ id: 58, cat: 'portrait', photo: P.portrait, prompt: 'remove the blemishes' },
|
||||
{ id: 59, cat: 'portrait', photo: P.portrait, prompt: 'brighten the face' },
|
||||
{ id: 60, cat: 'portrait', photo: P.portrait, prompt: 'brighten the eyes' },
|
||||
{ id: 61, cat: 'portrait', photo: P.portrait2, prompt: 'make her look glamorous' },
|
||||
{ id: 62, cat: 'portrait', photo: P.portrait, prompt: 'give it a professional headshot look' },
|
||||
{ id: 63, cat: 'portrait', photo: P.portrait, prompt: 'reduce the shine on the face' },
|
||||
{ id: 64, cat: 'portrait', photo: P.portrait2, prompt: 'warm up the portrait' },
|
||||
{ id: 65, cat: 'portrait', photo: P.portrait, prompt: 'soft smooth skin with a nice glow' },
|
||||
|
||||
// ---- generative (20) ----
|
||||
{ id: 66, cat: 'generative', photo: P.city, prompt: 'remove the person on the left' },
|
||||
{ id: 67, cat: 'generative', photo: P.hills, prompt: 'remove the distractions' },
|
||||
{ id: 68, cat: 'generative', photo: P.portrait, prompt: 'put her on a beach' },
|
||||
{ id: 69, cat: 'generative', photo: P.portrait, prompt: 'replace the background with mountains' },
|
||||
{ id: 70, cat: 'generative', photo: P.fjord, prompt: 'add a full moon in the sky' },
|
||||
{ id: 71, cat: 'generative', photo: P.sky, prompt: 'add dramatic clouds' },
|
||||
{ id: 72, cat: 'generative', photo: P.sky, prompt: 'extend the canvas to 16:9 with generative fill' },
|
||||
{ id: 73, cat: 'generative', photo: P.sky, prompt: 'extend the canvas to 9:16' },
|
||||
{ id: 74, cat: 'generative', photo: P.fjord, prompt: 'add a bird flying in the sky' },
|
||||
{ id: 75, cat: 'generative', photo: P.hills, prompt: 'add wildflowers in the foreground' },
|
||||
{ id: 76, cat: 'generative', photo: P.fjord, prompt: 'make it look snowy' },
|
||||
{ id: 77, cat: 'generative', photo: P.hills, prompt: 'make it autumn with orange leaves' },
|
||||
{ id: 78, cat: 'generative', photo: P.city, prompt: 'add rain and wet streets' },
|
||||
{ id: 79, cat: 'generative', photo: P.city, prompt: 'remove the car' },
|
||||
{ id: 80, cat: 'generative', photo: P.city, prompt: 'remove the sign' },
|
||||
{ id: 81, cat: 'generative', photo: P.fjord, prompt: 'change the sky to a sunset' },
|
||||
{ id: 82, cat: 'generative', photo: P.portrait, prompt: 'put sunglasses on her' },
|
||||
{ id: 83, cat: 'generative', photo: P.portrait, prompt: 'add a hat' },
|
||||
{ id: 84, cat: 'generative', photo: P.fjord, prompt: 'add a rainbow' },
|
||||
{ id: 85, cat: 'generative', photo: P.sky, prompt: 'generative fill the empty sky' },
|
||||
|
||||
// ---- multi-step (15) ----
|
||||
{ id: 86, cat: 'multistep', photo: P.city, prompt: 'crop to a square, make it warmer and add the title SUMMER' },
|
||||
{ id: 87, cat: 'multistep', photo: P.fjord, prompt: 'make it black and white and add a vignette' },
|
||||
{ id: 88, cat: 'multistep', photo: P.hills, prompt: 'brighten it and sharpen it' },
|
||||
{ id: 89, cat: 'multistep', photo: P.city, setup: 'text', prompt: 'warm it up and add a drop shadow to the title' },
|
||||
{ id: 90, cat: 'multistep', photo: P.portrait, prompt: 'remove the background and make it brighter' },
|
||||
{ id: 91, cat: 'multistep', photo: P.fjord, prompt: 'crop to 16:9 and give it a moody cinematic look' },
|
||||
{ id: 92, cat: 'multistep', photo: P.hills, prompt: 'flip it horizontally and add a vignette' },
|
||||
{ id: 93, cat: 'multistep', photo: P.city, prompt: 'sharpen it and increase the contrast' },
|
||||
{ id: 94, cat: 'multistep', photo: P.fjord, prompt: 'make it punchy and warm it up' },
|
||||
{ id: 95, cat: 'multistep', photo: P.hills, prompt: 'rotate the whole image 90 and add a vignette' },
|
||||
{ id: 96, cat: 'multistep', photo: P.sky, prompt: 'add the title HELLO and a drop shadow' },
|
||||
{ id: 97, cat: 'multistep', photo: P.fjord, prompt: 'select the subject, invert it and blur it' },
|
||||
{ id: 98, cat: 'multistep', photo: P.old, prompt: 'make it black and white then add a title' },
|
||||
{ id: 99, cat: 'multistep', photo: P.city, prompt: 'warm and brighten with a vignette' },
|
||||
{ id: 100, cat: 'multistep', photo: P.hills, prompt: 'crop to a square and make it vintage' },
|
||||
|
||||
// ---- v2.1 harder prompts (30): multi-step with generative, poster/text
|
||||
// design, reference looks, and targeted local recolours. ----
|
||||
|
||||
// hardmulti (10): compound instructions where at least one step is generative.
|
||||
{ id: 101, cat: 'hardmulti', photo: P.city, prompt: 'remove the person on the left and then warm up the photo' },
|
||||
{ id: 102, cat: 'hardmulti', photo: P.portrait, prompt: 'put her on a beach and add a warm sunset' },
|
||||
{ id: 103, cat: 'hardmulti', photo: P.fjord, prompt: 'add a full moon and make the sky more purple' },
|
||||
{ id: 104, cat: 'hardmulti', photo: P.city, prompt: 'remove the sign and add some flowers' },
|
||||
{ id: 105, cat: 'hardmulti', photo: P.portrait, prompt: 'replace the background with mountains and make it cooler' },
|
||||
{ id: 106, cat: 'hardmulti', photo: P.sky, prompt: 'add dramatic clouds then increase the contrast' },
|
||||
{ id: 107, cat: 'hardmulti', photo: P.hills, prompt: 'make it snowy and add a warm glow' },
|
||||
{ id: 108, cat: 'hardmulti', photo: P.street, prompt: 'remove the red car and add a bicycle' },
|
||||
{ id: 109, cat: 'hardmulti', photo: P.sky, prompt: 'extend to 16:9 with generative fill and add a title SUMMER' },
|
||||
{ id: 110, cat: 'hardmulti', photo: P.city, prompt: 'add rain and make it moody' },
|
||||
|
||||
// poster (8): title / tagline text design.
|
||||
{ id: 111, cat: 'poster', photo: P.canyon, prompt: 'make a movie poster titled THE SUMMIT' },
|
||||
{ id: 112, cat: 'poster', photo: P.fjord, prompt: 'add a bold title "ESCAPE" at the bottom and a tagline' },
|
||||
{ id: 113, cat: 'poster', photo: P.canyon, prompt: 'add large centred title text in white with a drop shadow' },
|
||||
{ id: 114, cat: 'poster', photo: P.fjord, prompt: 'make a travel poster titled VISIT NORWAY' },
|
||||
{ id: 115, cat: 'poster', photo: P.face, prompt: 'add the title "DREAM" in a stylish font and a thin border' },
|
||||
{ id: 116, cat: 'poster', photo: P.canyon, prompt: 'turn it into a vintage travel poster with the text EXPLORE' },
|
||||
{ id: 117, cat: 'poster', photo: P.city, setup: 'text', prompt: 'add a subtitle under the title and align it to the centre' },
|
||||
{ id: 118, cat: 'poster', photo: P.fjord, prompt: 'design a minimalist poster: add the title "SILENCE" and desaturate the background' },
|
||||
|
||||
// reference (20): "make it look like <style>" — any film, photographer or era.
|
||||
{ id: 119, cat: 'reference', photo: P.city, prompt: 'make it look like a Wes Anderson film' },
|
||||
{ id: 120, cat: 'reference', photo: P.hills, prompt: 'give it a Studio Ghibli animated look' },
|
||||
{ id: 121, cat: 'reference', photo: P.face, prompt: 'make it look like a 1970s film photograph' },
|
||||
{ id: 122, cat: 'reference', photo: P.city, prompt: 'give it a Blade Runner neon night look' },
|
||||
{ id: 123, cat: 'reference', photo: P.canyon, prompt: 'make it look like a National Geographic photo' },
|
||||
{ id: 124, cat: 'reference', photo: P.portrait, prompt: 'make it look like a vintage Polaroid' },
|
||||
{ id: 131, cat: 'reference', photo: P.fjord, prompt: 'make it look like a Kodak film photograph' },
|
||||
{ id: 132, cat: 'reference', photo: P.city, prompt: 'give it a cinematic teal and orange movie look' },
|
||||
{ id: 133, cat: 'reference', photo: P.canyon, prompt: 'make it look like an old western film' },
|
||||
{ id: 134, cat: 'reference', photo: P.hills, prompt: 'make it look like a National Geographic wildlife photo' },
|
||||
{ id: 135, cat: 'reference', photo: P.city, prompt: 'give it a moody film noir look' },
|
||||
{ id: 136, cat: 'reference', photo: P.street, prompt: 'make it look like a 1980s VHS still' },
|
||||
{ id: 137, cat: 'reference', photo: P.lake, prompt: 'make it look like a golden hour film photograph' },
|
||||
{ id: 138, cat: 'reference', photo: P.hills, prompt: 'give it a Kurosawa colour film look' },
|
||||
{ id: 139, cat: 'reference', photo: P.fjord, prompt: 'make it look like a David Attenborough nature documentary' },
|
||||
{ id: 140, cat: 'reference', photo: P.portrait2, prompt: 'make it look like a 1990s disposable camera photo' },
|
||||
{ id: 141, cat: 'reference', photo: P.sunset, prompt: 'give it a Technicolor vintage look' },
|
||||
{ id: 142, cat: 'reference', photo: P.lake, prompt: 'make it look like a black and white Ansel Adams landscape' },
|
||||
{ id: 143, cat: 'reference', photo: P.city, prompt: 'make it look like a Wong Kar-wai film' },
|
||||
{ id: 144, cat: 'reference', photo: P.old, prompt: 'give it a faded Kodachrome look' },
|
||||
|
||||
// targeted (6): recolour only a specific object/region.
|
||||
{ id: 125, cat: 'targeted', photo: P.street, prompt: 'make only the red car blue' },
|
||||
{ id: 126, cat: 'targeted', photo: P.street, prompt: 'change the yellow car to red' },
|
||||
{ id: 127, cat: 'targeted', photo: P.canyon, prompt: 'recolour the red rock to teal' },
|
||||
{ id: 128, cat: 'targeted', photo: P.fjord, prompt: 'turn the sky purple but keep the water natural' },
|
||||
{ id: 129, cat: 'targeted', photo: P.city, prompt: 'make the flowers bright pink' },
|
||||
{ id: 130, cat: 'targeted', photo: P.old, prompt: 'colourise the faded photo naturally' },
|
||||
|
||||
// bgremove (6): every common background-removal phrasing must map to the
|
||||
// deterministic ai.removeBackground op, never the AI agent / a tone.
|
||||
{ id: 145, cat: 'bgremove', photo: P.portrait, prompt: 'remove the background' },
|
||||
{ id: 146, cat: 'bgremove', photo: P.portrait, prompt: 'bg removal' },
|
||||
{ id: 147, cat: 'bgremove', photo: P.portrait, prompt: 'remove bg' },
|
||||
{ id: 148, cat: 'bgremove', photo: P.portrait, prompt: 'no background' },
|
||||
{ id: 149, cat: 'bgremove', photo: P.portrait, prompt: 'make the background transparent' },
|
||||
{ id: 150, cat: 'bgremove', photo: P.portrait, prompt: 'cut out the subject' },
|
||||
// ---- vague / misspelled (5) — the planner must not silently apply a
|
||||
// default brightness/vibrance tweak; it now falls back to the image model so
|
||||
// the prompt still does something meaningful.
|
||||
{ id: 151, cat: 'vague', photo: P.city, prompt: 'make it better' },
|
||||
{ id: 152, cat: 'vague', photo: P.fjord, prompt: 'fix it' },
|
||||
{ id: 153, cat: 'vague', photo: P.portrait, prompt: 'enhace the pic' },
|
||||
{ id: 154, cat: 'vague', photo: P.hills, prompt: 'do the thing' },
|
||||
{ id: 155, cat: 'vague', photo: P.portrait, prompt: 'make it look proffesional' },
|
||||
// ---- auto fall-through (model) — a generative prompt in Auto mode must land
|
||||
// as ONE NEW layer with a single undo group; a vague/unrecognised prompt now
|
||||
// falls through to the image model too (no dead-end "not sure" reply). ----
|
||||
{ id: 156, cat: 'auto', photo: P.city, prompt: 'add a man to the scene' },
|
||||
{ id: 157, cat: 'auto', photo: P.sky, prompt: 'add a hot air balloon' },
|
||||
{ id: 158, cat: 'auto', photo: P.fjord, prompt: 'do the thing' },
|
||||
// ---- restyle — must route to ai_edit_image (model edit), NOT ask and NOT
|
||||
// a filter tweak; run with MODE=agent to exercise the server planner. ----
|
||||
{ id: 159, cat: 'restyle', photo: P.fjord, prompt: 'turn it into an oil painting' },
|
||||
{ id: 160, cat: 'restyle', photo: P.hills, prompt: 'make it look like a watercolour' },
|
||||
{ id: 161, cat: 'restyle', photo: P.portrait, prompt: 'make it look like an anime' },
|
||||
{ id: 162, cat: 'restyle', photo: P.city, prompt: 'make it a comic book illustration' },
|
||||
];
|
||||
|
||||
// Cases that load a heavy in-process model on the app host (remove-background,
|
||||
// ~9.5 GB) and can OOM-kill the user's service; HARNESS is moving these to the
|
||||
// seneca image server. Marked pending instead of executed so a bench run never
|
||||
// crashes the app. Extend with SKIP=<ids> (e.g. SAM-heavy cases) if needed.
|
||||
// Heavy in-process cases to skip. Default #90 (remove-background) while it
|
||||
// risked the 7011 OOM; now that heavy image ops proxy to seneca, set
|
||||
// PENDING= (empty) to run it.
|
||||
const PENDING = new Set(String(process.env.PENDING != null ? process.env.PENDING : '90')
|
||||
.split(',').map(s => Number(s.trim())).filter(Boolean));
|
||||
for (const id of String(process.env.SKIP || '').split(',').map(s => Number(s.trim())).filter(Boolean)) PENDING.add(id);
|
||||
|
||||
function authToken() {
|
||||
const data = JSON.parse(fs.readFileSync(SESSION_FILE, 'utf8'));
|
||||
const now = Date.now() / 1000;
|
||||
for (const [token, info] of Object.entries(data)) {
|
||||
if (info.expiry == null || info.expiry > now) return token;
|
||||
}
|
||||
throw new Error(`no unexpired session in ${SESSION_FILE}`);
|
||||
}
|
||||
|
||||
function selected() {
|
||||
let cases = CASES;
|
||||
if (process.env.CATS) {
|
||||
const cats = new Set(process.env.CATS.split(',').map(s => s.trim()));
|
||||
cases = cases.filter(c => cats.has(c.cat));
|
||||
}
|
||||
if (process.env.PROMPTS) {
|
||||
const want = new Set(process.env.PROMPTS.split(',').map(s => Number(s.trim())));
|
||||
cases = cases.filter(c => want.has(c.id));
|
||||
}
|
||||
return cases;
|
||||
}
|
||||
|
||||
function saveShot(url, file) {
|
||||
const idx = url.indexOf(',');
|
||||
fs.writeFileSync(file, Buffer.from(url.slice(idx + 1), 'base64'));
|
||||
}
|
||||
|
||||
(async () => {
|
||||
fs.mkdirSync(OUT, { recursive: true });
|
||||
const browser = await chromium.launch({ headless: !process.env.HEADFUL });
|
||||
const context = await browser.newContext({ viewport: { width: 1280, height: 860 }, serviceWorkers: 'block' });
|
||||
await context.addCookies([{ name: 'odysseus_session', value: authToken(), domain: new URL(BASE).hostname, path: '/' }]);
|
||||
const page = await context.newPage();
|
||||
page.on('pageerror', e => console.log(' [pageerror]', String(e).slice(0, 240)));
|
||||
// Per-case image-op HTTP statuses so a real no-op (request succeeded but the
|
||||
// image is unchanged) is distinguishable from an outage (instant 5xx/000).
|
||||
let netLog = [];
|
||||
page.on('response', r => {
|
||||
const m = r.url().match(/\/api\/image\/([a-z-]+)/);
|
||||
if (m) netLog.push({ op: m[1], status: r.status() });
|
||||
});
|
||||
page.on('requestfailed', r => {
|
||||
const m = r.url().match(/\/api\/image\/([a-z-]+)/);
|
||||
if (m) netLog.push({ op: m[1], status: 'failed:' + ((r.failure() && r.failure().errorText) || '?') });
|
||||
});
|
||||
|
||||
const results = [];
|
||||
for (const c of selected()) {
|
||||
const tag = String(c.id).padStart(3, '0') + '-' + c.cat + '-' + c.prompt.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '').slice(0, 40);
|
||||
const rec = { id: c.id, cat: c.cat, prompt: c.prompt, photo: c.photo, ms: null, ok: null, blocked: null, reply: null, ops: [], newLayers: [], undoGroups: null, error: null, tag };
|
||||
if (PENDING.has(c.id)) {
|
||||
rec.pending = true;
|
||||
results.push(rec);
|
||||
console.log(`BENCH PENDING #${c.id} [${c.cat}] "${c.prompt}" (heavy in-process model; excluded)`);
|
||||
fs.writeFileSync(path.join(OUT, 'quickedit-bench.json'), JSON.stringify({ base: BASE, results }, null, 2));
|
||||
continue;
|
||||
}
|
||||
netLog = [];
|
||||
try {
|
||||
await page.goto(BASE + '/?nodraft=1', { waitUntil: 'domcontentloaded' });
|
||||
await page.locator('#tool-gallery-btn').waitFor({ state: 'attached', timeout: 30000 });
|
||||
await page.evaluate(async () => {
|
||||
const gallery = await import('/static/js/gallery.js?v=20260830editor4');
|
||||
gallery.openGallery();
|
||||
});
|
||||
await page.locator('#gallery-editor-tab').first().waitFor({ state: 'attached', timeout: 30000 });
|
||||
await page.evaluate(() => document.getElementById('gallery-editor-tab')?.click());
|
||||
const dataUrl = 'data:image/jpeg;base64,' + fs.readFileSync(path.join(PHOTOS, c.photo)).toString('base64');
|
||||
const selfCheck = process.env.SELFCHECK !== '0';
|
||||
const mode = process.env.MODE || '';
|
||||
await page.evaluate(async ({ selfCheck, mode, dataUrl }) => {
|
||||
const editor = await import('/static/js/galleryEditor.js?qb=' + Date.now());
|
||||
window.__geNoDraft = true;
|
||||
if (editor.isEditorOpen()) editor.closeEditor({ force: true });
|
||||
window.geQuickEditSelfCheck = selfCheck;
|
||||
try { if (mode) localStorage.setItem('ge-ai-command-mode', mode); else localStorage.removeItem('ge-ai-command-mode'); } catch (_) {}
|
||||
try { localStorage.setItem('ge-preflight-dismissed', '1'); } catch (_) {}
|
||||
// Size the document to the photo's aspect so importing never leaves
|
||||
// white letterbox margins (which the model then paints as an edge band).
|
||||
const dim = await new Promise((res) => { const im = new Image(); im.onload = () => res({ w: im.naturalWidth || 800, h: im.naturalHeight || 600 }); im.onerror = () => res({ w: 800, h: 600 }); im.src = dataUrl; });
|
||||
const W = 800; const H = Math.max(1, Math.min(1600, Math.round(W * dim.h / dim.w)));
|
||||
editor.openEditor(null, null, { w: W, h: H }, 'BENCH ' + Date.now());
|
||||
}, { selfCheck, mode, dataUrl });
|
||||
await page.locator('.ge-main-canvas').waitFor({ state: 'visible', timeout: 30000 });
|
||||
await page.waitForFunction(() => !!window.geEditorControl && !!window.geEditorAgent, null, { timeout: 20000 });
|
||||
|
||||
const out = await page.evaluate(async ({ dataUrl, prompt, setup }) => {
|
||||
const invoke = (op, params) => window.geEditorControl.invoke(op, params);
|
||||
await invoke('document.importImage', { dataUrl, name: 'photo', raster: true });
|
||||
if (setup === 'text') await invoke('text.add', { text: 'Summer', x: 80, y: 80, size: 64 });
|
||||
else if (setup === 'align') await invoke('layers.addRaster', {});
|
||||
const before = (await invoke('screenshot')).result.dataUrl;
|
||||
const mark0 = (await invoke('history.mark')).result;
|
||||
const layers0 = (await invoke('layers.list')).result.map(l => l.name + ':' + l.kind);
|
||||
const ops = [];
|
||||
const control = window.geEditorControl;
|
||||
const orig = control.invoke.bind(control);
|
||||
control.invoke = async (op, params) => { ops.push(op); return orig(op, params); };
|
||||
window.__selfCheckLog = [];
|
||||
const t0 = Date.now();
|
||||
let run = null, error = null;
|
||||
try {
|
||||
run = window.geQuickEditRun
|
||||
? await window.geQuickEditRun(prompt, {})
|
||||
: await window.geEditorAgent.run(prompt, {});
|
||||
}
|
||||
catch (err) { error = String((err && err.message) || err); }
|
||||
finally { control.invoke = orig; }
|
||||
const ms = Date.now() - t0;
|
||||
const after = (await invoke('screenshot')).result.dataUrl;
|
||||
const mark1 = (await invoke('history.mark')).result;
|
||||
const layers1 = (await invoke('layers.list')).result;
|
||||
const newLayers = layers1.map(l => l.name + ':' + l.kind).filter(s => !layers0.includes(s));
|
||||
return { before, after, mark0, mark1, newLayers, ms, ops, reply: run && (run.reply || run.message), ok: run && run.ok, blocked: run && run.blocked, error, selfChecks: window.__selfCheckLog || [] };
|
||||
}, { dataUrl, prompt: c.prompt, setup: c.setup || null });
|
||||
|
||||
rec.ms = out.ms; rec.ops = out.ops; rec.reply = out.reply; rec.ok = out.ok;
|
||||
rec.blocked = out.blocked; rec.newLayers = out.newLayers;
|
||||
rec.selfChecks = out.selfChecks || [];
|
||||
rec.undoGroups = (typeof out.mark1 === 'number' && typeof out.mark0 === 'number') ? out.mark1 - out.mark0 : null;
|
||||
rec.error = out.error;
|
||||
rec.net = netLog.slice();
|
||||
saveShot(out.before, path.join(OUT, tag + '-before.png'));
|
||||
saveShot(out.after, path.join(OUT, tag + '-after.png'));
|
||||
} catch (err) {
|
||||
rec.error = String((err && err.message) || err);
|
||||
}
|
||||
results.push(rec);
|
||||
for (const sc of (rec.selfChecks || [])) {
|
||||
console.log(`SELFCHECK #${c.id} verdict=${JSON.stringify(sc.verdict)} retry=${sc.retry ? JSON.stringify(sc.retry) : 'null'}`);
|
||||
}
|
||||
console.log(`BENCH ${rec.ok === false ? 'FAIL' : 'OK'} #${c.id} [${c.cat}] "${c.prompt}" ${rec.ms}ms ops=${rec.ops.join(',') || rec.error} net=${JSON.stringify(rec.net || [])}`);
|
||||
fs.writeFileSync(path.join(OUT, 'quickedit-bench.json'), JSON.stringify({ base: BASE, results }, null, 2));
|
||||
}
|
||||
|
||||
const pending = results.filter(r => r.pending).length;
|
||||
const ran = results.filter(r => !r.pending && r.ok !== false && !r.error).length;
|
||||
console.log(`QUICKEDIT_BENCH ${ran}/${results.length - pending} ran without error (${pending} pending) -> ${path.join(OUT, 'quickedit-bench.json')}`);
|
||||
await browser.close();
|
||||
process.exit(0);
|
||||
})().catch(err => { console.error('FAIL', err); process.exit(1); });
|
||||
@@ -0,0 +1,157 @@
|
||||
#!/usr/bin/env node
|
||||
// Quick Edit coverage audit runner (standalone; not a Playwright spec).
|
||||
//
|
||||
// Drives the live editor agent for every prompt in CASES and grades each by
|
||||
// whether an expected control op appears in the executed-step trace.
|
||||
//
|
||||
// Usage:
|
||||
// BASE_URL=http://127.0.0.1:7011 SESSION_FILE=/path/sessions.json \
|
||||
// NODE_PATH=/path/node_modules node quickedit-coverage.js
|
||||
//
|
||||
// PROMPTS=1,2,3 limit to a subset
|
||||
// OUT=/tmp/qe output dir for the JSON report
|
||||
// HEADFUL=1 show the browser
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const { chromium } = require('@playwright/test');
|
||||
|
||||
const BASE = process.env.BASE_URL || 'http://127.0.0.1:7011';
|
||||
const OUT = process.env.OUT || '/tmp/opencode/qe-coverage';
|
||||
const SESSION_FILE = process.env.SESSION_FILE || '/home/pewds/odysseus-cookbook-fresh/data/sessions.json';
|
||||
|
||||
const CASES = [
|
||||
{ id: 1, group: 'Fill', prompt: 'fill the white area with red', expect: ['fill.flood', 'selection.fill'] },
|
||||
{ id: 2, group: 'Fill', prompt: 'flood fill the sky with blue', expect: ['fill.flood', 'selection.fill', 'selection.sky'] },
|
||||
{ id: 3, group: 'Text', prompt: 'make the text bigger', expect: ['text.edit'], setup: 'text' },
|
||||
{ id: 4, group: 'Text', prompt: 'make the title red', expect: ['text.edit'], setup: 'text' },
|
||||
{ id: 5, group: 'Text', prompt: 'add the title "Summer"', expect: ['text.add'] },
|
||||
{ id: 6, group: 'Layer style', prompt: 'add a drop shadow to the title', expect: ['layers.addStyle'], setup: 'text' },
|
||||
{ id: 7, group: 'Layer style', prompt: 'add a stroke to the layer', expect: ['layers.addStyle'] },
|
||||
{ id: 8, group: 'Layer style', prompt: 'add a colour overlay', expect: ['layers.addStyle'] },
|
||||
{ id: 9, group: 'Align', prompt: 'align the logo to the centre', expect: ['layers.align'] },
|
||||
{ id: 10, group: 'Align', prompt: 'align the layer to the left', expect: ['layers.align'] },
|
||||
{ id: 11, group: 'Layer transform', prompt: 'rotate only this layer 90 degrees', expect: ['layer.rotate'] },
|
||||
{ id: 12, group: 'Layer transform', prompt: 'flip this layer horizontally', expect: ['layer.flip'] },
|
||||
{ id: 13, group: 'Document transform', prompt: 'rotate the whole image 90 degrees', expect: ['document.rotate'] },
|
||||
{ id: 14, group: 'Document transform', prompt: 'flip the canvas vertically', expect: ['document.flip'] },
|
||||
{ id: 15, group: 'Crop', prompt: 'crop to a square', expect: ['document.crop'] },
|
||||
{ id: 16, group: 'Crop', prompt: 'make this 16:9', expect: ['document.crop'] },
|
||||
{ id: 17, group: 'Crop', prompt: 'crop to 9:16 for a story', expect: ['document.crop'] },
|
||||
{ id: 18, group: 'Extend', prompt: 'extend the canvas to 16:9', expect: ['ai.outpaint'] },
|
||||
{ id: 19, group: 'Clipboard', prompt: 'copy the selection', expect: ['selection.copy'], setup: 'select' },
|
||||
{ id: 20, group: 'Clipboard', prompt: 'paste', expect: ['selection.paste'], setup: 'select' },
|
||||
{ id: 21, group: 'Selection', prompt: 'select the subject', expect: ['selection.subject'] },
|
||||
{ id: 22, group: 'Selection', prompt: 'select the sky', expect: ['selection.sky'] },
|
||||
{ id: 23, group: 'Selection', prompt: 'select all', expect: ['selection.selectAll'] },
|
||||
{ id: 24, group: 'Selection', prompt: 'invert the selection', expect: ['selection.invert'] },
|
||||
{ id: 25, group: 'Background', prompt: 'remove the background', expect: ['ai.removeBackground', 'selection.background'] },
|
||||
{ id: 26, group: 'Background', prompt: 'blur the background', expect: ['filters.applyMasked', 'filters.apply'] },
|
||||
{ id: 27, group: 'Adjustment', prompt: 'make it brighter', expect: ['photo.autoTone', 'adjustments.apply', 'shadows-highlights'] },
|
||||
{ id: 28, group: 'Adjustment', prompt: 'make it black and white', expect: ['adjustments.apply', 'black-white'], assert: 'black' },
|
||||
{ id: 29, group: 'Adjustment', prompt: 'warm it up', expect: ['adjustments.apply', 'photo.autoTone', 'white-balance'] },
|
||||
{ id: 30, group: 'Adjustment', prompt: 'make it punchy', expect: ['photo.autoTone', 'adjustments.apply', 'vibrance'] },
|
||||
{ id: 31, group: 'Adjustment', prompt: 'make it moody and cinematic', expect: ['adjustments.apply', 'white-balance'] },
|
||||
{ id: 32, group: 'Restore', prompt: 'restore this old photo', expect: ['photo.autoTone', 'adjustments.apply'] },
|
||||
{ id: 33, group: 'Retouch', prompt: 'smooth the skin', expect: ['retouch.skin', 'adjustments.apply'] },
|
||||
{ id: 34, group: 'Retouch', prompt: 'whiten the teeth', expect: ['retouch.teeth', 'adjustments.apply'] },
|
||||
{ id: 35, group: 'Retouch', prompt: 'remove the blemishes', expect: ['retouch.blemish', 'retouch.skin', 'adjustments.apply'] },
|
||||
{ id: 36, group: 'Filter', prompt: 'sharpen it', expect: ['filters.apply', 'adjustments.apply', 'photo.autoTone'] },
|
||||
{ id: 37, group: 'Filter', prompt: 'add a vignette', expect: ['filters.apply', 'adjustments.apply'] },
|
||||
{ id: 38, group: 'Layers', prompt: 'add a new layer', expect: ['layers.addRaster', 'layers.addAdjustment'] },
|
||||
{ id: 39, group: 'Layers', prompt: 'duplicate this layer', expect: ['layers.duplicate'] },
|
||||
{ id: 40, group: 'Layers', prompt: 'flatten the image', expect: ['layers.flatten'] },
|
||||
{ id: 41, group: 'Layers', prompt: 'merge the layer down', expect: ['layers.mergeDown'] },
|
||||
{ id: 42, group: 'Document', prompt: 'save as PSD', expect: ['document.savePsd'] },
|
||||
{ id: 43, group: 'Document', prompt: 'resize to 1920 wide', expect: ['document.resizeCanvas'] },
|
||||
{ id: 44, group: 'AI edit', prompt: 'remove the person on the left', expect: ['ai.generativeFill'] },
|
||||
{ id: 45, group: 'AI generate', prompt: 'add a full moon in the sky', expect: ['ai.generativeFill'] },
|
||||
];
|
||||
|
||||
function authToken() {
|
||||
const data = JSON.parse(fs.readFileSync(SESSION_FILE, 'utf8'));
|
||||
const now = Date.now() / 1000;
|
||||
for (const [token, info] of Object.entries(data)) {
|
||||
if (info.expiry == null || info.expiry > now) return token;
|
||||
}
|
||||
throw new Error(`no unexpired session in ${SESSION_FILE}`);
|
||||
}
|
||||
|
||||
function selected() {
|
||||
const raw = process.env.PROMPTS;
|
||||
if (!raw) return CASES;
|
||||
const want = new Set(raw.split(',').map(s => Number(s.trim())));
|
||||
return CASES.filter(c => want.has(c.id));
|
||||
}
|
||||
|
||||
(async () => {
|
||||
fs.mkdirSync(OUT, { recursive: true });
|
||||
const browser = await chromium.launch({ headless: !process.env.HEADFUL });
|
||||
const context = await browser.newContext({ viewport: { width: 1280, height: 860 }, serviceWorkers: 'block' });
|
||||
await context.addCookies([{ name: 'odysseus_session', value: authToken(), domain: new URL(BASE).hostname, path: '/' }]);
|
||||
const page = await context.newPage();
|
||||
page.on('pageerror', e => console.log(' [pageerror]', String(e).slice(0, 240)));
|
||||
|
||||
const results = [];
|
||||
for (const c of selected()) {
|
||||
let record = { id: c.id, group: c.group, prompt: c.prompt, expect: c.expect, ops: [], pass: false, error: null };
|
||||
try {
|
||||
await page.goto(BASE + '/?nodraft=1', { waitUntil: 'domcontentloaded' });
|
||||
await page.locator('#tool-gallery-btn').waitFor({ state: 'attached', timeout: 30000 });
|
||||
await page.evaluate(async () => {
|
||||
const gallery = await import('/static/js/gallery.js?v=20260830editor4');
|
||||
gallery.openGallery();
|
||||
});
|
||||
await page.locator('#gallery-editor-tab').first().waitFor({ state: 'attached', timeout: 30000 });
|
||||
await page.evaluate(() => document.getElementById('gallery-editor-tab')?.click());
|
||||
await page.evaluate(async () => {
|
||||
const editor = await import('/static/js/galleryEditor.js?qe=' + Date.now());
|
||||
window.__geNoDraft = true;
|
||||
if (editor.isEditorOpen()) editor.closeEditor({ force: true });
|
||||
editor.openEditor(null, null, { w: 640, h: 480 }, 'QE ' + Date.now());
|
||||
});
|
||||
await page.locator('.ge-main-canvas').waitFor({ state: 'visible', timeout: 30000 });
|
||||
await page.waitForFunction(() => !!window.geEditorControl, null, { timeout: 20000 });
|
||||
|
||||
if (c.setup) {
|
||||
await page.evaluate(async (setup) => {
|
||||
const control = window.geEditorControl;
|
||||
if (setup === 'text') await control.invoke('text.add', { text: 'Summer', x: 80, y: 80, size: 64 });
|
||||
else if (setup === 'select') await control.invoke('selection.rectangle', { x: 100, y: 100, width: 200, height: 160 });
|
||||
}, c.setup);
|
||||
}
|
||||
|
||||
const trace = await page.evaluate(async (prompt) => {
|
||||
const ops = [];
|
||||
const control = window.geEditorControl;
|
||||
const orig = control.invoke.bind(control);
|
||||
control.invoke = async (op, params) => { ops.push({ op, params }); return orig(op, params); };
|
||||
let result = null, error = null;
|
||||
try {
|
||||
result = window.geQuickEditRun
|
||||
? await window.geQuickEditRun(prompt, {})
|
||||
: await window.geEditorAgent.run(prompt, {});
|
||||
}
|
||||
catch (err) { error = String((err && err.message) || err); }
|
||||
finally { control.invoke = orig; }
|
||||
return { ops, reply: result && (result.reply || result.message), error };
|
||||
}, c.prompt);
|
||||
|
||||
record.ops = trace.ops.map(o => o.op);
|
||||
record.reply = trace.reply;
|
||||
record.error = trace.error;
|
||||
const opHit = c.expect.some(e => record.ops.some(o => o === e || o.startsWith(e + '.')));
|
||||
const assertHit = !c.assert || trace.ops.some(o => JSON.stringify(o.params || {}).includes(c.assert));
|
||||
record.pass = opHit && assertHit;
|
||||
} catch (err) {
|
||||
record.error = String((err && err.message) || err);
|
||||
}
|
||||
results.push(record);
|
||||
console.log(`QE ${record.pass ? 'PASS' : 'FAIL'} #${c.id} [${c.group}] "${c.prompt}" -> ${record.ops.join(', ') || record.error}`);
|
||||
}
|
||||
|
||||
const pass = results.filter(r => r.pass).length;
|
||||
fs.writeFileSync(path.join(OUT, 'quickedit-coverage.json'), JSON.stringify({ base: BASE, pass, total: results.length, results }, null, 2));
|
||||
console.log(`QUICKEDIT_COVERAGE ${pass}/${results.length} passed -> ${path.join(OUT, 'quickedit-coverage.json')}`);
|
||||
await browser.close();
|
||||
process.exit(0);
|
||||
})().catch(err => { console.error('FAIL', err); process.exit(1); });
|
||||
@@ -45,20 +45,10 @@ test('selection boundary animates, moves independently, and supports Quick Mask'
|
||||
expect((await editorState(page)).quickMaskActive).toBe(false);
|
||||
});
|
||||
|
||||
test('marquee supports exact fixed-size and fixed-ratio geometry', async ({ page }) => {
|
||||
test('marquee supports exact fixed-ratio geometry', async ({ page }) => {
|
||||
await openBlankEditor(page, { width: 320, height: 240 }, 'Precise marquee');
|
||||
await selectTool(page, 'marquee');
|
||||
|
||||
await page.locator('#ge-marquee-constraint').selectOption('size');
|
||||
await page.locator('#ge-marquee-width').fill('80');
|
||||
await page.locator('#ge-marquee-width').blur();
|
||||
await page.locator('#ge-marquee-height').fill('60');
|
||||
await page.locator('#ge-marquee-height').blur();
|
||||
const box = await page.locator('.ge-main-canvas').boundingBox();
|
||||
await page.mouse.click(Math.round(box.x + box.width * 0.8), Math.round(box.y + box.height * 0.75));
|
||||
await expect.poll(async () => (await editorState(page)).selection?.bounds).toEqual({ x: 240, y: 180, width: 80, height: 60 });
|
||||
|
||||
await page.locator('#ge-marquee-clear').click();
|
||||
await page.locator('#ge-marquee-constraint').selectOption('ratio');
|
||||
await page.locator('#ge-marquee-width').fill('4');
|
||||
await page.locator('#ge-marquee-width').blur();
|
||||
@@ -80,7 +70,7 @@ test('transform selection moves, scales, rotates, cancels, and preserves layer p
|
||||
|
||||
await page.locator('#ge-selection-menu-btn').click();
|
||||
await page.locator('[data-selection-action="transform"]').click();
|
||||
await expect(page.locator('.ge-transform-popup')).toBeVisible();
|
||||
await expect(page.locator('#ge-transform-section')).toBeVisible();
|
||||
await expect(page.locator('.ge-transform-popup .ge-adj-title')).toHaveText('Transform Selection');
|
||||
await dragOnCanvas(page, { x: 0.35, y: 0.4 }, { x: 0.45, y: 0.47 });
|
||||
const moved = await editorState(page);
|
||||
@@ -116,14 +106,11 @@ test('transform selection moves, scales, rotates, cancels, and preserves layer p
|
||||
await page.waitForTimeout(250);
|
||||
await page.locator('#ge-selection-menu-btn').click();
|
||||
await page.locator('[data-selection-action="transform"]').click();
|
||||
const mobilePopup = await page.locator('.ge-transform-popup').boundingBox();
|
||||
const mobileTitle = await page.locator('.ge-transform-popup .ge-adj-title').boundingBox();
|
||||
expect(mobilePopup.x).toBeGreaterThanOrEqual(0);
|
||||
expect(mobilePopup.y).toBeGreaterThanOrEqual(0);
|
||||
expect(mobilePopup.x + mobilePopup.width).toBeLessThanOrEqual(390);
|
||||
expect(mobilePopup.y + mobilePopup.height).toBeLessThanOrEqual(844);
|
||||
expect(mobileTitle.x).toBeGreaterThanOrEqual(mobilePopup.x);
|
||||
expect(mobileTitle.x + mobileTitle.width).toBeLessThanOrEqual(mobilePopup.x + mobilePopup.width);
|
||||
const mobileSection = await page.locator('#ge-transform-section').boundingBox();
|
||||
expect(mobileSection.x).toBeGreaterThanOrEqual(0);
|
||||
expect(mobileSection.y).toBeGreaterThanOrEqual(0);
|
||||
expect(mobileSection.x + mobileSection.width).toBeLessThanOrEqual(390);
|
||||
expect(mobileSection.y + mobileSection.height).toBeLessThanOrEqual(844);
|
||||
await page.locator('#ge-transform-cancel-btn').click();
|
||||
});
|
||||
|
||||
|
||||
@@ -4,7 +4,8 @@ const { openBlankEditor } = require('./helpers');
|
||||
|
||||
const FONTS = [
|
||||
'Inter', 'Roboto', 'Playfair Display', 'Lobster',
|
||||
'Bebas Neue', 'Roboto Mono', 'Georgia', 'Courier New',
|
||||
'Bebas Neue', 'Roboto Mono', 'Space Grotesk', 'Fraunces',
|
||||
'Syne', 'Bungee', 'Instrument Serif', 'Georgia', 'Courier New',
|
||||
];
|
||||
const md5 = (buf) => crypto.createHash('md5').update(buf).digest('hex');
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ const { openBlankEditor } = require('./helpers');
|
||||
test('transform frame exposes eight accurate handles and supports edge resize', async ({ page }) => {
|
||||
await openBlankEditor(page, { width: 640, height: 480 }, 'Transform frame');
|
||||
await selectTool(page, 'transform');
|
||||
await expect(page.locator('.ge-transform-popup')).toBeVisible();
|
||||
await expect(page.locator('#ge-transform-section')).toBeVisible();
|
||||
|
||||
const frame = await page.evaluate(async () => {
|
||||
const { state } = await import('/static/js/editor/state.js');
|
||||
@@ -154,7 +154,7 @@ test('rotated frame moves only from its visible interior and supports keyboard n
|
||||
})).toEqual({ x: 250, y: 180 });
|
||||
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(page.locator('.ge-transform-popup')).toBeHidden();
|
||||
await expect(page.locator('#ge-transform-section')).toBeHidden();
|
||||
await expect.poll(async () => page.evaluate(async () => {
|
||||
const { state } = await import('/static/js/editor/state.js');
|
||||
return state.transformActive;
|
||||
@@ -216,7 +216,7 @@ test('mobile touch input can grab every transform handle', async ({ browser, bro
|
||||
try {
|
||||
await openBlankEditor(page, { width: 640, height: 480 }, 'Mobile transform handles');
|
||||
await selectTool(page, 'transform');
|
||||
await expect(page.locator('.ge-transform-popup')).toBeVisible();
|
||||
await expect(page.locator('#ge-transform-section')).toBeVisible();
|
||||
const cdp = await context.newCDPSession(page);
|
||||
const ids = ['tl', 't', 'tr', 'r', 'br', 'b', 'bl', 'l', 'rot'];
|
||||
|
||||
@@ -323,7 +323,7 @@ test('mobile touch input can grab every transform handle', async ({ browser, bro
|
||||
test('transform handle keeps tracking when the pointer crosses the right panel', async ({ page }) => {
|
||||
await openBlankEditor(page, { width: 640, height: 480 }, 'Transform overshoot');
|
||||
await selectTool(page, 'transform');
|
||||
await expect(page.locator('.ge-transform-popup')).toBeVisible();
|
||||
await expect(page.locator('#ge-transform-section')).toBeVisible();
|
||||
|
||||
const setup = await page.evaluate(async () => {
|
||||
const { state } = await import('/static/js/editor/state.js');
|
||||
|
||||
Vendored
+17
@@ -0,0 +1,17 @@
|
||||
# CA trust regression fixtures
|
||||
|
||||
The public certificates embedded in `tests/test_ca_trust.py` exercise the October 2026 eMudhra trust-store check. They contain no private keys. `emsign-g3.pem` and `aaa.pem` are the incident's affected trust anchors; `emsign-g1.pem` is an unaffected emSign anchor that must remain usable. `localhost-leaf.pem` is a synthetic self-signed Ed25519 server certificate with `CA:FALSE`, generated solely to reproduce hashed non-CA certificates found in Ubuntu's OpenSSL directory; its private key was discarded. The test creates temporary PEM files from these public constants. The certificate fingerprints are the factual identity of each fixture.
|
||||
|
||||
Sources: the [incident root list](https://emudhra.we-have-a-problem.com/roots.json) and the public Mozilla root certificates distributed in Debian's `ca-certificates` package.
|
||||
|
||||
`incident.json` contains the four published roots, sixteen [compromised intermediate certificates and cross-signs](https://emudhra.we-have-a-problem.com/impacted-intermediates.json), and the [fourteen published server chains](https://emudhra.we-have-a-problem.com/chains.zip), retrieved on 2026-10-21 with chains refreshed on 2026-10-23. The refresh adds the two DV G3B hosts without Certificate Transparency entries and preserves the original twelve chains. Certificates are deduplicated by SHA-256 of DER; each chain lists its leaf first. The additional cross-signed `emSign Root TLS CA - G3` issuer comes from those chains and has fingerprint `3094025792039a68af6e553686410bdab7c0d4a36b9084cba64202e35d6ebbdd`. Like the intermediates, it must not be accepted as a custom trust anchor. All data is public certificate material, with no private keys.
|
||||
|
||||
Downloaded source SHA-256 values:
|
||||
|
||||
- `roots.json`: `119f32cd81a6b5a4e3c4c2df5fe07daa90dc43cafa123a62f0394f1895d92380`
|
||||
- `impacted-intermediates.json`: `f67b3abe9b4879f4e91874ed041beccd71257f1db41c588c724ccf12aa90c59e`
|
||||
- `chains.zip`: `7b949956955fc501a30f4d98d2f513d34c0bbbe92e29a968194c96e097ce2345`
|
||||
|
||||
The offline OpenSSL regression verifies server purpose, hostname and strict certificate rules with partial-chain trust, at `2026-10-23T12:00:00Z` (`1792756800`). Fixing the verification date keeps later certificate expiry from masking a broken mitigation. All fourteen chains must verify against the affected positive-control roots and fail against the unaffected G1-only store. Adding the compromised DV G3B intermediate restores four paths, including the two new hosts, which the checker must reject. The regression makes no network requests and skips chain verification if the OpenSSL executable is unavailable; certificate-inventory tests still run using Python's standard library.
|
||||
|
||||
The upstream SearXNG image validation uses the same `incident.json` inventory and fixed verification timestamp, checking each stored DER fingerprint before use. See [the image documentation](../../../docs/searxng-trust-image.md) for its validation targets and source provenance.
|
||||
+132
@@ -0,0 +1,132 @@
|
||||
{
|
||||
"verification_time": 1792756800,
|
||||
"roots": {
|
||||
"7dd78d5f4f13459a83dff9abbba62edbaf6f2d102bf257fd712f4d9f2746ed8d": "emSign Root TLS CA - G3",
|
||||
"f2edaaff3d9b701fb4d14c9f285f8c0bad90fc3bcc1fa07d47f4718edaba34a5": "emSign Root CS CA - G3",
|
||||
"d7a7a0fb5d7e2731d771e9484ebcdef71d5f0c3e0a2948782bc83ee0ea699ef4": "AAA Certificate Services",
|
||||
"86a1ecba089c4a8d3bbe2734c612ba341d813e043cf9e8a862cd5c57a36bbe6b": "emSign ECC Root CA - G3"
|
||||
},
|
||||
"intermediates": {
|
||||
"42c4accd12c4f59597925e1a08a7a90102608543809308d50ec427e118ba1493": "EM DV TLS CA - G3B (eMudhra)",
|
||||
"45a883f349aedfd5f3a649b5498ffb121712ba8b448f3f2a91dbc5a766fcddbe": "EM DV TLS CA - G3B (Sectigo)",
|
||||
"98b896625c0c230adeec3eb2cf6aad5001ecf9156a68644e82374ce5833bc25b": "EM OV TLS CA - G3B (eMudhra)",
|
||||
"5f1ded4dd590af74631290cf073b122a2aac6a1fa479778a0bd43579bd832699": "EM OV TLS CA - G3B (Sectigo)",
|
||||
"e7568fe6e12331f4fd7de61da4c7559d0f8eb192a218d4741a3ba75184333838": "EM EV TLS CA - G3B (eMudhra)",
|
||||
"579480aea166290bd3c643589e795fc0d0e54e86505d180f1dff99efd3199512": "EM EV TLS CA - G3B (Sectigo)",
|
||||
"0164d7ceae5bb909c272af151ff44b1177ae9b47d9424b94f1d9558c30934d19": "EM DV TLS CA - G3C (eMudhra)",
|
||||
"62b7f9868263f72f6911fc78ceaf72c1979158b7368bf648683bb2aa39b02e72": "EM DV TLS CA - G3C (Sectigo)",
|
||||
"cb6e15165ac802e9b3f993f16c25d41f4e81c21ebde71ae1b012ac00bf9c7c3f": "EM OV TLS CA - G3C (eMudhra)",
|
||||
"7485b51c0ed192878fa1d2f948fb529ae1842cc5086c4b67f7be5b2039de9489": "EM OV TLS CA - G3C (Sectigo)",
|
||||
"432ec25f0d28de9006d97bf752f17bbb2cd41f669933fcb072d9a1148d0261f3": "EM EV TLS CA - G3C (eMudhra)",
|
||||
"fd9d5c2dbbd4995ab69b8d50c439d9527b00529199c2fd01b2174eb58f07021f": "EM EV TLS CA - G3C (Sectigo)",
|
||||
"9e39de2881273acc225a149c72ba3e0f51424963669ab633ee1dc7df5db067b4": "EM OV CS CA - G3B (eMudhra)",
|
||||
"a496e23ac0cab8155fc9b117b97eae368efcbb9041aec8804d5a308d2d01c2d8": "EM OV CS CA - G3B (Sectigo)",
|
||||
"4b2460a466f95fd40113bcdcca2ceb29b3b093921de45474da7df48874c630b1": "EM OV CS CA - G3C (eMudhra)",
|
||||
"84112972b8e59d14560ae7e7119654474aad119990cf3722bfc1e3b91615e1fd": "EM OV CS CA - G3C (Sectigo)"
|
||||
},
|
||||
"certificates": {
|
||||
"0164d7ceae5bb909c272af151ff44b1177ae9b47d9424b94f1d9558c30934d19": "-----BEGIN CERTIFICATE-----\nMIIDaDCCAu6gAwIBAgIPWIsuPCu4zaE+GhIJJUMhMAoGCCqGSM49BAMDMFYxCzAJ\nBgNVBAYTAklOMSUwIwYDVQQKDBxlTXVkaHJhIFRlY2hub2xvZ2llcyBMaW1pdGVk\nMSAwHgYDVQQDDBdlbVNpZ24gUm9vdCBUTFMgQ0EgLSBHMzAeFw0yNTA0MzAxNTM1\nMDlaFw00MDA0MjgxODMwMDBaMEAxCzAJBgNVBAYTAlVTMRQwEgYDVQQKDAtlTXVk\naHJhIEluYzEbMBkGA1UEAwwSRU0gRFYgVExTIENBIC0gRzNDMHYwEAYHKoZIzj0C\nAQYFK4EEACIDYgAEOnp4YsjETTJN3WkDOmeCwO/u/kBV5ooSP6A3G6O9kZRRTuFy\npDPjYw8CvRiAN9nlFJ+bd9uUooHfmHsv6oiLdL3+j9pMEJnubKGVZv4+8OX2sJpV\nbMyLpQVh3xsqRB0Bo4IBljCCAZIwHwYDVR0jBBgwFoAU4TqrIXzx401UrtRyt/2L\nWUIgkFQwHQYDVR0OBBYEFK5+EFw8KZbBqEcnBVnx4D3U5QrzMB0GA1UdJQQWMBQG\nCCsGAQUFBwMBBggrBgEFBQcDAjBdBgNVHSAEVjBUMDkGCysGAQQBg44hAQABMCow\nKAYIKwYBBQUHAgEWHGh0dHA6Ly9yZXBvc2l0b3J5LmVtc2lnbi5jb20wDQYLKwYB\nBAGDjiEBAmQwCAYGZ4EMAQIBMHUGCCsGAQUFBwEBBGkwZzAkBggrBgEFBQcwAYYY\naHR0cDovL29jc3AtYy5lbXNpZ24uY29tMD8GCCsGAQUFBzAChjNodHRwOi8vcmVw\nb3NpdG9yeS5lbXNpZ24uY29tL2NlcnRzL0VNRFZUTFNDQUczQy5wN2MwNwYDVR0f\nBDAwLjAsoCqgKIYmaHR0cDovL2NybC5lbXNpZ24uY29tLz9yb290dGxzY2FnMy5j\ncmwwDgYDVR0PAQH/BAQDAgEGMBIGA1UdEwEB/wQIMAYBAf8CAQAwCgYIKoZIzj0E\nAwMDaAAwZQIxAMEN2HWMocbuWdy1d33pH6JpMvP/V/DIS5PtZbv1Q7TXEbxrcyxg\nyuddOJscYDJJlAIwB4sH1BpbScDzkdD4tJBSwrG/1Mx82vb8YaySehNuu1+1oXNd\nAEKwaJ0mVzScy2+6\n-----END CERTIFICATE-----\n",
|
||||
"0cbfe22b824385823fd3bf4be4d131e8901342933a7feef1cf8b3110b8e44a70": "-----BEGIN CERTIFICATE-----\nMIICQTCCAcigAwIBAgIUG01Z/jOotSvB49K4K27kFjpdnWMwCgYIKoZIzj0EAwIw\nPzELMAkGA1UEBhMCTkwxEzARBgNVBAoMCmVNdWRocmEgQlYxGzAZBgNVBAMMEkVN\nIERWIFRMUyBDQSAtIEczQjAeFw0yNjEwMjIxMjU3MzFaFw0yNzA1MDUxMzAyMzFa\nMDQxMjAwBgNVBAMMKWR2LWczYi1ub2N0LWVtdWRocmEud2UtaGF2ZS1hLXByb2Js\nZW0uY29tMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEI805yWr+RxanILifET+/\nGwHsXo7dp8ghA3OgwR0Mg11Bl6iYyOzldy4iU7kM+3qfDNsUrzxvkUkJZQpYieuC\n+aOBrDCBqTAMBgNVHRMBAf8EAjAAMA4GA1UdDwEB/wQEAwIHgDATBgNVHSUEDDAK\nBggrBgEFBQcDATA0BgNVHREELTArgilkdi1nM2Itbm9jdC1lbXVkaHJhLndlLWhh\ndmUtYS1wcm9ibGVtLmNvbTAdBgNVHQ4EFgQUOlRrLQGIczrmr4H+Oak06aUmJ4Yw\nHwYDVR0jBBgwFoAU4h4v5xwZOQ5VS2LBICRXNSSKJRQwCgYIKoZIzj0EAwIDZwAw\nZAIwf4ekmRgXAxXP2AjpVY2f1bO42GC7Nnc80t4OpaWJeVdXpT+X3dOsLRwlIUm0\nYbMwAjBihTx7oHfaYB3KRnFuhKqlHYmUcOe9Hjxqs6bubZohhrqMQzPocyasDhJL\nUx3HsgQ=\n-----END CERTIFICATE-----\n",
|
||||
"21f57f83631a93c98e867050075746b3361e0433c9e0e969462852476784f6f0": "-----BEGIN CERTIFICATE-----\nMIICPzCCAcagAwIBAgIUH0gWfBRnOiSjcduti/cukjDGAjIwCgYIKoZIzj0EAwIw\nPzELMAkGA1UEBhMCTkwxEzARBgNVBAoMCmVNdWRocmEgQlYxGzAZBgNVBAMMEkVN\nIE9WIFRMUyBDQSAtIEczQjAeFw0yNjEwMjAwMjIyNDJaFw0yNzA1MDMwMjI3NDJa\nMDMxMTAvBgNVBAMMKG92LWczYi1hYWEtZW11ZGhyYS53ZS1oYXZlLWEtcHJvYmxl\nbS5jb20wWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAAQ3EfjVE+6fUm9gr7UoUv6N\nYPttIdILR9OMZnAdTj+RWT1Z4fp7dpC5mat1gbIo4J6EDelcOQQz5/oGeB3gFJKw\no4GrMIGoMAwGA1UdEwEB/wQCMAAwDgYDVR0PAQH/BAQDAgeAMBMGA1UdJQQMMAoG\nCCsGAQUFBwMBMDMGA1UdEQQsMCqCKG92LWczYi1hYWEtZW11ZGhyYS53ZS1oYXZl\nLWEtcHJvYmxlbS5jb20wHQYDVR0OBBYEFKzQPp6gTBOWb0TBxZbkL5zsQn3oMB8G\nA1UdIwQYMBaAFJvYZVGv5+csN60I7mkZ2b83hK4KMAoGCCqGSM49BAMCA2cAMGQC\nMARKrRRBEWY2Ix1d80DGUvC7bWENP1Q9pf6BOGF4+84UiwV5+nAuht/+hdpAanWB\nsQIwJF2dW8tpPN6lTm1kDerzG6yJAJOz4drotaTthMF2+efuv5W2FL7Dr8qZSd1z\n201o\n-----END CERTIFICATE-----\n",
|
||||
"3094025792039a68af6e553686410bdab7c0d4a36b9084cba64202e35d6ebbdd": "-----BEGIN CERTIFICATE-----\nMIIDdDCCAvmgAwIBAgIQAJYaPvsTWVdu0EAFtGhKWzAKBggqhkjOPQQDAzBrMQsw\nCQYDVQQGEwJJTjETMBEGA1UECxMKZW1TaWduIFBLSTElMCMGA1UEChMcZU11ZGhy\nYSBUZWNobm9sb2dpZXMgTGltaXRlZDEgMB4GA1UEAxMXZW1TaWduIEVDQyBSb290\nIENBIC0gRzMwHhcNMjQwNzExMTU0MzM2WhcNMzkwNzA5MTgzMDAwWjBWMQswCQYD\nVQQGEwJJTjElMCMGA1UECgwcZU11ZGhyYSBUZWNobm9sb2dpZXMgTGltaXRlZDEg\nMB4GA1UEAwwXZW1TaWduIFJvb3QgVExTIENBIC0gRzMwdjAQBgcqhkjOPQIBBgUr\ngQQAIgNiAARfD9w/q1cWxL6D9bMGH6CQ4u+gAV6GN9s+PwQx9Bl4hC+0iU8QOk8m\nWO/RwXQy10DCSxT8vSIJClHKp+Ao4qJKdYeV0SwZ7LMQziyLfxIUOY6Grja/sQFw\nLKYf8EdDXAyjggF1MIIBcTAfBgNVHSMEGDAWgBR8XQKEE9TMipuBzhccLikenEhj\nQjAdBgNVHQ4EFgQU4TqrIXzx401UrtRyt/2LWUIgkFQwHQYDVR0lBBYwFAYIKwYB\nBQUHAwEGCCsGAQUFBwMCMD0GA1UdIAQ2MDQwMgYEVR0gADAqMCgGCCsGAQUFBwIB\nFhxodHRwOi8vcmVwb3NpdG9yeS5lbXNpZ24uY29tMHgGCCsGAQUFBwEBBGwwajAi\nBggrBgEFBQcwAYYWaHR0cDovL29jc3AuZW1zaWduLmNvbTBEBggrBgEFBQcwAoY4\naHR0cDovL3JlcG9zaXRvcnkuZW1zaWduLmNvbS9jZXJ0cy9lbVNpZ25FQ0NSb290\nQ0FHMy5jcnQwMwYDVR0fBCwwKjAooCagJIYiaHR0cDovL2NybC5lbXNpZ24uY29t\nP1Jvb3RDQUczLmNybDAOBgNVHQ8BAf8EBAMCAQYwEgYDVR0TAQH/BAgwBgEB/wIB\nATAKBggqhkjOPQQDAwNpADBmAjEAt9CIpRGp46QHQYDUBnLKw+vvK0/h9vmilEAg\nTroUl/anH4x2rXHqiCtBTBfgKDegAjEA8WpG1LTZdAIL0FSYqxXeREaeTSxEofM0\n1jJH3jGvCXC79LU9UPJ2o3TU+UIj2gMV\n-----END CERTIFICATE-----\n",
|
||||
"346cdd962e97189b59aee5526d1ae8fa714bb1a64094dfa67bef768ee93f36b5": "-----BEGIN CERTIFICATE-----\nMIICODCCAb+gAwIBAgIUbWvtwSNK/Qro223vAqyz323+VBAwCgYIKoZIzj0EAwIw\nQDELMAkGA1UEBhMCVVMxFDASBgNVBAoMC2VNdWRocmEgSW5jMRswGQYDVQQDDBJF\nTSBEViBUTFMgQ0EgLSBHM0MwHhcNMjYxMDIwMDA1OTM2WhcNMjcwNTAzMDEwNDM2\nWjAvMS0wKwYDVQQDDCRkdi1nM2MtZW11ZGhyYS53ZS1oYXZlLWEtcHJvYmxlbS5j\nb20wWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAAScdBQpNUOOCcag6YEVFccNRSFe\nUIl02iH53vDfoWPJFS005WzqF3CPDclB/kygP2gWF4pg/r5gPYdnI3/6Vdzro4Gn\nMIGkMAwGA1UdEwEB/wQCMAAwDgYDVR0PAQH/BAQDAgeAMBMGA1UdJQQMMAoGCCsG\nAQUFBwMBMC8GA1UdEQQoMCaCJGR2LWczYy1lbXVkaHJhLndlLWhhdmUtYS1wcm9i\nbGVtLmNvbTAdBgNVHQ4EFgQUBj2yGO/TzDOE/u/6QeUvcW8y1iMwHwYDVR0jBBgw\nFoAUrn4QXDwplsGoRycFWfHgPdTlCvMwCgYIKoZIzj0EAwIDZwAwZAIwBQOOQqG9\nccdzM/7XIlXLtota1esALaLmgEN/ZweosBIvk4xabHcERZInWVIwO45NAjBos8Ue\nNwDe1X3ahdK2lkxqBEoBMtUHJN18TnJKs2nK1gmXpMbmY5oKnAQQgCEZPA4=\n-----END CERTIFICATE-----\n",
|
||||
"42c4accd12c4f59597925e1a08a7a90102608543809308d50ec427e118ba1493": "-----BEGIN CERTIFICATE-----\nMIIDaDCCAu6gAwIBAgIQAKSu5njyM2K0wORgt3LyFDAKBggqhkjOPQQDAzBWMQsw\nCQYDVQQGEwJJTjElMCMGA1UECgwcZU11ZGhyYSBUZWNobm9sb2dpZXMgTGltaXRl\nZDEgMB4GA1UEAwwXZW1TaWduIFJvb3QgVExTIENBIC0gRzMwHhcNMjUwNDMwMTU1\nNDA4WhcNNDAwNDI4MTgzMDAwWjA/MQswCQYDVQQGEwJOTDETMBEGA1UECgwKZU11\nZGhyYSBCVjEbMBkGA1UEAwwSRU0gRFYgVExTIENBIC0gRzNCMHYwEAYHKoZIzj0C\nAQYFK4EEACIDYgAE557ZwEqXc8RzXlERHahawr1eZKs3S4DhLLwgOOweTw47gS/J\nhZ4eZ6+wTi+LyX90hbplqpEe6KTkW2LVzAPVa+Z4s8OMtNyTfKJGDiOEU8iSk10p\n1E+R4VYGtjzO2rRoo4IBljCCAZIwHwYDVR0jBBgwFoAU4TqrIXzx401UrtRyt/2L\nWUIgkFQwHQYDVR0OBBYEFOIeL+ccGTkOVUtiwSAkVzUkiiUUMB0GA1UdJQQWMBQG\nCCsGAQUFBwMBBggrBgEFBQcDAjBdBgNVHSAEVjBUMDkGCysGAQQBg44hAQABMCow\nKAYIKwYBBQUHAgEWHGh0dHA6Ly9yZXBvc2l0b3J5LmVtc2lnbi5jb20wDQYLKwYB\nBAGDjiEBAmQwCAYGZ4EMAQIBMHUGCCsGAQUFBwEBBGkwZzAkBggrBgEFBQcwAYYY\naHR0cDovL29jc3AtYi5lbXNpZ24uY29tMD8GCCsGAQUFBzAChjNodHRwOi8vcmVw\nb3NpdG9yeS5lbXNpZ24uY29tL2NlcnRzL0VNRFZUTFNDQUczQi5wN2MwNwYDVR0f\nBDAwLjAsoCqgKIYmaHR0cDovL2NybC5lbXNpZ24uY29tLz9yb290dGxzY2FnMy5j\ncmwwDgYDVR0PAQH/BAQDAgEGMBIGA1UdEwEB/wQIMAYBAf8CAQAwCgYIKoZIzj0E\nAwMDaAAwZQIwLmsGXi6oFMJ2klkcEMXMug6gvxQWZWkewiDWVzQyl2L6z6DuQfPO\nIbh1grvdULDCAjEAxZvLlxUavG69J6wnSjC8XBbqNIQsJ6CE2QdNOe84Ptxb6VG8\ngt2TlfJbHii+KNvK\n-----END CERTIFICATE-----\n",
|
||||
"4314941cf242a4ea4149aa7e9cdf77a15de2771a79984b809fc04443fea013c8": "-----BEGIN CERTIFICATE-----\nMIICOTCCAb+gAwIBAgIUXcJ4+VeNEICJ9JbBANtIyfP96EowCgYIKoZIzj0EAwIw\nQDELMAkGA1UEBhMCVVMxFDASBgNVBAoMC2VNdWRocmEgSW5jMRswGQYDVQQDDBJF\nTSBPViBUTFMgQ0EgLSBHM0MwHhcNMjYxMDIwMDA1OTM3WhcNMjcwNTAzMDEwNDM3\nWjAvMS0wKwYDVQQDDCRvdi1nM2MtZW11ZGhyYS53ZS1oYXZlLWEtcHJvYmxlbS5j\nb20wWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAATLEvRHdoPc0Z9ovgqlYmoKTHPU\nApwd6lAp0ou1G63SzuESyBzIW6Tl64CsKzlV9ggkZjX+L9/tOx97/1wmSTYvo4Gn\nMIGkMAwGA1UdEwEB/wQCMAAwDgYDVR0PAQH/BAQDAgeAMBMGA1UdJQQMMAoGCCsG\nAQUFBwMBMC8GA1UdEQQoMCaCJG92LWczYy1lbXVkaHJhLndlLWhhdmUtYS1wcm9i\nbGVtLmNvbTAdBgNVHQ4EFgQUrytBb+px+b58Df+d5vP8GpTW+/wwHwYDVR0jBBgw\nFoAUNOrqDOvJCtr50MXi7Zt4yYeeG2wwCgYIKoZIzj0EAwIDaAAwZQIwNdoOUOKz\nMLV1OFEoMjBeUqvkQAwsajvVWu0A23qyyvYlosyMp8aFv0gxURW6Yj6DAjEAhe0z\nkOQmmqb0eyhjh7weF4fHtwMtLi4jNFA6M/EfPTfkqE91MN9fSin5uhua5xhk\n-----END CERTIFICATE-----\n",
|
||||
"432ec25f0d28de9006d97bf752f17bbb2cd41f669933fcb072d9a1148d0261f3": "-----BEGIN CERTIFICATE-----\nMIIDaDCCAu6gAwIBAgIQAIAJkopkEHYBrwX6LxnxCTAKBggqhkjOPQQDAzBWMQsw\nCQYDVQQGEwJJTjElMCMGA1UECgwcZU11ZGhyYSBUZWNobm9sb2dpZXMgTGltaXRl\nZDEgMB4GA1UEAwwXZW1TaWduIFJvb3QgVExTIENBIC0gRzMwHhcNMjUwNDMwMTUz\nOTMxWhcNNDAwNDI4MTgzMDAwWjBAMQswCQYDVQQGEwJVUzEUMBIGA1UECgwLZU11\nZGhyYSBJbmMxGzAZBgNVBAMMEkVNIEVWIFRMUyBDQSAtIEczQzB2MBAGByqGSM49\nAgEGBSuBBAAiA2IABC1W6Cmm+4LGSkaX2WCvlR6QZ66R+SbTQwbaSKiq63hptqZ+\nbSdCbgRCqgOzbhmI3/Y2UU7hzwf1uks/8qBmRa59MdWGatMao9JG6wsxd+6Zy3Vm\nxRbOdICKZo+R8MR/yqOCAZUwggGRMB8GA1UdIwQYMBaAFOE6qyF88eNNVK7Ucrf9\ni1lCIJBUMB0GA1UdDgQWBBSmJV6AqU2385Z8G8eR35K87kvrXzAdBgNVHSUEFjAU\nBggrBgEFBQcDAQYIKwYBBQUHAwIwXAYDVR0gBFUwUzA5BgsrBgEEAYOOIQEAATAq\nMCgGCCsGAQUFBwIBFhxodHRwOi8vcmVwb3NpdG9yeS5lbXNpZ24uY29tMA0GCysG\nAQQBg44hAQJ4MAcGBWeBDAEBMHUGCCsGAQUFBwEBBGkwZzAkBggrBgEFBQcwAYYY\naHR0cDovL29jc3AtYy5lbXNpZ24uY29tMD8GCCsGAQUFBzAChjNodHRwOi8vcmVw\nb3NpdG9yeS5lbXNpZ24uY29tL2NlcnRzL0VNRVZUTFNDQUczQy5wN2MwNwYDVR0f\nBDAwLjAsoCqgKIYmaHR0cDovL2NybC5lbXNpZ24uY29tLz9yb290dGxzY2FnMy5j\ncmwwDgYDVR0PAQH/BAQDAgEGMBIGA1UdEwEB/wQIMAYBAf8CAQAwCgYIKoZIzj0E\nAwMDaAAwZQIwBy1wTnzH4lXDTy2PD91ysLjvnlVN3JbJmc1BIMEU8TOLkYVHibMc\n9HeyonikFSfmAjEA6uE3QRxlrZTz/1/lXPTEMUhoauAtycagtlNFp79kjhvNa2/f\nAal1GNxsOutLnr0Y\n-----END CERTIFICATE-----\n",
|
||||
"45a883f349aedfd5f3a649b5498ffb121712ba8b448f3f2a91dbc5a766fcddbe": "-----BEGIN CERTIFICATE-----\nMIIDzjCCAragAwIBAgIRAKAk/fS6Zlx4XOSgIEg4WLcwDQYJKoZIhvcNAQELBQAw\nezELMAkGA1UEBhMCR0IxGzAZBgNVBAgMEkdyZWF0ZXIgTWFuY2hlc3RlcjEQMA4G\nA1UEBwwHU2FsZm9yZDEaMBgGA1UECgwRQ29tb2RvIENBIExpbWl0ZWQxITAfBgNV\nBAMMGEFBQSBDZXJ0aWZpY2F0ZSBTZXJ2aWNlczAeFw0yNTA1MTQwMDAwMDBaFw0y\nODEyMzEyMzU5NTlaMD8xCzAJBgNVBAYTAk5MMRMwEQYDVQQKDAplTXVkaHJhIEJW\nMRswGQYDVQQDDBJFTSBEViBUTFMgQ0EgLSBHM0IwdjAQBgcqhkjOPQIBBgUrgQQA\nIgNiAATnntnASpdzxHNeUREdqFrCvV5kqzdLgOEsvCA47B5PDjuBL8mFnh5nr7BO\nL4vJf3SFumWqkR7opORbYtXMA9Vr5nizw4y03JN8okYOI4RTyJKTXSnUT5HhVga2\nPM7atGijggE1MIIBMTAfBgNVHSMEGDAWgBSgEQojPpbxB+zirynvgqV/0DCktDAd\nBgNVHQ4EFgQU4h4v5xwZOQ5VS2LBICRXNSSKJRQwDgYDVR0PAQH/BAQDAgEGMBIG\nA1UdEwEB/wQIMAYBAf8CAQAwHQYDVR0lBBYwFAYIKwYBBQUHAwEGCCsGAQUFBwMC\nMDEGA1UdIAQqMCgwCAYGZ4EMAQIBMA0GCysGAQQBg44hAQABMA0GCysGAQQBg44h\nAQJkMEMGA1UdHwQ8MDowOKA2oDSGMmh0dHA6Ly9jcmwuY29tb2RvY2EuY29tL0FB\nQUNlcnRpZmljYXRlU2VydmljZXMuY3JsMDQGCCsGAQUFBwEBBCgwJjAkBggrBgEF\nBQcwAYYYaHR0cDovL29jc3AuY29tb2RvY2EuY29tMA0GCSqGSIb3DQEBCwUAA4IB\nAQBzQ1IoqpdotM6gFrR4Q3YrKifOLeaj5XXunldpVE/vZn2TxC2y0s8oYiLR5ICE\nwqVXSB7N6BM1Mp+Nif7Jbhb2IswAfSR4Iik8+B0iRmi7lYoNlycCgHjmsjOmZaxM\nQkPmjXAJbG5f4V41OsvJZmS7lI3r0IuvEDVX3GAZ13on6IImSs9ybNpHQ7NCqkMQ\n7wH4uAO1dtpFycgJSdHaCOhHjrox2BTYGLdpUrYwY3EOAjlZ8UGgsxs7mG+xquMl\nbN1LFURwfnkVMP3T/DBIZ/ys2+lNFyxK3FlArrV4/000uTnVB1lO8oGORYdXoT5V\nGUyRCEIDhF5TBUuKF4E3kf1h\n-----END CERTIFICATE-----\n",
|
||||
"4b2460a466f95fd40113bcdcca2ceb29b3b093921de45474da7df48874c630b1": "-----BEGIN CERTIFICATE-----\nMIIDWzCCAuGgAwIBAgIPVEt/kiwxriIKSpIpFvbmMAoGCCqGSM49BAMDMFUxCzAJ\nBgNVBAYTAklOMSUwIwYDVQQKDBxlTXVkaHJhIFRlY2hub2xvZ2llcyBMaW1pdGVk\nMR8wHQYDVQQDDBZlbVNpZ24gUm9vdCBDUyBDQSAtIEczMB4XDTI1MDQzMDE2Mzg0\nMFoXDTQwMDQyODE4MzAwMFowPzELMAkGA1UEBhMCVVMxFDASBgNVBAoMC2VNdWRo\ncmEgSW5jMRowGAYDVQQDDBFFTSBPViBDUyBDQSAtIEczQzB2MBAGByqGSM49AgEG\nBSuBBAAiA2IABLnw4GNHAqacpqrdRDz9YsQbm6SyCSRpfXl052kaEm+5avs+NML9\nO8X0gwYfJl3CpYQ2NHMjUQQ2so8E84YjYwZ5gOJQPgJTLWVGI1pxZaJZoe9Jb3Qb\nX97neHII5Lj676OCAYswggGHMB8GA1UdIwQYMBaAFGh0A/iFyo1UUFcdFuDI3kUN\nq80tMB0GA1UdDgQWBBTub3MpjW+/p7Mbw5U09hVWRHhmtzATBgNVHSUEDDAKBggr\nBgEFBQcDAzBeBgNVHSAEVzBVMDkGCysGAQQBg44hAQABMCowKAYIKwYBBQUHAgEW\nHGh0dHA6Ly9yZXBvc2l0b3J5LmVtc2lnbi5jb20wDgYMKwYBBAGDjiEBAoFIMAgG\nBmeBDAEEATB0BggrBgEFBQcBAQRoMGYwJAYIKwYBBQUHMAGGGGh0dHA6Ly9vY3Nw\nLWMuZW1zaWduLmNvbTA+BggrBgEFBQcwAoYyaHR0cDovL3JlcG9zaXRvcnkuZW1z\naWduLmNvbS9jZXJ0cy9FTU9WQ1NDQUczQy5wN2MwNgYDVR0fBC8wLTAroCmgJ4Yl\naHR0cDovL2NybC5lbXNpZ24uY29tLz9yb290Y3NjYWczLmNybDAOBgNVHQ8BAf8E\nBAMCAQYwEgYDVR0TAQH/BAgwBgEB/wIBADAKBggqhkjOPQQDAwNoADBlAjEA8yPg\n8sntOhLwV2QXJ5LEaKSpo/fnE3uOgTadK73fDhyjiqr0Io9GVuQTYqBPbQ2TAjBg\neuApmBS486+iei03lRrp139dPw6k28jjGNvWsAU7A1N86J1zPJVDEyVH+nMAWC0=\n-----END CERTIFICATE-----\n",
|
||||
"5510ecf5764dee8dfe9468ae2d7323da21fcd5530889906540a2f8725c6f74db": "-----BEGIN CERTIFICATE-----\nMIICSTCCAdCgAwIBAgIUO7oRaIOhMWD0jFgvxusAGNi5CI0wCgYIKoZIzj0EAwIw\nPzELMAkGA1UEBhMCTkwxEzARBgNVBAoMCmVNdWRocmEgQlYxGzAZBgNVBAMMEkVN\nIERWIFRMUyBDQSAtIEczQjAeFw0yNjEwMjIxMzE0NDhaFw0yNzA1MDUxMzE5NDha\nMDgxNjA0BgNVBAMMLWR2LWczYi1hYWEtbm9jdC1lbXVkaHJhLndlLWhhdmUtYS1w\ncm9ibGVtLmNvbTBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABJ0yLMf28vw2E6pO\nc74doinU47pwTYD6xZ60nkrGkqI05DxVKXblos+V5S0+bgDjT2qw1F39EW9EUj8b\nNQmmqv6jgbAwga0wDAYDVR0TAQH/BAIwADAOBgNVHQ8BAf8EBAMCB4AwEwYDVR0l\nBAwwCgYIKwYBBQUHAwEwOAYDVR0RBDEwL4ItZHYtZzNiLWFhYS1ub2N0LWVtdWRo\ncmEud2UtaGF2ZS1hLXByb2JsZW0uY29tMB0GA1UdDgQWBBTruPPaZ1PiEFfpqdLo\n8Ra5Ah5a2jAfBgNVHSMEGDAWgBTiHi/nHBk5DlVLYsEgJFc1JIolFDAKBggqhkjO\nPQQDAgNnADBkAjBUTUTymJylZiZRV/RqMayZdUPJKRDIcn9+sUPpG66AjVgx8M+h\nDGzxxgeroOSxzSwCMBM/r1y7g371RI10nDU16T0WEAO/Udqyyuc8mJ2LCQ+Gxv3c\njU2NXGQLsvosv6T0ww==\n-----END CERTIFICATE-----\n",
|
||||
"579480aea166290bd3c643589e795fc0d0e54e86505d180f1dff99efd3199512": "-----BEGIN CERTIFICATE-----\nMIIDzTCCArWgAwIBAgIRANj4aF5PK9nYJAKoWhwetzEwDQYJKoZIhvcNAQELBQAw\nezELMAkGA1UEBhMCR0IxGzAZBgNVBAgMEkdyZWF0ZXIgTWFuY2hlc3RlcjEQMA4G\nA1UEBwwHU2FsZm9yZDEaMBgGA1UECgwRQ29tb2RvIENBIExpbWl0ZWQxITAfBgNV\nBAMMGEFBQSBDZXJ0aWZpY2F0ZSBTZXJ2aWNlczAeFw0yNTA1MTQwMDAwMDBaFw0y\nODEyMzEyMzU5NTlaMD8xCzAJBgNVBAYTAk5MMRMwEQYDVQQKDAplTXVkaHJhIEJW\nMRswGQYDVQQDDBJFTSBFViBUTFMgQ0EgLSBHM0IwdjAQBgcqhkjOPQIBBgUrgQQA\nIgNiAAQl26haZNOX2gsQUunqVG5Ie+/qnJ2daKDF6tz+73Wmm7XW/AH0K/vKbrQL\nzWTAdn42/9Bdl6gTxaoMbJUw55z18IZkaPE4BkIKpchRhAseJSVA55DktlTDhS7W\nSvHoZX2jggE0MIIBMDAfBgNVHSMEGDAWgBSgEQojPpbxB+zirynvgqV/0DCktDAd\nBgNVHQ4EFgQUPtwg2YlGmId7hz/YgUeyXBXYRwYwDgYDVR0PAQH/BAQDAgEGMBIG\nA1UdEwEB/wQIMAYBAf8CAQAwHQYDVR0lBBYwFAYIKwYBBQUHAwEGCCsGAQUFBwMC\nMDAGA1UdIAQpMCcwBwYFZ4EMAQEwDQYLKwYBBAGDjiEBAAEwDQYLKwYBBAGDjiEB\nAngwQwYDVR0fBDwwOjA4oDagNIYyaHR0cDovL2NybC5jb21vZG9jYS5jb20vQUFB\nQ2VydGlmaWNhdGVTZXJ2aWNlcy5jcmwwNAYIKwYBBQUHAQEEKDAmMCQGCCsGAQUF\nBzABhhhodHRwOi8vb2NzcC5jb21vZG9jYS5jb20wDQYJKoZIhvcNAQELBQADggEB\nAJNFuAFW9Gnw/8+kCO4KIlZdnM1Sh2u+8cK0EYAHhXpb5FlGvYkcARigo5oS7VoM\n7FOYlTueV6yICZKNAYawZPJPhNCGK+rp3cQBEz6d0n8OZEYZov9lb3ll8ORgSDuf\nFVIW6Gagg/UnOamLBTOR8eD5V73SCme/B2cHUDw+m5E8cc+0Ujzz3Cn3ex5JrhXk\n2T3s8PoVzEmhTgGboFfcWO1EYhPPrYwdxPYRQIXAXUXhLEOVNL9ASbZJLFI8E1IM\nm7NwxVN2s38jWpaCUaaF0tg7hfiy7oe13rzJfHsdqldJVTTUTPWVx8TC4ju0VLet\nbAlwhwFT6AP8uIVMGUHNw24=\n-----END CERTIFICATE-----\n",
|
||||
"59cf0386b56bc2414a0c06241ea9e25e01a9c93bac7a596648747f9d0ded4906": "-----BEGIN CERTIFICATE-----\nMIICQTCCAcegAwIBAgIULuWnWW6kFV6QZxp2ls8r18NCjXkwCgYIKoZIzj0EAwIw\nQDELMAkGA1UEBhMCVVMxFDASBgNVBAoMC2VNdWRocmEgSW5jMRswGQYDVQQDDBJF\nTSBPViBUTFMgQ0EgLSBHM0MwHhcNMjYxMDIwMDIyMjQyWhcNMjcwNTAzMDIyNzQy\nWjAzMTEwLwYDVQQDDChvdi1nM2MtYWFhLWVtdWRocmEud2UtaGF2ZS1hLXByb2Js\nZW0uY29tMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEb7+5g2YHNYwH2eQERrZc\nTmVuf/RmvzsGGMSwXmZke6vn35Ut5HME7UOOU3Muy+BSfjfpo6fjL5KOs3Ndz67d\nOKOBqzCBqDAMBgNVHRMBAf8EAjAAMA4GA1UdDwEB/wQEAwIHgDATBgNVHSUEDDAK\nBggrBgEFBQcDATAzBgNVHREELDAqgihvdi1nM2MtYWFhLWVtdWRocmEud2UtaGF2\nZS1hLXByb2JsZW0uY29tMB0GA1UdDgQWBBQ7oBzq3jpTuvntbIkzMV12Ps3O0TAf\nBgNVHSMEGDAWgBQ06uoM68kK2vnQxeLtm3jJh54bbDAKBggqhkjOPQQDAgNoADBl\nAjEA/Y7AZ4M2MYbN5Od9tREaUfFjZKoOjczfIKPjKNMjtGxTdL9WUXvAT4QDgktn\n/IJdAjAbORTkAXD5hggyG6K7p0MBz3lFZJb2l/LndZlrttJWBLP8QGrc44hrZZtK\nIq0d1dE=\n-----END CERTIFICATE-----\n",
|
||||
"5f1ded4dd590af74631290cf073b122a2aac6a1fa479778a0bd43579bd832699": "-----BEGIN CERTIFICATE-----\nMIIDzjCCAragAwIBAgIRAOlCI73pL5FOWrmsq/2sNxAwDQYJKoZIhvcNAQELBQAw\nezELMAkGA1UEBhMCR0IxGzAZBgNVBAgMEkdyZWF0ZXIgTWFuY2hlc3RlcjEQMA4G\nA1UEBwwHU2FsZm9yZDEaMBgGA1UECgwRQ29tb2RvIENBIExpbWl0ZWQxITAfBgNV\nBAMMGEFBQSBDZXJ0aWZpY2F0ZSBTZXJ2aWNlczAeFw0yNTA1MTQwMDAwMDBaFw0y\nODEyMzEyMzU5NTlaMD8xCzAJBgNVBAYTAk5MMRMwEQYDVQQKDAplTXVkaHJhIEJW\nMRswGQYDVQQDDBJFTSBPViBUTFMgQ0EgLSBHM0IwdjAQBgcqhkjOPQIBBgUrgQQA\nIgNiAARziXqg6zz9yosSQC/68KvE4SErr43ud4rJ7i1tuD+sKAxeBWOK56Ks1xwy\nKo42HN659pxDArndjXPRzGyqsswK7k4hbOl53VGKSvS2JuVoTN1lgDftDIVOTMEv\nTPhkbQ2jggE1MIIBMTAfBgNVHSMEGDAWgBSgEQojPpbxB+zirynvgqV/0DCktDAd\nBgNVHQ4EFgQUm9hlUa/n5yw3rQjuaRnZvzeErgowDgYDVR0PAQH/BAQDAgEGMBIG\nA1UdEwEB/wQIMAYBAf8CAQAwHQYDVR0lBBYwFAYIKwYBBQUHAwEGCCsGAQUFBwMC\nMDEGA1UdIAQqMCgwCAYGZ4EMAQICMA0GCysGAQQBg44hAQABMA0GCysGAQQBg44h\nAQJuMEMGA1UdHwQ8MDowOKA2oDSGMmh0dHA6Ly9jcmwuY29tb2RvY2EuY29tL0FB\nQUNlcnRpZmljYXRlU2VydmljZXMuY3JsMDQGCCsGAQUFBwEBBCgwJjAkBggrBgEF\nBQcwAYYYaHR0cDovL29jc3AuY29tb2RvY2EuY29tMA0GCSqGSIb3DQEBCwUAA4IB\nAQALRoOSvkzRa+tN5Rje9W5GiXd/qsicd9N6Gnh0SWC7VyWH+HycIqAnFCMYZznj\nCeGusomFYHE42SHWr85JDIbgzy6y3dO4i9n9w0Bx7yrXWBbDzFS8VgA3+EY4Cg7y\nmtoC3RPOJVtaXXPZ+68X804g1Mj+3vCSHj2lNPVgco0OzSIOJ/KMg1R9MLBkiKzb\nbERAGZHejY0HBhYd76IINZN8z6bBNah2UdFYxsOisLynPQUkol6gRLum024/FzVl\nCCDfnUusrkr+mAfPAvD/Vb8bhpA7jYtepuU+2mIdShj5lLlvAwZphn+ypWycuo2x\n0k9mldz2dqI9nhCiY+TTIOOR\n-----END CERTIFICATE-----\n",
|
||||
"62b7f9868263f72f6911fc78ceaf72c1979158b7368bf648683bb2aa39b02e72": "-----BEGIN CERTIFICATE-----\nMIIDzzCCAregAwIBAgIRAJHzR/Glt9OZYrleDuTimdEwDQYJKoZIhvcNAQELBQAw\nezELMAkGA1UEBhMCR0IxGzAZBgNVBAgMEkdyZWF0ZXIgTWFuY2hlc3RlcjEQMA4G\nA1UEBwwHU2FsZm9yZDEaMBgGA1UECgwRQ29tb2RvIENBIExpbWl0ZWQxITAfBgNV\nBAMMGEFBQSBDZXJ0aWZpY2F0ZSBTZXJ2aWNlczAeFw0yNTA1MTQwMDAwMDBaFw0y\nODEyMzEyMzU5NTlaMEAxCzAJBgNVBAYTAlVTMRQwEgYDVQQKDAtlTXVkaHJhIElu\nYzEbMBkGA1UEAwwSRU0gRFYgVExTIENBIC0gRzNDMHYwEAYHKoZIzj0CAQYFK4EE\nACIDYgAEOnp4YsjETTJN3WkDOmeCwO/u/kBV5ooSP6A3G6O9kZRRTuFypDPjYw8C\nvRiAN9nlFJ+bd9uUooHfmHsv6oiLdL3+j9pMEJnubKGVZv4+8OX2sJpVbMyLpQVh\n3xsqRB0Bo4IBNTCCATEwHwYDVR0jBBgwFoAUoBEKIz6W8Qfs4q8p74Klf9AwpLQw\nHQYDVR0OBBYEFK5+EFw8KZbBqEcnBVnx4D3U5QrzMA4GA1UdDwEB/wQEAwIBBjAS\nBgNVHRMBAf8ECDAGAQH/AgEAMB0GA1UdJQQWMBQGCCsGAQUFBwMBBggrBgEFBQcD\nAjAxBgNVHSAEKjAoMAgGBmeBDAECATANBgsrBgEEAYOOIQEAATANBgsrBgEEAYOO\nIQECZDBDBgNVHR8EPDA6MDigNqA0hjJodHRwOi8vY3JsLmNvbW9kb2NhLmNvbS9B\nQUFDZXJ0aWZpY2F0ZVNlcnZpY2VzLmNybDA0BggrBgEFBQcBAQQoMCYwJAYIKwYB\nBQUHMAGGGGh0dHA6Ly9vY3NwLmNvbW9kb2NhLmNvbTANBgkqhkiG9w0BAQsFAAOC\nAQEAr7b6Mx11TIJgVzHt6EH5qGNWeiiqoMrp8E5/k8s+jvupx1n083pZ5XOzUzND\nwEUe0W4C4FIGUHNQ4XDwqOw0yK42xqm2dvCKgsko2UvDlT0haZVtTCeWMQOe5J/J\nziaMfZwsZNfZ3aaI6XPtWDZtwY8AaNfyaeCpbtm5GcGnyNWk4jSNpbtpo6NjfXuP\nCFOm3rTjJ4zytP7Tso6iMm0L01cYFOlTmoPv4515R2r3SPnKDtijoB/D/vgxzojv\nDafgyG9afX8+laO9NbMMz4sqC1vZx3984i/sBNFiugm3Vvdq5OdJRjuGC1QILIs6\ndg/K3i8VQqRpbfaM9kIlMzZ3dQ==\n-----END CERTIFICATE-----\n",
|
||||
"66374ae64962c437bd815a2aae546c76e30c2eafac8335192c8e517c15a246ed": "-----BEGIN CERTIFICATE-----\nMIICNzCCAb6gAwIBAgIUPfthO0PYGAYKs9lQ4gp1IOkOr2MwCgYIKoZIzj0EAwIw\nPzELMAkGA1UEBhMCTkwxEzARBgNVBAoMCmVNdWRocmEgQlYxGzAZBgNVBAMMEkVN\nIEVWIFRMUyBDQSAtIEczQjAeFw0yNjEwMjAwMDU5MzZaFw0yNzA1MDMwMTA0MzZa\nMC8xLTArBgNVBAMMJGV2LWczYi1lbXVkaHJhLndlLWhhdmUtYS1wcm9ibGVtLmNv\nbTBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABPZPwlA3io/AX/pMwu0dNl9WMxrr\nC9j/xMLHgG72cWVzoX62REmvp9zyXQ04kHP9/sW6KnCcm5ZZ5i4YoJbDPASjgacw\ngaQwDAYDVR0TAQH/BAIwADAOBgNVHQ8BAf8EBAMCB4AwEwYDVR0lBAwwCgYIKwYB\nBQUHAwEwLwYDVR0RBCgwJoIkZXYtZzNiLWVtdWRocmEud2UtaGF2ZS1hLXByb2Js\nZW0uY29tMB0GA1UdDgQWBBSvX4QR4ERW5uNUaMq0yYzXCZf0ZjAfBgNVHSMEGDAW\ngBQ+3CDZiUaYh3uHP9iBR7JcFdhHBjAKBggqhkjOPQQDAgNnADBkAjACy/uJR01M\n/O9IEZyRHX1LSU28bmg542K0IX9jDRTrZXUqkDWfiZIL7xfnrRrBpHoCMFbBIaOa\nWwWtFtDg9ZqQvktsWS950u5qfW1sChgWNya/E3IMKFgtoHm2xrZsmfwLSQ==\n-----END CERTIFICATE-----\n",
|
||||
"7485b51c0ed192878fa1d2f948fb529ae1842cc5086c4b67f7be5b2039de9489": "-----BEGIN CERTIFICATE-----\nMIIDzzCCAregAwIBAgIRAI1aSBGTnvRH13ERzdmEmukwDQYJKoZIhvcNAQELBQAw\nezELMAkGA1UEBhMCR0IxGzAZBgNVBAgMEkdyZWF0ZXIgTWFuY2hlc3RlcjEQMA4G\nA1UEBwwHU2FsZm9yZDEaMBgGA1UECgwRQ29tb2RvIENBIExpbWl0ZWQxITAfBgNV\nBAMMGEFBQSBDZXJ0aWZpY2F0ZSBTZXJ2aWNlczAeFw0yNTA1MTQwMDAwMDBaFw0y\nODEyMzEyMzU5NTlaMEAxCzAJBgNVBAYTAlVTMRQwEgYDVQQKDAtlTXVkaHJhIElu\nYzEbMBkGA1UEAwwSRU0gT1YgVExTIENBIC0gRzNDMHYwEAYHKoZIzj0CAQYFK4EE\nACIDYgAEE4JRzVKskpjByKrZdzId65fnCb0LTKCspV3IrVHc/J0ViaFZfjpRIOHv\n1jHGPhg1ysrimGmmLhYx6KKBgatWYW3EXZGKvAnzqw5jz3kqpNztc4e+N7ulaVSf\nHAKycO1no4IBNTCCATEwHwYDVR0jBBgwFoAUoBEKIz6W8Qfs4q8p74Klf9AwpLQw\nHQYDVR0OBBYEFDTq6gzryQra+dDF4u2beMmHnhtsMA4GA1UdDwEB/wQEAwIBBjAS\nBgNVHRMBAf8ECDAGAQH/AgEAMB0GA1UdJQQWMBQGCCsGAQUFBwMBBggrBgEFBQcD\nAjAxBgNVHSAEKjAoMAgGBmeBDAECAjANBgsrBgEEAYOOIQEAATANBgsrBgEEAYOO\nIQECbjBDBgNVHR8EPDA6MDigNqA0hjJodHRwOi8vY3JsLmNvbW9kb2NhLmNvbS9B\nQUFDZXJ0aWZpY2F0ZVNlcnZpY2VzLmNybDA0BggrBgEFBQcBAQQoMCYwJAYIKwYB\nBQUHMAGGGGh0dHA6Ly9vY3NwLmNvbW9kb2NhLmNvbTANBgkqhkiG9w0BAQsFAAOC\nAQEAHlbhZpjhkPEAQVhPHRtBDaHqiisVD20pQ1TUeSx3l62Qe7NQ0b7vWQy7DN0i\nKOu7BGpP5EnIjvXbz9FhpfPJ4FhKEt3tQ51rqXz2oD8vRaryvExWPobDUz3j7HN6\nOzBsVMIe3PD7AAkpky0x8zMSi/ep5R9OMzybmNptbtppyuB5ytgq+h7lQdRIP+CQ\nTaJX3qTgfMqzntYre5GljmEB5hU2/rSn8zoSU2APkJidlZFpghtgbD4I2VRHZvCw\nFFr/SrFM9pwrgbJNZ1MtyTSVgI5NlNb0aMfLR+/r6jJt3ROeY2yh2QQ2dx+Yqgdb\nuL5ChFWOQU+ErSDnUq7yr+pjBQ==\n-----END CERTIFICATE-----\n",
|
||||
"7dd78d5f4f13459a83dff9abbba62edbaf6f2d102bf257fd712f4d9f2746ed8d": "-----BEGIN CERTIFICATE-----\nMIICKTCCAa+gAwIBAgIQDnYGcvFDRZ/I/gqwvAXjlDAKBggqhkjOPQQDAzBWMQsw\nCQYDVQQGEwJJTjElMCMGA1UECgwcZU11ZGhyYSBUZWNobm9sb2dpZXMgTGltaXRl\nZDEgMB4GA1UEAwwXZW1TaWduIFJvb3QgVExTIENBIC0gRzMwHhcNMjQwMjA4MDgx\nOTMyWhcNNDkwMjA3MDgxOTMyWjBWMQswCQYDVQQGEwJJTjElMCMGA1UECgwcZU11\nZGhyYSBUZWNobm9sb2dpZXMgTGltaXRlZDEgMB4GA1UEAwwXZW1TaWduIFJvb3Qg\nVExTIENBIC0gRzMwdjAQBgcqhkjOPQIBBgUrgQQAIgNiAARfD9w/q1cWxL6D9bMG\nH6CQ4u+gAV6GN9s+PwQx9Bl4hC+0iU8QOk8mWO/RwXQy10DCSxT8vSIJClHKp+Ao\n4qJKdYeV0SwZ7LMQziyLfxIUOY6Grja/sQFwLKYf8EdDXAyjQjBAMB0GA1UdDgQW\nBBThOqshfPHjTVSu1HK3/YtZQiCQVDAOBgNVHQ8BAf8EBAMCAQYwDwYDVR0TAQH/\nBAUwAwEB/zAKBggqhkjOPQQDAwNoADBlAjBhCFTkly7Yl7+s4hiw+k6ZJTXT2AiI\nrvmsmfqPr5E7ecwXb6kXA5LTFd6rjwTuV3sCMQC9w6ZSbVq1mveMO6dKFziOzClT\nKCT2RFT6yXtq6nzgRICgBmiXbJAXsmZ8yAwjyWc=\n-----END CERTIFICATE-----\n",
|
||||
"7e02622ab6d745c1edb4b431bb1c551e667305c3dd3eb0c7cc6dfdb523807424": "-----BEGIN CERTIFICATE-----\nMIICQDCCAcagAwIBAgIUU3mgjls/8ZJ8upmukdlSQR1IF/UwCgYIKoZIzj0EAwIw\nPzELMAkGA1UEBhMCTkwxEzARBgNVBAoMCmVNdWRocmEgQlYxGzAZBgNVBAMMEkVN\nIERWIFRMUyBDQSAtIEczQjAeFw0yNjEwMjAwMjIyNDFaFw0yNzA1MDMwMjI3NDFa\nMDMxMTAvBgNVBAMMKGR2LWczYi1hYWEtZW11ZGhyYS53ZS1oYXZlLWEtcHJvYmxl\nbS5jb20wWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAATK3sfcOxxtM2Vdq9Qbw/GR\nNsHlJcnqINEInXO3fkWhw/FYT80hkqGk0jSN/EKib3YZJsr5GyfkYcpEiA2rwMgm\no4GrMIGoMAwGA1UdEwEB/wQCMAAwDgYDVR0PAQH/BAQDAgeAMBMGA1UdJQQMMAoG\nCCsGAQUFBwMBMDMGA1UdEQQsMCqCKGR2LWczYi1hYWEtZW11ZGhyYS53ZS1oYXZl\nLWEtcHJvYmxlbS5jb20wHQYDVR0OBBYEFDggfJFz1eeToKmzFBSYkMCvpAu0MB8G\nA1UdIwQYMBaAFOIeL+ccGTkOVUtiwSAkVzUkiiUUMAoGCCqGSM49BAMCA2gAMGUC\nMDqDDOt7K313QIH0rhn/ZKnzFu8+/9W8OlTuFtDfVCTRDfRmqdyfsCfLsEk7xqHq\nuQIxAJqyLERABmU98U46OCuT5bp5h0ytuSEIGM7orTpKci5S1u1B/AOmwv162U7R\nTcVPHA==\n-----END CERTIFICATE-----\n",
|
||||
"84112972b8e59d14560ae7e7119654474aad119990cf3722bfc1e3b91615e1fd": "-----BEGIN CERTIFICATE-----\nMIIDxTCCAq2gAwIBAgIRANal72E7OQ8y75zfNJ93KEEwDQYJKoZIhvcNAQELBQAw\nezELMAkGA1UEBhMCR0IxGzAZBgNVBAgMEkdyZWF0ZXIgTWFuY2hlc3RlcjEQMA4G\nA1UEBwwHU2FsZm9yZDEaMBgGA1UECgwRQ29tb2RvIENBIExpbWl0ZWQxITAfBgNV\nBAMMGEFBQSBDZXJ0aWZpY2F0ZSBTZXJ2aWNlczAeFw0yNTA1MTQwMDAwMDBaFw0y\nODEyMzEyMzU5NTlaMD8xCzAJBgNVBAYTAlVTMRQwEgYDVQQKDAtlTXVkaHJhIElu\nYzEaMBgGA1UEAwwRRU0gT1YgQ1MgQ0EgLSBHM0MwdjAQBgcqhkjOPQIBBgUrgQQA\nIgNiAAS58OBjRwKmnKaq3UQ8/WLEG5uksgkkaX15dOdpGhJvuWr7PjTC/TvF9IMG\nHyZdwqWENjRzI1EENrKPBPOGI2MGeYDiUD4CUy1lRiNacWWiWaHvSW90G1/e53hy\nCOS4+u+jggEsMIIBKDAfBgNVHSMEGDAWgBSgEQojPpbxB+zirynvgqV/0DCktDAd\nBgNVHQ4EFgQU7m9zKY1vv6ezG8OVNPYVVkR4ZrcwDgYDVR0PAQH/BAQDAgEGMBIG\nA1UdEwEB/wQIMAYBAf8CAQAwEwYDVR0lBAwwCgYIKwYBBQUHAwMwMgYDVR0gBCsw\nKTAIBgZngQwBBAEwDQYLKwYBBAGDjiEBAAEwDgYMKwYBBAGDjiEBAoFIMEMGA1Ud\nHwQ8MDowOKA2oDSGMmh0dHA6Ly9jcmwuY29tb2RvY2EuY29tL0FBQUNlcnRpZmlj\nYXRlU2VydmljZXMuY3JsMDQGCCsGAQUFBwEBBCgwJjAkBggrBgEFBQcwAYYYaHR0\ncDovL29jc3AuY29tb2RvY2EuY29tMA0GCSqGSIb3DQEBCwUAA4IBAQBgGbZ+NDkn\n2Fr5dCVs/51+6cT5iEPP1stglX4TI2UaNh9LmPdltxXw3crV8gK5L8i+d+ScGibF\ny0obtBHNTldXDgLnAHIzY3n+pceyKj7O2ae9CYnyuu5RBifeI3XYKowWE+oXnVGF\nOy0CvCJ/JsdP6aYgoCa9hGnAYicYQQL6MOMQZ5PuhcSM8t4giIzrpgrEkUP93GYE\nemDG5Z8BYwKs80lLeQNBnwg/VHJ3iEF/o84RXYwrlrKqJ+33C094o6LNikMJCqOK\n/WYHZSR8Xyhf8GXFAj5pZuI6Bcpug2V/E7i42j8OBhc25aNYDqK+B0qmb90sjNUB\ng3tJeSIb/599\n-----END CERTIFICATE-----\n",
|
||||
"86a1ecba089c4a8d3bbe2734c612ba341d813e043cf9e8a862cd5c57a36bbe6b": "-----BEGIN CERTIFICATE-----\nMIICTjCCAdOgAwIBAgIKPPYHqWhwDtqLhDAKBggqhkjOPQQDAzBrMQswCQYDVQQG\nEwJJTjETMBEGA1UECxMKZW1TaWduIFBLSTElMCMGA1UEChMcZU11ZGhyYSBUZWNo\nbm9sb2dpZXMgTGltaXRlZDEgMB4GA1UEAxMXZW1TaWduIEVDQyBSb290IENBIC0g\nRzMwHhcNMTgwMjE4MTgzMDAwWhcNNDMwMjE4MTgzMDAwWjBrMQswCQYDVQQGEwJJ\nTjETMBEGA1UECxMKZW1TaWduIFBLSTElMCMGA1UEChMcZU11ZGhyYSBUZWNobm9s\nb2dpZXMgTGltaXRlZDEgMB4GA1UEAxMXZW1TaWduIEVDQyBSb290IENBIC0gRzMw\ndjAQBgcqhkjOPQIBBgUrgQQAIgNiAAQjpQy4LRL1KPOxst3iAhKAnjlfSU2fySU0\nWXTsuwYc58Byr+iuL+FBVIcUqEqy6HyC5ltqtdyzdc6LBtCGI79G1Y4PPwT01xyS\nfvalY8L1X44uT6EYGQIrMgqCZH0Wk9GjQjBAMB0GA1UdDgQWBBR8XQKEE9TMipuB\nzhccLikenEhjQjAOBgNVHQ8BAf8EBAMCAQYwDwYDVR0TAQH/BAUwAwEB/zAKBggq\nhkjOPQQDAwNpADBmAjEAvvNhzwIQHWSVB7gYboiFBS+DCBeQyh+KTOgNG3qxrdWB\nCUfvO6wIBHxcmbHtRwfSAjEAnbpV/KlK6O3t5nYBQnvI+GDZjVGLVTv7jHvrZQnD\n+JbNR6iC8hZVdyR+EhCVBCyj\n-----END CERTIFICATE-----\n",
|
||||
"9200d153c3cac272cc06bf68f9ea163fc24659fc3331e77668236fe00645ae52": "-----BEGIN CERTIFICATE-----\nMIICQjCCAcegAwIBAgIUMH3z62Z6agEULBZqrfhpw9/ZpCAwCgYIKoZIzj0EAwIw\nQDELMAkGA1UEBhMCVVMxFDASBgNVBAoMC2VNdWRocmEgSW5jMRswGQYDVQQDDBJF\nTSBEViBUTFMgQ0EgLSBHM0MwHhcNMjYxMDIwMDIyMjQxWhcNMjcwNTAzMDIyNzQx\nWjAzMTEwLwYDVQQDDChkdi1nM2MtYWFhLWVtdWRocmEud2UtaGF2ZS1hLXByb2Js\nZW0uY29tMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEAMPpHsQqEqIerrj/durO\nEN7gkUVJSVgIPN6+VJ2sEGXDyqXAN1fnNzGuASP/4WEr0RIhht3cYvuUYp2SYofr\nw6OBqzCBqDAMBgNVHRMBAf8EAjAAMA4GA1UdDwEB/wQEAwIHgDATBgNVHSUEDDAK\nBggrBgEFBQcDATAzBgNVHREELDAqgihkdi1nM2MtYWFhLWVtdWRocmEud2UtaGF2\nZS1hLXByb2JsZW0uY29tMB0GA1UdDgQWBBT8CNQz4OLVnIwrQYZBF+++9jTCvzAf\nBgNVHSMEGDAWgBSufhBcPCmWwahHJwVZ8eA91OUK8zAKBggqhkjOPQQDAgNpADBm\nAjEAp3ZLdfBvKcZxgR2p8LZaxWzP6uB7zU/EZ9BA49Ds2P5ByYkRodwK5qcwt4J/\nVL/vAjEAnnxrcrDX7SzoJxuho604GBh6eyBLJLCdmcy+qb+LA3u5oxP/OjAzCA4x\nv1kw6Tm4\n-----END CERTIFICATE-----\n",
|
||||
"98b896625c0c230adeec3eb2cf6aad5001ecf9156a68644e82374ce5833bc25b": "-----BEGIN CERTIFICATE-----\nMIIDZjCCAu2gAwIBAgIPbK2jZ/scEACIEg6iAtVoMAoGCCqGSM49BAMDMFYxCzAJ\nBgNVBAYTAklOMSUwIwYDVQQKDBxlTXVkaHJhIFRlY2hub2xvZ2llcyBMaW1pdGVk\nMSAwHgYDVQQDDBdlbVNpZ24gUm9vdCBUTFMgQ0EgLSBHMzAeFw0yNTA0MzAxNjAw\nMTJaFw00MDA0MjgxODMwMDBaMD8xCzAJBgNVBAYTAk5MMRMwEQYDVQQKDAplTXVk\naHJhIEJWMRswGQYDVQQDDBJFTSBPViBUTFMgQ0EgLSBHM0IwdjAQBgcqhkjOPQIB\nBgUrgQQAIgNiAARziXqg6zz9yosSQC/68KvE4SErr43ud4rJ7i1tuD+sKAxeBWOK\n56Ks1xwyKo42HN659pxDArndjXPRzGyqsswK7k4hbOl53VGKSvS2JuVoTN1lgDft\nDIVOTMEvTPhkbQ2jggGWMIIBkjAfBgNVHSMEGDAWgBThOqshfPHjTVSu1HK3/YtZ\nQiCQVDAdBgNVHQ4EFgQUm9hlUa/n5yw3rQjuaRnZvzeErgowHQYDVR0lBBYwFAYI\nKwYBBQUHAwEGCCsGAQUFBwMCMF0GA1UdIARWMFQwOQYLKwYBBAGDjiEBAAEwKjAo\nBggrBgEFBQcCARYcaHR0cDovL3JlcG9zaXRvcnkuZW1zaWduLmNvbTANBgsrBgEE\nAYOOIQECbjAIBgZngQwBAgIwdQYIKwYBBQUHAQEEaTBnMCQGCCsGAQUFBzABhhho\ndHRwOi8vb2NzcC1iLmVtc2lnbi5jb20wPwYIKwYBBQUHMAKGM2h0dHA6Ly9yZXBv\nc2l0b3J5LmVtc2lnbi5jb20vY2VydHMvRU1PVlRMU0NBRzNCLnA3YzA3BgNVHR8E\nMDAuMCygKqAohiZodHRwOi8vY3JsLmVtc2lnbi5jb20vP3Jvb3R0bHNjYWczLmNy\nbDAOBgNVHQ8BAf8EBAMCAQYwEgYDVR0TAQH/BAgwBgEB/wIBADAKBggqhkjOPQQD\nAwNnADBkAjBAk+5BizdJhrED3wb7F2Gm7bWI60dlNd075nghT2TrR4NWKZtJZcbc\n28xxIY/NONICMF6mPdsjosaqmvEMWqKcBh4vsIvXSKnQj5o8zrFB4oKS+accjYTe\n8Dz3bQjauvy9uw==\n-----END CERTIFICATE-----\n",
|
||||
"9dcb21b1841d75313b3c888f4bb9cc01f8f51379a76fbaa241ca492b668839e7": "-----BEGIN CERTIFICATE-----\nMIICOTCCAb6gAwIBAgIUY1R/PHrtXShV0uhLJ5EyjsHvMi0wCgYIKoZIzj0EAwIw\nPzELMAkGA1UEBhMCTkwxEzARBgNVBAoMCmVNdWRocmEgQlYxGzAZBgNVBAMMEkVN\nIERWIFRMUyBDQSAtIEczQjAeFw0yNjEwMjAwMDU5MzZaFw0yNzA1MDMwMTA0MzZa\nMC8xLTArBgNVBAMMJGR2LWczYi1lbXVkaHJhLndlLWhhdmUtYS1wcm9ibGVtLmNv\nbTBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABFpE24ACBFHQLzpg+7D767thJ3+Q\nNIoJJugUMT/OftEz6G6uDdxlS+9VfXYBDglHjJirjEPFNglOGJoQNQuiz7Gjgacw\ngaQwDAYDVR0TAQH/BAIwADAOBgNVHQ8BAf8EBAMCB4AwEwYDVR0lBAwwCgYIKwYB\nBQUHAwEwLwYDVR0RBCgwJoIkZHYtZzNiLWVtdWRocmEud2UtaGF2ZS1hLXByb2Js\nZW0uY29tMB0GA1UdDgQWBBT4r5v4etYNuG21BqBqs273OkcHiDAfBgNVHSMEGDAW\ngBTiHi/nHBk5DlVLYsEgJFc1JIolFDAKBggqhkjOPQQDAgNpADBmAjEAlxNF+/8l\nAPpgibawACy15yn5bcvTksz2Zw0R9z9nUK9e3MSSk+hXagg7LVb7V4NtAjEA4qyR\nlaFPkaokrnTjqQieq8CgDQ6tIz3FjBvj3D7vkhKXHaml6zD2zT5ih2Y+lD2h\n-----END CERTIFICATE-----\n",
|
||||
"9e39de2881273acc225a149c72ba3e0f51424963669ab633ee1dc7df5db067b4": "-----BEGIN CERTIFICATE-----\nMIIDWzCCAuCgAwIBAgIPeBiTP4+FtGGaKzX7qD0KMAoGCCqGSM49BAMDMFUxCzAJ\nBgNVBAYTAklOMSUwIwYDVQQKDBxlTXVkaHJhIFRlY2hub2xvZ2llcyBMaW1pdGVk\nMR8wHQYDVQQDDBZlbVNpZ24gUm9vdCBDUyBDQSAtIEczMB4XDTI1MDQzMDE2MDgy\nN1oXDTQwMDQyODE4MzAwMFowPjELMAkGA1UEBhMCTkwxEzARBgNVBAoMCmVNdWRo\ncmEgQlYxGjAYBgNVBAMMEUVNIE9WIENTIENBIC0gRzNCMHYwEAYHKoZIzj0CAQYF\nK4EEACIDYgAEqrsPsGZqz88B+tUr/P1MK/LLPDSX3nOVDlgZth/mlQmHQcjEkUOb\nNKBDHK/+W/m0wcjorgAB+fTcitgsWfNjnpbNbD1NsM7+wmw9YlF7N3YTcnQ/jord\nd7EAZPKr5eBQo4IBizCCAYcwHwYDVR0jBBgwFoAUaHQD+IXKjVRQVx0W4MjeRQ2r\nzS0wHQYDVR0OBBYEFHn66OzP2w11jaRY1p+27dx8Sd7mMBMGA1UdJQQMMAoGCCsG\nAQUFBwMDMF4GA1UdIARXMFUwOQYLKwYBBAGDjiEBAAEwKjAoBggrBgEFBQcCARYc\naHR0cDovL3JlcG9zaXRvcnkuZW1zaWduLmNvbTAOBgwrBgEEAYOOIQECgUgwCAYG\nZ4EMAQQBMHQGCCsGAQUFBwEBBGgwZjAkBggrBgEFBQcwAYYYaHR0cDovL29jc3At\nYi5lbXNpZ24uY29tMD4GCCsGAQUFBzAChjJodHRwOi8vcmVwb3NpdG9yeS5lbXNp\nZ24uY29tL2NlcnRzL0VNT1ZDU0NBRzNCLnA3YzA2BgNVHR8ELzAtMCugKaAnhiVo\ndHRwOi8vY3JsLmVtc2lnbi5jb20vP3Jvb3Rjc2NhZzMuY3JsMA4GA1UdDwEB/wQE\nAwIBBjASBgNVHRMBAf8ECDAGAQH/AgEAMAoGCCqGSM49BAMDA2kAMGYCMQC/zr03\nYEELsNKqzUtH2dVFi4BaKFdyrr/6G1dIWp8KAVgsw2/u4lyyboUDaC9jDvICMQC9\nHTxku84tNX9dmHOtH7aC2Nu/JM0jTf6KjZAvovCJzzv1IFWSZpVLZibynMLXOrM=\n-----END CERTIFICATE-----\n",
|
||||
"a496e23ac0cab8155fc9b117b97eae368efcbb9041aec8804d5a308d2d01c2d8": "-----BEGIN CERTIFICATE-----\nMIIDwzCCAqugAwIBAgIQQeMrc6wYXWzg10gLrPhOaTANBgkqhkiG9w0BAQsFADB7\nMQswCQYDVQQGEwJHQjEbMBkGA1UECAwSR3JlYXRlciBNYW5jaGVzdGVyMRAwDgYD\nVQQHDAdTYWxmb3JkMRowGAYDVQQKDBFDb21vZG8gQ0EgTGltaXRlZDEhMB8GA1UE\nAwwYQUFBIENlcnRpZmljYXRlIFNlcnZpY2VzMB4XDTI1MDUxNDAwMDAwMFoXDTI4\nMTIzMTIzNTk1OVowPjELMAkGA1UEBhMCTkwxEzARBgNVBAoMCmVNdWRocmEgQlYx\nGjAYBgNVBAMMEUVNIE9WIENTIENBIC0gRzNCMHYwEAYHKoZIzj0CAQYFK4EEACID\nYgAEqrsPsGZqz88B+tUr/P1MK/LLPDSX3nOVDlgZth/mlQmHQcjEkUObNKBDHK/+\nW/m0wcjorgAB+fTcitgsWfNjnpbNbD1NsM7+wmw9YlF7N3YTcnQ/jordd7EAZPKr\n5eBQo4IBLDCCASgwHwYDVR0jBBgwFoAUoBEKIz6W8Qfs4q8p74Klf9AwpLQwHQYD\nVR0OBBYEFHn66OzP2w11jaRY1p+27dx8Sd7mMA4GA1UdDwEB/wQEAwIBBjASBgNV\nHRMBAf8ECDAGAQH/AgEAMBMGA1UdJQQMMAoGCCsGAQUFBwMDMDIGA1UdIAQrMCkw\nCAYGZ4EMAQQBMA0GCysGAQQBg44hAQABMA4GDCsGAQQBg44hAQKBSDBDBgNVHR8E\nPDA6MDigNqA0hjJodHRwOi8vY3JsLmNvbW9kb2NhLmNvbS9BQUFDZXJ0aWZpY2F0\nZVNlcnZpY2VzLmNybDA0BggrBgEFBQcBAQQoMCYwJAYIKwYBBQUHMAGGGGh0dHA6\nLy9vY3NwLmNvbW9kb2NhLmNvbTANBgkqhkiG9w0BAQsFAAOCAQEAIh0OzWsdBYhY\n7+yxPGyosRL+uUSN6/Mrd30fBuougs5FVWQ3cRgLhImnUUgXADY9buugWhMmfT3c\nyU5T1MFXZXClCILm5mfg+oqm/ikUWJl3Ayf2GvYf2O++6vzqvkn418nI5Lpdt0O7\nLi524YS/3CZHDA+zZQ7rrHpZXgQ9Hmp8AUQ4FneaGjNanFoYxiZxwCyW5z4u7AoK\n0bmOVqKtcDFoVNz5++G9TYDJdhWOXmKKgzqRo1MK0PjazNT7MGHqknP93SqMVJ1g\nmX/qNWxYteAjlvFanQxBNX/+TRxDjuFsEHO/7BvqSpIIck0UEEfizhN8zNpWLbcl\nEqVgl0g39A==\n-----END CERTIFICATE-----\n",
|
||||
"bc88231a840e54cced148da48806169e8d0b1c0a96267a427aeccda9d10e6145": "-----BEGIN CERTIFICATE-----\nMIICQDCCAcagAwIBAgIUUYu3O8bpN1GPvCIHqWpT/FYUNDowCgYIKoZIzj0EAwIw\nPzELMAkGA1UEBhMCTkwxEzARBgNVBAoMCmVNdWRocmEgQlYxGzAZBgNVBAMMEkVN\nIEVWIFRMUyBDQSAtIEczQjAeFw0yNjEwMjAwMjIyNDFaFw0yNzA1MDMwMjI3NDFa\nMDMxMTAvBgNVBAMMKGV2LWczYi1hYWEtZW11ZGhyYS53ZS1oYXZlLWEtcHJvYmxl\nbS5jb20wWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAASjDLpnVdmvXM6R3UB2o42a\n1GnrYfxRXo7UII36mBl9mIS7p9MkAryhPdxitFHkgTrrbypVtzpydNtJIGGGXUwE\no4GrMIGoMAwGA1UdEwEB/wQCMAAwDgYDVR0PAQH/BAQDAgeAMBMGA1UdJQQMMAoG\nCCsGAQUFBwMBMDMGA1UdEQQsMCqCKGV2LWczYi1hYWEtZW11ZGhyYS53ZS1oYXZl\nLWEtcHJvYmxlbS5jb20wHQYDVR0OBBYEFEVBY1ecTN9bLMDl4ifdnN2WZL+WMB8G\nA1UdIwQYMBaAFD7cINmJRpiHe4c/2IFHslwV2EcGMAoGCCqGSM49BAMCA2gAMGUC\nMQCyvr9gktgmtsuTuFleFV+YExNNKLf3GZieuUprQcERrzh798oYKavTqZeYf9Kz\nRhECMCVs1B7KPh7/lgHRly14Z8RJA8ApR7e2cRREW5uPKFOSDlZ/RIezxsfEzsQ4\nlAODeA==\n-----END CERTIFICATE-----\n",
|
||||
"bcd6ab59ea95fd520bfe7709679b1f682abbd79c77367e84e25415b91de37753": "-----BEGIN CERTIFICATE-----\nMIICQDCCAcegAwIBAgIUTMUrGzSU+0BySFdrN3ALZq9Spv8wCgYIKoZIzj0EAwIw\nQDELMAkGA1UEBhMCVVMxFDASBgNVBAoMC2VNdWRocmEgSW5jMRswGQYDVQQDDBJF\nTSBFViBUTFMgQ0EgLSBHM0MwHhcNMjYxMDIwMDIyMjQyWhcNMjcwNTAzMDIyNzQy\nWjAzMTEwLwYDVQQDDChldi1nM2MtYWFhLWVtdWRocmEud2UtaGF2ZS1hLXByb2Js\nZW0uY29tMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEglClDbotLFFeY9SpryT4\nk958tvriY2DDnaPsmjQaecRhB6+L/8hLw6FMt+HT8SLvjIZnFY+AH92cBjedcNST\nAKOBqzCBqDAMBgNVHRMBAf8EAjAAMA4GA1UdDwEB/wQEAwIHgDATBgNVHSUEDDAK\nBggrBgEFBQcDATAzBgNVHREELDAqgihldi1nM2MtYWFhLWVtdWRocmEud2UtaGF2\nZS1hLXByb2JsZW0uY29tMB0GA1UdDgQWBBSVtGbn6xUT1iosUeaGA15Nq640aTAf\nBgNVHSMEGDAWgBSmJV6AqU2385Z8G8eR35K87kvrXzAKBggqhkjOPQQDAgNnADBk\nAjAg7b5bIK9X50QEX0U6G8fQh12BnFQ079Iluzd0Y2TJWCjtVXZi3nOciM8RzNKM\ntZECMCBf0JOyMt8RNS93Wj8kYbWkCtoKoUrqx7G3JTfGReKci+DdTvkF8/3F6doi\n9KB22Q==\n-----END CERTIFICATE-----\n",
|
||||
"cb6e15165ac802e9b3f993f16c25d41f4e81c21ebde71ae1b012ac00bf9c7c3f": "-----BEGIN CERTIFICATE-----\nMIIDaDCCAu6gAwIBAgIPKWkmwiejoVJjkrGUgqUEMAoGCCqGSM49BAMDMFYxCzAJ\nBgNVBAYTAklOMSUwIwYDVQQKDBxlTXVkaHJhIFRlY2hub2xvZ2llcyBMaW1pdGVk\nMSAwHgYDVQQDDBdlbVNpZ24gUm9vdCBUTFMgQ0EgLSBHMzAeFw0yNTA0MzAxNTM3\nNDFaFw00MDA0MjgxODMwMDBaMEAxCzAJBgNVBAYTAlVTMRQwEgYDVQQKDAtlTXVk\naHJhIEluYzEbMBkGA1UEAwwSRU0gT1YgVExTIENBIC0gRzNDMHYwEAYHKoZIzj0C\nAQYFK4EEACIDYgAEE4JRzVKskpjByKrZdzId65fnCb0LTKCspV3IrVHc/J0ViaFZ\nfjpRIOHv1jHGPhg1ysrimGmmLhYx6KKBgatWYW3EXZGKvAnzqw5jz3kqpNztc4e+\nN7ulaVSfHAKycO1no4IBljCCAZIwHwYDVR0jBBgwFoAU4TqrIXzx401UrtRyt/2L\nWUIgkFQwHQYDVR0OBBYEFDTq6gzryQra+dDF4u2beMmHnhtsMB0GA1UdJQQWMBQG\nCCsGAQUFBwMBBggrBgEFBQcDAjBdBgNVHSAEVjBUMDkGCysGAQQBg44hAQABMCow\nKAYIKwYBBQUHAgEWHGh0dHA6Ly9yZXBvc2l0b3J5LmVtc2lnbi5jb20wDQYLKwYB\nBAGDjiEBAm4wCAYGZ4EMAQICMHUGCCsGAQUFBwEBBGkwZzAkBggrBgEFBQcwAYYY\naHR0cDovL29jc3AtYy5lbXNpZ24uY29tMD8GCCsGAQUFBzAChjNodHRwOi8vcmVw\nb3NpdG9yeS5lbXNpZ24uY29tL2NlcnRzL0VNT1ZUTFNDQUczQy5wN2MwNwYDVR0f\nBDAwLjAsoCqgKIYmaHR0cDovL2NybC5lbXNpZ24uY29tLz9yb290dGxzY2FnMy5j\ncmwwDgYDVR0PAQH/BAQDAgEGMBIGA1UdEwEB/wQIMAYBAf8CAQAwCgYIKoZIzj0E\nAwMDaAAwZQIxAMSZt2xsPeumRdeSMSQ3CbzIZeQ/q7GDCWfeE1Pq+mhgA6gRM44c\nNVF3xJ+wsxJk+wIwFJbaDMokD8Ei6eNOiA8yUMYvfzQyngmrhZQMdPCK1SewrR79\n4rSb9vGqxZzelQth\n-----END CERTIFICATE-----\n",
|
||||
"ccd80467f9cac38f115dcb6e4f57e4d74f5efa519814ea934aeb3be53b83a191": "-----BEGIN CERTIFICATE-----\nMIICOTCCAb+gAwIBAgIUOcw5N4XGLiB/3GvB05bJuGoZPR0wCgYIKoZIzj0EAwIw\nQDELMAkGA1UEBhMCVVMxFDASBgNVBAoMC2VNdWRocmEgSW5jMRswGQYDVQQDDBJF\nTSBFViBUTFMgQ0EgLSBHM0MwHhcNMjYxMDIwMDA1OTM3WhcNMjcwNTAzMDEwNDM3\nWjAvMS0wKwYDVQQDDCRldi1nM2MtZW11ZGhyYS53ZS1oYXZlLWEtcHJvYmxlbS5j\nb20wWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAAQo7U02MAUO8EEMomXwIl+kSQzm\nb+nvXKIZ/hC4rZ1GpXchv4SzTrV1SB/Qg+pD6OBEXbBBnPmQNkePNQTlujqRo4Gn\nMIGkMAwGA1UdEwEB/wQCMAAwDgYDVR0PAQH/BAQDAgeAMBMGA1UdJQQMMAoGCCsG\nAQUFBwMBMC8GA1UdEQQoMCaCJGV2LWczYy1lbXVkaHJhLndlLWhhdmUtYS1wcm9i\nbGVtLmNvbTAdBgNVHQ4EFgQUqxe+7qS+MxNyioJZhQsL5MEEw6swHwYDVR0jBBgw\nFoAUpiVegKlNt/OWfBvHkd+SvO5L618wCgYIKoZIzj0EAwIDaAAwZQIwC/CxSIov\nwcar3sV98B8cL+QuR2kkJdLdXsFKzg/HDSJ1MOjqTGm924LB6r9nl0f7AjEAzcwr\nWQOYY9eycqIz+LG2daYS6LOctj+CjoAk/CLugWPxGxp4Aerfau2P1z1h5jAA\n-----END CERTIFICATE-----\n",
|
||||
"d7a7a0fb5d7e2731d771e9484ebcdef71d5f0c3e0a2948782bc83ee0ea699ef4": "-----BEGIN CERTIFICATE-----\nMIIEMjCCAxqgAwIBAgIBATANBgkqhkiG9w0BAQUFADB7MQswCQYDVQQGEwJHQjEb\nMBkGA1UECAwSR3JlYXRlciBNYW5jaGVzdGVyMRAwDgYDVQQHDAdTYWxmb3JkMRow\nGAYDVQQKDBFDb21vZG8gQ0EgTGltaXRlZDEhMB8GA1UEAwwYQUFBIENlcnRpZmlj\nYXRlIFNlcnZpY2VzMB4XDTA0MDEwMTAwMDAwMFoXDTI4MTIzMTIzNTk1OVowezEL\nMAkGA1UEBhMCR0IxGzAZBgNVBAgMEkdyZWF0ZXIgTWFuY2hlc3RlcjEQMA4GA1UE\nBwwHU2FsZm9yZDEaMBgGA1UECgwRQ29tb2RvIENBIExpbWl0ZWQxITAfBgNVBAMM\nGEFBQSBDZXJ0aWZpY2F0ZSBTZXJ2aWNlczCCASIwDQYJKoZIhvcNAQEBBQADggEP\nADCCAQoCggEBAL5AnfRu4ep2hxxNRUSOvkbIgwadwSr+GB+O5AL686tdUIoWMQua\nBtDFcCLNSS1UY8y2bmhGC1Pqy0wkwLxyTurxFa70VJoSCsN6sjNg4tqJVfMiWPPe\n3M/vg4aijJRPn2jymJBGhCfHdr/jzDUsi14HZGWCwEiwqJH5YZ92IFCokcdmtet4\nYgNW8IoaE+oxox6gmf049vYnMlhvB/VruPsUK6+3qszWY19zjNoFmag4qMsXeDZR\nrOme9Hg6jc8P2ULimAyrL58OAd7vn5lJ8S3frHRNG5i1R8XlKdH5kBjHYpy+g8cm\nez6KJcfA3Z3mNWgQIJ2P2N7Sw4ScDV7oL8kCAwEAAaOBwDCBvTAdBgNVHQ4EFgQU\noBEKIz6W8Qfs4q8p74Klf9AwpLQwDgYDVR0PAQH/BAQDAgEGMA8GA1UdEwEB/wQF\nMAMBAf8wewYDVR0fBHQwcjA4oDagNIYyaHR0cDovL2NybC5jb21vZG9jYS5jb20v\nQUFBQ2VydGlmaWNhdGVTZXJ2aWNlcy5jcmwwNqA0oDKGMGh0dHA6Ly9jcmwuY29t\nb2RvLm5ldC9BQUFDZXJ0aWZpY2F0ZVNlcnZpY2VzLmNybDANBgkqhkiG9w0BAQUF\nAAOCAQEACFb8AvCb6P+k+tZ7xkSAzk/ExfYAWMymtrwUSWgEdujm7l3sAg9g1o1Q\nGE8mTgHj5rCl7r+8dFRBv/38ErjHT1r0iWAFf2C3BUrz9vHCv8S5dIa2LX1rzNLz\nRt0vxuBqw8M0Ayx9lt1awg6nCpnBBYurDC/zXDrPbDdVCYfeU0BsWO/8tqtlbgT2\nG9w84FoVxp7Z8VlIMCFlA2zs6SFz7JsDoeA3raAVGI/6ugLOpyypEBMs1OUIJqsi\nl2D4kF501KKaU73yqWjgom7C12yxow+ev+to51byrvLjKzg6CYG1a4XXvi3tPxq3\nsmPi9WIsgtRqAEFQ8TmDn5XpNpaYbg==\n-----END CERTIFICATE-----\n",
|
||||
"e7568fe6e12331f4fd7de61da4c7559d0f8eb192a218d4741a3ba75184333838": "-----BEGIN CERTIFICATE-----\nMIIDaDCCAu2gAwIBAgIQAMfEHv6S2CXwXq6EGkqCwTAKBggqhkjOPQQDAzBWMQsw\nCQYDVQQGEwJJTjElMCMGA1UECgwcZU11ZGhyYSBUZWNobm9sb2dpZXMgTGltaXRl\nZDEgMB4GA1UEAwwXZW1TaWduIFJvb3QgVExTIENBIC0gRzMwHhcNMjUwNDMwMTYw\nMzMxWhcNNDAwNDI4MTgzMDAwWjA/MQswCQYDVQQGEwJOTDETMBEGA1UECgwKZU11\nZGhyYSBCVjEbMBkGA1UEAwwSRU0gRVYgVExTIENBIC0gRzNCMHYwEAYHKoZIzj0C\nAQYFK4EEACIDYgAEJduoWmTTl9oLEFLp6lRuSHvv6pydnWigxerc/u91ppu11vwB\n9Cv7ym60C81kwHZ+Nv/QXZeoE8WqDGyVMOec9fCGZGjxOAZCCqXIUYQLHiUlQOeQ\n5LZUw4Uu1krx6GV9o4IBlTCCAZEwHwYDVR0jBBgwFoAU4TqrIXzx401UrtRyt/2L\nWUIgkFQwHQYDVR0OBBYEFD7cINmJRpiHe4c/2IFHslwV2EcGMB0GA1UdJQQWMBQG\nCCsGAQUFBwMBBggrBgEFBQcDAjBcBgNVHSAEVTBTMDkGCysGAQQBg44hAQABMCow\nKAYIKwYBBQUHAgEWHGh0dHA6Ly9yZXBvc2l0b3J5LmVtc2lnbi5jb20wDQYLKwYB\nBAGDjiEBAngwBwYFZ4EMAQEwdQYIKwYBBQUHAQEEaTBnMCQGCCsGAQUFBzABhhho\ndHRwOi8vb2NzcC1iLmVtc2lnbi5jb20wPwYIKwYBBQUHMAKGM2h0dHA6Ly9yZXBv\nc2l0b3J5LmVtc2lnbi5jb20vY2VydHMvRU1FVlRMU0NBRzNCLnA3YzA3BgNVHR8E\nMDAuMCygKqAohiZodHRwOi8vY3JsLmVtc2lnbi5jb20vP3Jvb3R0bHNjYWczLmNy\nbDAOBgNVHQ8BAf8EBAMCAQYwEgYDVR0TAQH/BAgwBgEB/wIBADAKBggqhkjOPQQD\nAwNpADBmAjEA9Jq999oaOUKhFwopw33iCmt0OHIn3gjT43GfuOHR/YckXdwoNdDA\nlIEhz0NgRO3/AjEAvY/a3OMvktIZLYjn+WLKmp0QhrIMV+e6EpS7j4GVeOKouNp6\nMN/P+ByGeiRMghzB\n-----END CERTIFICATE-----\n",
|
||||
"f2edaaff3d9b701fb4d14c9f285f8c0bad90fc3bcc1fa07d47f4718edaba34a5": "-----BEGIN CERTIFICATE-----\nMIICKDCCAa2gAwIBAgIQAY/OySfBFvWcmX7cDM0uKzAKBggqhkjOPQQDAzBVMQsw\nCQYDVQQGEwJJTjElMCMGA1UECgwcZU11ZGhyYSBUZWNobm9sb2dpZXMgTGltaXRl\nZDEfMB0GA1UEAwwWZW1TaWduIFJvb3QgQ1MgQ0EgLSBHMzAeFw0yNDAyMDgwODMz\nMTRaFw00OTAyMDcwODMzMTRaMFUxCzAJBgNVBAYTAklOMSUwIwYDVQQKDBxlTXVk\naHJhIFRlY2hub2xvZ2llcyBMaW1pdGVkMR8wHQYDVQQDDBZlbVNpZ24gUm9vdCBD\nUyBDQSAtIEczMHYwEAYHKoZIzj0CAQYFK4EEACIDYgAESl9kM5D0VA/Og76dz5Xz\nw1hnGQ4fSR9993/qBkkvI48pXQVKOdyHCgAZThgWejQ8OpFRiTISjpabn4tfRFOY\nuzhApR/XxGbkg9X+2cEtbyghF9CdoF91W22V/R2EVe4xo0IwQDAdBgNVHQ4EFgQU\naHQD+IXKjVRQVx0W4MjeRQ2rzS0wDgYDVR0PAQH/BAQDAgEGMA8GA1UdEwEB/wQF\nMAMBAf8wCgYIKoZIzj0EAwMDaQAwZgIxAK9OlACrFod3UgWY1ai+DF9sGFhahU59\nrv4jJyV01QaxGn0HErZ8gjHkngqe+tcFogIxAPTdW0hBmrGjhxBWdv9mZNdVFiJ4\nlrZ3ahvimsLhTQpnDBgiGKv6Ov1Fx/CE6yjIAg==\n-----END CERTIFICATE-----\n",
|
||||
"f3c3c15bc7edf2b3ebdc922af112f2a0c414eb509901099ef0488c0942e77efe": "-----BEGIN CERTIFICATE-----\nMIICODCCAb6gAwIBAgIUPXjC97k7buQHZ7p5ehDU8QNyrR4wCgYIKoZIzj0EAwIw\nPzELMAkGA1UEBhMCTkwxEzARBgNVBAoMCmVNdWRocmEgQlYxGzAZBgNVBAMMEkVN\nIE9WIFRMUyBDQSAtIEczQjAeFw0yNjEwMjAwMDU5MzdaFw0yNzA1MDMwMTA0Mzda\nMC8xLTArBgNVBAMMJG92LWczYi1lbXVkaHJhLndlLWhhdmUtYS1wcm9ibGVtLmNv\nbTBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABA1haD+95Albk0UN6YtDCPpqevTv\nYUmEhsIVpb/hnhsbBlH0yLthSE2JLH14ywiQJXttoc/pDWC9eoICW6CJXL2jgacw\ngaQwDAYDVR0TAQH/BAIwADAOBgNVHQ8BAf8EBAMCB4AwEwYDVR0lBAwwCgYIKwYB\nBQUHAwEwLwYDVR0RBCgwJoIkb3YtZzNiLWVtdWRocmEud2UtaGF2ZS1hLXByb2Js\nZW0uY29tMB0GA1UdDgQWBBQUhzc4s202Hn62G0srmgguYdY2MTAfBgNVHSMEGDAW\ngBSb2GVRr+fnLDetCO5pGdm/N4SuCjAKBggqhkjOPQQDAgNoADBlAjEAoJ6d4zzA\nxE349yGHHrwKZpVCC/HBFutcOpOfzQoe8ikbmaaBTdtKai5TEllwt3dBAjBeHGiX\nVheDhJfewbei99cw1MfYB5tPwdfuY5pe1MJz0Tg9zJ4JgxBvF6LTT0OOXZ4=\n-----END CERTIFICATE-----\n",
|
||||
"fd9d5c2dbbd4995ab69b8d50c439d9527b00529199c2fd01b2174eb58f07021f": "-----BEGIN CERTIFICATE-----\nMIIDzTCCArWgAwIBAgIQBfB4dS3s56G7hZXTAxZxcTANBgkqhkiG9w0BAQsFADB7\nMQswCQYDVQQGEwJHQjEbMBkGA1UECAwSR3JlYXRlciBNYW5jaGVzdGVyMRAwDgYD\nVQQHDAdTYWxmb3JkMRowGAYDVQQKDBFDb21vZG8gQ0EgTGltaXRlZDEhMB8GA1UE\nAwwYQUFBIENlcnRpZmljYXRlIFNlcnZpY2VzMB4XDTI1MDUxNDAwMDAwMFoXDTI4\nMTIzMTIzNTk1OVowQDELMAkGA1UEBhMCVVMxFDASBgNVBAoMC2VNdWRocmEgSW5j\nMRswGQYDVQQDDBJFTSBFViBUTFMgQ0EgLSBHM0MwdjAQBgcqhkjOPQIBBgUrgQQA\nIgNiAAQtVugppvuCxkpGl9lgr5UekGeukfkm00MG2kioqut4abamfm0nQm4EQqoD\ns24ZiN/2NlFO4c8H9bpLP/KgZkWufTHVhmrTGqPSRusLMXfumct1ZsUWznSAimaP\nkfDEf8qjggE0MIIBMDAfBgNVHSMEGDAWgBSgEQojPpbxB+zirynvgqV/0DCktDAd\nBgNVHQ4EFgQUpiVegKlNt/OWfBvHkd+SvO5L618wDgYDVR0PAQH/BAQDAgEGMBIG\nA1UdEwEB/wQIMAYBAf8CAQAwHQYDVR0lBBYwFAYIKwYBBQUHAwEGCCsGAQUFBwMC\nMDAGA1UdIAQpMCcwBwYFZ4EMAQEwDQYLKwYBBAGDjiEBAAEwDQYLKwYBBAGDjiEB\nAngwQwYDVR0fBDwwOjA4oDagNIYyaHR0cDovL2NybC5jb21vZG9jYS5jb20vQUFB\nQ2VydGlmaWNhdGVTZXJ2aWNlcy5jcmwwNAYIKwYBBQUHAQEEKDAmMCQGCCsGAQUF\nBzABhhhodHRwOi8vb2NzcC5jb21vZG9jYS5jb20wDQYJKoZIhvcNAQELBQADggEB\nAIFfHiaMSjnrdZygRYV4ekRBmG7ybcFk4hGQWMMyMFi0cAEk2+dGfz5bl8IaIbLq\nz+uVJzhOgVEX7WqIfgbWfFsfPYHYjPHBmy7SlZ3BwA86IQ6qF/etpX2/qL9A5yRN\nN3nVowUpaqV6H3aCpkAh3wR2ZNkvAbLDvPz3J0l+sEWjWv+ZISOV4R+hXDTdPXV0\n1lKxnbwqEsdo/sE2QkCDKwFrZh/0uKjWspydJj+jSaDLTrGNx50Dgmn7VEcD1ZI5\nFXjknVUiO25YlmS1dtVZSBbyUKSjlrP/yDwPcZunoAsmCAAtCKsFLviLaymzvsKI\neSSDGxpSySp2VCxompGDk8g=\n-----END CERTIFICATE-----\n"
|
||||
},
|
||||
"chains": {
|
||||
"dv-g3b-aaa-emudhra.we-have-a-problem.com": [
|
||||
"7e02622ab6d745c1edb4b431bb1c551e667305c3dd3eb0c7cc6dfdb523807424",
|
||||
"45a883f349aedfd5f3a649b5498ffb121712ba8b448f3f2a91dbc5a766fcddbe"
|
||||
],
|
||||
"dv-g3b-aaa-noct-emudhra.we-have-a-problem.com": [
|
||||
"5510ecf5764dee8dfe9468ae2d7323da21fcd5530889906540a2f8725c6f74db",
|
||||
"45a883f349aedfd5f3a649b5498ffb121712ba8b448f3f2a91dbc5a766fcddbe"
|
||||
],
|
||||
"dv-g3b-emudhra.we-have-a-problem.com": [
|
||||
"9dcb21b1841d75313b3c888f4bb9cc01f8f51379a76fbaa241ca492b668839e7",
|
||||
"42c4accd12c4f59597925e1a08a7a90102608543809308d50ec427e118ba1493",
|
||||
"3094025792039a68af6e553686410bdab7c0d4a36b9084cba64202e35d6ebbdd"
|
||||
],
|
||||
"dv-g3b-noct-emudhra.we-have-a-problem.com": [
|
||||
"0cbfe22b824385823fd3bf4be4d131e8901342933a7feef1cf8b3110b8e44a70",
|
||||
"42c4accd12c4f59597925e1a08a7a90102608543809308d50ec427e118ba1493",
|
||||
"3094025792039a68af6e553686410bdab7c0d4a36b9084cba64202e35d6ebbdd"
|
||||
],
|
||||
"dv-g3c-aaa-emudhra.we-have-a-problem.com": [
|
||||
"9200d153c3cac272cc06bf68f9ea163fc24659fc3331e77668236fe00645ae52",
|
||||
"62b7f9868263f72f6911fc78ceaf72c1979158b7368bf648683bb2aa39b02e72"
|
||||
],
|
||||
"dv-g3c-emudhra.we-have-a-problem.com": [
|
||||
"346cdd962e97189b59aee5526d1ae8fa714bb1a64094dfa67bef768ee93f36b5",
|
||||
"0164d7ceae5bb909c272af151ff44b1177ae9b47d9424b94f1d9558c30934d19",
|
||||
"3094025792039a68af6e553686410bdab7c0d4a36b9084cba64202e35d6ebbdd"
|
||||
],
|
||||
"ev-g3b-aaa-emudhra.we-have-a-problem.com": [
|
||||
"bc88231a840e54cced148da48806169e8d0b1c0a96267a427aeccda9d10e6145",
|
||||
"579480aea166290bd3c643589e795fc0d0e54e86505d180f1dff99efd3199512"
|
||||
],
|
||||
"ev-g3b-emudhra.we-have-a-problem.com": [
|
||||
"66374ae64962c437bd815a2aae546c76e30c2eafac8335192c8e517c15a246ed",
|
||||
"e7568fe6e12331f4fd7de61da4c7559d0f8eb192a218d4741a3ba75184333838",
|
||||
"3094025792039a68af6e553686410bdab7c0d4a36b9084cba64202e35d6ebbdd"
|
||||
],
|
||||
"ev-g3c-aaa-emudhra.we-have-a-problem.com": [
|
||||
"bcd6ab59ea95fd520bfe7709679b1f682abbd79c77367e84e25415b91de37753",
|
||||
"fd9d5c2dbbd4995ab69b8d50c439d9527b00529199c2fd01b2174eb58f07021f"
|
||||
],
|
||||
"ev-g3c-emudhra.we-have-a-problem.com": [
|
||||
"ccd80467f9cac38f115dcb6e4f57e4d74f5efa519814ea934aeb3be53b83a191",
|
||||
"432ec25f0d28de9006d97bf752f17bbb2cd41f669933fcb072d9a1148d0261f3",
|
||||
"3094025792039a68af6e553686410bdab7c0d4a36b9084cba64202e35d6ebbdd"
|
||||
],
|
||||
"ov-g3b-aaa-emudhra.we-have-a-problem.com": [
|
||||
"21f57f83631a93c98e867050075746b3361e0433c9e0e969462852476784f6f0",
|
||||
"5f1ded4dd590af74631290cf073b122a2aac6a1fa479778a0bd43579bd832699"
|
||||
],
|
||||
"ov-g3b-emudhra.we-have-a-problem.com": [
|
||||
"f3c3c15bc7edf2b3ebdc922af112f2a0c414eb509901099ef0488c0942e77efe",
|
||||
"98b896625c0c230adeec3eb2cf6aad5001ecf9156a68644e82374ce5833bc25b",
|
||||
"3094025792039a68af6e553686410bdab7c0d4a36b9084cba64202e35d6ebbdd"
|
||||
],
|
||||
"ov-g3c-aaa-emudhra.we-have-a-problem.com": [
|
||||
"59cf0386b56bc2414a0c06241ea9e25e01a9c93bac7a596648747f9d0ded4906",
|
||||
"7485b51c0ed192878fa1d2f948fb529ae1842cc5086c4b67f7be5b2039de9489"
|
||||
],
|
||||
"ov-g3c-emudhra.we-have-a-problem.com": [
|
||||
"4314941cf242a4ea4149aa7e9cdf77a15de2771a79984b809fc04443fea013c8",
|
||||
"cb6e15165ac802e9b3f993f16c25d41f4e81c21ebde71ae1b012ac00bf9c7c3f",
|
||||
"3094025792039a68af6e553686410bdab7c0d4a36b9084cba64202e35d6ebbdd"
|
||||
]
|
||||
},
|
||||
"cross_signed_roots": {
|
||||
"3094025792039a68af6e553686410bdab7c0d4a36b9084cba64202e35d6ebbdd": "emSign Root TLS CA - G3 (emSign ECC Root CA - G3 cross-sign)"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
"""Tests for src/activity.py — in-flight heavy-image-op counters (safe restart)."""
|
||||
from src import activity
|
||||
|
||||
|
||||
def test_image_op_counter_increments_and_decrements():
|
||||
assert activity.heavy_image_ops_active() == 0
|
||||
activity.begin_image_op("a")
|
||||
activity.begin_image_op("a")
|
||||
assert activity.heavy_image_ops_active() == 2
|
||||
activity.end_image_op("a")
|
||||
assert activity.heavy_image_ops_active() == 1
|
||||
activity.end_image_op("a")
|
||||
assert activity.heavy_image_ops_active() == 0
|
||||
# never goes negative
|
||||
activity.end_image_op("a")
|
||||
assert activity.heavy_image_ops_active() == 0
|
||||
|
||||
|
||||
def test_image_op_context_manager_and_busy():
|
||||
assert activity.busy() is False
|
||||
with activity.image_op("/api/image/inpaint"):
|
||||
assert activity.busy() is True
|
||||
snap = activity.activity()
|
||||
assert snap["image_ops"] == 1
|
||||
assert snap["image_ops_by_name"].get("/api/image/inpaint") == 1
|
||||
assert activity.busy() is False
|
||||
assert activity.activity()["image_ops"] == 0
|
||||
|
||||
|
||||
def test_image_op_released_on_exception():
|
||||
try:
|
||||
with activity.image_op("boom"):
|
||||
raise RuntimeError("x")
|
||||
except RuntimeError:
|
||||
pass
|
||||
assert activity.heavy_image_ops_active() == 0
|
||||
File diff suppressed because it is too large
Load Diff
@@ -123,12 +123,88 @@ def test_blocks_listing_the_data_directory_itself():
|
||||
_resolve_tool_path(DATA_DIR)
|
||||
|
||||
|
||||
def test_blocks_app_state_reached_by_relative_path(monkeypatch):
|
||||
"""The data directory is a relative hop from the checkout root, so
|
||||
def test_blocks_app_state_reached_by_relative_path(tmp_path, monkeypatch):
|
||||
"""The data directory is a relative hop from the agent workspace, so
|
||||
confinement cannot depend on the model supplying an absolute path."""
|
||||
monkeypatch.chdir(os.path.dirname(DATA_DIR))
|
||||
current_execution = importlib.import_module("src.tool_execution")
|
||||
data_dir = tmp_path / "data"
|
||||
data_dir.mkdir()
|
||||
workspace = _configure_test_data_tree(monkeypatch, data_dir)["AGENT_WORKSPACE_DIR"]
|
||||
workspace.mkdir()
|
||||
monkeypatch.setattr(current_execution, "_AGENT_WORKDIR", str(workspace))
|
||||
with pytest.raises(ValueError, match="application state"):
|
||||
_resolve_tool_path(os.path.join(os.path.basename(DATA_DIR), "sessions.json"))
|
||||
current_execution._resolve_tool_path(os.path.join("..", "sessions.json"))
|
||||
|
||||
|
||||
def test_relative_path_resolves_in_agent_workspace_not_server_cwd(tmp_path, monkeypatch):
|
||||
"""With no workspace bound, bash runs in the agent workspace, so a
|
||||
relative write_file/read_file path must land there too (#6679), not in
|
||||
the server's cwd, which is outside the allowed roots."""
|
||||
current_execution = importlib.import_module("src.tool_execution")
|
||||
data_dir = tmp_path / "data"
|
||||
data_dir.mkdir()
|
||||
workspace = _configure_test_data_tree(monkeypatch, data_dir)["AGENT_WORKSPACE_DIR"]
|
||||
workspace.mkdir()
|
||||
monkeypatch.setattr(current_execution, "_AGENT_WORKDIR", str(workspace))
|
||||
server_cwd = tmp_path / "checkout"
|
||||
server_cwd.mkdir()
|
||||
monkeypatch.chdir(server_cwd)
|
||||
token = current_execution._active_workspace.set(None)
|
||||
try:
|
||||
assert current_execution._resolve_tool_path("test.txt") == os.path.join(
|
||||
os.path.realpath(workspace), "test.txt"
|
||||
)
|
||||
assert current_execution._resolve_search_root("sub") == os.path.join(
|
||||
os.path.realpath(workspace), "sub"
|
||||
)
|
||||
finally:
|
||||
current_execution._active_workspace.reset(token)
|
||||
|
||||
|
||||
def test_request_without_workspace_seals_the_agent_workspace(tmp_path, monkeypatch):
|
||||
"""With no workspace, the native file tools get the folder bash/python
|
||||
already run in (#6679) instead of no root at all, and a relative path
|
||||
lands there while app state next to it stays out of reach."""
|
||||
from src.agent_runtime.authority import ExactOperation, OperationGrant, RequestAuthority
|
||||
from src.agent_runtime.resource_binding import resolve_filesystem_operation
|
||||
current_execution = importlib.import_module("src.tool_execution")
|
||||
data_dir = tmp_path / "data"
|
||||
data_dir.mkdir()
|
||||
workspace = _configure_test_data_tree(monkeypatch, data_dir)["AGENT_WORKSPACE_DIR"]
|
||||
workspace.mkdir()
|
||||
(data_dir / "sessions.json").write_text("{}", encoding="utf-8")
|
||||
monkeypatch.setattr(current_execution, "_AGENT_WORKDIR", str(workspace))
|
||||
|
||||
authority = RequestAuthority("request", "alice", "s", "", (OperationGrant("write_file"),))
|
||||
assert [r.path for r in authority.resource_roots] == [os.path.realpath(workspace)]
|
||||
|
||||
bound = resolve_filesystem_operation(
|
||||
ExactOperation.normalize("write_file", "test.txt\nhello"), roots=authority.resource_roots)
|
||||
assert bound.bindings[0].resource.path == os.path.join(os.path.realpath(workspace), "test.txt")
|
||||
with pytest.raises(ValueError, match="outside the sealed roots"):
|
||||
resolve_filesystem_operation(
|
||||
ExactOperation.normalize("read_file", "../sessions.json"), roots=authority.resource_roots)
|
||||
|
||||
|
||||
def test_symlinked_agent_workspace_is_not_sealed(tmp_path, monkeypatch):
|
||||
"""A symlinked agent workspace must not become the file-tool root:
|
||||
agent_cwd() and create_directories() refuse the same layout."""
|
||||
from src.agent_runtime.authority import OperationGrant, RequestAuthority
|
||||
current_execution = importlib.import_module("src.tool_execution")
|
||||
data_dir = tmp_path / "data"
|
||||
data_dir.mkdir()
|
||||
workspace = _configure_test_data_tree(monkeypatch, data_dir)["AGENT_WORKSPACE_DIR"]
|
||||
elsewhere = tmp_path / "elsewhere"
|
||||
elsewhere.mkdir()
|
||||
(elsewhere / "notes.txt").write_text("private", encoding="utf-8")
|
||||
try:
|
||||
workspace.symlink_to(elsewhere, target_is_directory=True)
|
||||
except OSError:
|
||||
pytest.skip("cannot create symlink")
|
||||
monkeypatch.setattr(current_execution, "_AGENT_WORKDIR", str(workspace))
|
||||
|
||||
authority = RequestAuthority("request", "alice", "s", "", (OperationGrant("read_file"),))
|
||||
assert authority.resource_roots == ()
|
||||
|
||||
|
||||
def test_blocks_app_state_reached_through_a_symlink(tmp_path):
|
||||
|
||||
@@ -281,7 +281,8 @@ async def test_browser_approval_application_binding_is_exact(producer, field, va
|
||||
original = authority()
|
||||
content = '{"action":"session_info"}'
|
||||
approval = approval_for(original, "private_browser", content)
|
||||
changed = replace(original, **{field: value}, browser_sessions=(), browser_pages=())
|
||||
# resource_roots=None re-seals the root for the changed owner.
|
||||
changed = replace(original, **{field: value}, browser_sessions=(), browser_pages=(), resource_roots=None)
|
||||
_, result = await dispatch(changed, "private_browser", content, approval)
|
||||
assert result["exit_code"] == 1 and not approval._claimed
|
||||
|
||||
|
||||
@@ -0,0 +1,387 @@
|
||||
"""Offline regressions for incident roots, unaffected roots, and CA directories."""
|
||||
|
||||
import contextlib
|
||||
import hashlib
|
||||
import importlib.util
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import ssl
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from types import SimpleNamespace
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
SPEC = importlib.util.spec_from_file_location("check_ca_trust", ROOT / "scripts/check_ca_trust.py")
|
||||
checker = importlib.util.module_from_spec(SPEC)
|
||||
SPEC.loader.exec_module(checker)
|
||||
INCIDENT = json.loads((ROOT / "tests/fixtures/ca-trust/incident.json").read_text())
|
||||
|
||||
|
||||
class CATrustTests(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
# Public certificate data is embedded below. Tests create temporary
|
||||
# PEM files from these constants; no fixture contains a private key.
|
||||
cls.temporary = tempfile.TemporaryDirectory()
|
||||
cls.fixtures = Path(cls.temporary.name)
|
||||
for name, pem in PUBLIC_CERTIFICATES.items():
|
||||
(cls.fixtures / name).write_text(pem)
|
||||
for fingerprint, pem in INCIDENT["certificates"].items():
|
||||
if hashlib.sha256(ssl.PEM_cert_to_DER_cert(pem)).hexdigest() != fingerprint:
|
||||
raise ValueError("incident fixture fingerprint mismatch")
|
||||
(cls.fixtures / f"{fingerprint}.pem").write_text(pem)
|
||||
|
||||
@classmethod
|
||||
def tearDownClass(cls):
|
||||
cls.temporary.cleanup()
|
||||
|
||||
def test_unaffected_emsign_root_remains_usable(self):
|
||||
checker.check_bundle(self.fixtures / "emsign-g1.pem")
|
||||
|
||||
def test_verbose_python_check_reports_and_rejects_affected_user_bundle(self):
|
||||
# A fresh interpreter must see persisted user packages, independently
|
||||
# of pytest's imported certifi/pip modules and any active test venv.
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
home = Path(tmp)
|
||||
env = dict(os.environ, HOME=str(home), PYTHONUSERBASE=str(home / ".local"))
|
||||
env.pop("PYTHONNOUSERSITE", None)
|
||||
env.pop("PYTHONPATH", None)
|
||||
python = getattr(sys, "_base_executable", sys.executable)
|
||||
site_result = subprocess.run(
|
||||
[python, "-m", "site", "--user-site"], env=env, capture_output=True, text=True,
|
||||
)
|
||||
self.assertEqual(site_result.returncode, 0, site_result.stderr)
|
||||
user_site = Path(site_result.stdout.strip())
|
||||
for package in ("certifi", "pip", "pip/_vendor", "pip/_vendor/certifi"):
|
||||
directory = user_site / package
|
||||
directory.mkdir(parents=True, exist_ok=True)
|
||||
(directory / "__init__.py").write_text(
|
||||
"from pathlib import Path\ndef where(): return str(Path(__file__).with_name('cacert.pem'))\n"
|
||||
)
|
||||
application_bundle = user_site / "certifi/cacert.pem"
|
||||
pip_bundle = user_site / "pip/_vendor/certifi/cacert.pem"
|
||||
for bundle in (application_bundle, pip_bundle):
|
||||
bundle.write_text(PUBLIC_CERTIFICATES["emsign-g1.pem"])
|
||||
command = [python, str(ROOT / "scripts/check_ca_trust.py"), "--python", "--verbose"]
|
||||
clean = subprocess.run(command, env=env, capture_output=True, text=True)
|
||||
self.assertEqual(clean.returncode, 0, clean.stderr)
|
||||
self.assertIn(str(application_bundle), clean.stdout)
|
||||
self.assertIn(str(pip_bundle), clean.stdout)
|
||||
self.assertIn(str(home), clean.stdout)
|
||||
self.assertIn("Python executable:", clean.stdout)
|
||||
application_bundle.write_text(PUBLIC_CERTIFICATES["emsign-g3.pem"])
|
||||
affected = subprocess.run(command, env=env, capture_output=True, text=True)
|
||||
self.assertEqual(affected.returncode, 1, affected.stderr)
|
||||
self.assertIn(str(application_bundle), affected.stderr)
|
||||
self.assertIn("emSign ECC Root CA - G3", affected.stderr)
|
||||
|
||||
def test_compromised_emsign_path_is_rejected(self):
|
||||
with self.assertRaisesRegex(ValueError, "emSign ECC Root CA - G3"):
|
||||
checker.check_bundle(self.fixtures / "emsign-g3.pem")
|
||||
|
||||
def test_aaa_cross_sign_path_is_rejected(self):
|
||||
with self.assertRaisesRegex(ValueError, "AAA Certificate Services"):
|
||||
checker.check_bundle(self.fixtures / "aaa.pem")
|
||||
|
||||
def test_mixed_bundle_does_not_hide_an_affected_root(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
bundle = Path(tmp) / "mixed.pem"
|
||||
bundle.write_bytes((self.fixtures / "emsign-g1.pem").read_bytes() + (self.fixtures / "aaa.pem").read_bytes())
|
||||
with self.assertRaisesRegex(ValueError, "AAA Certificate Services"):
|
||||
checker.check_bundle(bundle)
|
||||
|
||||
def test_all_published_intermediates_and_cross_signs_are_rejected(self):
|
||||
self.assertEqual(len(INCIDENT["intermediates"]), 16)
|
||||
for fingerprint, name in INCIDENT["intermediates"].items():
|
||||
with self.subTest(certificate=name), self.assertRaisesRegex(ValueError, "affected CA certificates"):
|
||||
checker.check_bundle(self.fixtures / f"{fingerprint}.pem")
|
||||
|
||||
def test_published_roots_and_cross_signed_issuer_are_rejected(self):
|
||||
certificates = INCIDENT["roots"] | INCIDENT["cross_signed_roots"]
|
||||
self.assertEqual(len(certificates), 5)
|
||||
for fingerprint, name in certificates.items():
|
||||
with self.subTest(certificate=name), self.assertRaises(ValueError):
|
||||
checker.check_bundle(self.fixtures / f"{fingerprint}.pem")
|
||||
|
||||
def test_clean_roots_do_not_hide_an_added_compromised_intermediate(self):
|
||||
fingerprint = next(iter(INCIDENT["intermediates"]))
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
bundle = Path(tmp) / "custom.pem"
|
||||
bundle.write_text(PUBLIC_CERTIFICATES["emsign-g1.pem"] + INCIDENT["certificates"][fingerprint])
|
||||
with self.assertRaisesRegex(ValueError, "EM DV TLS CA - G3B"):
|
||||
checker.check_bundle(bundle)
|
||||
|
||||
@unittest.skipUnless(shutil.which("openssl"), "offline chain verification requires OpenSSL")
|
||||
def test_published_chains_and_custom_anchor_with_affected_positive_control(self):
|
||||
# Freeze time inside the public fixtures' validity interval. Otherwise
|
||||
# expiry could make an ineffective trust-store fix appear successful.
|
||||
help_result = subprocess.run(["openssl", "verify", "-help"], capture_output=True, text=True)
|
||||
no_store = ["-no-CAstore"] if "-no-CAstore" in help_result.stdout + help_result.stderr else []
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
directory = Path(tmp)
|
||||
affected = directory / "affected.pem"
|
||||
affected.write_text("".join(INCIDENT["certificates"][key] for key in INCIDENT["roots"]))
|
||||
clean = self.fixtures / "emsign-g1.pem"
|
||||
custom = directory / "custom.pem"
|
||||
fingerprint = next(iter(INCIDENT["intermediates"]))
|
||||
custom.write_text(clean.read_text() + INCIDENT["certificates"][fingerprint])
|
||||
self.assertEqual(len(INCIDENT["chains"]), 14)
|
||||
custom_accepted = []
|
||||
for hostname, chain in INCIDENT["chains"].items():
|
||||
leaf, intermediates = directory / "leaf.pem", directory / "chain.pem"
|
||||
leaf.write_text(INCIDENT["certificates"][chain[0]])
|
||||
intermediates.write_text("".join(INCIDENT["certificates"][key] for key in chain[1:]))
|
||||
|
||||
def verify(bundle):
|
||||
return subprocess.run([
|
||||
"openssl", "verify", "-no-CApath", *no_store,
|
||||
"-attime", str(INCIDENT["verification_time"]),
|
||||
"-partial_chain", "-x509_strict", "-purpose", "sslserver",
|
||||
"-verify_hostname", hostname, "-CAfile", str(bundle),
|
||||
"-untrusted", str(intermediates), str(leaf),
|
||||
], capture_output=True, text=True)
|
||||
|
||||
with self.subTest(hostname=hostname):
|
||||
positive = verify(affected)
|
||||
self.assertEqual(positive.returncode, 0, positive.stderr)
|
||||
negative = verify(clean)
|
||||
self.assertNotEqual(negative.returncode, 0)
|
||||
self.assertIn("unable to get local issuer certificate", negative.stderr)
|
||||
if verify(custom).returncode == 0:
|
||||
custom_accepted.append(hostname)
|
||||
self.assertCountEqual(custom_accepted, [
|
||||
"dv-g3b-aaa-emudhra.we-have-a-problem.com",
|
||||
"dv-g3b-aaa-noct-emudhra.we-have-a-problem.com",
|
||||
"dv-g3b-emudhra.we-have-a-problem.com",
|
||||
"dv-g3b-noct-emudhra.we-have-a-problem.com",
|
||||
])
|
||||
checker.check_bundle(clean)
|
||||
for bundle in (affected, custom):
|
||||
with self.subTest(bundle=bundle.name), self.assertRaises(ValueError):
|
||||
checker.check_bundle(bundle)
|
||||
|
||||
def test_empty_malformed_and_missing_bundles_fail_closed(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
for content in ("", "not a certificate"):
|
||||
path = Path(tmp) / "bad.pem"
|
||||
path.write_text(content)
|
||||
with self.subTest(content=content), self.assertRaises((ssl.SSLError, ValueError)):
|
||||
checker.check_bundle(path)
|
||||
with self.assertRaises(OSError):
|
||||
checker.check_bundle(Path(tmp) / "missing.pem")
|
||||
|
||||
def test_each_configured_override_is_checked(self):
|
||||
fingerprint = next(iter(INCIDENT["intermediates"]))
|
||||
affected = self.fixtures / f"{fingerprint}.pem"
|
||||
clean = self.fixtures / "emsign-g1.pem"
|
||||
for name in ("LLM_CA_BUNDLE", "SSL_CERT_FILE", "REQUESTS_CA_BUNDLE", "CURL_CA_BUNDLE",
|
||||
"PIP_CERT", "NODE_EXTRA_CA_CERTS", "GRPC_DEFAULT_SSL_ROOTS_FILE_PATH"):
|
||||
for bundle, expected in ((clean, 0), (affected, 1)):
|
||||
with self.subTest(variable=name, bundle=bundle.name), patch.dict(
|
||||
os.environ, {name: str(bundle)}, clear=True
|
||||
), contextlib.redirect_stdout(io.StringIO()), contextlib.redirect_stderr(io.StringIO()):
|
||||
self.assertEqual(checker.main(["--overrides", str(clean)]), expected)
|
||||
|
||||
def test_invalid_override_is_not_hidden_by_a_clean_explicit_bundle(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
directory = Path(tmp)
|
||||
for name, content in (("empty.pem", ""), ("malformed.pem", "invalid")):
|
||||
(directory / name).write_text(content)
|
||||
for name in ("missing.pem", "empty.pem", "malformed.pem"):
|
||||
with self.subTest(file=name), patch.dict(
|
||||
os.environ, {"LLM_CA_BUNDLE": str(directory / name)}, clear=True
|
||||
), contextlib.redirect_stderr(io.StringIO()):
|
||||
self.assertEqual(checker.main(["--overrides", str(self.fixtures / "emsign-g1.pem")]), 1)
|
||||
|
||||
def test_no_configured_overrides_does_not_report_validated_stores(self):
|
||||
with patch.dict(os.environ, {"LLM_CA_BUNDLE": " "}, clear=True), contextlib.redirect_stderr(io.StringIO()):
|
||||
self.assertEqual(checker.main(["--overrides"]), 1)
|
||||
|
||||
def test_requests_directory_override_checks_hashed_intermediates(self):
|
||||
fingerprint = next(iter(INCIDENT["intermediates"]))
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
directory = Path(tmp)
|
||||
for name in ("REQUESTS_CA_BUNDLE", "CURL_CA_BUNDLE", "PIP_CERT"):
|
||||
with self.subTest(variable=name), patch.dict(os.environ, {name: str(directory)}, clear=True):
|
||||
for content, expected in ((PUBLIC_CERTIFICATES["emsign-g1.pem"], 0),
|
||||
(INCIDENT["certificates"][fingerprint], 1)):
|
||||
(directory / "12345678.0").write_text(content)
|
||||
with contextlib.redirect_stdout(io.StringIO()), contextlib.redirect_stderr(io.StringIO()):
|
||||
self.assertEqual(checker.main(["--overrides"]), expected)
|
||||
(directory / "12345678.0").unlink()
|
||||
with contextlib.redirect_stderr(io.StringIO()):
|
||||
self.assertEqual(checker.main(["--overrides"]), 1)
|
||||
|
||||
def test_safe_cafile_does_not_hide_stale_hashed_directory(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
directory = Path(tmp)
|
||||
(directory / "14bc7599.0").symlink_to(self.fixtures / "emsign-g3.pem")
|
||||
defaults = ssl.get_default_verify_paths()._replace(
|
||||
cafile=str(self.fixtures / "emsign-g1.pem"), capath=str(directory)
|
||||
)
|
||||
errors = io.StringIO()
|
||||
with patch.object(checker.ssl, "get_default_verify_paths", return_value=defaults), patch.dict(
|
||||
os.environ, {defaults.openssl_capath_env: str(directory)}
|
||||
):
|
||||
with contextlib.redirect_stderr(errors):
|
||||
result = checker.main(["--system"])
|
||||
self.assertEqual(result, 1)
|
||||
self.assertIn("emSign ECC Root CA - G3", errors.getvalue())
|
||||
|
||||
def test_hashed_non_ca_certificate_does_not_reject_clean_store(self):
|
||||
# Ubuntu's OpenSSL directory can also hash ssl-cert-snakeoil.pem.
|
||||
# A valid server certificate has no CA entries in get_ca_certs().
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
directory = Path(tmp)
|
||||
(directory / "ce275665.0").symlink_to(self.fixtures / "localhost-leaf.pem")
|
||||
defaults = ssl.get_default_verify_paths()._replace(
|
||||
cafile=str(self.fixtures / "emsign-g1.pem"), capath=str(directory)
|
||||
)
|
||||
with patch.object(checker.ssl, "get_default_verify_paths", return_value=defaults), patch.dict(
|
||||
os.environ, {defaults.openssl_capath_env: str(directory)}
|
||||
), contextlib.redirect_stdout(io.StringIO()):
|
||||
self.assertEqual(checker.main(["--system"]), 0)
|
||||
|
||||
def test_multiple_ca_directories_are_all_checked(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
clean, stale = Path(tmp) / "clean", Path(tmp) / "stale"
|
||||
clean.mkdir()
|
||||
stale.mkdir()
|
||||
(stale / "ee64a828.0").symlink_to(self.fixtures / "aaa.pem")
|
||||
defaults = ssl.get_default_verify_paths()._replace(
|
||||
cafile=str(self.fixtures / "emsign-g1.pem"), capath=None
|
||||
)
|
||||
errors = io.StringIO()
|
||||
with patch.object(checker.ssl, "get_default_verify_paths", return_value=defaults), patch.dict(
|
||||
os.environ, {defaults.openssl_capath_env: os.pathsep.join(map(str, (clean, stale)))}
|
||||
):
|
||||
with contextlib.redirect_stderr(errors):
|
||||
result = checker.main(["--system"])
|
||||
self.assertEqual(result, 1)
|
||||
self.assertIn("AAA Certificate Services", errors.getvalue())
|
||||
|
||||
def test_grpc_bundled_roots_are_checked_without_an_override(self):
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
package = Path(temporary) / "grpc"
|
||||
bundle = package / "_cython" / "_credentials" / "roots.pem"
|
||||
bundle.parent.mkdir(parents=True)
|
||||
bundle.write_bytes((self.fixtures / "emsign-g3.pem").read_bytes())
|
||||
errors = io.StringIO()
|
||||
with patch.dict(os.environ, {"GRPC_DEFAULT_SSL_ROOTS_FILE_PATH": ""}), patch.object(
|
||||
checker.importlib.util, "find_spec", return_value=SimpleNamespace(origin=str(package / "__init__.py"))
|
||||
), contextlib.redirect_stderr(errors):
|
||||
self.assertEqual(checker.main(["--grpc"]), 1)
|
||||
self.assertIn("emSign ECC Root CA - G3", errors.getvalue())
|
||||
|
||||
def test_grpc_explicit_clean_store_replaces_the_independent_bundle(self):
|
||||
with patch.dict(os.environ, {"GRPC_DEFAULT_SSL_ROOTS_FILE_PATH": str(self.fixtures / "emsign-g1.pem")}), patch.object(
|
||||
checker.importlib.util, "find_spec", side_effect=AssertionError("must use the explicit store")
|
||||
), contextlib.redirect_stdout(io.StringIO()):
|
||||
self.assertEqual(checker.main(["--grpc"]), 0)
|
||||
|
||||
def test_grpc_invalid_override_does_not_fall_back_silently(self):
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
for name, content in (("empty.pem", ""), ("malformed.pem", "not a certificate"), ("affected.pem", PUBLIC_CERTIFICATES["aaa.pem"])):
|
||||
bundle = Path(temporary) / name
|
||||
bundle.write_text(content)
|
||||
with self.subTest(name=name), patch.dict(os.environ, {"GRPC_DEFAULT_SSL_ROOTS_FILE_PATH": str(bundle)}), contextlib.redirect_stderr(io.StringIO()):
|
||||
self.assertEqual(checker.main(["--grpc"]), 1)
|
||||
with patch.dict(os.environ, {"GRPC_DEFAULT_SSL_ROOTS_FILE_PATH": str(Path(temporary) / "missing.pem")}), contextlib.redirect_stderr(io.StringIO()):
|
||||
self.assertEqual(checker.main(["--grpc"]), 1)
|
||||
|
||||
def test_grpc_check_reports_a_missing_installation(self):
|
||||
with patch.dict(os.environ, {"GRPC_DEFAULT_SSL_ROOTS_FILE_PATH": ""}), patch.object(
|
||||
checker.importlib.util, "find_spec", return_value=None
|
||||
), contextlib.redirect_stderr(io.StringIO()):
|
||||
self.assertEqual(checker.main(["--grpc"]), 1)
|
||||
|
||||
|
||||
# Public Mozilla CA certificates; provenance: tests/fixtures/ca-trust/README.md.
|
||||
PUBLIC_CERTIFICATES = {
|
||||
'localhost-leaf.pem': """-----BEGIN CERTIFICATE-----
|
||||
MIIBOTCB7KADAgECAhRcVxnOQNEFWf92K4dm4I8ChyjVMzAFBgMrZXAwFDESMBAG
|
||||
A1UEAwwJbG9jYWxob3N0MB4XDTI2MTAyMTE4NTgwN1oXDTM2MTAxODE4NTgwN1ow
|
||||
FDESMBAGA1UEAwwJbG9jYWxob3N0MCowBQYDK2VwAyEARglphvNgp9qRCESLHnuu
|
||||
W8VC5urdZl+YgS+Ux1BoLryjUDBOMB0GA1UdDgQWBBSaEckyR/76lX3a5Ijh8B42
|
||||
oJwN1DAfBgNVHSMEGDAWgBSaEckyR/76lX3a5Ijh8B42oJwN1DAMBgNVHRMBAf8E
|
||||
AjAAMAUGAytlcANBAJkq9ayy+d4AUZnjhWM31rFke5DT+l9QFcPl34+dpVhqJV/L
|
||||
m4IpPHYagqrBRHn9Dv/C8qYyPBKnAwJRpNptsgw=
|
||||
-----END CERTIFICATE-----
|
||||
""",
|
||||
'aaa.pem': """-----BEGIN CERTIFICATE-----
|
||||
MIIEMjCCAxqgAwIBAgIBATANBgkqhkiG9w0BAQUFADB7MQswCQYDVQQGEwJHQjEb
|
||||
MBkGA1UECAwSR3JlYXRlciBNYW5jaGVzdGVyMRAwDgYDVQQHDAdTYWxmb3JkMRow
|
||||
GAYDVQQKDBFDb21vZG8gQ0EgTGltaXRlZDEhMB8GA1UEAwwYQUFBIENlcnRpZmlj
|
||||
YXRlIFNlcnZpY2VzMB4XDTA0MDEwMTAwMDAwMFoXDTI4MTIzMTIzNTk1OVowezEL
|
||||
MAkGA1UEBhMCR0IxGzAZBgNVBAgMEkdyZWF0ZXIgTWFuY2hlc3RlcjEQMA4GA1UE
|
||||
BwwHU2FsZm9yZDEaMBgGA1UECgwRQ29tb2RvIENBIExpbWl0ZWQxITAfBgNVBAMM
|
||||
GEFBQSBDZXJ0aWZpY2F0ZSBTZXJ2aWNlczCCASIwDQYJKoZIhvcNAQEBBQADggEP
|
||||
ADCCAQoCggEBAL5AnfRu4ep2hxxNRUSOvkbIgwadwSr+GB+O5AL686tdUIoWMQua
|
||||
BtDFcCLNSS1UY8y2bmhGC1Pqy0wkwLxyTurxFa70VJoSCsN6sjNg4tqJVfMiWPPe
|
||||
3M/vg4aijJRPn2jymJBGhCfHdr/jzDUsi14HZGWCwEiwqJH5YZ92IFCokcdmtet4
|
||||
YgNW8IoaE+oxox6gmf049vYnMlhvB/VruPsUK6+3qszWY19zjNoFmag4qMsXeDZR
|
||||
rOme9Hg6jc8P2ULimAyrL58OAd7vn5lJ8S3frHRNG5i1R8XlKdH5kBjHYpy+g8cm
|
||||
ez6KJcfA3Z3mNWgQIJ2P2N7Sw4ScDV7oL8kCAwEAAaOBwDCBvTAdBgNVHQ4EFgQU
|
||||
oBEKIz6W8Qfs4q8p74Klf9AwpLQwDgYDVR0PAQH/BAQDAgEGMA8GA1UdEwEB/wQF
|
||||
MAMBAf8wewYDVR0fBHQwcjA4oDagNIYyaHR0cDovL2NybC5jb21vZG9jYS5jb20v
|
||||
QUFBQ2VydGlmaWNhdGVTZXJ2aWNlcy5jcmwwNqA0oDKGMGh0dHA6Ly9jcmwuY29t
|
||||
b2RvLm5ldC9BQUFDZXJ0aWZpY2F0ZVNlcnZpY2VzLmNybDANBgkqhkiG9w0BAQUF
|
||||
AAOCAQEACFb8AvCb6P+k+tZ7xkSAzk/ExfYAWMymtrwUSWgEdujm7l3sAg9g1o1Q
|
||||
GE8mTgHj5rCl7r+8dFRBv/38ErjHT1r0iWAFf2C3BUrz9vHCv8S5dIa2LX1rzNLz
|
||||
Rt0vxuBqw8M0Ayx9lt1awg6nCpnBBYurDC/zXDrPbDdVCYfeU0BsWO/8tqtlbgT2
|
||||
G9w84FoVxp7Z8VlIMCFlA2zs6SFz7JsDoeA3raAVGI/6ugLOpyypEBMs1OUIJqsi
|
||||
l2D4kF501KKaU73yqWjgom7C12yxow+ev+to51byrvLjKzg6CYG1a4XXvi3tPxq3
|
||||
smPi9WIsgtRqAEFQ8TmDn5XpNpaYbg==
|
||||
-----END CERTIFICATE-----
|
||||
""",
|
||||
'emsign-g1.pem': """-----BEGIN CERTIFICATE-----
|
||||
MIIDlDCCAnygAwIBAgIKMfXkYgxsWO3W2DANBgkqhkiG9w0BAQsFADBnMQswCQYD
|
||||
VQQGEwJJTjETMBEGA1UECxMKZW1TaWduIFBLSTElMCMGA1UEChMcZU11ZGhyYSBU
|
||||
ZWNobm9sb2dpZXMgTGltaXRlZDEcMBoGA1UEAxMTZW1TaWduIFJvb3QgQ0EgLSBH
|
||||
MTAeFw0xODAyMTgxODMwMDBaFw00MzAyMTgxODMwMDBaMGcxCzAJBgNVBAYTAklO
|
||||
MRMwEQYDVQQLEwplbVNpZ24gUEtJMSUwIwYDVQQKExxlTXVkaHJhIFRlY2hub2xv
|
||||
Z2llcyBMaW1pdGVkMRwwGgYDVQQDExNlbVNpZ24gUm9vdCBDQSAtIEcxMIIBIjAN
|
||||
BgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAk0u76WaK7p1b1TST0Bsew+eeuGQz
|
||||
f2N4aLTNLnF115sgxk0pvLZoYIr3IZpWNVrzdr3YzZr/k1ZLpVkGoZM0Kd0WNHVO
|
||||
8oG0x5ZOrRkVUkr+PHB1cM2vK6sVmjM8qrOLqs1D/fXqcP/tzxE7lM5OMhbTI0Aq
|
||||
d7OvPAEsbO2ZLIvZTmmYsvePQbAyeGHWDV/D+qJAkh1cF+ZwPjXnorfCYuKrpDhM
|
||||
tTk1b+oDafo6VGiFbdbyL0NVHpENDtjVaqSW0RM8LHhQ6DqS0hdW5TUaQBw+jSzt
|
||||
Od9C4INBdN+jzcKGYEho42kLVACL5HZpIQ15TjQIXhTCzLG3rdd8cIrHhQIDAQAB
|
||||
o0IwQDAdBgNVHQ4EFgQU++8Nhp6w492pufEhF38+/PB3KxowDgYDVR0PAQH/BAQD
|
||||
AgEGMA8GA1UdEwEB/wQFMAMBAf8wDQYJKoZIhvcNAQELBQADggEBAFn/8oz1h31x
|
||||
PaOfG1vR2vjTnGs2vZupYeveFix0PZ7mddrXuqe8QhfnPZHr5X3dPpzxz5KsbEjM
|
||||
wiI/aTvFthUvozXGaCocV685743QNcMYDHsAVhzNixl03r4PEuDQqqE/AjSxcM6d
|
||||
GNYIAwlG7mDgfrbESQRRfXBgvKqy/3lyeqYdPV8q+Mri/Tm3R7nrft8EI6/6nAYH
|
||||
6ftjk4BAtcZsCjEozgyfz7MjNYBBjWzEN3uBL4ChQEKF6dk4jeihU80Bv2noWgby
|
||||
RQuQ+q7hv53yrlc8pa6yVvSLZUDp/TGBLPQ5Cdjua6e0ph0VpZj3AYHYhX3zUVxx
|
||||
iN66zB+Afko=
|
||||
-----END CERTIFICATE-----
|
||||
""",
|
||||
'emsign-g3.pem': """-----BEGIN CERTIFICATE-----
|
||||
MIICTjCCAdOgAwIBAgIKPPYHqWhwDtqLhDAKBggqhkjOPQQDAzBrMQswCQYDVQQG
|
||||
EwJJTjETMBEGA1UECxMKZW1TaWduIFBLSTElMCMGA1UEChMcZU11ZGhyYSBUZWNo
|
||||
bm9sb2dpZXMgTGltaXRlZDEgMB4GA1UEAxMXZW1TaWduIEVDQyBSb290IENBIC0g
|
||||
RzMwHhcNMTgwMjE4MTgzMDAwWhcNNDMwMjE4MTgzMDAwWjBrMQswCQYDVQQGEwJJ
|
||||
TjETMBEGA1UECxMKZW1TaWduIFBLSTElMCMGA1UEChMcZU11ZGhyYSBUZWNobm9s
|
||||
b2dpZXMgTGltaXRlZDEgMB4GA1UEAxMXZW1TaWduIEVDQyBSb290IENBIC0gRzMw
|
||||
djAQBgcqhkjOPQIBBgUrgQQAIgNiAAQjpQy4LRL1KPOxst3iAhKAnjlfSU2fySU0
|
||||
WXTsuwYc58Byr+iuL+FBVIcUqEqy6HyC5ltqtdyzdc6LBtCGI79G1Y4PPwT01xyS
|
||||
fvalY8L1X44uT6EYGQIrMgqCZH0Wk9GjQjBAMB0GA1UdDgQWBBR8XQKEE9TMipuB
|
||||
zhccLikenEhjQjAOBgNVHQ8BAf8EBAMCAQYwDwYDVR0TAQH/BAUwAwEB/zAKBggq
|
||||
hkjOPQQDAwNpADBmAjEAvvNhzwIQHWSVB7gYboiFBS+DCBeQyh+KTOgNG3qxrdWB
|
||||
CUfvO6wIBHxcmbHtRwfSAjEAnbpV/KlK6O3t5nYBQnvI+GDZjVGLVTv7jHvrZQnD
|
||||
+JbNR6iC8hZVdyR+EhCVBCyj
|
||||
-----END CERTIFICATE-----
|
||||
""",
|
||||
}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,41 @@
|
||||
"""The managed browser policy must distrust exactly the incident's eight keys."""
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
import unittest
|
||||
|
||||
from cryptography import x509
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
# Public incident inventory: impacted-intermediates.json, 2026-10-21.
|
||||
COMPROMISED_SPKIS = {
|
||||
"43bffa19d6ef651451f6841e019931e96f6f2eaeff28ab4b6f1ce4576de2717b",
|
||||
"49dd353dc2b919a3770645117d0e60c16115e7d1bc259eefe734b00ea434fe5f",
|
||||
"5c0660a2b164910d8ff589c18e14552e9e0ec237dcefe2cb736d41a4811f81a0",
|
||||
"63ecefee431d49523bc1136c4e72d0206a477086caf606cd5cf025686c109cf8",
|
||||
"92c063d67e8f14db71d288e11266ffdaf69cbf115f7bab14f3bc2f68d25e0e30",
|
||||
"e0254c7fcdc265e2f0f8a428dabb75ab4ae039b4872d62f5ee57f0877d5a8a41",
|
||||
"ebed6475c2c2d80fcb199f3fe8dd6e6b3ce60b77eb15d4e42c3dec0c6f5b5105",
|
||||
"ec4ffa1ad0ac88081314b7aaffef6a91f97cd57291bb5ba686c5415f5eb4f6b6",
|
||||
}
|
||||
|
||||
|
||||
class ChromiumCATrustTests(unittest.TestCase):
|
||||
def test_policy_distrusts_each_compromised_key_without_adding_trust(self):
|
||||
policy = json.loads((ROOT / "docker" / "chromium-emudhra.json").read_text())
|
||||
self.assertEqual(set(policy), {"CADistrustedCertificates"})
|
||||
self.assertEqual(len(policy["CADistrustedCertificates"]), 8)
|
||||
keys = set()
|
||||
for encoded in policy["CADistrustedCertificates"]:
|
||||
certificate = x509.load_der_x509_certificate(base64.b64decode(encoded, validate=True))
|
||||
self.assertTrue(certificate.extensions.get_extension_for_class(x509.BasicConstraints).value.ca)
|
||||
self.assertNotEqual(certificate.subject, certificate.issuer)
|
||||
public_key = certificate.public_key().public_bytes(
|
||||
serialization.Encoding.DER, serialization.PublicFormat.SubjectPublicKeyInfo
|
||||
)
|
||||
keys.add(hashlib.sha256(public_key).hexdigest())
|
||||
self.assertEqual(keys, COMPROMISED_SPKIS)
|
||||
@@ -33,3 +33,16 @@ def test_python_tests_are_authoritative():
|
||||
|
||||
assert "python -m pytest -q" in python_tests
|
||||
assert "continue-on-error:" not in python_tests
|
||||
|
||||
|
||||
def test_ci_checks_system_trust_before_project_installation():
|
||||
python_tests = _indented_block(_WORKFLOW.read_text(), "python-tests", 2)
|
||||
system_check = python_tests.index("python scripts/check_ca_trust.py --system\n")
|
||||
system_update = python_tests.index("sudo apt-get install -y --no-install-recommends ffmpeg bubblewrap ca-certificates")
|
||||
bootstrap = python_tests.index("uv pip install --system --require-hashes")
|
||||
python_check = python_tests.index("python scripts/check_ca_trust.py --python\n")
|
||||
application_install = python_tests.index("uv pip install --system -r requirements.txt")
|
||||
|
||||
assert system_update < system_check < bootstrap < python_check < application_install
|
||||
assert system_check < python_tests.index("npm ci")
|
||||
assert system_check < python_tests.index("npx playwright install")
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
"""Agent chat history uses the same scope as existing chat and history routes."""
|
||||
|
||||
import pytest
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from starlette.requests import Request
|
||||
|
||||
from routes import codex_routes
|
||||
|
||||
|
||||
def _endpoint(router, method: str, path: str):
|
||||
for route in router.routes:
|
||||
if route.path == path and method in route.methods:
|
||||
return route.endpoint
|
||||
raise AssertionError(f"{method} {path} route not found")
|
||||
|
||||
|
||||
def _request(scopes: list[str]) -> Request:
|
||||
request = Request({
|
||||
"type": "http",
|
||||
"method": "GET",
|
||||
"path": "/api/codex/chat/session-test",
|
||||
"headers": [],
|
||||
"state": {},
|
||||
})
|
||||
request.state.current_user = "api"
|
||||
request.state.api_token = True
|
||||
request.state.api_token_owner = "alice"
|
||||
request.state.api_token_scopes = scopes
|
||||
return request
|
||||
|
||||
|
||||
def _history_router(calls: list[tuple]):
|
||||
router = APIRouter()
|
||||
|
||||
@router.get("/api/history/{session_id}")
|
||||
async def history(
|
||||
request: Request,
|
||||
session_id: str,
|
||||
limit: int | None = None,
|
||||
offset: int | None = None,
|
||||
):
|
||||
calls.append((request, session_id, limit, offset))
|
||||
return {
|
||||
"name": "Model debugging",
|
||||
"model": "org/model",
|
||||
"history": [{"role": "assistant", "content": "second"}],
|
||||
"total": 3,
|
||||
"offset": offset,
|
||||
"limit": limit,
|
||||
"endpoint_url": "http://private-endpoint",
|
||||
}
|
||||
|
||||
return router
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_history_read_uses_existing_paged_handler_without_exposing_endpoint_url():
|
||||
calls = []
|
||||
router = codex_routes.setup_codex_routes(history_router=_history_router(calls))
|
||||
read_chat = _endpoint(router, "GET", "/api/codex/chat/{session_id}")
|
||||
request = _request(["chat"])
|
||||
|
||||
result = await read_chat(request, "session-test", offset=1, limit=1)
|
||||
|
||||
assert calls == [(request, "session-test", 1, 1)]
|
||||
assert result == {
|
||||
"session_id": "session-test",
|
||||
"name": "Model debugging",
|
||||
"model": "org/model",
|
||||
"messages": [{"role": "assistant", "content": "second"}],
|
||||
"total": 3,
|
||||
"offset": 1,
|
||||
"limit": 1,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_token_without_chat_scope_cannot_read_history_or_reach_handler():
|
||||
calls = []
|
||||
router = codex_routes.setup_codex_routes(history_router=_history_router(calls))
|
||||
read_chat = _endpoint(router, "GET", "/api/codex/chat/{session_id}")
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await read_chat(_request(["todos:read"]), "session-test")
|
||||
|
||||
assert exc_info.value.status_code == 403
|
||||
assert calls == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_history_read_uses_existing_page_limit():
|
||||
calls = []
|
||||
router = codex_routes.setup_codex_routes(history_router=_history_router(calls))
|
||||
read_chat = _endpoint(router, "GET", "/api/codex/chat/{session_id}")
|
||||
|
||||
await read_chat(_request(["chat"]), "session-test", limit=1000)
|
||||
|
||||
assert calls[0][2:] == (100, 0)
|
||||
|
||||
|
||||
def test_capabilities_report_history_permission_from_chat_scope():
|
||||
router = codex_routes.setup_codex_routes(history_router=_history_router([]))
|
||||
capabilities = _endpoint(router, "GET", "/api/codex/capabilities")
|
||||
|
||||
without_chat = capabilities(_request(["todos:read"]))["tools"]["chat"]
|
||||
reader = capabilities(_request(["chat"]))["tools"]["chat"]
|
||||
|
||||
assert without_chat["read"] is False
|
||||
assert reader["read"] is True
|
||||
assert reader["available"] is True
|
||||
assert reader["actions"] == ["read"]
|
||||
@@ -0,0 +1,34 @@
|
||||
"""Sidecar images must be pinned to a versioned tag and an immutable digest.
|
||||
|
||||
A bare or ``:latest`` reference changes underneath an existing install on the
|
||||
next ``docker compose pull``/``up``, so two hosts on the same commit can run
|
||||
different ChromaDB or ntfy builds. The ``odysseus`` service is excluded: it is
|
||||
the project's own image, selected through ``ODYSSEUS_IMAGE``. The standalone
|
||||
GPU files are held equal to these services by test_gpu_compose_standalone.py.
|
||||
"""
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
|
||||
COMPOSE = Path(__file__).resolve().parents[1] / "docker-compose.yml"
|
||||
PINNED = re.compile(r"^[\w.\-/]+:(?P<tag>[\w.\-]+)@sha256:[0-9a-f]{64}$")
|
||||
|
||||
|
||||
def _sidecars():
|
||||
services = yaml.safe_load(COMPOSE.read_text(encoding="utf-8"))["services"]
|
||||
return {name: svc["image"] for name, svc in services.items() if name != "odysseus"}
|
||||
|
||||
|
||||
def test_expected_sidecars_present():
|
||||
assert {"chromadb", "searxng", "ntfy"} <= set(_sidecars())
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name", sorted(_sidecars()))
|
||||
def test_sidecar_image_has_version_tag_and_digest(name):
|
||||
image = _sidecars()[name]
|
||||
match = PINNED.match(image)
|
||||
assert match, f"{name}: expected <repo>:<tag>@sha256:<digest>, got {image!r}"
|
||||
assert match.group("tag") != "latest", f"{name}: pin a release tag, not latest"
|
||||
@@ -0,0 +1,136 @@
|
||||
"""Read-only Cookbook tools must be admin-only for the agent.
|
||||
|
||||
Regression: `list_cached_models`, `list_served_models`, `tail_serve_output`,
|
||||
`list_downloads`, `search_hf_models`, `list_serve_presets` and
|
||||
`list_cookbook_servers` sat in the plan-mode read-only allowlist but not in
|
||||
NON_ADMIN_BLOCKED_TOOLS or the dispatcher's _ADMIN_TOOLS. Their
|
||||
implementations call require_admin routes over the internal loopback with the
|
||||
process-wide INTERNAL_TOOL_TOKEN and never look at the owner, so a non-admin
|
||||
user's agent could reach admin capability through them. The worst case is
|
||||
`list_cached_models`: a caller-chosen `host` is forwarded to
|
||||
/api/model/cached, which SSHes to that host with the app user's keys and
|
||||
walks caller-chosen `model_dir`s. THREAT_MODEL.md lists model serving as
|
||||
admin-only, so the read side has to be gated the same way as serve/download.
|
||||
"""
|
||||
|
||||
import sys
|
||||
import types
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
|
||||
from src.tool_security import (
|
||||
NON_ADMIN_BLOCKED_TOOLS,
|
||||
blocked_tools_for_owner,
|
||||
delegated_credential_blocked_tools,
|
||||
)
|
||||
from src.tool_execution import _ADMIN_TOOLS
|
||||
from src.agent_runtime.authority import OperationGrant, RequestAuthority
|
||||
|
||||
READONLY_COOKBOOK_TOOLS = (
|
||||
"list_cached_models",
|
||||
"list_served_models",
|
||||
"tail_serve_output",
|
||||
"list_downloads",
|
||||
"search_hf_models",
|
||||
"list_serve_presets",
|
||||
"list_cookbook_servers",
|
||||
)
|
||||
|
||||
|
||||
def _install_core_auth_stub(monkeypatch, *, is_admin: bool):
|
||||
"""Narrow auth surface: AUTH enabled, configured, owner admin or not."""
|
||||
core_mod = types.ModuleType("core")
|
||||
core_mod.__path__ = []
|
||||
auth_mod = types.ModuleType("core.auth")
|
||||
|
||||
class FakeAuth:
|
||||
is_configured = True
|
||||
|
||||
def is_admin(self, username):
|
||||
return is_admin
|
||||
|
||||
auth_mod.AuthManager = lambda: FakeAuth()
|
||||
core_mod.auth = auth_mod
|
||||
monkeypatch.setitem(sys.modules, "core", core_mod)
|
||||
monkeypatch.setitem(sys.modules, "core.auth", auth_mod)
|
||||
monkeypatch.setattr("src.auth_helpers._auth_disabled", lambda: False)
|
||||
return auth_mod
|
||||
|
||||
|
||||
def test_readonly_cookbook_tools_are_in_every_admin_gate():
|
||||
from src.agent_loop import _ADMIN_TOOLS as prompt_admin_tools
|
||||
|
||||
# Spelled out (not imported from a shared tuple in src/) so dropping one
|
||||
# from either set fails here instead of silently reopening the hole.
|
||||
for tool in READONLY_COOKBOOK_TOOLS:
|
||||
assert tool in NON_ADMIN_BLOCKED_TOOLS, tool
|
||||
assert tool in _ADMIN_TOOLS, tool
|
||||
assert tool in prompt_admin_tools, tool
|
||||
|
||||
|
||||
def test_readonly_cookbook_tools_hidden_from_non_admin_and_bearer_runs(monkeypatch):
|
||||
_install_core_auth_stub(monkeypatch, is_admin=False)
|
||||
hidden = blocked_tools_for_owner("regular-user")
|
||||
for tool in READONLY_COOKBOOK_TOOLS:
|
||||
assert tool in hidden, tool
|
||||
assert tool in delegated_credential_blocked_tools(), tool
|
||||
|
||||
|
||||
def test_admin_owner_keeps_readonly_cookbook_tools(monkeypatch):
|
||||
_install_core_auth_stub(monkeypatch, is_admin=True)
|
||||
assert blocked_tools_for_owner("admin-user") == set()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("tool_name", READONLY_COOKBOOK_TOOLS)
|
||||
async def test_non_admin_agent_cannot_execute_readonly_cookbook_tools(monkeypatch, tool_name):
|
||||
_install_core_auth_stub(monkeypatch, is_admin=False)
|
||||
from src.tool_execution import NO_TOOL_SECURITY_CONTEXT, execute_tool_block
|
||||
|
||||
# The loopback must never be touched: if the gate fails, the tool would
|
||||
# hit a require_admin route with the internal token.
|
||||
import httpx
|
||||
|
||||
def _boom(*args, **kwargs):
|
||||
raise AssertionError(f"{tool_name} reached the internal loopback for a non-admin owner")
|
||||
|
||||
monkeypatch.setattr(httpx, "AsyncClient", _boom)
|
||||
monkeypatch.setattr(httpx, "get", _boom)
|
||||
monkeypatch.setattr(httpx, "post", _boom)
|
||||
|
||||
payload = '{"host": "root@10.0.0.5", "ssh_port": "22", "model_dir": "/root,/etc"}'
|
||||
desc, result = await execute_tool_block(
|
||||
SimpleNamespace(tool_type=tool_name, content=payload),
|
||||
owner="regular-user",
|
||||
security_context=NO_TOOL_SECURITY_CONTEXT,
|
||||
request_authority=RequestAuthority(
|
||||
"cookbook-test", "regular-user", "", "", (OperationGrant(tool_name),)
|
||||
),
|
||||
)
|
||||
assert desc == f"{tool_name}: BLOCKED"
|
||||
assert result["exit_code"] == 1
|
||||
assert "admin" in result["error"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("tool_name", READONLY_COOKBOOK_TOOLS)
|
||||
async def test_admin_agent_can_dispatch_readonly_cookbook_tools(monkeypatch, tool_name):
|
||||
from src import tool_implementations
|
||||
from src.tool_execution import NO_TOOL_SECURITY_CONTEXT, execute_tool_block
|
||||
|
||||
_install_core_auth_stub(monkeypatch, is_admin=True)
|
||||
handler = AsyncMock(return_value={"output": "Cookbook result", "exit_code": 0})
|
||||
monkeypatch.setattr(tool_implementations, f"do_{tool_name}", handler)
|
||||
desc, result = await execute_tool_block(
|
||||
SimpleNamespace(tool_type=tool_name, content="{}"),
|
||||
owner="admin-user",
|
||||
security_context=NO_TOOL_SECURITY_CONTEXT,
|
||||
request_authority=RequestAuthority(
|
||||
"cookbook-test", "admin-user", "", "", (OperationGrant(tool_name),)
|
||||
),
|
||||
)
|
||||
assert desc == tool_name
|
||||
assert result["exit_code"] == 0
|
||||
handler.assert_awaited_once_with("{}", owner="admin-user")
|
||||
@@ -0,0 +1,63 @@
|
||||
"""Execute the publication workflow's image-name setup without contacting GHCR."""
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
|
||||
|
||||
WORKFLOW = (
|
||||
Path(__file__).resolve().parents[1]
|
||||
/ ".github"
|
||||
/ "workflows"
|
||||
/ "docker-publish.yml"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.skipif(shutil.which("bash") is None, reason="Docker publication requires Bash")
|
||||
@pytest.mark.parametrize("job", ["build", "merge"])
|
||||
@pytest.mark.parametrize(
|
||||
("repository", "expected"),
|
||||
[
|
||||
("RaresKeY/odysseus-dev", "rareskey/odysseus-dev"),
|
||||
("ExampleOwner/Example-Repo", "exampleowner/example-repo"),
|
||||
("odysseus-dev/odysseus", "odysseus-dev/odysseus"),
|
||||
],
|
||||
)
|
||||
def test_image_name_is_lowercase_before_docker_steps(tmp_path, job, repository, expected):
|
||||
workflow = yaml.safe_load(WORKFLOW.read_text(encoding="utf-8"))
|
||||
image_name = workflow["env"].get("IMAGE_NAME", "").replace(
|
||||
"${{ github.repository }}", repository
|
||||
)
|
||||
github_env = tmp_path / "github-env"
|
||||
env = {
|
||||
"PATH": os.environ["PATH"],
|
||||
"GITHUB_REPOSITORY": repository,
|
||||
"GITHUB_ENV": str(github_env),
|
||||
"GITHUB_OUTPUT": str(tmp_path / "github-output"),
|
||||
"GITHUB_SHA": "a" * 40,
|
||||
}
|
||||
|
||||
# Run the real setup shell before the job starts using Docker actions.
|
||||
# Checkout, login, builds and registry publication are deliberately excluded.
|
||||
for step in workflow["jobs"][job]["steps"]:
|
||||
if step.get("uses", "").startswith("docker/"):
|
||||
break
|
||||
if "run" not in step:
|
||||
continue
|
||||
subprocess.run(
|
||||
["bash", "--noprofile", "--norc", "-e", "-o", "pipefail", "-c", step["run"]],
|
||||
cwd=WORKFLOW.parents[2],
|
||||
env=env,
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if github_env.exists():
|
||||
env.update(line.split("=", 1) for line in github_env.read_text().splitlines())
|
||||
image_name = env.get("IMAGE_NAME", image_name)
|
||||
|
||||
assert image_name == expected
|
||||
@@ -2,6 +2,7 @@ import asyncio
|
||||
import json
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
from PIL import Image
|
||||
|
||||
from src.tools.image import do_edit_image
|
||||
@@ -122,7 +123,8 @@ def test_edit_image_rejects_actions_without_complete_input_contract():
|
||||
assert "Use prompt, upscale or rembg" in result["error"]
|
||||
|
||||
|
||||
def test_prompt_edit_forwards_owned_pixels_and_preserves_original(monkeypatch, tmp_path):
|
||||
@pytest.mark.parametrize('incognito', [False, True])
|
||||
def test_prompt_edit_forwards_owned_pixels_and_preserves_original(monkeypatch, tmp_path, incognito):
|
||||
source_path = tmp_path / 'source.png'
|
||||
Image.new('RGB', (3, 2), 'red').save(source_path)
|
||||
original = source_path.read_bytes()
|
||||
@@ -135,9 +137,9 @@ def test_prompt_edit_forwards_owned_pixels_and_preserves_original(monkeypatch, t
|
||||
return {'image_id': 'edited', 'image_url': '/api/generated-image/edited.png'}
|
||||
from pathlib import Path
|
||||
monkeypatch.setattr('src.ai_interaction.do_edit_image', edit)
|
||||
result = asyncio.run(do_edit_image(json.dumps({'image_id': 'source-id', 'action': 'prompt', 'prompt': 'Add another cow'}), owner='alice'))
|
||||
result = asyncio.run(do_edit_image(json.dumps({'image_id': 'source-id', 'action': 'prompt', 'prompt': 'Add another cow'}), owner='alice', incognito=incognito))
|
||||
assert result['image_id'] == 'edited'
|
||||
assert calls == [('Add another cow', original, {'session_id': 'session-1', 'owner': 'alice', 'size': 'auto'})]
|
||||
assert calls == [('Add another cow', original, {'session_id': 'session-1', 'owner': 'alice', 'size': 'auto', 'incognito': incognito})]
|
||||
assert source_path.read_bytes() == original
|
||||
denied = asyncio.run(do_edit_image('{"image_id":"source-id","action":"prompt","prompt":"edit"}', owner=None))
|
||||
assert denied['error'] == 'Image not found'
|
||||
@@ -165,7 +167,7 @@ def test_uploaded_image_edit_uses_owner_scoped_reference(monkeypatch, tmp_path):
|
||||
monkeypatch.setattr('src.ai_interaction.do_edit_image', edit)
|
||||
args = '{"image_id":"odysseus://attachment/upload.jpg","action":"prompt","prompt":"Make more realistic"}'
|
||||
assert asyncio.run(do_edit_image(args, owner='alice')) == {'image_id': 'edited'}
|
||||
assert calls == [('Make more realistic', str(path), {'owner': 'alice', 'size': 'auto'})]
|
||||
assert calls == [('Make more realistic', str(path), {'owner': 'alice', 'size': 'auto', 'incognito': False})]
|
||||
assert resolutions == [('upload.jpg', {'owner': 'alice', 'allow_admin': False})]
|
||||
assert 'error' in asyncio.run(do_edit_image(args, owner='bob'))
|
||||
assert len(calls) == 1
|
||||
@@ -173,3 +175,23 @@ def test_uploaded_image_edit_uses_owner_scoped_reference(monkeypatch, tmp_path):
|
||||
disabled = asyncio.run(do_edit_image(args, owner='alice'))
|
||||
assert 'disabled' in disabled['error']
|
||||
assert len(calls) == 1
|
||||
|
||||
|
||||
def test_private_upscale_does_not_save_image_or_gallery_record(monkeypatch, tmp_path):
|
||||
source_path = tmp_path / 'source.png'
|
||||
Image.new('RGB', (3, 2), 'red').save(source_path)
|
||||
original = source_path.read_bytes()
|
||||
db = _Db(_source(source_path.name))
|
||||
monkeypatch.setattr('core.database.SessionLocal', lambda: db)
|
||||
monkeypatch.setattr('src.constants.GENERATED_IMAGES_DIR', str(tmp_path))
|
||||
result = asyncio.run(do_edit_image(
|
||||
'{"image_id":"source-id","action":"upscale","scale":2}',
|
||||
owner='alice', incognito=True,
|
||||
))
|
||||
assert result['exit_code'] == 0
|
||||
assert result['image_url'].startswith('data:image/png;base64,')
|
||||
assert not result['image_id']
|
||||
assert db.added == []
|
||||
assert not db.committed
|
||||
assert list(tmp_path.iterdir()) == [source_path]
|
||||
assert source_path.read_bytes() == original
|
||||
|
||||
@@ -26,6 +26,15 @@ def test_plus_button_adds_a_pixel_layer_directly():
|
||||
assert "Pixel Layer" not in AI_MISC
|
||||
|
||||
|
||||
def test_new_layer_is_inserted_above_the_selected_layer():
|
||||
block = AI_MISC[AI_MISC.index("function addEmptyLayer()"):AI_MISC.index("const buildFxMenu")]
|
||||
assert "const activeIndex = state.layers.findIndex(item => item.id === state.activeLayerId);" in block
|
||||
assert "const insertIndex = activeIndex >= 0 ? activeIndex + 1 : state.layers.length;" in block
|
||||
assert "state.layers.splice(insertIndex, 0, layer);" in block
|
||||
# it must not jump to the very top of the stack
|
||||
assert "state.layers.push(layer);" not in block
|
||||
|
||||
|
||||
def test_fx_button_opens_the_adjustment_menu():
|
||||
assert "const buildFxMenu = (anchor) => {" in AI_MISC
|
||||
assert "const addFxButton = document.getElementById('ge-add-fx');" in AI_MISC
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
"""Server health audit fix: the local editor VLM returned 400s when asked for a
|
||||
model id it does not serve (the client default 'Qwen3.5-9B-MLX-8bit', or the
|
||||
stale caption model 'Qwen/Qwen3-VL-30B'). ``_resolve_model`` maps the requested
|
||||
id to one the base actually serves."""
|
||||
import asyncio
|
||||
|
||||
from src import editor_agent_model as eam
|
||||
|
||||
|
||||
def _cache(base, ids):
|
||||
eam._MODELS_CACHE[base] = set(ids)
|
||||
|
||||
|
||||
def test_resolve_model_keeps_a_served_request():
|
||||
base = "http://x:1/v1"
|
||||
_cache(base, ["Qwen/Qwen3.5-9B", "other"])
|
||||
assert asyncio.run(eam._resolve_model(base, "Qwen/Qwen3.5-9B", "Qwen/Qwen3.5-9B")) == "Qwen/Qwen3.5-9B"
|
||||
|
||||
|
||||
def test_resolve_model_falls_back_when_requested_is_not_served():
|
||||
base = "http://x:2/v1"
|
||||
_cache(base, ["Qwen/Qwen3.5-9B"])
|
||||
# e.g. the client's MLX build name, or a stale caption model id.
|
||||
assert asyncio.run(eam._resolve_model(base, "Qwen3.5-9B-MLX-8bit", "Qwen/Qwen3.5-9B")) == "Qwen/Qwen3.5-9B"
|
||||
assert asyncio.run(eam._resolve_model(base, "Qwen/Qwen3-VL-30B", "Qwen/Qwen3.5-9B")) == "Qwen/Qwen3.5-9B"
|
||||
|
||||
|
||||
def test_resolve_model_picks_a_served_id_when_fallback_absent():
|
||||
base = "http://x:3/v1"
|
||||
_cache(base, ["B", "A"])
|
||||
assert asyncio.run(eam._resolve_model(base, "nope", "also-nope")) == "A"
|
||||
|
||||
|
||||
def test_resolve_model_keeps_request_when_served_list_unknown():
|
||||
base = "http://x:4/v1"
|
||||
_cache(base, [])
|
||||
# Server unreachable / no models listed -> don't rewrite the request.
|
||||
assert asyncio.run(eam._resolve_model(base, "some/model", "fallback")) == "some/model"
|
||||
|
||||
|
||||
def test_self_check_resolves_the_model_before_calling(monkeypatch):
|
||||
monkeypatch.setattr(eam, "local_bases", lambda: ["http://editor.test/v1"])
|
||||
base = eam.local_bases()[0]
|
||||
_cache(base, ["Qwen/Qwen3.5-9B"])
|
||||
seen = {}
|
||||
|
||||
async def fake_post(b, model, messages, headers, timeout, **kwargs):
|
||||
seen["model"] = model
|
||||
return "DONE"
|
||||
|
||||
monkeypatch.setattr(eam, "_post_chat", fake_post)
|
||||
out = asyncio.run(eam.self_check(
|
||||
"data:image/png;base64,AAAA", "data:image/png;base64,BBBB",
|
||||
"make it brighter", model="Qwen3.5-9B-MLX-8bit"))
|
||||
assert seen["model"] == "Qwen/Qwen3.5-9B"
|
||||
assert out["done"] is True
|
||||
|
||||
|
||||
def test_caption_resolves_the_caption_model(monkeypatch):
|
||||
monkeypatch.setattr(eam, "caption_base", lambda: "http://caption.test/v1")
|
||||
seen = {}
|
||||
|
||||
async def fake_post(b, model, messages, headers, timeout, **kwargs):
|
||||
seen["base"] = b
|
||||
seen["model"] = model
|
||||
return "a photo"
|
||||
|
||||
monkeypatch.setattr(eam, "_post_chat", fake_post)
|
||||
import src.editor_agent_model as m
|
||||
_cache(m.caption_base(), ["Qwen/Qwen3.5-9B"])
|
||||
txt = asyncio.run(m.caption_image("data:image/png;base64,AAAA"))
|
||||
assert txt == "a photo"
|
||||
assert seen["model"] == "Qwen/Qwen3.5-9B"
|
||||
|
||||
|
||||
def test_unconfigured_editor_helpers_do_not_probe_private_hosts(monkeypatch):
|
||||
monkeypatch.setattr(eam, "local_bases", lambda: [])
|
||||
monkeypatch.setattr(eam, "caption_base", lambda: "")
|
||||
monkeypatch.setattr(eam, "probe_models", lambda *a: (_ for _ in ()).throw(AssertionError("unexpected probe")))
|
||||
eam._probe_worker()
|
||||
assert asyncio.run(eam.caption_image("data:image/png;base64,AAAA")) is None
|
||||
result = asyncio.run(eam.self_check("data:image/png;base64,AAAA", "data:image/png;base64,BBBB", "brighter"))
|
||||
assert result["reason"] == "unavailable"
|
||||
@@ -0,0 +1,42 @@
|
||||
"""Holding Alt over a paint tool borrows the eyedropper (Photoshop parity)."""
|
||||
|
||||
from pathlib import Path
|
||||
import re
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
EYE = (ROOT / "static/js/editor/tools/eyedropper.js").read_text(encoding="utf-8")
|
||||
EDITOR = (ROOT / "static/js/galleryEditor.js").read_text(encoding="utf-8")
|
||||
CSS = (ROOT / "static/css/13-image-editor.css").read_text(encoding="utf-8")
|
||||
SW = (ROOT / "static/sw.js").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_alt_eyedropper_tool_list_excludes_clone_and_heal():
|
||||
assert (
|
||||
"export const ALT_EYEDROPPER_TOOLS = new Set(['brush', 'pixel', 'eraser', 'smudge', 'dodge', 'burn', 'gradient', 'bucket']);"
|
||||
in EYE
|
||||
)
|
||||
listed = EYE.split("ALT_EYEDROPPER_TOOLS = new Set([")[1].split("]);")[0]
|
||||
# Alt already picks the clone source, so clone/heal must not borrow.
|
||||
assert "clone" not in listed
|
||||
assert "heal" not in listed
|
||||
|
||||
|
||||
def test_begin_draw_borrows_the_eyedropper_on_alt():
|
||||
assert "ALT_EYEDROPPER_TOOLS as _altEyedropperTools" in EDITOR
|
||||
assert "if (e.altKey && _altEyedropperTools.has(state.tool)) {" in EDITOR
|
||||
assert "_eyedropperTool.pick(e);" in EDITOR
|
||||
|
||||
|
||||
def test_alt_held_switches_the_cursor_and_hides_the_ring():
|
||||
assert "const _syncAltEyedropper = (on) => {" in EDITOR
|
||||
assert "document.body.classList.toggle('ge-alt-eyedropper', !!on && _altEyedropperTools.has(state.tool));" in EDITOR
|
||||
assert "document.addEventListener('keydown', (ev) => { if (state.editorOpen) _syncAltEyedropper(ev.altKey); });" in EDITOR
|
||||
assert "document.addEventListener('keyup', (ev) => { if (state.editorOpen) _syncAltEyedropper(ev.altKey); });" in EDITOR
|
||||
assert "window.addEventListener('blur', () => _syncAltEyedropper(false));" in EDITOR
|
||||
assert "body.ge-alt-eyedropper .ge-main-canvas {\n cursor: url(\"data:image/svg+xml;utf8,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22" in CSS
|
||||
assert "2 22, crosshair !important;" in CSS
|
||||
assert "body.ge-alt-eyedropper .ge-brush-cursor {\n display: none !important;\n}" in CSS
|
||||
|
||||
|
||||
def test_sw_cache_name_bumped():
|
||||
assert re.search(r"odysseus-v\d+-pr40-editor-precache", SW)
|
||||
@@ -0,0 +1,99 @@
|
||||
"""Visual audit clean-up regressions (inpaint popover, tool rail, Quick Edit pill,
|
||||
compact Text/Shape/Rembg strips, label sizes, dock/layer sizing)."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
CSS = (ROOT / "static/css/13-image-editor.css").read_text(encoding="utf-8")
|
||||
TOOLBAR = (ROOT / "static/js/editor/build/toolbar.js").read_text(encoding="utf-8")
|
||||
CONTROLS = (ROOT / "static/js/editor/build/controls.js").read_text(encoding="utf-8")
|
||||
EDITOR = (ROOT / "static/js/galleryEditor.js").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_inpaint_popover_stacks_its_rows_vertically():
|
||||
start = CSS.index("#ge-inpaint-section.ge-inpaint-popover {")
|
||||
block = CSS[start:CSS.index("}", start)]
|
||||
assert "display: flex !important;" in block
|
||||
assert "flex-direction: column !important;" in block
|
||||
assert "flex-wrap: nowrap !important;" in block
|
||||
assert "overflow-x: hidden;" in block
|
||||
assert ".ge-inpaint-section.ge-inpaint-popover .ge-inpaint-prompt" in CSS
|
||||
prompt = CSS[CSS.index(".ge-inpaint-section.ge-inpaint-popover .ge-inpaint-prompt"):]
|
||||
assert "width: 100%;" in prompt[:200]
|
||||
|
||||
|
||||
def test_tool_rail_scroller_keeps_swatches_out_of_the_tool_column():
|
||||
assert "scroller.className = 'ge-toolbar-scroll';" in TOOLBAR
|
||||
assert "toolbar.appendChild(scroller);" in TOOLBAR
|
||||
assert "scroller.appendChild(slotEl);" in TOOLBAR
|
||||
assert "toolbar.appendChild(swatches);" in TOOLBAR
|
||||
toolbar = CSS[CSS.index(".ge-toolbar {"):]
|
||||
toolbar = toolbar[:toolbar.index("}")]
|
||||
assert "display: flex;" in toolbar
|
||||
assert "flex-direction: column;" in toolbar
|
||||
assert "overflow: visible;" in toolbar
|
||||
assert ".ge-toolbar-scroll {" in CSS
|
||||
# The scroller must stretch its slots to the full rail width; centring
|
||||
# shrank every tool button to its icon width (~19px instead of 43px).
|
||||
scroller = CSS[CSS.index(".ge-toolbar-scroll {"):]
|
||||
scroller = scroller[:scroller.index("}")]
|
||||
assert "align-items: stretch;" in scroller
|
||||
assert "align-items: center;" not in scroller
|
||||
swatches = CSS[CSS.index(".ge-tool-swatches {"):]
|
||||
swatches = swatches[:swatches.index("}")]
|
||||
assert "position: static;" in swatches
|
||||
assert "position: sticky;" not in swatches
|
||||
assert "attachScrollAffordance(container.querySelector('.ge-toolbar-scroll'))" in EDITOR
|
||||
|
||||
|
||||
def test_collapsed_quick_edit_pill_does_not_add_canvas_scroll():
|
||||
start = CSS.index(".ge-ai-command-collapsed {")
|
||||
block = CSS[start:CSS.index("}", start)]
|
||||
assert "bottom: 0;" in block
|
||||
assert "-16px" not in block
|
||||
|
||||
|
||||
def test_text_and_shape_rare_controls_move_into_a_more_popover():
|
||||
assert 'id="ge-text-more"' in CONTROLS
|
||||
assert 'class="ge-btn ge-btn-sm ge-more-toggle" id="ge-text-more-toggle"' in CONTROLS
|
||||
assert 'id="ge-shape-more"' in CONTROLS
|
||||
assert 'id="ge-shape-more-toggle"' in CONTROLS
|
||||
assert "ge-tool-more" not in CONTROLS
|
||||
assert ".ge-opt-more {" in CSS
|
||||
assert "position: absolute;" in CSS[CSS.index(".ge-opt-more {"):CSS.index(".ge-opt-more {") + 260]
|
||||
assert "['ge-text-more-toggle', 'ge-text-more']" in EDITOR
|
||||
assert "['ge-shape-more-toggle', 'ge-shape-more']" in EDITOR
|
||||
# Escape is delegated through the gallery's single capture handler.
|
||||
esc = EDITOR[EDITOR.index("window.__galleryEditorHandleEscape = "):]
|
||||
esc = esc[:esc.index("return false;")]
|
||||
assert "document.querySelector('.ge-opt-more:not([hidden])')" in esc
|
||||
assert "openMore.hidden = true;" in esc
|
||||
|
||||
|
||||
def test_tall_strips_fit_on_one_row():
|
||||
assert "display: none;" in CSS[CSS.index(".ge-options-bar.ge-options-tall .ge-options-tool"):][:80]
|
||||
tall = CSS[CSS.index(".ge-options-bar.ge-options-tall .ge-controls--in-options {"):]
|
||||
tall = tall[:tall.index("}")]
|
||||
assert "flex: 1 1 auto;" in tall
|
||||
assert 'rows="2"' in CONTROLS
|
||||
|
||||
|
||||
def test_small_labels_grow_and_transform_steppers_are_bigger():
|
||||
dock_tab = CSS[CSS.index(".ge-dock-tab {"):]
|
||||
dock_tab = dock_tab[:dock_tab.index("}")]
|
||||
assert "font-size: 10px;" in dock_tab
|
||||
spin = CSS[CSS.index(".ge-transform-spin {"):]
|
||||
spin = spin[:spin.index("}")]
|
||||
assert "height: 20px;" in spin
|
||||
spin_btn = CSS[CSS.index("\n.ge-transform-spin button {"):]
|
||||
spin_btn = spin_btn[:spin_btn.index("}")]
|
||||
assert "width: 14px;" in spin_btn
|
||||
assert "font-size: 9px;" in spin_btn
|
||||
assert "#ge-transform-section .ge-transform-spin button {" in CSS
|
||||
|
||||
|
||||
def test_right_panel_gives_layers_more_room():
|
||||
dock = CSS[CSS.index(".ge-dock {"):]
|
||||
dock = dock[:dock.index("}")]
|
||||
assert "max-height: 56%;" in dock
|
||||
assert ".ge-right-panel--dock > .ge-layers { flex: 1 1 auto; min-height: 240px; }" in CSS
|
||||
@@ -145,12 +145,13 @@ def test_stroke_pipeline_has_pixel_stamping():
|
||||
assert "brushPixel: false" in state
|
||||
|
||||
|
||||
def test_pixel_brush_shows_a_size_grid():
|
||||
def test_pixel_brush_does_not_force_a_grid_overlay():
|
||||
guides = (ROOT / "static/js/editor/precision-guides.js").read_text()
|
||||
assert "const pixelBrush = !!state.brushPixel && (state.tool === 'brush' || state.tool === 'eraser');" in guides
|
||||
assert "if (state.gridVisible || pixelBrush) {" in guides
|
||||
assert "Math.max(1, Math.round(Number(state.brushSize) || 1))" in guides
|
||||
assert "const multiplier = pixelBrush ? 1 :" in guides
|
||||
# The Pixel tool must not paint a blue grid over the whole document (it
|
||||
# read as an unwanted blue tint); the grid follows View > Grid only.
|
||||
assert "state.gridVisible || pixelBrush" not in guides
|
||||
assert "122, 208, 255" not in guides
|
||||
assert "if (state.gridVisible) {" in guides
|
||||
|
||||
|
||||
def test_pixel_brush_snaps_only_while_shift_is_held():
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
"""The brush options strip fits on one line and hides its secondary controls
|
||||
(Spacing / Smoothing / Blend / pen-pressure) behind a caret ("v") dropdown,
|
||||
matching the wand's "More" treatment."""
|
||||
|
||||
from pathlib import Path
|
||||
import re
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
CSS = (ROOT / "static/css/13-image-editor.css").read_text(encoding="utf-8")
|
||||
CONTROLS = (ROOT / "static/js/editor/build/controls.js").read_text(encoding="utf-8")
|
||||
OPTIONS = (ROOT / "static/js/editor/build/options-bar.js").read_text(encoding="utf-8")
|
||||
STROKE = (ROOT / "static/js/editor/stroke-tool-sliders.js").read_text(encoding="utf-8")
|
||||
GALLERY = (ROOT / "static/js/galleryEditor.js").read_text(encoding="utf-8")
|
||||
SW = (ROOT / "static/sw.js").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_brush_strip_has_a_caret_more_dropdown():
|
||||
assert '<span class="ge-brush-more-wrap">' in CONTROLS
|
||||
assert 'id="ge-brush-more"' in CONTROLS
|
||||
assert 'aria-haspopup="true" aria-expanded="false"' in CONTROLS
|
||||
assert "ge-brush-more-btn" in CONTROLS
|
||||
assert '<path d="m6 9 6 6 6-6"/>' in CONTROLS
|
||||
assert 'class="ge-brush-more-menu" id="ge-brush-more-menu" hidden' in CONTROLS
|
||||
|
||||
|
||||
def test_secondary_brush_controls_live_after_the_menu_open():
|
||||
i = CONTROLS.index('id="ge-brush-more-menu"')
|
||||
j = CONTROLS.index('ge-brush-preset-row', i)
|
||||
block = CONTROLS[i:j]
|
||||
for marker in ('id="ge-brush-spacing"', 'id="ge-brush-smoothing"',
|
||||
'id="ge-brush-blend"', 'ge-brush-pressure-row',
|
||||
'id="ge-brush-flow-row"', 'id="ge-brush-softness-row"'):
|
||||
assert marker in block, marker
|
||||
|
||||
|
||||
def test_brush_size_label_drops_the_redundant_word():
|
||||
# The px value stays but the redundant "Size" word next to the brush is gone.
|
||||
assert '<label><span class="ge-size-label">' in CONTROLS
|
||||
assert ">Size <span" not in CONTROLS
|
||||
assert "nodeValue = (toolId === 'eraser') ? 'Brush Size ' : 'Size ';" not in GALLERY
|
||||
|
||||
|
||||
def test_stroke_groups_are_gated_per_tool():
|
||||
# Every brush-family tool shares one strip; the caret menu swaps the
|
||||
# secondary sliders by data-stroke-tool group.
|
||||
assert "const strokeTool = (toolId === 'brush' || toolId === 'pixel' || toolId === 'dodge' || toolId === 'burn') ? 'brush'" in GALLERY
|
||||
assert "document.querySelectorAll('#ge-brush-more-menu [data-stroke-tool]')" in GALLERY
|
||||
assert "row.dataset.strokeTool === strokeTool ? '' : 'none';" in GALLERY
|
||||
for group in ('eraser', 'clone', 'smudge'):
|
||||
assert f'class="ge-brush-stroke-group" data-stroke-tool="{group}"' in CONTROLS, group
|
||||
# The former standalone sections are gone.
|
||||
assert 'id="ge-brush-section"' not in CONTROLS
|
||||
assert 'id="ge-eraser-section"' not in CONTROLS
|
||||
|
||||
|
||||
def test_every_stroke_group_is_a_titled_card():
|
||||
# Each brush-family tool's rows ride the same titled card as Eraser so every
|
||||
# caret menu looks and reads the same, just with more or fewer rows.
|
||||
for group in ('brush', 'eraser', 'clone', 'smudge'):
|
||||
assert f'class="ge-brush-stroke-group" data-stroke-tool="{group}"' in CONTROLS, group
|
||||
assert '<div class="ge-section-title" id="ge-stroke-title-brush">Brush</div>' in CONTROLS
|
||||
assert '<div class="ge-section-title" id="ge-stroke-title-clone">Clone</div>' in CONTROLS
|
||||
assert '<div class="ge-section-title">Eraser</div>' in CONTROLS
|
||||
assert '<div class="ge-section-title">Smudge</div>' in CONTROLS
|
||||
# The shared brush card takes the active tool's name (Pixel / Dodge / Burn);
|
||||
# the clone card reads Healing for the heal brush.
|
||||
assert "const brushStrokeTitle = document.getElementById('ge-stroke-title-brush');" in GALLERY
|
||||
assert "brushStrokeTitle.textContent = _TOOLS[toolId]?.label || 'Brush';" in GALLERY
|
||||
assert "const cloneStrokeTitle = document.getElementById('ge-stroke-title-clone');" in GALLERY
|
||||
assert "cloneStrokeTitle.textContent = toolId === 'heal' ? 'Healing' : 'Clone';" in GALLERY
|
||||
# The rows no longer carry data-stroke-tool themselves — the card does.
|
||||
assert 'id="ge-brush-flow-row" data-stroke-tool="brush"' not in CONTROLS
|
||||
assert 'id="ge-brush-softness-row" data-stroke-tool="brush"' not in CONTROLS
|
||||
assert 'id="ge-brush-opacity-row" data-stroke-tool="brush"' not in CONTROLS
|
||||
|
||||
|
||||
def test_generic_options_more_button_is_retired():
|
||||
assert "const showMore = false;" in OPTIONS
|
||||
assert "moreBtn.hidden = !showMore;" in OPTIONS
|
||||
|
||||
|
||||
def test_brush_more_menu_is_wired():
|
||||
assert "document.getElementById('ge-brush-more')" in STROKE
|
||||
assert "document.getElementById('ge-brush-more-menu')" in STROKE
|
||||
assert "const closeBrushMore = () => {" in STROKE
|
||||
assert "if (event.key === 'Escape') closeBrushMore();" in STROKE
|
||||
|
||||
|
||||
def test_brush_more_menu_css_and_overflow_release():
|
||||
assert ".ge-brush-more-wrap { position: relative; display: inline-flex; }" in CSS
|
||||
assert ".ge-brush-more-menu {" in CSS
|
||||
assert ".ge-brush-more-menu[hidden] { display: none; }" in CSS
|
||||
assert '.ge-brush-more-btn[aria-expanded="true"] svg { transform: rotate(180deg); }' in CSS
|
||||
assert ".ge-options-bar:has(#ge-brush-more-menu:not([hidden])) { overflow: visible; }" in CSS
|
||||
|
||||
|
||||
def test_brush_strip_fits_and_smudge_row_respects_inline_hide():
|
||||
# The Opacity/Flow/Softness sliders are wound tighter so the strip fits.
|
||||
assert ".ge-controls--in-options #ge-brush-section { gap: 6px; }" in CSS
|
||||
# The brush controls hug their content so the sliders sit next to the caret.
|
||||
assert ".ge-controls--in-options #ge-brush-controls { flex: 0 0 auto; }" in CSS
|
||||
# The smudge Strength row is hidden with an inline display:none on other
|
||||
# tools; the `.ge-eraser-row { display:flex !important }` rule must not
|
||||
# override that.
|
||||
assert '.ge-eraser-row[style*="display:none"],' in CSS
|
||||
assert '.ge-eraser-row[style*="display: none"] { display: none !important; }' in CSS
|
||||
# The preset Save + × sit a touch high to line up with the dropdown.
|
||||
assert ".ge-brush-preset-row .ge-btn { flex: 0 0 auto; height: 24px; display: inline-flex; align-items: center; position: relative; top: -3px; }" in CSS
|
||||
# ...and [hidden] must beat that inline-flex so a built-in preset hides them.
|
||||
assert ".ge-brush-preset-row .ge-btn[hidden] { display: none !important; }" in CSS
|
||||
|
||||
|
||||
def test_caret_sits_in_the_size_row_next_to_the_slider():
|
||||
# The caret ("v") is inside the Size row, right after the size slider.
|
||||
row_i = CONTROLS.index('class="ge-control-row ge-brush-size-row"')
|
||||
preset_i = CONTROLS.index('ge-brush-preset-row', row_i)
|
||||
row = CONTROLS[row_i:preset_i]
|
||||
assert "ge-size-slider" in row
|
||||
assert "ge-brush-more-wrap" in row
|
||||
assert row.index("ge-size-slider") < row.index("ge-brush-more-wrap")
|
||||
assert ".ge-brush-size-row .ge-brush-more-wrap { margin-left: 4px; }" in CSS
|
||||
|
||||
|
||||
def test_custom_slot_and_save_button_visibility():
|
||||
# Save/delete only appear for a user-defined preset, never for a built-in.
|
||||
assert '<option value="">Custom</option>' in STROKE
|
||||
assert "const syncPresetButtons = () => {" in STROKE
|
||||
assert "const isBuiltIn = !!preset && !!preset.builtIn;" in STROKE
|
||||
assert "const isUserCustom = !!preset && !preset.builtIn;" in STROKE
|
||||
assert "if (presetSaveBtn) presetSaveBtn.hidden = isBuiltIn;" in STROKE
|
||||
assert "if (presetDeleteBtn) presetDeleteBtn.hidden = !isUserCustom;" in STROKE
|
||||
# The dropdown remembers the last picked preset instead of defaulting to Custom.
|
||||
assert "savedPresetId = localStorage.getItem('ge-brush-preset') || '';" in STROKE
|
||||
assert "renderPresets(presets.some(item => item.id === savedPresetId && !item.pixel) ? savedPresetId : '');" in STROKE
|
||||
assert "localStorage.setItem('ge-brush-preset', presetSelect.value || '');" in STROKE
|
||||
|
||||
|
||||
def test_custom_slot_drops_the_pixel_preset():
|
||||
# Selecting "Custom" clears pixel mode so the 1px Pixel preset is escapable.
|
||||
assert "if (!preset) {" in STROKE
|
||||
assert "state.brushPixel = false;" in STROKE
|
||||
|
||||
|
||||
def test_opacity_row_moved_into_the_caret_menu():
|
||||
i = CONTROLS.index('id="ge-brush-more-menu"')
|
||||
j = CONTROLS.index('ge-brush-preset-row', i)
|
||||
block = CONTROLS[i:j]
|
||||
assert 'id="ge-brush-opacity-row"' in block
|
||||
assert 'id="ge-brush-opacity-label"' in block
|
||||
assert 'id="ge-brush-opacity"' in block
|
||||
# Opacity rides the brush stroke group, shown for brush/dodge/burn.
|
||||
assert "data-stroke-tool=\"brush\"" in block
|
||||
assert "row.dataset.strokeTool === strokeTool ? '' : 'none';" in GALLERY
|
||||
|
||||
|
||||
def test_spacing_and_smoothing_have_preview_dots():
|
||||
assert 'class="ge-eraser-preview" id="ge-brush-preview-spacing"' in CONTROLS
|
||||
assert 'class="ge-eraser-preview" id="ge-brush-preview-smoothing"' in CONTROLS
|
||||
assert "const spacingPrev = document.getElementById('ge-brush-preview-spacing');" in STROKE
|
||||
assert "const smoothingPrev = document.getElementById('ge-brush-preview-smoothing');" in STROKE
|
||||
assert "spacingPrev.style.opacity =" in STROKE
|
||||
assert "smoothingPrev.style.background =" in STROKE
|
||||
|
||||
|
||||
def test_brush_colour_syncs_to_the_sidebar_swatch():
|
||||
assert "const _syncFgSwatch = (hex) => { if (fgSwatch) fgSwatch.value = hex; };" in GALLERY
|
||||
assert "_syncFgSwatch(e.target.value);" in GALLERY
|
||||
|
||||
|
||||
def test_blend_row_is_right_aligned():
|
||||
assert ".ge-brush-more-menu #ge-brush-blend { margin-left: auto; }" in CSS
|
||||
|
||||
|
||||
def test_sw_cache_name_bumped():
|
||||
assert re.search(r"odysseus-v\d+-pr40-editor-precache", SW)
|
||||
@@ -96,7 +96,36 @@ def test_bucket_tool_is_registered_and_wired():
|
||||
assert "const _bucketTool = createBucketTool({" in editor
|
||||
assert "if (state.tool === 'bucket') return _bucketTool.fill(e);" in editor
|
||||
assert "'fill.flood':" in editor
|
||||
assert "_pixelPaintTools = new Set(['brush', 'eraser', 'clone', 'heal', 'smudge', 'dodge', 'burn', 'gradient', 'bucket'])" in editor
|
||||
assert "_pixelPaintTools = new Set(['brush', 'pixel', 'eraser', 'clone', 'heal', 'smudge', 'dodge', 'burn', 'gradient', 'bucket', 'liquify'])" in editor
|
||||
|
||||
state = (ROOT / "static/js/editor/state.js").read_text()
|
||||
assert "bucketTolerance: 32" in state
|
||||
|
||||
|
||||
def test_bucket_rasterizes_a_pixel_layer_in_one_click():
|
||||
# A text/shape/placed layer used to bounce the user through a rasterize
|
||||
# dialog that could then sit behind the canvas, so the bucket looked broken.
|
||||
# Now the layer is rasterized silently (undoable) and filled in one click.
|
||||
editor = (ROOT / "static/js/galleryEditor.js").read_text()
|
||||
assert "function _rasterizeLayerNow(layer) {" in editor
|
||||
assert '_saveState(`Rasterize "${layer.name}"`);' in editor
|
||||
assert "if (!_rasterizeLayerNow(layer)) { void _offerRasterizeForTool(); return; }" in editor
|
||||
assert "if (!_pixelPaintTools.has(toolId)) void _offerRasterizeForTool(toolId);" in editor
|
||||
|
||||
|
||||
def test_all_paint_tools_rasterize_silently_instead_of_prompting():
|
||||
# Clone/heal/brush/etc. hit the same rasterize prompt on text/shape/placed
|
||||
# layers, so every paint tool now rasterizes silently and continues.
|
||||
editor = (ROOT / "static/js/galleryEditor.js").read_text()
|
||||
assert "// fall through — `layer` is a pixel layer now, so the tool runs below." in editor
|
||||
assert "if (_pixelPaintTools.has(state.tool) && !_getActiveMaskLayer() && !state.quickMaskActive &&" in editor
|
||||
|
||||
|
||||
def test_smudge_and_dodge_icons_are_distinct():
|
||||
toolbar = (ROOT / "static/js/editor/build/toolbar.js").read_text()
|
||||
# Smudge is a pointing hand/finger, not a swirl.
|
||||
assert "M12.5 12V5.5a1.75 1.75 0 1 1 3.5 0V12" in toolbar
|
||||
assert "M16 12v-1.5a1.5 1.5 0 0 1 3 0V16a5 5 0 0 1-5 5h-2.2" in toolbar
|
||||
# Dodge is the mirrored, filled dot.
|
||||
assert "m9.5 14.5-6 6" in toolbar
|
||||
assert '<circle cx="14" cy="10" r="6" fill="currentColor"/>' in toolbar
|
||||
|
||||
@@ -81,6 +81,8 @@ def test_selection_tool_menu_has_selection_ops():
|
||||
assert "Feather…" in ls
|
||||
assert "Expand" in ls
|
||||
assert "Contract" in ls
|
||||
assert "Fill Selection" in ls
|
||||
assert "Delete Selection" in ls
|
||||
|
||||
|
||||
def test_selection_items_disabled_without_selection():
|
||||
|
||||
@@ -35,19 +35,26 @@ def test_editor_paste_owns_clipboard_images_while_editor_open():
|
||||
|
||||
|
||||
def test_editor_ctrl_v_pastes_the_internal_selection_clipboard():
|
||||
"""Ctrl/Cmd+V must paste the internal selection clipboard directly.
|
||||
"""Ctrl/Cmd+V must not shadow a newer OS-clipboard image.
|
||||
|
||||
On an insecure origin (the editor is served over http on a LAN IP)
|
||||
``navigator.clipboard`` is unavailable, so copying a selection never reaches
|
||||
the OS clipboard and the native ``paste`` event carries stale ``text/plain``
|
||||
-> the internal-paste branch is skipped and Ctrl+V silently does nothing
|
||||
(the toolbar buttons worked because they bypass the event entirely).
|
||||
Intercepting the keystroke (preventDefault) bypasses the browser's paste
|
||||
event and always re-pasted ``state.internalClipboard``, so a screenshot
|
||||
taken outside the gallery could never be pasted. The key handler may only
|
||||
fall back to the internal copy when the paste event delivered no image
|
||||
(insecure origin: copy never reaches the OS clipboard).
|
||||
"""
|
||||
src = SRC.read_text(encoding="utf-8")
|
||||
# Shared, hoisted paste helper (also used by the native paste listener).
|
||||
assert "const pasteAsLayer = (imgSource, label, offset = { x: 0, y: 0 }) => {" in src
|
||||
# Explicit keyboard handler for the internal clipboard.
|
||||
# The paste listener must prefer real clipboard images.
|
||||
assert "const imageItem = Array.from(e.clipboardData?.items || [])" in src
|
||||
# Deferred keyboard fallback for the internal clipboard.
|
||||
assert "window.addEventListener('keydown', (e) => {" in src
|
||||
assert "if (String(e.key || '').toLowerCase() !== 'v') return;" in src
|
||||
assert "if (!state.internalClipboard) return;" in src
|
||||
assert "pasteKeyPending = true;" in src
|
||||
assert "if (!pasteKeyPending) return;" in src
|
||||
assert "pasteAsLayer(state.internalClipboard, 'Pasted Selection'" in src
|
||||
# The key handler must NOT swallow the keystroke any more.
|
||||
keydown = src[src.index("// ── Ctrl/Cmd+V ──"):src.index("// ── Drag-and-drop ──")]
|
||||
assert "e.preventDefault();" not in keydown
|
||||
assert "e.stopImmediatePropagation();" not in keydown
|
||||
|
||||
@@ -23,7 +23,7 @@ def test_color_panels_module_shape():
|
||||
|
||||
def test_right_panel_spreads_extra_panels():
|
||||
src = read(RIGHT_PANEL)
|
||||
assert "export function buildRightPanel({ controlsHTML, layerPanelHTML, extraPanels = [] }) {" in src
|
||||
assert "export function buildRightPanel({ controlsHTML, layerPanelHTML, extraPanels = [], onCloseGroup = null }) {" in src
|
||||
assert "...(Array.isArray(extraPanels) ? extraPanels : [])," in src
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
EDITOR = (ROOT / "static/js/galleryEditor.js").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_async_composite_renders_into_a_back_buffer():
|
||||
assert "let _documentBackCanvas = null;" in EDITOR
|
||||
assert "const copyBack = () => {" in EDITOR
|
||||
start = EDITOR.index(" if (asyncComposite) {")
|
||||
end = EDITOR.index(" _asyncCompositeInFlight = job;", start)
|
||||
block = EDITOR[start:end]
|
||||
assert "_documentBackCanvas = document.createElement('canvas');" in block
|
||||
assert "_drawDocumentLayersAsync(back.getContext('2d'), render.isCurrent)" in block
|
||||
assert "_drawDocumentLayers(back.getContext('2d'), render.isCurrent);" in block
|
||||
# The document canvas must only be written by a completed generation, so an
|
||||
# invalidated render can never leave a half-drawn document on screen.
|
||||
assert "_drawDocumentLayersAsync(documentCanvas.getContext('2d')" not in block
|
||||
assert "_drawDocumentLayers(documentCanvas.getContext('2d')" not in block
|
||||
assert block.count("if (!render.isCurrent()) return;") >= 2
|
||||
|
||||
|
||||
def test_completed_generation_replaces_the_document_atomically():
|
||||
copy = EDITOR[EDITOR.index(" const copyBack = () => {"):EDITOR.index(" const renderPromise =")]
|
||||
assert "dctx.clearRect(0, 0, documentCanvas.width, documentCanvas.height);" in copy
|
||||
assert "dctx.drawImage(back, 0, 0);" in copy
|
||||
assert "_finishComposite(render, documentCanvas);" in copy
|
||||
|
||||
|
||||
def test_direct_drawing_stays_synchronous_for_live_feedback():
|
||||
start = EDITOR.index(" const asyncComposite = _hasRetainedEffects()")
|
||||
end = EDITOR.index(";", EDITOR.index("typeof OffscreenCanvas !== 'undefined'", start))
|
||||
gate = EDITOR[start:end]
|
||||
assert "&& !state.drawing" in gate
|
||||
assert "&& !state.moving" in gate
|
||||
@@ -0,0 +1,62 @@
|
||||
"""Each docked section's "..." menu closes that whole section.
|
||||
|
||||
Audit/request (2026-10-13): a dock section (Color, Adjustments, Panels,
|
||||
History) has no way to be dismissed from itself — you had to hunt through
|
||||
Window > panel toggles. The section's "..." (tabs-more) menu now carries a
|
||||
"Close <group>" row (Photoshop's "Close Tab Group") that hides every panel
|
||||
in the group; the group re-collapses out of the rail and returns when one of
|
||||
its panels is re-enabled from the Window menu.
|
||||
"""
|
||||
from pathlib import Path
|
||||
import re
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
DOCK = (ROOT / "static/js/editor/build/dock.js").read_text(encoding="utf-8")
|
||||
RIGHT = (ROOT / "static/js/editor/build/right-panel.js").read_text(encoding="utf-8")
|
||||
EDITOR = (ROOT / "static/js/galleryEditor.js").read_text(encoding="utf-8")
|
||||
CSS = (ROOT / "static/css/13-image-editor.css").read_text(encoding="utf-8")
|
||||
SW = (ROOT / "static/sw.js").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_dock_accepts_and_uses_onCloseGroup():
|
||||
assert "onCloseGroup = null" in DOCK
|
||||
assert "typeof onCloseGroup === 'function'" in DOCK
|
||||
assert "onCloseGroup(group.name)" in DOCK
|
||||
|
||||
|
||||
def test_tabs_menu_offers_close_section():
|
||||
assert "ge-dock-tabs-menu-close" in DOCK
|
||||
assert "ge-dock-tabs-menu-sep" in DOCK
|
||||
i = DOCK.index("ge-dock-tabs-menu-close")
|
||||
block = DOCK[i - 400:i + 200]
|
||||
assert "Close ${group.name}" in block
|
||||
|
||||
|
||||
def test_more_button_shown_when_a_close_handler_exists():
|
||||
i = DOCK.index("const overflowing = tabsEl.scrollWidth")
|
||||
block = DOCK[i:i + 400]
|
||||
assert "typeof onCloseGroup === 'function'" in block
|
||||
|
||||
|
||||
def test_right_panel_forwards_onCloseGroup():
|
||||
assert "onCloseGroup = null" in RIGHT
|
||||
i = RIGHT.index("onCloseGroup,")
|
||||
block = RIGHT[i:i + 60]
|
||||
assert "});" in block
|
||||
|
||||
|
||||
def test_composer_closes_every_panel_in_the_group():
|
||||
i = EDITOR.index("onCloseGroup: (groupName) =>")
|
||||
block = EDITOR[i:i + 260]
|
||||
assert "panelVisibility.PANELS" in block
|
||||
assert "p.group === groupName" in block
|
||||
assert "panelVisibility.setVisible(p.id, false)" in block
|
||||
|
||||
|
||||
def test_close_menu_styling_present():
|
||||
assert ".ge-dock-tabs-menu-close {" in CSS
|
||||
assert ".ge-dock-tabs-menu-sep {" in CSS
|
||||
|
||||
|
||||
def test_sw_cache_bumped():
|
||||
assert re.search(r"CACHE_NAME = 'odysseus-v\d+-pr40-editor-precache'", SW)
|
||||
@@ -79,6 +79,15 @@ def test_dock_css_present():
|
||||
assert token in css, token
|
||||
|
||||
|
||||
def test_panel_group_divider_resizes_with_a_fixed_basis():
|
||||
src = DOCK.read_text(encoding="utf-8")
|
||||
assert "function wireDivider(divider, groupIndex) {" in src
|
||||
# The dragged group must keep an exact, non-shrinking basis; a `1 1` basis
|
||||
# lets the height-constrained dock shrink it back to 0 (dead divider).
|
||||
assert "target.style.flex = `0 0 ${Math.round(next)}px`;" in src
|
||||
assert "1 1 ${Math.round(next)}px" not in src
|
||||
|
||||
|
||||
def test_status_bar_and_swatch_markup():
|
||||
topbar = (ROOT / "static/js/editor/build/topbar.js").read_text(encoding="utf-8")
|
||||
assert "ge-status-tool" in topbar
|
||||
@@ -103,3 +112,14 @@ def test_3d_and_plugins_stubs_are_disabled():
|
||||
def test_dock_module_is_precached():
|
||||
sw = SW.read_text(encoding="utf-8")
|
||||
assert "'/static/js/editor/build/dock.js'" in sw
|
||||
|
||||
|
||||
def test_dock_divider_double_click_expands_and_restores():
|
||||
src = DOCK.read_text(encoding="utf-8")
|
||||
assert "divider.addEventListener('dblclick'" in src
|
||||
assert "target.dataset.dockExpanded === '1'" in src
|
||||
assert "target.dataset.dockPrevSize = String(" in src
|
||||
assert "target.dataset.dockExpanded = '1';" in src
|
||||
# a plain click from the double-click must not clear the expand flag
|
||||
assert "let moved = false;" in src
|
||||
assert "if (!moved) { moved = true;" in src
|
||||
|
||||
@@ -21,7 +21,7 @@ def test_dock_panel_wide_enough_for_full_tab_labels():
|
||||
panel = css.split(".ge-right-panel {")[1].split("}")[0]
|
||||
assert "width: 340px" in panel
|
||||
tab = css.split(".ge-dock-tab {")[1].split("}")[0]
|
||||
assert "font-size: 9px" in tab
|
||||
assert "font-size: 10px" in tab
|
||||
|
||||
|
||||
def test_history_and_actions_are_their_own_dock_group():
|
||||
|
||||
@@ -18,7 +18,7 @@ def test_dock_recomputes_tab_overflow_after_layout():
|
||||
def test_dock_overflow_chevron_tracks_scroll_width():
|
||||
src = DOCK.read_text(encoding="utf-8")
|
||||
assert "const overflowing = tabsEl.scrollWidth - tabsEl.clientWidth > 1;" in src
|
||||
assert "moreEl.hidden = !overflowing;" in src
|
||||
assert "moreEl.hidden = !(overflowing || typeof onCloseGroup === 'function');" in src
|
||||
|
||||
|
||||
def test_dock_hides_partial_tabs_instead_of_clipping_labels():
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
EDITOR = (ROOT / "static/js/galleryEditor.js").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_drop_shadow_dialog_selects_a_direction_and_distance():
|
||||
# Drop shadows are stored as x/y offsets, but the dialog has to expose a
|
||||
# direction: convert at the boundary instead of asking for H/V pixels.
|
||||
assert "function _shadowAngleDistance(params = {}) {" in EDITOR
|
||||
assert "function _shadowOffsets(values = {}) {" in EDITOR
|
||||
assert "angle: Math.round((Math.atan2(-y, x) * 180) / Math.PI)," in EDITOR
|
||||
assert "x: Math.round(Math.cos(radians) * distance)," in EDITOR
|
||||
assert "y: Math.round(-Math.sin(radians) * distance)," in EDITOR
|
||||
assert "{ key: 'distance', label: 'Distance', min: 0, max: 200, step: 1, value: _shadowAngleDistance" in EDITOR
|
||||
assert "{ key: 'angle', label: 'Direction', min: -180, max: 180, step: 1, value: _shadowAngleDistance" in EDITOR
|
||||
assert "{ key: 'x', label: 'Horizontal'" not in EDITOR
|
||||
assert "{ key: 'y', label: 'Vertical'" not in EDITOR
|
||||
# Both the add and the edit dialogs turn the values back into offsets.
|
||||
assert EDITOR.count("..._shadowOffsets(values)") == 2
|
||||
assert EDITOR.count("..._shadowOffsets(result)") == 2
|
||||
# Soft-shadow presets kept their metre; the preset loop migrates them.
|
||||
assert "{ ...preset.params, ..._shadowAngleDistance(preset.params) }" in EDITOR
|
||||
@@ -0,0 +1,52 @@
|
||||
import pathlib
|
||||
|
||||
ROOT = pathlib.Path(__file__).resolve().parents[1]
|
||||
PANEL = (ROOT / "static/js/editor/layer-panel.js").read_text(encoding="utf-8")
|
||||
SORT = (ROOT / "static/js/dragSort.js").read_text(encoding="utf-8")
|
||||
CSS = (ROOT / "static/css/13-image-editor.css").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_effect_rows_can_be_dragged_onto_layers_to_copy_them():
|
||||
# Photoshop-style: drag an effect sub-row onto another layer/group and it
|
||||
# is copied there (the source keeps its effect).
|
||||
assert "function cloneEffectForCopy(effect) {" in PANEL
|
||||
assert "function wireEffectDrag(sub, owner, effect) {" in PANEL
|
||||
assert "wireEffectDrag(sub, group, effect);" in PANEL
|
||||
assert "wireEffectDrag(sub, layer, effect);" in PANEL
|
||||
assert "'.ge-layer-item[data-layer-id], .ge-layer-group-row[data-group-id]'" in PANEL
|
||||
assert "layer.effects.push(cloneEffectForCopy(effect));" in PANEL
|
||||
assert "saveState(`Copy ${effectLabel(effect.type)}`);" in PANEL
|
||||
assert "copied to ${layer.name}" in PANEL
|
||||
|
||||
|
||||
def test_effect_copy_gets_fresh_ids_and_its_own_mask_canvas():
|
||||
clone = PANEL[PANEL.index("function cloneEffectForCopy(effect) {"):]
|
||||
clone = clone[:clone.index("\n }")]
|
||||
assert "id: `effect-${Math.random().toString(36).slice(2, 9)}`," in clone
|
||||
assert "id: `effect-mask-${Math.random().toString(36).slice(2, 9)}`" in clone
|
||||
assert "const canvas = document.createElement('canvas');" in clone
|
||||
assert "canvas.getContext('2d').drawImage(effect.mask.canvas, 0, 0);" in clone
|
||||
assert "copy.mask.canvas = canvas;" in clone
|
||||
assert "copy.mask.ctx = canvas.getContext('2d');" in clone
|
||||
|
||||
|
||||
def test_effect_drag_does_not_start_the_layer_reorder():
|
||||
enable = PANEL[PANEL.index("dragSortModule.enable('ge-layers-list'"):]
|
||||
enable = enable[:enable.index("onReorder:")]
|
||||
assert ".ge-adj-sub-item" in enable
|
||||
# A drop re-renders the list mid-drag, so the snap-back must bail out when
|
||||
# the placeholder/dragged row were already detached.
|
||||
assert "if (!placeholder.parentNode || !draggedEl.isConnected) {" in SORT
|
||||
assert "placeholder.remove();\n return;" in SORT
|
||||
|
||||
|
||||
def test_effect_drag_has_ghost_and_drop_target_styles():
|
||||
assert ".ge-effect-drag-ghost {" in CSS
|
||||
ghost = CSS[CSS.index(".ge-effect-drag-ghost {"):]
|
||||
ghost = ghost[:ghost.index("}")]
|
||||
assert "position: fixed;" in ghost
|
||||
assert "pointer-events: none;" in ghost
|
||||
assert "z-index: 10030;" in ghost
|
||||
assert ".ge-layer-item.is-effect-drop-target," in CSS
|
||||
assert ".ge-layer-group-row.is-effect-drop-target {" in CSS
|
||||
assert ".ge-layer-item.is-effect-dragging {" in CSS
|
||||
@@ -67,7 +67,7 @@ def test_extra_menus_are_precached_and_mounted():
|
||||
assert "'/static/js/editor/wire-extra-menus.js'" in SW
|
||||
assert "import { wireExtraMenus } from './editor/wire-extra-menus.js';" in GALLERY
|
||||
assert "wireExtraMenus({" in GALLERY
|
||||
assert "export function wireExtraMenus({ closeOtherTopbarMenus, registerDocClickAway, dock, combineShapes, workspace, uiModule, exportLayeredPsd, syncWindowChecks, togglePanel })" in WIRE
|
||||
assert "export function wireExtraMenus({ closeOtherTopbarMenus, registerDocClickAway, dock, combineShapes, workspace, saveWorkspace, uiModule, exportLayeredPsd, syncWindowChecks, togglePanel, toggleQuickEdit, newDocument, promptNewDocument, newFromClipboard })" in WIRE
|
||||
|
||||
|
||||
def test_cross_menu_close_knows_new_menus():
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
"""The eyedropper shows a floating loupe next to the pointer while sampling.
|
||||
|
||||
The options bar already carries a live pixel preview (#ge-eyedropper-loupe).
|
||||
When the eyedropper is pressed the same preview should also pop up next to the
|
||||
mouse, so you can see the exact pixel being sampled.
|
||||
"""
|
||||
from pathlib import Path
|
||||
import re
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
TOOL = (ROOT / "static/js/editor/tools/eyedropper.js").read_text(encoding="utf-8")
|
||||
EDITOR = (ROOT / "static/js/galleryEditor.js").read_text(encoding="utf-8")
|
||||
CSS = (ROOT / "static/css/13-image-editor.css").read_text(encoding="utf-8")
|
||||
SW = (ROOT / "static/sw.js").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_eyedropper_passes_the_event_to_the_preview():
|
||||
assert "syncPreview?.(result?.color || null, result, e);" in TOOL
|
||||
assert "syncPreview?.(state.color, result, e);" in TOOL
|
||||
assert "syncPreview?.(null, null, null);" in TOOL
|
||||
|
||||
|
||||
def test_editor_builds_a_floating_cursor_loupe():
|
||||
assert "el.className = 'ge-eyedropper-cursor-loupe';" in EDITOR
|
||||
assert "function _drawEyedropperLoupe(canvas, sample)" in EDITOR
|
||||
assert "_cursorLoupeEl.classList.toggle('is-visible', _cursorLoupeVisible);" in EDITOR
|
||||
|
||||
|
||||
def test_cursor_loupe_shows_on_press_and_hides_on_release():
|
||||
assert "if (state.tool === 'eyedropper') { _setCursorLoupe(true, e); return _eyedropperTool.pick(e); }" in EDITOR
|
||||
assert "if (_cursorLoupeVisible) _setCursorLoupe(false);" in EDITOR
|
||||
|
||||
|
||||
def test_cursor_loupe_css_is_a_pointer_tracking_circle():
|
||||
j = CSS.find(".ge-eyedropper-cursor-loupe {")
|
||||
assert j != -1
|
||||
block = CSS[j:j + 500]
|
||||
assert "position: fixed;" in block
|
||||
assert "pointer-events: none;" in block
|
||||
assert "border-radius: 50%;" in block
|
||||
assert ".ge-eyedropper-cursor-loupe.is-visible {" in CSS
|
||||
|
||||
|
||||
def test_sw_version_is_current():
|
||||
m = re.search(r"CACHE_NAME = '(odysseus-v\d+-pr40-editor-precache)'", SW)
|
||||
assert m, "CACHE_NAME not found"
|
||||
assert re.fullmatch(r"odysseus-v\d+-pr40-editor-precache", m.group(1))
|
||||
@@ -0,0 +1,27 @@
|
||||
"""Undoing an "add effect" step must remove the effect from the layer.
|
||||
|
||||
Regression: `_restoreState` rebuilt each layer but never restored `layer.effects`
|
||||
(only groups got theirs back), so a freshly added gradient survived the first
|
||||
Ctrl+Z and only disappeared on a second undo that restored an older snapshot.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
import re
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
EDITOR = (ROOT / "static/js/galleryEditor.js").read_text(encoding="utf-8")
|
||||
SW = (ROOT / "static/sw.js").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_restore_state_restores_layer_effects():
|
||||
assert "layer.effects = Array.isArray(s.effects)" in EDITOR
|
||||
assert "? s.effects.map(effect => _normalizeEffect(effect))" in EDITOR
|
||||
assert "layer._effectPreview = null;" in EDITOR
|
||||
|
||||
|
||||
def test_restore_state_still_restores_group_effects():
|
||||
assert "effects: Array.isArray(group.effects) ? group.effects.map(effect => _normalizeEffect(effect)) : []," in EDITOR
|
||||
|
||||
|
||||
def test_sw_cache_name_bumped():
|
||||
assert re.search(r"odysseus-v\d+-pr40-editor-precache", SW)
|
||||
@@ -57,6 +57,16 @@ def test_moves_share_pixels_but_keep_independent_offsets_and_changed_pixels():
|
||||
assert run_node(script) == {"count": 20, "bytes": 400, "first": 0, "last": 19, "shared": True, "changed": True}
|
||||
|
||||
|
||||
def test_moves_share_pixels_with_a_four_byte_comparison():
|
||||
# The pixel comparison runs over every byte of every layer on each undo
|
||||
# snapshot; comparing Uint32 words keeps pen/selection commits fast.
|
||||
source = (ROOT / "static/js/editor/history-budget.js").read_text(encoding="utf-8")
|
||||
assert "function samePixels(image, other) {" in source
|
||||
assert "new Uint32Array(image.data.buffer, image.data.byteOffset, length >> 2)" in source
|
||||
assert "new Uint32Array(other.data.buffer, other.data.byteOffset, length >> 2)" in source
|
||||
assert "candidates.find(other => samePixels(image, other))" in source
|
||||
|
||||
|
||||
def test_history_budget_counts_saved_selections_and_group_masks():
|
||||
script = textwrap.dedent(
|
||||
f"""
|
||||
|
||||
@@ -47,7 +47,9 @@ def test_tool_key_selects_its_tool(tool, key):
|
||||
assert ("\n " + tool + ": { label: '") in TOOLBAR_JS
|
||||
# … and the keyboard handler focuses the palette by key.
|
||||
assert "const toolId = toolKeyMap[e.key.toLowerCase()];" in KEYBOARD_JS
|
||||
assert 'const toolBtn = toolbar.querySelector(`[data-tool="${toolId}"]`) || toolbar.querySelector(`[data-tool-option="${toolId}"]`);' in KEYBOARD_JS
|
||||
assert 'const flyoutItem = toolbar.querySelector(`[data-tool-option="${toolId}"]`);' in KEYBOARD_JS
|
||||
assert "const slotEl = flyoutItem ? flyoutItem.closest('.ge-tool-slot') : null;" in KEYBOARD_JS
|
||||
assert "toolBtn = toolBtn || toolbar.querySelector(`[data-tool=\"${toolId}\"]`) || flyoutItem;" in KEYBOARD_JS
|
||||
assert "if (toolBtn) toolBtn.click();" in KEYBOARD_JS
|
||||
|
||||
|
||||
|
||||
@@ -27,6 +27,15 @@ def test_deleting_the_last_layer_is_allowed():
|
||||
assert "else { state.activeLayerId = null; state.selectedLayerIds = []; state.activeGroupId = null; }" in panel
|
||||
|
||||
|
||||
def test_enter_confirms_the_transform_even_from_a_transform_field():
|
||||
keyboard = (ROOT / "static/js/editor/keyboard-shortcuts.js").read_text(encoding="utf-8")
|
||||
# A form field would normally swallow Enter; an active transform must still
|
||||
# accept when Enter is pressed from one of its number inputs.
|
||||
assert "!(state.transformActive && e.key === 'Enter')) return;" in keyboard
|
||||
assert "if (e.key === 'Enter' && state.transformActive) {" in keyboard
|
||||
assert "confirmTransform();" in keyboard
|
||||
|
||||
|
||||
def test_ctrl_zoom_shortcuts_drive_the_toolbar_and_override_the_browser():
|
||||
keyboard = (ROOT / "static/js/editor/keyboard-shortcuts.js").read_text(encoding="utf-8")
|
||||
# Ctrl/Cmd +/-/0 zoom the canvas (they call the toolbar zoom buttons and
|
||||
@@ -36,3 +45,19 @@ def test_ctrl_zoom_shortcuts_drive_the_toolbar_and_override_the_browser():
|
||||
assert "document.getElementById('ge-zoom-100')?.click();" in keyboard
|
||||
assert "e.code === 'NumpadAdd'" in keyboard
|
||||
assert "e.code === 'NumpadSubtract'" in keyboard
|
||||
|
||||
|
||||
def test_ctrl_alt_l_opens_the_levels_adjustment():
|
||||
keyboard = (ROOT / "static/js/editor/keyboard-shortcuts.js").read_text(encoding="utf-8")
|
||||
# Photoshop-style chord: reuse the Image menu row so the dialog and the
|
||||
# adjustment-layer plumbing stay in one place.
|
||||
assert "e.altKey && e.code === 'KeyL'" in keyboard
|
||||
assert "document.querySelector('[data-image-action=\"adj-levels\"]')" in keyboard
|
||||
|
||||
|
||||
def test_ctrl_alt_shift_e_stamps_the_visible_layers():
|
||||
keyboard = (ROOT / "static/js/editor/keyboard-shortcuts.js").read_text(encoding="utf-8")
|
||||
# Photoshop's Stamp Visible reuses the layer footer's "Flatten Copy", which
|
||||
# merges every visible layer into a new layer on top and keeps the originals.
|
||||
assert "e.altKey && e.shiftKey && e.code === 'KeyE'" in keyboard
|
||||
assert "document.getElementById('ge-flatten')" in keyboard
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
PANEL = (ROOT / "static/js/editor/layer-panel.js").read_text(encoding="utf-8")
|
||||
SORT = (ROOT / "static/js/dragSort.js").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_layer_rows_are_draggable_from_anywhere():
|
||||
block = PANEL[PANEL.index("dragSortModule.enable('ge-layers-list'"):]
|
||||
block = block[: block.index("onReorder:")]
|
||||
# The whole row (grip, thumbnail, name) starts a reorder once the
|
||||
# pointer moves past the threshold; interactive controls are excluded.
|
||||
assert "threshold: 4" in block
|
||||
assert "ignoreSelector:" in block
|
||||
assert "handleSelector: '.ge-layer-drag'" not in block
|
||||
|
||||
|
||||
def test_drag_sort_waits_for_the_movement_threshold():
|
||||
assert "threshold: Math.max(0, Number(options.threshold) || 0)," in SORT
|
||||
assert "pending = { item, startY: e.clientY };" in SORT
|
||||
assert "Math.abs(e.clientY - pending.startY) < config.threshold" in SORT
|
||||
assert "pending = null;" in SORT
|
||||
@@ -0,0 +1,69 @@
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
LIQUIFY = ROOT / "static/js/editor/tools/liquify.js"
|
||||
EDITOR = (ROOT / "static/js/galleryEditor.js").read_text(encoding="utf-8")
|
||||
TOOLBAR = (ROOT / "static/js/editor/build/toolbar.js").read_text(encoding="utf-8")
|
||||
SHORTCUTS = (ROOT / "static/js/editor/tool-shortcuts.js").read_text(encoding="utf-8")
|
||||
CONTROLS = (ROOT / "static/js/editor/build/controls.js").read_text(encoding="utf-8")
|
||||
OPTIONS = (ROOT / "static/js/editor/build/options-bar.js").read_text(encoding="utf-8")
|
||||
STATE = (ROOT / "static/js/editor/state.js").read_text(encoding="utf-8")
|
||||
CSS = (ROOT / "static/css/13-image-editor.css").read_text(encoding="utf-8")
|
||||
SRC = LIQUIFY.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_liquify_module_exposes_a_warp_brush():
|
||||
assert "export function createLiquifyTool({ activeLayer, saveState, composite })" in SRC
|
||||
assert "const MODES = new Set(['push', 'twirl', 'pucker', 'bloat', 'smooth']);" in SRC
|
||||
assert "saveState('Liquify')" in SRC
|
||||
for name in ("begin(event)", "drag(event)", "end()", "cancel()"):
|
||||
assert name in SRC
|
||||
# Per-stroke undo: the snapshot is taken before the first dab mutates pixels.
|
||||
assert SRC.index("saveState('Liquify')") < SRC.index("state.drawing = true")
|
||||
# Warp maths for every mode.
|
||||
for token in ("function dab(", "sampleBilinear", "average3x3", "strokeTo"):
|
||||
assert token in SRC
|
||||
|
||||
|
||||
def test_liquify_state_and_toolbar_entry():
|
||||
assert "liquifyMode: 'push'," in STATE
|
||||
assert "liquifySize: 120," in STATE
|
||||
assert "liquifyStrength: 50," in STATE
|
||||
assert "liquify: { label: 'Liquify', icon:" in TOOLBAR
|
||||
assert "['smudge', 'dodge', 'burn', 'liquify']" in TOOLBAR
|
||||
assert "liquify: 'K'," in SHORTCUTS
|
||||
|
||||
|
||||
def test_liquify_controls_and_options_bar():
|
||||
assert 'id="ge-liquify-section"' in CONTROLS
|
||||
for value in ("push", "twirl", "pucker", "bloat", "smooth"):
|
||||
assert f'<option value="{value}">' in CONTROLS
|
||||
assert 'id="ge-liquify-mode"' in CONTROLS
|
||||
assert 'id="ge-liquify-size"' in CONTROLS
|
||||
assert 'id="ge-liquify-strength"' in CONTROLS
|
||||
assert "LIQUIFY_TOOLS[toolId]" in OPTIONS
|
||||
assert "makeSelect('Mode', '#ge-liquify-mode')" in OPTIONS
|
||||
assert "makeRange('Strength', src.strength)" in OPTIONS
|
||||
assert ".ge-liquify-hint {" in CSS
|
||||
|
||||
|
||||
def test_liquify_is_wired_into_the_draw_pipeline():
|
||||
assert "import { createLiquifyTool } from './editor/tools/liquify.js';" in EDITOR
|
||||
assert "const _liquifyTool = createLiquifyTool({" in EDITOR
|
||||
assert "'bucket', 'liquify']" in EDITOR # pixel-paint rasterize gate
|
||||
assert "if (state.tool === 'liquify') return _liquifyTool.begin(e);" in EDITOR
|
||||
assert "return _liquifyTool.drag(e);" in EDITOR
|
||||
# Liquify hides the native cursor, so the hover pass must keep the size
|
||||
# circle visible even though the branch returns before the shared block.
|
||||
assert "if (overCanvas) _updateBrushCursor(e);" in EDITOR
|
||||
assert "if (state.tool === 'liquify') return _liquifyTool.end(e);" in EDITOR
|
||||
# Tool-switch sync: section, color row, circle cursor.
|
||||
assert "liquifySection.style.display = toolId === 'liquify' ? '' : 'none';" in EDITOR
|
||||
assert "['eraser', 'clone', 'heal', 'smudge', 'dodge', 'burn', 'liquify']" in EDITOR
|
||||
assert "'dodge', 'burn', 'liquify'].includes(state.tool)" in EDITOR
|
||||
assert "basePx = Number(state.liquifySize) || 120;" in EDITOR
|
||||
|
||||
|
||||
def test_pen_action_row_sits_left_with_aligned_icons():
|
||||
assert "#ge-pen-section .ge-control-row.ge-actions { margin-left: 0; }" in CSS
|
||||
assert "#ge-pen-commit svg { position: relative; top: 2px; }" in CSS
|
||||
@@ -0,0 +1,273 @@
|
||||
"""Marquee options simplify to Free / Fixed ratio, and no duplicate mode row.
|
||||
|
||||
The options strip used to render a second copy of the marquee mode buttons
|
||||
(New / + / - / intersect via `ge-opt-mode` mirrors) that duplicated the real
|
||||
`ge-wand-mode-btn` buttons relocated into the strip. Those mirrors were
|
||||
removed, and the "Fixed size" marquee style was dropped (only Free and Fixed
|
||||
ratio remain).
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
import re
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
CONTROLS = (ROOT / "static/js/editor/build/controls.js").read_text(encoding="utf-8")
|
||||
OPTIONS = (ROOT / "static/js/editor/build/options-bar.js").read_text(encoding="utf-8")
|
||||
CSS = (ROOT / "static/css/13-image-editor.css").read_text(encoding="utf-8")
|
||||
DOC_EDITOR_CSS = (ROOT / "static/css/documents-gallery-editor.css").read_text(encoding="utf-8")
|
||||
SW = (ROOT / "static/sw.js").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_marquee_style_has_no_fixed_size_option():
|
||||
assert 'id="ge-marquee-constraint"' in CONTROLS
|
||||
i = CONTROLS.index('id="ge-marquee-constraint"')
|
||||
block = CONTROLS[i:i + 240]
|
||||
assert '<option value="free">Free</option>' in block
|
||||
assert '<option value="ratio">Fixed ratio</option>' in block
|
||||
assert 'value="size"' not in block
|
||||
assert "Fixed size" not in CONTROLS
|
||||
|
||||
|
||||
def test_marquee_has_no_duplicate_mode_mirror_row():
|
||||
assert "marquee: { modes: null, feather: null }," in OPTIONS
|
||||
assert "if (src.modes) parts.push(makeModes(src.modes));" in OPTIONS
|
||||
|
||||
|
||||
def test_no_selection_tool_renders_a_mode_mirror_row():
|
||||
# The real wand-mode buttons are relocated into the strip, so a mirror row
|
||||
# duplicates (and collides with) them for every selection tool.
|
||||
block = OPTIONS[OPTIONS.index("const SELECTION_TOOLS = {"):]
|
||||
block = block[:block.index("};")]
|
||||
assert "modes: '#ge-" not in block
|
||||
assert "modes: null" in block
|
||||
|
||||
|
||||
def test_sw_cache_name_bumped():
|
||||
assert re.search(r"odysseus-v\d+-pr40-editor-precache", SW)
|
||||
|
||||
|
||||
def test_options_body_shrinks_to_keep_controls_left_aligned():
|
||||
# An empty mirror body must not stretch and push the relocated controls
|
||||
# (and the marquee action row) away from the left edge.
|
||||
i = CSS.index(".ge-options-body {")
|
||||
block = CSS[i:i + 420]
|
||||
assert "flex: 0 1 auto;" in block
|
||||
assert "flex: 1 1 auto;" not in block
|
||||
|
||||
|
||||
def test_marquee_action_row_is_nudged_up():
|
||||
assert ".ge-controls--in-options .ge-control-row.ge-actions { position: relative; top: -2px; margin-left: auto; }" in CSS
|
||||
|
||||
|
||||
def test_auto_select_checkbox_is_accent_when_checked():
|
||||
assert 'class="ge-ctl-check"' in OPTIONS # the auto-select input uses the shared control
|
||||
i = DOC_EDITOR_CSS.index('body.ge-editor-open .ge-opt input[type="checkbox"]:checked,')
|
||||
block = DOC_EDITOR_CSS[i:i + 220]
|
||||
assert "background: var(--accent, var(--red));" in block
|
||||
assert "background: var(--fg);" not in block
|
||||
|
||||
|
||||
def test_wand_live_button_sits_next_to_the_intersect_mode_and_is_nudged_up():
|
||||
# Live now sits in the mode row, immediately after the intersect (∩) button.
|
||||
i = CONTROLS.index('id="ge-wand-section"')
|
||||
j = CONTROLS.index('id="ge-wand-tol-preview"')
|
||||
mode_row = CONTROLS[i:j]
|
||||
assert mode_row.index('data-wand-mode="intersect"') < mode_row.index('id="ge-wand-live"')
|
||||
# the tolerance row no longer carries the Live button
|
||||
k = CONTROLS.index('id="ge-wand-tol-preview"')
|
||||
tol_row = CONTROLS[k:k + 400]
|
||||
assert 'id="ge-wand-live"' not in tol_row
|
||||
c = CSS.index(".ge-wand-live-btn {")
|
||||
live_block = CSS[c:CSS.index("}", c)]
|
||||
assert "top: 0;" in live_block
|
||||
|
||||
|
||||
def test_wand_slider_label_hugs_its_text():
|
||||
assert "#ge-wand-section .ge-eraser-row label { flex: 0 0 auto; }" in CSS
|
||||
|
||||
|
||||
def test_wand_action_row_is_nudged_up_like_marquee():
|
||||
# The wand action row (eye/clear/invert/erase/...) is raised 2px, matching
|
||||
# the marquee row, by dropping its inline top margin from 4px to 2px.
|
||||
i = CONTROLS.index('id="ge-wand-section"')
|
||||
j = CONTROLS.index('id="ge-sam-section"')
|
||||
wand = CONTROLS[i:j]
|
||||
assert '<div class="ge-control-row ge-actions" style="margin-top:2px;flex-wrap:wrap;">' in wand
|
||||
assert '<div class="ge-control-row ge-actions" style="margin-top:4px;flex-wrap:wrap;">' not in wand
|
||||
|
||||
|
||||
def test_wand_action_row_matches_marquee():
|
||||
# Wand and marquee share the same selection actions; the wand row must use
|
||||
# the same compact buttons as the marquee row (icon-only clear, 14px icons,
|
||||
# same labels/modes) instead of its own older 12px + text variants.
|
||||
i = CONTROLS.index('id="ge-wand-section"')
|
||||
j = CONTROLS.index('id="ge-sam-section"')
|
||||
wand = CONTROLS[i:j]
|
||||
assert '<button class="ge-btn ge-btn-sm" id="ge-wand-clear" title="Clear selection" aria-label="Clear selection">' in wand
|
||||
assert 'data-wand-mode="add" title="Add to selection (Shift)">+</button>' in wand
|
||||
assert 'data-wand-mode="subtract" title="Subtract from selection (Alt)">−</button>' in wand
|
||||
for label in ("Invert", "Erase", "Copy Layer", "To Mask", "Quick Mask"):
|
||||
assert label in wand
|
||||
assert wand.count('<svg width="14" height="14"') >= 6
|
||||
assert '<svg width="12" height="12"' not in wand
|
||||
|
||||
|
||||
def test_wand_refine_sliders_are_inline():
|
||||
# The wand strip carries its post-selection refine sliders (Feather + Edge
|
||||
# stroke) and the tip directly: the old "More" dropdown was removed.
|
||||
i = CONTROLS.index('id="ge-wand-section"')
|
||||
j = CONTROLS.index('id="ge-sam-section"')
|
||||
wand = CONTROLS[i:j]
|
||||
assert 'id="ge-wand-more"' not in wand
|
||||
assert 'ge-wand-more-wrap' not in wand
|
||||
assert 'id="ge-wand-refine-feather"' in wand
|
||||
assert 'id="ge-wand-refine-grow"' in wand
|
||||
assert wand.index('id="ge-wand-refine-feather"') < wand.index('id="ge-wand-refine-grow"')
|
||||
assert 'class="ge-wand-tip"' not in wand
|
||||
|
||||
|
||||
def test_shared_more_menu_css_lets_the_strip_overflow():
|
||||
assert ".ge-wand-more-menu {" in CSS
|
||||
assert ".ge-wand-more-menu[hidden] { display: none; }" in CSS
|
||||
for menu in ("wand", "brush", "lasso", "sam", "quickselect", "objectselect"):
|
||||
assert f".ge-options-bar:has(#ge-{menu}-more-menu:not([hidden]))" in CSS
|
||||
|
||||
|
||||
def test_wand_has_no_more_dropdown_wiring():
|
||||
wire = (ROOT / "static/js/editor/wire-selection-controls.js").read_text(encoding="utf-8")
|
||||
assert "getElementById('ge-wand-more')" not in wire
|
||||
assert "getElementById('ge-wand-more-menu')" not in wire
|
||||
# the other selection tools keep their shared per-tool More menu
|
||||
assert "document.getElementById(`${prefix}-more-menu`)" in wire
|
||||
assert "const closeWandMore = () => {" not in wire
|
||||
|
||||
|
||||
def test_lasso_action_row_matches_marquee_and_wand():
|
||||
# The lasso strip is unified with marquee/wand: same compact action row
|
||||
# (icon-only clear, 14px icons, same labels) plus the visibility eye.
|
||||
i = CONTROLS.index('id="ge-lasso-section"')
|
||||
j = CONTROLS.index('id="ge-wand-section"')
|
||||
lasso = CONTROLS[i:j]
|
||||
assert '<button class="ge-btn ge-btn-sm" id="ge-lasso-clear" title="Clear selection" aria-label="Clear selection">' in lasso
|
||||
assert 'id="ge-lasso-vis"' in lasso
|
||||
for label in ("Invert", "Erase", "Copy Layer", "To Mask", "Quick Mask"):
|
||||
assert label in lasso
|
||||
assert lasso.count('<svg width="14" height="14"') >= 7
|
||||
assert '<svg width="12" height="12"' not in lasso
|
||||
assert 'class="ge-control-row ge-actions" style="margin-top:2px;flex-wrap:wrap;"' in lasso
|
||||
|
||||
|
||||
def test_lasso_secondary_controls_are_in_a_more_dropdown():
|
||||
i = CONTROLS.index('id="ge-lasso-section"')
|
||||
j = CONTROLS.index('id="ge-wand-section"')
|
||||
lasso = CONTROLS[i:j]
|
||||
assert 'id="ge-lasso-more"' in lasso
|
||||
assert 'class="ge-lasso-more-wrap"' in lasso
|
||||
assert '<path d="m6 9 6 6 6-6"/>' in lasso
|
||||
assert lasso.index('id="ge-lasso-more-menu"') < lasso.index('id="ge-lasso-refine-feather"')
|
||||
assert lasso.index('id="ge-lasso-mag-contrast') < lasso.index('ge-lasso-tip')
|
||||
# ids other tests rely on stay put
|
||||
for keep in ('id="ge-lasso-magnetic"', 'id="ge-lasso-mag-width"', 'id="ge-lasso-mag-contrast"'):
|
||||
assert keep in lasso
|
||||
|
||||
|
||||
def test_lasso_more_dropdown_css_and_wire():
|
||||
assert ".ge-lasso-more-menu {" in CSS
|
||||
assert ".ge-lasso-more-menu[hidden] { display: none; }" in CSS
|
||||
assert ".ge-options-bar:has(#ge-lasso-more-menu:not([hidden])) { overflow: visible; }" in CSS
|
||||
wire = (ROOT / "static/js/editor/wire-selection-controls.js").read_text(encoding="utf-8")
|
||||
assert "const lassoMore = document.getElementById('ge-lasso-more');" in wire
|
||||
assert "const closeLassoMore = () => {" in wire
|
||||
assert "document.getElementById('ge-lasso-clear')?.addEventListener('click', wandClear);" in wire
|
||||
assert "'ge-lasso-vis'" in wire
|
||||
|
||||
|
||||
def test_lasso_clear_clears_the_selection_mask():
|
||||
# The toolbar clear badges (data-clear-tool="lasso"/"quickselect"/
|
||||
# "objectselect") route through the same _wandClear path as marquee/wand,
|
||||
# so they clear the materialized selection.
|
||||
editor = (ROOT / "static/js/galleryEditor.js").read_text(encoding="utf-8")
|
||||
assert ("if (which === 'marquee' || which === 'wand' || which === 'lasso'\n"
|
||||
" || which === 'quickselect' || which === 'objectselect') {") in editor
|
||||
assert "#ge-marquee-clear, #ge-wand-clear, #ge-lasso-clear" in editor
|
||||
|
||||
|
||||
|
||||
def _selection_section(tool):
|
||||
import re as _re
|
||||
m = _re.search(rf'id="ge-{tool}-section"[\s\S]*?\n </div>', CONTROLS)
|
||||
assert m, f"section ge-{tool}-section not found"
|
||||
return m.group(0)
|
||||
|
||||
|
||||
def test_quickselect_objectselect_sam_match_the_wand_strip():
|
||||
# The three remaining selection tools were refactored to the wand shape:
|
||||
# compact mode labels, a caret "More" menu (help moved into it), and the
|
||||
# unified 7-button action row.
|
||||
for tool in ("sam", "quickselect", "objectselect"):
|
||||
sec = _selection_section(tool)
|
||||
assert 'class="ge-wand-more-wrap"' in sec
|
||||
assert f'id="ge-{tool}-more"' in sec
|
||||
assert f'id="ge-{tool}-more-menu"' in sec
|
||||
assert 'aria-haspopup="true"' in sec
|
||||
assert 'class="ge-wand-tip"' in sec
|
||||
# compact mode labels (no verbose "+ Add" / "− Subtract")
|
||||
assert "+ Add</button>" not in sec
|
||||
assert "− Subtract</button>" not in sec
|
||||
assert 'data-wand-mode="add" title="Add to selection">+</button>' in sec
|
||||
assert 'data-wand-mode="subtract" title="Subtract from selection">−</button>' in sec
|
||||
# the unified action row
|
||||
assert '<div class="ge-control-row ge-actions" style="margin-top:2px;flex-wrap:wrap;">' in sec
|
||||
assert f'id="ge-{tool}-clear" title="Clear selection" aria-label="Clear selection"' in sec
|
||||
for action in ("vis", "clear", "invert", "delete", "copy", "mask"):
|
||||
assert f'id="ge-{tool}-{action}"' in sec
|
||||
for label in ("Invert", "Erase", "Copy Layer", "To Mask", "Quick Mask"):
|
||||
assert label in sec
|
||||
assert sec.count('<svg width="14" height="14"') >= 7
|
||||
|
||||
|
||||
def test_selection_tool_carets_reuse_the_wand_classes():
|
||||
# CSS: the new menus reuse the wand caret classes and un-clip the strip.
|
||||
for tool in ("sam", "quickselect", "objectselect"):
|
||||
assert f".ge-options-bar:has(#ge-{tool}-more-menu:not([hidden])) {{ overflow: visible; }}" in CSS
|
||||
# the clear ✕ nudge + active-accent rules cover the new buttons too
|
||||
for tool in ("sam", "quickselect", "objectselect"):
|
||||
assert f"#ge-{tool}-clear svg" in CSS
|
||||
assert f"#ge-{tool}-clear.has-selection" in CSS
|
||||
|
||||
|
||||
def test_selection_tool_action_wiring_is_shared():
|
||||
wire = (ROOT / "static/js/editor/wire-selection-controls.js").read_text(encoding="utf-8")
|
||||
assert "const wireSelectionCaret = (prefix) => {" in wire
|
||||
assert "['ge-sam', 'ge-quickselect', 'ge-objectselect'].forEach((prefix) => {" in wire
|
||||
assert "document.getElementById(`${prefix}-vis`)?.addEventListener('click', toggleSelectionOverlay);" in wire
|
||||
assert "document.getElementById(`${prefix}-clear`)?.addEventListener('click', wandClear);" in wire
|
||||
assert "document.getElementById(`${prefix}-delete`)?.addEventListener('click', wandDeleteSelection);" in wire
|
||||
assert "document.getElementById(`${prefix}-mask`)?.addEventListener('click', wandToMask);" in wire
|
||||
assert "wireSelectionCaret(prefix);" in wire
|
||||
# the old one-off SAM bindings were removed (no double toggle / double bind)
|
||||
editor = (ROOT / "static/js/galleryEditor.js").read_text(encoding="utf-8")
|
||||
assert "document.getElementById('ge-sam-vis')?.addEventListener('click', () => {" not in editor
|
||||
assert "document.getElementById('ge-sam-clear')?.addEventListener('click', () => _wandClear());" not in editor
|
||||
# the clear indicator lights the new buttons too
|
||||
assert "#ge-marquee-clear, #ge-wand-clear, #ge-lasso-clear, #ge-sam-clear, #ge-quickselect-clear, #ge-objectselect-clear" in editor
|
||||
|
||||
|
||||
def test_quickselect_brush_size_slider_is_wired():
|
||||
wire = (ROOT / "static/js/editor/wire-selection-controls.js").read_text(encoding="utf-8")
|
||||
assert "state.quickSelectBrushSize = Math.max(4, Math.min(200, parseInt(quickSize.value, 10) || 24));" in wire
|
||||
|
||||
|
||||
def test_quickselect_brush_size_uses_the_shared_eraser_row_slider_style():
|
||||
# Quick Select's brush-size slider kept the bare .ge-ctl-range look (14px,
|
||||
# transparent) instead of the shared .ge-eraser-row slider the wand
|
||||
# Tolerance row uses. Its row now uses the same pattern (8px track, preview
|
||||
# dot, value chip) so every tool's slider reads the same.
|
||||
q = _selection_section("quickselect")
|
||||
assert 'class="ge-control-row ge-eraser-row"' in q
|
||||
assert 'id="ge-quickselect-size-preview"' in q
|
||||
assert 'id="ge-quickselect-size-label"' in q
|
||||
assert 'class="ge-quickselect-size" id="ge-quickselect-size"' in q
|
||||
assert "#ge-quickselect-section .ge-eraser-row label { flex: 0 0 auto; }" in CSS
|
||||
wire = (ROOT / "static/js/editor/wire-selection-controls.js").read_text(encoding="utf-8")
|
||||
assert "document.getElementById('ge-quickselect-size-label')" in wire
|
||||
@@ -19,6 +19,12 @@ def test_topbar_has_file_edit_help_menus():
|
||||
'data-file-action="import"',
|
||||
'data-file-action="save"',
|
||||
'data-file-action="save-project"',
|
||||
'data-submenu="file-new"',
|
||||
'data-file-action="new-default"',
|
||||
'data-file-action="new-square"',
|
||||
'data-file-action="new-portrait"',
|
||||
'data-file-action="new-story"',
|
||||
'data-file-action="new-custom"',
|
||||
'data-edit-action="undo"',
|
||||
'data-edit-action="redo"',
|
||||
'data-edit-action="history"',
|
||||
@@ -54,10 +60,22 @@ def test_extra_menus_wire_file_edit_help():
|
||||
"bind('ge-edit-menu-btn', 'ge-edit-menu'",
|
||||
"bind('ge-help-menu-btn', 'ge-help-menu'",
|
||||
"uiModule",
|
||||
"newDocument?.(1920, 1080)",
|
||||
"promptNewDocument?.()",
|
||||
]:
|
||||
assert token in src
|
||||
|
||||
|
||||
def test_new_document_is_wired_to_the_editor():
|
||||
src = read("static/js/galleryEditor.js")
|
||||
wire = src[src.index("wireExtraMenus({"):]
|
||||
wire = wire[:wire.index("});")]
|
||||
assert "newDocument: (w, h) => {" in wire
|
||||
assert "openEditor(null, null, { w: Math.round(w), h: Math.round(h) }, 'Untitled');" in wire
|
||||
assert "promptNewDocument: () => _promptCanvasSize({" in wire
|
||||
assert "showPresets: true," in wire
|
||||
|
||||
|
||||
def test_topbar_menu_ids_include_file_edit_help():
|
||||
src = read("static/js/editor/wire-topbar.js")
|
||||
for token in [
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
"""Mobile (<=700px) editor chrome: footer removal, Quick Edit stacking, more-tools chevron.
|
||||
|
||||
Requested (390x844 phone):
|
||||
1. Remove the floating zoom/Setup footer on phones — it collided with the peek
|
||||
layers sheet and the Quick Edit card. Undo/Redo/History already live in the
|
||||
layers-sheet header and pinch-zoom replaces the zoom buttons.
|
||||
2. The Quick Edit card lifts above the bottom sheets / keyboard; keep it above
|
||||
every other editor asset while lifted (z-index 90).
|
||||
3. The tool-rail "more tools" affordance should be a right chevron, not an
|
||||
ellipsis.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
import re
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
CSS = (ROOT / "static/css/13-image-editor.css").read_text(encoding="utf-8")
|
||||
EDITOR = (ROOT / "static/js/galleryEditor.js").read_text(encoding="utf-8")
|
||||
SW = (ROOT / "static/sw.js").read_text(encoding="utf-8")
|
||||
|
||||
MOBILE_BLOCK = """@media (max-width: 700px) {
|
||||
.ge-editor-footer {
|
||||
display: none !important;
|
||||
}
|
||||
/* The Quick Edit card lifts over the layers sheet / keyboard; keep it above
|
||||
every other editor asset (sheet headers, tool rail, popups) while lifted. */
|
||||
.ge-ai-command {
|
||||
z-index: 90;
|
||||
}
|
||||
}"""
|
||||
|
||||
|
||||
def test_mobile_footer_is_removed():
|
||||
assert MOBILE_BLOCK in CSS
|
||||
assert ".ge-editor-footer {\n display: none !important;" in CSS
|
||||
|
||||
|
||||
def test_quick_edit_stacks_above_other_assets():
|
||||
assert ".ge-ai-command {\n z-index: 90;" in CSS
|
||||
|
||||
|
||||
def test_more_tools_button_is_a_chevron():
|
||||
assert "toolbarMore.textContent = '\\u203A';" in EDITOR
|
||||
assert "toolbarMore.textContent = '\\u2026';" not in EDITOR
|
||||
|
||||
|
||||
def test_sw_cache_name_bumped():
|
||||
assert re.search(r"odysseus-v\d+-pr40-editor-precache", SW)
|
||||
@@ -15,7 +15,7 @@ def test_menu_row_and_toolbar_contain_sideways_scroll():
|
||||
# Asserted against the whole stylesheet: several `@media (max-width: 700px)`
|
||||
# blocks now exist, so "the last one" is not the one that holds these rules.
|
||||
assert "overscroll-behavior-x: contain;" in CSS
|
||||
assert ".ge-toolbar," in CSS
|
||||
assert ".ge-toolbar-scroll," in CSS
|
||||
assert ".ge-dock-tabs {" in CSS
|
||||
|
||||
|
||||
|
||||
@@ -30,6 +30,16 @@ def test_mobile_quick_edit_chip_hides_columns():
|
||||
assert ".ge-ai-command-suggestion-main {" in CSS
|
||||
|
||||
|
||||
def test_quick_edit_collapsed_bar_stays_translucent_over_the_canvas():
|
||||
# Hovering anywhere over the canvas must not drag the collapsed Quick Edit
|
||||
# bar fully in front of the image (annoying when zoomed in); only pointing
|
||||
# at the bar itself, or focusing it, brings it to full strength.
|
||||
assert ".ge-canvas-area:hover .ge-ai-command-collapsed {" in CSS
|
||||
assert "opacity: 0.55;" in CSS
|
||||
assert ".ge-canvas-area .ge-ai-command-collapsed:hover,\n.ge-canvas-area .ge-ai-command-collapsed:focus-within {" in CSS
|
||||
assert "opacity: 0.94;" in CSS
|
||||
|
||||
|
||||
def test_sw_version_is_current():
|
||||
m = re.search(r"CACHE_NAME = '(odysseus-v\d+-pr40-editor-precache)'", SW)
|
||||
assert m, "CACHE_NAME not found"
|
||||
|
||||
@@ -10,7 +10,7 @@ SW = (ROOT / "static" / "sw.js").read_text()
|
||||
def test_toolbar_is_wrapped_for_an_overlay_chevron():
|
||||
assert "toolbarWrap.className = 'ge-toolbar-wrap';" in JS
|
||||
assert "toolbarMore.className = 'ge-tool-more';" in JS
|
||||
assert "toolbarMore.textContent = '\\u2026';" in JS
|
||||
assert "toolbarMore.textContent = '\\u203A';" in JS
|
||||
assert "toolbarWrap.appendChild(toolbar);" in JS
|
||||
assert "toolbarWrap.appendChild(toolbarMore);" in JS
|
||||
assert "toolbar.scrollBy(" in JS
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import pathlib
|
||||
|
||||
ROOT = pathlib.Path(__file__).resolve().parents[1]
|
||||
EDITOR = (ROOT / "static/js/galleryEditor.js").read_text(encoding="utf-8")
|
||||
GALLERY = (ROOT / "static/js/gallery.js").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_new_edit_tab_asks_for_the_canvas_size_like_the_main_menu():
|
||||
block = EDITOR[EDITOR.index("plus.className = 'gallery-tab gallery-tab-new';"):]
|
||||
block = block[:block.index("extra.appendChild(plus);")]
|
||||
assert "plus.title = 'New edit tab';" in block
|
||||
assert "plus.setAttribute('aria-label', 'New edit tab');" in block
|
||||
# Same dialog as File > New Image instead of a silent 1024x1024 document.
|
||||
assert "await _promptCanvasSize({" in block
|
||||
assert "title: 'New edit'," in block
|
||||
assert "okLabel: 'Create'," in block
|
||||
assert "showPresets: true," in block
|
||||
assert "window.geEditorControl?.invoke('document.create', { width: size.w, height: size.h, name: 'Untitled' });" in block
|
||||
assert "document.create', {})" not in EDITOR
|
||||
|
||||
|
||||
def test_closing_the_new_tab_keeps_the_other_documents_alive():
|
||||
assert "window.geEditorCloseActiveDocument = () => {" in EDITOR
|
||||
assert "if (_openDocuments.length <= 1) return false;" in EDITOR
|
||||
# The switch must not re-add the closed id or copy its pixels over the
|
||||
# surviving document.
|
||||
assert "let _suppressDocSync = false;" in EDITOR
|
||||
assert "if (_suppressDocSync || !state.editorOpen) return null;" in EDITOR
|
||||
assert "_suppressDocSync = true;" in EDITOR
|
||||
assert ".finally(() => { _suppressDocSync = false; });" in EDITOR
|
||||
# The primary Edit-tab close tries the single-document close first.
|
||||
assert "if (window.geEditorCloseActiveDocument?.()) return;" in GALLERY
|
||||
@@ -21,8 +21,6 @@ def test_options_bar_module_shape():
|
||||
"#ge-clone-opacity",
|
||||
"#ge-lasso-feather",
|
||||
"#ge-wand-feather",
|
||||
"#ge-gradient-type",
|
||||
"#ge-gradient-opacity",
|
||||
):
|
||||
assert selector in src, selector
|
||||
# Selection modes reuse the existing wand-mode buttons rather than re-implementing.
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
"""Overnight editor-polish batch contracts.
|
||||
|
||||
Covers the UI/UX fixes applied in one pass: Brightness/Contrast presets,
|
||||
clearer selection-mode icons, marquee action-row icons, the crop-apply
|
||||
dismiss button, unified dock/layer icon sizing, the footer zoom glyph
|
||||
nudge, the histogram repaint hook, the bucket dodge/burn fixes.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
import re
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def _read(rel):
|
||||
return (ROOT / rel).read_text(encoding="utf-8")
|
||||
|
||||
|
||||
CSS = _read("static/css/13-image-editor.css")
|
||||
LH = _read("static/js/editor/layer-helpers.js")
|
||||
OPTIONS = _read("static/js/editor/build/options-bar.js")
|
||||
CONTROLS = _read("static/js/editor/build/controls.js")
|
||||
EDITOR = _read("static/js/galleryEditor.js")
|
||||
PANELS = _read("static/js/editor/panels/panels.js")
|
||||
STROKE = _read("static/js/editor/stroke-pipeline.js")
|
||||
BUCKET = _read("static/js/editor/tools/bucket.js")
|
||||
SW = _read("static/sw.js")
|
||||
|
||||
|
||||
def test_brightness_contrast_has_extra_presets():
|
||||
m = re.search(r"'brightness-contrast': \[([^\]]*)\]", LH)
|
||||
assert m, "brightness-contrast preset list missing"
|
||||
opts = m.group(1)
|
||||
for name in ("Brighten", "Darken", "Punch", "High Contrast", "Low Contrast", "Faded"):
|
||||
assert name in opts, name
|
||||
for case in (
|
||||
"brightness-contrast:Brighten",
|
||||
"brightness-contrast:Darken",
|
||||
"brightness-contrast:Punch",
|
||||
"brightness-contrast:Low Contrast",
|
||||
"brightness-contrast:Faded",
|
||||
):
|
||||
assert f"case '{case}':" in LH, case
|
||||
|
||||
|
||||
def test_intersect_mode_uses_overlap_icon():
|
||||
assert "const INTERSECT_ICON =" in OPTIONS
|
||||
assert "['intersect', INTERSECT_ICON]" in OPTIONS
|
||||
icon = '<circle cx="9" cy="12" r="6"/><circle cx="15" cy="12" r="6"/>'
|
||||
assert icon in OPTIONS
|
||||
# every wand/selection intersect button in the controls markup uses it
|
||||
assert CONTROLS.count(icon) >= 6
|
||||
assert 'data-wand-mode="intersect"' in CONTROLS
|
||||
|
||||
|
||||
def test_marquee_action_row_has_svg_icons():
|
||||
assert 'class="ge-btn ge-btn-sm" id="ge-marquee-clear"' in CONTROLS
|
||||
assert 'aria-label="Clear selection"' in CONTROLS
|
||||
for gid in ("ge-marquee-invert", "ge-marquee-delete", "ge-marquee-copy", "ge-marquee-mask"):
|
||||
assert f'id="{gid}"' in CONTROLS, gid
|
||||
|
||||
|
||||
def test_crop_apply_has_corner_dismiss():
|
||||
assert 'class="ge-crop-close"' in EDITOR
|
||||
# The × clears the committed crop via the tool reset (cancel() is a no-op
|
||||
# once the gesture has ended).
|
||||
assert "_cropTool.reset()" in EDITOR
|
||||
assert ".ge-crop-close {" in CSS
|
||||
assert "border-radius: 50%;" in CSS[CSS.index(".ge-crop-close {"):CSS.index(".ge-crop-close {") + 320]
|
||||
|
||||
|
||||
def test_crop_dismiss_x_is_nudged_up():
|
||||
# × glyph up 4px inside the round button; the button itself up 5px.
|
||||
assert 'class="ge-crop-close-x"' in EDITOR
|
||||
assert ".ge-crop-close-x { display: block; line-height: 1; transform: translateY(-2px); }" in CSS
|
||||
idx = CSS.index(".ge-crop-close {")
|
||||
assert "top: -13px;" in CSS[idx:idx + 120]
|
||||
|
||||
|
||||
def test_gradient_swatch_applies_the_gradient():
|
||||
# Clicking a preset selects the gradient tool and paints it across the layer.
|
||||
assert "document.querySelector('[data-tool=\"gradient\"]')?.click();" in PANELS
|
||||
assert "applyGradient?.();" in PANELS
|
||||
assert "applyGradient: () => _applyFullGradient()," in EDITOR
|
||||
assert "function _applyFullGradient()" in EDITOR
|
||||
assert "_gradientTool.begin(from)" in EDITOR
|
||||
|
||||
|
||||
def test_dock_and_layer_icon_sizes_unified():
|
||||
idx = CSS.index(".ge-layer-btn svg,")
|
||||
block = CSS[idx:idx + 360]
|
||||
assert ".ge-layer-group-toggle svg {" in block
|
||||
assert "width: 14px;" in block
|
||||
assert "height: 14px;" in block
|
||||
|
||||
|
||||
def test_adj_row_label_is_right_aligned():
|
||||
idx = CSS.index(".ge-adj-row > label {")
|
||||
block = CSS[idx:idx + 160]
|
||||
assert "text-align: right;" in block
|
||||
|
||||
|
||||
def test_footer_zoom_glyph_nudged_down():
|
||||
assert ".ge-footer-zoom .ge-zoom-glyph { left: 0; top: 4px; }" in CSS
|
||||
|
||||
|
||||
def test_histogram_picks_a_paintable_layer():
|
||||
assert "const histogramLayer = ()" in PANELS
|
||||
assert "drawHistogram(histCanvas, layer)" in PANELS
|
||||
|
||||
|
||||
def test_dodge_and_burn_cap_per_dab_strength():
|
||||
# Dodge/burn strength is now driven by the Intensity slider (default 25%,
|
||||
# scaled to 0.5 so even 100% builds gradually) instead of a fixed clamp.
|
||||
assert STROKE.count("const intensity = Number.isFinite(state.dodgeBurnIntensity)") >= 2
|
||||
assert STROKE.count("alpha = Math.max(0.004, Math.min(0.6, alpha * intensity * 0.5));") >= 2
|
||||
assert "operation = 'screen';" in STROKE
|
||||
assert "operation = 'multiply';" in STROKE
|
||||
|
||||
|
||||
def test_bucket_falls_back_to_a_paintable_layer():
|
||||
assert "function paintableLayer()" in BUCKET
|
||||
assert "'Select a pixel layer to fill'" in BUCKET
|
||||
|
||||
|
||||
def test_sw_cache_name_matches():
|
||||
assert re.search(r"CACHE_NAME = 'odysseus-v\d+-pr40-editor-precache'", SW)
|
||||
@@ -67,6 +67,11 @@ def test_import_and_save_live_in_the_topbar_row():
|
||||
assert "ge-win-btn ge-win-close ge-doc-tab-close" not in GALLERY
|
||||
assert "ge-editor-minimize" not in GALLERY
|
||||
assert "ge-header-max-btn svg { top: 0 !important; transform: none; }" in STYLE
|
||||
# The adopted header button must not stack up when the editor is rebuilt.
|
||||
assert "header.querySelectorAll('.ge-header-max-btn').forEach((stale) => stale.remove());" in GALLERY
|
||||
assert "document.querySelectorAll('.ge-header-max-btn').forEach((btn) => btn.remove());" in GALLERY
|
||||
# ...and the maximize glyph is centred by CSS, not an inline nudge.
|
||||
assert 'stroke-linejoin="round"><polyline points="15 3 21 3 21 9"' in TOPBAR
|
||||
|
||||
|
||||
def test_save_menu_has_a_png_option():
|
||||
@@ -96,7 +101,7 @@ def test_multi_document_tabs_render_in_the_gallery_tab_bar():
|
||||
assert "window.geEditorDocTabs = _renderEditorDocTabs" in GALLERY
|
||||
assert "const _renderEditorDocTabs = () => {" in GALLERY
|
||||
assert "window.geEditorControl?.invoke('document.switch', { id: doc.id })" in GALLERY
|
||||
assert "window.geEditorControl?.invoke('document.create', {});" in GALLERY
|
||||
assert "window.geEditorControl?.invoke('document.create', { width: size.w, height: size.h, name: 'Untitled' });" in GALLERY
|
||||
assert "gallery-tab gallery-tab-new" in GALLERY
|
||||
assert ".gallery-editor-tabs-extra { display: contents; }" in STYLE
|
||||
assert "window.geEditorDocTabs?.();" in GALLERY
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
"""Pixel is its own tool, not a brush preset ("brush type").
|
||||
|
||||
The 1px hard-square brush used to be exposed as the built-in `pixel-1px`
|
||||
preset, listed under the flyout's "Brushes" heading. It is now a real tool
|
||||
(`pixel`) in the brush slot's flyout, next to Brush and Eraser, and the pixel
|
||||
preset is hidden from the preset list/dropdown.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
import re
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def _read(rel):
|
||||
return (ROOT / rel).read_text(encoding="utf-8")
|
||||
|
||||
|
||||
TOOLBAR = _read("static/js/editor/build/toolbar.js")
|
||||
STROKE = _read("static/js/editor/tools/stroke.js")
|
||||
PIPELINE = _read("static/js/editor/stroke-pipeline.js")
|
||||
EDITOR = _read("static/js/galleryEditor.js")
|
||||
SLIDERS = _read("static/js/editor/stroke-tool-sliders.js")
|
||||
SW = _read("static/sw.js")
|
||||
|
||||
|
||||
def test_pixel_is_a_tool_with_an_icon():
|
||||
assert "pixel: { label: 'Pixel'," in TOOLBAR
|
||||
# it sits next to brush + eraser in the flyout slot
|
||||
assert "['brush', 'eraser', 'pixel']" in TOOLBAR
|
||||
|
||||
|
||||
def test_pixel_is_not_listed_as_a_brush_preset():
|
||||
# the flyout's Brushes list skips pixel presets...
|
||||
assert "loadBrushPresets().filter((preset) => !preset.pixel)" in TOOLBAR
|
||||
# ...and so does the options-strip preset dropdown.
|
||||
assert "const list = presets.filter(preset => !preset.pixel);" in SLIDERS
|
||||
assert "renderPresets(presets.some(item => item.id === savedPresetId && !item.pixel) ? savedPresetId : '');" in SLIDERS
|
||||
|
||||
|
||||
def test_pixel_stroke_is_handled_by_the_shared_pipeline():
|
||||
assert "'pixel'" in STROKE.split("const STROKE_TOOLS")[1].split("];")[0]
|
||||
assert "if (tool === 'pixel') return 'Pixel stroke';" in STROKE
|
||||
assert "'pixel'" in PIPELINE.split("const PAINT_TOOLS")[1].split("];")[0]
|
||||
assert "if ((tool === 'pixel' || (state.brushPixel && (tool === 'brush' || tool === 'eraser'))) && !target.paintingMask) {" in PIPELINE
|
||||
|
||||
|
||||
def test_editor_registers_the_pixel_tool_everywhere():
|
||||
assert "'brush', 'pixel', 'eraser'" in EDITOR # _pixelPaintTools
|
||||
assert "const needsBrush = ['brush', 'pixel', 'eraser', 'clone', 'heal', 'smudge', 'dodge', 'burn'].includes(toolId);" in EDITOR
|
||||
assert "const strokeTool = (toolId === 'brush' || toolId === 'pixel' || toolId === 'dodge' || toolId === 'burn') ? 'brush'" in EDITOR
|
||||
assert "'brush', 'pixel', 'gradient', 'bucket'" in EDITOR # hasToolControls
|
||||
assert "'brush', 'pixel', 'eraser', 'inpaint', 'lasso'" in EDITOR # useCircle
|
||||
# selecting the Pixel tool forces the pixel flag; brush/eraser drop it
|
||||
assert "if (toolId === 'pixel') state.brushPixel = true;" in EDITOR
|
||||
assert "else if (toolId === 'brush' || toolId === 'eraser') state.brushPixel = false;" in EDITOR
|
||||
|
||||
|
||||
def test_sw_cache_name_bumped():
|
||||
assert re.search(r"odysseus-v\d+-pr40-editor-precache", SW)
|
||||
@@ -0,0 +1,171 @@
|
||||
"""Planner routing regression suite (HARNESS-owned).
|
||||
|
||||
Turns PHOTOSHOP's bench prompt families (vague, restyle, bg-remove/scene,
|
||||
tone/auto, generative, structural) into assertions on the ROUTE CLASS the
|
||||
server planner picks, so a future planner change cannot silently re-break
|
||||
routing. The classes are:
|
||||
|
||||
ask_user -> the planner is unsure; it must ask with 2-3 choices and
|
||||
apply NO operations (never a silent tone default)
|
||||
deterministic -> a deterministic editor op / recipe (adjustments, filters,
|
||||
crop, text, rotate, fill, recolour ...)
|
||||
ai_edit_image -> a generative image-model edit (content add/remove/replace,
|
||||
restyle, look)
|
||||
generate_image -> text-to-image for a brand-new image
|
||||
|
||||
These use the pure routing helpers (``_plan_special`` / ``_generative_kind`` /
|
||||
``_fallback_plan``), so no model or network call is made.
|
||||
"""
|
||||
import json
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from routes import editor_agent_routes as eaar
|
||||
|
||||
_STATE = {"width": 900, "height": 600}
|
||||
_PROVIDER = True
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _reset_planner():
|
||||
eaar.set_editor_planner(None)
|
||||
yield
|
||||
eaar.set_editor_planner(None)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def client():
|
||||
app = FastAPI()
|
||||
app.include_router(eaar.setup_editor_agent_routes())
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
def _classify(prompt, provider=_PROVIDER):
|
||||
"""Return (route_class, ops) for a prompt using the deterministic helpers."""
|
||||
special = eaar._plan_special(prompt, _STATE, provider)
|
||||
if special:
|
||||
steps = list(special[0])
|
||||
else:
|
||||
fallback = eaar._fallback_plan(prompt)
|
||||
if fallback.get("ask_user"):
|
||||
return "ask_user", []
|
||||
steps = list(fallback.get("steps") or [])
|
||||
ops = [str(s.get("op") or "") for s in steps]
|
||||
if "generate_image" in ops:
|
||||
return "generate_image", ops
|
||||
if "ai_edit_image" in ops:
|
||||
return "ai_edit_image", ops
|
||||
return "deterministic", ops
|
||||
|
||||
|
||||
# (prompt, expected route class)
|
||||
BENCH_ROUTING = [
|
||||
# vague -> ask (never a silent tone tweak)
|
||||
("do the thing", "ask_user"),
|
||||
("give it some oomph", "ask_user"),
|
||||
("make it better", "ask_user"),
|
||||
# whole-image restyle -> model edit
|
||||
("make it look like an oil painting", "ai_edit_image"),
|
||||
("turn it into an anime", "ai_edit_image"),
|
||||
("make it a comic book illustration", "ai_edit_image"),
|
||||
("make it look like cyberpunk", "ai_edit_image"),
|
||||
# background / scene replacement -> model edit
|
||||
("put her on a beach", "ai_edit_image"),
|
||||
("make the background nicer", "ai_edit_image"),
|
||||
("make it snowy", "ai_edit_image"),
|
||||
# generative content add / remove -> model edit
|
||||
("add a full moon in the sky", "ai_edit_image"),
|
||||
("remove the distractions", "ai_edit_image"),
|
||||
("remove the person on the left", "ai_edit_image"),
|
||||
# brand-new image -> text-to-image
|
||||
("create a new image of a full moon", "generate_image"),
|
||||
("generate an image of a sunset", "generate_image"),
|
||||
("make a logo", "generate_image"),
|
||||
# tone / auto / look / structural -> deterministic
|
||||
("make it black and white", "deterministic"),
|
||||
("make it pop", "deterministic"),
|
||||
("make it look professional", "deterministic"),
|
||||
("make only the red car blue", "deterministic"),
|
||||
("make a 1080x1350 Instagram post from this", "deterministic"),
|
||||
("add a title TOKYO with a drop shadow", "deterministic"),
|
||||
("extend the canvas to 16:9", "deterministic"),
|
||||
("save as PSD with layers", "deterministic"),
|
||||
("fill the white area with red", "deterministic"),
|
||||
("rotate the whole image 90 degrees", "deterministic"),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("prompt,expected", BENCH_ROUTING)
|
||||
def test_bench_prompts_route_to_expected_class(prompt, expected):
|
||||
route_class, ops = _classify(prompt)
|
||||
assert route_class == expected, f"{prompt!r} -> {route_class} {ops} (want {expected})"
|
||||
|
||||
|
||||
def test_ask_plans_offer_two_to_three_choices():
|
||||
for prompt in ("do the thing", "give it some oomph", "make it better"):
|
||||
plan = eaar._fallback_plan(prompt)
|
||||
ask = plan.get("ask_user")
|
||||
assert isinstance(ask, dict), prompt
|
||||
opts = ask.get("options") or []
|
||||
assert 2 <= len(opts) <= 3, (prompt, opts)
|
||||
assert plan.get("steps") == []
|
||||
|
||||
|
||||
def test_object_removal_uses_selection_plus_model_edit():
|
||||
_cls, ops = _classify("remove the person on the left")
|
||||
assert "ai_edit_image" in ops
|
||||
assert any(op.startswith("selection.") for op in ops), ops
|
||||
|
||||
|
||||
def test_recolour_never_uses_a_flat_fill_or_a_global_tint():
|
||||
# Every recolour selects the object by description and either shifts the
|
||||
# masked hue or asks the image model for the masked region — never a flat
|
||||
# selection.fill and never a whole-image adjustments.apply.
|
||||
for prompt in ("make only the red car blue", "make the red car white", "make the flowers bright pink"):
|
||||
_cls, ops = _classify(prompt)
|
||||
assert "selection.samText" in ops, (prompt, ops)
|
||||
assert "selection.fill" not in ops, (prompt, ops)
|
||||
assert "adjustments.apply" not in ops, (prompt, ops)
|
||||
|
||||
|
||||
def test_chromatic_recolour_is_a_deterministic_masked_hue_shift():
|
||||
cls, ops = _classify("make only the red car blue")
|
||||
assert cls == "deterministic", ops
|
||||
assert ops == ["selection.samText", "adjustments.applyMasked"], ops
|
||||
|
||||
|
||||
def test_new_image_request_from_scratch_vs_edit_of_current_photo():
|
||||
# A photo edit ("make it a <style>") must NOT be treated as a new image.
|
||||
assert eaar._generative_kind("make it a comic book illustration") == "restyle"
|
||||
assert eaar._is_new_image_request("make it a comic book illustration") is False
|
||||
# A from-scratch generation still is.
|
||||
assert eaar._is_new_image_request("create an illustration of a cat") is True
|
||||
assert eaar._generative_kind("create an illustration of a cat") == "add"
|
||||
|
||||
|
||||
def test_plan_route_returns_deterministic_recolour(client):
|
||||
eaar.set_editor_planner(lambda messages, owner: json.dumps({"reply": "ok", "steps": []}))
|
||||
r = client.post("/api/editor/plan", json={"prompt": "make only the red car blue", "state": _STATE})
|
||||
assert r.status_code == 200
|
||||
data = r.json()
|
||||
assert data["planner"] == "deterministic"
|
||||
assert [s["op"] for s in data["steps"]] == ["selection.samText", "adjustments.applyMasked"]
|
||||
|
||||
|
||||
def test_plan_route_asks_for_a_vague_prompt(client):
|
||||
eaar.set_editor_planner(lambda messages, owner: json.dumps({"reply": "ok", "steps": []}))
|
||||
r = client.post("/api/editor/plan", json={"prompt": "do the thing", "state": _STATE})
|
||||
assert r.status_code == 200
|
||||
data = r.json()
|
||||
assert data.get("ask_user"), data
|
||||
assert data.get("steps") == []
|
||||
|
||||
|
||||
def test_plan_route_routes_restyle_to_model_edit(client):
|
||||
eaar.set_editor_planner(lambda messages, owner: json.dumps({"reply": "ok", "steps": []}))
|
||||
r = client.post("/api/editor/plan", json={"prompt": "turn it into an anime", "state": _STATE})
|
||||
assert r.status_code == 200
|
||||
ops = [s["op"] for s in r.json()["steps"]]
|
||||
assert "ai_edit_image" in ops, ops
|
||||
@@ -0,0 +1,227 @@
|
||||
"""Second editor-polish batch: slider chip editor alignment, action-row nudge,
|
||||
wand caret styling, crop-close reset, hex copy + favorites."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
CSS = (ROOT / "static/css/13-image-editor.css").read_text(encoding="utf-8")
|
||||
SLIDER = (ROOT / "static/js/editor/slider-ux.js").read_text(encoding="utf-8")
|
||||
EDITOR = (ROOT / "static/js/galleryEditor.js").read_text(encoding="utf-8")
|
||||
PANELS = (ROOT / "static/js/editor/panels/panels.js").read_text(encoding="utf-8")
|
||||
CONTROLS = (ROOT / "static/js/editor/build/controls.js").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_slider_edit_input_is_chip_sized():
|
||||
assert ".ge-eraser-row input.ge-slider-edit," in CSS
|
||||
assert ".ge-adj-row input.ge-slider-edit {" in CSS
|
||||
i = CSS.index(".ge-eraser-row input.ge-slider-edit,")
|
||||
block = CSS[i:i + 220]
|
||||
assert "height: 16px;" in block
|
||||
assert "box-sizing: border-box;" in block
|
||||
|
||||
|
||||
def test_slider_edit_input_is_vertically_centred():
|
||||
assert "inp.style.top = (crect.top - rrect.top + (crect.height - inp.offsetHeight) / 2) + 'px';" in SLIDER
|
||||
assert "crect.top - rrect.top - 1" not in SLIDER
|
||||
|
||||
|
||||
def test_action_row_buttons_are_nudged_up():
|
||||
assert ".ge-controls--in-options .ge-control-row.ge-actions .ge-btn {" in CSS
|
||||
i = CSS.index(".ge-controls--in-options .ge-control-row.ge-actions .ge-btn {")
|
||||
block = CSS[i:i + 120]
|
||||
assert "position: relative;" in block
|
||||
assert "top: -2px;" in block
|
||||
|
||||
|
||||
def test_wand_more_button_has_no_box():
|
||||
i = CSS.index(".ge-wand-more-btn {")
|
||||
block = CSS[i:i + 200]
|
||||
assert "background: transparent;" in block
|
||||
assert "border-color: transparent;" in block
|
||||
|
||||
|
||||
def test_wand_caret_and_value_are_nudged_left():
|
||||
i = CSS.index(".ge-wand-more-btn {")
|
||||
assert "left: -8px;" in CSS[i:i + 120]
|
||||
assert "top: -4px;" in CSS[i:i + 120]
|
||||
assert "#ge-wand-section .ge-slider-value { position: relative; left: -6px; }" in CSS
|
||||
|
||||
|
||||
def test_crop_close_x_is_nudged_up():
|
||||
assert ".ge-crop-close-x { display: block; line-height: 1; transform: translateY(-2px); }" in CSS
|
||||
|
||||
|
||||
def test_crop_close_clears_the_selection():
|
||||
assert "pop.querySelector('.ge-crop-close').addEventListener('click', () => _cropTool.reset());" in EDITOR
|
||||
assert "_cancelCrop('dismiss')" not in EDITOR
|
||||
|
||||
|
||||
def test_crop_close_button_and_presets_are_nudged_up():
|
||||
idx = CSS.index(".ge-crop-close {")
|
||||
assert "top: -13px;" in CSS[idx:idx + 120]
|
||||
assert ".ge-crop-preset-row { display: flex; gap: 4px; position: relative; top: -4px; }" in CSS
|
||||
|
||||
|
||||
def test_escape_removes_the_crop_like_the_x():
|
||||
# Escape routes through _cancelCrop, which now fully resets (not cancel()).
|
||||
assert "_cancelCrop('escape')" in EDITOR
|
||||
i = EDITOR.index("function _cancelCrop(reason = 'cancel') {")
|
||||
block = EDITOR[i:i + 520]
|
||||
assert "_cropTool.reset();" in block
|
||||
|
||||
|
||||
def test_hex_row_has_copy_and_favorite_buttons():
|
||||
assert "el('button', 'ge-hex-copy')" in PANELS
|
||||
assert "el('button', 'ge-hex-copy ge-hex-fav')" in PANELS
|
||||
assert "hexRow.append(hexHash, hexInput, hexCopy, hexFav);" in PANELS
|
||||
assert "navigator.clipboard?.writeText" in PANELS
|
||||
assert ".ge-hex-copy {" in CSS
|
||||
assert ".ge-hex-fav.active {" in CSS
|
||||
|
||||
|
||||
def test_color_favorites_are_persisted_and_rendered():
|
||||
assert "const FAV_KEY = 'ge-color-favorites';" in PANELS
|
||||
assert "const getFavoriteColors = () =>" in PANELS
|
||||
assert "const isFavorite = (hex) =>" in PANELS
|
||||
assert "favRow.innerHTML = favs.length" in PANELS
|
||||
assert "ge-swatch-fav" in PANELS
|
||||
|
||||
|
||||
def test_ctl_range_thumb_is_nudged_up_one_px():
|
||||
i = CSS.index(".ge-ctl-range::-webkit-slider-thumb {")
|
||||
block = CSS[i:i + 200]
|
||||
assert "margin-top: -5px;" in block
|
||||
|
||||
|
||||
def test_wand_tolerance_value_chip_is_always_tinted():
|
||||
assert "#ge-wand-section #ge-wand-tol-label.ge-slider-value {" in CSS
|
||||
i = CSS.index("#ge-wand-section #ge-wand-tol-label.ge-slider-value {")
|
||||
block = CSS[i:i + 160]
|
||||
assert "background: color-mix(in srgb, var(--fg) 10%, transparent);" in block
|
||||
assert "opacity: 1;" in block
|
||||
|
||||
|
||||
def test_selection_action_row_is_pinned_to_the_right_edge():
|
||||
assert ".ge-options-bar .ge-controls.ge-controls--in-options {" in CSS
|
||||
i = CSS.index(".ge-options-bar .ge-controls.ge-controls--in-options {")
|
||||
assert "flex: 1 1 auto;" in CSS[i:i + 260]
|
||||
assert ".ge-controls--in-options .ge-control-row.ge-actions { position: relative; top: -2px; margin-left: auto; }" in CSS
|
||||
j = CSS.index(".ge-controls--in-options > div:not([style*=\"display:none\"])")
|
||||
assert "flex: 1 1 auto;" in CSS[j:j + 500]
|
||||
|
||||
|
||||
def test_crop_can_expand_beyond_the_canvas():
|
||||
# normalizeCropRect must not clamp back inside the image anymore.
|
||||
GEO = (ROOT / "static/js/editor/document-geometry.js").read_text(encoding="utf-8")
|
||||
i = GEO.index("function normalizeCropRect(")
|
||||
block = GEO[i:i + 900]
|
||||
assert "editorState.imgWidth - 1" not in block
|
||||
assert "Math.max(-MAX, Math.min(MAX, Math.round(rect.x)))" in block
|
||||
assert "const MAX = 20000;" in block
|
||||
|
||||
|
||||
def test_crop_tool_allows_dragging_past_the_edge_and_syncs_a_frame():
|
||||
CROP = (ROOT / "static/js/editor/tools/crop.js").read_text(encoding="utf-8")
|
||||
assert "createCropTool({ composite, showCropApply, snapFrame, syncCropFrame })" in CROP
|
||||
assert "x: Math.max(-MAX, Math.min(MAX, end.x))," in CROP
|
||||
assert "syncCropFrame?.();" in CROP
|
||||
|
||||
|
||||
def test_crop_frame_outline_is_wired_and_styled():
|
||||
assert "function _syncCropFrame()" in EDITOR
|
||||
assert "el.className = 'ge-crop-frame';" in EDITOR
|
||||
assert "syncCropFrame: () => _syncCropFrame()," in EDITOR
|
||||
assert ".ge-crop-frame {" in CSS
|
||||
i = CSS.index(".ge-crop-frame {")
|
||||
block = CSS[i:i + 260]
|
||||
assert "position: absolute;" in block
|
||||
assert "pointer-events: none;" in block
|
||||
|
||||
|
||||
def test_crop_can_start_outside_the_canvas_without_panning():
|
||||
# The crop tool must be able to begin a rect in the empty area around the
|
||||
# image (so it can be pulled out past the edge) instead of the canvas-area
|
||||
# pan grabbing the pointer and switching to hand mode.
|
||||
CE = (ROOT / "static/js/editor/canvas-events.js").read_text(encoding="utf-8")
|
||||
assert "if (state.tool !== 'lasso' && state.tool !== 'crop') return;" in CE
|
||||
assert "(state.tool === 'lasso' || state.tool === 'crop') && !directPan" in CE
|
||||
# Pressing editor chrome inside the area (e.g. the crop Apply panel) must
|
||||
# not start a fresh crop.
|
||||
assert ".ge-crop-apply, .ge-adj-popup, .ge-transform-popup, .ge-fx-popup, .ge-inpaint-popup" in CE
|
||||
|
||||
|
||||
def test_crop_apply_has_a_standard_size_dropdown():
|
||||
# The crop Apply box (W × H pixel inputs) carries a dropdown of standard
|
||||
# crop sizes that set the rect to a fixed pixel size (or the original).
|
||||
assert 'class="ge-crop-size-preset"' in EDITOR
|
||||
i = EDITOR.index('class="ge-crop-size-preset"')
|
||||
block = EDITOR[i:i + 900]
|
||||
for v in ('custom', '1080x1080', '1080x1350', '1080x1920', '1200x630',
|
||||
'1280x720', '1920x1080', '2048x1536', '3000x2400', '4000x3000', 'original'):
|
||||
assert f'value="{v}"' in block
|
||||
assert '<option value="custom">Custom</option>' in block
|
||||
assert '<option value="original">Original</option>' in block
|
||||
# change handler applies the chosen pixel size / original image size
|
||||
assert "sizeSel?.addEventListener('change'" in EDITOR
|
||||
assert "if (v === 'original') { w = state.imgWidth; h = state.imgHeight; }" in EDITOR
|
||||
assert "const m = /^(\\d+)x(\\d+)$/.exec(v);" in EDITOR
|
||||
# manual W/H edits reset the dropdown to Custom
|
||||
assert "if (sizeSel) sizeSel.value = 'custom';" in EDITOR
|
||||
# CSS styles the select like the number inputs and leaves a gap to its right
|
||||
assert ".ge-crop-apply select.ge-crop-size-preset {" in CSS
|
||||
ci = CSS.index(".ge-crop-apply select.ge-crop-size-preset {")
|
||||
assert "margin-right: 8px;" in CSS[ci:ci + 260]
|
||||
|
||||
|
||||
def test_clear_x_is_nudged_down_and_lit_when_a_selection_is_active():
|
||||
# The Clear ✕ in the marquee/wand action rows: the glyph sits 1px lower,
|
||||
# and the button turns accent while a selection is active.
|
||||
i = CSS.index("#ge-marquee-clear svg,")
|
||||
block = CSS[i:i + 900]
|
||||
assert "#ge-wand-clear svg," in block
|
||||
assert "#ge-objectselect-clear svg { position: relative; top: 1px; }" in block
|
||||
assert "#ge-marquee-clear.has-selection," in block
|
||||
assert "#ge-objectselect-clear.has-selection { color: var(--accent, var(--red));" in block
|
||||
assert "querySelectorAll('#ge-marquee-clear, #ge-wand-clear, #ge-lasso-clear, #ge-sam-clear, #ge-quickselect-clear, #ge-objectselect-clear').forEach((btn) => {" in EDITOR
|
||||
assert "btn.classList.toggle('has-selection', !!state.wandMask);" in EDITOR
|
||||
|
||||
|
||||
def test_transform_cancel_and_apply_are_right_aligned():
|
||||
# The Transform popup's Cancel + Apply share the right edge of the body.
|
||||
i = CSS.index(".ge-transform-popup-body #ge-transform-cancel-btn {")
|
||||
assert "margin-left: auto;" in CSS[i:i + 120]
|
||||
|
||||
|
||||
def test_clone_source_clear_matches_the_wand_clear_button():
|
||||
# The Clone "clear sampled source" ✕ now uses the same SVG cross and
|
||||
# button classes as the wand/marquee Clear ✕, and lights up (accent)
|
||||
# while a source is sampled.
|
||||
assert 'class="ge-btn ge-btn-sm" id="ge-clone-source-clear"' in CONTROLS
|
||||
assert '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M18 6 6 18"></path><path d="m6 6 12 12"></path></svg>' in CONTROLS
|
||||
assert "ge-icon-btn\" id=\"ge-clone-source-clear\"" not in CONTROLS
|
||||
assert "#ge-clone-source-clear svg { position: relative; top: -1px; }" in CSS
|
||||
assert "#ge-clone-source-clear.has-selection { color: var(--accent, var(--red));" in CSS
|
||||
assert "clear.classList.toggle('has-selection', hasSource);" in EDITOR
|
||||
|
||||
|
||||
def test_clone_sample_mode_lives_in_the_clone_section():
|
||||
# The Sample select was moved out of the brush "More" menu (the custom
|
||||
# stroke group) into the dedicated Clone section, sharing the standard
|
||||
# control-select style.
|
||||
assert 'id="ge-clone-sample-mode" class="ge-ctl-select"' in CONTROLS
|
||||
assert '<select id="ge-clone-sample-mode">' not in CONTROLS
|
||||
section_at = CONTROLS.index('id="ge-clone-section"')
|
||||
select_at = CONTROLS.index('id="ge-clone-sample-mode"')
|
||||
assert select_at > section_at
|
||||
|
||||
|
||||
def test_ai_canvas_ascii_wave_uses_the_theme_accent():
|
||||
# The flowing ASCII "wave" skeleton painted over the canvas while an image
|
||||
# is generated used a hardcoded green; it must follow the theme accent.
|
||||
assert ".ge-canvas-area .ge-ai-ascii-fireflies {" in CSS
|
||||
block = CSS[CSS.index(".ge-canvas-area .ge-ai-ascii-fireflies {"):]
|
||||
block = block[:block.index("}")]
|
||||
assert "color: var(--accent, var(--red));" in block
|
||||
assert "#b8e6c1" not in block
|
||||
assert "rgba(184, 230, 193, 0.20)" not in CSS
|
||||
assert "color-mix(in srgb, var(--accent, var(--red)) 20%, transparent)" in CSS
|
||||
@@ -7,20 +7,27 @@ ROUTES = (ROOT / "routes" / "gallery" / "gallery_routes.py").read_text()
|
||||
SW = (ROOT / "static" / "sw.js").read_text()
|
||||
|
||||
|
||||
def test_client_image_mode_generates():
|
||||
assert "if (mode === 'image')" in JS
|
||||
def test_client_gen_mode_generates():
|
||||
assert "if (mode === 'gen')" in JS
|
||||
assert "_runQuickControlOp('ai.generateImage'" in JS
|
||||
assert "Generating an image…" in JS
|
||||
assert "Generated an image." in JS
|
||||
assert "Could not generate an image:" in JS
|
||||
|
||||
|
||||
def test_client_image_mode_runs_after_harmonize():
|
||||
def test_client_gen_mode_runs_after_harmonize():
|
||||
harm = JS.index("_isHarmonizeIntent(prompt))")
|
||||
img = JS.index("if (mode === 'image')")
|
||||
img = JS.index("if (mode === 'gen')")
|
||||
assert harm < img
|
||||
|
||||
|
||||
def test_editing_status_is_distinct_from_generating_status():
|
||||
# Image mode makes a brand-new picture; every other mode edits the canvas.
|
||||
assert "_setAiCommandStatus('Editing the image…', 'running');" in JS
|
||||
assert "_setAiCommandStatus('Generating an image…', 'running');" in JS
|
||||
assert "_setAiCommandStatus('Generating…', 'running');" not in JS
|
||||
|
||||
|
||||
def test_server_generate_route_exists():
|
||||
assert '@router.post("/api/image/generate")' in ROUTES
|
||||
assert "do_generate_image" in ROUTES
|
||||
@@ -30,3 +37,25 @@ def test_server_generate_route_exists():
|
||||
def test_sw_cache_bumped():
|
||||
m = re.search(r"CACHE_NAME = '(odysseus-v\d+-pr40-editor-precache)'", SW)
|
||||
assert m and re.fullmatch(r"odysseus-v\d+-pr40-editor-precache", m.group(1))
|
||||
|
||||
|
||||
def test_generate_route_forwards_the_picked_model():
|
||||
"""The model picker's choice must reach do_generate_image.
|
||||
|
||||
Settings defaults may otherwise point at the local Qwen server and the
|
||||
user's chosen OpenRouter model is never used.
|
||||
"""
|
||||
route = ROUTES.split('@router.post("/api/image/generate")', 1)[1][:2600]
|
||||
assert 'model_choice = str((body or {}).get("model") or "").strip()' in route
|
||||
assert 'payload["model"] = model_choice' in route
|
||||
assert "content = _json.dumps(payload)" in route
|
||||
assert "localStorage.getItem('ge-ai-command-model')" in JS
|
||||
|
||||
|
||||
def test_cloud_image_model_is_resolved_before_the_local_fallback():
|
||||
"""A gateway model must not be hijacked onto the local image endpoint."""
|
||||
ai = (ROOT / "src" / "ai_interaction.py").read_text()
|
||||
block = ai.split("async def _resolve_image_model(model_name: str):", 1)[1][:900]
|
||||
plain = block.index("_resolve_model(model_name, owner=owner)")
|
||||
typed = block.index('model_type="image"')
|
||||
assert plain < typed
|
||||
|
||||
@@ -52,7 +52,7 @@ def test_quick_edit_flip_and_rotate_accept_numeric_degrees():
|
||||
|
||||
def test_quick_edit_transforms_do_not_add_a_second_undo_snapshot():
|
||||
start = EDITOR.index("if (/\\brotate\\b.*\\b180\\b")
|
||||
end = EDITOR.index("const _bgRemove = ", start)
|
||||
end = EDITOR.index("if (_looksBackgroundRemoval(p)) {", start)
|
||||
transform_block = EDITOR[start:end]
|
||||
assert "_saveState('Rotate" not in transform_block
|
||||
assert "_saveState('Flip" not in transform_block
|
||||
@@ -163,7 +163,7 @@ def test_brush_preset_picker_is_visible_in_the_options_bar():
|
||||
def test_pixel_brush_shows_a_square_cursor():
|
||||
# The pixel brush draws hard squares, so the cursor preview is a square
|
||||
# snapped to whole pixels rather than a feathered circle.
|
||||
assert "const pixelBrush = !!state.brushPixel && (state.tool === 'brush' || state.tool === 'eraser');" in EDITOR
|
||||
assert "const pixelBrush = state.tool === 'pixel' || (!!state.brushPixel && (state.tool === 'brush' || state.tool === 'eraser'));" in EDITOR
|
||||
assert "state.cursorEl.classList.toggle('ge-brush-cursor--pixel', pixelBrush);" in EDITOR
|
||||
assert ".ge-brush-cursor.ge-brush-cursor--pixel" in STYLE
|
||||
assert "border-radius: 0;" in STYLE
|
||||
@@ -207,9 +207,13 @@ def test_quick_edit_is_local_first_and_only_uses_ai_when_needed():
|
||||
assert "const planned = await _runLocalPlan(prompt);" in wrapper
|
||||
assert "if (tryLocal && !_looksGenerative(prompt)) {" in wrapper
|
||||
assert wrapper.index("_runLocalPlan(prompt)") < wrapper.index("agent.run(_enrichLookPrompt(prompt)")
|
||||
# Routing mode selector: auto / quick / agent / image, with a helper.
|
||||
# Routing mode selector: auto / gen / edit, with a helper.
|
||||
assert "function _getQuickEditMode() {" in EDITOR
|
||||
assert "id=\"ge-ai-command-mode\"" in EDITOR
|
||||
assert "<option value=\"auto\">Auto</option>" in EDITOR
|
||||
assert "<option value=\"gen\">Img Gen</option>" in EDITOR
|
||||
assert "<option value=\"edit\">Img Edit</option>" in EDITOR
|
||||
assert EDITOR.index('<option value="auto">Auto</option>') < EDITOR.index('<option value="gen">Img Gen</option>')
|
||||
assert "const mode = _getQuickEditMode();" in wrapper
|
||||
# Auto/Image/Tab fall through to the IMAGE MODEL, not a filter planner.
|
||||
assert "const _runImageModelEdit = async (prompt, { signal = null } = {}) => {" in EDITOR
|
||||
@@ -232,7 +236,11 @@ def test_quick_edit_is_local_first_and_only_uses_ai_when_needed():
|
||||
assert "'Nothing to trim — the image already fills the canvas.'" in wrapper
|
||||
assert "invoke('document.crop', { x: b.x, y: b.y, width: b.w, height: b.h })" in wrapper
|
||||
# Deterministic ops are skipped in agent/image/Tab mode.
|
||||
assert "const tryLocal = !forceAi && !forceAgent && mode !== 'image';" in wrapper
|
||||
# Deterministic ops run in Auto only; Gen/Edit/Tab skip them.
|
||||
assert "const tryLocal = !forceAi && mode === 'auto';" in wrapper
|
||||
# Gen = text-to-image on a new layer; Edit = the image edit model only.
|
||||
assert "if (mode === 'gen') {" in wrapper
|
||||
assert "if (mode === 'edit' && !forceAi) {" in wrapper
|
||||
assert "if (tryLocal && /\\b(?:crop|trim)\\b/i.test(cropText) && !hasRatio && !/\\bselection\\b|\\bselected\\b/i.test(cropText)) {" in wrapper
|
||||
|
||||
|
||||
@@ -306,7 +314,7 @@ def test_ai_work_glows_the_canvas():
|
||||
assert "height: Math.max(1, area.clientHeight)," in EDITOR
|
||||
assert "area.style.setProperty('--ge-ai-busy-w'" in EDITOR
|
||||
assert ".ge-canvas-area.ge-canvas-ai-busy::after" in STYLE
|
||||
assert "background: rgba(6, 14, 9, 0.38);" in STYLE
|
||||
assert "background: color-mix(in srgb, var(--bg, #111) 62%, transparent);" in STYLE
|
||||
assert "left: var(--ge-ai-busy-x, 0px);" in STYLE
|
||||
assert "width: var(--ge-ai-busy-w, 100%);" in STYLE
|
||||
assert "@keyframes ge-ai-canvas-sheen" in STYLE
|
||||
@@ -490,9 +498,19 @@ def test_remove_background_intent_matches_common_phrasings():
|
||||
# The bg-removal fast path must catch "removal" (not just "remove"),
|
||||
# "bg remov", "remove bg", "no background", "cutout", "isolate the
|
||||
# subject" etc. — otherwise these fall through to the AI agent.
|
||||
assert "const _bgRemove = " in EDITOR
|
||||
assert "const _looksBackgroundRemoval = " in EDITOR
|
||||
assert "remov\\w*|erase|delete|drop|get\\s*rid\\s*of|cut\\s*out|cutout|knock\\s*out|isolate|strip|clear|transparent|no|without|rm" in EDITOR
|
||||
assert "if (_bgRemove) {" in EDITOR
|
||||
assert "if (_looksBackgroundRemoval(p)) {" in EDITOR
|
||||
|
||||
|
||||
def test_remove_background_never_falls_back_to_the_generative_model():
|
||||
# A rembg outage must report the failure, not send "remove background" to
|
||||
# the image model (which repainted a blank canvas instead).
|
||||
guard = EDITOR[EDITOR.index("if (_looksBackgroundRemoval(prompt)) {"):]
|
||||
guard = guard[:guard.index("// Explicit \"harmonize / blend the layers\"")]
|
||||
assert "await _executeQuickEditLocal(prompt);" in guard
|
||||
assert "Could not remove the background:" in guard
|
||||
assert "_runImageModelEdit" not in guard
|
||||
|
||||
|
||||
def test_remove_background_mask_is_a_layer_mask():
|
||||
@@ -698,3 +716,43 @@ def test_generation_503_is_handled_gracefully():
|
||||
assert "if (status === 503) return _GEN_DISABLED_MSG;" in EDITOR
|
||||
assert "_GEN_DISABLED_MSG = 'Image generation is switched off on the server at the moment.'" in EDITOR
|
||||
assert "window.__geGenerationDisabled" in EDITOR
|
||||
|
||||
|
||||
def test_sending_a_prompt_returns_focus_to_the_document():
|
||||
# After Enter the prompt box used to keep focus, so pressing V (Move) or B
|
||||
# (Brush) typed into an empty prompt instead of switching tools.
|
||||
start = EDITOR.index("const enqueueQuickEditJob = (prompt, forceAi = false) => {")
|
||||
body = EDITOR[start:start + 1400]
|
||||
assert "input.blur();\n pumpQuickEdit();" in body
|
||||
assert "input.focus();\n pumpQuickEdit();" not in body
|
||||
|
||||
|
||||
def test_edit_scope_control_chooses_all_layers_or_the_selected_layer():
|
||||
# "Edit" used to always send the flattened document; the scope control next
|
||||
# to the routing dropdown lets the selected layer be sent on its own.
|
||||
assert 'id="ge-ai-command-scope"' in EDITOR
|
||||
assert '<option value="all">All layers</option>' in EDITOR
|
||||
assert '<option value="layer">This layer</option>' in EDITOR
|
||||
assert "localStorage.getItem('ge-ai-command-edit-scope')" in EDITOR
|
||||
assert "localStorage.setItem('ge-ai-command-edit-scope', scopeSelect.value);" in EDITOR
|
||||
assert "scopeSelect.hidden = modeSelect.value === 'gen';" in EDITOR
|
||||
assert "const _aiEditSourceCanvas = () => {" in EDITOR
|
||||
assert "const flat = _aiEditSourceCanvas();" in EDITOR
|
||||
assert "if (scope !== 'layer') return flatten();" in EDITOR
|
||||
assert "out.getContext('2d').drawImage(layer.canvas, off.x, off.y);" in EDITOR
|
||||
assert ".ge-ai-command .ge-ai-command-scope" in STYLE
|
||||
|
||||
|
||||
def test_cancel_aborts_the_in_flight_image_request():
|
||||
# Cancel used to drop the row but leave the model call running, so the
|
||||
# result still landed on the canvas. The job must abort the actual fetch.
|
||||
assert "let _aiEditAbort = null;" in EDITOR
|
||||
assert "_aiEditAbort = _ctl;" in EDITOR
|
||||
assert "if (_aiEditAbort === _ctl) _aiEditAbort = null;" in EDITOR
|
||||
cancel = EDITOR[EDITOR.index("const cancelJob = (job) => {"):]
|
||||
cancel = cancel[:cancel.index("_setAiCommandStatus('', '');")]
|
||||
assert "try { _aiEditAbort?.abort(); } catch (_) {}" in cancel
|
||||
css = (ROOT / "static/css/13-image-editor.css").read_text(encoding="utf-8")
|
||||
block = css[css.index(".ge-ai-job-cancel {"):]
|
||||
block = block[:block.index("}")]
|
||||
assert "top: -2px;" in block
|
||||
|
||||
@@ -24,7 +24,7 @@ def test_gallery_attaches_affordance_to_both_rails():
|
||||
src = GALLERY.read_text(encoding="utf-8")
|
||||
assert "import { attachScrollAffordance } from './editor/scroll-affordance.js';" in src
|
||||
assert "attachScrollAffordance(container.querySelector('.ge-topbar-left'))" in src
|
||||
assert "attachScrollAffordance(container.querySelector('.ge-toolbar'))" in src
|
||||
assert "attachScrollAffordance(container.querySelector('.ge-toolbar-scroll'))" in src
|
||||
|
||||
|
||||
def test_mobile_css_fades_overflowing_rail_edges():
|
||||
@@ -34,7 +34,7 @@ def test_mobile_css_fades_overflowing_rail_edges():
|
||||
before = css[:idx]
|
||||
assert before.rfind("@media (max-width: 700px)") > 0
|
||||
assert ".ge-topbar-left.ge-scroll-fade," in css
|
||||
assert ".ge-toolbar.ge-scroll-fade {" in css
|
||||
assert ".ge-toolbar-scroll.ge-scroll-fade {" in css
|
||||
assert "mask-image: linear-gradient(to right, transparent 0, #000 42px" in css
|
||||
assert ".ge-topbar-left.ge-scroll-fade.is-at-left.is-at-right," in css
|
||||
assert ".ge-toolbar.ge-scroll-fade.is-at-left.is-at-right" in css
|
||||
assert ".ge-toolbar-scroll.ge-scroll-fade.is-at-left.is-at-right" in css
|
||||
|
||||
@@ -30,4 +30,4 @@ def test_scroll_fade_only_applies_to_marked_rails():
|
||||
# scoped to the two mobile rails, not every toolbar.
|
||||
css = _css()
|
||||
assert ".ge-topbar-left.ge-scroll-fade," in css
|
||||
assert ".ge-toolbar.ge-scroll-fade {" in css
|
||||
assert ".ge-toolbar-scroll.ge-scroll-fade {" in css
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
"""Selection marching-ants must keep a constant on-screen size regardless of zoom.
|
||||
|
||||
The selection outline canvas is rendered at SCREEN resolution (image size ×
|
||||
zoom) and the painter strokes a flat 1px outline in screen pixels, so there is
|
||||
no bitmap resampling: the outline keeps the same thickness AND opacity at every
|
||||
zoom level. The crop overlay got the same zoom compensation.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
MASK = (ROOT / "static/js/editor/selection-mask.js").read_text(encoding="utf-8")
|
||||
EDITOR = (ROOT / "static/js/galleryEditor.js").read_text(encoding="utf-8")
|
||||
SW = (ROOT / "static/sw.js").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_selection_boundary_helper_returns_flags():
|
||||
assert "export function selectionBoundary(mask, threshold = 128) {" in MASK
|
||||
assert "flags" in MASK
|
||||
# Rows outside the selection are skipped instead of scanning the whole
|
||||
# canvas, which keeps closing a pen path fast on large documents.
|
||||
assert "const firsts = new Int32Array(height).fill(-1);" in MASK
|
||||
assert "const lasts = new Int32Array(height).fill(-1);" in MASK
|
||||
assert "for (let x = first; x <= last; x += 1) {" in MASK
|
||||
|
||||
|
||||
def test_overlay_renders_at_screen_resolution():
|
||||
# The overlay bitmap must match its CSS box (image size × zoom) so the
|
||||
# strokes are never up-scaled (which is what made opacity drift).
|
||||
assert "const dispW = Math.max(1, Math.round(state.imgWidth * state.zoom));" in EDITOR
|
||||
assert "overlay.style.width = `${dispW}px`;" in EDITOR
|
||||
|
||||
|
||||
def test_painter_draws_flat_screen_pixels():
|
||||
assert "export function paintSelectionBoundary(ctx, pixels, width, height, phase = 0, zoom = 1, flags = null) {" in MASK
|
||||
assert "ctx.lineWidth = 1;" in MASK
|
||||
assert "const x0 = x * scale;" in MASK
|
||||
assert "new Path2D()" in MASK
|
||||
|
||||
|
||||
def test_overlay_passes_zoom_and_flags():
|
||||
assert "selectionBoundary as _selectionBoundary," in EDITOR
|
||||
assert (
|
||||
"_paintSelectionBoundary(ctx, cache.pixels, cache.width, cache.height, "
|
||||
"_selectionAnimationPhase, state.zoom, cache.flags);" in EDITOR
|
||||
)
|
||||
assert "..._selectionBoundary(mask)," in EDITOR
|
||||
|
||||
|
||||
def test_crop_overlay_is_zoom_compensated():
|
||||
assert "state.mainCtx.lineWidth = 1 / state.zoom;" in EDITOR
|
||||
assert "state.mainCtx.setLineDash([4 / state.zoom, 4 / state.zoom]);" in EDITOR
|
||||
|
||||
|
||||
def test_sw_cache_name_bumped():
|
||||
import re
|
||||
assert re.search(r"odysseus-v\d+-pr40-editor-precache", SW)
|
||||
@@ -0,0 +1,38 @@
|
||||
"""Quick Select / Object Select must honour the New/Add/Subtract mode toggle.
|
||||
|
||||
The wand's segmented control (`.ge-wand-mode-btn`) writes `state.wandMode`;
|
||||
every selection tool has to read it back. Quick Select used to hard-code
|
||||
`'add'` (so every dab added, regardless of the toggle) and Object Select
|
||||
hard-coded `'replace'`, so picking Subtract did nothing in either tool.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
import re
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
QUICK = (ROOT / "static/js/editor/tools/quick-select.js").read_text(encoding="utf-8")
|
||||
OBJECT = (ROOT / "static/js/editor/tools/object-select.js").read_text(encoding="utf-8")
|
||||
SW = (ROOT / "static/sw.js").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_quick_select_reads_the_wand_mode_toggle():
|
||||
assert "import { state } from '../state.js';" in QUICK
|
||||
assert "const modeFor = (event) => selectionModeForEvent(event, state.wandMode || 'replace');" in QUICK
|
||||
# the draft mode is captured on begin and re-used for the whole stroke
|
||||
assert "let strokeMode = 'replace';" in QUICK
|
||||
assert "strokeMode = modeFor(event);" in QUICK
|
||||
assert "sample(event, strokeMode);" in QUICK
|
||||
# after the first replace, later dabs accumulate instead of wiping progress
|
||||
assert "sample(event, strokeMode === 'replace' ? 'add' : selectionModeForEvent(event, strokeMode));" in QUICK
|
||||
# the old hard-coded add default is gone
|
||||
assert "selectionModeForEvent(event, 'add')" not in QUICK
|
||||
|
||||
|
||||
def test_object_select_reads_the_wand_mode_toggle():
|
||||
assert "import { state } from '../state.js';" in OBJECT
|
||||
assert "mode = selectionModeForEvent(event, state.wandMode || 'replace');" in OBJECT
|
||||
assert "mode = selectionModeForEvent(event, 'replace');" not in OBJECT
|
||||
|
||||
|
||||
def test_sw_cache_name_bumped():
|
||||
assert re.search(r"odysseus-v\d+-pr40-editor-precache", SW)
|
||||
@@ -51,11 +51,18 @@ def test_toolbar_declares_new_selection_tools():
|
||||
|
||||
|
||||
def test_eyedropper_icon_is_a_pipette_not_the_brush():
|
||||
# The eyedropper and the brush must read as different tools at a glance,
|
||||
# so the eyedropper uses a pipette (pointed tip + bulb), not a brush head.
|
||||
# The eyedropper must read as an actual dropper at a glance, so it uses the
|
||||
# pipette glyph (bulb + barrel + pointed tip), not a droplet or a brush head.
|
||||
assert "m2 22 1-1h3l9-9" in TOOLBAR
|
||||
assert "m15 6 3.4-3.4a2.1 2.1 0 1 1 3 3" in TOOLBAR
|
||||
assert "M9.06 11.9l8.07-8.06a2.85" in TOOLBAR
|
||||
assert "M3 21v-3l9-9" in TOOLBAR
|
||||
assert "3.4-3.4a2.1 2.1 0 1 1 3 3" in TOOLBAR
|
||||
|
||||
|
||||
def test_eyedropper_tool_uses_a_live_pipette_cursor():
|
||||
# Picking the Eyedropper swaps the canvas cursor for the pipette (tip at the
|
||||
# hotspot) instead of leaving the default crosshair.
|
||||
assert "const eyedropperCursorSvg = `data:image/svg+xml;utf8,${encodeURIComponent(" in GALLERY
|
||||
assert "else if (state.tool === 'eyedropper') cursor = `url(\"${eyedropperCursorSvg}\") 2 22, crosshair`;" in GALLERY
|
||||
|
||||
|
||||
def test_all_toolbar_tool_icons_are_uniform_svgs():
|
||||
@@ -115,13 +122,15 @@ def test_editor_wires_tools_and_color_range():
|
||||
assert "if (state.tool === 'objectselect') return _objectSelectTool.end(e);" in GALLERY
|
||||
assert "async function _colorRangeSelection()" in GALLERY
|
||||
assert "colorRange: _colorRangeSelection," in GALLERY
|
||||
assert "data-tool=\"quickselect\"" in GALLERY
|
||||
assert "data-tool=\"objectselect\"" in GALLERY
|
||||
assert "const toolBtn = (id) => state.container.querySelector(" in GALLERY
|
||||
assert '.ge-tool-btn[data-tool="${id}"], .ge-tool-btn[data-tool-option="${id}"]' in GALLERY
|
||||
assert "const quickSelectBtn = toolBtn('quickselect');" in GALLERY
|
||||
assert "const objectSelectBtn = toolBtn('objectselect');" in GALLERY
|
||||
|
||||
|
||||
def test_options_bar_and_select_menu_entries():
|
||||
assert "quickselect: { modes: '#ge-quickselect-section', feather: null }" in OPTIONS_BAR
|
||||
assert "objectselect: { modes: '#ge-objectselect-section', feather: null }" in OPTIONS_BAR
|
||||
assert "quickselect: { modes: null, feather: null }" in OPTIONS_BAR
|
||||
assert "objectselect: { modes: null, feather: null }" in OPTIONS_BAR
|
||||
assert 'data-selection-action="colorRange"' in TOPBAR
|
||||
assert "action === 'colorRange'" in WIRE_MENUS
|
||||
assert "colorRange," in WIRE_MENUS
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
"""A slot's main keyboard key must keep the sub-tool you last picked.
|
||||
|
||||
The gradient / paint-bucket slot (and the brush / eraser / pixel slot) share one
|
||||
toolbar button. Picking Paint Bucket from the flyout re-renders the slot face as
|
||||
bucket, so pressing G must re-activate the face (keeping Paint Bucket) rather
|
||||
than falling through to the hidden gradient entry and snapping back.
|
||||
"""
|
||||
from pathlib import Path
|
||||
import re
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
KEYS = (ROOT / "static/js/editor/keyboard-shortcuts.js").read_text(encoding="utf-8")
|
||||
SW = (ROOT / "static/sw.js").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_slot_key_reactivates_the_current_member():
|
||||
assert "const flyoutItem = toolbar.querySelector(`[data-tool-option=\"${toolId}\"]`);" in KEYS
|
||||
assert "const slotEl = flyoutItem ? flyoutItem.closest('.ge-tool-slot') : null;" in KEYS
|
||||
assert "const firstMember = slotEl.querySelector('.ge-tool-flyout-item[data-tool-option]');" in KEYS
|
||||
assert "if (toolId === defaultTool) toolBtn = slotEl.querySelector('.ge-tool-slot-face');" in KEYS
|
||||
assert "toolBtn = toolBtn || toolbar.querySelector(`[data-tool=\"${toolId}\"]`) || flyoutItem;" in KEYS
|
||||
|
||||
|
||||
def test_sw_cache_name_bumped():
|
||||
assert re.search(r"odysseus-v\d+-pr40-editor-precache", SW)
|
||||
@@ -21,7 +21,7 @@ def test_hex_code_field_is_wired():
|
||||
js = _read("static/js/editor/panels/panels.js")
|
||||
assert "const hexRow = el('div', 'ge-hex-row');" in js
|
||||
assert "const hexInput = el('input', 'ge-hex-input');" in js
|
||||
assert "hexRow.append(hexHash, hexInput);" in js
|
||||
assert "hexRow.append(hexHash, hexInput, hexCopy, hexFav);" in js
|
||||
assert "swatchBody.appendChild(hexRow);" in js
|
||||
assert "const _setHex = (hex) => {" in js
|
||||
assert "hexInput.addEventListener('input'," in js
|
||||
|
||||
@@ -10,7 +10,8 @@ def _read(rel):
|
||||
|
||||
def test_self_hosted_font_faces_are_declared():
|
||||
css = _read("static/css/00-tokens.css")
|
||||
for family in ["Inter", "Roboto", "Playfair Display", "Lobster", "Bebas Neue", "Roboto Mono"]:
|
||||
for family in ["Inter", "Roboto", "Playfair Display", "Lobster", "Bebas Neue", "Roboto Mono",
|
||||
"Space Grotesk", "Fraunces", "Syne", "Bungee", "Instrument Serif"]:
|
||||
assert f"font-family: '{family}'" in css, family
|
||||
for fname in [
|
||||
"Inter-Regular.woff2",
|
||||
@@ -19,6 +20,11 @@ def test_self_hosted_font_faces_are_declared():
|
||||
"Lobster-Regular.woff2",
|
||||
"BebasNeue-Regular.woff2",
|
||||
"RobotoMono-Regular.woff2",
|
||||
"SpaceGrotesk-Regular.woff2",
|
||||
"Fraunces-Regular.woff2",
|
||||
"Syne-Regular.woff2",
|
||||
"Bungee-Regular.woff2",
|
||||
"InstrumentSerif-Regular.woff2",
|
||||
]:
|
||||
assert f"/static/fonts/{fname}" in css, fname
|
||||
|
||||
@@ -33,6 +39,14 @@ def test_font_files_exist_on_disk():
|
||||
"BebasNeue-Regular.woff2",
|
||||
"RobotoMono-Regular.woff2",
|
||||
"RobotoMono-Bold.woff2",
|
||||
"SpaceGrotesk-Regular.woff2",
|
||||
"SpaceGrotesk-Bold.woff2",
|
||||
"Fraunces-Regular.woff2",
|
||||
"Fraunces-Bold.woff2",
|
||||
"Syne-Regular.woff2",
|
||||
"Syne-Bold.woff2",
|
||||
"Bungee-Regular.woff2",
|
||||
"InstrumentSerif-Regular.woff2",
|
||||
]:
|
||||
assert (REPO / "static" / "fonts" / fname).is_file(), fname
|
||||
|
||||
@@ -49,7 +63,8 @@ def test_text_layer_exposes_font_helpers():
|
||||
|
||||
def test_options_bar_font_select_lists_webfonts():
|
||||
js = _read("static/js/editor/build/controls.js")
|
||||
for family in ["Inter", "Roboto", "Playfair Display", "Lobster", "Bebas Neue", "Roboto Mono"]:
|
||||
for family in ["Inter", "Roboto", "Playfair Display", "Lobster", "Bebas Neue", "Roboto Mono",
|
||||
"Space Grotesk", "Fraunces", "Syne", "Bungee", "Instrument Serif"]:
|
||||
assert family in js, family
|
||||
|
||||
|
||||
@@ -64,3 +79,46 @@ def test_sw_version_is_current():
|
||||
sw = _read("static/sw.js")
|
||||
m = re.search(r"CACHE_NAME = '([^']+)'", sw)
|
||||
assert m and re.fullmatch(r"odysseus-v\d+-pr40-editor-precache", m.group(1)), m
|
||||
|
||||
|
||||
def test_text_box_width_survives_the_position_loop():
|
||||
js = _read("static/js/editor/text-edit-overlay.js")
|
||||
# The per-frame position() used to rewrite the width unconditionally, which
|
||||
# stomped the native resize handle before the ResizeObserver saw it.
|
||||
assert "const targetW = Math.max(80, text.frameWidth * scaleX);" in js
|
||||
assert "if (!resizing && Math.abs(targetW - appliedWidth) > 0.5) {" in js
|
||||
assert "input.style.minWidth = '40px';" in js
|
||||
assert "let appliedWidth = 0;" in js
|
||||
assert "appliedWidth = 0;" in js
|
||||
|
||||
|
||||
def test_text_box_has_a_visible_corner_resize_handle():
|
||||
js = _read("static/js/editor/text-edit-overlay.js")
|
||||
css = _read("static/css/13-image-editor.css")
|
||||
# The native textarea grip is invisible and tiny in this theme, so the
|
||||
# editor exposes its own 14px grab square that drives the frame width.
|
||||
assert "handle.className = 'ge-direct-text-handle';" in js
|
||||
assert "handle.addEventListener('pointerdown'" in js
|
||||
assert "handle.addEventListener('pointermove'" in js
|
||||
assert "input.style.width = `${w}px`;" in js
|
||||
assert "onInput?.(layer, input.value, input, w / Math.max(0.01, dragStart.scale));" in js
|
||||
assert "handle.innerHTML = '<svg width=\"12\" height=\"12\"" in js
|
||||
assert ".ge-direct-text-handle {" in css
|
||||
assert "cursor: nwse-resize;" in css
|
||||
assert "width: 16px;" in css
|
||||
handle_block = css[css.index(".ge-direct-text-handle {"):]
|
||||
handle_block = handle_block[:handle_block.index("}")]
|
||||
assert "position: fixed;" in handle_block
|
||||
assert "z-index: 10021;" in handle_block
|
||||
assert "background: transparent;" in handle_block
|
||||
assert "opacity: 0.55;" in handle_block
|
||||
# The editing surface is see-through so the artwork stays visible while typing.
|
||||
editor_block = css[css.index(".ge-direct-text-editor {"):]
|
||||
editor_block = editor_block[:editor_block.index("}")]
|
||||
assert "background: transparent;" in editor_block
|
||||
# `body.ge-editor-open textarea` is more specific, so the transparent
|
||||
# surface is restated for the on-canvas text field.
|
||||
override = css[css.index("body.ge-editor-open textarea.ge-direct-text-editor {"):]
|
||||
override = override[:override.index("}")]
|
||||
assert "background: transparent;" in override
|
||||
assert "border: 1px dashed var(--red);" in override
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Marker test: the mobile tool-rail overflow control shows an ellipsis.
|
||||
"""Marker test: the mobile tool-rail overflow control shows a '>' chevron.
|
||||
|
||||
EVAL flagged the '<' '>' chevron; it should match the dock overflow control
|
||||
(an ellipsis '...').
|
||||
Superseded 2026-10-14: the maintainer asked for a '>' chevron instead of the
|
||||
ellipsis, so it clearly reads as "more tools" rather than a menu.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
@@ -12,9 +12,9 @@ EDITOR = (ROOT / "static/js/galleryEditor.js").read_text()
|
||||
SW = (ROOT / "static/sw.js").read_text()
|
||||
|
||||
|
||||
def test_tool_rail_more_is_an_ellipsis():
|
||||
assert "toolbarMore.textContent = '\\u2026';" in EDITOR
|
||||
assert "toolbarMore.textContent = '\\u203A';" not in EDITOR
|
||||
def test_tool_rail_more_is_a_chevron():
|
||||
assert "toolbarMore.textContent = '\\u203A';" in EDITOR
|
||||
assert "toolbarMore.textContent = '\\u2026';" not in EDITOR
|
||||
|
||||
|
||||
def test_sw_version_is_current():
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
"""Toolbar batch: inline wand refinements, custom crop ratio, dodge/burn
|
||||
intensity, gradient strip de-duplication, the tall text strip and the pen
|
||||
overlay that no longer re-composites the whole document."""
|
||||
from pathlib import Path
|
||||
import re
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
CONTROLS = (ROOT / "static/js/editor/build/controls.js").read_text(encoding="utf-8")
|
||||
EDITOR = (ROOT / "static/js/galleryEditor.js").read_text(encoding="utf-8")
|
||||
CSS = (ROOT / "static/css/13-image-editor.css").read_text(encoding="utf-8")
|
||||
STATE = (ROOT / "static/js/editor/state.js").read_text(encoding="utf-8")
|
||||
PIPE = (ROOT / "static/js/editor/stroke-pipeline.js").read_text(encoding="utf-8")
|
||||
SLIDERS = (ROOT / "static/js/editor/stroke-tool-sliders.js").read_text(encoding="utf-8")
|
||||
PEN = (ROOT / "static/js/editor/tools/pen-selection.js").read_text(encoding="utf-8")
|
||||
OB = (ROOT / "static/js/editor/build/options-bar.js").read_text(encoding="utf-8")
|
||||
SW = (ROOT / "static/sw.js").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_custom_crop_ratio_field():
|
||||
assert 'id="ge-crop-custom-ratio"' in CONTROLS
|
||||
assert 'class="ge-crop-custom"' in CONTROLS
|
||||
assert ".ge-crop-custom input {" in CSS
|
||||
# The typed ratio is applied through the same preset path as the buttons,
|
||||
# and a centred rect is created when none is drawn yet.
|
||||
assert "customRatioInput.addEventListener('change', applyCustomRatio);" in EDITOR
|
||||
assert "if (!(rect && rect.w > 5 && rect.h > 5)) {" in EDITOR
|
||||
|
||||
|
||||
def test_dodge_and_burn_intensity_slider():
|
||||
assert 'id="ge-dodgeburn-intensity"' in CONTROLS
|
||||
assert "dodgeBurnIntensity: 25," in STATE
|
||||
assert "state.dodgeBurnIntensity = parseInt(e.target.value, 10);" in SLIDERS
|
||||
assert PIPE.count("state.dodgeBurnIntensity") >= 2
|
||||
assert PIPE.count("alpha * intensity") >= 2
|
||||
# The strip mirrors Intensity so it is adjustable without the More menu.
|
||||
assert "intensity: '#ge-dodgeburn-intensity'" in OB
|
||||
assert "makeRange('Intensity', src.intensity)" in OB
|
||||
# The row is a real strip row next to Size, shown for Dodge/Burn only.
|
||||
assert "if (dodgeBurnRow) dodgeBurnRow.style.display = (toolId === 'dodge' || toolId === 'burn') ? '' : 'none';" in EDITOR
|
||||
menu = CONTROLS[CONTROLS.index('id="ge-brush-more-menu"'):]
|
||||
assert 'id="ge-dodgeburn-intensity-row"' not in menu.split('ge-brush-pressure-row')[0]
|
||||
# Its label is abbreviated (Int) and its column shrinks to the word so the
|
||||
# text sits flush against its own slider.
|
||||
assert '<label title="Intensity">Int <span id="ge-dodgeburn-intensity-label">25%</span></label>' in CONTROLS
|
||||
assert "#ge-dodgeburn-intensity-row label { flex: 0 0 auto; min-width: 20px; }" in CSS
|
||||
# The dot and its label sit 10px further right than the row's edge.
|
||||
assert "#ge-dodgeburn-intensity-row > .ge-eraser-preview { margin-left: 10px; }" in CSS
|
||||
|
||||
|
||||
def test_every_clear_cross_is_the_same_square_target():
|
||||
start = CSS.index("#ge-marquee-clear,\n#ge-wand-clear,")
|
||||
block = CSS[start:start + 700]
|
||||
assert " width: 26px;\n height: 26px;" in block
|
||||
assert " min-width: 26px;\n min-height: 26px;" in block
|
||||
assert " display: inline-flex;\n align-items: center;\n justify-content: center;" in block
|
||||
for sel in ("#ge-wand-clear", "#ge-marquee-clear", "#ge-lasso-clear", "#ge-clone-source-clear"):
|
||||
assert sel in block
|
||||
|
||||
|
||||
def test_clone_clear_icon_moves_back_when_a_source_is_selected():
|
||||
assert "#ge-clone-source-clear.has-selection svg { top: 1px; }" in CSS
|
||||
|
||||
|
||||
def test_gradient_strip_is_not_duplicated():
|
||||
# The real #ge-gradient-section renders inside the strip; the mirror must
|
||||
# not add a second Type/Opacity pair.
|
||||
assert "ge-gradient-type" not in OB
|
||||
assert "ge-gradient-opacity" not in OB
|
||||
assert 'id="ge-gradient-section"' in CONTROLS
|
||||
|
||||
|
||||
def test_text_strip_can_wrap_tall():
|
||||
assert "ge-options-tall" in CSS
|
||||
assert "classList.toggle('ge-options-tall', toolId === 'text')" in EDITOR
|
||||
assert CSS.count("flex-wrap: wrap !important;") >= 2
|
||||
|
||||
|
||||
def test_pen_tool_paints_its_overlay_without_recompositing():
|
||||
assert "state.penOverlay" in PEN
|
||||
assert "state.penOverlayCtx" in PEN
|
||||
assert "const repaint = () => { if (typeof redraw === 'function') redraw(); else composite(); };" in PEN
|
||||
assert PEN.count("repaint();") >= 4
|
||||
# begin()/drag() must not re-composite the document
|
||||
begin = PEN[PEN.index("begin(event)"):PEN.index("drag(event)")]
|
||||
assert "composite()" not in begin
|
||||
assert "redraw: () => _penTool.draw()," in EDITOR
|
||||
assert ".ge-pen-overlay {" in CSS
|
||||
assert "state.penOverlay = null;" in EDITOR
|
||||
# The stylesheet hides the overlay by default, so revealing it must set an
|
||||
# explicit display rather than clearing the inline value.
|
||||
assert "overlay.style.display = 'block';" in PEN
|
||||
# Closing the path must show the marching ants. It refreshes just the
|
||||
# selection overlay (cheap) and falls back to a full composite.
|
||||
assert "if (typeof refreshSelection === 'function') refreshSelection();" in PEN
|
||||
assert "else composite();" in PEN
|
||||
assert "refreshSelection: () => _refreshSelectionOverlay()," in EDITOR
|
||||
# A pen close only changes the selection, so it must not re-read every
|
||||
# layer's pixels for the undo snapshot.
|
||||
assert "saveState('Pen selection', { selectionOnly: true });" in PEN
|
||||
assert "const reuse = options.selectionOnly ? state.undoStack.at(-1) : null;" in EDITOR
|
||||
# The preview shows handles only for the anchor being shaped.
|
||||
assert "const shaped = dragging ? points.length - 1 : -1;" in PEN
|
||||
|
||||
|
||||
def test_brush_size_readout_hugs_its_slider():
|
||||
# The px value IS the whole label on the size row; letting the shared 36px
|
||||
# label column force it open leaves a dead gap before its own slider.
|
||||
assert ".ge-brush-size-row label { min-width: 0; }" in CSS
|
||||
|
||||
|
||||
def test_sw_cache_name_bumped():
|
||||
assert re.search(r"CACHE_NAME = 'odysseus-v\d+-pr40-editor-precache'", SW)
|
||||
@@ -0,0 +1,54 @@
|
||||
"""Toolbar flyout affordances: the "more tools" corner triangle must survive
|
||||
face re-renders, and brush-preset rows in the brush flyout show a tip glyph.
|
||||
|
||||
The triangle hint used to be injected into the slot FACE element, so any
|
||||
`setInner(face, id)` re-render (e.g. after picking a flyout member) wiped it and
|
||||
the slot looked like a single tool. It now lives on the slot wrapper, and the
|
||||
brush-preset rows carry a soft/hard/pixel disc reflecting the preset tip.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
import re
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
TOOLBAR = (ROOT / "static/js/editor/build/toolbar.js").read_text(encoding="utf-8")
|
||||
CSS = (ROOT / "static/css/13-image-editor.css").read_text(encoding="utf-8")
|
||||
SW = (ROOT / "static/sw.js").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_flyout_triangle_is_on_the_slot_not_the_face():
|
||||
assert "slotEl.insertAdjacentHTML('beforeend', '<span class=\"ge-tool-flyout-tri\" aria-hidden=\"true\"></span>');" in TOOLBAR
|
||||
assert "const tri = slotEl.querySelector('.ge-tool-flyout-tri');" in TOOLBAR
|
||||
# the hint must not be re-injected into the face (which setInner replaces)
|
||||
assert "face.insertAdjacentHTML('beforeend', '<span class=\"ge-tool-flyout-tri\"" not in TOOLBAR
|
||||
|
||||
|
||||
def test_flyout_triangle_css_is_visible():
|
||||
# The element is a generous transparent hit area; the visible mark is
|
||||
# painted by ::after so it stays easy to click despite being small.
|
||||
i = CSS.index(".ge-tool-flyout-tri {")
|
||||
block = CSS[i:i + 460]
|
||||
assert "width: 18px;" in block
|
||||
assert "height: 18px;" in block
|
||||
j = CSS.index(".ge-tool-flyout-tri::after {")
|
||||
mark = CSS[j:j + 420]
|
||||
assert "border-left: 7px solid transparent;" in mark
|
||||
assert "border-bottom: 7px solid var(--fg-muted, currentColor);" in mark
|
||||
assert "opacity: 0.7;" in mark
|
||||
|
||||
|
||||
def test_brush_preset_rows_have_a_tip_icon():
|
||||
assert "const soft = typeof preset.softness === 'number' ? preset.softness : 100;" in TOOLBAR
|
||||
assert "const presetIcon = preset.pixel" in TOOLBAR
|
||||
assert '<span class="ge-tool-icon">${presetIcon}</span><span class="ge-tool-flyout-label">${preset.name}</span>' in TOOLBAR
|
||||
|
||||
|
||||
def test_flyout_is_clamped_inside_the_viewport():
|
||||
assert "const maxLeft = Math.max(4, window.innerWidth - fw - 4);" in TOOLBAR
|
||||
assert "const maxTop = Math.max(4, window.innerHeight - fh - 4);" in TOOLBAR
|
||||
assert "if (viewLeft > maxLeft) flyout.style.left = (maxLeft - cbX) + 'px';" in TOOLBAR
|
||||
assert "if (viewTop > maxTop) flyout.style.top = (maxTop - cbY) + 'px';" in TOOLBAR
|
||||
|
||||
|
||||
def test_sw_cache_name_bumped():
|
||||
assert re.search(r"odysseus-v\d+-pr40-editor-precache", SW)
|
||||
@@ -58,7 +58,7 @@ def test_mobile_topbar_pins_the_right_actions():
|
||||
|
||||
def test_desktop_tool_rail_keeps_long_tool_names_readable():
|
||||
toolbar_start = STYLE.index(".ge-toolbar {")
|
||||
toolbar_end = STYLE.index(".ge-toolbar::-webkit-scrollbar", toolbar_start)
|
||||
toolbar_end = STYLE.index(".ge-toolbar-scroll::-webkit-scrollbar", toolbar_start)
|
||||
toolbar = STYLE[toolbar_start:toolbar_end]
|
||||
assert "width: 50px;" in toolbar
|
||||
label_rule = re.search(r"(?m)^\.ge-tool-label\s*\{([^}]*)\}", STYLE[toolbar_end:])
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def test_entering_transform_from_a_shortcut_swaps_the_options_strip():
|
||||
# A shortcut must behave like picking the Transform tool: the previous
|
||||
# tool's options (e.g. Text) are hidden so they don't overlap the
|
||||
# transform controls.
|
||||
editor = (ROOT / "static/js/galleryEditor.js").read_text(encoding="utf-8")
|
||||
assert "const _enterTransform = () => {" in editor
|
||||
assert "toolbar.querySelector('.ge-tool-flyout-item[data-tool-option=\"transform\"]')" in editor
|
||||
assert "startTransform: _enterTransform," in editor
|
||||
|
||||
|
||||
def test_transform_entry_shows_a_one_time_hint():
|
||||
editor = (ROOT / "static/js/galleryEditor.js").read_text(encoding="utf-8")
|
||||
assert "const _maybeShowTransformHint = () => {" in editor
|
||||
assert "sessionStorage.getItem('ge-transform-hint')" in editor
|
||||
assert "sessionStorage.setItem('ge-transform-hint', '1');" in editor
|
||||
assert "Free Transform \u2014 Enter applies, Esc cancels. Shortcut: Ctrl+Alt+T" in editor
|
||||
assert "_startTransform();\n _maybeShowTransformHint();" in editor
|
||||
|
||||
|
||||
def test_shortcuts_popover_lists_ctrl_alt_t_for_transform():
|
||||
popups = (ROOT / "static/js/editor/build/popups.js").read_text(encoding="utf-8")
|
||||
assert "<kbd>Ctrl</kbd>+<kbd>Alt</kbd>+<kbd>T</kbd> Free Transform</div>" in popups
|
||||
|
||||
|
||||
def test_transform_hint_is_pinned_to_one_stable_spot():
|
||||
# The hint used to jump between the top-right corner and the full-width
|
||||
# bottom banner depending on what else was open.
|
||||
editor = (ROOT / "static/js/galleryEditor.js").read_text(encoding="utf-8")
|
||||
assert "{ toastClass: 'toast-transform-hint' }" in editor
|
||||
ui = (ROOT / "static/js/ui.js").read_text(encoding="utf-8")
|
||||
assert "if (el.classList.contains('toast-transform-hint')) {" in ui
|
||||
assert "'toast-message-sent', 'toast-transform-hint');" in ui
|
||||
css = (ROOT / "static/css/13-image-editor.css").read_text(encoding="utf-8")
|
||||
block = css[css.index("body.ge-editor-open .toast.toast-transform-hint {"):]
|
||||
block = block[:block.index("}")]
|
||||
assert "bottom: 84px;" in block
|
||||
assert "left: 0;" in block and "right: 0;" in block
|
||||
assert "margin-left: auto;" in block and "margin-right: auto;" in block
|
||||
|
||||
|
||||
def test_transform_preserve_label_stays_on_one_line():
|
||||
css = (ROOT / "static/css/13-image-editor.css").read_text(encoding="utf-8")
|
||||
block = css[css.index("#ge-transform-section .ge-transform-preserve-field {"):]
|
||||
block = block[:block.index("}")]
|
||||
assert "flex: 0 0 auto;" in block
|
||||
assert "white-space: nowrap;" in block
|
||||
assert "#ge-transform-section .ge-transform-preserve-field span { display: none; }" in css
|
||||
popup = (ROOT / "static/js/editor/build/transform-popup.js").read_text(encoding="utf-8")
|
||||
assert 'title="Preserve source pixels (applies the transform non-destructively)"' in popup
|
||||
assert 'aria-label="Preserve source pixels"' in popup
|
||||
@@ -12,7 +12,7 @@ SNAP_MODULE = (ROOT / "static/js/editor/snap.js").as_uri()
|
||||
def run_node(body: str):
|
||||
script = textwrap.dedent(
|
||||
f"""
|
||||
import {{transformFrameGeometry, hitTestTransformHandle, pointInTransformFrame, resizeTransformFrame}} from {json.dumps(MODULE)};
|
||||
import {{transformFrameGeometry, hitTestTransformHandle, pointInTransformFrame, resizeTransformFrame, skewTransformFrame, distortTransformFrame}} from {json.dumps(MODULE)};
|
||||
{body}
|
||||
"""
|
||||
)
|
||||
@@ -220,3 +220,121 @@ def test_shift_alt_corner_resize_is_centered_and_aspect_locked():
|
||||
"flipH": False,
|
||||
"flipV": False,
|
||||
}
|
||||
|
||||
|
||||
def test_skew_drag_shears_the_dragged_edge_only():
|
||||
result = run_node(
|
||||
"""
|
||||
const frame={centerX:0,centerY:0,width:200,height:100,rotation:0};
|
||||
const right=skewTransformFrame(frame,'r',{x:0,y:0},{x:0,y:40});
|
||||
const top=skewTransformFrame(frame,'t',{x:0,y:0},{x:50,y:0});
|
||||
const corner=skewTransformFrame(frame,'br',{x:0,y:0},{x:30,y:20});
|
||||
const none=skewTransformFrame(frame,'r',{x:0,y:0},{x:0,y:0});
|
||||
console.log(JSON.stringify({right,top,corner,none}));
|
||||
"""
|
||||
)
|
||||
# Right edge dragged down -> skew Y only; top edge dragged right ->
|
||||
# skew X only; corner -> both; no drag -> unchanged.
|
||||
assert result["right"]["skewX"] == 0
|
||||
assert abs(result["right"]["skewY"] - 21.8) < 0.5
|
||||
assert abs(result["top"]["skewX"] - (-45.0)) < 0.5
|
||||
assert result["top"]["skewY"] == 0
|
||||
assert abs(result["corner"]["skewX"] - 30.96) < 0.5
|
||||
assert abs(result["corner"]["skewY"] - 11.31) < 0.5
|
||||
assert result["none"] == {"skewX": 0, "skewY": 0}
|
||||
|
||||
|
||||
def test_skew_drag_is_relative_to_the_start_and_clamped():
|
||||
result = run_node(
|
||||
"""
|
||||
const frame={centerX:0,centerY:0,width:100,height:100,rotation:0};
|
||||
const grown=skewTransformFrame({...frame,height:100},'b',{x:0,y:0},{x:10,y:0},{startSkewX:20,startSkewY:0});
|
||||
const huge=skewTransformFrame({...frame,height:1},'b',{x:0,y:0},{x:9000,y:0});
|
||||
console.log(JSON.stringify({grown,huge}));
|
||||
"""
|
||||
)
|
||||
assert result["grown"]["skewX"] > 20 # start offset + drag delta
|
||||
assert result["huge"]["skewX"] == 85 # clamped below the singular 90°
|
||||
|
||||
|
||||
|
||||
def test_distort_drag_moves_only_the_dragged_corner():
|
||||
result = run_node(
|
||||
"""
|
||||
const frame={centerX:0,centerY:0,width:100,height:100,rotation:0};
|
||||
const corner=distortTransformFrame(frame,'br',{x:0,y:0},{x:10,y:20});
|
||||
console.log(JSON.stringify({corner}));
|
||||
"""
|
||||
)
|
||||
assert result["corner"] == {
|
||||
"tl": {"x": 0, "y": 0},
|
||||
"tr": {"x": 0, "y": 0},
|
||||
"br": {"x": 0.1, "y": 0.2},
|
||||
"bl": {"x": 0, "y": 0},
|
||||
}
|
||||
|
||||
|
||||
def test_distort_drag_moves_only_the_dragged_edge_corners():
|
||||
result = run_node(
|
||||
"""
|
||||
const frame={centerX:0,centerY:0,width:100,height:100,rotation:0};
|
||||
const edge=distortTransformFrame(frame,'t',{x:0,y:0},{x:5,y:-10});
|
||||
console.log(JSON.stringify({edge}));
|
||||
"""
|
||||
)
|
||||
assert result["edge"] == {
|
||||
"tl": {"x": 0.05, "y": -0.1},
|
||||
"tr": {"x": 0.05, "y": -0.1},
|
||||
"br": {"x": 0, "y": 0},
|
||||
"bl": {"x": 0, "y": 0},
|
||||
}
|
||||
|
||||
|
||||
def test_distort_ignores_rotation_and_move_handles():
|
||||
result = run_node(
|
||||
"""
|
||||
const frame={centerX:0,centerY:0,width:100,height:100,rotation:0};
|
||||
console.log(JSON.stringify({
|
||||
rot: distortTransformFrame(frame,'rot',{x:0,y:0},{x:30,y:0}),
|
||||
move: distortTransformFrame(frame,'move',{x:0,y:0},{x:30,y:0}),
|
||||
}));
|
||||
"""
|
||||
)
|
||||
assert result["rot"] is None
|
||||
assert result["move"] is None
|
||||
|
||||
|
||||
def test_distort_is_clamped_and_relative_to_the_start():
|
||||
result = run_node(
|
||||
"""
|
||||
const frame={centerX:0,centerY:0,width:100,height:100,rotation:0};
|
||||
const huge=distortTransformFrame(frame,'br',{x:0,y:0},{x:900,y:0});
|
||||
const grown=distortTransformFrame(frame,'br',{x:0,y:0},{x:10,y:0},{startDistort:{tl:{x:0,y:0},tr:{x:0,y:0},br:{x:0.5,y:0},bl:{x:0,y:0}}});
|
||||
console.log(JSON.stringify({huge: huge.br.x, grown: grown.br.x}));
|
||||
"""
|
||||
)
|
||||
assert result["huge"] == 4 # clamped to the ±4 frame-normal limit
|
||||
assert abs(result["grown"] - 0.6) < 1e-9 # start offset + drag delta
|
||||
|
||||
|
||||
def test_geometry_places_handles_on_the_distorted_quad():
|
||||
result = run_node(
|
||||
"""
|
||||
const geometry=transformFrameGeometry({centerX:0,centerY:0,width:100,height:100,rotation:0,distort:{tl:{x:0,y:0},tr:{x:0,y:0},br:{x:0.1,y:0.2},bl:{x:0,y:0}}},{zoom:1});
|
||||
console.log(JSON.stringify({
|
||||
corners: geometry.corners,
|
||||
handles: Object.fromEntries(geometry.resizeHandles.map(h=>[h.id,[h.x,h.y]])),
|
||||
}));
|
||||
"""
|
||||
)
|
||||
assert result["corners"] == {
|
||||
"tl": {"x": -50, "y": -50},
|
||||
"tr": {"x": 50, "y": -50},
|
||||
"br": {"x": 60, "y": 70},
|
||||
"bl": {"x": -50, "y": 50},
|
||||
}
|
||||
assert result["handles"]["br"] == [60, 70]
|
||||
assert result["handles"]["t"] == [0, -50]
|
||||
assert result["handles"]["r"] == [55, 10]
|
||||
assert result["handles"]["b"] == [5, 60]
|
||||
assert result["handles"]["l"] == [-50, 0]
|
||||
|
||||
@@ -11,6 +11,9 @@ STATE = read("static/js/editor/state.js")
|
||||
POPUP = read("static/js/editor/build/transform-popup.js")
|
||||
SESSION = read("static/js/editor/tools/transform-session.js")
|
||||
MULTI = read("static/js/editor/multi-transform.js")
|
||||
GEO = read("static/js/editor/transform-frame-geometry.js")
|
||||
HANDLES = read("static/js/editor/tools/transform-handles.js")
|
||||
DRAG = read("static/js/editor/tools/transform-drag.js")
|
||||
|
||||
|
||||
def test_state_has_skew_fields():
|
||||
@@ -42,3 +45,88 @@ def test_multitransform_geometry_carries_skew():
|
||||
assert "skewPadY" in MULTI
|
||||
assert "skewX," in MULTI
|
||||
assert "skewY," in MULTI
|
||||
|
||||
|
||||
def test_geometry_applies_local_shear():
|
||||
assert "export function skewTransformFrame(frame, handle, startPoint, currentPoint, options = {}) {" in GEO
|
||||
assert "const shearX = Math.tan(skewX * Math.PI / 180);" in GEO
|
||||
assert "const shearY = Math.tan(skewY * Math.PI / 180);" in GEO
|
||||
assert "if (ySign) skewX = clamp(startSkewX" in GEO
|
||||
assert "if (xSign) skewY = clamp(startSkewY" in GEO
|
||||
|
||||
|
||||
def test_ctrl_drag_skews_the_grabbed_handle():
|
||||
assert "import { resizeTransformFrame, skewTransformFrame, distortTransformFrame } from '../transform-frame-geometry.js';" in DRAG
|
||||
assert "state.transformStartSkewX = state.transformPendingSkewX || 0;" in DRAG
|
||||
assert "state.transformStartSkewY = state.transformPendingSkewY || 0;" in DRAG
|
||||
assert "if (e.ctrlKey || e.metaKey) {" in DRAG
|
||||
assert "skewTransformFrame({" in DRAG
|
||||
assert "state.transformPendingSkewX = skewed.skewX;" in DRAG
|
||||
assert "state.transformPendingSkewY = skewed.skewY;" in DRAG
|
||||
|
||||
|
||||
def test_handles_thread_skew_and_grow_the_overlay_past_the_image():
|
||||
# transformFrame() must feed skew into the shared geometry...
|
||||
assert "skewX: state.transformPendingSkewX || 0," in HANDLES
|
||||
assert "skewY: state.transformPendingSkewY || 0," in HANDLES
|
||||
# ...and the overlay must be sized to the frame, not just the image.
|
||||
assert "export function syncOverlay(margin, extents = null) {" in HANDLES
|
||||
assert "const cover = syncOverlay(margin, frameExtents(geometry));" in HANDLES
|
||||
assert "function frameExtents(geometry) {" in HANDLES
|
||||
assert "ctx.translate(-cover.left, -cover.top);" in HANDLES
|
||||
assert "const MAX_BITMAP = 8192;" in HANDLES
|
||||
|
||||
|
||||
def test_transform_controls_live_in_the_header_strip():
|
||||
css = read("static/css/13-image-editor.css")
|
||||
controls = read("static/js/editor/build/controls.js")
|
||||
assert 'class="ge-transform-section" id="ge-transform-section" style="display:none;"' in controls
|
||||
assert "const pop = document.getElementById('ge-transform-section');" in SESSION
|
||||
assert "pop.innerHTML = `<div class=\"ge-transform-popup\">${transformPopupHTML()}</div>`;" in SESSION
|
||||
assert "state.container.appendChild(pop)" not in SESSION
|
||||
assert "pop.className = 'ge-transform-popup'" not in SESSION
|
||||
assert "state.transformPopup.style.display = 'none';" in SESSION
|
||||
assert "#ge-transform-section .ge-transform-popup," in css
|
||||
assert "display: contents;" in css
|
||||
# The head's `display: flex !important` (for the floating popup) must be
|
||||
# out-ranked so the wrapper flattens into the strip's single row.
|
||||
assert "#ge-transform-section .ge-transform-popup-head { display: contents !important; }" in css
|
||||
# The floating popup spaced its buttons off the fields; that stray top
|
||||
# margin must be neutralised or the strip row grows taller than the bar.
|
||||
assert "#ge-transform-section button { margin-top: 0 !important; }" in css
|
||||
|
||||
|
||||
def test_ctrl_drag_distorts_only_the_dragged_point():
|
||||
# New state slot + reset + target round-trip.
|
||||
assert "transformPendingDistort: null," in STATE
|
||||
assert "distort: state.transformPendingDistort," in SESSION
|
||||
assert "state.transformPendingDistort = target.distort || null;" in SESSION
|
||||
# The session renders the warped quad.
|
||||
assert "if (geometry.distort && geometry.quad) {" in SESSION
|
||||
assert "perspectiveWarpCanvas(source, normalized, canvas.width, canvas.height)" in SESSION
|
||||
# Geometry exposes the pure distort helper + places handles on the quad.
|
||||
assert "export function distortTransformFrame(frame, handle, startPoint, currentPoint, options = {}) {" in GEO
|
||||
assert "const corners = ['tl', 'tr', 'br', 'bl'];" in GEO
|
||||
assert "tl: ['tl'], tr: ['tr'], br: ['br'], bl: ['bl']," in GEO
|
||||
assert "t: ['tl', 'tr'], b: ['bl', 'br'], l: ['tl', 'bl'], r: ['tr', 'br']," in GEO
|
||||
assert "}[handle];" in GEO
|
||||
assert "const LIMIT = 4;" in GEO
|
||||
# Handles thread the quad through to the skeleton.
|
||||
assert "distort: state.transformPendingDistort || null," in HANDLES
|
||||
# Multi-transform sizes the layer to the quad AABB.
|
||||
assert "const distort = target.distort || null;" in MULTI
|
||||
# Drag: Ctrl grabs a corner/edge -> distort (only that point); skew stays for rot.
|
||||
assert "import { resizeTransformFrame, skewTransformFrame, distortTransformFrame } from '../transform-frame-geometry.js';" in DRAG
|
||||
assert "state.transformStartDistort = state.transformPendingDistort || null;" in DRAG
|
||||
assert "const distorted = distortTransformFrame({" in DRAG
|
||||
assert "state.transformPendingDistort = distorted;" in DRAG
|
||||
|
||||
|
||||
def test_distort_bakes_into_the_raster_instead_of_the_placed_source():
|
||||
# A free-distort quad is a non-affine warp; placed-layer data is affine
|
||||
# only, so the session must fall back to the raster branch for it.
|
||||
assert "const usePlaced = snapshot.placed" in SESSION
|
||||
assert "&& (snapshot.wasPlaced || state.transformPreserveSource !== false)" in SESSION
|
||||
assert "&& !plannedGeometry.distort;" in SESSION
|
||||
assert "if (usePlaced) {" in SESSION
|
||||
assert "if (snapshot.kind === 'raster' || plannedGeometry.distort) {" in SESSION
|
||||
|
||||
@@ -18,11 +18,12 @@ STYLE = (ROOT / "static/css/13-image-editor.css").read_text(encoding="utf-8")
|
||||
SW = (ROOT / "static/sw.js").read_text(encoding="utf-8")
|
||||
|
||||
WINDOW_ACTIONS = {
|
||||
"quick-edit",
|
||||
"options", "layers", "properties", "character", "adjustments",
|
||||
"swatches", "gradients", "channels", "paths", "patterns", "histogram",
|
||||
"info", "navigator", "history", "actions",
|
||||
"workspace-essentials", "workspace-photography", "workspace-painting",
|
||||
"workspace-graphic", "shortcuts",
|
||||
"workspace-graphic", "workspace-minimal", "workspace-custom", "shortcuts",
|
||||
}
|
||||
|
||||
|
||||
@@ -48,8 +49,12 @@ def test_window_menu_is_a_real_toggle_kept_open():
|
||||
assert "function _syncWindowMenuChecks(menu)" in GALLERY
|
||||
assert "panelVisibility.isVisible(action)" in GALLERY
|
||||
assert "state.historyPanelEl" in GALLERY
|
||||
assert "ge-shortcuts-popover" in GALLERY
|
||||
assert "_shortcutsPopover?.isOpen?.()" in GALLERY
|
||||
assert "togglePanel: (id) => panelVisibility.toggle(id)," in GALLERY
|
||||
# Quick Edit has no dock panel: the Window row mirrors its collapse control.
|
||||
assert "else if (action === 'quick-edit') toggleQuickEdit?.();" in WIRE
|
||||
assert "wrap.dispatchEvent(new CustomEvent(collapsed ? 'ge-ai-command-open' : 'ge-ai-command-close'));" in GALLERY
|
||||
assert "on = !!wrap && !wrap.classList.contains('ge-ai-command-collapsed');" in GALLERY
|
||||
|
||||
|
||||
def test_history_row_stays_toggleable():
|
||||
@@ -74,3 +79,32 @@ def test_window_dot_uses_shared_active_glyph_css():
|
||||
|
||||
def test_sw_cache_name_bumped_for_window_dots():
|
||||
assert re.search(r"odysseus-v\d+-pr40-editor-precache", SW)
|
||||
|
||||
|
||||
def test_shortcuts_row_toggles_off():
|
||||
# The shortcuts popover dismisses itself on document mousedown; a click on
|
||||
# a topbar dropdown row (the Window menu "Keyboard Shortcuts" row) must not
|
||||
# count as click-away, or the row's own click would immediately reopen it
|
||||
# and the row could only ever turn it on.
|
||||
POPOVER = (ROOT / "static/js/editor/shortcuts-popover.js").read_text(encoding="utf-8")
|
||||
assert "e.target.closest('.ge-topbar .dropdown')" in POPOVER
|
||||
|
||||
|
||||
def test_workspace_choice_closes_the_window_menu():
|
||||
assert "const closeWindowMenu = () => {" in WIRE
|
||||
assert "menu.hidden = true;" in WIRE
|
||||
# both the preset rows and the Save Current row close the menu
|
||||
assert "closeWindowMenu();" in WIRE
|
||||
|
||||
|
||||
def test_save_current_workspace_option():
|
||||
assert 'data-window-action="workspace-save"' in TOPBAR
|
||||
assert "<span>Save Current</span>" in TOPBAR
|
||||
assert 'data-window-action="workspace-custom"' in TOPBAR
|
||||
assert "saveWorkspace?.();" in WIRE
|
||||
assert "saveWorkspace: _saveCurrentWorkspace," in GALLERY
|
||||
assert "const _saveCurrentWorkspace = () => {" in GALLERY
|
||||
assert "localStorage.setItem('ge-workspace-custom'" in GALLERY
|
||||
# applyWorkspace resolves a user-saved slot from localStorage
|
||||
WS = (ROOT / "static/js/editor/workspaces.js").read_text(encoding="utf-8")
|
||||
assert "localStorage.getItem(`ge-workspace-${name}`)" in WS
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Window > Workspace submenu: a nested flyout with 4 window setups.
|
||||
"""Window > Workspace submenu: a nested flyout with the window setups.
|
||||
|
||||
The Window dropdown used to list the 4 workspace presets as flat rows. They are
|
||||
now a `.ge-menu-parent` flyout ("Workspace") whose 4 children set up the editor
|
||||
The Window dropdown used to list the workspace presets as flat rows. They are
|
||||
now a `.ge-menu-parent` flyout ("Workspace") whose children set up the editor
|
||||
windows (dock panels + tab + right-panel width). The submenu open/close toggle
|
||||
is the shared `wireSubmenuToggles` helper (also used by Image/Filter/Selection).
|
||||
"""
|
||||
@@ -23,8 +23,8 @@ SW = (ROOT / "static/sw.js").read_text(encoding="utf-8")
|
||||
def test_window_menu_has_a_workspace_flyout_parent():
|
||||
assert 'class="dropdown-item-compact ge-menu-parent" data-submenu="window-workspace"' in TOPBAR
|
||||
assert '<div class="ge-submenu" data-submenu-panel="window-workspace">' in TOPBAR
|
||||
# the 4 variants still carry their window-action ids
|
||||
for name in ("essentials", "photography", "painting", "graphic"):
|
||||
# the variants still carry their window-action ids
|
||||
for name in ("essentials", "photography", "painting", "graphic", "minimal"):
|
||||
assert f'data-window-action="workspace-{name}"' in TOPBAR
|
||||
|
||||
|
||||
@@ -39,13 +39,24 @@ def test_submenu_toggle_helper_is_shared():
|
||||
|
||||
def test_workspaces_set_up_the_windows():
|
||||
# every preset declares the panels it shows
|
||||
assert WORKSPACES.count("panels:") == 4
|
||||
assert WORKSPACES.count("panels:") == 5
|
||||
assert "setPanelVisible" in WORKSPACES
|
||||
assert "PANEL_IDS" in WORKSPACES
|
||||
# the "minimal" preset is just layers + history
|
||||
assert "minimal: Object.freeze({ label: 'Minimal'" in WORKSPACES
|
||||
assert "panels: Object.freeze(['layers', 'history'])" in WORKSPACES
|
||||
# composer hands the panel-visibility setter into applyWorkspace
|
||||
assert "setPanelVisible: (id, visible) => panelVisibility.setVisible(id, visible)" in GALLERY
|
||||
|
||||
|
||||
def test_active_workspace_is_remembered_and_restored_on_open():
|
||||
# the chosen workspace is restored from localStorage when the editor opens
|
||||
assert "localStorage.getItem('ge-active-workspace')" in GALLERY
|
||||
restore = GALLERY[GALLERY.index("Remember the workspace/window layout"):]
|
||||
assert "_applyWorkspace(savedWorkspace" in restore
|
||||
assert "rightPanel: window.innerWidth > 700 ? rightPanel : null" in restore
|
||||
|
||||
|
||||
def test_workspace_flyout_releases_the_menu_clip():
|
||||
assert ".ge-window-menu:has(.ge-menu-parent.open)" in CSS
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ from src.clean_agent_preview import (
|
||||
from src.turn_contract import (
|
||||
editor_request_instructions, requested_capabilities, selected_tools_for_request,
|
||||
preserve_bound_editor_selected_tools, resolve_turn_contract,
|
||||
targets_bound_editor_request,
|
||||
)
|
||||
from src.tool_schemas import FUNCTION_TOOL_SCHEMAS
|
||||
from src.tool_policy import ToolPolicy
|
||||
@@ -72,6 +73,16 @@ def test_direct_edits_share_the_capability_router(prompt):
|
||||
assert not active_editor_suggestion_request(doc, prompt)
|
||||
|
||||
|
||||
@pytest.mark.parametrize('prompt', [
|
||||
'ga mer ut pa att vi tanker pa framtid for bjorn och att vi faktist trivs sa bra har',
|
||||
'Go more into how we are thinking about the future.',
|
||||
])
|
||||
def test_implicit_revision_followups_route_to_active_document_only(prompt):
|
||||
assert requested_capabilities(prompt, active_document=True) == {'documents'}
|
||||
assert requested_capabilities(prompt, active_document=False) == frozenset()
|
||||
assert targets_bound_editor_request(prompt)
|
||||
|
||||
|
||||
def test_source_review_still_requires_tool_completion_after_research():
|
||||
schemas = [s for s in FUNCTION_TOOL_SCHEMAS if s['function']['name'] in {'web_search', 'suggest_document'}]
|
||||
assert required_active_editor_tool_choice(active_editor_target=True, suggestion_target=True,
|
||||
|
||||
@@ -0,0 +1,515 @@
|
||||
"""User-supplied IMAP/SMTP hosts must pass the outbound host guard.
|
||||
|
||||
Regression: POST /api/email/accounts/test opened raw ``imaplib`` / ``smtplib``
|
||||
connections to whatever host:port the caller supplied, and the account save
|
||||
routes persisted arbitrary hosts for the background pollers to contact. Both
|
||||
are reachable by any authenticated user (``require_user`` / ``require_owner``,
|
||||
no admin gate), so a non-admin could port-scan loopback, the LAN and the cloud
|
||||
metadata range through the server and read the first 200 chars of the banner
|
||||
back out of the error message. THREAT_MODEL.md lists email as admin-only.
|
||||
|
||||
Policy (see routes/email_routes.py:_check_mail_host): link-local (metadata),
|
||||
multicast, reserved and unspecified targets are always rejected; loopback and
|
||||
private ranges are rejected for non-admin owners or when
|
||||
EMAIL_BLOCK_PRIVATE_IPS=true, and stay allowed for admins (local-first: a LAN
|
||||
Dovecot is a normal setup). Unresolved names are rejected before persistence;
|
||||
live connections also validate and pin their own resolved addresses.
|
||||
"""
|
||||
|
||||
import socket
|
||||
import unittest.mock as mock
|
||||
|
||||
import pytest
|
||||
|
||||
from src.url_safety import check_outbound_host, connect_outbound_tcp, OutboundAddressBlocked
|
||||
|
||||
|
||||
# ── helper policy table ──────────────────────────────────────────────────────
|
||||
|
||||
@pytest.mark.parametrize("host", [
|
||||
"169.254.169.254",
|
||||
"[::ffff:169.254.169.254]",
|
||||
"::ffff:169.254.169.254",
|
||||
"fe80::1",
|
||||
"0.0.0.0",
|
||||
"224.0.0.1",
|
||||
"100.100.100.200",
|
||||
"[100.100.100.200]",
|
||||
])
|
||||
def test_always_blocked_addresses(host):
|
||||
ok, reason = check_outbound_host(host)
|
||||
assert ok is False, (host, reason)
|
||||
ok, reason = check_outbound_host(host, block_private=False)
|
||||
assert ok is False
|
||||
|
||||
|
||||
@pytest.mark.parametrize("host", [
|
||||
"127.0.0.1",
|
||||
"::1",
|
||||
"[::1]",
|
||||
"10.0.0.5",
|
||||
"192.168.1.10",
|
||||
"100.64.1.1",
|
||||
"localhost",
|
||||
"LOCALHOST.",
|
||||
"mail.localhost",
|
||||
"ip6-localhost",
|
||||
])
|
||||
def test_private_and_loopback_gated_by_block_private(host):
|
||||
ok, _ = check_outbound_host(host, block_private=True)
|
||||
assert ok is False, host
|
||||
ok, _ = check_outbound_host(host, block_private=False)
|
||||
assert ok is True, host
|
||||
|
||||
|
||||
@pytest.mark.parametrize("host", ["host:993", "http://mail.example.com", "user@mail.example.com", "mail.example.com/", "a b", "", " "])
|
||||
def test_non_bare_hosts_rejected(host):
|
||||
ok, _ = check_outbound_host(host, resolver=lambda h: ["93.184.216.34"])
|
||||
assert ok is False, host
|
||||
|
||||
|
||||
def test_nonstring_rejected():
|
||||
assert check_outbound_host(None)[0] is False # type: ignore[arg-type]
|
||||
assert check_outbound_host(123)[0] is False # type: ignore[arg-type]
|
||||
|
||||
|
||||
def test_resolved_name_is_classified():
|
||||
ok, reason = check_outbound_host("metadata.google.internal", resolver=lambda h: ["169.254.169.254"])
|
||||
assert ok is False and "link-local" in reason
|
||||
ok, _ = check_outbound_host("mail.corp", block_private=True, resolver=lambda h: ["10.1.2.3"])
|
||||
assert ok is False
|
||||
ok, _ = check_outbound_host("mail.corp", block_private=False, resolver=lambda h: ["10.1.2.3"])
|
||||
assert ok is True
|
||||
ok, _ = check_outbound_host("imap.example.org", block_private=True, resolver=lambda h: ["93.184.216.34"])
|
||||
assert ok is True
|
||||
|
||||
|
||||
def test_unresolved_policy():
|
||||
def _nx(h):
|
||||
raise OSError("Name or service not known")
|
||||
assert check_outbound_host("nope.invalid", resolver=_nx)[0] is False
|
||||
assert check_outbound_host("nope.invalid", resolver=lambda h: [])[0] is False
|
||||
|
||||
|
||||
# ── route wiring ─────────────────────────────────────────────────────────────
|
||||
|
||||
def _route(router, path, method):
|
||||
for r in router.routes:
|
||||
if r.path == path and method in getattr(r, "methods", set()):
|
||||
return r.endpoint
|
||||
raise AssertionError(f"route not found: {method} {path}")
|
||||
|
||||
|
||||
def _make_orm_db():
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from core.database import Base
|
||||
engine = create_engine("sqlite:///:memory:", connect_args={"check_same_thread": False})
|
||||
Base.metadata.create_all(engine)
|
||||
Factory = sessionmaker(bind=engine)
|
||||
return Factory
|
||||
|
||||
|
||||
def _set_admin(monkeypatch, is_admin: bool):
|
||||
monkeypatch.setattr("src.tool_security.owner_is_admin_or_single_user", lambda owner: is_admin)
|
||||
monkeypatch.delenv("EMAIL_BLOCK_PRIVATE_IPS", raising=False)
|
||||
monkeypatch.delenv("EMAIL_ALLOW_PRIVATE_IPS", raising=False)
|
||||
|
||||
|
||||
class _Req:
|
||||
def __init__(self, body):
|
||||
self._body = body
|
||||
|
||||
async def json(self):
|
||||
return self._body
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_test_connection_rejects_loopback_for_non_admin_before_any_socket(monkeypatch):
|
||||
import routes.email_routes as email_routes
|
||||
_set_admin(monkeypatch, False)
|
||||
router = email_routes.setup_email_routes()
|
||||
test_conn = _route(router, "/api/email/accounts/test", "POST")
|
||||
|
||||
body = {
|
||||
"imap_host": "127.0.0.1", "imap_port": 11434, "imap_user": "x", "imap_password": "x",
|
||||
"imap_starttls": False,
|
||||
"smtp_host": "169.254.169.254", "smtp_port": 80, "smtp_security": "none",
|
||||
}
|
||||
with mock.patch("socket.socket") as create_socket:
|
||||
result = await test_conn(req=_Req(body), owner="regular-user")
|
||||
|
||||
assert result["ok"] is False
|
||||
assert ("Rejected IMAP host" in result["imap"]["error"] or "server address is not allowed" in result["imap"]["error"])
|
||||
assert ("Rejected SMTP host" in result["smtp"]["error"] or "server address is not allowed" in result["smtp"]["error"])
|
||||
create_socket.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_test_connection_metadata_ip_rejected_even_for_admin(monkeypatch):
|
||||
import routes.email_routes as email_routes
|
||||
_set_admin(monkeypatch, True)
|
||||
router = email_routes.setup_email_routes()
|
||||
test_conn = _route(router, "/api/email/accounts/test", "POST")
|
||||
|
||||
body = {"imap_host": "169.254.169.254", "imap_port": 80, "imap_user": "x", "imap_password": "x", "imap_starttls": True}
|
||||
with mock.patch("socket.socket") as create_socket:
|
||||
result = await test_conn(req=_Req(body), owner="admin")
|
||||
assert result["ok"] is False
|
||||
assert ("link-local" in result["imap"]["error"] or "server address is not allowed" in result["imap"]["error"])
|
||||
create_socket.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_test_connection_admin_may_use_loopback(monkeypatch):
|
||||
"""Local-first: an admin's Dovecot on localhost keeps working."""
|
||||
import routes.email_routes as email_routes
|
||||
_set_admin(monkeypatch, True)
|
||||
router = email_routes.setup_email_routes()
|
||||
test_conn = _route(router, "/api/email/accounts/test", "POST")
|
||||
|
||||
body = {"imap_host": "127.0.0.1", "imap_port": 31143, "imap_user": "x", "imap_password": "x", "imap_starttls": False}
|
||||
with mock.patch("routes.email_routes._open_imap_connection", return_value=mock.MagicMock()) as imap_open:
|
||||
result = await test_conn(req=_Req(body), owner="admin")
|
||||
assert result["imap"]["ok"] is True
|
||||
imap_open.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_account_rejects_metadata_host_before_db(monkeypatch):
|
||||
import routes.email_routes as email_routes
|
||||
_set_admin(monkeypatch, True)
|
||||
router = email_routes.setup_email_routes()
|
||||
create = _route(router, "/api/email/accounts", "POST")
|
||||
|
||||
def _no_db():
|
||||
raise AssertionError("DB must not be touched for a rejected host")
|
||||
|
||||
with mock.patch("core.database.SessionLocal", _no_db):
|
||||
result = await create(
|
||||
{"name": "Backup", "imap_host": "169.254.169.254", "imap_user": "u", "imap_password": "p"},
|
||||
owner="admin",
|
||||
)
|
||||
assert result["ok"] is False
|
||||
assert "Rejected IMAP host" in result["error"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_account_rejects_private_host_for_non_admin(monkeypatch):
|
||||
import routes.email_routes as email_routes
|
||||
_set_admin(monkeypatch, False)
|
||||
router = email_routes.setup_email_routes()
|
||||
create = _route(router, "/api/email/accounts", "POST")
|
||||
with mock.patch("core.database.SessionLocal", _make_orm_db()):
|
||||
result = await create(
|
||||
{"name": "Scan", "imap_host": "93.184.216.34", "smtp_host": "10.0.0.5", "imap_user": "u", "imap_password": "p"},
|
||||
owner="regular-user",
|
||||
)
|
||||
assert result["ok"] is False
|
||||
assert "Rejected SMTP host" in result["error"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_account_admin_private_host_allowed_unless_locked_down(monkeypatch):
|
||||
import routes.email_routes as email_routes
|
||||
_set_admin(monkeypatch, True)
|
||||
router = email_routes.setup_email_routes()
|
||||
create = _route(router, "/api/email/accounts", "POST")
|
||||
payload = {"name": "LAN", "imap_host": "192.168.1.10", "imap_user": "u", "imap_password": "p"}
|
||||
|
||||
with mock.patch("core.database.SessionLocal", _make_orm_db()):
|
||||
result = await create(dict(payload), owner="admin")
|
||||
assert result["ok"] is True
|
||||
|
||||
monkeypatch.setenv("EMAIL_BLOCK_PRIVATE_IPS", "true")
|
||||
with mock.patch("core.database.SessionLocal", _make_orm_db()):
|
||||
result = await create(dict(payload), owner="admin")
|
||||
assert result["ok"] is False
|
||||
assert "Rejected IMAP host" in result["error"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_account_rejects_bad_host_and_keeps_row(monkeypatch):
|
||||
import routes.email_routes as email_routes
|
||||
from core.database import EmailAccount
|
||||
_set_admin(monkeypatch, False)
|
||||
Factory = _make_orm_db()
|
||||
db = Factory()
|
||||
db.add(EmailAccount(id="acct-1", owner="regular-user", name="Mine", imap_host="imap.gmail.com", imap_port=993))
|
||||
db.commit()
|
||||
db.close()
|
||||
|
||||
router = email_routes.setup_email_routes()
|
||||
update = _route(router, "/api/email/accounts/{account_id}", "PUT")
|
||||
with mock.patch("core.database.SessionLocal", Factory), \
|
||||
mock.patch("routes.email_routes._assert_owns_account", lambda account_id, owner: None):
|
||||
result = await update("acct-1", {"imap_host": "127.0.0.1", "imap_port": 8000}, owner="regular-user")
|
||||
assert result["ok"] is False
|
||||
assert "Rejected IMAP host" in result["error"]
|
||||
|
||||
db = Factory()
|
||||
try:
|
||||
assert db.get(EmailAccount, "acct-1").imap_host == "imap.gmail.com"
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_config_rejects_bad_host(monkeypatch):
|
||||
import routes.email_routes as email_routes
|
||||
_set_admin(monkeypatch, False)
|
||||
router = email_routes.setup_email_routes()
|
||||
update_cfg = _route(router, "/api/email/config", "PUT")
|
||||
|
||||
def _no_db():
|
||||
raise AssertionError("DB must not be touched for a rejected host")
|
||||
|
||||
with mock.patch("core.database.SessionLocal", _no_db), \
|
||||
mock.patch("routes.email_routes._load_settings", _no_db):
|
||||
result = await update_cfg({"smtp_host": "10.0.0.1", "smtp_port": 6379}, account_id=None, owner="regular-user")
|
||||
assert result["ok"] is False
|
||||
assert "Rejected SMTP host" in result["error"]
|
||||
|
||||
|
||||
# ── adversarial runtime & connection-time tests ─────────────────────────────
|
||||
|
||||
def test_connect_outbound_tcp_blocks_dns_rebinding():
|
||||
with mock.patch("socket.getaddrinfo", return_value=[(socket.AF_INET, socket.SOCK_STREAM, 6, "", ("169.254.169.254", 993))]):
|
||||
with pytest.raises(OutboundAddressBlocked) as exc_info:
|
||||
connect_outbound_tcp("rebind.example.org", 993, block_private=False)
|
||||
assert "link-local" in str(exc_info.value)
|
||||
|
||||
|
||||
def test_connect_outbound_tcp_multiple_ips_fails_closed():
|
||||
mixed_addrs = [
|
||||
(socket.AF_INET, socket.SOCK_STREAM, 6, "", ("93.184.216.34", 993)),
|
||||
(socket.AF_INET, socket.SOCK_STREAM, 6, "", ("10.0.0.1", 993)),
|
||||
]
|
||||
with mock.patch("socket.getaddrinfo", return_value=mixed_addrs):
|
||||
with pytest.raises(OutboundAddressBlocked) as exc_info:
|
||||
connect_outbound_tcp("dual.example.org", 993, block_private=True)
|
||||
assert "private/shared/loopback" in str(exc_info.value)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_test_connection_endpoint_dns_rebinding_blocked(monkeypatch):
|
||||
"""If DNS changes between save-check and connection, connect-time validation stops it."""
|
||||
import routes.email_routes as email_routes
|
||||
_set_admin(monkeypatch, False)
|
||||
router = email_routes.setup_email_routes()
|
||||
test_conn = _route(router, "/api/email/accounts/test", "POST")
|
||||
|
||||
body = {
|
||||
"imap_host": "rebind.example.org", "imap_port": 993, "imap_user": "u", "imap_password": "p",
|
||||
"imap_starttls": False,
|
||||
}
|
||||
rebinding_addrs = [(socket.AF_INET, socket.SOCK_STREAM, 6, "", ("127.0.0.1", 993))]
|
||||
with mock.patch("socket.getaddrinfo", return_value=rebinding_addrs), \
|
||||
mock.patch("socket.socket") as create_socket:
|
||||
result = await test_conn(req=_Req(body), owner="regular-user")
|
||||
|
||||
assert result["ok"] is False
|
||||
assert ("Rejected IMAP host" in result["imap"]["error"] or "server address is not allowed" in result["imap"]["error"])
|
||||
create_socket.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_runtime_imap_connect_blocks_preexisting_db_row(monkeypatch):
|
||||
"""A database row with private host (preexisting, restored, or DB-injected) fails closed at runtime."""
|
||||
import routes.email_helpers as email_helpers
|
||||
from core.database import EmailAccount
|
||||
_set_admin(monkeypatch, False)
|
||||
Factory = _make_orm_db()
|
||||
db = Factory()
|
||||
db.add(EmailAccount(id="acct-malicious", owner="regular-user", name="Evil", imap_host="127.0.0.1", imap_port=993, imap_user="u", imap_password="p"))
|
||||
db.commit()
|
||||
db.close()
|
||||
|
||||
with mock.patch("core.database.SessionLocal", Factory):
|
||||
with pytest.raises(OutboundAddressBlocked) as exc_info:
|
||||
email_helpers._imap_connect("acct-malicious", owner="regular-user")
|
||||
assert "private/shared/loopback" in str(exc_info.value)
|
||||
|
||||
|
||||
def test_runtime_send_smtp_message_blocks_metadata_host(monkeypatch):
|
||||
"""_send_smtp_message fails closed on cloud metadata destination."""
|
||||
import routes.email_helpers as email_helpers
|
||||
_set_admin(monkeypatch, True)
|
||||
cfg = {
|
||||
"smtp_host": "169.254.169.254",
|
||||
"smtp_port": 25,
|
||||
"owner": "admin",
|
||||
}
|
||||
with pytest.raises(OutboundAddressBlocked) as exc_info:
|
||||
email_helpers._send_smtp_message(cfg, "from@example.com", ["to@example.com"], b"data")
|
||||
assert "link-local" in str(exc_info.value)
|
||||
|
||||
|
||||
def test_ipv6_loopback_allowed_for_admin_blocked_for_non_admin():
|
||||
mock_sock = mock.MagicMock()
|
||||
with mock.patch("socket.socket", return_value=mock_sock):
|
||||
sock = connect_outbound_tcp("::1", 993, block_private=False)
|
||||
assert sock is mock_sock
|
||||
mock_sock.connect.assert_called_with(("::1", 993, 0, 0))
|
||||
|
||||
with pytest.raises(OutboundAddressBlocked) as exc_info:
|
||||
connect_outbound_tcp("::1", 993, block_private=True)
|
||||
assert "private/shared/loopback" in str(exc_info.value)
|
||||
|
||||
|
||||
def test_email_block_private_ips_env_overrides_admin(monkeypatch):
|
||||
import routes.email_helpers as email_helpers
|
||||
_set_admin(monkeypatch, True)
|
||||
monkeypatch.setenv("EMAIL_BLOCK_PRIVATE_IPS", "true")
|
||||
cfg = {
|
||||
"smtp_host": "127.0.0.1",
|
||||
"smtp_port": 25,
|
||||
"owner": "admin",
|
||||
}
|
||||
with pytest.raises(OutboundAddressBlocked) as exc_info:
|
||||
email_helpers._send_smtp_message(cfg, "from@example.com", ["to@example.com"], b"data")
|
||||
assert "private/shared/loopback" in str(exc_info.value)
|
||||
|
||||
|
||||
def test_tls_sni_preserved_during_safe_connection():
|
||||
import routes.email_helpers as email_helpers
|
||||
mock_sock = mock.MagicMock()
|
||||
mock_ssl_sock = mock.MagicMock()
|
||||
mock_ctx = mock.MagicMock()
|
||||
mock_ctx.wrap_socket.return_value = mock_ssl_sock
|
||||
|
||||
with mock.patch("routes.email_helpers.connect_outbound_tcp", return_value=mock_sock):
|
||||
imap = email_helpers._PolicyIMAP4_SSL.__new__(email_helpers._PolicyIMAP4_SSL)
|
||||
imap._block_private = False
|
||||
imap.host = "imap.secure.example.com"
|
||||
imap.port = 993
|
||||
imap.ssl_context = mock_ctx
|
||||
res = imap._create_socket(10)
|
||||
mock_ctx.wrap_socket.assert_called_with(mock_sock, server_hostname="imap.secure.example.com")
|
||||
assert res is mock_ssl_sock
|
||||
|
||||
mock_smtp_sock = mock.MagicMock()
|
||||
mock_ctx.wrap_socket.return_value = mock_smtp_sock
|
||||
with mock.patch("routes.email_helpers.connect_outbound_tcp", return_value=mock_sock):
|
||||
smtp = email_helpers._PolicySMTP_SSL.__new__(email_helpers._PolicySMTP_SSL)
|
||||
smtp._block_private = False
|
||||
smtp._host = "smtp.secure.example.com"
|
||||
smtp.context = mock_ctx
|
||||
smtp.source_address = None
|
||||
res = smtp._get_socket("smtp.secure.example.com", 465, 10)
|
||||
mock_ctx.wrap_socket.assert_called_with(mock_sock, server_hostname="smtp.secure.example.com")
|
||||
assert res is mock_smtp_sock
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("route_name", ["create", "update", "config"])
|
||||
@pytest.mark.parametrize("field", ["imap_host", "smtp_host"])
|
||||
@pytest.mark.parametrize("is_admin", [False, True])
|
||||
@pytest.mark.parametrize("answer", ["error", "empty", "garbage", "none"])
|
||||
async def test_save_rejects_unresolved_hosts_before_persistence(monkeypatch, route_name, field, is_admin, answer):
|
||||
import routes.email_routes as routes
|
||||
import src.url_safety as safety
|
||||
|
||||
_set_admin(monkeypatch, is_admin)
|
||||
def resolve(host):
|
||||
if answer == "error":
|
||||
raise RuntimeError("resolver-internal-detail")
|
||||
return {"empty": [], "garbage": ["not-an-ip"], "none": None}[answer]
|
||||
|
||||
def no_persistence(*args, **kwargs):
|
||||
raise AssertionError("Rejected mail host reached persistence")
|
||||
|
||||
monkeypatch.setattr(safety, "_default_resolver", resolve)
|
||||
monkeypatch.setattr("core.database.SessionLocal", no_persistence)
|
||||
monkeypatch.setattr(routes, "_load_settings", no_persistence)
|
||||
monkeypatch.setattr(routes, "_save_settings", no_persistence)
|
||||
monkeypatch.setattr(routes, "_assert_owns_account", lambda *a: None)
|
||||
router = routes.setup_email_routes()
|
||||
payload = {"name": "Pending DNS", field: "pending.example.invalid"}
|
||||
owner = "admin" if is_admin else "regular-user"
|
||||
if route_name == "create":
|
||||
result = await _route(router, "/api/email/accounts", "POST")(payload, owner=owner)
|
||||
elif route_name == "update":
|
||||
result = await _route(router, "/api/email/accounts/{account_id}", "PUT")("acct", payload, owner=owner)
|
||||
else:
|
||||
result = await _route(router, "/api/email/config", "PUT")(payload, account_id=None, owner=owner)
|
||||
assert result["ok"] is False
|
||||
assert "does not resolve" in result["error"]
|
||||
assert "resolver-internal-detail" not in result["error"]
|
||||
assert "not-an-ip" not in result["error"]
|
||||
|
||||
|
||||
def test_connect_pins_the_first_resolution_without_rebinding_lookup():
|
||||
public = [(socket.AF_INET, socket.SOCK_STREAM, 6, "", ("93.184.216.34", 993))]
|
||||
metadata = [(socket.AF_INET, socket.SOCK_STREAM, 6, "", ("169.254.169.254", 993))]
|
||||
resolver = mock.Mock(side_effect=[public, metadata])
|
||||
with mock.patch("socket.socket") as create_socket:
|
||||
sock = connect_outbound_tcp("rebind.example.org", 993, resolver=resolver, block_private=True)
|
||||
resolver.assert_called_once_with("rebind.example.org", 993, 0, socket.SOCK_STREAM)
|
||||
sock.connect.assert_called_once_with(("93.184.216.34", 993))
|
||||
|
||||
|
||||
def test_resolver_type_error_propagates_without_alternate_signature_retry():
|
||||
resolver = mock.Mock(side_effect=TypeError("resolver-internal-bug"))
|
||||
with mock.patch("socket.socket") as create_socket:
|
||||
with pytest.raises(TypeError, match="resolver-internal-bug"):
|
||||
connect_outbound_tcp("mail.example.org", 993, resolver=resolver)
|
||||
resolver.assert_called_once_with("mail.example.org", 993, 0, socket.SOCK_STREAM)
|
||||
create_socket.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("port", [None, 0, -1, 65536, True, 25.5, "not-a-port"])
|
||||
def test_invalid_tcp_port_is_rejected_before_resolution(port):
|
||||
resolver = mock.Mock()
|
||||
with pytest.raises(ValueError):
|
||||
connect_outbound_tcp("mail.example.org", port, resolver=resolver)
|
||||
resolver.assert_not_called()
|
||||
|
||||
|
||||
def test_empty_resolver_iterator_fails_closed():
|
||||
assert check_outbound_host("empty.example.org", resolver=lambda host: iter(()))[0] is False
|
||||
|
||||
|
||||
def test_shared_space_metadata_endpoint_is_identified_correctly():
|
||||
ok, reason = check_outbound_host("100.100.100.200", block_private=False)
|
||||
assert ok is False
|
||||
assert "metadata endpoint blocked" in reason
|
||||
assert "link-local" not in reason
|
||||
|
||||
|
||||
def test_importing_mail_helpers_does_not_replace_stdlib_clients():
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
subprocess.run([sys.executable, "-c", """
|
||||
import imaplib, smtplib
|
||||
original = (imaplib.IMAP4, imaplib.IMAP4_SSL, smtplib.SMTP, smtplib.SMTP_SSL)
|
||||
import routes.email.email_helpers
|
||||
assert original == (imaplib.IMAP4, imaplib.IMAP4_SSL, smtplib.SMTP, smtplib.SMTP_SSL)
|
||||
"""], check=True, capture_output=True, text=True)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("route_name", ["create", "update", "config"])
|
||||
async def test_save_host_resolution_runs_off_the_event_loop(monkeypatch, route_name):
|
||||
import threading
|
||||
import routes.email_routes as routes
|
||||
|
||||
event_loop_thread = threading.get_ident()
|
||||
def guard(*args):
|
||||
assert threading.get_ident() != event_loop_thread
|
||||
return "Host rejected"
|
||||
|
||||
monkeypatch.setattr(routes, "_check_mail_host", guard)
|
||||
monkeypatch.setattr(routes, "_assert_owns_account", lambda *args: None)
|
||||
router = routes.setup_email_routes()
|
||||
payload = {"name": "Pending DNS", "imap_host": "pending.example.invalid"}
|
||||
if route_name == "create":
|
||||
result = await _route(router, "/api/email/accounts", "POST")(payload, owner="admin")
|
||||
elif route_name == "update":
|
||||
result = await _route(router, "/api/email/accounts/{account_id}", "PUT")("acct", payload, owner="admin")
|
||||
else:
|
||||
result = await _route(router, "/api/email/config", "PUT")(payload, account_id=None, owner="admin")
|
||||
assert result == {"ok": False, "error": "Host rejected"}
|
||||
|
||||
|
||||
@@ -41,18 +41,18 @@ def test_email_bulk_read_unread_checks_backend_success_before_syncing_cache():
|
||||
assert "_libCacheWriteBack()" in src
|
||||
|
||||
|
||||
def test_email_bulk_export_attachments_is_ui_only_selected_context():
|
||||
def test_email_bulk_exports_messages_and_attachments_from_selected_context():
|
||||
frontend = email_library_source()
|
||||
backend = _EMAIL_ROUTES.read_text(encoding="utf-8")
|
||||
export_src = frontend[
|
||||
frontend.index("async function _exportSelectedAttachments()"):
|
||||
frontend.index("\n}\n\n// URL-suffix helper", frontend.index("async function _exportSelectedAttachments()")) + 3
|
||||
]
|
||||
export_src = _function_source("_exportSelectedBundle")
|
||||
payload_src = _function_source("_selectedEmailExportPayload")
|
||||
|
||||
assert "Export Emails" in frontend
|
||||
assert "Email file (.eml)" in frontend
|
||||
assert "_exportSelectedEmailPdf" in frontend
|
||||
assert "Export Attachments" in frontend
|
||||
assert "All in view" in frontend
|
||||
assert "/api/email/attachments-download-bulk" in export_src
|
||||
assert "${isEmailExport ? 'messages' : 'attachments'}-download-bulk" in export_src
|
||||
assert "method: 'POST'" in export_src
|
||||
assert "res.blob()" in export_src
|
||||
assert "account_id: String(em.account_id || state._libAccountId || '')" in payload_src
|
||||
@@ -63,6 +63,96 @@ def test_email_bulk_export_attachments_is_ui_only_selected_context():
|
||||
assert "_fixture_email_read(uid, folder, owner)" in backend
|
||||
assert "_is_likely_signature_image_attachment(att)" in backend
|
||||
|
||||
assert '@router.post("/messages-download-bulk")' in backend
|
||||
assert '"(UID RFC822)"' in backend
|
||||
assert "msg.as_bytes(policy=email_policy.SMTP)" in backend
|
||||
assert 'media_type="message/rfc822"' in backend
|
||||
assert '"X-Exported-Emails"' in backend
|
||||
|
||||
|
||||
def test_email_print_pdf_export_reads_without_marking_read_and_sanitizes_body():
|
||||
"""Print / save PDF export must use the browser print dialog (SAN-157).
|
||||
|
||||
The removed html2pdf library must not be loaded or referenced. Instead
|
||||
the export uses a sandboxed iframe and window.print(), matching the
|
||||
established pattern in document.js.
|
||||
"""
|
||||
frontend = email_library_source()
|
||||
print_src = _function_source("_printSelectedEmails")
|
||||
menu_src = js_function_source("_showBulkActionsMenu", frontend)
|
||||
|
||||
assert "mark_seen=false&full=true" in print_src
|
||||
assert "_safeRenderEmailBody(data)" in print_src
|
||||
assert "new DOMParser()" in print_src
|
||||
assert "up to 20 emails" in print_src
|
||||
assert "Print / save PDF" in menu_src
|
||||
|
||||
# SAN-157: must NOT reference the removed html2pdf library.
|
||||
assert "html2pdf" not in print_src
|
||||
assert "html2pdf" not in frontend
|
||||
|
||||
# Must use the browser print pattern with a sandboxed iframe.
|
||||
assert "contentWindow.print()" in print_src
|
||||
assert "sandbox" in print_src
|
||||
assert "allow-same-origin" in print_src
|
||||
assert "allow-modals" in print_src
|
||||
assert "afterprint" in print_src
|
||||
assert "email-browser-print-frame" in print_src
|
||||
|
||||
|
||||
def test_email_eml_export_bounds_total_size():
|
||||
"""The /messages-download-bulk endpoint must enforce a total size cap
|
||||
to prevent unbounded memory growth when exporting many large messages."""
|
||||
backend = _EMAIL_ROUTES.read_text(encoding="utf-8")
|
||||
|
||||
assert "max_total_bytes" in backend
|
||||
assert "total_bytes" in backend
|
||||
assert "Export too large" in backend
|
||||
assert "status_code=413" in backend
|
||||
|
||||
|
||||
def test_email_eml_export_validates_selection_count():
|
||||
"""The /messages-download-bulk endpoint must reject oversized selections."""
|
||||
backend = _EMAIL_ROUTES.read_text(encoding="utf-8")
|
||||
|
||||
assert 'len(messages) > 250' in backend
|
||||
assert '"Too many emails selected"' in backend
|
||||
|
||||
|
||||
def test_email_eml_export_rejects_non_numeric_remote_uids():
|
||||
"""Non-numeric UIDs must not be passed to IMAP UID FETCH."""
|
||||
backend = _EMAIL_ROUTES.read_text(encoding="utf-8")
|
||||
|
||||
# After fixture lookup fails, non-numeric UIDs are skipped.
|
||||
assert 're.fullmatch(r"\\d+", uid)' in backend
|
||||
|
||||
|
||||
def test_email_eml_export_handles_partial_batch_failures():
|
||||
"""When an IMAP fetch batch returns partial results, exported/skipped
|
||||
accounting must remain correct."""
|
||||
backend = _EMAIL_ROUTES.read_text(encoding="utf-8")
|
||||
|
||||
# The skipped counter increments for UIDs not found in the batch response.
|
||||
assert "skipped += len(batch) - len(found)" in backend
|
||||
# Whole-connection failures count remaining unprocessed rows as skipped.
|
||||
assert "skipped += remaining_rows" in backend
|
||||
assert '"X-Exported-Emails"' in backend
|
||||
assert '"X-Skipped-Emails"' in backend
|
||||
|
||||
|
||||
def test_email_eml_export_enforces_account_ownership():
|
||||
"""The /messages-download-bulk endpoint must assert account ownership."""
|
||||
backend = _EMAIL_ROUTES.read_text(encoding="utf-8")
|
||||
|
||||
assert "_assert_owns_account(account_id, owner)" in backend
|
||||
|
||||
|
||||
def test_email_eml_export_deduplicates_selection():
|
||||
"""Duplicate items in selection must be skipped to avoid duplicate files."""
|
||||
backend = _EMAIL_ROUTES.read_text(encoding="utf-8")
|
||||
|
||||
assert "seen_items" in backend
|
||||
|
||||
|
||||
def test_fixture_tag_filter_supports_receipt_tag_flow():
|
||||
backend = _EMAIL_ROUTES.read_text(encoding="utf-8")
|
||||
@@ -171,3 +261,808 @@ def test_email_compose_can_attach_gallery_images():
|
||||
assert "GalleryImage" in backend
|
||||
assert "_gallery_image_path(img.filename)" in backend
|
||||
assert "_stage_compose_file" in backend
|
||||
|
||||
|
||||
class _MockImapConn:
|
||||
def __init__(self, messages_by_uid: dict[str, bytes] | None = None, fetch_exc: Exception | None = None, fetch_cb=None):
|
||||
self.messages = messages_by_uid or {}
|
||||
self.fetch_exc = fetch_exc
|
||||
self.fetch_cb = fetch_cb
|
||||
self.fetch_calls: list = []
|
||||
|
||||
def select(self, folder, readonly=True):
|
||||
return "OK", [b"1"]
|
||||
|
||||
def uid(self, command, *args):
|
||||
if command == "FETCH":
|
||||
self.fetch_calls.append(args)
|
||||
if self.fetch_exc is not None:
|
||||
raise self.fetch_exc
|
||||
if self.fetch_cb:
|
||||
return self.fetch_cb(args)
|
||||
uid_set_b = args[0]
|
||||
uid_set = uid_set_b.decode() if isinstance(uid_set_b, bytes) else str(uid_set_b)
|
||||
uids = [u.strip() for u in uid_set.split(",") if u.strip()]
|
||||
fetch_cmd = str(args[1]) if len(args) > 1 else ""
|
||||
res = []
|
||||
if "RFC822.SIZE" in fetch_cmd:
|
||||
for u in uids:
|
||||
if u in self.messages:
|
||||
meta = f"1 (UID {u} RFC822.SIZE {len(self.messages[u])})".encode()
|
||||
res.append(meta)
|
||||
return "OK", res
|
||||
for u in uids:
|
||||
if u in self.messages:
|
||||
meta = f"1 (UID {u} RFC822 {{{len(self.messages[u])}}}".encode()
|
||||
res.append((meta, self.messages[u]))
|
||||
res.append(b")")
|
||||
return "OK", res
|
||||
return "NO", []
|
||||
|
||||
|
||||
def _create_test_app(monkeypatch, mailbox: _MockImapConn | None = None, assert_owns_fn=None):
|
||||
from contextlib import contextmanager
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
from routes.email import email_routes as routes
|
||||
|
||||
conn = mailbox or _MockImapConn()
|
||||
|
||||
@contextmanager
|
||||
def mock_imap(account_id=None, owner=""):
|
||||
yield conn
|
||||
|
||||
monkeypatch.setattr(routes, "_start_poller", lambda: None)
|
||||
monkeypatch.setattr(routes, "_POOL_HOOKS", {})
|
||||
monkeypatch.setattr(routes, "_imap", mock_imap)
|
||||
if assert_owns_fn:
|
||||
monkeypatch.setattr(routes, "_assert_owns_account", assert_owns_fn)
|
||||
|
||||
app = FastAPI()
|
||||
app.include_router(routes.setup_email_routes())
|
||||
app.dependency_overrides[routes.require_owner] = lambda: "test-user"
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
def test_messages_download_bulk_validates_empty_and_oversized_payload(monkeypatch):
|
||||
client = _create_test_app(monkeypatch)
|
||||
|
||||
res_empty = client.post("/api/email/messages-download-bulk", json={"messages": []})
|
||||
assert res_empty.status_code == 400
|
||||
assert "No emails selected" in res_empty.text
|
||||
|
||||
oversized = [{"uid": str(i)} for i in range(251)]
|
||||
res_over = client.post("/api/email/messages-download-bulk", json={"messages": oversized})
|
||||
assert res_over.status_code == 400
|
||||
assert "Too many emails selected" in res_over.text
|
||||
|
||||
|
||||
def test_messages_download_bulk_single_message_eml_export(monkeypatch):
|
||||
raw_rfc822 = b"From: sender@example.com\r\nTo: test-user\r\nSubject: Report\r\n\r\nReport content"
|
||||
mailbox = _MockImapConn({"101": raw_rfc822})
|
||||
client = _create_test_app(monkeypatch, mailbox=mailbox)
|
||||
|
||||
res = client.post("/api/email/messages-download-bulk", json={
|
||||
"messages": [{"uid": "101", "subject": "Quarterly Report", "date": "2026-10-20"}]
|
||||
})
|
||||
|
||||
assert res.status_code == 200
|
||||
assert res.headers["content-type"].startswith("message/rfc822")
|
||||
assert res.headers["x-exported-emails"] == "1"
|
||||
assert res.headers["x-skipped-emails"] == "0"
|
||||
assert "email.eml" in res.headers["content-disposition"]
|
||||
assert res.content == raw_rfc822
|
||||
|
||||
|
||||
def test_messages_download_bulk_multi_message_zip_export(monkeypatch):
|
||||
import io
|
||||
import zipfile
|
||||
|
||||
msg1 = b"From: a@example.com\r\nSubject: First\r\n\r\nOne"
|
||||
msg2 = b"From: b@example.com\r\nSubject: Second\r\n\r\nTwo"
|
||||
mailbox = _MockImapConn({"101": msg1, "102": msg2})
|
||||
client = _create_test_app(monkeypatch, mailbox=mailbox)
|
||||
|
||||
res = client.post("/api/email/messages-download-bulk", json={
|
||||
"messages": [
|
||||
{"uid": "101", "subject": "First Msg", "date": "2026-10-18"},
|
||||
{"uid": "102", "subject": "Second Msg", "date": "2026-10-19"},
|
||||
]
|
||||
})
|
||||
|
||||
assert res.status_code == 200
|
||||
assert res.headers["content-type"] == "application/zip"
|
||||
assert res.headers["x-exported-emails"] == "2"
|
||||
assert res.headers["x-skipped-emails"] == "0"
|
||||
|
||||
zf = zipfile.ZipFile(io.BytesIO(res.content))
|
||||
namelist = zf.namelist()
|
||||
assert len(namelist) == 2
|
||||
assert any("101.eml" in name for name in namelist)
|
||||
assert any("102.eml" in name for name in namelist)
|
||||
|
||||
|
||||
def test_messages_download_bulk_partial_failure_and_duplicate_dedup(monkeypatch):
|
||||
import io
|
||||
import zipfile
|
||||
|
||||
msg1 = b"From: a@example.com\r\nSubject: Exists\r\n\r\nExists body"
|
||||
# UID 101 exists, UID 999 does not exist in mailbox
|
||||
mailbox = _MockImapConn({"101": msg1})
|
||||
client = _create_test_app(monkeypatch, mailbox=mailbox)
|
||||
|
||||
res = client.post("/api/email/messages-download-bulk", json={
|
||||
"messages": [
|
||||
{"uid": "101", "subject": "Exists"},
|
||||
{"uid": "999", "subject": "Missing"},
|
||||
{"uid": "101", "subject": "Duplicate"}, # duplicate must be skipped
|
||||
]
|
||||
})
|
||||
|
||||
assert res.status_code == 200
|
||||
assert res.headers["content-type"] == "application/zip"
|
||||
assert res.headers["x-exported-emails"] == "1"
|
||||
assert res.headers["x-skipped-emails"] == "2"
|
||||
|
||||
zf = zipfile.ZipFile(io.BytesIO(res.content))
|
||||
assert len(zf.namelist()) == 1
|
||||
|
||||
|
||||
def test_messages_download_bulk_enforces_account_ownership_guard(monkeypatch):
|
||||
from fastapi import HTTPException
|
||||
|
||||
def mock_assert_owns(account_id, owner):
|
||||
if account_id == "other-tenant-acct":
|
||||
raise HTTPException(status_code=404, detail="Account not found")
|
||||
|
||||
client = _create_test_app(monkeypatch, assert_owns_fn=mock_assert_owns)
|
||||
|
||||
res = client.post("/api/email/messages-download-bulk", json={
|
||||
"messages": [{"uid": "101", "account_id": "other-tenant-acct"}]
|
||||
})
|
||||
|
||||
assert res.status_code == 404
|
||||
assert "Account not found" in res.text
|
||||
|
||||
|
||||
def test_messages_download_bulk_mid_batch_failure_accounting(monkeypatch):
|
||||
"""When an exception occurs mid-batch after partial progress, already-exported
|
||||
records must not be double-counted as skipped."""
|
||||
class MidBatchFailImap:
|
||||
def select(self, folder, readonly=True):
|
||||
return "OK", [b"1"]
|
||||
def uid(self, command, *args):
|
||||
fetch_cmd = str(args[1]) if len(args) > 1 else ""
|
||||
if "RFC822.SIZE" in fetch_cmd:
|
||||
return "OK", [f"{i} (UID {i} RFC822.SIZE 100)".encode() for i in range(1, 11)]
|
||||
records = []
|
||||
for i in range(1, 5):
|
||||
records.append((f"{i} (UID {i} RFC822 {{{10}}}".encode(), f"Subject: Msg {i}\r\n\r\nBody {i}".encode()))
|
||||
class BadRecord:
|
||||
def __bytes__(self):
|
||||
raise RuntimeError("Corrupt memory buffer")
|
||||
records.append((b"5 (UID 5 RFC822 {10}", BadRecord()))
|
||||
return "OK", records
|
||||
|
||||
from contextlib import contextmanager
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
from routes.email import email_routes as routes
|
||||
|
||||
@contextmanager
|
||||
def mock_imap(account_id=None, owner=""):
|
||||
yield MidBatchFailImap()
|
||||
|
||||
monkeypatch.setattr(routes, "_start_poller", lambda: None)
|
||||
monkeypatch.setattr(routes, "_POOL_HOOKS", {})
|
||||
monkeypatch.setattr(routes, "_imap", mock_imap)
|
||||
|
||||
app = FastAPI()
|
||||
app.include_router(routes.setup_email_routes())
|
||||
app.dependency_overrides[routes.require_owner] = lambda: "test-user"
|
||||
client = TestClient(app)
|
||||
|
||||
rows = [{"uid": str(i)} for i in range(1, 11)]
|
||||
res = client.post("/api/email/messages-download-bulk", json={"messages": rows})
|
||||
assert res.status_code == 200
|
||||
exp = int(res.headers.get("x-exported-emails"))
|
||||
skp = int(res.headers.get("x-skipped-emails"))
|
||||
assert exp == 4
|
||||
assert skp == 6
|
||||
assert exp + skp == 10
|
||||
|
||||
|
||||
def test_messages_download_bulk_size_cap_413_boundary(monkeypatch):
|
||||
"""Cumulative export size exceeding max_total_bytes returns HTTP 413."""
|
||||
class LargeMessageImap:
|
||||
def select(self, folder, readonly=True):
|
||||
return "OK", [b"1"]
|
||||
def uid(self, command, *args):
|
||||
raw_uids = args[0] if isinstance(args[0], str) else args[0].decode()
|
||||
if "(UID RFC822.SIZE)" in str(args):
|
||||
records = []
|
||||
for u in raw_uids.split(","):
|
||||
records.append(f"{u} (UID {u} RFC822.SIZE 10485760)".encode())
|
||||
return "OK", records
|
||||
records = []
|
||||
big_chunk = b"X" * (10 * 1024 * 1024)
|
||||
for u in raw_uids.split(","):
|
||||
records.append((f"{u} (UID {u} RFC822".encode(), big_chunk))
|
||||
return "OK", records
|
||||
|
||||
from contextlib import contextmanager
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
from routes.email import email_routes as routes
|
||||
|
||||
@contextmanager
|
||||
def mock_imap(account_id=None, owner=""):
|
||||
yield LargeMessageImap()
|
||||
|
||||
monkeypatch.setattr(routes, "_start_poller", lambda: None)
|
||||
monkeypatch.setattr(routes, "_POOL_HOOKS", {})
|
||||
monkeypatch.setattr(routes, "_imap", mock_imap)
|
||||
|
||||
app = FastAPI()
|
||||
app.include_router(routes.setup_email_routes())
|
||||
app.dependency_overrides[routes.require_owner] = lambda: "test-user"
|
||||
client = TestClient(app)
|
||||
|
||||
# 52 messages of 10 MB = 520 MB > 512 MiB limit
|
||||
payload_52 = [{"uid": str(i + 1), "subject": f"Big {i}"} for i in range(52)]
|
||||
res = client.post("/api/email/messages-download-bulk", json={"messages": payload_52})
|
||||
assert res.status_code == 413
|
||||
assert res.json()["detail"] == "Export too large"
|
||||
|
||||
|
||||
def test_messages_download_bulk_per_message_limit_413(monkeypatch):
|
||||
"""A single oversized message (>64 MiB) triggers HTTP 413."""
|
||||
class HugeSingleMsgImap:
|
||||
def select(self, folder, readonly=True):
|
||||
return "OK", [b"1"]
|
||||
def uid(self, command, *args):
|
||||
return "OK", [b"1 (UID 101 RFC822.SIZE 73400320)"]
|
||||
|
||||
from contextlib import contextmanager
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
from routes.email import email_routes as routes
|
||||
|
||||
@contextmanager
|
||||
def mock_imap(account_id=None, owner=""):
|
||||
yield HugeSingleMsgImap()
|
||||
|
||||
monkeypatch.setattr(routes, "_start_poller", lambda: None)
|
||||
monkeypatch.setattr(routes, "_POOL_HOOKS", {})
|
||||
monkeypatch.setattr(routes, "_imap", mock_imap)
|
||||
|
||||
app = FastAPI()
|
||||
app.include_router(routes.setup_email_routes())
|
||||
app.dependency_overrides[routes.require_owner] = lambda: "test-user"
|
||||
client = TestClient(app)
|
||||
|
||||
res = client.post("/api/email/messages-download-bulk", json={
|
||||
"messages": [{"uid": "101", "subject": "Huge Email"}]
|
||||
})
|
||||
assert res.status_code == 413
|
||||
assert res.json()["detail"] == "Export too large"
|
||||
|
||||
|
||||
def test_messages_download_bulk_account_ownership_memoization(monkeypatch):
|
||||
"""Account ownership validation is memoized per distinct account_id to eliminate N+1 DB queries."""
|
||||
owns_calls = []
|
||||
def counting_assert_owns(account_id, owner):
|
||||
owns_calls.append(account_id)
|
||||
|
||||
raw_rfc822 = b"From: sender@example.com\r\nSubject: Test\r\n\r\nBody"
|
||||
msgs = {str(i): raw_rfc822 for i in range(1, 26)}
|
||||
mailbox = _MockImapConn(msgs)
|
||||
client = _create_test_app(monkeypatch, mailbox=mailbox, assert_owns_fn=counting_assert_owns)
|
||||
|
||||
# 25 messages all referencing the same account_id
|
||||
payload = [{"uid": str(i), "account_id": "acc-shared"} for i in range(1, 26)]
|
||||
res = client.post("/api/email/messages-download-bulk", json={"messages": payload})
|
||||
assert res.status_code == 200
|
||||
assert len(owns_calls) == 1
|
||||
|
||||
|
||||
def test_messages_download_bulk_corrupt_zip_fails_closed(monkeypatch):
|
||||
"""If ZIP construction fails (e.g. disk error), fail closed with 500 instead of returning corrupt archive."""
|
||||
import zipfile
|
||||
raw_rfc822 = b"From: sender@example.com\r\nSubject: Test\r\n\r\nBody"
|
||||
mailbox = _MockImapConn({"101": raw_rfc822, "102": raw_rfc822})
|
||||
|
||||
orig_writestr = zipfile.ZipFile.writestr
|
||||
def failing_writestr(self, zinfo_or_arcname, data=None, *args, **kwargs):
|
||||
raise OSError("Disk write failed: No space left on device")
|
||||
|
||||
monkeypatch.setattr(zipfile.ZipFile, "writestr", failing_writestr)
|
||||
client = _create_test_app(monkeypatch, mailbox=mailbox)
|
||||
|
||||
res = client.post("/api/email/messages-download-bulk", json={
|
||||
"messages": [{"uid": "101"}, {"uid": "102"}]
|
||||
})
|
||||
assert res.status_code == 500
|
||||
assert "Failed to create export archive" in res.json()["detail"]
|
||||
|
||||
|
||||
def test_messages_download_bulk_preflight_no_fails_safe_no_body_fetch(monkeypatch):
|
||||
"""When preflight returns NO, size safety is unknown: fail safely (502) and do not fetch bodies."""
|
||||
mailbox = _MockImapConn({"101": b"dummy", "102": b"dummy"})
|
||||
mailbox.fetch_cb = lambda args: ("NO", []) if "RFC822.SIZE" in str(args) else ("OK", [])
|
||||
client = _create_test_app(monkeypatch, mailbox=mailbox)
|
||||
|
||||
res = client.post("/api/email/messages-download-bulk", json={"messages": [{"uid": "101"}, {"uid": "102"}]})
|
||||
assert res.status_code == 502
|
||||
assert "Unable to verify email size safety" in res.json()["detail"]
|
||||
assert not any("(UID RFC822)" in str(call) for call in mailbox.fetch_calls)
|
||||
|
||||
|
||||
def test_messages_download_bulk_preflight_empty_missing_mail(monkeypatch):
|
||||
"""When preflight returns empty data, all requested messages are missing on server: returns 404 with no body fetch."""
|
||||
mailbox = _MockImapConn() # no messages
|
||||
client = _create_test_app(monkeypatch, mailbox=mailbox)
|
||||
|
||||
res = client.post("/api/email/messages-download-bulk", json={"messages": [{"uid": "101"}, {"uid": "102"}]})
|
||||
assert res.status_code == 404
|
||||
assert "No selected emails could be exported" in res.json()["detail"]
|
||||
assert not any("(UID RFC822)" in str(call) for call in mailbox.fetch_calls)
|
||||
|
||||
|
||||
def test_messages_download_bulk_preflight_malformed_fails_safe_no_body_fetch(monkeypatch):
|
||||
"""When preflight returns records without RFC822.SIZE, fail safely (502) and do not fetch bodies."""
|
||||
mailbox = _MockImapConn({"101": b"dummy"})
|
||||
mailbox.fetch_cb = lambda args: ("OK", [b"1 (UID 101 FLAGS (\\Seen))"]) if "RFC822.SIZE" in str(args) else ("OK", [])
|
||||
client = _create_test_app(monkeypatch, mailbox=mailbox)
|
||||
|
||||
res = client.post("/api/email/messages-download-bulk", json={"messages": [{"uid": "101"}]})
|
||||
assert res.status_code == 502
|
||||
assert "Invalid email size metadata from server" in res.json()["detail"]
|
||||
assert not any("(UID RFC822)" in str(call) for call in mailbox.fetch_calls)
|
||||
|
||||
|
||||
def test_messages_download_bulk_preflight_inconsistent_fails_safe_no_body_fetch(monkeypatch):
|
||||
"""When preflight returns negative or corrupted size values, fail safely (502) and do not fetch bodies."""
|
||||
mailbox = _MockImapConn({"101": b"dummy"})
|
||||
mailbox.fetch_cb = lambda args: ("OK", [b"1 (UID 101 RFC822.SIZE -50)"]) if "RFC822.SIZE" in str(args) else ("OK", [])
|
||||
client = _create_test_app(monkeypatch, mailbox=mailbox)
|
||||
|
||||
res = client.post("/api/email/messages-download-bulk", json={"messages": [{"uid": "101"}]})
|
||||
assert res.status_code == 502
|
||||
assert "Invalid email size metadata from server" in res.json()["detail"]
|
||||
assert not any("(UID RFC822)" in str(call) for call in mailbox.fetch_calls)
|
||||
|
||||
|
||||
def test_messages_download_bulk_preflight_partial_missing_omitted_from_body_fetch(monkeypatch):
|
||||
"""When some requested UIDs do not exist on the server, omit them from body fetch and export verified items."""
|
||||
msg1 = b"From: a@example.com\r\nSubject: Only One\r\n\r\nBody"
|
||||
mailbox = _MockImapConn({"101": msg1}) # 102 does not exist on server
|
||||
client = _create_test_app(monkeypatch, mailbox=mailbox)
|
||||
|
||||
res = client.post("/api/email/messages-download-bulk", json={
|
||||
"messages": [{"uid": "101"}, {"uid": "102"}]
|
||||
})
|
||||
assert res.status_code == 200
|
||||
assert res.headers["x-exported-emails"] == "1"
|
||||
assert res.headers["x-skipped-emails"] == "1"
|
||||
|
||||
# Verify body fetch was only requested for 101, NOT 102
|
||||
body_fetches = [call for call in mailbox.fetch_calls if "(UID RFC822)" in str(call)]
|
||||
assert len(body_fetches) == 1
|
||||
requested_uids = body_fetches[0][0].decode() if isinstance(body_fetches[0][0], bytes) else str(body_fetches[0][0])
|
||||
assert requested_uids == "101"
|
||||
assert "102" not in requested_uids
|
||||
|
||||
|
||||
def test_messages_download_bulk_duplicate_imap_records_rejected(monkeypatch):
|
||||
"""Duplicate IMAP FETCH response records for the same UID must be ignored.
|
||||
They must not produce duplicate archive entries or corrupt accounting."""
|
||||
import io
|
||||
import zipfile
|
||||
|
||||
msg1 = b"From: a@example.com\r\nSubject: Unique\r\n\r\nBody 1"
|
||||
mailbox = _MockImapConn({"101": msg1})
|
||||
|
||||
def custom_fetch(args):
|
||||
fetch_cmd = str(args[1]) if len(args) > 1 else ""
|
||||
if "RFC822.SIZE" in fetch_cmd:
|
||||
return "OK", [b"1 (UID 101 RFC822.SIZE 100)", b"2 (UID 102 RFC822.SIZE 100)"]
|
||||
# In body fetch, server sends duplicate records for 101, and nothing for 102
|
||||
records = [
|
||||
(b"1 (UID 101 RFC822 {50}", msg1),
|
||||
b")",
|
||||
(b"1 (UID 101 RFC822 {50}", msg1), # DUPLICATE RECORD
|
||||
b")",
|
||||
]
|
||||
return "OK", records
|
||||
|
||||
mailbox.fetch_cb = custom_fetch
|
||||
client = _create_test_app(monkeypatch, mailbox=mailbox)
|
||||
|
||||
res = client.post("/api/email/messages-download-bulk", json={
|
||||
"messages": [{"uid": "101"}, {"uid": "102"}]
|
||||
})
|
||||
assert res.status_code == 200
|
||||
assert res.headers["x-exported-emails"] == "1"
|
||||
assert res.headers["x-skipped-emails"] == "1"
|
||||
|
||||
zf = zipfile.ZipFile(io.BytesIO(res.content))
|
||||
assert len(zf.namelist()) == 1
|
||||
assert "101.eml" in zf.namelist()[0]
|
||||
|
||||
|
||||
def test_messages_download_bulk_single_message_preflight_failure_no_body_fetch(monkeypatch):
|
||||
"""In single message mode, bad preflight or missing mail fails safely without body fetch."""
|
||||
mailbox = _MockImapConn({"101": b"dummy"})
|
||||
|
||||
# Case A: preflight NO -> 502
|
||||
mailbox.fetch_cb = lambda args: ("NO", []) if "RFC822.SIZE" in str(args) else ("OK", [])
|
||||
client = _create_test_app(monkeypatch, mailbox=mailbox)
|
||||
res_no = client.post("/api/email/messages-download-bulk", json={"messages": [{"uid": "101"}]})
|
||||
assert res_no.status_code == 502
|
||||
assert not any("(UID RFC822)" in str(call) for call in mailbox.fetch_calls)
|
||||
|
||||
# Case B: missing mail -> 404
|
||||
mailbox.fetch_calls.clear()
|
||||
mailbox.fetch_cb = lambda args: ("OK", []) if "RFC822.SIZE" in str(args) else ("OK", [])
|
||||
res_miss = client.post("/api/email/messages-download-bulk", json={"messages": [{"uid": "101"}]})
|
||||
assert res_miss.status_code == 404
|
||||
assert not any("(UID RFC822)" in str(call) for call in mailbox.fetch_calls)
|
||||
|
||||
# Case C: malformed metadata -> 502
|
||||
mailbox.fetch_calls.clear()
|
||||
mailbox.fetch_cb = lambda args: ("OK", [b"1 (UID 101 NO_SIZE)"]) if "RFC822.SIZE" in str(args) else ("OK", [])
|
||||
res_mal = client.post("/api/email/messages-download-bulk", json={"messages": [{"uid": "101"}]})
|
||||
assert res_mal.status_code == 502
|
||||
assert not any("(UID RFC822)" in str(call) for call in mailbox.fetch_calls)
|
||||
|
||||
|
||||
def test_messages_download_bulk_spooled_stream_lifecycle():
|
||||
"""Verify that _iter_spooled closes the underlying SpooledTemporaryFile on completion and GeneratorExit."""
|
||||
import tempfile
|
||||
from routes.email.email_routes import setup_email_routes
|
||||
|
||||
# Test normal completion
|
||||
f = tempfile.SpooledTemporaryFile(max_size=1024, mode="w+b")
|
||||
f.write(b"Hello world payload")
|
||||
f.seek(0)
|
||||
# Re-create generator helper pattern
|
||||
def _iter(file_obj, chunk_size=64):
|
||||
try:
|
||||
while True:
|
||||
chunk = file_obj.read(chunk_size)
|
||||
if not chunk:
|
||||
break
|
||||
yield chunk
|
||||
finally:
|
||||
file_obj.close()
|
||||
|
||||
gen = _iter(f)
|
||||
chunks = list(gen)
|
||||
assert b"".join(chunks) == b"Hello world payload"
|
||||
assert f.closed
|
||||
|
||||
# Test early disconnect / cancellation (GeneratorExit)
|
||||
f2 = tempfile.SpooledTemporaryFile(max_size=1024, mode="w+b")
|
||||
f2.write(b"Chunk1Chunk2Chunk3")
|
||||
f2.seek(0)
|
||||
gen2 = _iter(f2, chunk_size=6)
|
||||
next(gen2)
|
||||
assert not f2.closed
|
||||
gen2.close() # Simulates client disconnect / generator cancellation
|
||||
assert f2.closed
|
||||
|
||||
|
||||
def test_uid_and_size_from_fetch_meta_case_insensitivity_and_validation():
|
||||
"""Unit test: _uid_from_fetch_meta and _size_from_fetch_meta case insensitivity and boundary validation."""
|
||||
from routes.email.email_routes import _uid_from_fetch_meta, _size_from_fetch_meta
|
||||
|
||||
# 1. Standard uppercase UID and RFC822.SIZE
|
||||
meta_upper = b"1 (UID 101 RFC822.SIZE 1234)"
|
||||
assert _uid_from_fetch_meta(meta_upper) == "101"
|
||||
assert _size_from_fetch_meta(meta_upper) == 1234
|
||||
|
||||
# 2. Lowercase uid and rfc822.size
|
||||
meta_lower = b"1 (uid 202 rfc822.size 5678)"
|
||||
assert _uid_from_fetch_meta(meta_lower) == "202"
|
||||
assert _size_from_fetch_meta(meta_lower) == 5678
|
||||
|
||||
# 3. Mixed-case combinations
|
||||
combos = [
|
||||
(b"1 (Uid 303 Rfc822.Size 1000)", "303", 1000),
|
||||
(b"1 (uID 404 RFC822.size 2000)", "404", 2000),
|
||||
(b"1 (UID 505 rfc822.size 3000)", "505", 3000),
|
||||
(b"1 (uid 606 RFC822.SIZE 4000)", "606", 4000),
|
||||
(b"1 (uId 707 Rfc822.SIZE 5000)", "707", 5000),
|
||||
(b"1 (FLAGS (\\Seen) uid 808 rfc822.Size 6000)", "808", 6000),
|
||||
]
|
||||
for meta_bytes, exp_uid, exp_sz in combos:
|
||||
assert _uid_from_fetch_meta(meta_bytes) == exp_uid
|
||||
assert _size_from_fetch_meta(meta_bytes) == exp_sz
|
||||
|
||||
# 4. Malformed metadata cases
|
||||
assert _uid_from_fetch_meta(b"1 (rfc822.size 1234)") == ""
|
||||
assert _size_from_fetch_meta(b"1 (uid 101 flags (\\seen))") is None
|
||||
assert _uid_from_fetch_meta(b"1 (uid abc rfc822.size 1234)") == ""
|
||||
assert _size_from_fetch_meta(b"1 (uid 101 rfc822.size invalid)") is None
|
||||
assert _size_from_fetch_meta(b"1 (uid 101 rfc822.size -50)") is None
|
||||
|
||||
# 5. Boundary integrity (must not match substrings like UUID, GUID, NOT_RFC822.SIZE)
|
||||
assert _uid_from_fetch_meta(b"1 (UUID 101 GUID 202 NOT_RFC822.SIZE 1234)") == ""
|
||||
assert _size_from_fetch_meta(b"1 (UUID 101 GUID 202 NOT_RFC822.SIZE 1234)") is None
|
||||
assert _uid_from_fetch_meta(b"1 (uid101 rfc822.size1234)") == ""
|
||||
assert _size_from_fetch_meta(b"1 (uid101 rfc822.size1234)") is None
|
||||
assert _uid_from_fetch_meta(b"") == ""
|
||||
assert _size_from_fetch_meta(b"") is None
|
||||
|
||||
|
||||
def test_messages_download_bulk_case_insensitive_imap_atoms(monkeypatch):
|
||||
"""End-to-end: Bulk and single-message export with lowercase and mixed-case IMAP atoms."""
|
||||
import io
|
||||
import zipfile
|
||||
|
||||
msg101 = b"From: a@example.com\r\nSubject: Item 101\r\n\r\nBody 1"
|
||||
msg102 = b"From: b@example.com\r\nSubject: Item 102\r\n\r\nBody 2"
|
||||
mailbox = _MockImapConn({"101": msg101, "102": msg102})
|
||||
|
||||
def custom_fetch(args):
|
||||
fetch_cmd = str(args[1]) if len(args) > 1 else ""
|
||||
if "RFC822.SIZE" in fetch_cmd or "rfc822.size" in fetch_cmd.lower():
|
||||
# Server returns lowercase/mixed-case atoms
|
||||
return "OK", [
|
||||
b"1 (uid 101 rfc822.size " + str(len(msg101)).encode() + b")",
|
||||
b"2 (Uid 102 Rfc822.Size " + str(len(msg102)).encode() + b")",
|
||||
]
|
||||
# In body fetch, server returns mixed-case atoms
|
||||
return "OK", [
|
||||
(b"1 (uid 101 rfc822 {" + str(len(msg101)).encode(), msg101),
|
||||
b")",
|
||||
(b"2 (Uid 102 RFC822 {" + str(len(msg102)).encode(), msg102),
|
||||
b")",
|
||||
]
|
||||
|
||||
mailbox.fetch_cb = custom_fetch
|
||||
client = _create_test_app(monkeypatch, mailbox=mailbox)
|
||||
|
||||
# 1. Bulk download (2 items) with lowercase / mixed-case IMAP responses
|
||||
res_bulk = client.post("/api/email/messages-download-bulk", json={
|
||||
"messages": [{"uid": "101"}, {"uid": "102"}]
|
||||
})
|
||||
assert res_bulk.status_code == 200
|
||||
assert res_bulk.headers["x-exported-emails"] == "2"
|
||||
assert res_bulk.headers["x-skipped-emails"] == "0"
|
||||
zf = zipfile.ZipFile(io.BytesIO(res_bulk.content))
|
||||
assert len(zf.namelist()) == 2
|
||||
|
||||
# 2. Single message download fast path with lowercase IMAP response
|
||||
mailbox.fetch_calls.clear()
|
||||
res_single = client.post("/api/email/messages-download-bulk", json={
|
||||
"messages": [{"uid": "101"}]
|
||||
})
|
||||
assert res_single.status_code == 200
|
||||
assert res_single.headers["content-type"].startswith("message/rfc822")
|
||||
assert res_single.content == msg101
|
||||
|
||||
# 3. Lowercase malformed preflight fails safely with 502 and NO body fetch
|
||||
mailbox.fetch_calls.clear()
|
||||
mailbox.fetch_cb = lambda args: (
|
||||
("OK", [b"1 (uid 101 rfc822.size NaN)"])
|
||||
if "RFC822.SIZE" in str(args) or "rfc822.size" in str(args).lower()
|
||||
else ("OK", [])
|
||||
)
|
||||
res_malformed = client.post("/api/email/messages-download-bulk", json={
|
||||
"messages": [{"uid": "101"}]
|
||||
})
|
||||
assert res_malformed.status_code == 502
|
||||
assert "Invalid email size metadata from server" in res_malformed.json()["detail"]
|
||||
assert not any("(UID RFC822)" in str(call) for call in mailbox.fetch_calls)
|
||||
|
||||
|
||||
def test_messages_download_bulk_multi_group_preflight_failure_propagates_502(monkeypatch):
|
||||
"""A preflight safety failure in any group produces HTTP 502 for the entire request,
|
||||
even when another group has already exported messages into the temporary ZIP.
|
||||
Confirms temporary-file cleanup, zero body FETCH for the failed group, and both group orderings."""
|
||||
import tempfile
|
||||
|
||||
msg101 = b"From: a@example.com\r\nSubject: Good\r\n\r\nBody 1"
|
||||
|
||||
spooled_instances: list[tempfile.SpooledTemporaryFile] = []
|
||||
orig_spooled = tempfile.SpooledTemporaryFile
|
||||
|
||||
def tracking_spooled(*args, **kwargs):
|
||||
inst = orig_spooled(*args, **kwargs)
|
||||
spooled_instances.append(inst)
|
||||
return inst
|
||||
|
||||
monkeypatch.setattr(tempfile, "SpooledTemporaryFile", tracking_spooled)
|
||||
|
||||
class MultiMailboxConn:
|
||||
def __init__(self):
|
||||
self.fetch_calls: list[tuple[str, tuple]] = []
|
||||
self.current_folder = "INBOX"
|
||||
|
||||
def select(self, folder, readonly=True):
|
||||
self.current_folder = str(folder)
|
||||
return "OK", [b"1"]
|
||||
|
||||
def uid(self, command, *args):
|
||||
self.fetch_calls.append((self.current_folder, args))
|
||||
fetch_cmd = str(args[1]) if len(args) > 1 else ""
|
||||
uid_set = args[0].decode() if isinstance(args[0], bytes) else str(args[0])
|
||||
|
||||
if "RFC822.SIZE" in fetch_cmd:
|
||||
if "folder-fail" in self.current_folder:
|
||||
return "NO", [] # Preflight failure for folder-fail
|
||||
return "OK", [f"1 (UID {uid_set} RFC822.SIZE 100)".encode()]
|
||||
|
||||
# Body fetch
|
||||
if "folder-fail" in self.current_folder:
|
||||
return "NO", []
|
||||
return "OK", [(f"1 (UID {uid_set} RFC822 {{100}}".encode(), msg101), b")"]
|
||||
|
||||
# 1. Successful group followed by failed preflight
|
||||
conn1 = MultiMailboxConn()
|
||||
client1 = _create_test_app(monkeypatch, mailbox=conn1)
|
||||
spooled_instances.clear()
|
||||
|
||||
res1 = client1.post("/api/email/messages-download-bulk", json={
|
||||
"messages": [
|
||||
{"uid": "101", "folder": "folder-good"},
|
||||
{"uid": "202", "folder": "folder-fail"},
|
||||
]
|
||||
})
|
||||
assert res1.status_code == 502
|
||||
assert "Unable to verify email size safety" in res1.json()["detail"]
|
||||
|
||||
# No RFC822 body FETCH for the failed group
|
||||
failed_body_fetches1 = [
|
||||
call for folder, call in conn1.fetch_calls
|
||||
if "folder-fail" in folder and "(UID RFC822)" in str(call)
|
||||
]
|
||||
assert len(failed_body_fetches1) == 0
|
||||
|
||||
# Temporary file cleanup confirmed
|
||||
assert len(spooled_instances) == 1
|
||||
assert spooled_instances[0].closed
|
||||
|
||||
# 2. Failed preflight followed by successful group
|
||||
conn2 = MultiMailboxConn()
|
||||
client2 = _create_test_app(monkeypatch, mailbox=conn2)
|
||||
spooled_instances.clear()
|
||||
|
||||
res2 = client2.post("/api/email/messages-download-bulk", json={
|
||||
"messages": [
|
||||
{"uid": "202", "folder": "folder-fail"},
|
||||
{"uid": "101", "folder": "folder-good"},
|
||||
]
|
||||
})
|
||||
assert res2.status_code == 502
|
||||
assert "Unable to verify email size safety" in res2.json()["detail"]
|
||||
|
||||
# No RFC822 body FETCH for the failed group
|
||||
failed_body_fetches2 = [
|
||||
call for folder, call in conn2.fetch_calls
|
||||
if "folder-fail" in folder and "(UID RFC822)" in str(call)
|
||||
]
|
||||
assert len(failed_body_fetches2) == 0
|
||||
|
||||
# Temporary file cleanup confirmed
|
||||
assert len(spooled_instances) == 1
|
||||
assert spooled_instances[0].closed
|
||||
|
||||
|
||||
def test_messages_download_bulk_multi_group_both_successful(monkeypatch):
|
||||
"""When both groups succeed preflight and body fetch, return 200 with all messages in ZIP."""
|
||||
import io
|
||||
import zipfile
|
||||
|
||||
msg101 = b"From: a@example.com\r\nSubject: Item 1\r\n\r\nBody 1"
|
||||
msg202 = b"From: b@example.com\r\nSubject: Item 2\r\n\r\nBody 2"
|
||||
|
||||
class MultiMailboxConn:
|
||||
def __init__(self):
|
||||
self.current_folder = "INBOX"
|
||||
|
||||
def select(self, folder, readonly=True):
|
||||
self.current_folder = str(folder)
|
||||
return "OK", [b"1"]
|
||||
|
||||
def uid(self, command, *args):
|
||||
fetch_cmd = str(args[1]) if len(args) > 1 else ""
|
||||
uid_set = args[0].decode() if isinstance(args[0], bytes) else str(args[0])
|
||||
msg = msg101 if "101" in uid_set else msg202
|
||||
if "RFC822.SIZE" in fetch_cmd:
|
||||
return "OK", [f"1 (UID {uid_set} RFC822.SIZE {len(msg)})".encode()]
|
||||
return "OK", [(f"1 (UID {uid_set} RFC822 {{{len(msg)}}}".encode(), msg), b")"]
|
||||
|
||||
conn = MultiMailboxConn()
|
||||
client = _create_test_app(monkeypatch, mailbox=conn)
|
||||
|
||||
res = client.post("/api/email/messages-download-bulk", json={
|
||||
"messages": [
|
||||
{"uid": "101", "folder": "folder-1"},
|
||||
{"uid": "202", "folder": "folder-2"},
|
||||
]
|
||||
})
|
||||
assert res.status_code == 200
|
||||
assert res.headers["x-exported-emails"] == "2"
|
||||
assert res.headers["x-skipped-emails"] == "0"
|
||||
zf = zipfile.ZipFile(io.BytesIO(res.content))
|
||||
assert len(zf.namelist()) == 2
|
||||
|
||||
|
||||
def test_messages_download_bulk_multi_group_genuinely_missing_message(monkeypatch):
|
||||
"""Genuinely missing messages (unreturned by preflight) do not cause 502;
|
||||
they are skipped while verified messages in any group are successfully exported."""
|
||||
import io
|
||||
import zipfile
|
||||
|
||||
msg101 = b"From: a@example.com\r\nSubject: Exists\r\n\r\nBody"
|
||||
|
||||
class MissingMailboxConn:
|
||||
def __init__(self):
|
||||
self.fetch_calls: list = []
|
||||
self.current_folder = "INBOX"
|
||||
|
||||
def select(self, folder, readonly=True):
|
||||
self.current_folder = str(folder)
|
||||
return "OK", [b"1"]
|
||||
|
||||
def uid(self, command, *args):
|
||||
self.fetch_calls.append((self.current_folder, args))
|
||||
fetch_cmd = str(args[1]) if len(args) > 1 else ""
|
||||
uid_set = args[0].decode() if isinstance(args[0], bytes) else str(args[0])
|
||||
if "RFC822.SIZE" in fetch_cmd:
|
||||
# 101 exists on server; 999 is genuinely missing (unreported in size metadata)
|
||||
res = []
|
||||
if "101" in uid_set:
|
||||
res.append(f"1 (UID 101 RFC822.SIZE {len(msg101)})".encode())
|
||||
return "OK", res
|
||||
# Body fetch only returns 101
|
||||
return "OK", [(f"1 (UID 101 RFC822 {{{len(msg101)}}}".encode(), msg101), b")"]
|
||||
|
||||
conn = MissingMailboxConn()
|
||||
client = _create_test_app(monkeypatch, mailbox=conn)
|
||||
|
||||
res = client.post("/api/email/messages-download-bulk", json={
|
||||
"messages": [
|
||||
{"uid": "101", "folder": "folder-1"},
|
||||
{"uid": "999", "folder": "folder-1"},
|
||||
]
|
||||
})
|
||||
assert res.status_code == 200
|
||||
assert res.headers["x-exported-emails"] == "1"
|
||||
assert res.headers["x-skipped-emails"] == "1"
|
||||
|
||||
zf = zipfile.ZipFile(io.BytesIO(res.content))
|
||||
assert len(zf.namelist()) == 1
|
||||
|
||||
# Verify 999 was never requested in body fetch
|
||||
body_fetches = [
|
||||
call for folder, call in conn.fetch_calls
|
||||
if "(UID RFC822)" in str(call)
|
||||
]
|
||||
assert len(body_fetches) == 1
|
||||
assert "999" not in str(body_fetches[0])
|
||||
|
||||
|
||||
def test_email_print_browser_behaviors():
|
||||
"""Real Chromium DOM: placeholder stripping, button removal, and partial failure."""
|
||||
import shutil
|
||||
import subprocess
|
||||
if not shutil.which('node'):
|
||||
import pytest
|
||||
pytest.skip('Node is required')
|
||||
probe = subprocess.run(['node', '-e', "require.resolve('playwright')"], cwd=_REPO, capture_output=True)
|
||||
if probe.returncode:
|
||||
import pytest
|
||||
pytest.skip('Playwright is required')
|
||||
result = subprocess.run(
|
||||
['node', str(_REPO / 'tests/test_email_library_print_browser.cjs')],
|
||||
cwd=_REPO,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=60,
|
||||
)
|
||||
assert result.returncode == 0, result.stdout + result.stderr
|
||||
assert "ALL_BROWSER_TESTS_PASSED" in result.stdout
|
||||
|
||||
@@ -0,0 +1,438 @@
|
||||
/**
|
||||
* Browser print regression tests for Email Library print and PDF export.
|
||||
*
|
||||
* Requirements:
|
||||
* - Node.js (v20+)
|
||||
* - Playwright (`npm ci` or `npm install -D @playwright/test`)
|
||||
* - Chromium browser binary:
|
||||
* - Standard Playwright install: `npx playwright install chromium`
|
||||
* - Or custom executable via env vars:
|
||||
* - `PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH=/path/to/chrome`
|
||||
* - `CHROME_PATH=/path/to/chrome`
|
||||
* - Or custom browsers cache dir via:
|
||||
* - `PLAYWRIGHT_BROWSERS_PATH=/path/to/cache`
|
||||
*/
|
||||
|
||||
const http = require('http');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
|
||||
let chromium;
|
||||
try {
|
||||
({ chromium } = require('playwright'));
|
||||
} catch (err) {
|
||||
if (err && err.code === 'MODULE_NOT_FOUND') {
|
||||
console.error(
|
||||
'Error: Playwright module not found.\n' +
|
||||
'Please install dependencies by running:\n' +
|
||||
' npm ci\n' +
|
||||
'or ensure NODE_PATH contains the playwright package.'
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
const ROOT = path.resolve(__dirname, '..');
|
||||
|
||||
// Discover available Chromium executable using standard Playwright resolution,
|
||||
// custom environment variables, standard cache paths, or system binaries.
|
||||
function findChromiumPath() {
|
||||
// 1. Explicit environment variable overrides
|
||||
if (process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH && fs.existsSync(process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH)) {
|
||||
return process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH;
|
||||
}
|
||||
if (process.env.CHROME_PATH && fs.existsSync(process.env.CHROME_PATH)) {
|
||||
return process.env.CHROME_PATH;
|
||||
}
|
||||
|
||||
// 2. Playwright's configured/default executable path
|
||||
try {
|
||||
const defaultPath = chromium.executablePath();
|
||||
if (defaultPath && fs.existsSync(defaultPath)) {
|
||||
return defaultPath;
|
||||
}
|
||||
} catch (e) {
|
||||
// chromium.executablePath() may throw if no default browser is installed
|
||||
}
|
||||
|
||||
// 3. Inspect standard Playwright browser cache directories
|
||||
const cacheDirs = [];
|
||||
if (process.env.PLAYWRIGHT_BROWSERS_PATH) {
|
||||
cacheDirs.push(process.env.PLAYWRIGHT_BROWSERS_PATH);
|
||||
}
|
||||
if (process.platform === 'win32') {
|
||||
if (process.env.LOCALAPPDATA) {
|
||||
cacheDirs.push(path.join(process.env.LOCALAPPDATA, 'ms-playwright'));
|
||||
}
|
||||
cacheDirs.push(path.join(os.homedir(), 'AppData', 'Local', 'ms-playwright'));
|
||||
} else if (process.platform === 'darwin') {
|
||||
cacheDirs.push(path.join(os.homedir(), 'Library', 'Caches', 'ms-playwright'));
|
||||
} else {
|
||||
cacheDirs.push(path.join(os.homedir(), '.cache', 'ms-playwright'));
|
||||
}
|
||||
|
||||
for (const cacheDir of cacheDirs) {
|
||||
if (!fs.existsSync(cacheDir)) continue;
|
||||
try {
|
||||
const entries = fs.readdirSync(cacheDir).sort().reverse();
|
||||
for (const entry of entries) {
|
||||
const entryPath = path.join(cacheDir, entry);
|
||||
const candidates = [
|
||||
path.join(entryPath, 'chrome-headless-shell-linux64', 'chrome-headless-shell'),
|
||||
path.join(entryPath, 'chrome-linux64', 'chrome'),
|
||||
path.join(entryPath, 'chrome-linux', 'chrome'),
|
||||
path.join(entryPath, 'chrome-win64', 'chrome.exe'),
|
||||
path.join(entryPath, 'chrome-win', 'chrome.exe'),
|
||||
path.join(entryPath, 'chrome-mac', 'Chromium.app', 'Contents', 'MacOS', 'Chromium'),
|
||||
];
|
||||
for (const candidate of candidates) {
|
||||
if (fs.existsSync(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
// 4. System browser fallbacks
|
||||
const systemCandidates = process.platform === 'win32' ? [
|
||||
path.join(process.env['PROGRAMFILES'] || 'C:\\Program Files', 'Google', 'Chrome', 'Application', 'chrome.exe'),
|
||||
path.join(process.env['PROGRAMFILES(X86)'] || 'C:\\Program Files (x86)', 'Google', 'Chrome', 'Application', 'chrome.exe'),
|
||||
] : [
|
||||
'/usr/bin/google-chrome',
|
||||
'/usr/bin/chromium',
|
||||
'/usr/bin/chromium-browser',
|
||||
'/snap/bin/chromium',
|
||||
];
|
||||
for (const sc of systemCandidates) {
|
||||
if (fs.existsSync(sc)) return sc;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async function run() {
|
||||
const server = http.createServer((req, res) => {
|
||||
const url = new URL(req.url, 'http://127.0.0.1');
|
||||
if (url.pathname.startsWith('/api/email/read/')) {
|
||||
const uid = url.pathname.replace('/api/email/read/', '');
|
||||
if (uid === 'fail_uid' || uid === 'net_fail') {
|
||||
res.writeHead(500, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: 'Mailbox disconnected' }));
|
||||
return;
|
||||
}
|
||||
if (uid === 'crash_network') {
|
||||
req.destroy();
|
||||
return;
|
||||
}
|
||||
|
||||
const markSeen = url.searchParams.get('mark_seen');
|
||||
if (markSeen !== 'false') {
|
||||
res.writeHead(400, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: 'mark_seen must be false' }));
|
||||
return;
|
||||
}
|
||||
|
||||
let body_html = '';
|
||||
if (uid === 'img_test') {
|
||||
body_html = [
|
||||
'<div>',
|
||||
'<p>Email with diverse images</p>',
|
||||
'<!-- Inline image with alt text -->',
|
||||
'<img src="cid:logo123" alt="Company Brand Logo">',
|
||||
'<!-- Remote image with title -->',
|
||||
'<img src="https://example.com/tracker.png" title="Tracking Pixel">',
|
||||
'<!-- Image without alt or title -->',
|
||||
'<img src="https://example.com/no-alt.jpg">',
|
||||
'<!-- Interactive control that shouldn\'t be in print -->',
|
||||
'<button type="button" class="btn-action">Interactive Action</button>',
|
||||
'<!-- XSS payload -->',
|
||||
'<script>window.__pwned = true;</script>',
|
||||
'</div>'
|
||||
].join('\n');
|
||||
} else if (uid === 'long_msg') {
|
||||
body_html = `<div><p>${'A'.repeat(5000)}</p></div>`;
|
||||
} else {
|
||||
body_html = `<div><p>Standard message body for ${uid}</p></div>`;
|
||||
}
|
||||
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({
|
||||
uid: uid,
|
||||
subject: `Subject ${uid}`,
|
||||
from_name: 'Sender',
|
||||
from_address: 'sender@example.com',
|
||||
to: 'user@example.com',
|
||||
date: '2026-10-20',
|
||||
date_display: 'Oct 20, 2026',
|
||||
body: `Plain text for ${uid}`,
|
||||
body_html: body_html,
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
let filePath = path.join(ROOT, url.pathname);
|
||||
if (fs.existsSync(filePath) && fs.statSync(filePath).isFile()) {
|
||||
const ext = path.extname(filePath);
|
||||
const mime = ext === '.js' ? 'application/javascript' : ext === '.html' ? 'text/html' : ext === '.css' ? 'text/css' : 'text/plain';
|
||||
res.writeHead(200, { 'Content-Type': mime });
|
||||
res.end(fs.readFileSync(filePath));
|
||||
return;
|
||||
}
|
||||
res.writeHead(404);
|
||||
res.end('Not found');
|
||||
});
|
||||
|
||||
await new Promise(r => server.listen(0, '127.0.0.1', r));
|
||||
const port = server.address().port;
|
||||
const origin = `http://127.0.0.1:${port}`;
|
||||
|
||||
const launchOpts = { headless: true };
|
||||
const chromePath = findChromiumPath();
|
||||
if (chromePath) launchOpts.executablePath = chromePath;
|
||||
|
||||
let browser;
|
||||
try {
|
||||
browser = await chromium.launch(launchOpts);
|
||||
} catch (launchErr) {
|
||||
throw new Error(
|
||||
`Failed to launch Chromium browser: ${launchErr.message}\n` +
|
||||
'Missing or unexecutable Chromium binary.\n' +
|
||||
'To install Playwright Chromium, run:\n' +
|
||||
' npx playwright install chromium\n' +
|
||||
'Or set PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH to a valid Chromium executable.'
|
||||
);
|
||||
}
|
||||
const context = await browser.newContext();
|
||||
const page = await context.newPage();
|
||||
|
||||
const results = {};
|
||||
|
||||
try {
|
||||
await page.goto(`${origin}/static/index.html`);
|
||||
await page.evaluate(() => {
|
||||
if (!document.getElementById('email-lib-bulk-actions')) {
|
||||
const d = document.createElement('div'); d.id = 'email-lib-bulk-actions'; document.body.appendChild(d);
|
||||
}
|
||||
if (!document.getElementById('email-lib-selected-count')) {
|
||||
const d = document.createElement('div'); d.id = 'email-lib-selected-count'; document.body.appendChild(d);
|
||||
}
|
||||
});
|
||||
|
||||
// TEST 1: Full Print Image Sanitization and Replacement
|
||||
const printImageResult = await page.evaluate(async (base) => {
|
||||
window.API_BASE = base;
|
||||
const mod = await import(`${base}/static/js/emailLibrary/index.js`);
|
||||
const stateMod = await import(`${base}/static/js/emailLibrary/state.js`);
|
||||
|
||||
stateMod.state._selectedUids = new Set(['img_test']);
|
||||
stateMod.state._libEmails = [{ uid: 'img_test', subject: 'Image Test', folder: 'INBOX' }];
|
||||
|
||||
let iframePrintInvoked = false;
|
||||
let printDocHtml = '';
|
||||
let afterPrintTriggered = false;
|
||||
let hasPlaceholderElement = false;
|
||||
|
||||
const observer = new MutationObserver(() => {
|
||||
const frame = document.getElementById('email-browser-print-frame');
|
||||
if (frame && frame.contentWindow && !frame._patched) {
|
||||
frame._patched = true;
|
||||
frame.contentWindow.print = function() {
|
||||
iframePrintInvoked = true;
|
||||
printDocHtml = frame.contentDocument ? frame.contentDocument.documentElement.outerHTML : '';
|
||||
const body = frame.contentDocument ? frame.contentDocument.body : null;
|
||||
hasPlaceholderElement = !!body?.querySelector('.email-inline-image-placeholder');
|
||||
frame.contentWindow.dispatchEvent(new Event('afterprint'));
|
||||
afterPrintTriggered = true;
|
||||
};
|
||||
}
|
||||
});
|
||||
observer.observe(document.body, { childList: true });
|
||||
|
||||
await mod._exportSelectedEmailPdf();
|
||||
observer.disconnect();
|
||||
|
||||
const frameAfter = document.getElementById('email-browser-print-frame');
|
||||
|
||||
return {
|
||||
iframePrintInvoked,
|
||||
afterPrintTriggered,
|
||||
frameRemovedAfterPrint: frameAfter === null,
|
||||
hasLogoNotice: printDocHtml.includes('[Image: Company Brand Logo]'),
|
||||
hasTrackerNotice: printDocHtml.includes('[Image: Tracking Pixel]'),
|
||||
hasNoAltNotice: printDocHtml.includes('[Image omitted]'),
|
||||
hasPlaceholderElement,
|
||||
hasRemoteImageBlockedText: printDocHtml.includes('Remote image blocked'),
|
||||
hasDownloadButton: printDocHtml.includes('email-inline-image-download-btn'),
|
||||
hasInteractiveActionBtn: printDocHtml.includes('btn-action'),
|
||||
hasXSS: printDocHtml.includes('<script>') || printDocHtml.includes('window.__pwned'),
|
||||
};
|
||||
}, origin);
|
||||
|
||||
results.printImageSanitization = printImageResult;
|
||||
|
||||
// TEST 2: Partial Failure - 1 failure out of 3 emails
|
||||
const partialFailureResult = await page.evaluate(async (base) => {
|
||||
window.API_BASE = base;
|
||||
const mod = await import(`${base}/static/js/emailLibrary/index.js`);
|
||||
const stateMod = await import(`${base}/static/js/emailLibrary/state.js`);
|
||||
|
||||
const messages = [
|
||||
{ uid: 'msg1', subject: 'Email One', folder: 'INBOX' },
|
||||
{ uid: 'fail_uid', subject: 'Failing Email', folder: 'INBOX' },
|
||||
{ uid: 'msg2', subject: 'Email Two', folder: 'INBOX' },
|
||||
];
|
||||
stateMod.state._selectedUids = new Set(messages.map(m => m.uid));
|
||||
stateMod.state._libEmails = messages;
|
||||
|
||||
let printInvoked = false;
|
||||
let printDocHtml = '';
|
||||
|
||||
const observer = new MutationObserver(() => {
|
||||
const frame = document.getElementById('email-browser-print-frame');
|
||||
if (frame && frame.contentWindow && !frame._patched) {
|
||||
frame._patched = true;
|
||||
frame.contentWindow.print = function() {
|
||||
printInvoked = true;
|
||||
printDocHtml = frame.contentDocument ? frame.contentDocument.documentElement.outerHTML : '';
|
||||
frame.contentWindow.dispatchEvent(new Event('afterprint'));
|
||||
};
|
||||
}
|
||||
});
|
||||
observer.observe(document.body, { childList: true });
|
||||
|
||||
await mod._exportSelectedEmailPdf();
|
||||
observer.disconnect();
|
||||
|
||||
const toast = document.getElementById('toast')?.textContent || '';
|
||||
|
||||
return {
|
||||
printInvoked,
|
||||
toast,
|
||||
hasMsg1: printDocHtml.includes('Subject msg1'),
|
||||
hasMsg2: printDocHtml.includes('Subject msg2'),
|
||||
hasOmittedNotice: printDocHtml.includes('1 selected email was unavailable and omitted from this print'),
|
||||
};
|
||||
}, origin);
|
||||
|
||||
results.partialFailureTest = partialFailureResult;
|
||||
|
||||
// TEST 3: All emails fail
|
||||
const allFailedResult = await page.evaluate(async (base) => {
|
||||
window.API_BASE = base;
|
||||
const mod = await import(`${base}/static/js/emailLibrary/index.js`);
|
||||
const stateMod = await import(`${base}/static/js/emailLibrary/state.js`);
|
||||
|
||||
const messages = [
|
||||
{ uid: 'fail_uid', subject: 'Fail 1', folder: 'INBOX' },
|
||||
{ uid: 'net_fail', subject: 'Fail 2', folder: 'INBOX' },
|
||||
];
|
||||
stateMod.state._selectedUids = new Set(messages.map(m => m.uid));
|
||||
stateMod.state._libEmails = messages;
|
||||
|
||||
let printInvoked = false;
|
||||
const observer = new MutationObserver(() => {
|
||||
const frame = document.getElementById('email-browser-print-frame');
|
||||
if (frame && frame.contentWindow && !frame._patched) {
|
||||
frame._patched = true;
|
||||
frame.contentWindow.print = function() {
|
||||
printInvoked = true;
|
||||
};
|
||||
}
|
||||
});
|
||||
observer.observe(document.body, { childList: true });
|
||||
|
||||
let thrownError = null;
|
||||
try {
|
||||
await mod._exportSelectedEmailPdf();
|
||||
} catch (err) {
|
||||
thrownError = err.message || String(err);
|
||||
}
|
||||
observer.disconnect();
|
||||
|
||||
const frame = document.getElementById('email-browser-print-frame');
|
||||
const toast = document.getElementById('toast')?.textContent || '';
|
||||
|
||||
return {
|
||||
printInvoked,
|
||||
frameOpened: frame !== null,
|
||||
thrownError,
|
||||
toast,
|
||||
};
|
||||
}, origin);
|
||||
|
||||
results.allFailedTest = allFailedResult;
|
||||
|
||||
// TEST 4: Idempotent Iframe Cleanup & Window Afterprint
|
||||
const cleanupResult = await page.evaluate(async (base) => {
|
||||
window.API_BASE = base;
|
||||
const mod = await import(`${base}/static/js/emailLibrary/index.js`);
|
||||
const stateMod = await import(`${base}/static/js/emailLibrary/state.js`);
|
||||
|
||||
stateMod.state._selectedUids = new Set(['msg1']);
|
||||
stateMod.state._libEmails = [{ uid: 'msg1', subject: 'Cleanup Test', folder: 'INBOX' }];
|
||||
|
||||
const observer = new MutationObserver(() => {
|
||||
const frame = document.getElementById('email-browser-print-frame');
|
||||
if (frame && frame.contentWindow && !frame._patched) {
|
||||
frame._patched = true;
|
||||
frame.contentWindow.print = function() {
|
||||
window.dispatchEvent(new Event('afterprint'));
|
||||
};
|
||||
}
|
||||
});
|
||||
observer.observe(document.body, { childList: true });
|
||||
|
||||
await mod._exportSelectedEmailPdf();
|
||||
observer.disconnect();
|
||||
|
||||
const frameAfter = document.getElementById('email-browser-print-frame');
|
||||
return {
|
||||
frameRemovedViaWindowAfterprint: frameAfter === null,
|
||||
};
|
||||
}, origin);
|
||||
|
||||
results.cleanupTest = cleanupResult;
|
||||
|
||||
} finally {
|
||||
await browser.close();
|
||||
server.close();
|
||||
}
|
||||
|
||||
// Assertions
|
||||
const t1 = results.printImageSanitization;
|
||||
if (!t1.iframePrintInvoked) throw new Error('Assertion failed: print not invoked');
|
||||
if (!t1.frameRemovedAfterPrint) throw new Error('Assertion failed: frame not removed');
|
||||
if (!t1.hasLogoNotice) throw new Error('Assertion failed: [Image: Company Brand Logo] missing');
|
||||
if (!t1.hasTrackerNotice) throw new Error('Assertion failed: [Image: Tracking Pixel] missing');
|
||||
if (!t1.hasNoAltNotice) throw new Error('Assertion failed: [Image omitted] missing');
|
||||
if (t1.hasPlaceholderElement) throw new Error('Assertion failed: .email-inline-image-placeholder found in print doc');
|
||||
if (t1.hasRemoteImageBlockedText) throw new Error('Assertion failed: Remote image blocked leaked into print doc');
|
||||
if (t1.hasDownloadButton) throw new Error('Assertion failed: download button leaked into print doc');
|
||||
if (t1.hasInteractiveActionBtn) throw new Error('Assertion failed: interactive button leaked into print doc');
|
||||
if (t1.hasXSS) throw new Error('Assertion failed: script tag found');
|
||||
|
||||
const t2 = results.partialFailureTest;
|
||||
if (!t2.printInvoked) throw new Error('Assertion failed: partial print was not invoked');
|
||||
if (!t2.hasMsg1 || !t2.hasMsg2) throw new Error('Assertion failed: successful messages missing from print');
|
||||
if (!t2.hasOmittedNotice) throw new Error('Assertion failed: omitted notice banner missing');
|
||||
if (!t2.toast.includes('Prepared 2 emails for printing; 1 unavailable')) {
|
||||
throw new Error('Assertion failed: toast text mismatch: ' + t2.toast);
|
||||
}
|
||||
|
||||
const t3 = results.allFailedTest;
|
||||
if (t3.printInvoked) throw new Error('Assertion failed: print should NOT be invoked when all fail');
|
||||
if (t3.frameOpened) throw new Error('Assertion failed: print frame should NOT remain in DOM');
|
||||
|
||||
const t4 = results.cleanupTest;
|
||||
if (!t4.frameRemovedViaWindowAfterprint) throw new Error('Assertion failed: top window afterprint did not clean up frame');
|
||||
|
||||
console.log('ALL_BROWSER_TESTS_PASSED');
|
||||
}
|
||||
|
||||
run().catch(err => {
|
||||
console.error('Browser test failed:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
"""Email list stale-while-revalidate (SWR) regression tests.
|
||||
|
||||
A plain list view must never block ~2-5 s on IMAP while we already hold a
|
||||
recent snapshot or a durable index: it should return the snapshot immediately
|
||||
(with ``sync.stale``) and refresh in the background. Blocking IMAP only remains
|
||||
for a genuine cold start (no snapshot AND no indexed rows).
|
||||
"""
|
||||
import asyncio
|
||||
import pathlib
|
||||
|
||||
from routes.email import email_routes as er
|
||||
|
||||
SRC = pathlib.Path(er.__file__).read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def _list_endpoint():
|
||||
router = er.setup_email_routes()
|
||||
for route in router.routes:
|
||||
if getattr(route, "name", "") == "list_emails":
|
||||
return route.endpoint
|
||||
raise AssertionError("list_emails route not found")
|
||||
|
||||
|
||||
def _call(endpoint, **over):
|
||||
kwargs = dict(
|
||||
folder="INBOX", limit=50, offset=0, filter="all", from_addr=None,
|
||||
account_id=None, has_attachments=0, date_from=None, date_to=None,
|
||||
cached_only=0, cache_bust=None, refresh=0, owner="pewds",
|
||||
)
|
||||
kwargs.update(over)
|
||||
return asyncio.run(endpoint(**kwargs))
|
||||
|
||||
|
||||
def test_list_returns_index_snapshot_with_a_stale_marker_without_blocking_on_imap(monkeypatch):
|
||||
endpoint = _list_endpoint()
|
||||
monkeypatch.setattr(
|
||||
er, "_email_index_list",
|
||||
lambda owner, account_id, folder, filter_, limit, offset, has_att: (
|
||||
[{"uid": "11", "subject": "Indexed", "is_read": False}], 1, "2026-10-11T00:00:00Z",
|
||||
),
|
||||
)
|
||||
# The synchronous path must never open IMAP; if it did the request would
|
||||
# raise instead of returning the index snapshot immediately.
|
||||
monkeypatch.setattr(er, "_imap_connect", lambda *a, **k: (_ for _ in ()).throw(
|
||||
AssertionError("SWR must not open IMAP when an index snapshot exists")))
|
||||
|
||||
result = _call(endpoint, cache_bust="123")
|
||||
assert result["total"] == 1
|
||||
assert [e["uid"] for e in result["emails"]] == ["11"]
|
||||
assert result["sync"]["stale"] is True
|
||||
assert result["sync"]["source"] in ("index", "cache_stale")
|
||||
|
||||
|
||||
def test_cold_start_with_no_snapshot_and_no_index_is_not_marked_stale(monkeypatch):
|
||||
# With neither a snapshot nor an index, SWR must NOT fire: the request
|
||||
# falls through to the real (blocking) path, so the response is not a
|
||||
# stale-marked snapshot.
|
||||
endpoint = _list_endpoint()
|
||||
monkeypatch.setattr(er, "_email_index_list", lambda *a, **k: ([], 0, None))
|
||||
monkeypatch.setattr(er, "_imap_connect", lambda *a, **k: (_ for _ in ()).throw(
|
||||
RuntimeError("offline test")))
|
||||
try:
|
||||
result = _call(endpoint)
|
||||
except Exception:
|
||||
return # the cold path legitimately attempted real IMAP
|
||||
assert isinstance(result, dict)
|
||||
assert not (result.get("sync") or {}).get("stale")
|
||||
|
||||
|
||||
def test_swr_contract_is_wired_into_the_list_path():
|
||||
assert "_LIST_STALE" in SRC
|
||||
assert "def _list_stale_put" in SRC
|
||||
assert "def _schedule_list_refresh" in SRC
|
||||
assert 'sync_meta["source"] = "cache_stale"' in SRC
|
||||
assert 'sync_meta["stale"] = True' in SRC
|
||||
# The manual-refresh contract the UI relies on must stay intact.
|
||||
assert "manual_refresh = bool(refresh)" in SRC
|
||||
assert "_invalidate_list_cache(account_id, folder)" in SRC
|
||||
assert "cached_rows = {} if refresh else _email_index_rows" in SRC
|
||||
@@ -79,3 +79,68 @@ async def test_inherited_authority_still_cannot_widen_to_ungranted_bash(tmp_path
|
||||
assert pending.process_operation is None
|
||||
_, result = await _dispatch(child, "bash", "printf approved-run", exact)
|
||||
assert result.get("blocked") is True
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def agent_workspace():
|
||||
import os
|
||||
from src.constants import AGENT_WORKSPACE_DIR
|
||||
os.makedirs(AGENT_WORKSPACE_DIR, exist_ok=True)
|
||||
return os.path.realpath(AGENT_WORKSPACE_DIR)
|
||||
|
||||
|
||||
def test_approval_without_a_workspace_seals_a_launch_in_the_agent_workspace(agent_workspace):
|
||||
# Agent mode with no folder picked is the default. bash already runs in the
|
||||
# agent workspace there (agent_cwd), so that is where its approval seals.
|
||||
authority = _authority("", "get_workspace")
|
||||
|
||||
pending, _ = _approve(authority, "bash", "pwd")
|
||||
|
||||
bound = pending.process_operation
|
||||
assert bound is not None
|
||||
assert bound.launch is not None
|
||||
assert bound.launch.scope.root.path == agent_workspace
|
||||
|
||||
|
||||
async def test_approved_bash_without_a_workspace_runs_in_the_agent_workspace(agent_workspace):
|
||||
authority = _authority("", "get_workspace")
|
||||
_, exact = _approve(authority, "bash", "pwd")
|
||||
|
||||
description, result = await _dispatch(authority, "bash", "pwd", exact)
|
||||
|
||||
assert result.get("failure_kind") != "resource_identity_denied", result
|
||||
assert result.get("exit_code") == 0, result
|
||||
containment = result.get("containment") or {}
|
||||
assert containment.get("workspace", agent_workspace) == agent_workspace, containment
|
||||
|
||||
|
||||
async def test_inherited_authority_without_a_workspace_still_cannot_widen(agent_workspace):
|
||||
from dataclasses import replace
|
||||
child = replace(_authority("", "get_workspace"), inherited=True)
|
||||
pending, exact = _approve(child, "bash", "pwd")
|
||||
|
||||
assert pending.process_operation is None
|
||||
_, result = await _dispatch(child, "bash", "pwd", exact)
|
||||
assert result.get("blocked") is True
|
||||
|
||||
|
||||
async def test_approved_write_without_a_workspace_lands_in_the_agent_workspace(agent_workspace):
|
||||
# write_file had the same gap: with no workspace the approval sealed no
|
||||
# root, so "approve" ended in "no sealed resource identity".
|
||||
import os
|
||||
target = "approved-write-6635.txt"
|
||||
content = f"{target}\nhello"
|
||||
authority = _authority("", "get_workspace")
|
||||
pending, exact = _approve(authority, "write_file", content)
|
||||
assert pending.resource_operation is not None
|
||||
|
||||
_, result = await _dispatch(authority, "write_file", content, exact)
|
||||
|
||||
assert result.get("failure_kind") != "resource_identity_denied", result
|
||||
written = os.path.join(agent_workspace, target)
|
||||
try:
|
||||
with open(written) as fh:
|
||||
assert fh.read().strip() == "hello"
|
||||
finally:
|
||||
if os.path.exists(written):
|
||||
os.remove(written)
|
||||
|
||||
@@ -76,3 +76,158 @@ def test_mixed_endpoint_requires_configured_image_model(monkeypatch):
|
||||
assert lookup(Query(), endpoint.base_url, 'alice', 'text-model') is None
|
||||
assert lookup(Query(), endpoint.base_url, 'alice', 'openai/gpt-image-1') is None
|
||||
assert lookup(Query(), 'https://unregistered.test/v1', 'alice', 'openai/gpt-5-image') is None
|
||||
|
||||
|
||||
def _form_fields(content, content_type):
|
||||
"""Parse simple multipart form fields (text parts only) from a request body."""
|
||||
boundary = content_type.split('boundary=')[1].strip().strip('"')
|
||||
fields = {}
|
||||
for part in content.split(('--' + boundary).encode()):
|
||||
if b'name="' not in part:
|
||||
continue
|
||||
head, _, body = part.partition(b'\r\n\r\n')
|
||||
import re as _re
|
||||
match = _re.search(rb'name="([^"]+)"', head)
|
||||
if not match:
|
||||
continue
|
||||
fields[match.group(1).decode()] = body.rstrip(b'\r\n-').decode('latin1')
|
||||
return fields
|
||||
|
||||
|
||||
def _self_hosted_inpaint(monkeypatch, image, mask, body_extra, respond, include_mask=True):
|
||||
base_url = 'http://100.69.120.65:8188/v1'
|
||||
endpoint = SimpleNamespace(base_url=base_url, api_key='local', owner='alice')
|
||||
monkeypatch.setattr(gallery, 'SessionLocal', lambda: SimpleNamespace(close=lambda: None))
|
||||
monkeypatch.setattr(gallery, 'require_privilege', lambda *a: 'alice')
|
||||
monkeypatch.setattr(gallery, '_visible_image_endpoint_for_base', lambda *a: endpoint)
|
||||
monkeypatch.setattr('src.url_safety.check_outbound_url', lambda *a, **k: (True, ''))
|
||||
_real_client = httpx.AsyncClient
|
||||
monkeypatch.setattr(httpx, 'AsyncClient',
|
||||
lambda **kw: _real_client(transport=httpx.MockTransport(respond), **kw))
|
||||
|
||||
class Request:
|
||||
async def json(self):
|
||||
payload = {'_endpoint': base_url, 'image': png(image), 'prompt': 'do a thing'}
|
||||
if include_mask:
|
||||
payload['mask'] = png(mask)
|
||||
payload.update(body_extra)
|
||||
return payload
|
||||
|
||||
route = next(r for r in gallery.setup_gallery_routes().routes if r.path == '/api/image/inpaint')
|
||||
return asyncio.run(route.endpoint(Request()))
|
||||
|
||||
|
||||
def test_self_hosted_inpaint_caps_oversize_and_restores_dimensions(monkeypatch):
|
||||
seen = {}
|
||||
|
||||
def respond(request):
|
||||
assert str(request.url) == 'http://100.69.120.65:8188/v1/images/edits'
|
||||
seen.update(_form_fields(request.content, request.headers['content-type']))
|
||||
return httpx.Response(200, json={'data': [{'b64_json': png(Image.new('RGB', (768, 576), 'blue'))}]})
|
||||
|
||||
out = _self_hosted_inpaint(monkeypatch, Image.new('RGB', (1600, 1200), 'red'),
|
||||
Image.new('L', (1600, 1200), 255),
|
||||
{'steps': 8}, respond)
|
||||
# Working size is capped to a model-friendly size (<=768 long side, /16).
|
||||
assert seen['size'] == '768x576'
|
||||
# A requested step budget passes through up to the cap (default 8).
|
||||
assert seen['steps'] == '8'
|
||||
# The returned image keeps the original dimensions.
|
||||
assert Image.open(io.BytesIO(base64.b64decode(out['image']))).size == (1600, 1200)
|
||||
|
||||
|
||||
def test_self_hosted_inpaint_clamps_steps_to_the_cap(monkeypatch):
|
||||
seen = {}
|
||||
|
||||
def respond(request):
|
||||
seen.update(_form_fields(request.content, request.headers['content-type']))
|
||||
return httpx.Response(200, json={'data': [{'b64_json': png(Image.new('RGB', (640, 480), 'blue'))}]})
|
||||
|
||||
_self_hosted_inpaint(monkeypatch, Image.new('RGB', (640, 480), 'red'),
|
||||
Image.new('L', (640, 480), 255), {'steps': 20}, respond)
|
||||
# Over-cap requests are clamped to the configured maximum (default 8).
|
||||
assert seen['steps'] == '8'
|
||||
|
||||
|
||||
def test_self_hosted_inpaint_leaves_small_images_unchanged(monkeypatch):
|
||||
seen = {}
|
||||
|
||||
def respond(request):
|
||||
seen.update(_form_fields(request.content, request.headers['content-type']))
|
||||
return httpx.Response(200, json={'data': [{'b64_json': png(Image.new('RGB', (640, 480), 'green'))}]})
|
||||
|
||||
out = _self_hosted_inpaint(monkeypatch, Image.new('RGB', (640, 480), 'red'),
|
||||
Image.new('L', (640, 480), 255), {}, respond)
|
||||
# Under the cap: dimensions pass through untouched; with no requested count
|
||||
# sent the moderate default (4) is used.
|
||||
assert seen['size'] == '640x480'
|
||||
assert seen['steps'] == '4'
|
||||
assert Image.open(io.BytesIO(base64.b64decode(out['image']))).size == (640, 480)
|
||||
|
||||
|
||||
def test_self_hosted_inpaint_without_mask_uses_a_full_white_mask(monkeypatch):
|
||||
"""A whole-image instruction edit may omit the mask; it must not raise
|
||||
KeyError -> 400 (the health-audit regression)."""
|
||||
seen = {}
|
||||
mask_bytes = {}
|
||||
|
||||
def respond(request):
|
||||
seen.update(_form_fields(request.content, request.headers['content-type']))
|
||||
# The provider must receive a (full-white) mask, not a missing one.
|
||||
import re as _re
|
||||
parts = request.content.split(b'--')
|
||||
for part in parts:
|
||||
if b'name="mask"' in part:
|
||||
head, _, body = part.partition(b'\r\n\r\n')
|
||||
mask_bytes['png'] = body.rstrip(b'\r\n-')
|
||||
return httpx.Response(200, json={'data': [{'b64_json': png(Image.new('RGB', (32, 32), 'blue'))}]})
|
||||
|
||||
out = _self_hosted_inpaint(monkeypatch, Image.new('RGB', (32, 32), 'red'),
|
||||
None, {}, respond, include_mask=False)
|
||||
assert 'image' in out, out
|
||||
assert seen.get('size') == '32x32'
|
||||
# The synthesized mask is image-sized and all-white (whole image editable).
|
||||
mask_img = Image.open(io.BytesIO(mask_bytes['png'])).convert('L')
|
||||
assert mask_img.size == (32, 32)
|
||||
assert mask_img.getextrema() == (255, 255)
|
||||
|
||||
|
||||
def test_openai_inpaint_without_mask_is_a_clean_400(monkeypatch):
|
||||
"""OpenAI edits need a mask; a missing one must be a clean 400, not a
|
||||
KeyError traceback."""
|
||||
from fastapi import HTTPException
|
||||
|
||||
endpoint = SimpleNamespace(base_url='https://api.openai.com/v1', api_key='sk-x', owner='alice')
|
||||
monkeypatch.setattr(gallery, 'SessionLocal', lambda: SimpleNamespace(close=lambda: None))
|
||||
monkeypatch.setattr(gallery, 'require_privilege', lambda *a: 'alice')
|
||||
monkeypatch.setattr(gallery, '_visible_image_endpoint_for_base', lambda *a: endpoint)
|
||||
monkeypatch.setattr('src.url_safety.check_outbound_url', lambda *a, **k: (True, ''))
|
||||
|
||||
class Request:
|
||||
async def json(self):
|
||||
return {'_endpoint': 'https://api.openai.com/v1',
|
||||
'image': png(Image.new('RGB', (32, 32), 'red')), 'prompt': 'x'}
|
||||
|
||||
route = next(r for r in gallery.setup_gallery_routes().routes if r.path == '/api/image/inpaint')
|
||||
try:
|
||||
asyncio.run(route.endpoint(Request()))
|
||||
raise AssertionError('expected a 400 for a missing mask')
|
||||
except HTTPException as exc:
|
||||
assert exc.status_code == 400
|
||||
assert 'mask' in str(exc.detail).lower()
|
||||
|
||||
|
||||
def test_self_hosted_inpaint_forwards_request_id(monkeypatch):
|
||||
"""A client-supplied request_id is forwarded to the provider so the client
|
||||
can poll GET /api/image/edit-progress for step progress."""
|
||||
seen = {}
|
||||
|
||||
def respond(request):
|
||||
seen.update(_form_fields(request.content, request.headers.get('content-type', '')))
|
||||
return httpx.Response(200, json={'model': 'qwen-image-edit-2511',
|
||||
'data': [{'b64_json': png(Image.new('RGBA', (32, 32), 'red'))}]})
|
||||
|
||||
out = _self_hosted_inpaint(monkeypatch, Image.new('RGB', (32, 32), 'red'),
|
||||
Image.new('L', (32, 32), 255), {'request_id': 'job-777'}, respond)
|
||||
assert 'image' in out, out
|
||||
assert seen.get('request_id') == 'job-777'
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
"""Tests for the image-op progress contract (HARNESS-owned).
|
||||
|
||||
The 7011 side accepts an optional client-supplied ``request_id`` on heavy image
|
||||
ops and exposes ``GET /api/image/edit-progress`` which proxies the provider's
|
||||
``/v1/images/progress/{request_id}`` route. When the provider has no such route
|
||||
the endpoint returns ``{"available": false}`` so the client keeps an
|
||||
indeterminate bar.
|
||||
"""
|
||||
import asyncio
|
||||
from types import SimpleNamespace
|
||||
|
||||
import httpx
|
||||
|
||||
import routes.gallery_routes as gallery
|
||||
|
||||
|
||||
def _route(path):
|
||||
return next(r for r in gallery.setup_gallery_routes().routes if r.path == path)
|
||||
|
||||
|
||||
class _Req:
|
||||
def __init__(self, query):
|
||||
self.query_params = query
|
||||
|
||||
|
||||
def _fake_db():
|
||||
class Db:
|
||||
def close(self):
|
||||
pass
|
||||
|
||||
def query(self, *args, **kwargs):
|
||||
return self
|
||||
|
||||
def filter(self, *args, **kwargs):
|
||||
return self
|
||||
|
||||
def first(self):
|
||||
return None
|
||||
|
||||
return Db()
|
||||
|
||||
|
||||
def test_edit_progress_requires_request_id(monkeypatch):
|
||||
monkeypatch.setattr(gallery, "require_privilege", lambda *a: "alice")
|
||||
route = _route("/api/image/edit-progress")
|
||||
try:
|
||||
asyncio.run(route.endpoint(_Req({})))
|
||||
except Exception as exc: # FastAPI HTTPException
|
||||
assert getattr(exc, "status_code", None) == 400
|
||||
else:
|
||||
raise AssertionError("expected a 400 for a missing request_id")
|
||||
|
||||
|
||||
def test_edit_progress_rejects_an_unsafe_request_id(monkeypatch):
|
||||
monkeypatch.setattr(gallery, "require_privilege", lambda *a: "alice")
|
||||
monkeypatch.setattr(gallery, "SessionLocal", lambda: _fake_db())
|
||||
route = _route("/api/image/edit-progress")
|
||||
try:
|
||||
asyncio.run(route.endpoint(_Req({"request_id": "../secret"})))
|
||||
except Exception as exc:
|
||||
assert getattr(exc, "status_code", None) == 400
|
||||
else:
|
||||
raise AssertionError("expected a 400 for an unsafe request_id")
|
||||
|
||||
|
||||
def test_edit_progress_returns_provider_payload(monkeypatch):
|
||||
endpoint = SimpleNamespace(base_url="http://100.69.120.65:8188/v1", api_key="k")
|
||||
monkeypatch.setattr(gallery, "SessionLocal", lambda: _fake_db())
|
||||
monkeypatch.setattr(gallery, "require_privilege", lambda *a: "alice")
|
||||
monkeypatch.setattr(gallery, "_first_visible_image_endpoint", lambda *a: endpoint)
|
||||
seen = {}
|
||||
|
||||
def respond(request):
|
||||
seen["url"] = str(request.url)
|
||||
# Live seneca contract stages: loading | editing | compositing | done | error.
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={"stage": "editing", "step": 3, "steps": 8, "eta_s": 42.0, "progress": 0.375},
|
||||
)
|
||||
|
||||
client = httpx.AsyncClient
|
||||
monkeypatch.setattr(httpx, "AsyncClient", lambda **k: client(transport=httpx.MockTransport(respond), **k))
|
||||
out = asyncio.run(_route("/api/image/edit-progress").endpoint(_Req({"request_id": "abc-123"})))
|
||||
assert out["available"] is True
|
||||
assert out["stage"] == "editing" # passed through unchanged
|
||||
assert out["step"] == 3 and out["steps"] == 8
|
||||
assert seen["url"].endswith("/images/progress/abc-123")
|
||||
|
||||
|
||||
def test_edit_progress_available_false_when_provider_has_no_route(monkeypatch):
|
||||
endpoint = SimpleNamespace(base_url="http://100.69.120.65:8188/v1", api_key="k")
|
||||
monkeypatch.setattr(gallery, "SessionLocal", lambda: _fake_db())
|
||||
monkeypatch.setattr(gallery, "require_privilege", lambda *a: "alice")
|
||||
monkeypatch.setattr(gallery, "_first_visible_image_endpoint", lambda *a: endpoint)
|
||||
|
||||
client = httpx.AsyncClient
|
||||
monkeypatch.setattr(
|
||||
httpx, "AsyncClient",
|
||||
lambda **k: client(transport=httpx.MockTransport(lambda req: httpx.Response(404)), **k),
|
||||
)
|
||||
out = asyncio.run(_route("/api/image/edit-progress").endpoint(_Req({"request_id": "abc"})))
|
||||
assert out["available"] is False
|
||||
|
||||
|
||||
def test_edit_progress_available_false_when_unreachable(monkeypatch):
|
||||
endpoint = SimpleNamespace(base_url="http://100.69.120.65:8188/v1", api_key="k")
|
||||
monkeypatch.setattr(gallery, "SessionLocal", lambda: _fake_db())
|
||||
monkeypatch.setattr(gallery, "require_privilege", lambda *a: "alice")
|
||||
monkeypatch.setattr(gallery, "_first_visible_image_endpoint", lambda *a: endpoint)
|
||||
|
||||
class Boom:
|
||||
async def __aenter__(self):
|
||||
raise httpx.ConnectError("nope")
|
||||
|
||||
async def __aexit__(self, *a):
|
||||
return False
|
||||
|
||||
monkeypatch.setattr(httpx, "AsyncClient", lambda **k: Boom())
|
||||
out = asyncio.run(_route("/api/image/edit-progress").endpoint(_Req({"request_id": "abc"})))
|
||||
assert out["available"] is False
|
||||
@@ -6,7 +6,7 @@ EDITOR = (ROOT / "static/js/galleryEditor.js").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_history_snapshot_captures_document_and_layer_state():
|
||||
snapshot = EDITOR[EDITOR.index("function _snapshotState()") : EDITOR.index("function _saveState(")]
|
||||
snapshot = EDITOR[EDITOR.index("function _snapshotState(options = {})") : EDITOR.index("function _saveState(")]
|
||||
|
||||
assert "activeLayerId: state.activeLayerId" in snapshot
|
||||
assert "nextLayerId: state.nextLayerId" in snapshot
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
"""Heavy in-process image ops must not load GB-scale models by default.
|
||||
|
||||
remove-bg / SAM mask / Real-ESRGAN upscale+denoise used to load rembg / torch
|
||||
models inside the web process and OOM-killed the host. They now default to a
|
||||
graceful 503, proxy to the seneca remote ops service when
|
||||
``ODYSSEUS_IMAGE_OPS_BASE`` is set (translate to the /v1/images/* contract), and
|
||||
only run in-process when explicitly enabled via ODYSSEUS_ALLOW_LOCAL_IMAGE_MODELS.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from routes.gallery import gallery_routes as gr
|
||||
|
||||
|
||||
HEAVY_ROUTES = [
|
||||
("/api/image/remove-bg", {"image": "x"}, "Background removal"),
|
||||
("/api/image/mask", {"image": "x"}, "Smart mask"),
|
||||
("/api/image/upscale-local", {"image": "x"}, "AI upscale"),
|
||||
("/api/image/denoise", {"image": "x"}, "AI denoise"),
|
||||
]
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clean_env(monkeypatch):
|
||||
monkeypatch.setattr(gr, "require_privilege", lambda *a, **k: None)
|
||||
monkeypatch.delenv("ODYSSEUS_IMAGE_OPS_BASE", raising=False)
|
||||
monkeypatch.delenv("ODYSSEUS_ALLOW_LOCAL_IMAGE_MODELS", raising=False)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def client():
|
||||
app = FastAPI()
|
||||
app.include_router(gr.setup_gallery_routes())
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
def test_local_image_models_disabled_by_default():
|
||||
assert gr._local_image_models_enabled() is False
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value", ["1", "true", "Yes", "ON"])
|
||||
def test_local_image_models_enabled_env(monkeypatch, value):
|
||||
monkeypatch.setenv("ODYSSEUS_ALLOW_LOCAL_IMAGE_MODELS", value)
|
||||
assert gr._local_image_models_enabled() is True
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value", ["", "0", "no", "off", "maybe"])
|
||||
def test_local_image_models_disabled_env(monkeypatch, value):
|
||||
monkeypatch.setenv("ODYSSEUS_ALLOW_LOCAL_IMAGE_MODELS", value)
|
||||
assert gr._local_image_models_enabled() is False
|
||||
|
||||
|
||||
def test_rss_mb_is_positive():
|
||||
assert gr._rss_mb() > 0
|
||||
|
||||
|
||||
def test_route_heavy_image_op_returns_none_when_enabled(monkeypatch):
|
||||
monkeypatch.setenv("ODYSSEUS_ALLOW_LOCAL_IMAGE_MODELS", "1")
|
||||
assert asyncio.run(gr._route_heavy_image_op("/api/image/denoise", {}, label="x")) is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize("path,body,label", HEAVY_ROUTES)
|
||||
def test_heavy_ops_503_when_disabled(client, path, body, label):
|
||||
resp = client.post(path, json=body)
|
||||
assert resp.status_code == 503
|
||||
detail = resp.json().get("detail", "")
|
||||
assert "temporarily unavailable" in detail
|
||||
assert label in detail
|
||||
|
||||
|
||||
def test_remove_bg_disabled_does_not_import_rembg(monkeypatch):
|
||||
import sys
|
||||
|
||||
monkeypatch.setitem(sys.modules, "rembg", None)
|
||||
|
||||
app = FastAPI()
|
||||
app.include_router(gr.setup_gallery_routes())
|
||||
client = TestClient(app)
|
||||
resp = client.post("/api/image/remove-bg", json={"image": "x"})
|
||||
assert resp.status_code == 503
|
||||
|
||||
|
||||
class _FakeResponse:
|
||||
def __init__(self, payload, status_code=200):
|
||||
self._payload = payload
|
||||
self.status_code = status_code
|
||||
|
||||
def json(self):
|
||||
return self._payload
|
||||
|
||||
|
||||
class _FakeAsyncClient:
|
||||
calls = []
|
||||
response = _FakeResponse({})
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
pass
|
||||
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *args):
|
||||
return False
|
||||
|
||||
async def post(self, url, json=None, files=None, data=None):
|
||||
_FakeAsyncClient.calls.append((url, {"json": json, "files": files, "data": data}))
|
||||
return _FakeAsyncClient.response
|
||||
|
||||
|
||||
def _client():
|
||||
app = FastAPI()
|
||||
app.include_router(gr.setup_gallery_routes())
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
def test_remove_bg_proxies_to_remote_contract(monkeypatch):
|
||||
monkeypatch.setenv("ODYSSEUS_IMAGE_OPS_BASE", "http://seneca.test:8188")
|
||||
monkeypatch.setattr(httpx, "AsyncClient", _FakeAsyncClient)
|
||||
_FakeAsyncClient.calls = []
|
||||
_FakeAsyncClient.response = _FakeResponse(
|
||||
{"data": [{"b64_json": "RB", "mask_b64": "M", "bbox": [0, 0, 1, 1]}], "model": "birefnet"}
|
||||
)
|
||||
|
||||
resp = _client().post("/api/image/remove-bg", json={"image": "AAAA"})
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["image"] == "RB"
|
||||
url, kw = _FakeAsyncClient.calls[-1]
|
||||
assert url == "http://seneca.test:8188/v1/images/remove-background"
|
||||
assert "image" in kw["files"]
|
||||
|
||||
|
||||
def test_mask_proxies_to_remote_contract(monkeypatch):
|
||||
monkeypatch.setenv("ODYSSEUS_IMAGE_OPS_BASE", "http://seneca.test:8188")
|
||||
monkeypatch.setattr(httpx, "AsyncClient", _FakeAsyncClient)
|
||||
_FakeAsyncClient.calls = []
|
||||
_FakeAsyncClient.response = _FakeResponse(
|
||||
{"masks": [{"b64_json": "MASK", "bbox": [10, 20, 30, 40], "score": 0.9, "label": "person"}],
|
||||
"model": "sam+grounding"}
|
||||
)
|
||||
|
||||
resp = _client().post("/api/image/mask", json={"image": "AAAA", "text": "person"})
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["mask"] == "MASK"
|
||||
# remote [x,y,w,h] -> app [x1,y1,x2,y2]
|
||||
assert body["bbox"] == [10, 20, 40, 60]
|
||||
url, kw = _FakeAsyncClient.calls[-1]
|
||||
assert url == "http://seneca.test:8188/v1/images/mask"
|
||||
assert kw["data"]["prompt"] == "person"
|
||||
|
||||
|
||||
def test_mask_picks_top_score_not_first(monkeypatch):
|
||||
monkeypatch.setenv("ODYSSEUS_IMAGE_OPS_BASE", "http://seneca.test:8188")
|
||||
monkeypatch.setattr(httpx, "AsyncClient", _FakeAsyncClient)
|
||||
_FakeAsyncClient.response = _FakeResponse(
|
||||
{"masks": [
|
||||
{"b64_json": "LOW", "bbox": [0, 0, 1, 1], "score": 0.2},
|
||||
{"b64_json": "HIGH", "bbox": [0, 0, 2, 2], "score": 0.95},
|
||||
{"b64_json": "MID", "bbox": [0, 0, 3, 3], "score": 0.5},
|
||||
], "model": "sam+grounding"}
|
||||
)
|
||||
resp = _client().post("/api/image/mask", json={"image": "AAAA", "text": "person"})
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["mask"] == "HIGH"
|
||||
|
||||
|
||||
def test_mask_box_is_sent_as_xywh(monkeypatch):
|
||||
monkeypatch.setenv("ODYSSEUS_IMAGE_OPS_BASE", "http://seneca.test:8188")
|
||||
monkeypatch.setattr(httpx, "AsyncClient", _FakeAsyncClient)
|
||||
_FakeAsyncClient.calls = []
|
||||
_FakeAsyncClient.response = _FakeResponse({"masks": [{"b64_json": "M", "bbox": [0, 0, 1, 1], "score": 1.0}]})
|
||||
resp = _client().post("/api/image/mask", json={"image": "AAAA", "box": [10, 20, 40, 60]})
|
||||
assert resp.status_code == 200
|
||||
_, kw = _FakeAsyncClient.calls[-1]
|
||||
assert kw["data"]["box"] == "10,20,30,40"
|
||||
|
||||
|
||||
def test_mask_remote_empty_masks_is_200_nothing_selected(monkeypatch):
|
||||
monkeypatch.setenv("ODYSSEUS_IMAGE_OPS_BASE", "http://seneca.test:8188")
|
||||
monkeypatch.setattr(httpx, "AsyncClient", _FakeAsyncClient)
|
||||
_FakeAsyncClient.response = _FakeResponse({"masks": [], "model": "sam+grounding"})
|
||||
resp = _client().post("/api/image/mask", json={"image": "AAAA", "text": "person"})
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["mask"] is None
|
||||
assert body["masks"] == []
|
||||
assert "Nothing selected" in body["error"]
|
||||
|
||||
|
||||
def test_remote_proxy_surfaces_error_status(monkeypatch):
|
||||
monkeypatch.setenv("ODYSSEUS_IMAGE_OPS_BASE", "http://seneca.test:8188")
|
||||
monkeypatch.setattr(httpx, "AsyncClient", _FakeAsyncClient)
|
||||
_FakeAsyncClient.response = _FakeResponse({}, status_code=500)
|
||||
resp = _client().post("/api/image/remove-bg", json={"image": "AAAA"})
|
||||
assert resp.status_code == 503
|
||||
assert "temporarily unavailable" in resp.json().get("detail", "")
|
||||
@@ -0,0 +1,124 @@
|
||||
"""Image endpoint fallback: image models on mixed (non image-only) endpoints.
|
||||
|
||||
Chat image generation resolves its model by name across every enabled endpoint,
|
||||
so a ``qwen-image-edit`` / ``gpt-image-1`` model registered on a shared
|
||||
OpenAI-compatible gateway works there. The editor's generative ops used a
|
||||
stricter image-only lookup and reported "No image generation endpoint
|
||||
configured" on those same setups. These tests pin the fallback that makes the
|
||||
editor find the same endpoint.
|
||||
"""
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
import routes.gallery_routes as gallery_routes
|
||||
|
||||
|
||||
class _Predicate:
|
||||
def __init__(self, check):
|
||||
self._check = check
|
||||
|
||||
def __call__(self, row):
|
||||
return self._check(row)
|
||||
|
||||
def __or__(self, other):
|
||||
return _Predicate(lambda row: self(row) or other(row))
|
||||
|
||||
|
||||
class _Column:
|
||||
def __init__(self, name):
|
||||
self.name = name
|
||||
|
||||
def __eq__(self, value):
|
||||
return _Predicate(lambda row: getattr(row, self.name) == value)
|
||||
|
||||
|
||||
class _ModelEndpoint:
|
||||
base_url = _Column("base_url")
|
||||
model_type = _Column("model_type")
|
||||
is_enabled = _Column("is_enabled")
|
||||
owner = _Column("owner")
|
||||
|
||||
|
||||
class _Query:
|
||||
def __init__(self, rows):
|
||||
self._rows = list(rows)
|
||||
|
||||
def filter(self, *predicates):
|
||||
self._rows = [row for row in self._rows if all(pred(row) for pred in predicates)]
|
||||
return self
|
||||
|
||||
def all(self):
|
||||
return list(self._rows)
|
||||
|
||||
|
||||
class _DB:
|
||||
def __init__(self, rows):
|
||||
self._rows = rows
|
||||
|
||||
def query(self, model):
|
||||
assert model is _ModelEndpoint
|
||||
return _Query(self._rows)
|
||||
|
||||
|
||||
def _ep(base_url, owner=None, *, enabled=True, model_type="chat", models=None):
|
||||
return SimpleNamespace(
|
||||
base_url=base_url,
|
||||
owner=owner,
|
||||
is_enabled=enabled,
|
||||
model_type=model_type,
|
||||
api_key="sk-secret",
|
||||
cached_models=models or [],
|
||||
pinned_models=[],
|
||||
)
|
||||
|
||||
|
||||
def _patch_model(monkeypatch):
|
||||
monkeypatch.setattr(gallery_routes, "ModelEndpoint", _ModelEndpoint)
|
||||
|
||||
|
||||
def test_mixed_endpoint_with_image_model_is_found(monkeypatch):
|
||||
_patch_model(monkeypatch)
|
||||
rows = [_ep("https://gateway.example.com/v1", models=["qwen-image-edit", "llama-3"])]
|
||||
|
||||
ep = gallery_routes._first_visible_image_endpoint(_DB(rows), None)
|
||||
|
||||
assert ep is not None
|
||||
assert ep.base_url == "https://gateway.example.com/v1"
|
||||
|
||||
|
||||
def test_mixed_endpoint_without_image_model_is_ignored(monkeypatch):
|
||||
_patch_model(monkeypatch)
|
||||
rows = [_ep("https://gateway.example.com/v1", models=["llama-3", "mistral"])]
|
||||
|
||||
assert gallery_routes._first_visible_image_endpoint(_DB(rows), None) is None
|
||||
|
||||
|
||||
def test_image_only_endpoint_wins_over_mixed(monkeypatch):
|
||||
_patch_model(monkeypatch)
|
||||
rows = [
|
||||
_ep("https://gateway.example.com/v1", models=["gpt-image-1"]),
|
||||
_ep("https://images.example.com/v1", model_type="image"),
|
||||
]
|
||||
|
||||
ep = gallery_routes._first_visible_image_endpoint(_DB(rows), None)
|
||||
|
||||
assert ep is not None
|
||||
assert ep.model_type == "image"
|
||||
|
||||
|
||||
def test_unreachable_endpoint_reports_502():
|
||||
"""The inpaint proxy must distinguish an unreachable endpoint from a provider error.
|
||||
|
||||
502 (gateway) is reserved for "the image server could not be reached"; 503 is
|
||||
reserved for "generation is switched off on the server", which the editor maps
|
||||
to its own fixed message.
|
||||
"""
|
||||
from pathlib import Path
|
||||
|
||||
src = (
|
||||
Path(__file__).resolve().parents[1] / "routes" / "gallery" / "gallery_routes.py"
|
||||
).read_text()
|
||||
assert "Image server unreachable" in src
|
||||
assert "except httpx.TransportError as exc:" in src
|
||||
assert "provider HTTP" in src
|
||||
assert 'raise HTTPException(503, f"Image endpoint unreachable' not in src
|
||||
@@ -0,0 +1,39 @@
|
||||
"""Source checks for the gallery Settings-tab image-model picker.
|
||||
|
||||
The Quick Edit model picker lives in the editor; HARNESS mirrors it into the
|
||||
gallery Settings tab, fed by GET /api/editor/image-models and sharing the
|
||||
same localStorage key (`ge-ai-command-model`) so the two stay in sync.
|
||||
"""
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
GALLERY_JS = (ROOT / "static/js/gallery.js").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_settings_tab_has_image_model_picker():
|
||||
assert 'id="gallery-image-model"' in GALLERY_JS
|
||||
assert "/api/editor/image-models" in GALLERY_JS
|
||||
assert "ge-ai-command-model" in GALLERY_JS
|
||||
|
||||
|
||||
def test_image_model_picker_lives_in_the_settings_container():
|
||||
settings_idx = GALLERY_JS.index('id="gallery-settings-container"')
|
||||
picker_idx = GALLERY_JS.index('id="gallery-image-model"')
|
||||
assert picker_idx > settings_idx
|
||||
|
||||
|
||||
def test_picker_persists_with_the_shared_localstorage_key():
|
||||
assert "localStorage.setItem(_GE_MODEL_KEY" in GALLERY_JS
|
||||
assert "localStorage.getItem(_GE_MODEL_KEY)" in GALLERY_JS
|
||||
|
||||
|
||||
def test_cloud_image_models_are_listed_alongside_the_local_ones():
|
||||
"""Gateway endpoints (OpenRouter) register as llm but carry image models.
|
||||
|
||||
The picker must surface those ids too — otherwise the only selectable
|
||||
edit model is the local Qwen server.
|
||||
"""
|
||||
routes = (ROOT / "routes/editor_agent_routes.py").read_text(encoding="utf-8")
|
||||
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
|
||||
@@ -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
|
||||
@@ -0,0 +1,187 @@
|
||||
"""Tests for the seneca single-image op proxies (HARNESS-owned).
|
||||
|
||||
POST /api/image/replace-background -> provider /v1/images/replace-background
|
||||
POST /api/image/outpaint -> provider /v1/images/outpaint (alias /extend)
|
||||
|
||||
Both forward a client-supplied JPEG/PNG as multipart with the prompt/params plus
|
||||
``request_id`` (so GET /api/image/edit-progress lights the progress bar) and
|
||||
return the provider image as ``{image}``.
|
||||
"""
|
||||
import asyncio
|
||||
import base64
|
||||
from types import SimpleNamespace
|
||||
|
||||
import httpx
|
||||
|
||||
import routes.gallery_routes as gallery
|
||||
|
||||
_IMG_B64 = base64.b64encode(b"abc-not-a-real-png").decode()
|
||||
|
||||
|
||||
def _route(path):
|
||||
return next(r for r in gallery.setup_gallery_routes().routes if r.path == path)
|
||||
|
||||
|
||||
class _Req:
|
||||
def __init__(self, body):
|
||||
self._body = body
|
||||
|
||||
async def json(self):
|
||||
return self._body
|
||||
|
||||
|
||||
def _fake_db():
|
||||
class Db:
|
||||
def close(self):
|
||||
pass
|
||||
|
||||
def query(self, *args, **kwargs):
|
||||
return self
|
||||
|
||||
def filter(self, *args, **kwargs):
|
||||
return self
|
||||
|
||||
def first(self):
|
||||
return None
|
||||
|
||||
return Db()
|
||||
|
||||
|
||||
def _setup(monkeypatch, respond):
|
||||
endpoint = SimpleNamespace(base_url="http://100.69.120.65:8188/v1", api_key="k")
|
||||
monkeypatch.setattr(gallery, "SessionLocal", lambda: _fake_db())
|
||||
monkeypatch.setattr(gallery, "require_privilege", lambda *a: "alice")
|
||||
monkeypatch.setattr(gallery, "_first_visible_image_endpoint", lambda *a: endpoint)
|
||||
seen = {}
|
||||
|
||||
def _respond(request):
|
||||
seen["url"] = str(request.url)
|
||||
seen["body"] = bytes(request.content)
|
||||
return respond(request)
|
||||
|
||||
client = httpx.AsyncClient
|
||||
monkeypatch.setattr(
|
||||
httpx, "AsyncClient",
|
||||
lambda **k: client(transport=httpx.MockTransport(_respond), **k),
|
||||
)
|
||||
return seen
|
||||
|
||||
|
||||
def _ok(_req):
|
||||
return httpx.Response(200, json={"data": [{"b64_json": "AAAA"}]})
|
||||
|
||||
|
||||
def test_replace_background_forwards_multipart_and_returns_image(monkeypatch):
|
||||
seen = _setup(monkeypatch, _ok)
|
||||
out = asyncio.run(_route("/api/image/replace-background").endpoint(_Req({
|
||||
"image": _IMG_B64, "prompt": "put her on a beach", "steps": 8,
|
||||
"harmonise": True, "shadow": False, "request_id": "rb-123",
|
||||
})))
|
||||
assert out["image"] == "AAAA"
|
||||
assert seen["url"].endswith("/images/replace-background")
|
||||
body = seen["body"]
|
||||
assert b"put her on a beach" in body
|
||||
assert b"rb-123" in body # request_id forwarded for progress
|
||||
assert b"harmonise" in body and b"shadow" in body
|
||||
assert b"8" in body # steps
|
||||
|
||||
|
||||
def test_outpaint_forwards_ratio_size_context_and_returns_image(monkeypatch):
|
||||
seen = _setup(monkeypatch, _ok)
|
||||
out = asyncio.run(_route("/api/image/outpaint").endpoint(_Req({
|
||||
"image": _IMG_B64, "prompt": "extend the sky", "ratio": "16:9",
|
||||
"size": "1920x1080", "steps": 4, "context": "calm blue sky",
|
||||
"request_id": "op-9",
|
||||
})))
|
||||
assert out["image"] == "AAAA"
|
||||
assert seen["url"].endswith("/images/outpaint")
|
||||
body = seen["body"]
|
||||
for token in (b"16:9", b"1920x1080", b"calm blue sky", b"op-9"):
|
||||
assert token in body, token
|
||||
|
||||
|
||||
def test_outpaint_extend_alias_uses_the_same_provider_route(monkeypatch):
|
||||
seen = _setup(monkeypatch, _ok)
|
||||
out = asyncio.run(_route("/api/image/extend").endpoint(_Req({
|
||||
"image": _IMG_B64, "prompt": "extend", "ratio": "9:16",
|
||||
})))
|
||||
assert out["image"] == "AAAA"
|
||||
assert seen["url"].endswith("/images/outpaint")
|
||||
|
||||
|
||||
def test_outpaint_reads_openai_style_url_result(monkeypatch):
|
||||
def _respond(_req):
|
||||
return httpx.Response(200, json={"data": [{"url": "http://100.69.120.65:8188/v1/images/x.png"}]})
|
||||
|
||||
_setup(monkeypatch, _respond)
|
||||
monkeypatch.setattr(gallery, "_fetch_result_image_b64", lambda url: _async_return("ZZZZ"))
|
||||
out = asyncio.run(_route("/api/image/outpaint").endpoint(_Req({"image": _IMG_B64, "prompt": "x"})))
|
||||
assert out["image"] == "ZZZZ"
|
||||
|
||||
|
||||
def test_replace_background_502_when_provider_returns_no_image(monkeypatch):
|
||||
_setup(monkeypatch, lambda _req: httpx.Response(200, json={"data": []}))
|
||||
try:
|
||||
asyncio.run(_route("/api/image/replace-background").endpoint(_Req({"image": _IMG_B64})))
|
||||
except Exception as exc:
|
||||
assert getattr(exc, "status_code", None) == 502
|
||||
else:
|
||||
raise AssertionError("expected a 502 when the provider returns no image")
|
||||
|
||||
|
||||
async def _async_return(value):
|
||||
return value
|
||||
|
||||
|
||||
def _capture_forward(monkeypatch):
|
||||
"""Replace _forward_seneca_image_op with a fake that records kwargs."""
|
||||
captured = {}
|
||||
|
||||
async def _fake_forward(base, api_key, provider_path, image_bytes, fields, *, label, timeout=300.0):
|
||||
captured["label"] = label
|
||||
captured["provider_path"] = provider_path
|
||||
captured["timeout"] = timeout
|
||||
return {"image": "AAAA"}
|
||||
|
||||
monkeypatch.setattr(gallery, "_forward_seneca_image_op", _fake_forward)
|
||||
monkeypatch.setattr(gallery, "SessionLocal", lambda: _fake_db())
|
||||
monkeypatch.setattr(gallery, "require_privilege", lambda *a: "alice")
|
||||
monkeypatch.setattr(
|
||||
gallery, "_first_visible_image_endpoint",
|
||||
lambda *a: SimpleNamespace(base_url="http://100.69.120.65:8188/v1", api_key="k"),
|
||||
)
|
||||
return captured
|
||||
|
||||
|
||||
def test_outpaint_proxy_uses_a_long_timeout(monkeypatch):
|
||||
captured = _capture_forward(monkeypatch)
|
||||
out = asyncio.run(_route("/api/image/outpaint").endpoint(_Req({"image": _IMG_B64, "prompt": "extend to 9:16"})))
|
||||
assert out["image"] == "AAAA"
|
||||
assert captured["provider_path"] == "/images/outpaint"
|
||||
assert captured["timeout"] >= 900, captured
|
||||
|
||||
|
||||
def test_outpaint_extend_alias_also_uses_the_long_timeout(monkeypatch):
|
||||
captured = _capture_forward(monkeypatch)
|
||||
asyncio.run(_route("/api/image/extend").endpoint(_Req({"image": _IMG_B64, "prompt": "extend"})))
|
||||
assert captured["timeout"] >= 900, captured
|
||||
|
||||
|
||||
def test_replace_background_keeps_the_short_default_timeout(monkeypatch):
|
||||
captured = _capture_forward(monkeypatch)
|
||||
asyncio.run(_route("/api/image/replace-background").endpoint(_Req({"image": _IMG_B64, "prompt": "beach"})))
|
||||
assert captured["timeout"] == 300.0, captured
|
||||
|
||||
|
||||
def test_outpaint_timeout_env_override(monkeypatch):
|
||||
monkeypatch.setenv("ODYSSEUS_IMAGE_OUTPAINT_TIMEOUT", "720")
|
||||
assert gallery._outpaint_timeout() == 720.0
|
||||
monkeypatch.delenv("ODYSSEUS_IMAGE_OUTPAINT_TIMEOUT", raising=False)
|
||||
assert gallery._outpaint_timeout() == 900.0
|
||||
|
||||
|
||||
def test_graceful_shutdown_window_covers_a_long_outpaint():
|
||||
from pathlib import Path
|
||||
|
||||
src = (Path(__file__).resolve().parent.parent / "app.py").read_text()
|
||||
assert '"ODYSSEUS_GRACEFUL_SHUTDOWN_TIMEOUT", "900"' in src
|
||||
@@ -120,8 +120,9 @@ def test_disabled_generation_is_not_restored():
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize('incognito', [False, True])
|
||||
@pytest.mark.parametrize('exit_code', [None, 0])
|
||||
async def test_generation_dispatch_uses_owner_aware_backend(monkeypatch, exit_code):
|
||||
async def test_generation_dispatch_uses_owner_aware_backend(monkeypatch, exit_code, incognito):
|
||||
from types import SimpleNamespace
|
||||
from src import ai_interaction, tool_execution
|
||||
from src.agent_runtime.journal import ActionJournal, bind_journal
|
||||
@@ -145,6 +146,7 @@ async def test_generation_dispatch_uses_owner_aware_backend(monkeypatch, exit_co
|
||||
_, denied = await execute(block, owner='pewds', session_id='fixture',
|
||||
disabled_tools={'generate_image'}, security_context=tool_execution.NO_TOOL_SECURITY_CONTEXT)
|
||||
_, result = await execute(block, owner='pewds', session_id='fixture',
|
||||
client_runtime_context={'web_ui_state': {'incognito': incognito}},
|
||||
security_context=tool_execution.NO_TOOL_SECURITY_CONTEXT)
|
||||
assert denied['exit_code'] != 0
|
||||
assert journal.actions[0].execution_id is None
|
||||
@@ -152,7 +154,7 @@ async def test_generation_dispatch_uses_owner_aware_backend(monkeypatch, exit_co
|
||||
assert journal.actions[1].execution_id is not None
|
||||
assert journal.actions[1].outcome['authoritative'] is (exit_code == 0)
|
||||
assert result['image_id'] == 'test'
|
||||
assert calls == [(block.content, {'owner': 'pewds', 'session_id': 'fixture'})]
|
||||
assert calls == [(block.content, {'owner': 'pewds', 'session_id': 'fixture', **({'incognito': True} if incognito else {})})]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
"""The heavy image-edit CPU work must run off the event loop, and the app must
|
||||
expose in-flight heavy-op counts + a graceful shutdown so a deploy restart does
|
||||
not drop a 60-150 s edit."""
|
||||
import base64
|
||||
import io
|
||||
from pathlib import Path
|
||||
|
||||
from PIL import Image
|
||||
|
||||
from routes.gallery import gallery_routes as gr
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def _png_b64(w, h, color=(120, 160, 210)):
|
||||
buf = io.BytesIO()
|
||||
Image.new("RGBA", (w, h), color).save(buf, format="PNG")
|
||||
return base64.b64encode(buf.getvalue()).decode("ascii")
|
||||
|
||||
|
||||
def test_prepare_local_edit_caps_large_input():
|
||||
src, mask, mw, mh, orig = gr._prepare_local_edit(_png_b64(4000, 3000), None, 768)
|
||||
assert orig == (4000, 3000)
|
||||
assert max(mw, mh) <= 768 and mw % 16 == 0 and mh % 16 == 0
|
||||
assert mask # a full-white mask is synthesised when none is supplied
|
||||
assert Image.open(io.BytesIO(src)).size == (mw, mh)
|
||||
|
||||
|
||||
def test_prepare_local_edit_leaves_small_input_unchanged():
|
||||
_src, _mask, mw, mh, orig = gr._prepare_local_edit(_png_b64(96, 96), _png_b64(96, 96, (255, 255, 255)), 768)
|
||||
assert orig == (96, 96) and (mw, mh) == (96, 96)
|
||||
|
||||
|
||||
def test_restore_local_b64_resizes_back_to_original():
|
||||
raw = _png_b64(768, 576)
|
||||
out = gr._restore_local_b64(raw, (4000, 3000), (768, 576))
|
||||
assert Image.open(io.BytesIO(base64.b64decode(out))).size == (4000, 3000)
|
||||
# a same-size result is returned untouched
|
||||
assert gr._restore_local_b64(raw, (768, 576), (768, 576)) == raw
|
||||
|
||||
|
||||
def test_inpaint_offloads_cpu_work_to_a_thread():
|
||||
src = (ROOT / "routes/gallery/gallery_routes.py").read_text()
|
||||
assert "await asyncio.to_thread(\n _prepare_local_edit" in src
|
||||
assert "asyncio.to_thread(\n _restore_local_b64" in src
|
||||
|
||||
|
||||
def test_app_exposes_busy_count_and_graceful_shutdown():
|
||||
src = (ROOT / "app.py").read_text()
|
||||
assert "busy_image_ops" in src
|
||||
assert '@app.get("/api/busy")' in src
|
||||
assert "_HEAVY_IMAGE_OP_PREFIXES" in src
|
||||
assert "timeout_graceful_shutdown" in src
|
||||
assert "activity.begin_image_op" in src and "activity.end_image_op" in src
|
||||
@@ -33,6 +33,38 @@ def test_native_generation_respects_admin_disable(monkeypatch):
|
||||
assert 'disabled' in result['error']
|
||||
|
||||
|
||||
@pytest.mark.parametrize('editing', [False, True])
|
||||
def test_incognito_images_never_write_gallery_or_files(image_transport, editing):
|
||||
configure, source, requests, rows = image_transport
|
||||
configure('https://openrouter.ai/api/v1')
|
||||
if editing:
|
||||
result = asyncio.run(ai_interaction.do_edit_image(
|
||||
'Private edit', str(source), model_spec='openai/gpt-5-image',
|
||||
owner='alice', session_id='private-chat', incognito=True))
|
||||
else:
|
||||
result = asyncio.run(ai_interaction.do_generate_image(
|
||||
'Private image\nopenai/gpt-5-image', owner='alice',
|
||||
session_id='private-chat', incognito=True))
|
||||
assert 'error' not in result
|
||||
assert result['image_url'].startswith('data:image/png;base64,')
|
||||
assert base64.b64decode(result['image_url'].split(',', 1)[1]) == source.read_bytes()
|
||||
assert not result['image_id']
|
||||
assert not rows
|
||||
assert not (source.parent / 'generated').exists()
|
||||
|
||||
|
||||
def test_incognito_url_image_is_not_persisted(image_transport, monkeypatch):
|
||||
configure, source, requests, rows = image_transport
|
||||
configure('https://api.openai.com/v1', body={'data': [{'url': 'https://example.com/output.png'}]})
|
||||
monkeypatch.setattr(httpx, 'get', lambda *args, **kwargs: httpx.Response(200, content=source.read_bytes()))
|
||||
monkeypatch.setattr('src.url_safety.check_outbound_url', lambda *args, **kwargs: (True, ''))
|
||||
result = asyncio.run(ai_interaction.do_generate_image(
|
||||
'Private image\nopenai/gpt-5-image', owner='alice', incognito=True))
|
||||
assert result['image_url'].startswith('data:image/png;base64,')
|
||||
assert not rows
|
||||
assert not (source.parent / 'generated').exists()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def image_transport(monkeypatch, tmp_path):
|
||||
rows = []
|
||||
|
||||
@@ -0,0 +1,257 @@
|
||||
"""Regression tests for SSRF hardening on the manage_endpoints agent tool.
|
||||
|
||||
Verifies that the do_manage_endpoints tool function rejects user-supplied URLs
|
||||
that resolve to link-local (cloud metadata), non-HTTP schemes, or (when
|
||||
MODELENDPOINT_BLOCK_PRIVATE_IPS=true) private/loopback addresses -- applying
|
||||
the shared outbound-URL policy in check_outbound_url(). The HTTP route
|
||||
(POST /api/model-endpoints) receives the same guard in #6316.
|
||||
|
||||
The agent tool path bypasses the HTTP route entirely, so it needs its own
|
||||
validation. A prompt-injection payload smuggled into a skill, note, fetched
|
||||
page, or email could instruct the model to register a cloud-metadata or
|
||||
internal-network endpoint via this tool.
|
||||
"""
|
||||
import ipaddress
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import types
|
||||
|
||||
import pytest
|
||||
|
||||
import src.url_safety as _url_safety
|
||||
|
||||
_real_check_outbound_url = _url_safety.check_outbound_url
|
||||
|
||||
|
||||
def _fake_resolver(host):
|
||||
"""Resolver that returns deterministic IPs without real DNS."""
|
||||
_HOST_MAP = {
|
||||
"169.254.169.254": ["169.254.169.254"],
|
||||
"metadata.google.internal": ["169.254.169.254"],
|
||||
"localhost": ["127.0.0.1"],
|
||||
"127.0.0.1": ["127.0.0.1"],
|
||||
"0.0.0.0": ["0.0.0.0"],
|
||||
"example.com": ["93.184.216.34"],
|
||||
"192.168.1.1": ["192.168.1.1"],
|
||||
"10.0.0.1": ["10.0.0.1"],
|
||||
"api.openai.com": ["104.18.6.192"],
|
||||
}
|
||||
if host in _HOST_MAP:
|
||||
return _HOST_MAP[host]
|
||||
try:
|
||||
ipaddress.ip_address(host)
|
||||
return [host]
|
||||
except ValueError:
|
||||
pass
|
||||
raise OSError(f"Name or service not known: {host}")
|
||||
|
||||
|
||||
def _check_with_fake_resolver(url, *, block_private=False, resolver=None):
|
||||
"""Call the real check_outbound_url with our fake resolver."""
|
||||
return _real_check_outbound_url(url, block_private=block_private, resolver=_fake_resolver)
|
||||
|
||||
|
||||
def _make_content(base_url, *, name="", api_key=""):
|
||||
"""Build a JSON content string for the manage_endpoints 'add' action."""
|
||||
return json.dumps({
|
||||
"action": "add",
|
||||
"name": name,
|
||||
"base_url": base_url,
|
||||
"api_key": api_key,
|
||||
})
|
||||
|
||||
|
||||
class _FakeQuery:
|
||||
def __init__(self, model):
|
||||
self._model = model
|
||||
|
||||
def filter(self, *args):
|
||||
return self
|
||||
|
||||
def first(self):
|
||||
return None
|
||||
|
||||
def all(self):
|
||||
return []
|
||||
|
||||
|
||||
class _FakeDB:
|
||||
def __init__(self):
|
||||
self.added = []
|
||||
self.committed = False
|
||||
|
||||
def query(self, model):
|
||||
return _FakeQuery(model)
|
||||
|
||||
def add(self, obj):
|
||||
self.added.append(obj)
|
||||
|
||||
def commit(self):
|
||||
self.committed = True
|
||||
|
||||
def close(self):
|
||||
pass
|
||||
|
||||
|
||||
class _FakeModelEndpoint:
|
||||
def __init__(self, **kwargs):
|
||||
for k, v in kwargs.items():
|
||||
setattr(self, k, v)
|
||||
|
||||
|
||||
def _setup_mocks(monkeypatch, db):
|
||||
"""Set up faked core.database and patched check_outbound_url."""
|
||||
fake_core_db = types.ModuleType("core.database")
|
||||
fake_core_db.SessionLocal = lambda: db
|
||||
fake_core_db.ModelEndpoint = _FakeModelEndpoint
|
||||
monkeypatch.setitem(sys.modules, "core.database", fake_core_db)
|
||||
|
||||
monkeypatch.setattr(_url_safety, "check_outbound_url", _check_with_fake_resolver)
|
||||
|
||||
if "src.agent_tools.admin_tools" in sys.modules:
|
||||
monkeypatch.delitem(sys.modules, "src.agent_tools.admin_tools", raising=False)
|
||||
|
||||
|
||||
def _get_do_manage_endpoints():
|
||||
"""Import and return do_manage_endpoints from the reloaded admin_tools."""
|
||||
from src.agent_tools.admin_tools import do_manage_endpoints
|
||||
return do_manage_endpoints
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cloud_metadata_blocked(monkeypatch):
|
||||
"""Cloud metadata IP (169.254.169.254) must be rejected."""
|
||||
db = _FakeDB()
|
||||
_setup_mocks(monkeypatch, db)
|
||||
do_manage_endpoints = _get_do_manage_endpoints()
|
||||
content = _make_content("http://169.254.169.254/latest/meta-data/")
|
||||
result = await do_manage_endpoints(content, owner="admin")
|
||||
|
||||
assert result["exit_code"] == 1
|
||||
assert "Rejected" in result["error"]
|
||||
assert len(db.added) == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_non_http_scheme_blocked(monkeypatch):
|
||||
"""Non-HTTP schemes (file://, gopher://) must be rejected."""
|
||||
db = _FakeDB()
|
||||
_setup_mocks(monkeypatch, db)
|
||||
do_manage_endpoints = _get_do_manage_endpoints()
|
||||
content = _make_content("file:///etc/passwd")
|
||||
result = await do_manage_endpoints(content, owner="admin")
|
||||
|
||||
assert result["exit_code"] == 1
|
||||
assert "Rejected" in result["error"]
|
||||
assert len(db.added) == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_loopback_accepted_by_default(monkeypatch):
|
||||
"""Loopback addresses are accepted by default (local-first design).
|
||||
|
||||
The agent tool uses check_outbound_url directly, which allows
|
||||
loopback by default -- this is correct for local model endpoints
|
||||
(Ollama, llama.cpp, etc.). Loopback is only blocked in strict mode.
|
||||
"""
|
||||
db = _FakeDB()
|
||||
_setup_mocks(monkeypatch, db)
|
||||
do_manage_endpoints = _get_do_manage_endpoints()
|
||||
content = _make_content("http://localhost:11434/v1")
|
||||
result = await do_manage_endpoints(content, owner="admin")
|
||||
|
||||
assert result["exit_code"] == 0
|
||||
assert "Added endpoint" in result["response"]
|
||||
assert len(db.added) == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_strict_mode_blocks_private(monkeypatch):
|
||||
"""MODELENDPOINT_BLOCK_PRIVATE_IPS=true blocks private IPs."""
|
||||
db = _FakeDB()
|
||||
_setup_mocks(monkeypatch, db)
|
||||
monkeypatch.setenv("MODELENDPOINT_BLOCK_PRIVATE_IPS", "true")
|
||||
do_manage_endpoints = _get_do_manage_endpoints()
|
||||
content = _make_content("http://192.168.1.1:8080/v1")
|
||||
result = await do_manage_endpoints(content, owner="admin")
|
||||
|
||||
assert result["exit_code"] == 1
|
||||
assert "Rejected" in result["error"]
|
||||
assert len(db.added) == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_safe_url_accepted(monkeypatch):
|
||||
"""A safe public URL should be accepted and stored."""
|
||||
db = _FakeDB()
|
||||
_setup_mocks(monkeypatch, db)
|
||||
do_manage_endpoints = _get_do_manage_endpoints()
|
||||
content = _make_content("https://api.openai.com/v1", name="OpenAI")
|
||||
result = await do_manage_endpoints(content, owner="admin")
|
||||
|
||||
assert result["exit_code"] == 0
|
||||
assert "Added endpoint" in result["response"]
|
||||
assert len(db.added) == 1
|
||||
assert db.committed is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_empty_url_rejected(monkeypatch):
|
||||
"""An empty base_url must be rejected before SSRF check."""
|
||||
db = _FakeDB()
|
||||
_setup_mocks(monkeypatch, db)
|
||||
do_manage_endpoints = _get_do_manage_endpoints()
|
||||
content = _make_content("")
|
||||
result = await do_manage_endpoints(content, owner="admin")
|
||||
|
||||
assert result["exit_code"] == 1
|
||||
assert "base_url is required" in result["error"]
|
||||
assert len(db.added) == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_gopher_scheme_blocked(monkeypatch):
|
||||
"""gopher:// scheme must be rejected."""
|
||||
db = _FakeDB()
|
||||
_setup_mocks(monkeypatch, db)
|
||||
do_manage_endpoints = _get_do_manage_endpoints()
|
||||
content = _make_content("gopher://evil:6379/secret")
|
||||
result = await do_manage_endpoints(content, owner="admin")
|
||||
|
||||
assert result["exit_code"] == 1
|
||||
assert "Rejected" in result["error"]
|
||||
assert len(db.added) == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_error_not_leaked(monkeypatch):
|
||||
"""Error messages must not leak raw exception text."""
|
||||
db = _FakeDB()
|
||||
_setup_mocks(monkeypatch, db)
|
||||
do_manage_endpoints = _get_do_manage_endpoints()
|
||||
content = _make_content("http://169.254.169.254/latest/meta-data/")
|
||||
result = await do_manage_endpoints(content, owner="admin")
|
||||
|
||||
assert result["exit_code"] == 1
|
||||
assert "Rejected" in result["error"]
|
||||
assert "Traceback" not in result["error"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ipv6_loopback_accepted_by_default(monkeypatch):
|
||||
"""::1 must be treated as loopback (accepted local-first), not reserved.
|
||||
|
||||
Proves this branch carries the dfd1f7e7 url_safety fix after rebasing
|
||||
onto current dev: the pre-fix guard checked is_reserved before is_loopback
|
||||
and rejected ::1 even in default local-first mode.
|
||||
"""
|
||||
db = _FakeDB()
|
||||
_setup_mocks(monkeypatch, db)
|
||||
do_manage_endpoints = _get_do_manage_endpoints()
|
||||
content = _make_content("http://[::1]:11434/v1")
|
||||
result = await do_manage_endpoints(content, owner="admin")
|
||||
|
||||
assert result["exit_code"] == 0
|
||||
assert "Added endpoint" in result["response"]
|
||||
assert len(db.added) == 1
|
||||
@@ -0,0 +1,289 @@
|
||||
"""manage_tasks must apply the admin-only task-action policy.
|
||||
|
||||
``POST /api/tasks`` (routes/task/task_routes.py) and the scheduler
|
||||
(src/task_scheduler.py) both refuse ``action`` tasks whose action is in
|
||||
``ADMIN_ONLY_TASK_ACTIONS`` (run_local, run_script, ssh_command,
|
||||
cookbook_serve) for owners without admin task privileges — those actions run
|
||||
``subprocess.run(shell=True)`` or SSH with no sandbox. The model-facing
|
||||
``do_manage_tasks`` applied neither check, so an agent could store such a task
|
||||
on create/edit and resume/run it; the scheduler would only pause it at
|
||||
execution time. Regression for issue #6021: the tool now enforces the same
|
||||
policy on create, edit, resume and run, and keeps allowing admins and the
|
||||
non-privileged actions.
|
||||
"""
|
||||
|
||||
import json
|
||||
import tempfile
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from sqlalchemy.pool import NullPool
|
||||
|
||||
from tests.helpers.import_state import clear_fake_database_modules
|
||||
|
||||
clear_fake_database_modules()
|
||||
|
||||
import core.database as cdb
|
||||
from core.database import ScheduledTask
|
||||
import src.task_action_policy as policy_mod
|
||||
import src.tools.system as system_mod
|
||||
from src.tools.system import do_manage_tasks
|
||||
|
||||
_TMPDB = tempfile.NamedTemporaryFile(suffix=".db", delete=False)
|
||||
_ENGINE = create_engine(
|
||||
f"sqlite:///{_TMPDB.name}",
|
||||
connect_args={"check_same_thread": False},
|
||||
poolclass=NullPool,
|
||||
)
|
||||
cdb.Base.metadata.create_all(_ENGINE)
|
||||
_TS = sessionmaker(bind=_ENGINE, autoflush=False, autocommit=False)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _bind_session_factory(monkeypatch):
|
||||
# do_manage_tasks does `from core.database import SessionLocal` at call
|
||||
# time. Bind it per test (not at import) so this file stays
|
||||
# order-independent from sibling files that point it at their own DB.
|
||||
monkeypatch.setattr(cdb, "SessionLocal", _TS)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def privileges(monkeypatch):
|
||||
"""Drive owner_has_admin_task_privileges from a plain dict."""
|
||||
admins = {"root"}
|
||||
monkeypatch.setattr(
|
||||
policy_mod, "owner_has_admin_task_privileges",
|
||||
lambda owner: owner in admins,
|
||||
)
|
||||
return admins
|
||||
|
||||
|
||||
def _seed(task_id, owner, task_type="llm", action=None, status="active"):
|
||||
db = _TS()
|
||||
try:
|
||||
db.add(ScheduledTask(
|
||||
id=task_id, owner=owner, name=task_id, prompt="original",
|
||||
task_type=task_type, action=action, trigger_type="webhook",
|
||||
status=status, output_target="session",
|
||||
))
|
||||
db.commit()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def _get(task_id):
|
||||
db = _TS()
|
||||
try:
|
||||
return db.query(ScheduledTask).filter(ScheduledTask.id == task_id).first()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def _count(owner):
|
||||
db = _TS()
|
||||
try:
|
||||
return db.query(ScheduledTask).filter(ScheduledTask.owner == owner).count()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("action_name", sorted(policy_mod.ADMIN_ONLY_TASK_ACTIONS))
|
||||
async def test_create_admin_only_action_denied_for_non_admin(privileges, action_name):
|
||||
before = _count("alice")
|
||||
out = await do_manage_tasks(json.dumps({
|
||||
"action": "create", "task_type": "action", "action_name": action_name,
|
||||
"name": "pwn", "schedule": "daily", "scheduled_time": "09:00",
|
||||
}), owner="alice")
|
||||
assert out["exit_code"] == 1, out
|
||||
assert "requires admin privileges" in out["error"]
|
||||
assert _count("alice") == before
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_admin_only_action_allowed_for_admin(privileges):
|
||||
out = await do_manage_tasks(json.dumps({
|
||||
"action": "create", "task_type": "action", "action_name": "run_local",
|
||||
"name": "ok", "schedule": "daily", "scheduled_time": "09:00",
|
||||
}), owner="root")
|
||||
assert out["exit_code"] == 0, out
|
||||
assert _get(out["task_id"]).action == "run_local"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_regular_action_allowed_for_non_admin(privileges):
|
||||
out = await do_manage_tasks(json.dumps({
|
||||
"action": "create", "task_type": "action", "action_name": "tidy_sessions",
|
||||
"name": "ok", "schedule": "daily", "scheduled_time": "09:00",
|
||||
}), owner="alice")
|
||||
assert out["exit_code"] == 0, out
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_llm_task_allowed_for_non_admin(privileges):
|
||||
out = await do_manage_tasks(json.dumps({
|
||||
"action": "create", "task_type": "llm", "prompt": "summarize",
|
||||
"schedule": "daily", "scheduled_time": "09:00",
|
||||
}), owner="alice")
|
||||
assert out["exit_code"] == 0, out
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_edit_cannot_turn_llm_task_into_shell_action(privileges):
|
||||
_seed("alice-llm", "alice")
|
||||
out = await do_manage_tasks(json.dumps({
|
||||
"action": "edit", "task_id": "alice-llm",
|
||||
"task_type": "action", "action_name": "ssh_command",
|
||||
}), owner="alice")
|
||||
assert out["exit_code"] == 1, out
|
||||
assert "requires admin privileges" in out["error"]
|
||||
task = _get("alice-llm")
|
||||
assert task.task_type == "llm" and task.action is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_edit_cannot_swap_action_name_to_admin_only(privileges):
|
||||
_seed("alice-action", "alice", task_type="action", action="tidy_sessions")
|
||||
out = await do_manage_tasks(json.dumps({
|
||||
"action": "edit", "task_id": "alice-action", "action_name": "run_script",
|
||||
}), owner="alice")
|
||||
assert out["exit_code"] == 1, out
|
||||
assert _get("alice-action").action == "tidy_sessions"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_edit_of_unrelated_field_on_admin_only_task_denied_for_non_admin(privileges):
|
||||
# A task that slipped in before the policy (or via demotion of its owner)
|
||||
# cannot be kept alive by a non-admin: the route rejects the same edit.
|
||||
_seed("alice-legacy", "alice", task_type="action", action="run_local")
|
||||
out = await do_manage_tasks(json.dumps({
|
||||
"action": "edit", "task_id": "alice-legacy", "name": "renamed",
|
||||
}), owner="alice")
|
||||
assert out["exit_code"] == 1, out
|
||||
assert _get("alice-legacy").name == "alice-legacy"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resume_admin_only_task_denied_for_non_admin(privileges):
|
||||
_seed("alice-paused", "alice", task_type="action", action="run_local", status="paused")
|
||||
out = await do_manage_tasks(json.dumps({
|
||||
"action": "resume", "task_id": "alice-paused",
|
||||
}), owner="alice")
|
||||
assert out["exit_code"] == 1, out
|
||||
assert _get("alice-paused").status == "paused"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pause_admin_only_task_still_allowed(privileges):
|
||||
# Narrowing is always fine: a non-admin may pause a task they own.
|
||||
_seed("alice-active", "alice", task_type="action", action="run_local")
|
||||
out = await do_manage_tasks(json.dumps({
|
||||
"action": "pause", "task_id": "alice-active",
|
||||
}), owner="alice")
|
||||
assert out["exit_code"] == 0, out
|
||||
assert _get("alice-active").status == "paused"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_admin_only_task_denied_before_scheduler(privileges, monkeypatch):
|
||||
_seed("alice-run", "alice", task_type="action", action="ssh_command")
|
||||
calls = []
|
||||
|
||||
class _Sched:
|
||||
async def run_task_now(self, task_id):
|
||||
calls.append(task_id)
|
||||
return True
|
||||
|
||||
import src.event_bus as event_bus
|
||||
monkeypatch.setattr(event_bus, "get_task_scheduler", lambda: _Sched())
|
||||
out = await do_manage_tasks(json.dumps({
|
||||
"action": "run", "task_id": "alice-run",
|
||||
}), owner="alice")
|
||||
assert out["exit_code"] == 1, out
|
||||
assert calls == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_admin_only_task_allowed_for_admin(privileges, monkeypatch):
|
||||
_seed("root-run", "root", task_type="action", action="ssh_command")
|
||||
calls = []
|
||||
|
||||
class _Sched:
|
||||
async def run_task_now(self, task_id):
|
||||
calls.append(task_id)
|
||||
return True
|
||||
|
||||
import src.event_bus as event_bus
|
||||
monkeypatch.setattr(event_bus, "get_task_scheduler", lambda: _Sched())
|
||||
out = await do_manage_tasks(json.dumps({
|
||||
"action": "run", "task_id": "root-run",
|
||||
}), owner="root")
|
||||
assert out["exit_code"] == 0, out
|
||||
assert calls == ["root-run"]
|
||||
|
||||
|
||||
def test_policy_helper_uses_shared_constants(privileges):
|
||||
# The tool must consult the shared policy module, not a private copy of
|
||||
# the action names, so a new admin-only action is covered everywhere.
|
||||
assert system_mod._admin_task_action_denied("alice", "action", "run_local") is not None
|
||||
assert system_mod._admin_task_action_denied("root", "action", "run_local") is None
|
||||
assert system_mod._admin_task_action_denied("alice", "action", "tidy_sessions") is None
|
||||
assert system_mod._admin_task_action_denied("alice", "llm", "run_local") is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_admin_only_task_by_name_denied_for_non_admin(privileges, monkeypatch):
|
||||
_seed("alice-run-name", "alice", task_type="action", action="ssh_command")
|
||||
calls = []
|
||||
|
||||
class _Sched:
|
||||
async def run_task_now(self, task_id):
|
||||
calls.append(task_id)
|
||||
return True
|
||||
|
||||
import src.event_bus as event_bus
|
||||
monkeypatch.setattr(event_bus, "get_task_scheduler", lambda: _Sched())
|
||||
out = await do_manage_tasks(json.dumps({
|
||||
"action": "run", "name": "alice-run-name",
|
||||
}), owner="alice")
|
||||
assert out["exit_code"] == 1, out
|
||||
assert "requires admin privileges" in out["error"]
|
||||
assert calls == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_admin_only_action_seals_authority_for_admin(privileges):
|
||||
out = await do_manage_tasks(json.dumps({
|
||||
"action": "create", "task_type": "action", "action_name": "run_local",
|
||||
"name": "sealed-task", "schedule": "daily", "scheduled_time": "09:00",
|
||||
}), owner="root")
|
||||
assert out["exit_code"] == 0, out
|
||||
task = _get(out["task_id"])
|
||||
assert task.action == "run_local"
|
||||
assert task.request_authority_json is not None
|
||||
|
||||
|
||||
def test_real_owner_has_admin_task_privileges_branches(monkeypatch):
|
||||
"""Verify owner_has_admin_task_privileges without mocking."""
|
||||
# Auth disabled branch
|
||||
monkeypatch.setenv("AUTH_ENABLED", "false")
|
||||
assert policy_mod.owner_has_admin_task_privileges("alice") is True
|
||||
assert policy_mod.owner_has_admin_task_privileges(None) is True
|
||||
|
||||
# Auth enabled branch with configured AuthManager
|
||||
monkeypatch.delenv("AUTH_ENABLED", raising=False)
|
||||
|
||||
class FakeAuth:
|
||||
is_configured = True
|
||||
|
||||
def is_admin(self, u):
|
||||
return u == "admin"
|
||||
|
||||
import core.auth as c_auth
|
||||
monkeypatch.setattr(c_auth, "AuthManager", lambda: FakeAuth())
|
||||
|
||||
assert policy_mod.owner_has_admin_task_privileges("admin") is True
|
||||
assert policy_mod.owner_has_admin_task_privileges("alice") is False
|
||||
assert policy_mod.owner_has_admin_task_privileges("internal-tool") is True
|
||||
assert policy_mod.owner_has_admin_task_privileges(None) is False
|
||||
@@ -264,3 +264,231 @@ def test_launchers_export_the_port_they_serve_on():
|
||||
text = (_repo_root() / name).read_text(encoding="utf-8")
|
||||
assert uvicorn_flag in text, f"{name}: launcher no longer passes {uvicorn_flag}"
|
||||
assert export in text, f"{name}: serves on a port the app cannot read back"
|
||||
|
||||
|
||||
# ── Callback & Exchange Expired State Handling ───────────────────
|
||||
|
||||
def test_oauth_callback_expired_state_returns_dedicated_expired_page(monkeypatch):
|
||||
"""An unknown or expired generic MCP OAuth state in the callback must return
|
||||
the dedicated 'Authorization Expired' page instead of a bare 403 'Admin only'
|
||||
or a 404 'Server not found'."""
|
||||
from unittest.mock import MagicMock
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from fastapi.testclient import TestClient
|
||||
from routes.mcp import mcp_routes
|
||||
|
||||
# Simulate unauthenticated request that would fail admin check if invoked
|
||||
def _deny_admin(request):
|
||||
raise HTTPException(403, "Admin only")
|
||||
|
||||
monkeypatch.setattr(mcp_routes, "require_admin", _deny_admin)
|
||||
|
||||
# Empty DB / no matching server
|
||||
class FakeQuery:
|
||||
def filter(self, *a):
|
||||
return self
|
||||
|
||||
def first(self):
|
||||
return None
|
||||
|
||||
class FakeSession:
|
||||
def query(self, *a):
|
||||
return FakeQuery()
|
||||
|
||||
def close(self):
|
||||
pass
|
||||
|
||||
monkeypatch.setattr(mcp_routes, "SessionLocal", lambda: FakeSession())
|
||||
|
||||
app = FastAPI()
|
||||
manager = MagicMock()
|
||||
router = mcp_routes.setup_mcp_routes(manager)
|
||||
app.include_router(router)
|
||||
client = TestClient(app)
|
||||
|
||||
# Both code and state are provided, but state is not in _pending (expired/unknown)
|
||||
resp = client.get("/api/mcp/oauth/callback?code=some-code&state=expired-state-123")
|
||||
assert resp.status_code == 400
|
||||
assert "Authorization Expired" in resp.text
|
||||
assert (
|
||||
"This authorization round has expired. Press Reconnect in Odysseus and try again."
|
||||
in resp.text
|
||||
)
|
||||
# Ensure it did not return the bare 403 "Admin only"
|
||||
assert "Admin only" not in resp.text
|
||||
|
||||
|
||||
def test_oauth_callback_active_pending_state_succeeds_without_admin(monkeypatch):
|
||||
"""An active pending generic MCP OAuth state resolves without requiring admin auth."""
|
||||
from unittest.mock import MagicMock
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from fastapi.testclient import TestClient
|
||||
from routes.mcp import mcp_routes
|
||||
|
||||
# Admin check fails if invoked
|
||||
def _deny_admin(request):
|
||||
raise HTTPException(403, "Admin only")
|
||||
|
||||
monkeypatch.setattr(mcp_routes, "require_admin", _deny_admin)
|
||||
|
||||
app = FastAPI()
|
||||
manager = MagicMock()
|
||||
router = mcp_routes.setup_mcp_routes(manager)
|
||||
app.include_router(router)
|
||||
client = TestClient(app)
|
||||
|
||||
async def go():
|
||||
fut = mcp_oauth.register_pending("pending-round-1")
|
||||
resp = client.get("/api/mcp/oauth/callback?code=auth-code-xyz&state=pending-round-1")
|
||||
assert resp.status_code == 200
|
||||
assert "Authorization Successful" in resp.text
|
||||
code, state = await asyncio.wait_for(fut, timeout=1)
|
||||
assert code == "auth-code-xyz"
|
||||
assert state == "pending-round-1"
|
||||
|
||||
asyncio.run(go())
|
||||
|
||||
|
||||
def test_oauth_callback_legacy_google_server_requires_admin(monkeypatch):
|
||||
"""Legacy Google OAuth callbacks (where state is server_id of a server with
|
||||
oauth_config) still enforce require_admin."""
|
||||
import json
|
||||
from unittest.mock import MagicMock
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from fastapi.testclient import TestClient
|
||||
from routes.mcp import mcp_routes
|
||||
from core.database import McpServer
|
||||
|
||||
# Server exists in DB with Google oauth_config
|
||||
google_srv = McpServer(
|
||||
id="google-srv-1",
|
||||
name="Google Workspace",
|
||||
oauth_config=json.dumps({"provider": "google", "keys_file": "k.json", "token_file": "t.json"}),
|
||||
)
|
||||
|
||||
class FakeQuery:
|
||||
def filter(self, *a):
|
||||
return self
|
||||
|
||||
def first(self):
|
||||
return google_srv
|
||||
|
||||
class FakeSession:
|
||||
def query(self, *a):
|
||||
return FakeQuery()
|
||||
|
||||
def close(self):
|
||||
pass
|
||||
|
||||
monkeypatch.setattr(mcp_routes, "SessionLocal", lambda: FakeSession())
|
||||
|
||||
# When require_admin denies, 403 is raised for the legacy Google path
|
||||
def _deny_admin(request):
|
||||
raise HTTPException(403, "Admin only")
|
||||
|
||||
monkeypatch.setattr(mcp_routes, "require_admin", _deny_admin)
|
||||
|
||||
app = FastAPI()
|
||||
manager = MagicMock()
|
||||
router = mcp_routes.setup_mcp_routes(manager)
|
||||
app.include_router(router)
|
||||
client = TestClient(app)
|
||||
|
||||
resp = client.get("/api/mcp/oauth/callback?code=code-123&state=google-srv-1")
|
||||
assert resp.status_code == 403
|
||||
assert "Admin only" in resp.text
|
||||
|
||||
|
||||
def test_oauth_exchange_expired_generic_state_returns_expired_page(monkeypatch):
|
||||
"""Remote paste-back flow for a generic MCP server returns the dedicated expired
|
||||
page when the state is no longer pending, instead of 'No OAuth config.'."""
|
||||
from unittest.mock import MagicMock
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
from routes.mcp import mcp_routes
|
||||
from core.database import McpServer
|
||||
|
||||
monkeypatch.setattr(mcp_routes, "require_admin", lambda r: None)
|
||||
|
||||
# Server exists in DB without oauth_config (generic MCP server)
|
||||
generic_srv = McpServer(
|
||||
id="http-srv-1",
|
||||
name="Remote HTTP MCP",
|
||||
oauth_config=None,
|
||||
)
|
||||
|
||||
class FakeQuery:
|
||||
def filter(self, *a):
|
||||
return self
|
||||
|
||||
def first(self):
|
||||
return generic_srv
|
||||
|
||||
class FakeSession:
|
||||
def query(self, *a):
|
||||
return FakeQuery()
|
||||
|
||||
def close(self):
|
||||
pass
|
||||
|
||||
monkeypatch.setattr(mcp_routes, "SessionLocal", lambda: FakeSession())
|
||||
|
||||
app = FastAPI()
|
||||
manager = MagicMock()
|
||||
router = mcp_routes.setup_mcp_routes(manager)
|
||||
app.include_router(router)
|
||||
client = TestClient(app)
|
||||
|
||||
resp = client.post(
|
||||
"/api/mcp/oauth/exchange/http-srv-1",
|
||||
data={"callback_url": "http://localhost:7000/api/mcp/oauth/callback?code=code-1&state=expired-state"},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
assert "Authorization Expired" in resp.text
|
||||
assert (
|
||||
"This authorization round has expired. Press Reconnect in Odysseus and try again."
|
||||
in resp.text
|
||||
)
|
||||
assert "No OAuth config." not in resp.text
|
||||
|
||||
|
||||
def test_oauth_callback_missing_state_returns_malformed_error_not_expired():
|
||||
"""Missing state parameter in callback must return a bad-request error and must
|
||||
not be treated as an expired authorization round."""
|
||||
from unittest.mock import MagicMock
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
from routes.mcp import mcp_routes
|
||||
|
||||
app = FastAPI()
|
||||
manager = MagicMock()
|
||||
router = mcp_routes.setup_mcp_routes(manager)
|
||||
app.include_router(router)
|
||||
client = TestClient(app)
|
||||
|
||||
resp = client.get("/api/mcp/oauth/callback?code=some-code")
|
||||
assert resp.status_code == 400
|
||||
assert "No OAuth state parameter found in the callback URL." in resp.text
|
||||
assert "Authorization Expired" not in resp.text
|
||||
assert "This authorization round has expired" not in resp.text
|
||||
|
||||
|
||||
def test_oauth_callback_missing_code_returns_malformed_error_not_expired():
|
||||
"""Missing code parameter in callback must return a bad-request error and must
|
||||
not be treated as an expired authorization round."""
|
||||
from unittest.mock import MagicMock
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
from routes.mcp import mcp_routes
|
||||
|
||||
app = FastAPI()
|
||||
manager = MagicMock()
|
||||
router = mcp_routes.setup_mcp_routes(manager)
|
||||
app.include_router(router)
|
||||
client = TestClient(app)
|
||||
|
||||
resp = client.get("/api/mcp/oauth/callback?state=some-state")
|
||||
assert resp.status_code == 400
|
||||
assert "No authorization code found in the URL." in resp.text
|
||||
assert "Authorization Expired" not in resp.text
|
||||
assert "This authorization round has expired" not in resp.text
|
||||
|
||||
@@ -0,0 +1,296 @@
|
||||
"""Regression tests for SSRF hardening on model endpoint routes.
|
||||
|
||||
Verifies that POST /model-endpoints/test, POST /model-endpoints, and
|
||||
PATCH /model-endpoints/{ep_id} reject user-supplied URLs that resolve to
|
||||
link-local (cloud metadata), non-HTTP schemes, or (when
|
||||
MODELENDPOINT_BLOCK_PRIVATE_IPS=true) private/loopback addresses — matching
|
||||
the existing SSRF guard pattern in embedding_routes.py.
|
||||
|
||||
A stub resolver is injected so the loopback tests never touch real DNS.
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
# ── Resolver stub (same pattern as tests/test_url_safety.py) ────────────────
|
||||
def _resolver(mapping):
|
||||
"""Create a stub resolver for deterministic DNS in tests."""
|
||||
def resolve(host):
|
||||
if host in mapping:
|
||||
return mapping[host]
|
||||
raise OSError(f"unresolvable: {host}")
|
||||
return resolve
|
||||
|
||||
|
||||
LOCALHOST_V4 = _resolver({"localhost": ["127.0.0.1"]})
|
||||
LOCALHOST_V6 = _resolver({"localhost": ["::1"]})
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(monkeypatch):
|
||||
"""Build a TestClient with auth disabled and minimal DB stubs."""
|
||||
monkeypatch.setenv("AUTH_ENABLED", "false")
|
||||
monkeypatch.setenv("DATABASE_URL", "sqlite:///:memory:")
|
||||
|
||||
# Stub heavy deps that model_routes imports at module level.
|
||||
# Use monkeypatch so entries are removed after the test.
|
||||
for mod in [
|
||||
"src.tls_overrides",
|
||||
"src.llm_core",
|
||||
"src.settings",
|
||||
"src.constants",
|
||||
"core.log_safety",
|
||||
]:
|
||||
if mod not in sys.modules:
|
||||
monkeypatch.setitem(sys.modules, mod, MagicMock())
|
||||
|
||||
# Provide real endpoint_resolver so the route logic works
|
||||
if "src.endpoint_resolver" not in sys.modules or isinstance(
|
||||
sys.modules["src.endpoint_resolver"], MagicMock
|
||||
):
|
||||
er = MagicMock()
|
||||
er.normalize_base = lambda url: url
|
||||
er.resolve_url = lambda url: url
|
||||
er.build_chat_url = lambda base, **kw: base
|
||||
er.build_models_url = lambda base, **kw: base + "/v1/models"
|
||||
er.build_headers = lambda base, **kw: {}
|
||||
monkeypatch.setitem(sys.modules, "src.endpoint_resolver", er)
|
||||
|
||||
# Clear cached model_routes module so it picks up our stubs
|
||||
monkeypatch.delitem(sys.modules, "routes.model_routes", raising=False)
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
import routes.model_routes as mr
|
||||
from fastapi import FastAPI
|
||||
|
||||
app = FastAPI()
|
||||
router = mr.setup_model_routes(model_discovery=None)
|
||||
app.include_router(router)
|
||||
app.state.auth_enabled = False
|
||||
return TestClient(app, raise_server_exceptions=False)
|
||||
|
||||
|
||||
class TestModelEndpointSSRF:
|
||||
"""SSRF protection for POST /model-endpoints/test."""
|
||||
|
||||
def test_cloud_metadata_blocked(self, client):
|
||||
"""Cloud metadata IP (169.254.169.254) must be rejected."""
|
||||
resp = client.post(
|
||||
"/api/model-endpoints/test",
|
||||
data={"base_url": "http://169.254.169.254/latest/meta-data/"},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
assert "Rejected" in resp.json()["detail"]
|
||||
|
||||
def test_non_http_scheme_blocked(self, client):
|
||||
"""Non-HTTP schemes (file://, ftp://) must be rejected."""
|
||||
resp = client.post(
|
||||
"/api/model-endpoints/test",
|
||||
data={"base_url": "file:///etc/passwd"},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
assert "Rejected" in resp.json()["detail"]
|
||||
|
||||
def test_loopback_accepted_by_default(self, client):
|
||||
"""Local-first: loopback is accepted when block_private=False."""
|
||||
with patch("src.url_safety._default_resolver", LOCALHOST_V4):
|
||||
resp = client.post(
|
||||
"/api/model-endpoints/test",
|
||||
data={"base_url": "http://localhost:11434/v1"},
|
||||
)
|
||||
# Guard passes; probe fails (no server) but that is not SSRF.
|
||||
assert "Rejected endpoint URL" not in str(resp.json())
|
||||
|
||||
def test_loopback_rejected_in_strict_mode(self, client):
|
||||
"""Strict mode: loopback is rejected when block_private=True."""
|
||||
os.environ["MODELENDPOINT_BLOCK_PRIVATE_IPS"] = "true"
|
||||
try:
|
||||
with patch("src.url_safety._default_resolver", LOCALHOST_V4):
|
||||
resp = client.post(
|
||||
"/api/model-endpoints/test",
|
||||
data={"base_url": "http://localhost:11434/v1"},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
assert "Rejected" in resp.json()["detail"]
|
||||
finally:
|
||||
os.environ.pop("MODELENDPOINT_BLOCK_PRIVATE_IPS", None)
|
||||
|
||||
def test_strict_mode_blocks_direct_loopback_ip(self, client):
|
||||
"""Strict mode rejects 127.0.0.1 passed directly (no DNS needed)."""
|
||||
os.environ["MODELENDPOINT_BLOCK_PRIVATE_IPS"] = "true"
|
||||
try:
|
||||
resp = client.post(
|
||||
"/api/model-endpoints/test",
|
||||
data={"base_url": "http://127.0.0.1:11434/v1"},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
assert "Rejected" in resp.json()["detail"]
|
||||
finally:
|
||||
os.environ.pop("MODELENDPOINT_BLOCK_PRIVATE_IPS", None)
|
||||
|
||||
def test_ipv6_loopback_literal_accepted_by_default(self, client):
|
||||
"""::1 is loopback, not a disallowed 'reserved' address (dfd1f7e7)."""
|
||||
resp = client.post(
|
||||
"/api/model-endpoints/test",
|
||||
data={"base_url": "http://[::1]:11434/v1"},
|
||||
)
|
||||
assert "Rejected endpoint URL" not in str(resp.json())
|
||||
|
||||
def test_ipv6_loopback_localhost_accepted_by_default(self, client):
|
||||
"""Dual-stack hosts whose resolver answers ::1 first must work."""
|
||||
with patch("src.url_safety._default_resolver", LOCALHOST_V6):
|
||||
resp = client.post(
|
||||
"/api/model-endpoints/test",
|
||||
data={"base_url": "http://localhost:11434/v1"},
|
||||
)
|
||||
assert "Rejected endpoint URL" not in str(resp.json())
|
||||
|
||||
def test_ipv6_loopback_rejected_in_strict_mode(self, client):
|
||||
"""Strict mode still rejects ::1."""
|
||||
os.environ["MODELENDPOINT_BLOCK_PRIVATE_IPS"] = "true"
|
||||
try:
|
||||
resp = client.post(
|
||||
"/api/model-endpoints/test",
|
||||
data={"base_url": "http://[::1]:11434/v1"},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
assert "Rejected" in resp.json()["detail"]
|
||||
finally:
|
||||
os.environ.pop("MODELENDPOINT_BLOCK_PRIVATE_IPS", None)
|
||||
|
||||
|
||||
class TestModelEndpointCreateSSRF:
|
||||
"""SSRF protection for POST /model-endpoints."""
|
||||
|
||||
def test_cloud_metadata_blocked(self, client):
|
||||
"""Cloud metadata IP must be rejected on endpoint creation."""
|
||||
resp = client.post(
|
||||
"/api/model-endpoints",
|
||||
data={"base_url": "http://169.254.169.254/latest/meta-data/"},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
assert "Rejected" in resp.json()["detail"]
|
||||
|
||||
def test_non_http_scheme_blocked(self, client):
|
||||
"""Non-HTTP schemes must be rejected on endpoint creation."""
|
||||
resp = client.post(
|
||||
"/api/model-endpoints",
|
||||
data={"base_url": "gopher://evil:6379/secret"},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
assert "Rejected" in resp.json()["detail"]
|
||||
|
||||
def test_loopback_accepted_by_default(self, client):
|
||||
"""Local-first: loopback is accepted when block_private=False."""
|
||||
with patch("src.url_safety._default_resolver", LOCALHOST_V4):
|
||||
resp = client.post(
|
||||
"/api/model-endpoints",
|
||||
data={"base_url": "http://localhost:11434/v1"},
|
||||
)
|
||||
# Guard passes; route may 500 on DB/probe but never 400 SSRF.
|
||||
assert resp.status_code != 400
|
||||
body = resp.text if resp.status_code == 500 else str(resp.json())
|
||||
assert "Rejected endpoint URL" not in body
|
||||
|
||||
def test_loopback_rejected_in_strict_mode(self, client):
|
||||
"""Strict mode: loopback is rejected when block_private=True."""
|
||||
os.environ["MODELENDPOINT_BLOCK_PRIVATE_IPS"] = "true"
|
||||
try:
|
||||
with patch("src.url_safety._default_resolver", LOCALHOST_V4):
|
||||
resp = client.post(
|
||||
"/api/model-endpoints",
|
||||
data={"base_url": "http://localhost:11434/v1"},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
assert "Rejected" in resp.json()["detail"]
|
||||
finally:
|
||||
os.environ.pop("MODELENDPOINT_BLOCK_PRIVATE_IPS", None)
|
||||
|
||||
|
||||
class TestModelEndpointPatchSSRF:
|
||||
"""SSRF protection for PATCH /api/model-endpoints/{ep_id} (base_url).
|
||||
|
||||
The update path rewrites the stored base_url, which later fetches probe,
|
||||
list models and serve chat through — it must pass the same guard as the
|
||||
POST routes instead of bypassing it.
|
||||
"""
|
||||
|
||||
def _install_fake_endpoint(self, monkeypatch, base_url="http://localhost:9999/v1"):
|
||||
import routes.model_routes as mr
|
||||
|
||||
ep = SimpleNamespace(
|
||||
id="ep1",
|
||||
name="EP",
|
||||
base_url=base_url,
|
||||
api_key=None,
|
||||
is_enabled=True,
|
||||
owner=None,
|
||||
hidden_models=None,
|
||||
cached_models=None,
|
||||
pinned_models=None,
|
||||
model_type="llm",
|
||||
supports_tools=None,
|
||||
endpoint_kind="auto",
|
||||
model_refresh_mode="auto",
|
||||
model_refresh_interval=None,
|
||||
model_refresh_timeout=None,
|
||||
created_at=None,
|
||||
updated_at=None,
|
||||
)
|
||||
|
||||
class _FakeDb:
|
||||
def query(self, _model):
|
||||
return self
|
||||
|
||||
def filter(self, *_args, **_kwargs):
|
||||
return self
|
||||
|
||||
def first(self):
|
||||
return ep
|
||||
|
||||
def commit(self):
|
||||
pass
|
||||
|
||||
def close(self):
|
||||
pass
|
||||
|
||||
monkeypatch.setattr(mr, "SessionLocal", lambda: _FakeDb())
|
||||
monkeypatch.setattr(mr, "require_admin", lambda request: None)
|
||||
monkeypatch.setattr(mr, "_chatgpt_endpoint_visible", lambda ep_, request: True)
|
||||
return ep
|
||||
|
||||
def test_patch_cloud_metadata_blocked(self, client, monkeypatch):
|
||||
"""PATCH must reject the cloud metadata URL, not store it."""
|
||||
ep = self._install_fake_endpoint(monkeypatch)
|
||||
resp = client.patch(
|
||||
"/api/model-endpoints/ep1",
|
||||
json={"base_url": "http://169.254.169.254/latest/meta-data"},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
assert "Rejected endpoint URL" in resp.json()["detail"]
|
||||
assert ep.base_url == "http://localhost:9999/v1"
|
||||
|
||||
def test_patch_non_http_scheme_blocked(self, client, monkeypatch):
|
||||
"""PATCH must reject non-HTTP schemes."""
|
||||
ep = self._install_fake_endpoint(monkeypatch)
|
||||
resp = client.patch(
|
||||
"/api/model-endpoints/ep1",
|
||||
json={"base_url": "file:///etc/passwd"},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
assert "Rejected endpoint URL" in resp.json()["detail"]
|
||||
assert ep.base_url == "http://localhost:9999/v1"
|
||||
|
||||
def test_patch_valid_url_accepted(self, client, monkeypatch):
|
||||
"""Control: a legitimate local endpoint URL still updates fine."""
|
||||
ep = self._install_fake_endpoint(monkeypatch)
|
||||
resp = client.patch(
|
||||
"/api/model-endpoints/ep1",
|
||||
json={"base_url": "http://127.0.0.1:11434/v1"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert ep.base_url == "http://127.0.0.1:11434/v1"
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user