feat(editor): harden photo editor security and interactions

This commit is contained in:
Alexandre Teixeira
2026-10-28 17:37:10 +00:00
parent 0b8176b11e
commit c237a1cb9c
68 changed files with 3630 additions and 426 deletions
+7 -2
View File
@@ -1,7 +1,7 @@
# Browser End-to-End Tests
The photo editor release gate launches an isolated, authentication-disabled
Odysseus server on port `7013` with a temporary SQLite database.
The photo editor release gate launches an isolated Odysseus server on port
`7013` with a temporary SQLite database and disposable authenticated account.
```bash
npm install
@@ -13,3 +13,8 @@ Set `PHOTO_EDITOR_E2E_PORT` to use another port. Failure screenshots and traces
are written to `test-results/photo-editor/`. The server uses the repository
`.venv` automatically; set `ODYSSEUS_TEST_PYTHON` to another Python executable
when using a different environment layout.
The disposable account is created in the temporary data directory and exercises
owner-scoped draft and notification routes without using an existing account.
`PHOTO_EDITOR_E2E_AUTH=0` selects the legacy unauthenticated configuration for
investigating it; offline-upgrade coverage requires the authenticated mode.
@@ -1,5 +1,5 @@
const { test, expect } = require('@playwright/test');
const { selectTool } = require('./helpers.js');
const { chooseFilterAction, selectTool } = require('./helpers.js');
const {
dragOnCanvas,
encodedImagePixelDigest,
@@ -11,7 +11,7 @@ const {
} = require('./helpers.js');
async function addAdjustment(page, type) {
await page.locator('#ge-add-layer').click();
await page.locator('#ge-add-fx').click();
const menu = page.locator('.ge-add-layer-menu');
await expect(menu).toBeVisible();
await menu.locator(`[data-adjustment-type="${type}"]`).click();
@@ -252,8 +252,7 @@ test('retained Gaussian Blur survives flattening and draft reopen', async ({ pag
await dragOnCanvas(page, { x: 0.2, y: 0.25 }, { x: 0.8, y: 0.7 });
const before = await flattenedPixelDigest(page);
await page.locator('#ge-filter-menu-btn').click();
await page.locator('[data-filter-action="effect-blur-gaussian"]').click();
await chooseFilterAction(page, 'effect-blur-gaussian');
await expect(page.locator('.ge-filter-modal')).toBeVisible();
await page.locator('.ge-filter-row input[data-key="radius"]').fill('14');
await page.locator('.ge-filter-modal [data-action="apply"]').click();
@@ -282,8 +281,7 @@ test('retained Gaussian Blur survives flattening and draft reopen', async ({ pag
test('retained Sharpen keeps editable amount and survives draft reopen', async ({ page }) => {
await openBlankEditor(page, { width: 320, height: 220 }, 'Retained sharpen E2E');
await page.locator('#ge-filter-menu-btn').click();
await page.locator('[data-filter-action="effect-preset-crisp-detail"]').click();
await chooseFilterAction(page, 'effect-preset-crisp-detail');
await expect(page.locator('.ge-filter-modal')).toBeVisible();
expect(await page.locator('.ge-filter-row input[data-key="amount"]').inputValue()).toBe('35');
await page.locator('.ge-filter-row input[data-key="amount"]').fill('75');
@@ -308,16 +306,14 @@ test('retained overlay and shadow effects keep editable metadata', async ({ page
await page.mouse.down();
await page.mouse.move(canvasBox.x + 150, canvasBox.y + 140);
await page.mouse.up();
await page.locator('#ge-filter-menu-btn').click();
await page.locator('[data-filter-action="effect-color-overlay"]').click();
await chooseFilterAction(page, 'effect-color-overlay');
await expect(page.locator('.ge-filter-modal')).toBeVisible();
await page.locator('.ge-filter-row input[type="color"]').fill('#336699');
await page.locator('.ge-filter-row input[data-key="opacity"]').fill('35');
await page.locator('.ge-filter-modal [data-action="apply"]').click();
await page.locator('button[title="Add effect mask from selection"]').click();
await page.locator('#ge-filter-menu-btn').click();
await page.locator('[data-filter-action="effect-drop-shadow"]').click();
await chooseFilterAction(page, 'effect-drop-shadow');
await expect(page.locator('.ge-filter-modal')).toBeVisible();
await page.locator('.ge-filter-row input[data-key="blur"]').fill('18');
await page.locator('.ge-filter-modal [data-action="apply"]').click();
@@ -348,8 +344,7 @@ test('retained Stroke uses layer alpha and persists its controls', async ({ page
await openBlankEditor(page, { width: 320, height: 220 }, 'Retained stroke E2E');
await selectTool(page, 'brush');
await dragOnCanvas(page, { x: 0.25, y: 0.25 }, { x: 0.75, y: 0.7 });
await page.locator('#ge-filter-menu-btn').click();
await page.locator('[data-filter-action="effect-stroke"]').click();
await chooseFilterAction(page, 'effect-stroke');
await expect(page.locator('.ge-filter-modal')).toBeVisible();
await page.locator('.ge-filter-row input[data-key="width"]').fill('9');
await page.locator('.ge-filter-modal [data-action="apply"]').click();
@@ -1,5 +1,9 @@
const { test, expect } = require('@playwright/test');
// These tests borrow /login as a lightweight page. A signed-in session would
// make the login page redirect to the app mid-test, so run them signed out.
test.use({ storageState: { cookies: [], origins: [] } });
async function addAppStyles(page) {
const { stylesheetUrls } = await import('../../helpers/stylesheets.mjs');
for (const url of await stylesheetUrls()) {
+11 -3
View File
@@ -54,7 +54,8 @@ test('long sampled strokes stay responsive and undo atomically', async ({ page }
await page.mouse.down();
await page.mouse.move(end.x, end.y, { steps: 240 });
await page.mouse.up();
expect(Date.now() - startedAt).toBeLessThan(5000);
const strokeDuration = Date.now() - startedAt;
expect(strokeDuration).toBeLessThan(5000);
const after = await flattenedPixelDigest(page);
expect(after).not.toEqual(before);
await expect(thumb).toHaveAttribute('width', '68');
@@ -78,14 +79,17 @@ test('eyedropper and retouch tools use the editor stroke lifecycle', async ({ pa
await selectTool(page, 'eyedropper');
await expect(page.locator('#ge-eyedropper-section')).toBeVisible();
await page.locator('#ge-eyedropper-sample').selectOption('composite');
await page.mouse.move(sample.x, sample.y);
// The options strip height differs per tool, which re-centres the canvas a
// few pixels; hover the same image point that was painted.
const painted = await canvasPoint(page, 0.3, 0.3);
await page.mouse.move(painted.x, painted.y);
await expect(page.locator('#ge-eyedropper-live-value')).toHaveText('#33aa55');
await expect(page.locator('#ge-eyedropper-live-rgb')).toHaveText('RGB 51 170 85');
await expect(page.locator('#ge-eyedropper-live-hsl')).toHaveText('HSL 137 54% 43%');
await expect(page.locator('#ge-eyedropper-live-swatch')).not.toHaveClass(/empty/);
await expect(page.locator('#ge-eyedropper-loupe')).toBeVisible();
expect((await page.locator('#ge-eyedropper-loupe').evaluate(canvas => canvas.getContext('2d').getImageData(42, 42, 1, 1).data[3]))).toBe(255);
await page.mouse.click(sample.x, sample.y);
await page.mouse.click(painted.x, painted.y);
await expect(page.locator('.ge-color-picker').first()).toHaveValue('#33aa55');
for (const tool of ['dodge', 'burn']) {
@@ -178,7 +182,11 @@ test('smudge carries nearby pixels along a stroke and undoes atomically', async
return [...canvas.getContext('2d').getImageData(x, y, 1, 1).data];
});
await selectTool(page, 'smudge');
// Smudge strength lives in the brush-family "More" menu with the other
// per-tool stroke modifiers.
await page.locator('#ge-brush-more').click();
await expect(page.locator('#ge-smudge-strength-row')).toBeVisible();
await page.keyboard.press('Escape');
await page.mouse.move(source.x, source.y);
await page.mouse.down();
await page.mouse.move(target.x, target.y, { steps: 20 });
@@ -1,6 +1,6 @@
const { test, expect } = require('@playwright/test');
const { selectTool } = require('./helpers.js');
const { dragOnCanvas, editorState, flattenedPixelDigest, openBlankEditor } = require('./helpers.js');
const { dragOnCanvas, editorState, flattenedPixelDigest, openBlankEditor, settledCanvasBox } = require('./helpers.js');
test('tool switching cancels incomplete crop and selection gestures without stale edits', async ({ page }) => {
await openBlankEditor(page, { width: 320, height: 240 }, 'Gesture cancellation');
@@ -75,13 +75,15 @@ test('touch crop and selection gestures complete through the shared lifecycle',
try {
await openBlankEditor(page, { width: 320, height: 240 }, 'Touch gestures');
const cdp = await context.newCDPSession(page);
const canvasBox = await page.locator('.ge-main-canvas').boundingBox();
const touch = async (type, x, y) => cdp.send('Input.dispatchTouchEvent', {
type,
touchPoints: type === 'touchEnd' ? [] : [{ x, y, id: 1, radiusX: 6, radiusY: 6 }],
});
await selectTool(page, 'crop');
// Picking a tool minimizes the phone Layers sheet and refits the canvas;
// aim at where the canvas settles, as a finger would.
const canvasBox = await settledCanvasBox(page);
await touch('touchStart', canvasBox.x + 35, canvasBox.y + 30);
await touch('touchMove', canvasBox.x + 190, canvasBox.y + 145);
await touch('touchEnd', canvasBox.x + 190, canvasBox.y + 145);
@@ -113,18 +115,24 @@ test('touch brush paints and undoes as one stroke', async ({ browser, browserNam
try {
await openBlankEditor(page, { width: 320, height: 240 }, 'Touch brush');
const cdp = await context.newCDPSession(page);
const canvasBox = await page.locator('.ge-main-canvas').boundingBox();
const touch = async (type, x, y) => cdp.send('Input.dispatchTouchEvent', {
type,
touchPoints: type === 'touchEnd' ? [] : [{ x, y, id: 1, radiusX: 6, radiusY: 6 }],
});
await selectTool(page, 'brush');
// Re-tapping the active tool collapses its controls sheet (canvas-first
// phone editing), which also uncovers the Layers sheet grab handle.
await selectTool(page, 'brush');
const canvasBox = await settledCanvasBox(page);
const before = await flattenedPixelDigest(page);
await touch('touchStart', canvasBox.x + canvasBox.width * 0.25, canvasBox.y + canvasBox.height * 0.5);
await touch('touchMove', canvasBox.x + canvasBox.width * 0.75, canvasBox.y + canvasBox.height * 0.5);
await touch('touchEnd', canvasBox.x + canvasBox.width * 0.75, canvasBox.y + canvasBox.height * 0.5);
const after = await flattenedPixelDigest(page);
expect(after).not.toEqual(before);
// History buttons live in the Layers sheet, which the tool pick minimized.
await page.locator('.ge-layers-grab').click();
await expect(page.locator('.ge-right-panel')).not.toHaveClass(/minimized/);
await page.locator('#ge-undo').click();
expect(await flattenedPixelDigest(page)).toEqual(before);
} finally {
@@ -39,7 +39,7 @@ async function downloadFormat(page, format, filename) {
}
async function addAdjustment(page, type) {
await page.locator('#ge-add-layer').click();
await page.locator('#ge-add-fx').click();
await expect(page.locator('.ge-add-layer-menu')).toBeVisible();
await page.locator(`.ge-add-layer-menu [data-adjustment-type="${type}"]`).click();
await expect(page.locator('.ge-adj-popup')).toBeVisible();
@@ -100,7 +100,7 @@ test('composited correction exports preserve pixels and format metadata', async
await openBlankEditor(page, { width: 160, height: 120 }, 'Export fidelity');
await seedGradient(page);
await page.locator('#ge-add-layer').click();
await page.locator('#ge-add-fx').click();
await page.locator('.ge-add-layer-menu [data-adjustment-type="exposure"]').click();
await page.locator('.ge-adj-row input[data-key="exposure"]').fill('35');
await page.locator('.ge-adj-row input[data-key="exposure"]').dispatchEvent('input');
+3 -5
View File
@@ -1,5 +1,5 @@
const { test, expect } = require('@playwright/test');
const { selectTool } = require('./helpers.js');
const { chooseFilterAction, selectTool } = require('./helpers.js');
const fs = require('node:fs');
const {
compareExportPixels,
@@ -38,8 +38,7 @@ test('group retained effects edit, toggle, reorder surface, and survive reopen',
const groupId = (await editorState(page)).groups[0].id;
const groupRow = page.locator(`.ge-layer-group-row[data-group-id="${groupId}"]`);
await page.locator('.ge-layer-controls:visible .ge-layer-btn[title*="effect"]').click();
await page.locator('[data-filter-action="effect-blur-gaussian"]').click();
await chooseFilterAction(page, 'effect-blur-gaussian', '.ge-layer-controls:visible .ge-layer-btn[title*="effect"]');
await expect(page.locator('.ge-filter-modal')).toBeVisible();
await page.locator('.ge-filter-row input[data-key="radius"]').fill('14');
await page.locator('.ge-filter-modal [data-action="apply"]').click();
@@ -84,8 +83,7 @@ test('group retained effects export the visible composite and survive reopen', a
await page.locator('.ge-layer-item[data-layer-id]').filter({ hasText: 'Background' }).first().click({ modifiers: ['Control'] });
await page.locator('#ge-group-selected').click();
await page.locator('.ge-layer-controls:visible .ge-layer-btn[title*="effect"]').click();
await page.locator('[data-filter-action="effect-color-overlay"]').click();
await chooseFilterAction(page, 'effect-color-overlay', '.ge-layer-controls:visible .ge-layer-btn[title*="effect"]');
await expect(page.locator('.ge-filter-modal')).toBeVisible();
await page.locator('.ge-filter-row input[type="color"]').fill('#336699');
await page.locator('.ge-filter-row input[data-key="opacity"]').fill('40');
+1 -2
View File
@@ -10,7 +10,6 @@ test('group drag reorders the complete subtree with undo and server persistence'
const originalOrder = current.layers.map(layer => layer.id);
await page.locator('#ge-add-layer').click();
await page.locator('.ge-add-layer-menu [data-layer-kind="raster"]').click();
current = await editorState(page);
const looseId = current.activeLayerId;
expect(current.layers.map(layer => layer.id)).toEqual([...originalOrder, looseId]);
@@ -66,7 +65,7 @@ test('mobile layer drag handle supports long-press reorder', async ({ browser, b
const page = await context.newPage();
try {
await openBlankEditor(page, { width: 320, height: 240 }, 'Mobile layer reorder');
await page.locator('.ge-layers-header').click();
await page.locator('.ge-layers-title').click();
await expect(page.locator('.ge-right-panel')).toHaveClass(/expanded/);
const before = await editorState(page);
// State is bottom-to-top; the panel is top-to-bottom.
+60 -37
View File
@@ -1,40 +1,22 @@
const { expect } = require('@playwright/test');
async function openBlankEditor(page, size = { width: 640, height: 480 }, name = 'Photo editor E2E') {
let lastError = null;
for (let pageAttempt = 0; pageAttempt < 3; pageAttempt += 1) {
try {
await page.goto('/', { waitUntil: 'domcontentloaded' });
await page.locator('#tool-gallery-btn').waitFor({ state: 'attached', timeout: 20_000 });
await page.evaluate(async () => {
window.__photoEditorImport = async path => {
let lastImportError = null;
for (let attempt = 0; attempt < 4; attempt += 1) {
const url = attempt === 0 ? path : `${path}?e2e_retry=${attempt}-${Date.now()}`;
try { return await import(url); } catch (error) { lastImportError = error; }
await new Promise(resolve => setTimeout(resolve, 150 * (attempt + 1)));
}
throw lastImportError;
};
const gallery = await window.__photoEditorImport('/static/js/gallery.js?v=20260830editor4');
gallery.openGallery();
});
await page.locator('#gallery-editor-tab').waitFor({ state: 'visible', timeout: 20_000 });
await page.locator('#gallery-editor-tab').click();
await page.evaluate(async ({ size: nextSize, name: nextName }) => {
const editor = await window.__photoEditorImport('/static/js/galleryEditor.js');
editor.openEditor(null, null, { w: nextSize.width, h: nextSize.height }, nextName);
}, { size, name });
await page.locator('.ge-main-canvas').waitFor({ state: 'visible', timeout: 20_000 });
lastError = null;
break;
} catch (error) {
lastError = error;
await page.goto('about:blank');
await page.waitForTimeout(250 * (pageAttempt + 1));
}
}
if (lastError) throw lastError;
await page.goto('/', { waitUntil: 'domcontentloaded' });
// Workflow tests exercise the canvas, not the optional setup overlay.
await page.evaluate(() => localStorage.setItem('ge-preflight-dismissed', '1'));
await page.locator('#tool-gallery-btn').waitFor({ state: 'attached', timeout: 20_000 });
await page.evaluate(async () => {
window.__photoEditorImport = path => import(path);
const gallery = await window.__photoEditorImport('/static/js/gallery.js?v=20260830editor4');
gallery.openGallery();
});
await page.locator('#gallery-editor-tab').waitFor({ state: 'visible', timeout: 20_000 });
await page.locator('#gallery-editor-tab').click();
await page.evaluate(async ({ size: nextSize, name: nextName }) => {
const editor = await window.__photoEditorImport('/static/js/galleryEditor.js');
editor.openEditor(null, null, { w: nextSize.width, h: nextSize.height }, nextName);
}, { size, name });
await page.locator('.ge-main-canvas').waitFor({ state: 'visible', timeout: 20_000 });
await expect.poll(async () => page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
return state.layers.length;
@@ -368,12 +350,51 @@ async function selectTool(page, toolId) {
await face.first().click();
return;
}
// Flyout members are pre-rendered (hidden until the slot opens). Dispatch a
// click directly so the helper does not depend on hover/flyout visibility.
await page.locator(`.ge-tool-flyout-item[data-tool-option="${toolId}"]`).first().dispatchEvent('click');
// Open the flyout via its corner triangle, wait for item visibility, and click realistically
const slot = page.locator('.ge-tool-slot', { has: page.locator(`[data-tool-option="${toolId}"]`) }).first();
const tri = slot.locator('.ge-tool-flyout-tri');
if (await tri.count() > 0) {
await tri.click();
}
const item = slot.locator(`.ge-tool-flyout-item[data-tool-option="${toolId}"]`);
await expect(item).toBeVisible({ timeout: 3000 });
await item.click();
}
// On phones, picking a tool minimizes the Layers sheet and refits the canvas.
// Wait for the canvas to stop moving before aiming at it.
async function settledCanvasBox(page) {
let box = null;
await expect.poll(async () => {
const previous = box;
box = await page.locator('.ge-main-canvas').boundingBox();
return JSON.stringify(previous) === JSON.stringify(box);
}).toBe(true);
return box;
}
// Rare text/shape options live in the options strip's "More" popover, which
// closes on any outside press. Open it (idempotently) before using them.
async function openOptionsMore(page, kind) {
const menu = page.locator(`#ge-${kind}-more`);
if (await menu.isHidden()) await page.locator(`#ge-${kind}-more-toggle`).click();
await expect(menu).toBeVisible();
}
// Filter actions live in Photoshop-style flyouts (Blur, Retained effects, ...).
// Open the menu, then the item's parent row, as a user would.
async function chooseFilterAction(page, action, opener = '#ge-filter-menu-btn') {
await page.locator(opener).click();
const menu = page.locator('#ge-filter-menu');
await expect(menu).toBeVisible();
const item = menu.locator(`[data-filter-action="${action}"]`);
const panel = await item.evaluate(node => node.closest('.ge-submenu')?.dataset.submenuPanel || null);
if (panel) await menu.locator(`.ge-menu-parent[data-submenu="${panel}"]`).click();
await item.click();
}
module.exports = {
chooseFilterAction,
compareExportPixels,
dragOnCanvas,
encodedImagePixelDigest,
@@ -381,7 +402,9 @@ module.exports = {
flattenedPixelDigest,
openBlankEditor,
openExportDialog,
openOptionsMore,
reopenDraft,
selectTool,
settledCanvasBox,
waitForDraft,
};
@@ -1,5 +1,5 @@
const { test, expect } = require('@playwright/test');
const { openBlankEditor, editorState, dragOnCanvas } = require('./helpers');
const { chooseFilterAction, dragOnCanvas, editorState, openBlankEditor, waitForDraft } = require('./helpers.js');
test.beforeEach(async ({ page }) => {
// The isolated editor server has no logged-in notification owner.
@@ -35,6 +35,96 @@ test('top toolbar stays on one row and its menus remain clickable at narrow widt
}
});
test('save button icon follows the draft status through an edit and autosave', async ({ page }) => {
await openBlankEditor(page, { width: 320, height: 240 });
// Autosave can settle within a frame, so record the rendered icon at every
// status change instead of racing it with polling assertions.
await page.evaluate(() => {
const status = document.getElementById('ge-draft-status');
const visibleIcon = () => [...document.querySelectorAll('#ge-save-menu-btn .ge-save-state-icon')]
.filter(icon => icon.getBoundingClientRect().width > 0)
.map(icon => (icon.classList.contains('ge-save-state-saved') ? 'saved' : 'dirty'));
window.__saveStates = [];
new MutationObserver(() => window.__saveStates.push({ text: status.textContent, icons: visibleIcon() }))
.observe(status, { childList: true, characterData: true, subtree: true });
});
await page.keyboard.press('b');
await dragOnCanvas(page, { x: 0.2, y: 0.2 }, { x: 0.7, y: 0.7 });
await waitForDraft(page);
await expect(page.locator('#ge-draft-status')).toHaveText('Saved');
const states = await page.evaluate(() => window.__saveStates);
expect(states.some(entry => entry.text === 'Unsaved')).toBe(true);
for (const entry of states) {
expect(entry.icons).toEqual([entry.text === 'Saved' ? 'saved' : 'dirty']);
}
});
test('save-state representations cover all draft states without error normalization', async ({ page }) => {
await openBlankEditor(page, { width: 320, height: 240 });
await waitForDraft(page);
const cases = [
{ label: 'Unsaved', state: 'dirty', expectedText: 'Unsaved', expectedState: 'dirty', iconClass: 'ge-save-state-dirty', isError: false },
{ label: 'Saving soon…', state: 'pending', expectedText: 'Saving soon…', expectedState: 'pending', iconClass: 'ge-save-state-dirty', isError: false },
{ label: 'Saving…', state: 'saving', expectedText: 'Saving…', expectedState: 'saving', iconClass: 'ge-save-state-dirty', isError: false },
{ label: 'Saved', state: 'saved', expectedText: 'Saved', expectedState: 'saved', iconClass: 'ge-save-state-saved', isError: false },
{ label: 'Save failed', state: 'error', expectedText: 'Save failed', expectedState: 'error', iconClass: 'ge-save-state-error', isError: true },
{ label: 'Save paused', state: 'paused', expectedText: 'Save paused', expectedState: 'paused', iconClass: 'ge-save-state-error', isError: true },
{ label: 'Save failed: network offline', state: '', expectedText: 'Save failed: network offline', expectedState: 'error', iconClass: 'ge-save-state-error', isError: true },
];
for (const c of cases) {
await page.evaluate(({ label, state }) => {
window.__geSetDraftStatus(label, state);
}, c);
const statusEl = page.locator('#ge-draft-status');
const saveBtn = page.locator('#ge-save-menu-btn');
await expect(statusEl).toHaveText(c.expectedText);
await expect(statusEl).toHaveAttribute('data-state', c.expectedState);
const visibleIcon = await page.evaluate(() => {
const icons = [...document.querySelectorAll('#ge-save-menu-btn .ge-save-state-icon')];
const visible = icons.filter(i => !i.hasAttribute('hidden'));
return visible.map(i => i.className.baseVal || i.className);
});
expect(visibleIcon.length).toBe(1);
expect(visibleIcon[0]).toContain(c.iconClass);
if (c.isError) {
await expect(saveBtn).toHaveAttribute('aria-label', new RegExp(`Draft autosave failed: ${c.expectedText}`));
} else {
await expect(saveBtn).toHaveAttribute('aria-label', `Draft status: ${c.expectedText}`);
}
}
});
test('autosave network failure transitions to error state and shows failure indicator', async ({ page }) => {
await openBlankEditor(page, { width: 320, height: 240 });
await page.route(/\/api\/editor-drafts/, route => route.fulfill({
status: 500,
contentType: 'application/json',
body: JSON.stringify({ detail: 'Database error' }),
}));
await page.keyboard.press('b');
await dragOnCanvas(page, { x: 0.2, y: 0.2 }, { x: 0.7, y: 0.7 });
const statusEl = page.locator('#ge-draft-status');
await expect(statusEl).toHaveText('Save failed', { timeout: 10_000 });
await expect(statusEl).toHaveAttribute('data-state', 'error');
const saveBtn = page.locator('#ge-save-menu-btn');
await expect(saveBtn).toHaveAttribute('aria-label', /Draft autosave failed/);
const visibleIcons = await page.evaluate(() => {
const icons = [...document.querySelectorAll('#ge-save-menu-btn .ge-save-state-icon')];
return icons.filter(i => !i.hasAttribute('hidden')).map(i => i.className.baseVal || i.className);
});
expect(visibleIcons).toHaveLength(1);
expect(visibleIcons[0]).toContain('ge-save-state-error');
});
test('committed paint refreshes the layer thumbnail and flashes only the edited row', async ({ page }) => {
await openBlankEditor(page, { width: 320, height: 240 });
const activeId = (await editorState(page)).activeLayerId;
@@ -55,8 +145,7 @@ test('filter Escape restores pixels and preserves redo history', async ({ page }
await dragOnCanvas(page, { x: 0.7, y: 0.2 }, { x: 0.2, y: 0.7 });
await page.keyboard.press('Control+z');
const before = await editorState(page);
await page.locator('#ge-filter-menu-btn').click();
await page.locator('[data-filter-action="blur-gaussian"]').click();
await chooseFilterAction(page, 'blur-gaussian');
await expect(page.locator('.ge-filter-modal')).toBeVisible();
await page.keyboard.press('Escape');
await expect(page.locator('.ge-filter-modal')).toHaveCount(0);
@@ -75,7 +164,10 @@ test('focused fields own undo, selection and clipboard without changing layers',
await input.fill('Editable text');
await input.press('Control+Enter');
// A tool's ordinary text input must also retain native clipboard ownership.
// Frame width lives in the text options' collapsed "More" section.
await page.locator('#ge-text-more-toggle').click();
await page.locator('#ge-text-frame-width').focus();
await expect(page.locator('#ge-text-frame-width')).toBeFocused();
const before = await editorState(page);
await page.keyboard.press('Control+z');
await page.keyboard.press('Control+j');
@@ -247,3 +339,76 @@ test('switching away from a held brush ends one stroke and desktop reselect keep
await page.keyboard.press('Control+z');
expect((await editorState(page)).layers).toEqual(before.layers);
});
test('concurrent edits during in-flight save preserve dirty unsaved status and revision sequence', async ({ page }) => {
await openBlankEditor(page, { width: 320, height: 240 });
await waitForDraft(page);
let delayedRouteResolve = null;
const delayedRoutePromise = new Promise(resolve => { delayedRouteResolve = resolve; });
let saveCallCount = 0;
await page.route(/\/api\/editor-drafts/, async route => {
saveCallCount += 1;
if (saveCallCount === 1) {
await delayedRoutePromise;
}
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ id: 'concurrent-draft-1', name: 'Untitled' }),
});
});
// Edit 1
await page.keyboard.press('b');
await dragOnCanvas(page, { x: 0.2, y: 0.2 }, { x: 0.4, y: 0.4 });
// Wait until save is in flight
await expect(page.locator('#ge-draft-status')).toHaveText('Saving…', { timeout: 10_000 });
// Edit 2 while save 1 is in flight
await dragOnCanvas(page, { x: 0.5, y: 0.5 }, { x: 0.7, y: 0.7 });
const revsDuringFlight = await page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
return {
docRev: state.documentRevision,
persistingRev: state.persistingRevision,
dirty: state.persistDirty,
};
});
expect(revsDuringFlight.docRev).toBeGreaterThan(revsDuringFlight.persistingRev);
expect(revsDuringFlight.dirty).toBe(true);
// Now let save 1 complete
delayedRouteResolve();
// Wait for the chained second save to complete
await expect.poll(async () => {
return page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
return state.savedRevision >= state.documentRevision && !state.persistDirty;
});
}, { timeout: 15_000 }).toBe(true);
await expect(page.locator('#ge-draft-status')).toHaveText('Saved');
});
test('failed draft save retains unsaved dirty status and failure indicator', async ({ page }) => {
await openBlankEditor(page, { width: 320, height: 240 });
await waitForDraft(page);
await page.route(/\/api\/editor-drafts/, route => route.abort('failed'));
await page.keyboard.press('b');
await dragOnCanvas(page, { x: 0.2, y: 0.2 }, { x: 0.4, y: 0.4 });
await expect(page.locator('#ge-draft-status')).toHaveText('Save failed', { timeout: 10_000 });
const dirtyAfterFailure = await page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
return state.persistDirty;
});
expect(dirtyAfterFailure).toBe(true);
});
@@ -3,8 +3,6 @@ const { selectTool } = require('./helpers.js');
const { dragOnCanvas, editorState, openBlankEditor, reopenDraft, waitForDraft } = require('./helpers.js');
test('Ctrl-click thumbnail shows pixel selection even after outlines were hidden', async ({ page }) => {
// Notification polling requires login even in the auth-disabled test server.
await page.route('**/api/tasks/notification-logs*', route => route.fulfill({ json: { logs: [] } }));
await openBlankEditor(page, { width: 240, height: 160 }, 'Thumbnail selection');
const id = await page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
@@ -32,7 +30,6 @@ test('Ctrl-click thumbnail shows pixel selection even after outlines were hidden
test('selected layers align to the canvas and undo as one operation', async ({ page }) => {
await openBlankEditor(page, { width: 320, height: 240 }, 'Alignment E2E');
await page.locator('#ge-add-layer').click();
await page.locator('.ge-add-layer-menu [data-layer-kind="raster"]').click();
await page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
const edit = state.layers.find(layer => layer.name === 'Edit');
@@ -111,7 +108,6 @@ test('group rows can be selected with the keyboard', async ({ page }) => {
test('Delete removes the selected layer and undo restores it', async ({ page }) => {
await openBlankEditor(page, { width: 240, height: 160 }, 'Keyboard delete E2E');
await page.locator('#ge-add-layer').click();
await page.locator('.ge-add-layer-menu [data-layer-kind="raster"]').click();
const addedId = (await editorState(page)).activeLayerId;
await page.locator(`.ge-layer-item[data-layer-id="${addedId}"]`).click();
await page.keyboard.press('Backspace');
@@ -193,7 +189,6 @@ test('multi-selected layers move together and support history-backed bulk action
await editRow.click();
await page.locator('#ge-add-layer').click();
await page.locator('.ge-add-layer-menu [data-layer-kind="raster"]').click();
const addedId = (await editorState(page)).activeLayerId;
await editRow.click({ modifiers: ['Control'] });
await expect(page.locator('.ge-layer-item.selected[data-layer-id]')).toHaveCount(2);
@@ -278,7 +273,6 @@ test('nested groups preserve hierarchy, ancestor locks, collapse, and server reo
const innerId = current.groups[0].id;
await page.locator('#ge-add-layer').click();
await page.locator('.ge-add-layer-menu [data-layer-kind="raster"]').click();
current = await editorState(page);
const looseId = current.activeLayerId;
await page.locator(`.ge-layer-group-row[data-group-id="${innerId}"]`).click();
@@ -1,5 +1,5 @@
const { test, expect } = require('@playwright/test');
const { editorState, flattenedPixelDigest, openBlankEditor } = require('./helpers.js');
const { chooseFilterAction, editorState, flattenedPixelDigest, openBlankEditor } = require('./helpers.js');
async function seedVisibleLayers(page) {
await page.evaluate(async () => {
@@ -18,14 +18,13 @@ test('Merge all preserves retained effects and adjustment output', async ({ page
await openBlankEditor(page, { width: 320, height: 220 }, 'Merge fidelity E2E');
await seedVisibleLayers(page);
await page.locator('#ge-filter-menu-btn').click();
await page.locator('[data-filter-action="effect-color-overlay"]').click();
await chooseFilterAction(page, 'effect-color-overlay');
await expect(page.locator('.ge-filter-modal')).toBeVisible();
await page.locator('.ge-filter-row input[type="color"]').fill('#f0c04a');
await page.locator('.ge-filter-row input[data-key="opacity"]').fill('35');
await page.locator('.ge-filter-modal [data-action="apply"]').click();
await page.locator('#ge-add-layer').click();
await page.locator('#ge-add-fx').click();
await page.locator('.ge-add-layer-menu [data-adjustment-type="exposure"]').click();
await expect(page.locator('.ge-adj-popup')).toBeVisible();
await page.locator('.ge-adj-row input[data-key="exposure"]').fill('45');
@@ -0,0 +1,57 @@
const { test, expect } = require('@playwright/test');
const { openBlankEditor, selectTool } = require('./helpers.js');
test.use({ viewport: { width: 390, height: 844 }, hasTouch: true });
test('minimizing layers restores fit view and keeps an explicit zoom', async ({ page }) => {
await openBlankEditor(page, { width: 320, height: 240 });
const canvas = page.locator('.ge-main-canvas');
const zoom = () => page.evaluate(async () => (await import('/static/js/editor/state.js')).state.zoom);
await page.locator('.ge-layers-title').click();
await expect(page.locator('.ge-right-panel')).toHaveClass(/expanded/);
await selectTool(page, 'eraser');
await selectTool(page, 'eraser');
await expect(page.locator('.ge-right-panel')).toHaveClass(/minimized/);
await expect.poll(async () => (await canvas.boundingBox()).width).toBeGreaterThan(200);
await page.locator('.ge-layers-grab').click();
await expect(page.locator('.ge-right-panel')).not.toHaveClass(/minimized/);
await page.locator('#ge-zoom-in').evaluate(button => button.click());
await expect.poll(() => page.evaluate(async () => (await import('/static/js/editor/state.js')).state.zoomViewMode)).toBe('scale');
const manualZoom = await zoom();
await page.locator('.ge-layers-title').click();
await selectTool(page, 'eraser');
await selectTool(page, 'eraser');
await expect(page.locator('.ge-right-panel')).toHaveClass(/minimized/);
await expect.poll(zoom).toBe(manualZoom);
});
test('minimizing layers keeps a pinch zoom', async ({ page, context, browserName }) => {
test.skip(browserName !== 'chromium', 'Uses Chromium CDP multi-touch injection');
await openBlankEditor(page, { width: 320, height: 240 });
const state = () => page.evaluate(async () => {
const { state: editor } = await import('/static/js/editor/state.js');
return { zoom: editor.zoom, mode: editor.zoomViewMode };
});
const fitted = await state();
const box = await page.locator('.ge-main-canvas').boundingBox();
const cx = box.x + box.width / 2;
const cy = box.y + box.height / 2;
const cdp = await context.newCDPSession(page);
const fingers = spread => [{ x: cx - spread, y: cy, id: 1 }, { x: cx + spread, y: cy, id: 2 }];
await cdp.send('Input.dispatchTouchEvent', { type: 'touchStart', touchPoints: fingers(20) });
for (const spread of [30, 40, 50]) {
await cdp.send('Input.dispatchTouchEvent', { type: 'touchMove', touchPoints: fingers(spread) });
}
await cdp.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] });
const pinched = await state();
expect(pinched.mode).toBe('scale');
expect(pinched.zoom).toBeGreaterThan(fitted.zoom * 2);
await page.locator('.ge-layers-title').click();
await expect(page.locator('.ge-right-panel')).toHaveClass(/expanded/);
await selectTool(page, 'eraser');
await selectTool(page, 'eraser');
await expect(page.locator('.ge-right-panel')).toHaveClass(/minimized/);
await expect.poll(async () => (await state()).zoom).toBe(pinched.zoom);
});
@@ -121,6 +121,10 @@ test('mobile touch mask editing supports undo and persists through reload', asyn
const painted = await editorState(page);
const paintedLayer = painted.layers.find(layer => layer.masks.length);
expect(paintedLayer.masks[0].pixelHash).not.toBe(before);
// History controls live in the layers header, which is hidden while the
// sheet is minimized for painting. Reopen it as a phone user would.
await page.locator('.ge-layers-grab').click();
await expect(page.locator('.ge-right-panel')).not.toHaveClass(/minimized/);
await page.locator('#ge-undo').click();
expect((await editorState(page)).layers.find(layer => layer.masks.length).masks[0].pixelHash)
.toBe(before);
@@ -207,6 +211,8 @@ test('mobile touch moves an unlinked layer mask without moving its layer', async
expect(movedLayer.offset).toEqual(beforeLayer.offset);
expect(movedLayer.masks[0].offset.x).not.toBe(beforeLayer.masks[0].offset.x);
expect(movedLayer.masks[0].offset.y).not.toBe(beforeLayer.masks[0].offset.y);
await page.locator('.ge-layers-grab').click();
await expect(page.locator('.ge-right-panel')).not.toHaveClass(/minimized/);
await page.locator('#ge-undo').click();
expect((await editorState(page)).layers.find(layer => layer.id === layerId).masks[0].offset)
.toEqual(beforeLayer.masks[0].offset);
@@ -0,0 +1,87 @@
const { test, expect } = require('@playwright/test');
const path = require('node:path');
test('static editor modules survive a cache upgrade and offline reopen', async ({ browser }, testInfo) => {
const context = await browser.newContext({
baseURL: testInfo.project.use.baseURL,
storageState: path.join(process.env.PHOTO_EDITOR_E2E_DATA_DIR, 'browser-auth.json'),
serviceWorkers: 'allow',
});
const page = await context.newPage();
const pickerUrl = '/static/js/colorPicker.js?v=20260910eyedropper1';
const cssUrl = '/static/css/13-image-editor.css?v=20260929csssplit';
const editorUrl = '/static/js/galleryEditor.js?v=20260909movepicklayer1';
try {
// The app's worker intentionally controls /static/, not the SPA root.
await page.goto('/static/js/panels.js?v=20260909movepicklayer1');
const installed = await page.evaluate(async ({ pickerUrl, cssUrl }) => {
const previous = await caches.open('odysseus-v1113-pr40-editor-precache');
await previous.put(pickerUrl, new Response('stale picker'));
await previous.put(cssUrl, new Response('stale css'));
const registration = await navigator.serviceWorker.register('/static/sw.js?v=20260916autoscroll1');
await navigator.serviceWorker.ready;
if (!navigator.serviceWorker.controller) {
await new Promise(resolve => navigator.serviceWorker.addEventListener('controllerchange', resolve, { once: true }));
}
return { scope: new URL(registration.scope).pathname, root: !!(await navigator.serviceWorker.getRegistration('/')) };
}, { pickerUrl, cssUrl });
expect(installed).toEqual({ scope: '/static/', root: false });
await expect.poll(() => page.evaluate(async () => (await caches.keys()).includes('odysseus-v1113-pr40-editor-precache'))).toBe(false);
for (const url of [pickerUrl, cssUrl, editorUrl, '/static/js/platform.js', '/static/js/modelSort.js']) {
expect(await page.evaluate(async value => !!(await caches.match(value)), url)).toBe(true);
}
// Simulate stale entries in the active version, then verify network-first
// refresh updates both JS and CSS before testing the offline fallback.
const currentCache = await page.evaluate(async () => (await caches.keys()).find(name => name.startsWith('odysseus-')));
expect(currentCache).toMatch(/^odysseus-v\d+-pr40-editor-precache$/);
expect(currentCache).not.toBe('odysseus-v1113-pr40-editor-precache');
await page.evaluate(async ({ currentCache, pickerUrl, cssUrl }) => {
const cache = await caches.open(currentCache);
await cache.put(pickerUrl, new Response('stale picker'));
await cache.put(cssUrl, new Response('stale css'));
for (const url of [pickerUrl, cssUrl]) {
const response = await fetch(url);
if (!response.ok) throw new Error(`Network refresh failed: ${url} ${response.status}`);
}
}, { currentCache, pickerUrl, cssUrl });
for (const url of [pickerUrl, cssUrl]) {
await expect.poll(() => page.evaluate(async value => {
const response = await caches.match(value);
return response ? !(await response.text()).startsWith('stale ') : false;
}, url)).toBe(true);
}
// Origin is the only Vary header the offline fallback may ignore. An entry
// that varies on anything else (as a per-user response would) must miss.
const varyProbeUrl = '/static/js/e2e-vary-probe.js';
await page.evaluate(async ({ currentCache, varyProbeUrl }) => {
const cache = await caches.open(currentCache);
await cache.put(
new Request(varyProbeUrl, { headers: { 'X-Probe-Owner': 'another-user' } }),
new Response('export default "private";', { headers: { 'Content-Type': 'text/javascript', Vary: 'Origin, X-Probe-Owner' } }),
);
}, { currentCache, varyProbeUrl });
await context.setOffline(true);
for (let attempt = 0; attempt < 2; attempt += 1) {
const offline = await context.newPage();
await offline.goto('/static/js/panels.js?v=20260909movepicklayer1');
expect(await offline.evaluate(() => !!navigator.serviceWorker.controller)).toBe(true);
const result = await offline.evaluate(async ({ pickerUrl, editorUrl, cssUrl, varyProbeUrl }) => {
const editor = await import(editorUrl);
const picker = await import(pickerUrl);
const css = await fetch(cssUrl);
const privateProbe = await import(varyProbeUrl).then(() => 'served', () => 'rejected');
return {
editor: typeof editor.openEditor,
picker: ['getRecentColors', 'recordRecentColor', 'suggestColorsFor'].every(key => typeof picker[key] === 'function'),
cssOk: css.ok,
privateProbe,
};
}, { pickerUrl, editorUrl, cssUrl, varyProbeUrl });
expect(result).toEqual({ editor: 'function', picker: true, cssOk: true, privateProbe: 'rejected' });
await offline.close();
}
} finally { await context.close(); }
});
@@ -0,0 +1,95 @@
const { test, expect } = require('@playwright/test');
const { openBlankEditor } = require('./helpers.js');
test('channel and path panels display hostile layer metadata as text', async ({ page }) => {
await openBlankEditor(page, { width: 64, height: 64 });
const result = await page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
const { createColorPanels } = await import('/static/js/editor/panels/panels.js');
const { createStudioPanels } = await import('/static/js/editor/panels/studio-panels.js');
const attack = '<img src=x onerror="window.__panelXss=true">';
const layer = state.layers.find(item => item.id === state.activeLayerId);
layer.name = attack;
layer.kind = 'shape';
layer.shape = { type: attack };
const colors = createColorPanels({ state });
const studio = createStudioPanels({ state });
document.body.append(colors.channels, studio.paths);
colors.refresh();
studio.refresh();
return {
attack,
channel: colors.channels.querySelector('.ge-channel-name').textContent,
path: studio.paths.querySelector('.ge-channel-name').textContent,
images: colors.channels.querySelectorAll('img').length + studio.paths.querySelectorAll('img').length,
executed: !!window.__panelXss,
};
});
expect(result.channel).toBe(result.attack);
expect(result.path).toBe(`${result.attack} · ${result.attack}`);
expect(result.images).toBe(0);
expect(result.executed).toBe(false);
});
test('unknown effect types cannot inject markup into the layer panel', async ({ page }) => {
await openBlankEditor(page, { width: 64, height: 64 });
const attack = '<img src=x onerror="window.__effectXss=true">';
const id = await page.evaluate(async value => {
const { state } = await import('/static/js/editor/state.js');
const layer = state.layers.find(item => item.id === state.activeLayerId);
layer.effects = [{ id: 'effect-test', type: value, visible: true, params: {} }];
return layer.id;
}, attack);
await page.locator(`.ge-layer-item[data-layer-id="${id}"] .ge-layer-vis`).first().click();
const list = page.locator('#ge-layers-list');
await expect(list).toContainText('Gaussian Blur');
await expect(list).not.toContainText(attack);
await expect(list.locator('img')).toHaveCount(0);
expect(await page.evaluate(() => !!window.__effectXss)).toBe(false);
});
test('saved brush preset names remain literal text in the flyout', async ({ page }) => {
const attack = '<img src=x onerror="window.__brushXss=true">';
await page.addInitScript(value => {
localStorage.setItem('odysseus.editor.brush-presets.v1', JSON.stringify([
{ id: 'hostile-preset', name: value, size: 8, opacity: 100, flow: 100, softness: 20 },
]));
}, attack);
await openBlankEditor(page, { width: 64, height: 64 });
const preset = page.locator('.ge-tool-flyout-preset[data-brush-preset="hostile-preset"]');
await expect(preset.locator('.ge-tool-flyout-label')).toHaveText(attack);
await expect(preset.locator('img')).toHaveCount(0);
expect(await page.evaluate(() => !!window.__brushXss)).toBe(false);
});
test('imported adjustment parameters cannot inject markup into the adjustment popup', async ({ page }) => {
await openBlankEditor(page, { width: 64, height: 64 });
const attack = '"><img src=x onerror="window.__adjXss=true">';
const cases = [
{ type: 'color-lookup', params: { cube: { size: attack, data: [0, 0, 0] }, strength: attack }, text: '.ge-adj-lut-name', expected: 'Loaded LUT · 0³' },
{ type: 'gradient-map', params: { shadows: attack, highlights: attack } },
{ type: 'levels', params: { inBlack: attack, outWhite: attack } },
];
for (const item of cases) {
await page.locator('#ge-add-fx').click();
await page.locator(`.ge-add-layer-menu [data-adjustment-type="${item.type}"]`).click();
await page.locator('.ge-adj-popup [data-adj-action="ok"]').click();
await expect(page.locator('.ge-adj-popup')).toHaveCount(0);
// Project files and drafts reach the editor through the codec's
// normalizer, which validates the type but not every parameter.
await page.evaluate(async ({ type, params }) => {
const { state } = await import('/static/js/editor/state.js');
const { normalizeAdjustmentData } = await import('/static/js/editor/adjustment-layer.js');
const layer = state.layers.find(entry => entry.id === state.activeLayerId);
layer.adjustment = normalizeAdjustmentData({ type, params });
}, item);
await page.locator('.ge-layer-controls:visible .ge-layer-fx-btn').click();
const popup = page.locator('.ge-adj-popup');
await expect(popup).toBeVisible();
await expect(popup.locator('img')).toHaveCount(0);
if (item.text) await expect(popup.locator(item.text)).toHaveText(item.expected);
expect(await popup.evaluate(node => node.innerHTML.includes('onerror'))).toBe(false);
await popup.locator('[data-adj-action="cancel"]').click();
}
expect(await page.evaluate(() => !!window.__adjXss)).toBe(false);
});
@@ -38,3 +38,45 @@ test('mobile footer docks flush between the canvas and the layers sheet', async
expect(g.footerLeft).toBeLessThanOrEqual(1);
expect(Math.abs(g.footerRight - 390)).toBeLessThanOrEqual(1);
});
test('phone footer controls for zoom, setup, and AI settings are accessible via normal clicks', async ({ page }) => {
await openBlankEditor(page, { width: 640, height: 480 }, 'Mobile footer controls');
await page.locator('.ge-tour-close').click({ timeout: 500 }).catch(() => {});
const footer = page.locator('.ge-editor-footer');
await expect(footer).toBeVisible();
const setupBtn = page.locator('#ge-setup-btn');
const aiBtn = page.locator('#ge-ai-settings-btn');
const zoomIn = page.locator('#ge-zoom-in');
const zoomOut = page.locator('#ge-zoom-out');
const zoomFit = page.locator('#ge-zoom-100');
await expect(setupBtn).toBeVisible();
await expect(aiBtn).toBeVisible();
await expect(zoomIn).toBeVisible();
await expect(zoomOut).toBeVisible();
await expect(zoomFit).toBeVisible();
// Test zoom controls via normal clicks
const zoomLabel = page.locator('.ge-zoom-label');
const initialZoom = await zoomLabel.textContent();
await zoomIn.click();
await expect(zoomLabel).not.toHaveText(initialZoom);
await zoomFit.click();
await expect(zoomLabel).toHaveText(/%/);
// Test Setup modal accessibility via normal click
await setupBtn.click();
const preflight = page.locator('.ge-preflight');
await expect(preflight).toBeVisible();
await page.locator('.ge-preflight-start').click();
await expect(preflight).toBeHidden();
// Test AI Settings modal accessibility via normal click
await aiBtn.click();
const aiSettings = page.locator('.ge-ai-settings');
await expect(aiSettings).toBeVisible();
await page.locator('.ge-ai-settings-done').click();
await expect(aiSettings).toBeHidden();
});
@@ -0,0 +1,17 @@
const { test, expect } = require('@playwright/test');
const { openBlankEditor } = require('./helpers.js');
test.use({ viewport: { width: 390, height: 844 }, hasTouch: true });
test('setup checklist remains usable above the mobile layers sheet', async ({ page }) => {
await openBlankEditor(page);
await page.locator('#ge-setup-btn').click();
const checklist = page.locator('.ge-preflight');
const start = checklist.locator('[data-preflight-start]');
await expect(checklist).toBeVisible();
await start.scrollIntoViewIfNeeded();
await expect(start).toHaveCSS('white-space', 'nowrap');
expect(await start.evaluate(button => button.scrollWidth <= button.clientWidth)).toBe(true);
await start.click();
await expect(checklist).toBeHidden();
});
@@ -1,8 +1,7 @@
const { test, expect } = require('@playwright/test');
const { selectTool } = require('./helpers.js');
const { openBlankEditor, editorState } = require('./helpers.js');
const { dragOnCanvas, editorState, openBlankEditor, selectTool } = require('./helpers.js');
test('paint tools offer rasterization, cancel preserves text, Enter accepts and undo restores it', async ({ page }) => {
test('paint tools require confirmation before rasterizing text layers, preserving text on cancel', async ({ page }) => {
await openBlankEditor(page);
await selectTool(page, 'text');
const canvas = await page.locator('.ge-main-canvas').boundingBox();
@@ -11,19 +10,39 @@ test('paint tools offer rasterization, cancel preserves text, Enter accepts and
await text.fill('Keep editable');
await text.press('Control+Enter');
const retained = (await editorState(page)).layers.find(layer => layer.kind === 'text');
expect(retained).toBeDefined();
const kind = async () => (await editorState(page)).layers.find(layer => layer.id === retained.id)?.kind;
// Tool selection alone does not prompt and does not rasterize
for (const tool of ['brush', 'eraser']) {
await selectTool(page, tool);
await expect(page.locator('#styled-confirm-overlay')).toBeHidden();
expect(await kind()).toBe('text');
}
// Attempting to draw triggers confirmation
await selectTool(page, 'brush');
await expect(page.locator('#styled-confirm-ok')).toHaveText('Rasterize');
await page.keyboard.press('Escape');
await dragOnCanvas(page, { x: 0.1, y: 0.1 }, { x: 0.25, y: 0.2 });
await expect(page.locator('#styled-confirm-overlay')).toBeVisible();
// Cancelling keeps text editable with no changes
await page.locator('#styled-confirm-cancel').click();
await expect(page.locator('#styled-confirm-overlay')).toBeHidden();
expect((await editorState(page)).layers.find(layer => layer.id === retained.id).kind).toBe('text');
await selectTool(page, 'brush');
await expect(page.locator('#styled-confirm-ok')).toBeFocused();
await page.keyboard.press('Enter');
expect(await kind()).toBe('text');
// Second stroke prompt confirmed -> rasterizes layer
await dragOnCanvas(page, { x: 0.1, y: 0.1 }, { x: 0.25, y: 0.2 });
await expect(page.locator('#styled-confirm-overlay')).toBeVisible();
await page.locator('#styled-confirm-ok').click();
await expect(page.locator('#styled-confirm-overlay')).toBeHidden();
expect((await editorState(page)).layers.find(layer => layer.id === retained.id).kind).toBe('raster');
expect(await kind()).toBe('raster');
// Subsequent strokes on the raster layer draw immediately without prompting
await dragOnCanvas(page, { x: 0.2, y: 0.2 }, { x: 0.35, y: 0.3 });
await expect(page.locator('#styled-confirm-overlay')).toBeHidden();
// Undo restores editable text
await page.keyboard.press('Control+z');
expect((await editorState(page)).layers.find(layer => layer.id === retained.id).kind).toBe('text');
await selectTool(page, 'eraser');
await expect(page.locator('#styled-confirm-ok')).toBeVisible();
await page.keyboard.press('Escape');
await page.keyboard.press('Control+z');
expect(await kind()).toBe('text');
});
@@ -106,12 +106,15 @@ 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();
await expect(page.locator('#ge-transform-section')).toBeVisible();
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();
await expect.poll(() => page.evaluate(async () => (await import('/static/js/editor/state.js')).state.transformActive)).toBe(false);
expect((await editorState(page)).selection.pixelHash).toBe(original.selection.pixelHash);
});
test('named selections support reselect, load, delete, and server-draft reopen', async ({ page }) => {
@@ -0,0 +1,46 @@
const { test, expect } = require('@playwright/test');
const { openBlankEditor, selectTool } = require('./helpers.js');
// Editors opened wider than 700px move the tool controls into the options
// strip. At tablet widths (701-820px) that strip must stay visible, or every
// tool control is stranded in a hidden container.
for (const width of [768, 820]) {
test(`tool controls stay reachable at a ${width}px tablet width`, async ({ page }) => {
await page.setViewportSize({ width, height: 1024 });
await openBlankEditor(page, { width: 320, height: 240 }, `Tablet ${width}`);
const inViewport = locator => locator.evaluate(el => {
const box = el.getBoundingClientRect();
return box.width > 0 && box.left >= 0 && box.right <= innerWidth + 1 && box.bottom <= innerHeight + 1;
});
await selectTool(page, 'brush');
await expect(page.locator('#ge-options-bar')).toBeVisible();
await expect(page.locator('#ge-options-bar .ge-size-slider')).toBeVisible();
expect(await inViewport(page.locator('#ge-options-bar .ge-size-slider'))).toBe(true);
await selectTool(page, 'transform');
await expect(page.locator('#ge-transform-w')).toBeVisible();
await page.locator('#ge-transform-w').fill('200');
await page.locator('#ge-transform-w').dispatchEvent('input');
await expect.poll(() => page.evaluate(async () => (await import('/static/js/editor/state.js')).state.transformPendingW)).toBe(200);
await page.locator('#ge-transform-cancel-btn').click();
});
}
test('responsive transition across 700px breakpoint preserves dock/sheet layout and state', async ({ page }) => {
// Desktop -> Phone transition
await page.setViewportSize({ width: 1024, height: 768 });
await openBlankEditor(page, { width: 320, height: 240 }, 'Responsive transitions');
await expect(page.locator('.ge-right-panel')).toHaveClass(/ge-right-panel--dock/);
await expect(page.locator('.ge-right-panel > .ge-dock')).toBeVisible();
// Resize across 700px to phone
await page.setViewportSize({ width: 390, height: 844 });
await expect(page.locator('.ge-right-panel')).not.toHaveClass(/ge-right-panel--dock/);
await expect(page.locator('.ge-right-panel > .ge-dock')).toBeHidden();
// Resize back to desktop
await page.setViewportSize({ width: 1024, height: 768 });
await expect(page.locator('.ge-right-panel')).toHaveClass(/ge-right-panel--dock/);
await expect(page.locator('.ge-right-panel > .ge-dock')).toBeVisible();
});
+13 -1
View File
@@ -1,10 +1,11 @@
const { test, expect } = require('@playwright/test');
const { selectTool } = require('./helpers.js');
const { dragOnCanvas, editorState, openBlankEditor, reopenDraft, waitForDraft } = require('./helpers.js');
const { dragOnCanvas, editorState, openBlankEditor, openOptionsMore, reopenDraft, waitForDraft } = require('./helpers.js');
test('text edits directly on canvas and remains retained after reopen', async ({ page }) => {
await openBlankEditor(page);
await selectTool(page, 'text');
await openOptionsMore(page, 'text');
await page.locator('#ge-text-frame-width').fill('260');
const canvas = await page.locator('.ge-main-canvas').boundingBox();
await page.mouse.click(canvas.x + canvas.width * 0.2, canvas.y + canvas.height * 0.2);
@@ -14,11 +15,13 @@ test('text edits directly on canvas and remains retained after reopen', async ({
await editor.press('Control+Enter');
await expect(editor).toHaveCount(0);
await openOptionsMore(page, 'text');
await page.locator('#ge-text-letter-spacing').fill('3.5');
await page.locator('#ge-text-line-height').fill('1.4');
await page.locator('#ge-text-font').selectOption('Georgia');
await page.locator('.ge-text-auto-width-option').click();
await expect(page.locator('#ge-text-auto-width')).toBeChecked();
await openOptionsMore(page, 'text');
await expect(page.locator('#ge-text-frame-width')).toBeDisabled();
await page.locator('#ge-text-frame-height').fill('180');
await page.locator('#ge-text-vertical-align').selectOption('bottom');
@@ -47,8 +50,13 @@ test('text paragraph controls remain usable in a narrow phone viewport', async (
const section = page.locator('#ge-text-section');
await expect(section).toBeVisible();
expect(await section.evaluate(el => el.scrollWidth <= el.clientWidth + 1)).toBe(true);
await openOptionsMore(page, 'text');
await expect(page.locator('#ge-text-frame-height')).toBeVisible();
await expect(page.locator('#ge-text-vertical-align')).toBeVisible();
expect(await page.locator('#ge-text-more').evaluate(el => {
const box = el.getBoundingClientRect();
return box.left >= 0 && box.right <= innerWidth + 1;
})).toBe(true);
});
test('rectangle ellipse line and polygon remain editable shape layers', async ({ page }) => {
@@ -71,6 +79,7 @@ test('rectangle ellipse line and polygon remain editable shape layers', async ({
let current = await editorState(page);
const shapes = current.layers.filter(layer => layer.kind === 'shape');
expect(shapes.map(layer => layer.shape.type)).toEqual(types);
await openOptionsMore(page, 'shape');
await page.locator('#ge-shape-sides').fill('7');
await page.locator('#ge-shape-stroke-width').fill('6');
await page.locator('#ge-shape-radius').fill('14');
@@ -96,6 +105,7 @@ test('rectangle ellipse line and polygon remain editable shape layers', async ({
test('gradient shape fill remains editable after reopening the project', async ({ page }) => {
await openBlankEditor(page);
await selectTool(page, 'shape');
await openOptionsMore(page, 'shape');
await expect(page.locator('#ge-shape-gradient-add-stop')).toBeHidden();
await dragOnCanvas(page, { x: 0.2, y: 0.2 }, { x: 0.65, y: 0.55 });
await page.locator('#ge-shape-gradient-angle').evaluate((angle) => {
@@ -110,6 +120,7 @@ test('gradient shape fill remains editable after reopening the project', async (
angle.value = '35';
angle.dispatchEvent(new Event('input', { bubbles: true }));
});
await openOptionsMore(page, 'shape');
await expect(page.locator('#ge-shape-gradient-add-stop')).toBeVisible();
const before = await editorState(page);
@@ -143,6 +154,7 @@ test('shape gradients retain added stops through editing and reopen', async ({ p
await openBlankEditor(page, { width: 360, height: 240 }, 'Multi-stop gradient E2E');
await selectTool(page, 'shape');
await dragOnCanvas(page, { x: 0.15, y: 0.2 }, { x: 0.75, y: 0.65 });
await openOptionsMore(page, 'shape');
await page.locator('#ge-shape-fill-type').selectOption('linear-gradient');
await page.evaluate(() => {
for (const [id, value] of [['ge-shape-gradient-start', '#ff0000'], ['ge-shape-gradient-end', '#0000ff']]) {
+50 -3
View File
@@ -223,15 +223,23 @@ test('mobile touch input can grab every transform handle', async ({ browser, bro
const handlePoint = id => page.evaluate(async handleId => {
const { state } = await import('/static/js/editor/state.js');
const { transformFrameGeometry } = await import('/static/js/editor/transform-frame-geometry.js');
const geometry = transformFrameGeometry({
const frame = {
centerX: state.transformCenter.x,
centerY: state.transformCenter.y,
width: state.transformPendingW,
height: state.transformPendingH,
rotation: state.transformPendingRot,
}, { zoom: state.zoom, rotationInside: handleId === 'rot' });
const handle = geometry.handles.find(item => item.id === handleId);
};
const rect = state.mainCanvas.getBoundingClientRect();
const outside = transformFrameGeometry(frame, { zoom: state.zoom }).rotationHandle;
const x = rect.left + outside.x * rect.width / state.mainCanvas.width;
const y = rect.top + outside.y * rect.height / state.mainCanvas.height;
const area = state.container.querySelector('.ge-canvas-area').getBoundingClientRect();
const popup = state.transformPopup?.getBoundingClientRect();
const rotationInside = y < area.top + 6 || x < area.left + 6 || x > area.right - 6 ||
!!(popup && x >= popup.left && x <= popup.right && y >= popup.top && y <= popup.bottom);
const geometry = transformFrameGeometry(frame, { zoom: state.zoom, rotationInside });
const handle = geometry.handles.find(item => item.id === handleId);
return {
x: rect.left + handle.x * rect.width / state.mainCanvas.width,
y: rect.top + handle.y * rect.height / state.mainCanvas.height,
@@ -366,3 +374,42 @@ test('transform handle keeps tracking when the pointer crosses the right panel',
});
expect(after).toBeGreaterThan(setup.width);
});
test('phone transform overlay follows the canvas through refits and zoom', async ({ browser }) => {
const context = await browser.newContext({
viewport: { width: 390, height: 844 },
isMobile: true,
hasTouch: true,
serviceWorkers: 'block',
});
const page = await context.newPage();
try {
await openBlankEditor(page, { width: 640, height: 480 }, 'Phone transform overlay');
const fitBefore = await page.evaluate(async () => (await import('/static/js/editor/state.js')).state.zoom);
await selectTool(page, 'transform');
await expect(page.locator('#ge-transform-section')).toBeVisible();
const alignment = () => page.evaluate(async () => {
const { state } = await import('/static/js/editor/state.js');
const overlay = state.transformOverlay.getBoundingClientRect();
const canvas = state.mainCanvas.getBoundingClientRect();
return {
zoom: state.zoom,
overlayScale: overlay.width / state.transformOverlay.width,
covers: overlay.left <= canvas.left + 1 && overlay.top <= canvas.top + 1
&& overlay.right >= canvas.right - 1 && overlay.bottom >= canvas.bottom - 1,
};
});
// Selecting Transform collapses the Layers sheet, which refits the canvas.
await expect.poll(async () => (await alignment()).zoom).not.toBe(fitBefore);
let current = await alignment();
expect(Math.abs(current.overlayScale - current.zoom)).toBeLessThan(0.002);
expect(current.covers).toBe(true);
await page.locator('#ge-zoom-in').evaluate(button => button.click());
await expect.poll(async () => (await alignment()).zoom).toBeGreaterThan(current.zoom);
current = await alignment();
expect(Math.abs(current.overlayScale - current.zoom)).toBeLessThan(0.002);
} finally {
await context.close();
}
});
+15 -4
View File
@@ -10,10 +10,12 @@ const defaultPython = process.platform === 'win32'
? path.join(repositoryRoot, '.venv', 'Scripts', 'python.exe')
: path.join(repositoryRoot, '.venv', 'bin', 'python');
const python = process.env.ODYSSEUS_TEST_PYTHON || (fs.existsSync(defaultPython) ? defaultPython : 'python');
const tmpBase = fs.existsSync('/var/tmp') ? '/var/tmp' : os.tmpdir();
const dataDirectory = process.env.PHOTO_EDITOR_E2E_DATA_DIR
|| fs.mkdtempSync(path.join(os.tmpdir(), 'odysseus-photo-editor-e2e-'));
|| fs.mkdtempSync(path.join(tmpBase, 'odysseus-photo-editor-e2e-'));
const databasePath = process.env.PHOTO_EDITOR_E2E_DB_PATH
|| path.join(dataDirectory, 'app.db');
const authenticated = process.env.PHOTO_EDITOR_E2E_AUTH !== '0';
process.env.PHOTO_EDITOR_E2E_DB_PATH = databasePath;
process.env.PHOTO_EDITOR_E2E_DATA_DIR = dataDirectory;
@@ -22,6 +24,9 @@ if (!['chromium', 'firefox', 'webkit'].includes(browserName)) {
throw new Error(`Unsupported PHOTO_EDITOR_E2E_BROWSER: ${browserName}`);
}
const isCI = Boolean(process.env.CI) || Boolean(process.env.GITHUB_ACTIONS);
const traceOptIn = !isCI && ['1', 'true'].includes(String(process.env.PLAYWRIGHT_ENABLE_TRACE || '').toLowerCase());
module.exports = defineConfig({
testDir: path.join(__dirname, 'photo-editor'),
outputDir: path.join(__dirname, '..', '..', 'test-results', 'photo-editor'),
@@ -29,18 +34,24 @@ module.exports = defineConfig({
expect: { timeout: 10_000 },
fullyParallel: false,
workers: 1,
reporter: process.env.CI ? [['line'], ['html', { open: 'never' }]] : 'line',
reporter: isCI ? 'line' : 'line',
globalSetup: require.resolve('./setup.js'),
globalTeardown: require.resolve('./teardown.js'),
use: {
baseURL,
browserName,
headless: true,
launchOptions: {
args: ['--disable-dev-shm-usage', '--no-sandbox'],
},
viewport: { width: 1440, height: 960 },
extraHTTPHeaders: { 'Accept-Encoding': 'identity' },
serviceWorkers: 'block',
...(authenticated ? { storageState: path.join(dataDirectory, 'browser-auth.json') } : {}),
screenshot: 'only-on-failure',
trace: 'retain-on-failure',
// Unconditional CI trace suppression: Playwright traces are NEVER recorded in CI.
// Local developer tracing requires explicit PLAYWRIGHT_ENABLE_TRACE=1.
trace: traceOptIn ? 'retain-on-failure' : 'off',
},
webServer: {
command: `${JSON.stringify(python)} -m uvicorn app:app --host 127.0.0.1 --port ${port}`,
@@ -50,7 +61,7 @@ module.exports = defineConfig({
reuseExistingServer: false,
env: {
...process.env,
AUTH_ENABLED: 'false',
AUTH_ENABLED: authenticated ? 'true' : 'false',
DATABASE_URL: `sqlite:///${databasePath}`,
ODYSSEUS_DATA_DIR: dataDirectory,
ODYSSEUS_STARTUP_WARMUPS: '0',
+16
View File
@@ -1,4 +1,6 @@
const fs = require('node:fs');
const path = require('node:path');
const { request } = require('@playwright/test');
module.exports = async () => {
const dataDirectory = process.env.PHOTO_EDITOR_E2E_DATA_DIR;
@@ -7,4 +9,18 @@ module.exports = async () => {
throw new Error('Photo editor E2E data paths were not configured');
}
fs.mkdirSync(dataDirectory, { recursive: true });
if (process.env.PHOTO_EDITOR_E2E_AUTH !== '0') {
const port = Number(process.env.PHOTO_EDITOR_E2E_PORT || 7013);
const client = await request.newContext({ baseURL: `http://127.0.0.1:${port}` });
try {
const credentials = { username: 'editor-e2e', password: 'disposable-editor-e2e-password' };
const setup = await client.post('/api/auth/setup', { data: credentials });
if (!setup.ok()) throw new Error(`Disposable editor account setup failed: ${setup.status()}`);
const login = await client.post('/api/auth/login', { data: credentials });
if (!login.ok()) throw new Error(`Disposable editor account login failed: ${login.status()}`);
await client.storageState({ path: path.join(dataDirectory, 'browser-auth.json') });
} finally {
await client.dispose();
}
}
};
+81 -2
View File
@@ -1,7 +1,86 @@
const fs = require('node:fs');
const path = require('node:path');
const { execSync } = require('node:child_process');
module.exports = async () => {
const dataDirectory = process.env.PHOTO_EDITOR_E2E_DATA_DIR;
if (!dataDirectory) return;
fs.rmSync(dataDirectory, { recursive: true, force: true });
if (dataDirectory) {
try {
fs.rmSync(dataDirectory, { recursive: true, force: true });
} catch (_) {}
}
// Secure default tracing: never publish trace archives in CI or without explicit opt-in.
// When traces are not opted-in, purge all archives unconditionally.
// When local opt-in tracing is active, run the strict fail-closed structured sanitizer.
const resultsDir = path.join(__dirname, '..', '..', 'test-results');
const reportDir = path.join(__dirname, '..', '..', 'playwright-report');
if (fs.existsSync(reportDir)) {
try {
fs.rmSync(reportDir, { recursive: true, force: true });
} catch (_) {}
}
if (fs.existsSync(resultsDir)) {
const purgeSensitiveArtifacts = (dir) => {
if (!fs.existsSync(dir)) return;
const entries = fs.readdirSync(dir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
purgeSensitiveArtifacts(fullPath);
} else if (entry.isFile() && (
entry.name.endsWith('.zip') ||
entry.name.endsWith('.tmp') ||
entry.name.endsWith('.png') ||
entry.name.endsWith('.har')
)) {
try {
fs.rmSync(fullPath, { force: true });
} catch (_) {}
}
}
};
const purgeZipAndTmp = (dir) => {
if (!fs.existsSync(dir)) return;
const entries = fs.readdirSync(dir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
purgeZipAndTmp(fullPath);
} else if (entry.isFile() && (entry.name.endsWith('.zip') || entry.name.endsWith('.tmp'))) {
try {
fs.rmSync(fullPath, { force: true });
} catch (_) {}
}
}
};
const isCI = Boolean(process.env.CI) || Boolean(process.env.GITHUB_ACTIONS);
const traceOptIn = !isCI && ['1', 'true'].includes(String(process.env.PLAYWRIGHT_ENABLE_TRACE || '').toLowerCase());
if (isCI || !traceOptIn) {
// In CI or default local runs, purge all sensitive trace archives and artifacts
purgeSensitiveArtifacts(resultsDir);
} else {
const python = process.env.ODYSSEUS_TEST_PYTHON || 'python';
const sanitizerScript = path.join(__dirname, '..', '..', 'scripts', 'sanitize_trace_artifacts.py');
let sanitizerSucceeded = false;
try {
execSync(`${JSON.stringify(python)} ${JSON.stringify(sanitizerScript)} ${JSON.stringify(resultsDir)}`, {
stdio: 'ignore',
timeout: 30_000,
});
sanitizerSucceeded = true;
} catch (_) {
sanitizerSucceeded = false;
}
if (!sanitizerSucceeded) {
// Fail closed: purge all remaining zip or tmp archives
purgeZipAndTmp(resultsDir);
}
}
}
};
+2 -2
View File
@@ -1043,8 +1043,8 @@ def test_editor_agent_model_override_is_first_candidate(monkeypatch):
monkeypatch.setattr(eam, "local_bases", lambda: ["http://local1"])
monkeypatch.setattr(eam, "local_model", lambda: "LMODEL")
cands = eam._candidates(None, "http://ovr::OVERRIDE")
assert cands[0] == {"base": "http://ovr", "model": "OVERRIDE", "headers": None,
cands = eam._candidates(None, "http://local1::OVERRIDE")
assert cands[0] == {"base": "http://local1", "model": "OVERRIDE", "headers": None,
"source": "override"}
assert any(c["source"] == "local" and c["base"] == "http://local1" for c in cands)
+23 -1
View File
@@ -1,4 +1,5 @@
import json
import os
from pathlib import Path
import subprocess
@@ -27,10 +28,22 @@ def test_action_names_and_unknown_steps_are_literal_text():
const panel = createActionsPanel();
document.body.append(panel.element);
panel.setSteps([attack]);
const input = panel.element.querySelector('.ge-actions-name');
input.value = attack + '<svg onload="window.__actionsXss=true">';
input.parentElement.querySelector('button').click();
const savedName = input.value;
const savedButton = Array.from(panel.element.querySelectorAll('.ge-actions-saved-row button'))
.find(button => button.textContent === savedName);
savedButton.click();
return {
images: panel.element.querySelectorAll('img').length,
svgs: panel.element.querySelectorAll('svg').length,
saved: panel.element.querySelector('.ge-actions-saved-row button').textContent,
step: panel.element.querySelector('.ge-actions-step-label').textContent,
option: Array.from(panel.element.querySelector('.ge-actions-batch-select').options)
.find(option => option.value === savedName)?.textContent,
persisted: JSON.parse(localStorage.getItem('ge-actions'))[savedName],
savedName,
executed: Boolean(window.__actionsXss),
attack,
};
@@ -39,12 +52,21 @@ def test_action_names_and_unknown_steps_are_literal_text():
} finally { await browser.close(); }
})().catch(error => { console.error(error); process.exit(1); });
'''
env = os.environ.copy()
if 'PLAYWRIGHT_BROWSERS_PATH' not in env:
for candidate in [Path.home() / '.cache/ms-playwright', Path('/home/alteixeira20/.cache/ms-playwright')]:
if candidate.exists():
env['PLAYWRIGHT_BROWSERS_PATH'] = str(candidate)
break
result = subprocess.run(
['node', '-e', script, str(ROOT / 'static/js/editor/actions-panel.js')],
cwd=ROOT, capture_output=True, text=True, timeout=30,
cwd=ROOT, capture_output=True, text=True, timeout=30, env=env,
)
assert result.returncode == 0, result.stderr
observed = json.loads(result.stdout)
assert observed['images'] == 0
assert observed['svgs'] == 0
assert observed['saved'] == observed['step'] == observed['attack']
assert observed['option'] == observed['savedName']
assert observed['persisted'] == [observed['attack']]
assert not observed['executed']
@@ -0,0 +1,43 @@
"""Every adjustment popup that renders slider rows must store their values.
``buildAdjBody`` renders ``sliderRow`` controls per adjustment type, and
``onAdjSliderInput`` maps a moved slider onto ``layer._stagedAdj.params``. A
type with rows but no input branch shows a working slider whose value is
silently dropped on OK (Vibrance regressed exactly this way). The browser
behavior is covered by tests/e2e/photo-editor/adjustment-layers.spec.js.
"""
import re
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
POPUP = (ROOT / "static/js/editor/fx/adj-popup.js").read_text(encoding="utf-8")
def _function_body(name: str, next_name: str) -> str:
start = POPUP.index(f"function {name}(")
return POPUP[start:POPUP.index(f"function {next_name}(", start)]
def _types_with_slider_rows() -> set[str]:
builder = _function_body("buildAdjBody", "onAdjSliderInput")
branches = re.split(r"\}\s*else if \(|if \(type === ", builder)
types = set()
for branch in branches:
names = re.findall(r"type === '([a-z-]+)'", "type === " + branch.split(")", 1)[0])
if names and "sliderRow(" in branch:
types.update(names)
return types
def test_every_slider_adjustment_type_writes_its_params():
rendered = _types_with_slider_rows()
assert {"exposure", "white-balance", "vibrance", "black-white"} <= rendered, rendered
handler = _function_body("onAdjSliderInput", "wireCurveEditor")
handled = set(re.findall(r"type === '([a-z-]+)'", handler))
missing = sorted(rendered - handled)
assert not missing, f"slider rows whose input is never stored: {missing}"
def test_vibrance_slider_stores_the_raw_value():
handler = _function_body("onAdjSliderInput", "wireCurveEditor")
assert re.search(r"else if \(type === 'vibrance'\) \{\s*p\[key\] = raw;", handler)
+3 -14
View File
@@ -102,23 +102,12 @@ def test_bucket_tool_is_registered_and_wired():
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.
def test_paint_tools_require_confirmation_before_rasterizing():
editor = (ROOT / "static/js/galleryEditor.js").read_text()
assert "function _rasterizeLayerNow(layer) {" in editor
assert "async function _confirmRasterizeLayer(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
assert "void _confirmRasterizeLayer(layer);" in editor
def test_smudge_and_dodge_icons_are_distinct():
+3 -2
View File
@@ -20,8 +20,9 @@ def test_editor_paste_owns_clipboard_images_while_editor_open():
"""
src = SRC.read_text(encoding="utf-8")
assert "wireClipboardAndDrop" in src
# The bail-out must be scoped to targets inside the editor.
assert "const insideEditor = target && container && container.contains(target);" in src
# The bail-out must be scoped to targets inside the editor, and must not
# throw for a paste whose target is not a Node (e.g. dispatched at window).
assert "const insideEditor = target instanceof Node && !!container && container.contains(target);" in src
assert (
"if (insideEditor && (target.isContentEditable || "
"target.closest?.('input, textarea, select'))) return;"
@@ -0,0 +1,29 @@
from pathlib import Path
import subprocess
def test_shared_picker_exports_editor_color_contract():
root = Path(__file__).resolve().parents[1]
script = r'''
import assert from 'node:assert/strict';
import { pathToFileURL } from 'node:url';
const store = new Map();
globalThis.localStorage = { getItem: key => store.get(key), setItem: (key, value) => store.set(key, value) };
globalThis.HTMLInputElement = class {};
const picker = await import(pathToFileURL(process.argv[1]));
store.set('odysseus-recent-colors', JSON.stringify(['<img src=x onerror=alert(1)>', 123, '#ABCDEF']));
assert.deepEqual(picker.getRecentColors(), ['#ABCDEF']);
picker.recordRecentColor('#abcdef');
picker.recordRecentColor('#ff0000');
assert.deepEqual(picker.getRecentColors(), ['#ff0000', '#abcdef']);
const suggestions = picker.suggestColorsFor('#ff0000');
assert.equal(suggestions.length, 5);
assert.equal(suggestions[0].hex, '#00ffff');
assert.ok(suggestions.every(s => /^#[0-9a-f]{6}$/.test(s.hex)));
store.set('odysseus-recent-colors', '{}');
assert.deepEqual(picker.getRecentColors(), []);
'''
result = subprocess.run(['node', '--input-type=module', '-e', script,
str(root / 'static/js/colorPicker.js')],
cwd=root, capture_output=True, text=True, timeout=30)
assert result.returncode == 0, result.stderr
+4 -15
View File
@@ -18,21 +18,10 @@ 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_mobile_footer_is_docked_flush_and_accessible():
assert ".ge-editor-footer" in CSS
assert "overflow-x: auto;" in CSS
assert ".ge-editor-footer {\n display: none !important;" not in CSS
def test_quick_edit_stacks_above_other_assets():
+5 -2
View File
@@ -19,8 +19,11 @@ def test_phone_menu_bar_compaction_rules_present():
assert ".ge-topbar-left .ge-stacked-btn {" in CSS
assert "font-size: 11px;" in CSS
assert "padding: 0 6px !important;" in CSS
assert ".ge-topbar.ge-topbar-compact #ge-draft-status {" in CSS
assert "display: none;" in CSS
# Compaction drops the Save action label only. The draft status is the
# button's role="status" live region (its icon is aria-hidden), so it must
# stay rendered; interaction-contract.spec.js checks it at 390px.
assert ".ge-topbar.ge-topbar-compact #ge-save-menu-btn .ge-save-action-label { display: none !important; }" in CSS
assert not re.search(r"ge-topbar-compact\s+#ge-draft-status\s*\{[^}]*display:\s*none", CSS)
def test_sw_cache_name_is_version_agnostic_pattern():
+163
View File
@@ -0,0 +1,163 @@
import json
import pytest
from unittest.mock import MagicMock, patch
from starlette.requests import Request
from starlette.datastructures import Headers
from routes.editor_agent_routes import setup_editor_agent_routes
from routes.gallery.gallery_routes import _visible_image_endpoint_for_base
def create_fake_request(user: str | None = None, is_admin: bool = False):
scope = {
"type": "http",
"method": "GET",
"path": "/api/editor/image-models",
"headers": [],
"app": MagicMock(),
}
req = Request(scope)
# Mock auth_manager on app state
auth_mgr = MagicMock()
auth_mgr.is_admin.return_value = is_admin
auth_mgr.is_configured = True
req.app.state.auth_manager = auth_mgr
return req
class FakeEndpoint:
def __init__(self, id, name, base_url, model_type="image", is_enabled=True, owner=None, cached_models=None):
self.id = id
self.name = name
self.base_url = base_url
self.model_type = model_type
self.is_enabled = is_enabled
self.owner = owner
self.cached_models = cached_models or json.dumps(["qwen-image-edit", "qwen-generate"])
self.pinned_models = None
@pytest.mark.asyncio
async def test_editor_image_models_non_admin_redacts_urls():
router = setup_editor_agent_routes()
route = next(r for r in router.routes if r.path == "/api/editor/image-models")
fake_ep = FakeEndpoint(
id=42,
name="private-gpu-cluster",
base_url="http://10.0.0.50:8000/v1",
owner="testuser",
)
fake_db = MagicMock()
fake_query = MagicMock()
fake_db.query.return_value = fake_query
fake_query.filter.return_value = fake_query
fake_query.all.return_value = [fake_ep]
req = create_fake_request(user="testuser", is_admin=False)
with patch("routes.editor_agent_routes._user", return_value="testuser"), \
patch("src.tool_security.owner_is_admin_or_single_user", return_value=False), \
patch("core.database.SessionLocal", return_value=fake_db), \
patch("src.auth_helpers.owner_filter", side_effect=lambda q, *a, **k: q), \
patch("src.endpoint_resolver._endpoint_enabled_models", return_value=["qwen-image-edit"]):
resp = await route.endpoint(req)
data = json.loads(resp.body.decode())
assert "models" in data
assert len(data["models"]) == 1
m = data["models"][0]
assert m["id"] == "qwen-image-edit"
assert m["endpoint_id"] == 42
# Non-admin must NOT see internal URL!
assert "base_url" not in m
assert "10.0.0.50" not in json.dumps(data)
@pytest.mark.asyncio
async def test_editor_image_models_admin_includes_urls():
router = setup_editor_agent_routes()
route = next(r for r in router.routes if r.path == "/api/editor/image-models")
fake_ep = FakeEndpoint(
id=42,
name="private-gpu-cluster",
base_url="http://10.0.0.50:8000/v1",
owner="adminuser",
)
fake_db = MagicMock()
fake_query = MagicMock()
fake_db.query.return_value = fake_query
fake_query.filter.return_value = fake_query
fake_query.all.return_value = [fake_ep]
req = create_fake_request(user="adminuser", is_admin=True)
with patch("routes.editor_agent_routes._user", return_value="adminuser"), \
patch("src.tool_security.owner_is_admin_or_single_user", return_value=True), \
patch("core.database.SessionLocal", return_value=fake_db), \
patch("src.auth_helpers.owner_filter", side_effect=lambda q, *a, **k: q), \
patch("src.endpoint_resolver._endpoint_enabled_models", return_value=["qwen-image-edit"]):
resp = await route.endpoint(req)
data = json.loads(resp.body.decode())
assert len(data["models"]) == 1
m = data["models"][0]
assert m["endpoint_id"] == 42
# Admin is allowed to see the endpoint base URL
assert m.get("base_url") == "http://10.0.0.50:8000/v1"
@pytest.mark.asyncio
async def test_editor_agent_models_non_admin_redacts_urls_and_probe():
router = setup_editor_agent_routes()
route = next(r for r in router.routes if r.path == "/api/editor/agent-models")
raw_models = [
{"id": "Qwen/Qwen3.5-9B", "label": "Qwen3.5-9B · local", "source": "local", "vision": True, "base_url": "http://100.69.120.65:8097/v1"},
{"id": "hosted-default", "label": "hosted-default", "source": "hosted", "vision": None, "base_url": "https://api.hosted.ai/v1"},
]
req = create_fake_request(user="regularuser", is_admin=False)
with patch("routes.editor_agent_routes._user", return_value="regularuser"), \
patch("src.tool_security.owner_is_admin_or_single_user", return_value=False), \
patch("src.editor_agent_model.list_editor_agent_models", return_value=raw_models):
resp = await route.endpoint(req)
data = json.loads(resp.body.decode())
assert data.get("probe") == {}
assert len(data["models"]) == 2
for m in data["models"]:
assert "base_url" not in m
assert "100.69.120.65" not in json.dumps(data)
def test_visible_image_endpoint_for_base_supports_id_and_url():
fake_ep = FakeEndpoint(
id=7,
name="my-diffusion",
base_url="http://internal-host:8080/v1",
owner="alice",
)
fake_db = MagicMock()
fake_query = MagicMock()
fake_db.query.return_value = fake_query
fake_query.filter.return_value = fake_query
fake_query.all.return_value = [fake_ep]
with patch("src.auth_helpers.owner_filter", side_effect=lambda q, *a, **k: q):
# 1. Lookup by numeric string ID
res_id = _visible_image_endpoint_for_base(fake_db, "7", "alice", "qwen-image-edit")
assert res_id == fake_ep
# 2. Lookup by URL
res_url = _visible_image_endpoint_for_base(fake_db, "http://internal-host:8080", "alice", "qwen-image-edit")
assert res_url == fake_ep
# 3. Lookup non-existent
res_none = _visible_image_endpoint_for_base(fake_db, "999", "alice", "qwen-image-edit")
assert res_none is None
@@ -0,0 +1,458 @@
import asyncio
import http.server
import json
import threading
from typing import List, Tuple
from unittest.mock import MagicMock, patch
import pytest
from starlette.requests import Request
from starlette.testclient import TestClient
from core.database import SessionLocal, ModelEndpoint
from routes.editor_agent_routes import setup_editor_agent_routes
import src.editor_agent_model as eam
class TrackingRequestHandler(http.server.BaseHTTPRequestHandler):
received_requests: List[Tuple[str, str, dict]] = []
def do_POST(self):
length = int(self.headers.get("content-length", 0))
body = self.rfile.read(length) if length > 0 else b""
try:
body_json = json.loads(body.decode("utf-8"))
except Exception:
body_json = {}
TrackingRequestHandler.received_requests.append((self.command, self.path, body_json))
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.end_headers()
self.wfile.write(b'{"choices": [{"message": {"content": "ATTACKER_PLAN"}}]}')
def log_message(self, format, *args):
pass
@pytest.fixture
def local_listener():
TrackingRequestHandler.received_requests = []
server = http.server.HTTPServer(("127.0.0.1", 0), TrackingRequestHandler)
port = server.server_address[1]
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
yield f"http://127.0.0.1:{port}/v1"
server.shutdown()
server.server_close()
def create_mock_request(path: str, body: dict, user: str = "testuser", is_admin: bool = False):
scope = {
"type": "http",
"method": "POST",
"path": path,
"headers": [(b"content-type", b"application/json")],
"app": MagicMock(),
}
req = Request(scope)
auth_mgr = MagicMock()
auth_mgr.is_admin.return_value = is_admin
auth_mgr.is_configured = True
req.app.state.auth_manager = auth_mgr
async def mock_json():
return body
req.json = mock_json
return req
SYNTHETIC_SCREENSHOT = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=="
def test_ssrf_arbitrary_listener_receives_zero_requests(local_listener, monkeypatch):
"""Prove attacker-supplied agent_model URL receives 0 requests on planner routes."""
router = setup_editor_agent_routes()
plan_route = next(r for r in router.routes if r.path == "/api/editor/plan")
agent_route = next(r for r in router.routes if r.path == "/api/editor/agent")
async def _fake_wait(fut, timeout=None, **kwargs):
return {"ok": True, "result": {"width": 100, "height": 100}}
monkeypatch.setattr("routes.editor_agent_routes.asyncio.wait_for", _fake_wait)
payload = {
"prompt": "make it brighter",
"screenshot": SYNTHETIC_SCREENSHOT,
"agent_model": f"{local_listener}::attacker-model",
}
# Test /api/editor/plan
req_plan = create_mock_request("/api/editor/plan", payload, user="alice", is_admin=False)
with patch("routes.editor_agent_routes._user", return_value="alice"), \
patch("src.tool_security.owner_is_admin_or_single_user", return_value=False):
resp_plan = asyncio.run(plan_route.endpoint(req_plan))
assert resp_plan.status_code == 200
assert len(TrackingRequestHandler.received_requests) == 0
# Test /api/editor/agent
req_agent = create_mock_request("/api/editor/agent", payload, user="alice", is_admin=False)
with patch("routes.editor_agent_routes._user", return_value="alice"), \
patch("src.tool_security.owner_is_admin_or_single_user", return_value=False):
resp_agent = asyncio.run(agent_route.endpoint(req_agent))
assert resp_agent.status_code == 200
assert len(TrackingRequestHandler.received_requests) == 0
def test_ssrf_admin_cannot_target_arbitrary_url(local_listener):
"""Admin access must not grant arbitrary outbound network destinations."""
payload = {
"prompt": "make it brighter",
"screenshot": SYNTHETIC_SCREENSHOT,
"agent_model": f"{local_listener}::attacker-model",
}
router = setup_editor_agent_routes()
plan_route = next(r for r in router.routes if r.path == "/api/editor/plan")
req = create_mock_request("/api/editor/plan", payload, user="admin_user", is_admin=True)
with patch("routes.editor_agent_routes._user", return_value="admin_user"), \
patch("src.tool_security.owner_is_admin_or_single_user", return_value=True):
resp = asyncio.run(plan_route.endpoint(req))
assert resp.status_code == 200
assert len(TrackingRequestHandler.received_requests) == 0
def test_candidates_cloud_metadata_rejected():
"""Cloud metadata destination is rejected fail-closed."""
cands = eam._candidates("user1", "http://169.254.169.254/latest/meta-data::cloud-meta")
assert cands == []
def test_candidates_foreign_owned_endpoint_rejected():
"""Foreign-owned endpoint ID fails closed for a different user."""
db = SessionLocal()
try:
ep = ModelEndpoint(
id="ep-foreign-test",
name="Foreign Cluster",
base_url="http://192.168.1.100:8000/v1",
owner="victim_user",
is_enabled=True,
cached_models=json.dumps(["victim-model"]),
)
db.merge(ep)
db.commit()
# Attacker user query
cands = eam._candidates("attacker_user", "ep-foreign-test::victim-model")
assert cands == []
finally:
db.query(ModelEndpoint).filter(ModelEndpoint.id == "ep-foreign-test").delete()
db.commit()
db.close()
def test_candidates_disabled_endpoint_rejected():
"""Disabled endpoint ID fails closed."""
db = SessionLocal()
try:
ep = ModelEndpoint(
id="ep-disabled-test",
name="Disabled Cluster",
base_url="http://192.168.1.100:8000/v1",
owner="user1",
is_enabled=False,
cached_models=json.dumps(["some-model"]),
)
db.merge(ep)
db.commit()
cands = eam._candidates("user1", "ep-disabled-test::some-model")
assert cands == []
finally:
db.query(ModelEndpoint).filter(ModelEndpoint.id == "ep-disabled-test").delete()
db.commit()
db.close()
def test_candidates_nonexistent_endpoint_rejected():
"""Nonexistent endpoint ID fails closed."""
cands = eam._candidates("user1", "ep-nonexistent-9999::model1")
assert cands == []
def test_candidates_forged_model_id_rejected():
"""Forged model ID on an authorized endpoint fails closed."""
db = SessionLocal()
try:
ep = ModelEndpoint(
id="ep-auth-test",
name="User Cluster",
base_url="http://127.0.0.1:9099/v1",
owner="user1",
is_enabled=True,
cached_models=json.dumps(["real-model"]),
)
db.merge(ep)
db.commit()
# Legitimate model passes
valid_cands = eam._candidates("user1", "ep-auth-test::real-model")
assert len(valid_cands) >= 1
assert valid_cands[0]["model"] == "real-model"
# Forged model fails closed
forged_cands = eam._candidates("user1", "ep-auth-test::forged-attacker-model")
assert forged_cands == []
finally:
db.query(ModelEndpoint).filter(ModelEndpoint.id == "ep-auth-test").delete()
db.commit()
db.close()
def test_candidates_duplicate_model_ids_scoped_by_endpoint():
"""Duplicate model IDs across different endpoints do not collide or cross providers."""
db = SessionLocal()
try:
ep1 = ModelEndpoint(
id="ep-provider-1",
name="Provider 1",
base_url="http://127.0.0.1:9001/v1",
owner="user1",
is_enabled=True,
cached_models=json.dumps(["shared-model-name"]),
)
ep2 = ModelEndpoint(
id="ep-provider-2",
name="Provider 2",
base_url="http://127.0.0.1:9002/v1",
owner="user1",
is_enabled=True,
cached_models=json.dumps(["shared-model-name"]),
)
db.merge(ep1)
db.merge(ep2)
db.commit()
cands1 = eam._candidates("user1", "ep-provider-1::shared-model-name")
assert len(cands1) >= 1
assert cands1[0]["endpoint_id"] == "ep-provider-1"
assert "9001" in cands1[0]["base"]
cands2 = eam._candidates("user1", "ep-provider-2::shared-model-name")
assert len(cands2) >= 1
assert cands2[0]["endpoint_id"] == "ep-provider-2"
assert "9002" in cands2[0]["base"]
finally:
db.query(ModelEndpoint).filter(ModelEndpoint.id.in_(["ep-provider-1", "ep-provider-2"])).delete()
db.commit()
db.close()
def test_candidates_authorized_shared_endpoint():
"""Shared endpoint (owner is NULL) is accessible to authenticated user."""
db = SessionLocal()
try:
ep = ModelEndpoint(
id="ep-shared-test",
name="Shared Org Cluster",
base_url="http://127.0.0.1:9005/v1",
owner=None,
is_enabled=True,
cached_models=json.dumps(["org-planner-model"]),
)
db.merge(ep)
db.commit()
cands = eam._candidates("any_user", "ep-shared-test::org-planner-model")
assert len(cands) >= 1
assert cands[0]["endpoint_id"] == "ep-shared-test"
assert cands[0]["model"] == "org-planner-model"
finally:
db.query(ModelEndpoint).filter(ModelEndpoint.id == "ep-shared-test").delete()
db.commit()
db.close()
def test_candidates_legacy_raw_url_override_verified_against_registered_db():
"""Legacy raw URL format resolves only when matching a registered DB endpoint."""
db = SessionLocal()
try:
ep = ModelEndpoint(
id="ep-legacy-test",
name="Legacy Registered",
base_url="http://registered-server.internal:8000/v1",
owner="user1",
is_enabled=True,
cached_models=json.dumps(["legacy-model"]),
)
db.merge(ep)
db.commit()
# Matching registered row resolves
cands = eam._candidates("user1", "http://registered-server.internal:8000/v1::legacy-model")
assert len(cands) >= 1
assert cands[0]["endpoint_id"] == "ep-legacy-test"
# Unregistered raw URL fails closed
unreg_cands = eam._candidates("user1", "http://unregistered-server.internal:8000/v1::legacy-model")
assert unreg_cands == []
finally:
db.query(ModelEndpoint).filter(ModelEndpoint.id == "ep-legacy-test").delete()
db.commit()
db.close()
def test_candidates_unauthorized_override_does_not_silently_fallback(monkeypatch):
"""Unauthorized override fails closed rather than silently falling back to defaults."""
monkeypatch.setattr(eam, "local_bases", lambda: ["http://default-local:8000/v1"])
monkeypatch.setattr(eam, "local_model", lambda: "default-local-model")
# Override is unauthorized/attacker -> MUST NOT fallback to default-local
cands = eam._candidates("user1", "http://attacker-listener.com:9999/v1::attacker-model")
assert cands == []
def test_editor_routes_unauthenticated_returns_401_when_auth_configured():
"""Unauthenticated requests must return 401 when authentication is enabled."""
from fastapi import FastAPI
from fastapi.testclient import TestClient
app = FastAPI()
router = setup_editor_agent_routes()
app.include_router(router)
auth_mgr = MagicMock()
auth_mgr.is_configured = True
app.state.auth_manager = auth_mgr
with patch("src.auth_helpers._auth_disabled", return_value=False), \
patch("src.auth_helpers.get_current_user", return_value=None):
client = TestClient(app, raise_server_exceptions=False)
resp_plan = client.post("/api/editor/plan", json={"prompt": "draw cat"})
assert resp_plan.status_code == 401
assert resp_plan.json().get("detail") == "Not authenticated"
resp_agent = client.post("/api/editor/agent", json={"prompt": "draw cat"})
assert resp_agent.status_code == 401
assert resp_agent.json().get("detail") == "Not authenticated"
def test_candidates_empty_owner_cannot_query_foreign_private_endpoint():
"""Empty owner in multi-user mode cannot access private endpoints of other users."""
db = SessionLocal()
try:
ep = ModelEndpoint(
id="ep-private-victim",
name="Victim Private Cluster",
base_url="http://192.168.1.105:8000/v1",
owner="victim_user",
is_enabled=True,
cached_models=json.dumps(["victim-secret-model"]),
)
db.merge(ep)
db.commit()
with patch("src.auth_helpers._auth_disabled", return_value=False):
# None owner fails closed
cands_none = eam._candidates(None, "ep-private-victim::victim-secret-model")
assert cands_none == []
# Empty string owner fails closed
cands_empty = eam._candidates("", "ep-private-victim::victim-secret-model")
assert cands_empty == []
finally:
db.query(ModelEndpoint).filter(ModelEndpoint.id == "ep-private-victim").delete()
db.commit()
db.close()
def test_editor_routes_api_token_without_chat_scope_returns_403():
"""Bearer API token missing 'chat' scope is rejected with 403."""
from fastapi import FastAPI
from fastapi.testclient import TestClient
app = FastAPI()
@app.middleware("http")
async def inject_token_middleware(request, call_next):
request.state.api_token = True
request.state.api_token_scopes = ["image", "read"] # Missing "chat"
request.state.api_token_owner = "alice"
return await call_next(request)
app.include_router(setup_editor_agent_routes())
client = TestClient(app, raise_server_exceptions=False)
resp_plan = client.post("/api/editor/plan", json={"prompt": "make it pop"})
assert resp_plan.status_code == 403
assert "chat" in resp_plan.json().get("detail", "")
resp_agent = client.post("/api/editor/agent", json={"prompt": "make it pop"})
assert resp_agent.status_code == 403
assert "chat" in resp_agent.json().get("detail", "")
def test_editor_routes_api_token_with_chat_scope_resolves_owner_endpoint():
"""Bearer API token with 'chat' scope resolves the token owner's endpoints."""
from fastapi import FastAPI
from fastapi.testclient import TestClient
db = SessionLocal()
try:
alice_ep = ModelEndpoint(
id="ep-alice-token",
name="Alice Cluster",
base_url="http://127.0.0.1:9098/v1",
owner="alice",
is_enabled=True,
cached_models=json.dumps(["alice-model"]),
)
bob_ep = ModelEndpoint(
id="ep-bob-token",
name="Bob Cluster",
base_url="http://127.0.0.1:9099/v1",
owner="bob",
is_enabled=True,
cached_models=json.dumps(["bob-model"]),
)
db.merge(alice_ep)
db.merge(bob_ep)
db.commit()
app = FastAPI()
@app.middleware("http")
async def inject_token_middleware(request, call_next):
request.state.api_token = True
request.state.api_token_scopes = ["chat"]
request.state.api_token_owner = "alice"
return await call_next(request)
app.include_router(setup_editor_agent_routes())
client = TestClient(app, raise_server_exceptions=False)
# Alice's token querying Alice's endpoint resolves
cands = eam._candidates("alice", "ep-alice-token::alice-model")
assert len(cands) == 1
assert cands[0]["endpoint_id"] == "ep-alice-token"
# Alice's token querying Bob's endpoint fails closed
cands_bob = eam._candidates("alice", "ep-bob-token::bob-model")
assert cands_bob == []
# Route-level check: calling /api/editor/plan with Alice's token targeting Bob's endpoint
# falls back and does NOT route to Bob
resp = client.post(
"/api/editor/plan",
json={"prompt": "enhance", "agent_model": "ep-bob-token::bob-model"}
)
assert resp.status_code == 200
# Should report fallback or default, never bob's endpoint
assert resp.json().get("agent_model") != "ep-bob-token::bob-model"
finally:
db.query(ModelEndpoint).filter(
ModelEndpoint.id.in_(["ep-alice-token", "ep-bob-token"])
).delete()
db.commit()
db.close()
+5 -3
View File
@@ -388,11 +388,13 @@ def test_quick_edit_has_an_agent_model_picker():
assert "Recommended, not required" in AI_SETTINGS
assert "function _getSelectedQuickEditAgentModel()" in EDITOR
assert "getAgentModel: () => _getSelectedQuickEditAgentModel()," in EDITOR
# Local Qwen3.5-9B VL is the default; server default is the alternative.
# Local/hosted agent models are dynamically discovered via /api/editor/agent-models;
# private Tailscale/CGNAT IPs must not be hardcoded in frontend source.
assert "export function resolveQuickEditAgentModel()" in models
assert "export const QUICK_EDIT_AGENT_OPTIONS" in models
assert "Qwen3.5-9B VL · local" in models
assert "http://100.69.120.65:8097/v1::Qwen/Qwen3.5-9B" in models
assert "Server default (hosted planner)" in models
assert "100.69.120.65" not in models
assert "/api/editor/agent-models" in models
assert "localStorage.setItem('ge-ai-command-agent-model', aiCommandAgentSelect.value)" in models
assert "localStorage.getItem('ge-ai-command-agent-model')" in models
# The request carries the agent model.
+4 -1
View File
@@ -40,7 +40,10 @@ def test_flyout_triangle_css_is_visible():
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
assert "icon.className = 'ge-tool-icon';" in TOOLBAR
assert 'icon.innerHTML = presetIcon;' in TOOLBAR
assert "label.className = 'ge-tool-flyout-label';" in TOOLBAR
assert 'label.textContent = preset.name;' in TOOLBAR
def test_flyout_is_clamped_inside_the_viewport():
+327
View File
@@ -0,0 +1,327 @@
import asyncio
import http.server
import json
import threading
from typing import List, Tuple
from unittest.mock import MagicMock, patch
import pytest
from starlette.requests import Request
from starlette.exceptions import HTTPException
from core.database import SessionLocal, ModelEndpoint
from routes.gallery.gallery_routes import setup_gallery_routes, _visible_image_endpoint_for_base
class TrackingPollHandler(http.server.BaseHTTPRequestHandler):
requests_received: List[str] = []
def do_GET(self):
TrackingPollHandler.requests_received.append(self.path)
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.end_headers()
self.wfile.write(b'{"available": true, "progress": 50}')
def do_POST(self):
TrackingPollHandler.requests_received.append(self.path)
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.end_headers()
self.wfile.write(b'{"images": ["data:image/png;base64,AAAA"]}')
def log_message(self, format, *args):
pass
@pytest.fixture
def mock_provider_listener():
TrackingPollHandler.requests_received = []
server = http.server.HTTPServer(("127.0.0.1", 0), TrackingPollHandler)
port = server.server_address[1]
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
yield f"http://127.0.0.1:{port}"
server.shutdown()
server.server_close()
def create_gallery_request(method: str, path: str, query_params: dict = None, body: dict = None, user: str = "alice"):
scope = {
"type": "http",
"method": method,
"path": path,
"headers": [(b"content-type", b"application/json")],
"query_string": "&".join(f"{k}={v}" for k, v in (query_params or {}).items()).encode("utf-8"),
"app": MagicMock(),
}
req = Request(scope)
async def mock_json():
return body or {}
req.json = mock_json
return req
def test_edit_progress_disabled_endpoint_causes_no_outbound_polling(mock_provider_listener):
"""GET /api/image/edit-progress with disabled endpoint causes zero outbound requests."""
db = SessionLocal()
ep_id = "ep-test-disabled-progress"
try:
ep = ModelEndpoint(
id=ep_id,
name="Disabled Endpoint",
base_url=mock_provider_listener,
model_type="image",
is_enabled=False,
owner="alice",
cached_models=json.dumps(["test-diff"]),
)
db.merge(ep)
db.commit()
router = setup_gallery_routes()
route = next(r for r in router.routes if r.path == "/api/image/edit-progress")
req = create_gallery_request(
"GET",
"/api/image/edit-progress",
query_params={"request_id": "req-123", "endpoint_id": ep_id},
user="alice",
)
with patch("routes.gallery.gallery_routes.require_privilege", return_value="alice"):
resp = asyncio.run(route.endpoint(req))
assert isinstance(resp, dict)
assert resp["available"] is False
assert resp.get("reason") == "endpoint not found or disabled"
# Zero outbound polling to the provider!
assert len(TrackingPollHandler.requests_received) == 0
finally:
db.query(ModelEndpoint).filter(ModelEndpoint.id == ep_id).delete()
db.commit()
db.close()
def test_edit_progress_foreign_owned_endpoint_causes_no_outbound_polling(mock_provider_listener):
"""GET /api/image/edit-progress with foreign-owned endpoint causes zero outbound requests."""
db = SessionLocal()
ep_id = "ep-test-foreign-progress"
try:
ep = ModelEndpoint(
id=ep_id,
name="Victim Endpoint",
base_url=mock_provider_listener,
model_type="image",
is_enabled=True,
owner="victim_user",
cached_models=json.dumps(["test-diff"]),
)
db.merge(ep)
db.commit()
router = setup_gallery_routes()
route = next(r for r in router.routes if r.path == "/api/image/edit-progress")
# Attacker user 'bob' requests victim's endpoint
req = create_gallery_request(
"GET",
"/api/image/edit-progress",
query_params={"request_id": "req-456", "endpoint_id": ep_id},
user="bob",
)
with patch("routes.gallery.gallery_routes.require_privilege", return_value="bob"):
resp = asyncio.run(route.endpoint(req))
assert isinstance(resp, dict)
assert resp["available"] is False
assert resp.get("reason") == "endpoint not found or disabled"
assert len(TrackingPollHandler.requests_received) == 0
finally:
db.query(ModelEndpoint).filter(ModelEndpoint.id == ep_id).delete()
db.commit()
db.close()
def test_edit_progress_rejects_chat_only_endpoint_causes_no_polling(mock_provider_listener):
"""GET /api/image/edit-progress rejects chat-only endpoint ID with zero outbound requests."""
db = SessionLocal()
ep_id = "ep-test-chat-progress"
try:
ep = ModelEndpoint(
id=ep_id,
name="Chat Endpoint",
base_url=mock_provider_listener,
model_type="llm", # chat only!
is_enabled=True,
owner="alice",
cached_models=json.dumps(["llama-3-8b"]),
)
db.merge(ep)
db.commit()
router = setup_gallery_routes()
route = next(r for r in router.routes if r.path == "/api/image/edit-progress")
req = create_gallery_request(
"GET",
"/api/image/edit-progress",
query_params={"request_id": "req-789", "endpoint_id": ep_id},
user="alice",
)
with patch("routes.gallery.gallery_routes.require_privilege", return_value="alice"):
resp = asyncio.run(route.endpoint(req))
assert isinstance(resp, dict)
assert resp["available"] is False
assert resp.get("reason") == "endpoint not found or disabled"
# Zero outbound polling to the chat provider!
assert len(TrackingPollHandler.requests_received) == 0
finally:
db.query(ModelEndpoint).filter(ModelEndpoint.id == ep_id).delete()
db.commit()
db.close()
def test_edit_progress_blocks_private_ip_when_policy_enabled(mock_provider_listener, monkeypatch):
"""GET /api/image/edit-progress enforces IMAGE_BLOCK_PRIVATE_IPS policy."""
monkeypatch.setenv("IMAGE_BLOCK_PRIVATE_IPS", "true")
db = SessionLocal()
ep_id = "ep-test-private-progress"
try:
ep = ModelEndpoint(
id=ep_id,
name="Private Image Endpoint",
base_url=mock_provider_listener, # 127.0.0.1 loopback
model_type="image",
is_enabled=True,
owner="alice",
cached_models=json.dumps(["sd-v1"]),
)
db.merge(ep)
db.commit()
router = setup_gallery_routes()
route = next(r for r in router.routes if r.path == "/api/image/edit-progress")
req = create_gallery_request(
"GET",
"/api/image/edit-progress",
query_params={"request_id": "req-priv", "endpoint_id": ep_id},
user="alice",
)
with patch("routes.gallery.gallery_routes.require_privilege", return_value="alice"):
resp = asyncio.run(route.endpoint(req))
assert isinstance(resp, dict)
assert resp["available"] is False
assert "rejected" in str(resp.get("reason"))
# Zero outbound polling because private IP was blocked by policy!
assert len(TrackingPollHandler.requests_received) == 0
finally:
db.query(ModelEndpoint).filter(ModelEndpoint.id == ep_id).delete()
db.commit()
db.close()
def test_harmonize_rejects_chat_only_endpoint(mock_provider_listener):
"""POST /api/image/harmonize rejects a chat-only endpoint that has no image capability."""
db = SessionLocal()
ep_id = "ep-test-chat-only"
try:
ep = ModelEndpoint(
id=ep_id,
name="Chat Only Llama",
base_url=mock_provider_listener,
model_type="llm", # chat only
is_enabled=True,
owner="alice",
cached_models=json.dumps(["meta-llama/Llama-3-8B-Instruct", "mistral-7b"]),
)
db.merge(ep)
db.commit()
router = setup_gallery_routes()
route = next(r for r in router.routes if r.path == "/api/image/harmonize")
payload = {
"image": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==",
"_endpoint": ep_id,
"_model": "meta-llama/Llama-3-8B-Instruct",
}
req = create_gallery_request("POST", "/api/image/harmonize", body=payload, user="alice")
with patch("routes.gallery.gallery_routes.require_privilege", return_value="alice"):
with pytest.raises(HTTPException) as exc_info:
asyncio.run(route.endpoint(req))
assert exc_info.value.status_code == 403
assert "Choose a registered image endpoint" in exc_info.value.detail
# Zero outbound requests to chat provider
assert len(TrackingPollHandler.requests_received) == 0
finally:
db.query(ModelEndpoint).filter(ModelEndpoint.id == ep_id).delete()
db.commit()
db.close()
def test_harmonize_accepts_eligible_image_model_on_mixed_endpoint(mock_provider_listener):
"""POST /api/image/harmonize accepts a mixed endpoint when an image-capable model is selected."""
db = SessionLocal()
ep_id = "ep-test-mixed-endpoint"
try:
ep = ModelEndpoint(
id=ep_id,
name="Mixed Gateway",
base_url=mock_provider_listener,
model_type="llm", # mixed gateway
is_enabled=True,
owner="alice",
cached_models=json.dumps(["gpt-4o", "qwen-image-edit"]),
)
db.merge(ep)
db.commit()
# Testing capability helper directly with image model
ep_resolved = _visible_image_endpoint_for_base(db, ep_id, "alice", "qwen-image-edit")
assert ep_resolved is not None
assert ep_resolved.id == ep_id
# Testing with chat model fails
ep_chat = _visible_image_endpoint_for_base(db, ep_id, "alice", "gpt-4o")
assert ep_chat is None
finally:
db.query(ModelEndpoint).filter(ModelEndpoint.id == ep_id).delete()
db.commit()
db.close()
def test_harmonize_preserves_legitimate_image_capable_endpoint(mock_provider_listener):
"""Legitimate image endpoint (model_type == 'image') resolves and works properly."""
db = SessionLocal()
ep_id = "ep-test-sd-diffusion"
try:
ep = ModelEndpoint(
id=ep_id,
name="Forge SD WebUI",
base_url=mock_provider_listener,
model_type="image",
is_enabled=True,
owner="alice",
cached_models=json.dumps(["sd_xl_base_1.0.safetensors"]),
)
db.merge(ep)
db.commit()
ep_resolved = _visible_image_endpoint_for_base(db, ep_id, "alice", "sd_xl_base_1.0.safetensors")
assert ep_resolved is not None
assert ep_resolved.id == ep_id
finally:
db.query(ModelEndpoint).filter(ModelEndpoint.id == ep_id).delete()
db.commit()
db.close()
+99
View File
@@ -0,0 +1,99 @@
"""Service-worker cache policy for the /static/ scope.
The worker is registered at /static/sw.js without a scope, so it only controls
/static/ clients. The SPA root is outside that scope, which means an ordinary
page load never populates the worker's cache: anything the lazy editor graph
imports must be in the precache lists, shared modules included. The real
browser upgrade/offline proof lives in
tests/e2e/photo-editor/offline-upgrade.spec.js.
"""
import os
import re
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
STATIC = ROOT / "static"
JS_DIR = STATIC / "js"
SW = (STATIC / "sw.js").read_text(encoding="utf-8")
_STATIC_IMPORT = re.compile(
r"""(?:^|\n)\s*(?:import\s+(?:[^;'"()]*?\s+from\s+)?|export\s+(?:\*|\{[^}]*\})\s+from\s+)['"]([^'"]+)['"]"""
)
def _list(name: str) -> set[str]:
block = re.search(rf"const {name} = \[(.*?)\];", SW, re.S)
assert block, f"{name} not found in static/sw.js"
return set(re.findall(r"'([^']+)'", block.group(1)))
def _fetch_handler() -> str:
start = SW.index("self.addEventListener('fetch'")
return SW[start:]
def _lazy_editor_graph() -> set[str]:
panels = (JS_DIR / "panels.js").read_text(encoding="utf-8")
entry = re.search(r"import\(['\"]\./(galleryEditor\.js(?:\?[^'\"]+)?)['\"]\)", panels)
assert entry, "panels.js must lazily import galleryEditor.js"
seen: set[str] = set()
stack = [entry.group(1)]
while stack:
current = stack.pop()
if current in seen:
continue
seen.add(current)
source = (JS_DIR / current.split("?")[0]).read_text(encoding="utf-8")
for spec in _STATIC_IMPORT.findall(source):
if not spec.startswith("."):
continue
path, _, query = spec.partition("?")
target = os.path.normpath(os.path.join(os.path.dirname(current.split("?")[0]), path))
stack.append(f"{target}?{query}" if query else target)
return seen
def test_every_module_in_the_lazy_editor_graph_is_precached_with_its_exact_url():
graph = _lazy_editor_graph()
assert len(graph) > 100, len(graph)
precached = _list("PRECACHE") | _list("PANEL_PRECACHE")
missing = sorted(f"/static/js/{module}" for module in graph if f"/static/js/{module}" not in precached)
assert not missing, (
"the editor imports these modules, but the /static/-scoped worker never "
f"sees the SPA page load that would cache them: {missing}"
)
def test_worker_keeps_the_static_scope():
index = (STATIC / "index.html").read_text(encoding="utf-8")
registrations = re.findall(r"serviceWorker\.register\(([^)]*)\)", index)
assert registrations == ["'/static/sw.js?v=20260916autoscroll1'"]
assert "Service-Worker-Allowed" not in (ROOT / "app.py").read_text(encoding="utf-8")
def test_fetch_handler_ignores_cross_origin_api_and_non_get_requests():
handler = _fetch_handler()
api_guard = handler.index("url.pathname.startsWith('/api/') || e.request.method !== 'GET') return;")
origin_guard = handler.index("if (url.origin !== self.location.origin) return;")
first_respond = handler.index("e.respondWith(")
assert api_guard < origin_guard < first_respond
def test_offline_fallback_ignores_only_an_origin_vary_inside_the_current_cache():
helper = SW[SW.index("async function cachedStaticFile(request)"):SW.index("self.addEventListener('fetch'")]
assert "const cache = await caches.open(CACHE_NAME);" in helper
assert "await cache.match(request);" in helper
assert "cache.matchAll(request, { ignoreVary: true })" in helper
assert ".every(name => name === 'origin')" in helper
assert "|| Response.error();" in helper
# No other lookup may relax Vary or search caches left by other versions.
assert SW.count("ignoreVary") == 1
assert "caches.match(" not in _fetch_handler()
assert ".catch(() => cachedStaticFile(e.request))" in _fetch_handler()
def test_activation_removes_every_other_cache_version():
assert re.search(r"const CACHE_NAME = 'odysseus-v\d+-[a-z0-9-]+';", SW)
activate = SW[SW.index("self.addEventListener('activate'"):SW.index("async function cachedStaticFile")]
assert "keys.filter(k => k !== CACHE_NAME).map(k => caches.delete(k))" in activate
assert "self.clients.claim()" in activate
+244
View File
@@ -0,0 +1,244 @@
import json
import os
import shutil
import tempfile
import zipfile
import pytest
from scripts.sanitize_trace_artifacts import (
sanitize_structured_obj,
sanitize_jsonl_content,
sanitize_url,
sanitize_body_text,
sanitize_trace_archive,
sanitize_directory,
)
def test_sanitize_headers_and_inverted_keys():
"""Inverted key order and non-Bearer schemes are safely redacted."""
# Standard order
h1 = {"name": "Authorization", "value": "Bearer my_super_secret_jwt_12345"}
assert sanitize_structured_obj(h1)["value"] == "[REDACTED]"
# Inverted order {"value": ..., "name": ...}
h2 = {"value": "Digest username=admin, nonce=abc, response=secret123", "name": "Authorization"}
res2 = sanitize_structured_obj(h2)
assert res2["value"] == "[REDACTED]"
# ApiKey scheme
h3 = {"name": "Authorization", "value": "ApiKey secret_api_key_xyz"}
assert sanitize_structured_obj(h3)["value"] == "[REDACTED]"
# Custom token header
h4 = {"name": "X-Auth-Token", "value": "auth_token_999"}
assert sanitize_structured_obj(h4)["value"] == "[REDACTED]"
def test_sanitize_cookies_arbitrary_names():
"""Arbitrary cookie names and values are completely redacted."""
cookie_entry = {
"cookies": [
{"name": "odysseus_session", "value": "secret_sess_abc"},
{"name": "remember_me_custom", "value": "remember_secret_val"},
{"name": "sso_auth_id", "value": "sso_uuid_12345"},
]
}
sanitized = sanitize_structured_obj(cookie_entry)
for c in sanitized["cookies"]:
assert c["value"] == "[REDACTED]"
def test_sanitize_dom_snapshots_and_nested_action_params():
"""DOM snapshot trees and deeply nested action params are redacted."""
# 1. Action with nested options before value
action_record = {
"type": "action",
"name": "fill",
"params": {
"selector": "#login-password",
"options": {"force": True, "timeout": 5000},
"value": "escaped\"quote\"password123!",
},
}
res_action = sanitize_structured_obj(action_record)
assert res_action["params"]["value"] == "[REDACTED]"
# 2. DOM snapshot input element
dom_record = {
"nodeName": "INPUT",
"attributes": {
"type": "password",
"name": "user_password",
"value": "super_secret_in_dom",
},
}
res_dom = sanitize_structured_obj(dom_record)
assert res_dom["attributes"]["value"] == "[REDACTED]"
def test_sanitize_url_tokens_and_authority():
"""URL query parameters and userinfo credentials are scrubbed."""
# Authority credentials
url1 = "https://admin:my_secret_pw@example.com/api/v1/resource"
assert sanitize_url(url1) == "https://admin:[REDACTED]@example.com/api/v1/resource"
# Query tokens
url2 = "https://example.com/oauth/callback?code=auth_code_secret&state=xyz&token=access_token_123"
sanitized2 = sanitize_url(url2)
assert "auth_code_secret" not in sanitized2
assert "access_token_123" not in sanitized2
assert "code=%5BREDACTED%5D" in sanitized2 or "code=[REDACTED]" in sanitized2
assert "token=%5BREDACTED%5D" in sanitized2 or "token=[REDACTED]" in sanitized2
assert "state=xyz" in sanitized2
def test_sanitize_request_bodies():
"""JSON and urlencoded form request bodies are scrubbed."""
# JSON body
json_body = '{"username": "admin", "password": "superSecretPassword123!", "api_key": "sk-123456789"}'
s_json = sanitize_body_text(json_body)
assert "superSecretPassword123!" not in s_json
assert "sk-123456789" not in s_json
assert '"password": "[REDACTED]"' in s_json
assert '"api_key": "[REDACTED]"' in s_json
assert '"username": "admin"' in s_json
# Form-encoded body
form_body = "username=alice&password=mypassword999&token=abcde&action=login"
s_form = sanitize_body_text(form_body)
assert "mypassword999" not in s_form
assert "abcde" not in s_form
assert "password=%5BREDACTED%5D" in s_form or "password=[REDACTED]" in s_form
assert "token=%5BREDACTED%5D" in s_form or "token=[REDACTED]" in s_form
assert "username=alice" in s_form
def test_sanitize_valid_archive(tmp_path):
"""Structured archive sanitization cleans HAR, trace, and masks source code without corruption."""
zp = str(tmp_path / "valid_trace.zip")
with zipfile.ZipFile(zp, "w") as z:
# JSON trace record
trace_record = {
"type": "action",
"name": "fill",
"params": {"selector": "#password", "value": 'secret"quote"pass'},
}
z.writestr("0-trace.trace", json.dumps(trace_record).encode() + b"\n")
# Network record with inverted header keys and URL query
net_record = {
"type": "request",
"request": {
"url": "https://localhost:7013/api/auth/login?token=token_abc",
"headers": [{"value": "Digest secret_digest", "name": "Authorization"}],
"postData": '{"password": "my_request_pw"}',
},
}
z.writestr("0-trace.network", json.dumps(net_record).encode() + b"\n")
# Embedded test source file
z.writestr("resources/src@test_auth.js", b'const password = "admin_password";\n')
# Binary image screenshot
z.writestr("screenshot.png", b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDRdiagnostic")
ok = sanitize_trace_archive(zp)
assert ok is True
assert os.path.exists(zp)
with zipfile.ZipFile(zp, "r") as z:
# Verify trace record
trace_data = json.loads(z.read("0-trace.trace").decode().strip())
assert trace_data["params"]["value"] == "[REDACTED]"
# Verify network record
net_data = json.loads(z.read("0-trace.network").decode().strip())
assert "token_abc" not in net_data["request"]["url"]
assert net_data["request"]["headers"][0]["value"] == "[REDACTED]"
assert "my_request_pw" not in net_data["request"]["postData"]
# Verify embedded source code was masked safely
src_content = z.read("resources/src@test_auth.js").decode()
assert "admin_password" not in src_content
assert "[SOURCE CODE REDACTED FOR TRACE CONFIDENTIALITY]" in src_content
assert "const password: [REDACTED]" not in src_content # No syntax error!
# Verify screenshot is unmodified
png = z.read("screenshot.png")
assert png == b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDRdiagnostic"
def test_fail_closed_malformed_zip(tmp_path):
zp = str(tmp_path / "corrupt.zip")
with open(zp, "wb") as f:
f.write(b"this is completely invalid zip data PK\x03\x04 corrupt")
ok = sanitize_trace_archive(zp)
assert ok is False
# Fail-closed: corrupted zip must be deleted immediately!
assert not os.path.exists(zp)
def test_fail_closed_partial_archive(tmp_path):
zp = str(tmp_path / "partial.zip")
with open(zp, "wb") as f:
f.write(b"PK\x03\x04\x14\x00\x00\x00")
ok = sanitize_trace_archive(zp)
assert ok is False
assert not os.path.exists(zp)
def test_fail_closed_empty_archive(tmp_path):
zp = str(tmp_path / "empty.zip")
with open(zp, "wb") as f:
pass
ok = sanitize_trace_archive(zp)
assert ok is False
assert not os.path.exists(zp)
def test_fail_closed_on_unhandled_exception(tmp_path, monkeypatch):
zp = str(tmp_path / "valid.zip")
with zipfile.ZipFile(zp, "w") as z:
z.writestr("test.trace", b'{"name": "fill"}\n')
def broken_sanitize(obj):
raise RuntimeError("Simulated crash")
monkeypatch.setattr("scripts.sanitize_trace_artifacts.sanitize_structured_obj", broken_sanitize)
ok = sanitize_trace_archive(zp)
assert ok is False
assert not os.path.exists(zp)
assert not os.path.exists(zp + ".tmp")
def test_multiple_trace_archives_in_directory(tmp_path):
sub1 = tmp_path / "test1"
sub2 = tmp_path / "test2"
sub1.mkdir()
sub2.mkdir()
z1 = str(sub1 / "trace.zip")
z2 = str(sub2 / "trace.zip")
with zipfile.ZipFile(z1, "w") as z:
z.writestr("0.trace", b'{"type": "action", "params": {"value": "secret1"}}\n')
with zipfile.ZipFile(z2, "w") as z:
z.writestr("0.trace", b'{"type": "action", "params": {"value": "secret2"}}\n')
count = sanitize_directory(str(tmp_path))
assert count == 2
assert os.path.exists(z1)
with zipfile.ZipFile(z1, "r") as z:
content = json.loads(z.read("0.trace").decode().strip())
assert content["params"]["value"] == "[REDACTED]"
assert os.path.exists(z2)
with zipfile.ZipFile(z2, "r") as z:
content = json.loads(z.read("0.trace").decode().strip())
assert content["params"]["value"] == "[REDACTED]"