mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-10-27 08:12:22 +01:00
Merge current dev and refresh PR #6740 from the 7011 integration
This commit is contained in:
@@ -0,0 +1,66 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { chromium } from 'playwright';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
|
||||
const origin = process.env.ODYSSEUS_NOTES_RUNTIME_ORIGIN;
|
||||
const evidence = process.env.NOTES_NATIVE_EVIDENCE_DIR;
|
||||
if (evidence) fs.mkdirSync(evidence, { recursive: true });
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const credentials = { username: 'notes-reference', password: randomUUID() + 'Aa1!' };
|
||||
try {
|
||||
for (const mobile of [false, true]) {
|
||||
const context = await browser.newContext({ viewport: mobile ? { width: 390, height: 844 } : { width: 1440, height: 1000 }, hasTouch: mobile, isMobile: mobile });
|
||||
const page = await context.newPage();
|
||||
if (!mobile) {
|
||||
const setup = await context.request.post(`${origin}/api/auth/setup`, { data: credentials });
|
||||
assert.equal(setup.ok(), true, await setup.text());
|
||||
}
|
||||
const login = await context.request.post(`${origin}/api/auth/login`, { data: credentials });
|
||||
assert.equal(login.ok(), true, await login.text());
|
||||
const response = await context.request.post(`${origin}/api/notes`, { data: { title: mobile ? 'Mobile reference' : 'Desktop reference', content: 'Native API draft', note_type: 'note', label: 'reference' } });
|
||||
assert.equal(response.ok(), true, await response.text());
|
||||
const created = await response.json();
|
||||
assert.ok(created.id);
|
||||
assert.equal(created.owner, credentials.username);
|
||||
await page.goto(origin);
|
||||
await page.waitForFunction(() => window.notesModule?.openPanel);
|
||||
await page.evaluate(() => { localStorage.setItem('odysseus-notes-first-open-hint-v1', '1'); window.notesModule.openPanel(); });
|
||||
const card = page.locator(`.note-card[data-note-id="${created.id}"]`);
|
||||
await card.waitFor();
|
||||
if (evidence) await page.screenshot({ path: path.join(evidence, mobile ? 'native-mobile.png' : 'native-desktop.png'), fullPage: true, animations: 'disabled' });
|
||||
await page.locator('#notes-select-btn').click();
|
||||
await card.locator('.note-card-cb').check({ force: mobile });
|
||||
await page.locator('#notes-send-agent').click();
|
||||
assert.ok(await page.locator('.note-card-selectmode').count());
|
||||
await page.evaluate(async () => {
|
||||
const sessions = await import('/static/js/sessions.js');
|
||||
sessions.createDirectChat('http://127.0.0.1:1', 'reference-model', null);
|
||||
});
|
||||
await page.locator('#notes-send-agent').click();
|
||||
assert.equal(await page.locator('.note-card-selectmode').count(), 0);
|
||||
assert.equal(await page.locator('#selected-notes-context-count').textContent(), '1 note selected');
|
||||
await page.evaluate(async () => (await import('/static/js/sessions.js')).createDirectChat('http://127.0.0.1:1', 'another-reference-model', null));
|
||||
assert.equal(await page.locator('#selected-notes-context-count').textContent(), '');
|
||||
if (mobile) await card.locator('.note-card-title').tap();
|
||||
else await card.locator('.note-card-title').click();
|
||||
if (mobile) await page.locator('.note-form-content-reader').tap({ position: { x: 5, y: 5 } });
|
||||
await page.locator('.note-form-content').fill('Saved by the native Notes application');
|
||||
await page.waitForFunction(id => !localStorage.getItem('odysseus-note-draft-' + id), created.id);
|
||||
const fetched = await context.request.get(`${origin}/api/notes`);
|
||||
const notes = (await fetched.json()).notes;
|
||||
assert.equal(notes.find(note => note.id === created.id).content, 'Saved by the native Notes application');
|
||||
await page.evaluate(() => window.notesModule.closePanel());
|
||||
await page.locator('#notes-pane').waitFor({ state: 'detached' });
|
||||
await context.close();
|
||||
console.log(JSON.stringify({ native: true, mobile, noteId: created.id, crud: true, selectedContext: true }));
|
||||
}
|
||||
const smoke = spawnSync(process.env.NOTES_TEST_PYTHON, ['-m', 'pytest', '-q', 'tests/smoke/test_notes_smoke.py'], {
|
||||
env: { ...process.env, APP_PORT: new URL(origin).port, ODYSSEUS_ADMIN_USER: credentials.username, ODYSSEUS_ADMIN_PASSWORD: credentials.password },
|
||||
encoding: 'utf8', timeout: 90000,
|
||||
});
|
||||
assert.equal(smoke.status, 0, smoke.stdout + smoke.stderr);
|
||||
console.log('PASS: original Notes release smoke test against the disposable authenticated instance');
|
||||
} finally { await browser.close(); }
|
||||
@@ -0,0 +1,39 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { chromium } from 'playwright';
|
||||
import fs from 'node:fs';
|
||||
|
||||
const origin = process.env.ODYSSEUS_TEST_STATIC_ORIGIN;
|
||||
const manifest = JSON.parse(fs.readFileSync(new URL('../../frontend/modules.json', import.meta.url), 'utf8'));
|
||||
const outputs = manifest.modules.map(entry => '/static/' + entry.deployment);
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
try {
|
||||
const context = await browser.newContext({ serviceWorkers: 'allow' });
|
||||
const page = await context.newPage();
|
||||
await page.goto(`${origin}/static/js/panels.js`);
|
||||
const cached = await page.evaluate(async outputs => {
|
||||
await navigator.serviceWorker.register('/static/sw.js');
|
||||
await navigator.serviceWorker.ready;
|
||||
if (!navigator.serviceWorker.controller) await new Promise(resolve => navigator.serviceWorker.addEventListener('controllerchange', resolve, { once: true }));
|
||||
const missing = [];
|
||||
for (const output of outputs) if (!(await caches.match(output))?.ok) missing.push(output);
|
||||
return missing;
|
||||
}, outputs);
|
||||
assert.deepEqual(cached, []);
|
||||
assert.equal(await page.evaluate(async () => {
|
||||
const shared = await import('/static/js/shared/ui/confetti.js');
|
||||
const compare = await import('/static/js/compare/vote.js?v=20260828resendcaldrag1');
|
||||
return shared.spawnConfetti === compare.spawnConfetti;
|
||||
}), true);
|
||||
await context.setOffline(true);
|
||||
const offline = await context.newPage();
|
||||
offline.on('requestfailed', request => console.log('offline failed:', request.url(), request.failure()));
|
||||
offline.on('console', message => { if (message.type() === 'error') console.log('browser:', message.text()); });
|
||||
await offline.goto(`${origin}/static/js/panels.js`);
|
||||
const result = await offline.evaluate(async () => {
|
||||
const facade = await import('/static/js/notes.js?v=20260911notesselectioncancel1');
|
||||
const feature = await import('/static/js/features/notes/index.js');
|
||||
return { identity: facade.default === feature.default && window.notesModule === feature.default, closed: facade.isPanelOpen() === false, exports: Object.keys(facade).sort() };
|
||||
});
|
||||
assert.equal(result.identity && result.closed, true);
|
||||
console.log(JSON.stringify({ ...result, maintainedPrecache: outputs.length }));
|
||||
} finally { await browser.close(); }
|
||||
@@ -0,0 +1,148 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { chromium } from 'playwright';
|
||||
|
||||
const origin = process.env.ODYSSEUS_TEST_STATIC_ORIGIN;
|
||||
const evidence = process.env.NOTES_EVIDENCE_DIR;
|
||||
if (evidence) fs.mkdirSync(evidence, { recursive: true });
|
||||
const baseline = process.env.NOTES_BASELINE === '1';
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
try {
|
||||
for (const device of ['desktop', 'mobile', 'tablet']) {
|
||||
const mobile = device === 'mobile';
|
||||
const context = await browser.newContext({ viewport: mobile ? { width: 390, height: 844 } : device === 'tablet' ? { width: 1024, height: 800 } : { width: 1440, height: 1000 }, hasTouch: device !== 'desktop', isMobile: mobile });
|
||||
const page = await context.newPage();
|
||||
const errors = [];
|
||||
page.on('pageerror', error => errors.push(error.stack));
|
||||
if (baseline) {
|
||||
const body = execFileSync('git', ['show', '1cc56cb9:static/js/notes.js'], { encoding: 'utf8' });
|
||||
await page.route('**/static/js/notes.js*', route => route.fulfill({ contentType: 'text/javascript', body }));
|
||||
}
|
||||
const requests = [];
|
||||
const notes = [
|
||||
{ id: 'plain', title: 'Reference note', note_type: 'note', content: 'Visit https://example.org and keep this draft.', label: 'work', sort_order: 0 },
|
||||
{ id: 'todo', title: 'Reference checklist', note_type: 'todo', items: [{ text: 'First task', done: false }, { text: 'Second task', done: true }], sort_order: 1 },
|
||||
{ id: 'goal', title: 'Reference goal', note_type: 'goal', content: 'A bounded goal', items: [{ text: 'Next step', done: false }], sort_order: 2 },
|
||||
];
|
||||
await page.route('**/api/**', async route => {
|
||||
const request = route.request();
|
||||
requests.push({ url: new URL(request.url()).pathname, method: request.method(), body: request.postData() });
|
||||
const id = new URL(request.url()).pathname.split('/').at(-1);
|
||||
if (request.method() === 'PUT') Object.assign(notes.find(n => n.id === id) || {}, request.postDataJSON());
|
||||
await route.fulfill({ json: request.method() === 'GET' ? { notes } : notes.find(n => n.id === id) || {} });
|
||||
});
|
||||
await page.goto(`${origin}/static/js/panels.js`);
|
||||
const shell = fs.readFileSync(new URL('../../static/index.html', import.meta.url), 'utf8');
|
||||
const styles = [...shell.matchAll(/<link[^>]+rel="stylesheet"[^>]*>/g)].map(m => m[0]).join('\n');
|
||||
await page.setContent(`<html><head><meta name="viewport" content="width=device-width,initial-scale=1">${styles}</head><body><div id="chat-container"></div><div id="toast"></div><button id="tool-notes-btn"><span id="notes-new-note-btn"></span></button><button id="rail-notes"></button></body></html>`);
|
||||
await page.evaluate(() => {
|
||||
localStorage.setItem('odysseus-notes-first-open-hint-v1', '1');
|
||||
window.referenceIntervals = [];
|
||||
window.referenceMouseups = new Set();
|
||||
const add = window.addEventListener.bind(window), remove = window.removeEventListener.bind(window);
|
||||
window.addEventListener = (type, fn, options) => { if (type === 'mouseup' && fn.name === '_end') window.referenceMouseups.add(fn); add(type, fn, options); };
|
||||
window.removeEventListener = (type, fn, options) => { if (type === 'mouseup' && fn.name === '_end') window.referenceMouseups.delete(fn); remove(type, fn, options); };
|
||||
const interval = window.setInterval.bind(window);
|
||||
window.setInterval = (fn, ms, ...args) => { const id = interval(fn, ms, ...args); window.referenceIntervals.push({ id, ms }); return id; };
|
||||
});
|
||||
const publicAPI = await page.evaluate(async () => {
|
||||
const mod = await import('/static/js/notes.js?v=20260911notesselectioncancel1');
|
||||
const again = await import('/static/js/notes.js?v=20260911notesselectioncancel1');
|
||||
window.referenceNotes = mod;
|
||||
mod.openPanel();
|
||||
return { names: Object.keys(mod).sort(), defaults: Object.keys(mod.default).sort(), arities: Object.fromEntries(Object.keys(mod).filter(k => k !== 'default').map(k => [k, mod[k].length])), asyncMethods: Object.keys(mod).filter(k => mod[k]?.constructor?.name === 'AsyncFunction').sort(), singleton: again === mod, bridge: window.notesModule === mod.default, aliases: mod.openNotes === mod.openPanel && mod.closeNotes === mod.closePanel && mod.isNotesOpen === mod.isPanelOpen && mod.default.openNotes === mod.openPanel };
|
||||
});
|
||||
assert.equal(publicAPI.singleton && publicAPI.bridge && publicAPI.aliases, true);
|
||||
assert.deepEqual(publicAPI.names, ['closeNotes', 'closePanel', 'default', 'dismissFiredReminderDot', 'isNotesOpen', 'isPanelOpen', 'newNote', 'openNote', 'openNotes', 'openPanel', 'refreshDueBadge', 'togglePanel']);
|
||||
assert.deepEqual(publicAPI.defaults, ['closeNotes', 'closePanel', 'isNotesOpen', 'isPanelOpen', 'newNote', 'openNote', 'openNotes', 'openPanel', 'refreshDueBadge', 'togglePanel']);
|
||||
assert.deepEqual(publicAPI.arities, { closeNotes: 1, closePanel: 1, dismissFiredReminderDot: 0, isNotesOpen: 0, isPanelOpen: 0, newNote: 0, openNote: 1, openNotes: 0, openPanel: 0, refreshDueBadge: 0, togglePanel: 0 });
|
||||
assert.deepEqual(publicAPI.asyncMethods, ['openNote', 'refreshDueBadge']);
|
||||
await page.locator('.note-card').first().waitFor();
|
||||
assert.equal(await page.locator('.note-card').count(), 3);
|
||||
if (evidence) await page.screenshot({ path: path.join(evidence, `notes-${device}.png`), fullPage: true, animations: 'disabled' });
|
||||
await page.locator('#notes-search').fill('checklist');
|
||||
assert.equal(await page.locator('.note-card').count(), 1);
|
||||
await page.locator('#notes-search').fill('');
|
||||
await page.locator('#notes-select-btn').click();
|
||||
await page.locator('.note-card-cb').first().check({ force: mobile });
|
||||
await page.locator('#notes-send-agent').click();
|
||||
assert.equal(await page.locator('.note-card-selectmode').count(), 3, 'rejected selection stays selected');
|
||||
await page.evaluate(() => window.addEventListener('odysseus:notes-context-selected', e => { window.referenceSelected = e.detail.noteIds; e.detail.accepted = true; }, { once: true }));
|
||||
await page.locator('#notes-send-agent').click();
|
||||
assert.equal(await page.locator('.note-card-selectmode').count(), 0);
|
||||
assert.equal((await page.evaluate(() => window.referenceSelected)).length, 1);
|
||||
await page.locator('#notes-search').fill('checklist');
|
||||
await page.evaluate(() => window.referenceNotes.closePanel());
|
||||
await page.locator('#notes-pane').waitFor({ state: 'detached' });
|
||||
await page.evaluate(() => window.referenceNotes.openPanel());
|
||||
await page.locator('.note-card').first().waitFor();
|
||||
assert.equal(await page.locator('#notes-search').inputValue(), '');
|
||||
assert.equal(await page.locator('.note-card').count(), 3);
|
||||
if (!baseline && device !== 'desktop') {
|
||||
const beginTouch = () => page.evaluate(() => {
|
||||
const card = document.querySelector('.note-card');
|
||||
const rect = card.getBoundingClientRect();
|
||||
const touch = new Touch({ identifier: 1, target: card, clientX: rect.left + 5, clientY: rect.top + 5 });
|
||||
card.dispatchEvent(new TouchEvent('touchstart', { touches: [touch], bubbles: true }));
|
||||
});
|
||||
if (device === 'tablet') {
|
||||
await beginTouch();
|
||||
await page.waitForFunction(() => document.querySelector('.note-card.dragging'));
|
||||
await page.evaluate(() => document.querySelector('.note-card.dragging').dispatchEvent(new TouchEvent('touchcancel', { bubbles: true })));
|
||||
await page.locator('#notes-search').fill('checklist');
|
||||
await page.locator('#notes-search').fill('');
|
||||
await beginTouch();
|
||||
await page.waitForFunction(() => document.querySelector('.note-card.dragging'));
|
||||
} else {
|
||||
await beginTouch(); // Close before the selection hold timer can fire.
|
||||
}
|
||||
await page.evaluate(() => window.referenceNotes.closePanel());
|
||||
await page.locator('#notes-pane').waitFor({ state: 'detached' });
|
||||
await page.waitForTimeout(500);
|
||||
assert.equal(await page.evaluate(() => document.documentElement.style.touchAction), '');
|
||||
await page.evaluate(() => window.referenceNotes.openPanel());
|
||||
await page.locator('.note-card').first().waitFor();
|
||||
assert.equal(await page.locator('.note-card-selectmode').count(), 0);
|
||||
}
|
||||
if (mobile) await page.locator('[data-note-id="plain"] .note-card-title').tap();
|
||||
else await page.locator('[data-note-id="plain"] .note-card-title').click();
|
||||
if (mobile) await page.locator('.note-form-content-reader').tap({ position: { x: 5, y: 5 } });
|
||||
await page.locator('.note-form-content').fill('Recovered draft');
|
||||
assert.equal(await page.evaluate(() => JSON.parse(localStorage.getItem('odysseus-note-draft-plain')).content), 'Recovered draft');
|
||||
await page.waitForFunction(() => !localStorage.getItem('odysseus-note-draft-plain'));
|
||||
assert.ok(requests.some(r => r.method === 'PUT' && JSON.parse(r.body).content === 'Recovered draft'));
|
||||
await page.locator('.note-form-type-pill[data-type="draw"]').click();
|
||||
const canvas = page.locator('.note-form-canvas');
|
||||
const box = await canvas.boundingBox();
|
||||
assert.ok(box);
|
||||
await page.mouse.move(box.x + 25, box.y + 25);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(box.x + 55, box.y + 55, { steps: 5 });
|
||||
await page.mouse.up();
|
||||
await page.locator('.note-form-draw-undo').waitFor();
|
||||
assert.equal(await page.locator('.note-form-draw-undo').isDisabled(), false);
|
||||
await page.locator('.note-form-draw-undo').click();
|
||||
assert.equal(await page.locator('.note-form-draw-undo').isDisabled(), true);
|
||||
await page.locator('.note-form-type-pill[data-type="note"]').click();
|
||||
if (!baseline) await page.waitForFunction(() => window.referenceMouseups.size === 0);
|
||||
await page.locator('.note-form-remind-btn').click();
|
||||
await page.locator('.note-reminder-menu').waitFor();
|
||||
await page.evaluate(() => window.referenceNotes.closePanel());
|
||||
await page.locator('#notes-pane').waitFor({ state: 'detached' });
|
||||
if (!baseline) {
|
||||
assert.equal(await page.locator('.note-reminder-menu').count(), 0);
|
||||
assert.equal(await page.evaluate(() => window.referenceIntervals.filter(t => t.ms === 30000).length), 1);
|
||||
const identity = await page.evaluate(async () => {
|
||||
const feature = await import('/static/js/features/notes/index.js');
|
||||
const unversioned = await import('/static/js/notes.js');
|
||||
return feature.default === window.referenceNotes.default && unversioned.default === feature.default && feature.openPanel === window.referenceNotes.openPanel;
|
||||
});
|
||||
assert.equal(identity, true);
|
||||
}
|
||||
assert.deepEqual(errors, []);
|
||||
await context.close();
|
||||
console.log(JSON.stringify({ device, mobile, ...publicAPI, requests: requests.length }));
|
||||
}
|
||||
} finally { await browser.close(); }
|
||||
@@ -0,0 +1,67 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
|
||||
const src = await readFile(new URL('../static/js/sessions.js', import.meta.url), 'utf8');
|
||||
const START = '// === copy-chat header (debug trace block) ===';
|
||||
const END = '// === end copy-chat header ===';
|
||||
const block = src.slice(src.indexOf(START) + START.length, src.indexOf(END));
|
||||
const extract = new Function(block + '\nreturn { _buildCopyChatHeader, _toolSurfaceLabel, _formatJst };');
|
||||
const { _buildCopyChatHeader, _toolSurfaceLabel, _formatJst } = extract();
|
||||
|
||||
test('copy-chat header has exact shape and values', () => {
|
||||
const header = _buildCopyChatHeader({
|
||||
version: '1.0.3',
|
||||
build: '20261006.1',
|
||||
sourceCommit: 'a1b2c3d4e5f6',
|
||||
model: 'gpt-4o',
|
||||
endpointUrl: 'https://api.example.com/v1',
|
||||
toolSurface: 'Ajax Spec (router)',
|
||||
sessionId: 'sess-123',
|
||||
copiedAt: new Date(Date.UTC(2026, 9, 13, 5, 30)),
|
||||
});
|
||||
const expected = [
|
||||
'--- Odysseus info ---',
|
||||
'Odysseus: 1.0.3 | Harness build: 20261006.1 | Commit: a1b2c3d4',
|
||||
'Model: gpt-4o @ https://api.example.com/v1 | Tool surface: Ajax Spec (router)',
|
||||
'Copied: 2026-10-13 14:30 JST | Session: sess-123',
|
||||
'----------------------',
|
||||
].join('\n');
|
||||
assert.equal(header, expected);
|
||||
});
|
||||
|
||||
test('unknown fields are never omitted', () => {
|
||||
const header = _buildCopyChatHeader({});
|
||||
const expected = [
|
||||
'--- Odysseus info ---',
|
||||
'Odysseus: unknown | Harness build: unknown | Commit: unknown',
|
||||
'Model: unknown @ unknown | Tool surface: unknown',
|
||||
'Copied: unknown JST | Session: unknown',
|
||||
'----------------------',
|
||||
].join('\n');
|
||||
assert.equal(header, expected);
|
||||
});
|
||||
|
||||
test('closing separator is exactly 22 dashes', () => {
|
||||
const header = _buildCopyChatHeader({});
|
||||
const lines = header.split('\n');
|
||||
assert.equal(lines.length, 5);
|
||||
assert.equal(lines[lines.length - 1], '----------------------');
|
||||
assert.equal(lines[lines.length - 1].length, 22);
|
||||
});
|
||||
|
||||
test('tool surface labels map modes to human labels', () => {
|
||||
assert.equal(_toolSurfaceLabel('full'), 'Regular tools');
|
||||
assert.equal(_toolSurfaceLabel('compact'), 'Ajax Compact');
|
||||
assert.equal(_toolSurfaceLabel('spec'), 'Ajax Spec (router)');
|
||||
assert.equal(_toolSurfaceLabel('none'), 'Tools off');
|
||||
assert.equal(_toolSurfaceLabel(''), 'Auto');
|
||||
assert.equal(_toolSurfaceLabel(undefined), 'Auto');
|
||||
assert.equal(_toolSurfaceLabel(null), 'Auto');
|
||||
assert.equal(_toolSurfaceLabel('weird'), 'unknown');
|
||||
});
|
||||
|
||||
test('JST timestamp is UTC+9 with HH:MM padding', () => {
|
||||
assert.equal(_formatJst(new Date(Date.UTC(2026, 0, 1, 0, 5))), '2026-01-01 09:05');
|
||||
assert.equal(_formatJst(new Date(Date.UTC(2026, 11, 31, 15, 0))), '2027-01-01 00:00');
|
||||
});
|
||||
@@ -1,10 +1,10 @@
|
||||
{
|
||||
"digest": "9868d50a542b7ad1",
|
||||
"digest": "f7e60e4a567c46dc",
|
||||
"elements": {
|
||||
"app-shell": {
|
||||
"app-loader": "f04ad5bf6312d53f",
|
||||
"attach-strip": "a6474951e2481214",
|
||||
"body": "58df17f0afa190b9",
|
||||
"body": "4956f449e6a30e93",
|
||||
"chat-container": "b00a7c6be4e107b1",
|
||||
"chat-context-pill": "dd905b87245a9a71",
|
||||
"chat-history": "e65bdc51f4d1675f",
|
||||
@@ -23,8 +23,8 @@
|
||||
"input-icon-btn": "3d1c773acd84a825",
|
||||
"memory-close-btn": "c3f4ba99098a6e4b",
|
||||
"memory-list": "da9a37a3de3c1a31",
|
||||
"memory-modal": "5efc81537eab4526",
|
||||
"memory-modal-content": "3cadc8581361436e",
|
||||
"memory-modal": "e2f97b123dc9bc55",
|
||||
"memory-modal-content": "5c5fe055c711e642",
|
||||
"memory-modal-header": "a8c822fe8c6d20e7",
|
||||
"memory-search-input": "8b0fbb33401750d4",
|
||||
"memory-toolbar-btn": "9f985efa7c896f90",
|
||||
@@ -42,7 +42,7 @@
|
||||
"rail-new-chat": "f8f1c2f6be426ab9",
|
||||
"reasoning-effort-btn": "2a07cdd8ec2b4d26",
|
||||
"rename-session-modal": "9345624780009d14",
|
||||
"root": "820190c144fda419",
|
||||
"root": "a9f7ace2c1cefb24",
|
||||
"save-custom-preset": "443da70ef5fe9b88",
|
||||
"scroll-bottom-btn": "24602d72e2949991",
|
||||
"search-input": "53b94f3e5fdb384d",
|
||||
@@ -54,7 +54,7 @@
|
||||
"settings-btn-sm": "c31bcb9bdfbae10d",
|
||||
"settings-fallbacks": "e339280590935fe3",
|
||||
"settings-logs-tab": "e470140373597143",
|
||||
"settings-modal": "5efc81537eab4526",
|
||||
"settings-modal": "e2f97b123dc9bc55",
|
||||
"settings-nav-search": "f4484b2980f2ccc5",
|
||||
"settings-row": "de568724268cec6e",
|
||||
"settings-select": "a96e8430b0ec6668",
|
||||
@@ -69,7 +69,7 @@
|
||||
"theme-grid": "4d4b2aeb8952cc6f",
|
||||
"theme-io-btn": "859f222c79a8c721",
|
||||
"theme-modal": "0a65ca916656cdbb",
|
||||
"theme-popup": "3aa65e6f313bbc65",
|
||||
"theme-popup": "fb86cc5f81d0aaa6",
|
||||
"theme-tabs": "0cc4987f39876dce",
|
||||
"toast": "c60c6a60d4982005",
|
||||
"tool-indicator": "0dee45eaca8b703a",
|
||||
@@ -83,7 +83,7 @@
|
||||
"#agent-indicator": "09a1b8d94b1487ee",
|
||||
"#agent-indicator.active": "67e2ad3fa48acd3b",
|
||||
"#compare-model-overlay .modal-body": "0b1ee357313551b9",
|
||||
"#compare-model-overlay .modal-content": "56883147d181bd9c",
|
||||
"#compare-model-overlay .modal-content": "9b2145292f439a88",
|
||||
"#cookbook-modal .cookbook-body": "20bd8a7268cee788",
|
||||
"#cookbook-modal .hwfit-cached-list.cookbook-serve-models-just-loaded > .doclib-card": "48f6787a3df3f19e",
|
||||
"#cookbook-modal .modal-content": "0c4e1e686df0990e",
|
||||
@@ -101,7 +101,7 @@
|
||||
"#email-lib-modal .email-lib-sync-status": "6d1a0374a16cf7f4",
|
||||
"#email-lib-modal .email-reader-atts": "c5d68ecda3c14bb8",
|
||||
"#email-lib-modal .email-reader-header": "85afca11c8cbd38e",
|
||||
"#email-lib-modal .modal-content": "d8b8fff38c613c95",
|
||||
"#email-lib-modal .modal-content": "262753f287410717",
|
||||
"#email-lib-settings-btn": "9ae15bf04b6a22ce",
|
||||
"#email-unread-dot.sidebar-notif-dot": "c80f1e03592983b6",
|
||||
"#export-compact-btn": "7d0325a38e51134b",
|
||||
@@ -686,44 +686,44 @@
|
||||
},
|
||||
"variants": {
|
||||
"app-shell": {
|
||||
"desktop-dark-comfortable": "65b23a35b5a34126",
|
||||
"desktop-dark-compact": "1bac3a0aa4467555",
|
||||
"desktop-dark-spacious": "944583b1854fecbb",
|
||||
"desktop-light-comfortable": "d279432f8c120b58",
|
||||
"desktop-light-compact": "aa768c34ba3c7abc",
|
||||
"desktop-light-spacious": "b74f53e95bfa1d82",
|
||||
"laptop-dark-comfortable": "d98d16f3d60ed275",
|
||||
"laptop-dark-compact": "c3eaf3c4278a0120",
|
||||
"laptop-dark-spacious": "a085fe0f7d9eb66c",
|
||||
"laptop-light-comfortable": "0d7c64f324bebe87",
|
||||
"laptop-light-compact": "da50bf03b0f1be58",
|
||||
"laptop-light-spacious": "a6789fd05a66bf5a",
|
||||
"phone-dark-comfortable": "21b94f88da549eb5",
|
||||
"phone-dark-compact": "b9aa7def2b85414e",
|
||||
"phone-dark-spacious": "21b94f88da549eb5",
|
||||
"phone-light-comfortable": "4a0cf45db775ea7d",
|
||||
"phone-light-compact": "f7fbcb94d99c8730",
|
||||
"phone-light-spacious": "4a0cf45db775ea7d",
|
||||
"tablet-dark-comfortable": "e18256ce58398f11",
|
||||
"tablet-dark-compact": "3cf3e52a10313d44",
|
||||
"tablet-dark-spacious": "e18256ce58398f11",
|
||||
"tablet-light-comfortable": "c2a45155c391f26c",
|
||||
"tablet-light-compact": "9fa7dc03dd02b723",
|
||||
"tablet-light-spacious": "c2a45155c391f26c"
|
||||
"desktop-dark-comfortable": "0c9123117135e362",
|
||||
"desktop-dark-compact": "c694f5d5118ac0e2",
|
||||
"desktop-dark-spacious": "90a67613e8cfdf5d",
|
||||
"desktop-light-comfortable": "17727a9945ccc77b",
|
||||
"desktop-light-compact": "2a887c30d231e357",
|
||||
"desktop-light-spacious": "94b4666734b87625",
|
||||
"laptop-dark-comfortable": "26d334b139b653f2",
|
||||
"laptop-dark-compact": "fea30bad6824357d",
|
||||
"laptop-dark-spacious": "4fdb28deaaaec870",
|
||||
"laptop-light-comfortable": "c7fc986a7e70aac8",
|
||||
"laptop-light-compact": "147645431229ddff",
|
||||
"laptop-light-spacious": "d513c285d30c8289",
|
||||
"phone-dark-comfortable": "551761344bec8d09",
|
||||
"phone-dark-compact": "293d66518c3ec994",
|
||||
"phone-dark-spacious": "551761344bec8d09",
|
||||
"phone-light-comfortable": "cb41acd3f9af3578",
|
||||
"phone-light-compact": "dbef6e9e373e9c10",
|
||||
"phone-light-spacious": "cb41acd3f9af3578",
|
||||
"tablet-dark-comfortable": "0f682621b4ead393",
|
||||
"tablet-dark-compact": "e6f3fece5e716a2c",
|
||||
"tablet-dark-spacious": "0f682621b4ead393",
|
||||
"tablet-light-comfortable": "6e398df9ca69c58b",
|
||||
"tablet-light-compact": "dca75981c8675bfa",
|
||||
"tablet-light-spacious": "6e398df9ca69c58b"
|
||||
},
|
||||
"bench": {
|
||||
"desktop-dark-comfortable": "f9fa9d54db5ad45a",
|
||||
"desktop-dark-compact": "7603a804cb2e23a2",
|
||||
"desktop-dark-spacious": "b0c69306349fc078",
|
||||
"desktop-light-comfortable": "d3e98c4c1e90686f",
|
||||
"desktop-light-compact": "a04ae70088bc8b35",
|
||||
"desktop-light-spacious": "4aa01a125f541429",
|
||||
"laptop-dark-comfortable": "025e60ebe84e0f98",
|
||||
"laptop-dark-compact": "53d6555bb7a6b5bb",
|
||||
"laptop-dark-spacious": "c124d9ce1633d027",
|
||||
"laptop-light-comfortable": "e8328c45f5127bb0",
|
||||
"laptop-light-compact": "dc2da3534534c888",
|
||||
"laptop-light-spacious": "abe5483724c987e0",
|
||||
"desktop-dark-comfortable": "a9709cb47eec7544",
|
||||
"desktop-dark-compact": "bfab96cb72fd6faf",
|
||||
"desktop-dark-spacious": "25469178b443428a",
|
||||
"desktop-light-comfortable": "2166794d643fae4c",
|
||||
"desktop-light-compact": "81d8ab64407b1d9a",
|
||||
"desktop-light-spacious": "d6d19f33476665af",
|
||||
"laptop-dark-comfortable": "57169e64e1fb5324",
|
||||
"laptop-dark-compact": "6ac903cd8a711bcf",
|
||||
"laptop-dark-spacious": "a47535332e9c5de7",
|
||||
"laptop-light-comfortable": "1e2b381de3556fac",
|
||||
"laptop-light-compact": "6dbfb465121949c1",
|
||||
"laptop-light-spacious": "1b8bfcd4bc4ed214",
|
||||
"phone-dark-comfortable": "67ca3c78e6b9495a",
|
||||
"phone-dark-compact": "ad6f3a9f5c8d81cd",
|
||||
"phone-dark-spacious": "1e6f66e56519ba5d",
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { chromium } from 'playwright';
|
||||
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
try {
|
||||
for (const width of [390, 900]) {
|
||||
const page = await browser.newPage({ viewport: { width, height: 844 }, acceptDownloads: true });
|
||||
await page.goto('http://127.0.0.1:7011/static/js/documentStats.js');
|
||||
await page.setContent('<link rel="stylesheet" href="/static/css/00-tokens.css"><div id="toast"></div><div id="chat-container"></div><div id="sidebar"></div>');
|
||||
await page.evaluate(async () => {
|
||||
const mod = await import('/static/js/document.js?mobile-export-regression=1');
|
||||
mod.init('/api');
|
||||
mod.injectFreshDoc({ id: 'export-test', title: 'Export test', language: 'markdown', version_count: 1, current_content: '# Mobile export\n\nActual document content.' });
|
||||
});
|
||||
await page.locator('#doc-footer-export-btn').waitFor({ state: 'visible' });
|
||||
await page.evaluate(() => {
|
||||
document.querySelector('.doc-editor-pane').style.setProperty('z-index', '12000', 'important');
|
||||
});
|
||||
await page.locator('#doc-footer-export-btn').click();
|
||||
const menu = page.locator('#doc-export-menu');
|
||||
await menu.waitFor({ state: 'visible' });
|
||||
const bounds = await menu.boundingBox();
|
||||
assert(bounds.x >= 0 && bounds.y >= 0);
|
||||
assert(bounds.x + bounds.width <= width && bounds.y + bounds.height <= 844);
|
||||
assert(await menu.evaluate(el => Number(getComputedStyle(el).zIndex) > 12000));
|
||||
await page.screenshot({ path: `/tmp/document-export-${width}.png` });
|
||||
// A second click closes the menu, then reopening must still allow export.
|
||||
await page.locator('#doc-footer-export-btn').click();
|
||||
assert.equal(await menu.count(), 0);
|
||||
await page.locator('#doc-footer-export-btn').click();
|
||||
const pending = page.waitForEvent('download');
|
||||
await page.locator('#doc-export-menu button').filter({ hasText: /^Export Markdown$/ }).click();
|
||||
const download = await pending;
|
||||
assert(download.suggestedFilename().endsWith('.md'));
|
||||
assert.equal(await download.failure(), null);
|
||||
const stream = await download.createReadStream();
|
||||
let content = '';
|
||||
for await (const chunk of stream) content += chunk;
|
||||
assert.equal(content, '# Mobile export\n\nActual document content.');
|
||||
await page.close();
|
||||
console.log(`PASS ${width}px: visible menu, toggle, download contents`);
|
||||
}
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
Vendored
+17
@@ -0,0 +1,17 @@
|
||||
# CA trust regression fixtures
|
||||
|
||||
The public certificates embedded in `tests/test_ca_trust.py` exercise the October 2026 eMudhra trust-store check. They contain no private keys. `emsign-g3.pem` and `aaa.pem` are the incident's affected trust anchors; `emsign-g1.pem` is an unaffected emSign anchor that must remain usable. `localhost-leaf.pem` is a synthetic self-signed Ed25519 server certificate with `CA:FALSE`, generated solely to reproduce hashed non-CA certificates found in Ubuntu's OpenSSL directory; its private key was discarded. The test creates temporary PEM files from these public constants. The certificate fingerprints are the factual identity of each fixture.
|
||||
|
||||
Sources: the [incident root list](https://emudhra.we-have-a-problem.com/roots.json) and the public Mozilla root certificates distributed in Debian's `ca-certificates` package.
|
||||
|
||||
`incident.json` contains the four published roots, sixteen [compromised intermediate certificates and cross-signs](https://emudhra.we-have-a-problem.com/impacted-intermediates.json), and the [fourteen published server chains](https://emudhra.we-have-a-problem.com/chains.zip), retrieved on 2026-10-21 with chains refreshed on 2026-10-23. The refresh adds the two DV G3B hosts without Certificate Transparency entries and preserves the original twelve chains. Certificates are deduplicated by SHA-256 of DER; each chain lists its leaf first. The additional cross-signed `emSign Root TLS CA - G3` issuer comes from those chains and has fingerprint `3094025792039a68af6e553686410bdab7c0d4a36b9084cba64202e35d6ebbdd`. Like the intermediates, it must not be accepted as a custom trust anchor. All data is public certificate material, with no private keys.
|
||||
|
||||
Downloaded source SHA-256 values:
|
||||
|
||||
- `roots.json`: `119f32cd81a6b5a4e3c4c2df5fe07daa90dc43cafa123a62f0394f1895d92380`
|
||||
- `impacted-intermediates.json`: `f67b3abe9b4879f4e91874ed041beccd71257f1db41c588c724ccf12aa90c59e`
|
||||
- `chains.zip`: `7b949956955fc501a30f4d98d2f513d34c0bbbe92e29a968194c96e097ce2345`
|
||||
|
||||
The offline OpenSSL regression verifies server purpose, hostname and strict certificate rules with partial-chain trust, at `2026-10-23T12:00:00Z` (`1792756800`). Fixing the verification date keeps later certificate expiry from masking a broken mitigation. All fourteen chains must verify against the affected positive-control roots and fail against the unaffected G1-only store. Adding the compromised DV G3B intermediate restores four paths, including the two new hosts, which the checker must reject. The regression makes no network requests and skips chain verification if the OpenSSL executable is unavailable; certificate-inventory tests still run using Python's standard library.
|
||||
|
||||
The upstream SearXNG image validation uses the same `incident.json` inventory and fixed verification timestamp, checking each stored DER fingerprint before use. See [the image documentation](../../../docs/searxng-trust-image.md) for its validation targets and source provenance.
|
||||
+132
@@ -0,0 +1,132 @@
|
||||
{
|
||||
"verification_time": 1792756800,
|
||||
"roots": {
|
||||
"7dd78d5f4f13459a83dff9abbba62edbaf6f2d102bf257fd712f4d9f2746ed8d": "emSign Root TLS CA - G3",
|
||||
"f2edaaff3d9b701fb4d14c9f285f8c0bad90fc3bcc1fa07d47f4718edaba34a5": "emSign Root CS CA - G3",
|
||||
"d7a7a0fb5d7e2731d771e9484ebcdef71d5f0c3e0a2948782bc83ee0ea699ef4": "AAA Certificate Services",
|
||||
"86a1ecba089c4a8d3bbe2734c612ba341d813e043cf9e8a862cd5c57a36bbe6b": "emSign ECC Root CA - G3"
|
||||
},
|
||||
"intermediates": {
|
||||
"42c4accd12c4f59597925e1a08a7a90102608543809308d50ec427e118ba1493": "EM DV TLS CA - G3B (eMudhra)",
|
||||
"45a883f349aedfd5f3a649b5498ffb121712ba8b448f3f2a91dbc5a766fcddbe": "EM DV TLS CA - G3B (Sectigo)",
|
||||
"98b896625c0c230adeec3eb2cf6aad5001ecf9156a68644e82374ce5833bc25b": "EM OV TLS CA - G3B (eMudhra)",
|
||||
"5f1ded4dd590af74631290cf073b122a2aac6a1fa479778a0bd43579bd832699": "EM OV TLS CA - G3B (Sectigo)",
|
||||
"e7568fe6e12331f4fd7de61da4c7559d0f8eb192a218d4741a3ba75184333838": "EM EV TLS CA - G3B (eMudhra)",
|
||||
"579480aea166290bd3c643589e795fc0d0e54e86505d180f1dff99efd3199512": "EM EV TLS CA - G3B (Sectigo)",
|
||||
"0164d7ceae5bb909c272af151ff44b1177ae9b47d9424b94f1d9558c30934d19": "EM DV TLS CA - G3C (eMudhra)",
|
||||
"62b7f9868263f72f6911fc78ceaf72c1979158b7368bf648683bb2aa39b02e72": "EM DV TLS CA - G3C (Sectigo)",
|
||||
"cb6e15165ac802e9b3f993f16c25d41f4e81c21ebde71ae1b012ac00bf9c7c3f": "EM OV TLS CA - G3C (eMudhra)",
|
||||
"7485b51c0ed192878fa1d2f948fb529ae1842cc5086c4b67f7be5b2039de9489": "EM OV TLS CA - G3C (Sectigo)",
|
||||
"432ec25f0d28de9006d97bf752f17bbb2cd41f669933fcb072d9a1148d0261f3": "EM EV TLS CA - G3C (eMudhra)",
|
||||
"fd9d5c2dbbd4995ab69b8d50c439d9527b00529199c2fd01b2174eb58f07021f": "EM EV TLS CA - G3C (Sectigo)",
|
||||
"9e39de2881273acc225a149c72ba3e0f51424963669ab633ee1dc7df5db067b4": "EM OV CS CA - G3B (eMudhra)",
|
||||
"a496e23ac0cab8155fc9b117b97eae368efcbb9041aec8804d5a308d2d01c2d8": "EM OV CS CA - G3B (Sectigo)",
|
||||
"4b2460a466f95fd40113bcdcca2ceb29b3b093921de45474da7df48874c630b1": "EM OV CS CA - G3C (eMudhra)",
|
||||
"84112972b8e59d14560ae7e7119654474aad119990cf3722bfc1e3b91615e1fd": "EM OV CS CA - G3C (Sectigo)"
|
||||
},
|
||||
"certificates": {
|
||||
"0164d7ceae5bb909c272af151ff44b1177ae9b47d9424b94f1d9558c30934d19": "-----BEGIN CERTIFICATE-----\nMIIDaDCCAu6gAwIBAgIPWIsuPCu4zaE+GhIJJUMhMAoGCCqGSM49BAMDMFYxCzAJ\nBgNVBAYTAklOMSUwIwYDVQQKDBxlTXVkaHJhIFRlY2hub2xvZ2llcyBMaW1pdGVk\nMSAwHgYDVQQDDBdlbVNpZ24gUm9vdCBUTFMgQ0EgLSBHMzAeFw0yNTA0MzAxNTM1\nMDlaFw00MDA0MjgxODMwMDBaMEAxCzAJBgNVBAYTAlVTMRQwEgYDVQQKDAtlTXVk\naHJhIEluYzEbMBkGA1UEAwwSRU0gRFYgVExTIENBIC0gRzNDMHYwEAYHKoZIzj0C\nAQYFK4EEACIDYgAEOnp4YsjETTJN3WkDOmeCwO/u/kBV5ooSP6A3G6O9kZRRTuFy\npDPjYw8CvRiAN9nlFJ+bd9uUooHfmHsv6oiLdL3+j9pMEJnubKGVZv4+8OX2sJpV\nbMyLpQVh3xsqRB0Bo4IBljCCAZIwHwYDVR0jBBgwFoAU4TqrIXzx401UrtRyt/2L\nWUIgkFQwHQYDVR0OBBYEFK5+EFw8KZbBqEcnBVnx4D3U5QrzMB0GA1UdJQQWMBQG\nCCsGAQUFBwMBBggrBgEFBQcDAjBdBgNVHSAEVjBUMDkGCysGAQQBg44hAQABMCow\nKAYIKwYBBQUHAgEWHGh0dHA6Ly9yZXBvc2l0b3J5LmVtc2lnbi5jb20wDQYLKwYB\nBAGDjiEBAmQwCAYGZ4EMAQIBMHUGCCsGAQUFBwEBBGkwZzAkBggrBgEFBQcwAYYY\naHR0cDovL29jc3AtYy5lbXNpZ24uY29tMD8GCCsGAQUFBzAChjNodHRwOi8vcmVw\nb3NpdG9yeS5lbXNpZ24uY29tL2NlcnRzL0VNRFZUTFNDQUczQy5wN2MwNwYDVR0f\nBDAwLjAsoCqgKIYmaHR0cDovL2NybC5lbXNpZ24uY29tLz9yb290dGxzY2FnMy5j\ncmwwDgYDVR0PAQH/BAQDAgEGMBIGA1UdEwEB/wQIMAYBAf8CAQAwCgYIKoZIzj0E\nAwMDaAAwZQIxAMEN2HWMocbuWdy1d33pH6JpMvP/V/DIS5PtZbv1Q7TXEbxrcyxg\nyuddOJscYDJJlAIwB4sH1BpbScDzkdD4tJBSwrG/1Mx82vb8YaySehNuu1+1oXNd\nAEKwaJ0mVzScy2+6\n-----END CERTIFICATE-----\n",
|
||||
"0cbfe22b824385823fd3bf4be4d131e8901342933a7feef1cf8b3110b8e44a70": "-----BEGIN CERTIFICATE-----\nMIICQTCCAcigAwIBAgIUG01Z/jOotSvB49K4K27kFjpdnWMwCgYIKoZIzj0EAwIw\nPzELMAkGA1UEBhMCTkwxEzARBgNVBAoMCmVNdWRocmEgQlYxGzAZBgNVBAMMEkVN\nIERWIFRMUyBDQSAtIEczQjAeFw0yNjEwMjIxMjU3MzFaFw0yNzA1MDUxMzAyMzFa\nMDQxMjAwBgNVBAMMKWR2LWczYi1ub2N0LWVtdWRocmEud2UtaGF2ZS1hLXByb2Js\nZW0uY29tMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEI805yWr+RxanILifET+/\nGwHsXo7dp8ghA3OgwR0Mg11Bl6iYyOzldy4iU7kM+3qfDNsUrzxvkUkJZQpYieuC\n+aOBrDCBqTAMBgNVHRMBAf8EAjAAMA4GA1UdDwEB/wQEAwIHgDATBgNVHSUEDDAK\nBggrBgEFBQcDATA0BgNVHREELTArgilkdi1nM2Itbm9jdC1lbXVkaHJhLndlLWhh\ndmUtYS1wcm9ibGVtLmNvbTAdBgNVHQ4EFgQUOlRrLQGIczrmr4H+Oak06aUmJ4Yw\nHwYDVR0jBBgwFoAU4h4v5xwZOQ5VS2LBICRXNSSKJRQwCgYIKoZIzj0EAwIDZwAw\nZAIwf4ekmRgXAxXP2AjpVY2f1bO42GC7Nnc80t4OpaWJeVdXpT+X3dOsLRwlIUm0\nYbMwAjBihTx7oHfaYB3KRnFuhKqlHYmUcOe9Hjxqs6bubZohhrqMQzPocyasDhJL\nUx3HsgQ=\n-----END CERTIFICATE-----\n",
|
||||
"21f57f83631a93c98e867050075746b3361e0433c9e0e969462852476784f6f0": "-----BEGIN CERTIFICATE-----\nMIICPzCCAcagAwIBAgIUH0gWfBRnOiSjcduti/cukjDGAjIwCgYIKoZIzj0EAwIw\nPzELMAkGA1UEBhMCTkwxEzARBgNVBAoMCmVNdWRocmEgQlYxGzAZBgNVBAMMEkVN\nIE9WIFRMUyBDQSAtIEczQjAeFw0yNjEwMjAwMjIyNDJaFw0yNzA1MDMwMjI3NDJa\nMDMxMTAvBgNVBAMMKG92LWczYi1hYWEtZW11ZGhyYS53ZS1oYXZlLWEtcHJvYmxl\nbS5jb20wWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAAQ3EfjVE+6fUm9gr7UoUv6N\nYPttIdILR9OMZnAdTj+RWT1Z4fp7dpC5mat1gbIo4J6EDelcOQQz5/oGeB3gFJKw\no4GrMIGoMAwGA1UdEwEB/wQCMAAwDgYDVR0PAQH/BAQDAgeAMBMGA1UdJQQMMAoG\nCCsGAQUFBwMBMDMGA1UdEQQsMCqCKG92LWczYi1hYWEtZW11ZGhyYS53ZS1oYXZl\nLWEtcHJvYmxlbS5jb20wHQYDVR0OBBYEFKzQPp6gTBOWb0TBxZbkL5zsQn3oMB8G\nA1UdIwQYMBaAFJvYZVGv5+csN60I7mkZ2b83hK4KMAoGCCqGSM49BAMCA2cAMGQC\nMARKrRRBEWY2Ix1d80DGUvC7bWENP1Q9pf6BOGF4+84UiwV5+nAuht/+hdpAanWB\nsQIwJF2dW8tpPN6lTm1kDerzG6yJAJOz4drotaTthMF2+efuv5W2FL7Dr8qZSd1z\n201o\n-----END CERTIFICATE-----\n",
|
||||
"3094025792039a68af6e553686410bdab7c0d4a36b9084cba64202e35d6ebbdd": "-----BEGIN CERTIFICATE-----\nMIIDdDCCAvmgAwIBAgIQAJYaPvsTWVdu0EAFtGhKWzAKBggqhkjOPQQDAzBrMQsw\nCQYDVQQGEwJJTjETMBEGA1UECxMKZW1TaWduIFBLSTElMCMGA1UEChMcZU11ZGhy\nYSBUZWNobm9sb2dpZXMgTGltaXRlZDEgMB4GA1UEAxMXZW1TaWduIEVDQyBSb290\nIENBIC0gRzMwHhcNMjQwNzExMTU0MzM2WhcNMzkwNzA5MTgzMDAwWjBWMQswCQYD\nVQQGEwJJTjElMCMGA1UECgwcZU11ZGhyYSBUZWNobm9sb2dpZXMgTGltaXRlZDEg\nMB4GA1UEAwwXZW1TaWduIFJvb3QgVExTIENBIC0gRzMwdjAQBgcqhkjOPQIBBgUr\ngQQAIgNiAARfD9w/q1cWxL6D9bMGH6CQ4u+gAV6GN9s+PwQx9Bl4hC+0iU8QOk8m\nWO/RwXQy10DCSxT8vSIJClHKp+Ao4qJKdYeV0SwZ7LMQziyLfxIUOY6Grja/sQFw\nLKYf8EdDXAyjggF1MIIBcTAfBgNVHSMEGDAWgBR8XQKEE9TMipuBzhccLikenEhj\nQjAdBgNVHQ4EFgQU4TqrIXzx401UrtRyt/2LWUIgkFQwHQYDVR0lBBYwFAYIKwYB\nBQUHAwEGCCsGAQUFBwMCMD0GA1UdIAQ2MDQwMgYEVR0gADAqMCgGCCsGAQUFBwIB\nFhxodHRwOi8vcmVwb3NpdG9yeS5lbXNpZ24uY29tMHgGCCsGAQUFBwEBBGwwajAi\nBggrBgEFBQcwAYYWaHR0cDovL29jc3AuZW1zaWduLmNvbTBEBggrBgEFBQcwAoY4\naHR0cDovL3JlcG9zaXRvcnkuZW1zaWduLmNvbS9jZXJ0cy9lbVNpZ25FQ0NSb290\nQ0FHMy5jcnQwMwYDVR0fBCwwKjAooCagJIYiaHR0cDovL2NybC5lbXNpZ24uY29t\nP1Jvb3RDQUczLmNybDAOBgNVHQ8BAf8EBAMCAQYwEgYDVR0TAQH/BAgwBgEB/wIB\nATAKBggqhkjOPQQDAwNpADBmAjEAt9CIpRGp46QHQYDUBnLKw+vvK0/h9vmilEAg\nTroUl/anH4x2rXHqiCtBTBfgKDegAjEA8WpG1LTZdAIL0FSYqxXeREaeTSxEofM0\n1jJH3jGvCXC79LU9UPJ2o3TU+UIj2gMV\n-----END CERTIFICATE-----\n",
|
||||
"346cdd962e97189b59aee5526d1ae8fa714bb1a64094dfa67bef768ee93f36b5": "-----BEGIN CERTIFICATE-----\nMIICODCCAb+gAwIBAgIUbWvtwSNK/Qro223vAqyz323+VBAwCgYIKoZIzj0EAwIw\nQDELMAkGA1UEBhMCVVMxFDASBgNVBAoMC2VNdWRocmEgSW5jMRswGQYDVQQDDBJF\nTSBEViBUTFMgQ0EgLSBHM0MwHhcNMjYxMDIwMDA1OTM2WhcNMjcwNTAzMDEwNDM2\nWjAvMS0wKwYDVQQDDCRkdi1nM2MtZW11ZGhyYS53ZS1oYXZlLWEtcHJvYmxlbS5j\nb20wWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAAScdBQpNUOOCcag6YEVFccNRSFe\nUIl02iH53vDfoWPJFS005WzqF3CPDclB/kygP2gWF4pg/r5gPYdnI3/6Vdzro4Gn\nMIGkMAwGA1UdEwEB/wQCMAAwDgYDVR0PAQH/BAQDAgeAMBMGA1UdJQQMMAoGCCsG\nAQUFBwMBMC8GA1UdEQQoMCaCJGR2LWczYy1lbXVkaHJhLndlLWhhdmUtYS1wcm9i\nbGVtLmNvbTAdBgNVHQ4EFgQUBj2yGO/TzDOE/u/6QeUvcW8y1iMwHwYDVR0jBBgw\nFoAUrn4QXDwplsGoRycFWfHgPdTlCvMwCgYIKoZIzj0EAwIDZwAwZAIwBQOOQqG9\nccdzM/7XIlXLtota1esALaLmgEN/ZweosBIvk4xabHcERZInWVIwO45NAjBos8Ue\nNwDe1X3ahdK2lkxqBEoBMtUHJN18TnJKs2nK1gmXpMbmY5oKnAQQgCEZPA4=\n-----END CERTIFICATE-----\n",
|
||||
"42c4accd12c4f59597925e1a08a7a90102608543809308d50ec427e118ba1493": "-----BEGIN CERTIFICATE-----\nMIIDaDCCAu6gAwIBAgIQAKSu5njyM2K0wORgt3LyFDAKBggqhkjOPQQDAzBWMQsw\nCQYDVQQGEwJJTjElMCMGA1UECgwcZU11ZGhyYSBUZWNobm9sb2dpZXMgTGltaXRl\nZDEgMB4GA1UEAwwXZW1TaWduIFJvb3QgVExTIENBIC0gRzMwHhcNMjUwNDMwMTU1\nNDA4WhcNNDAwNDI4MTgzMDAwWjA/MQswCQYDVQQGEwJOTDETMBEGA1UECgwKZU11\nZGhyYSBCVjEbMBkGA1UEAwwSRU0gRFYgVExTIENBIC0gRzNCMHYwEAYHKoZIzj0C\nAQYFK4EEACIDYgAE557ZwEqXc8RzXlERHahawr1eZKs3S4DhLLwgOOweTw47gS/J\nhZ4eZ6+wTi+LyX90hbplqpEe6KTkW2LVzAPVa+Z4s8OMtNyTfKJGDiOEU8iSk10p\n1E+R4VYGtjzO2rRoo4IBljCCAZIwHwYDVR0jBBgwFoAU4TqrIXzx401UrtRyt/2L\nWUIgkFQwHQYDVR0OBBYEFOIeL+ccGTkOVUtiwSAkVzUkiiUUMB0GA1UdJQQWMBQG\nCCsGAQUFBwMBBggrBgEFBQcDAjBdBgNVHSAEVjBUMDkGCysGAQQBg44hAQABMCow\nKAYIKwYBBQUHAgEWHGh0dHA6Ly9yZXBvc2l0b3J5LmVtc2lnbi5jb20wDQYLKwYB\nBAGDjiEBAmQwCAYGZ4EMAQIBMHUGCCsGAQUFBwEBBGkwZzAkBggrBgEFBQcwAYYY\naHR0cDovL29jc3AtYi5lbXNpZ24uY29tMD8GCCsGAQUFBzAChjNodHRwOi8vcmVw\nb3NpdG9yeS5lbXNpZ24uY29tL2NlcnRzL0VNRFZUTFNDQUczQi5wN2MwNwYDVR0f\nBDAwLjAsoCqgKIYmaHR0cDovL2NybC5lbXNpZ24uY29tLz9yb290dGxzY2FnMy5j\ncmwwDgYDVR0PAQH/BAQDAgEGMBIGA1UdEwEB/wQIMAYBAf8CAQAwCgYIKoZIzj0E\nAwMDaAAwZQIwLmsGXi6oFMJ2klkcEMXMug6gvxQWZWkewiDWVzQyl2L6z6DuQfPO\nIbh1grvdULDCAjEAxZvLlxUavG69J6wnSjC8XBbqNIQsJ6CE2QdNOe84Ptxb6VG8\ngt2TlfJbHii+KNvK\n-----END CERTIFICATE-----\n",
|
||||
"4314941cf242a4ea4149aa7e9cdf77a15de2771a79984b809fc04443fea013c8": "-----BEGIN CERTIFICATE-----\nMIICOTCCAb+gAwIBAgIUXcJ4+VeNEICJ9JbBANtIyfP96EowCgYIKoZIzj0EAwIw\nQDELMAkGA1UEBhMCVVMxFDASBgNVBAoMC2VNdWRocmEgSW5jMRswGQYDVQQDDBJF\nTSBPViBUTFMgQ0EgLSBHM0MwHhcNMjYxMDIwMDA1OTM3WhcNMjcwNTAzMDEwNDM3\nWjAvMS0wKwYDVQQDDCRvdi1nM2MtZW11ZGhyYS53ZS1oYXZlLWEtcHJvYmxlbS5j\nb20wWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAATLEvRHdoPc0Z9ovgqlYmoKTHPU\nApwd6lAp0ou1G63SzuESyBzIW6Tl64CsKzlV9ggkZjX+L9/tOx97/1wmSTYvo4Gn\nMIGkMAwGA1UdEwEB/wQCMAAwDgYDVR0PAQH/BAQDAgeAMBMGA1UdJQQMMAoGCCsG\nAQUFBwMBMC8GA1UdEQQoMCaCJG92LWczYy1lbXVkaHJhLndlLWhhdmUtYS1wcm9i\nbGVtLmNvbTAdBgNVHQ4EFgQUrytBb+px+b58Df+d5vP8GpTW+/wwHwYDVR0jBBgw\nFoAUNOrqDOvJCtr50MXi7Zt4yYeeG2wwCgYIKoZIzj0EAwIDaAAwZQIwNdoOUOKz\nMLV1OFEoMjBeUqvkQAwsajvVWu0A23qyyvYlosyMp8aFv0gxURW6Yj6DAjEAhe0z\nkOQmmqb0eyhjh7weF4fHtwMtLi4jNFA6M/EfPTfkqE91MN9fSin5uhua5xhk\n-----END CERTIFICATE-----\n",
|
||||
"432ec25f0d28de9006d97bf752f17bbb2cd41f669933fcb072d9a1148d0261f3": "-----BEGIN CERTIFICATE-----\nMIIDaDCCAu6gAwIBAgIQAIAJkopkEHYBrwX6LxnxCTAKBggqhkjOPQQDAzBWMQsw\nCQYDVQQGEwJJTjElMCMGA1UECgwcZU11ZGhyYSBUZWNobm9sb2dpZXMgTGltaXRl\nZDEgMB4GA1UEAwwXZW1TaWduIFJvb3QgVExTIENBIC0gRzMwHhcNMjUwNDMwMTUz\nOTMxWhcNNDAwNDI4MTgzMDAwWjBAMQswCQYDVQQGEwJVUzEUMBIGA1UECgwLZU11\nZGhyYSBJbmMxGzAZBgNVBAMMEkVNIEVWIFRMUyBDQSAtIEczQzB2MBAGByqGSM49\nAgEGBSuBBAAiA2IABC1W6Cmm+4LGSkaX2WCvlR6QZ66R+SbTQwbaSKiq63hptqZ+\nbSdCbgRCqgOzbhmI3/Y2UU7hzwf1uks/8qBmRa59MdWGatMao9JG6wsxd+6Zy3Vm\nxRbOdICKZo+R8MR/yqOCAZUwggGRMB8GA1UdIwQYMBaAFOE6qyF88eNNVK7Ucrf9\ni1lCIJBUMB0GA1UdDgQWBBSmJV6AqU2385Z8G8eR35K87kvrXzAdBgNVHSUEFjAU\nBggrBgEFBQcDAQYIKwYBBQUHAwIwXAYDVR0gBFUwUzA5BgsrBgEEAYOOIQEAATAq\nMCgGCCsGAQUFBwIBFhxodHRwOi8vcmVwb3NpdG9yeS5lbXNpZ24uY29tMA0GCysG\nAQQBg44hAQJ4MAcGBWeBDAEBMHUGCCsGAQUFBwEBBGkwZzAkBggrBgEFBQcwAYYY\naHR0cDovL29jc3AtYy5lbXNpZ24uY29tMD8GCCsGAQUFBzAChjNodHRwOi8vcmVw\nb3NpdG9yeS5lbXNpZ24uY29tL2NlcnRzL0VNRVZUTFNDQUczQy5wN2MwNwYDVR0f\nBDAwLjAsoCqgKIYmaHR0cDovL2NybC5lbXNpZ24uY29tLz9yb290dGxzY2FnMy5j\ncmwwDgYDVR0PAQH/BAQDAgEGMBIGA1UdEwEB/wQIMAYBAf8CAQAwCgYIKoZIzj0E\nAwMDaAAwZQIwBy1wTnzH4lXDTy2PD91ysLjvnlVN3JbJmc1BIMEU8TOLkYVHibMc\n9HeyonikFSfmAjEA6uE3QRxlrZTz/1/lXPTEMUhoauAtycagtlNFp79kjhvNa2/f\nAal1GNxsOutLnr0Y\n-----END CERTIFICATE-----\n",
|
||||
"45a883f349aedfd5f3a649b5498ffb121712ba8b448f3f2a91dbc5a766fcddbe": "-----BEGIN CERTIFICATE-----\nMIIDzjCCAragAwIBAgIRAKAk/fS6Zlx4XOSgIEg4WLcwDQYJKoZIhvcNAQELBQAw\nezELMAkGA1UEBhMCR0IxGzAZBgNVBAgMEkdyZWF0ZXIgTWFuY2hlc3RlcjEQMA4G\nA1UEBwwHU2FsZm9yZDEaMBgGA1UECgwRQ29tb2RvIENBIExpbWl0ZWQxITAfBgNV\nBAMMGEFBQSBDZXJ0aWZpY2F0ZSBTZXJ2aWNlczAeFw0yNTA1MTQwMDAwMDBaFw0y\nODEyMzEyMzU5NTlaMD8xCzAJBgNVBAYTAk5MMRMwEQYDVQQKDAplTXVkaHJhIEJW\nMRswGQYDVQQDDBJFTSBEViBUTFMgQ0EgLSBHM0IwdjAQBgcqhkjOPQIBBgUrgQQA\nIgNiAATnntnASpdzxHNeUREdqFrCvV5kqzdLgOEsvCA47B5PDjuBL8mFnh5nr7BO\nL4vJf3SFumWqkR7opORbYtXMA9Vr5nizw4y03JN8okYOI4RTyJKTXSnUT5HhVga2\nPM7atGijggE1MIIBMTAfBgNVHSMEGDAWgBSgEQojPpbxB+zirynvgqV/0DCktDAd\nBgNVHQ4EFgQU4h4v5xwZOQ5VS2LBICRXNSSKJRQwDgYDVR0PAQH/BAQDAgEGMBIG\nA1UdEwEB/wQIMAYBAf8CAQAwHQYDVR0lBBYwFAYIKwYBBQUHAwEGCCsGAQUFBwMC\nMDEGA1UdIAQqMCgwCAYGZ4EMAQIBMA0GCysGAQQBg44hAQABMA0GCysGAQQBg44h\nAQJkMEMGA1UdHwQ8MDowOKA2oDSGMmh0dHA6Ly9jcmwuY29tb2RvY2EuY29tL0FB\nQUNlcnRpZmljYXRlU2VydmljZXMuY3JsMDQGCCsGAQUFBwEBBCgwJjAkBggrBgEF\nBQcwAYYYaHR0cDovL29jc3AuY29tb2RvY2EuY29tMA0GCSqGSIb3DQEBCwUAA4IB\nAQBzQ1IoqpdotM6gFrR4Q3YrKifOLeaj5XXunldpVE/vZn2TxC2y0s8oYiLR5ICE\nwqVXSB7N6BM1Mp+Nif7Jbhb2IswAfSR4Iik8+B0iRmi7lYoNlycCgHjmsjOmZaxM\nQkPmjXAJbG5f4V41OsvJZmS7lI3r0IuvEDVX3GAZ13on6IImSs9ybNpHQ7NCqkMQ\n7wH4uAO1dtpFycgJSdHaCOhHjrox2BTYGLdpUrYwY3EOAjlZ8UGgsxs7mG+xquMl\nbN1LFURwfnkVMP3T/DBIZ/ys2+lNFyxK3FlArrV4/000uTnVB1lO8oGORYdXoT5V\nGUyRCEIDhF5TBUuKF4E3kf1h\n-----END CERTIFICATE-----\n",
|
||||
"4b2460a466f95fd40113bcdcca2ceb29b3b093921de45474da7df48874c630b1": "-----BEGIN CERTIFICATE-----\nMIIDWzCCAuGgAwIBAgIPVEt/kiwxriIKSpIpFvbmMAoGCCqGSM49BAMDMFUxCzAJ\nBgNVBAYTAklOMSUwIwYDVQQKDBxlTXVkaHJhIFRlY2hub2xvZ2llcyBMaW1pdGVk\nMR8wHQYDVQQDDBZlbVNpZ24gUm9vdCBDUyBDQSAtIEczMB4XDTI1MDQzMDE2Mzg0\nMFoXDTQwMDQyODE4MzAwMFowPzELMAkGA1UEBhMCVVMxFDASBgNVBAoMC2VNdWRo\ncmEgSW5jMRowGAYDVQQDDBFFTSBPViBDUyBDQSAtIEczQzB2MBAGByqGSM49AgEG\nBSuBBAAiA2IABLnw4GNHAqacpqrdRDz9YsQbm6SyCSRpfXl052kaEm+5avs+NML9\nO8X0gwYfJl3CpYQ2NHMjUQQ2so8E84YjYwZ5gOJQPgJTLWVGI1pxZaJZoe9Jb3Qb\nX97neHII5Lj676OCAYswggGHMB8GA1UdIwQYMBaAFGh0A/iFyo1UUFcdFuDI3kUN\nq80tMB0GA1UdDgQWBBTub3MpjW+/p7Mbw5U09hVWRHhmtzATBgNVHSUEDDAKBggr\nBgEFBQcDAzBeBgNVHSAEVzBVMDkGCysGAQQBg44hAQABMCowKAYIKwYBBQUHAgEW\nHGh0dHA6Ly9yZXBvc2l0b3J5LmVtc2lnbi5jb20wDgYMKwYBBAGDjiEBAoFIMAgG\nBmeBDAEEATB0BggrBgEFBQcBAQRoMGYwJAYIKwYBBQUHMAGGGGh0dHA6Ly9vY3Nw\nLWMuZW1zaWduLmNvbTA+BggrBgEFBQcwAoYyaHR0cDovL3JlcG9zaXRvcnkuZW1z\naWduLmNvbS9jZXJ0cy9FTU9WQ1NDQUczQy5wN2MwNgYDVR0fBC8wLTAroCmgJ4Yl\naHR0cDovL2NybC5lbXNpZ24uY29tLz9yb290Y3NjYWczLmNybDAOBgNVHQ8BAf8E\nBAMCAQYwEgYDVR0TAQH/BAgwBgEB/wIBADAKBggqhkjOPQQDAwNoADBlAjEA8yPg\n8sntOhLwV2QXJ5LEaKSpo/fnE3uOgTadK73fDhyjiqr0Io9GVuQTYqBPbQ2TAjBg\neuApmBS486+iei03lRrp139dPw6k28jjGNvWsAU7A1N86J1zPJVDEyVH+nMAWC0=\n-----END CERTIFICATE-----\n",
|
||||
"5510ecf5764dee8dfe9468ae2d7323da21fcd5530889906540a2f8725c6f74db": "-----BEGIN CERTIFICATE-----\nMIICSTCCAdCgAwIBAgIUO7oRaIOhMWD0jFgvxusAGNi5CI0wCgYIKoZIzj0EAwIw\nPzELMAkGA1UEBhMCTkwxEzARBgNVBAoMCmVNdWRocmEgQlYxGzAZBgNVBAMMEkVN\nIERWIFRMUyBDQSAtIEczQjAeFw0yNjEwMjIxMzE0NDhaFw0yNzA1MDUxMzE5NDha\nMDgxNjA0BgNVBAMMLWR2LWczYi1hYWEtbm9jdC1lbXVkaHJhLndlLWhhdmUtYS1w\ncm9ibGVtLmNvbTBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABJ0yLMf28vw2E6pO\nc74doinU47pwTYD6xZ60nkrGkqI05DxVKXblos+V5S0+bgDjT2qw1F39EW9EUj8b\nNQmmqv6jgbAwga0wDAYDVR0TAQH/BAIwADAOBgNVHQ8BAf8EBAMCB4AwEwYDVR0l\nBAwwCgYIKwYBBQUHAwEwOAYDVR0RBDEwL4ItZHYtZzNiLWFhYS1ub2N0LWVtdWRo\ncmEud2UtaGF2ZS1hLXByb2JsZW0uY29tMB0GA1UdDgQWBBTruPPaZ1PiEFfpqdLo\n8Ra5Ah5a2jAfBgNVHSMEGDAWgBTiHi/nHBk5DlVLYsEgJFc1JIolFDAKBggqhkjO\nPQQDAgNnADBkAjBUTUTymJylZiZRV/RqMayZdUPJKRDIcn9+sUPpG66AjVgx8M+h\nDGzxxgeroOSxzSwCMBM/r1y7g371RI10nDU16T0WEAO/Udqyyuc8mJ2LCQ+Gxv3c\njU2NXGQLsvosv6T0ww==\n-----END CERTIFICATE-----\n",
|
||||
"579480aea166290bd3c643589e795fc0d0e54e86505d180f1dff99efd3199512": "-----BEGIN CERTIFICATE-----\nMIIDzTCCArWgAwIBAgIRANj4aF5PK9nYJAKoWhwetzEwDQYJKoZIhvcNAQELBQAw\nezELMAkGA1UEBhMCR0IxGzAZBgNVBAgMEkdyZWF0ZXIgTWFuY2hlc3RlcjEQMA4G\nA1UEBwwHU2FsZm9yZDEaMBgGA1UECgwRQ29tb2RvIENBIExpbWl0ZWQxITAfBgNV\nBAMMGEFBQSBDZXJ0aWZpY2F0ZSBTZXJ2aWNlczAeFw0yNTA1MTQwMDAwMDBaFw0y\nODEyMzEyMzU5NTlaMD8xCzAJBgNVBAYTAk5MMRMwEQYDVQQKDAplTXVkaHJhIEJW\nMRswGQYDVQQDDBJFTSBFViBUTFMgQ0EgLSBHM0IwdjAQBgcqhkjOPQIBBgUrgQQA\nIgNiAAQl26haZNOX2gsQUunqVG5Ie+/qnJ2daKDF6tz+73Wmm7XW/AH0K/vKbrQL\nzWTAdn42/9Bdl6gTxaoMbJUw55z18IZkaPE4BkIKpchRhAseJSVA55DktlTDhS7W\nSvHoZX2jggE0MIIBMDAfBgNVHSMEGDAWgBSgEQojPpbxB+zirynvgqV/0DCktDAd\nBgNVHQ4EFgQUPtwg2YlGmId7hz/YgUeyXBXYRwYwDgYDVR0PAQH/BAQDAgEGMBIG\nA1UdEwEB/wQIMAYBAf8CAQAwHQYDVR0lBBYwFAYIKwYBBQUHAwEGCCsGAQUFBwMC\nMDAGA1UdIAQpMCcwBwYFZ4EMAQEwDQYLKwYBBAGDjiEBAAEwDQYLKwYBBAGDjiEB\nAngwQwYDVR0fBDwwOjA4oDagNIYyaHR0cDovL2NybC5jb21vZG9jYS5jb20vQUFB\nQ2VydGlmaWNhdGVTZXJ2aWNlcy5jcmwwNAYIKwYBBQUHAQEEKDAmMCQGCCsGAQUF\nBzABhhhodHRwOi8vb2NzcC5jb21vZG9jYS5jb20wDQYJKoZIhvcNAQELBQADggEB\nAJNFuAFW9Gnw/8+kCO4KIlZdnM1Sh2u+8cK0EYAHhXpb5FlGvYkcARigo5oS7VoM\n7FOYlTueV6yICZKNAYawZPJPhNCGK+rp3cQBEz6d0n8OZEYZov9lb3ll8ORgSDuf\nFVIW6Gagg/UnOamLBTOR8eD5V73SCme/B2cHUDw+m5E8cc+0Ujzz3Cn3ex5JrhXk\n2T3s8PoVzEmhTgGboFfcWO1EYhPPrYwdxPYRQIXAXUXhLEOVNL9ASbZJLFI8E1IM\nm7NwxVN2s38jWpaCUaaF0tg7hfiy7oe13rzJfHsdqldJVTTUTPWVx8TC4ju0VLet\nbAlwhwFT6AP8uIVMGUHNw24=\n-----END CERTIFICATE-----\n",
|
||||
"59cf0386b56bc2414a0c06241ea9e25e01a9c93bac7a596648747f9d0ded4906": "-----BEGIN CERTIFICATE-----\nMIICQTCCAcegAwIBAgIULuWnWW6kFV6QZxp2ls8r18NCjXkwCgYIKoZIzj0EAwIw\nQDELMAkGA1UEBhMCVVMxFDASBgNVBAoMC2VNdWRocmEgSW5jMRswGQYDVQQDDBJF\nTSBPViBUTFMgQ0EgLSBHM0MwHhcNMjYxMDIwMDIyMjQyWhcNMjcwNTAzMDIyNzQy\nWjAzMTEwLwYDVQQDDChvdi1nM2MtYWFhLWVtdWRocmEud2UtaGF2ZS1hLXByb2Js\nZW0uY29tMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEb7+5g2YHNYwH2eQERrZc\nTmVuf/RmvzsGGMSwXmZke6vn35Ut5HME7UOOU3Muy+BSfjfpo6fjL5KOs3Ndz67d\nOKOBqzCBqDAMBgNVHRMBAf8EAjAAMA4GA1UdDwEB/wQEAwIHgDATBgNVHSUEDDAK\nBggrBgEFBQcDATAzBgNVHREELDAqgihvdi1nM2MtYWFhLWVtdWRocmEud2UtaGF2\nZS1hLXByb2JsZW0uY29tMB0GA1UdDgQWBBQ7oBzq3jpTuvntbIkzMV12Ps3O0TAf\nBgNVHSMEGDAWgBQ06uoM68kK2vnQxeLtm3jJh54bbDAKBggqhkjOPQQDAgNoADBl\nAjEA/Y7AZ4M2MYbN5Od9tREaUfFjZKoOjczfIKPjKNMjtGxTdL9WUXvAT4QDgktn\n/IJdAjAbORTkAXD5hggyG6K7p0MBz3lFZJb2l/LndZlrttJWBLP8QGrc44hrZZtK\nIq0d1dE=\n-----END CERTIFICATE-----\n",
|
||||
"5f1ded4dd590af74631290cf073b122a2aac6a1fa479778a0bd43579bd832699": "-----BEGIN CERTIFICATE-----\nMIIDzjCCAragAwIBAgIRAOlCI73pL5FOWrmsq/2sNxAwDQYJKoZIhvcNAQELBQAw\nezELMAkGA1UEBhMCR0IxGzAZBgNVBAgMEkdyZWF0ZXIgTWFuY2hlc3RlcjEQMA4G\nA1UEBwwHU2FsZm9yZDEaMBgGA1UECgwRQ29tb2RvIENBIExpbWl0ZWQxITAfBgNV\nBAMMGEFBQSBDZXJ0aWZpY2F0ZSBTZXJ2aWNlczAeFw0yNTA1MTQwMDAwMDBaFw0y\nODEyMzEyMzU5NTlaMD8xCzAJBgNVBAYTAk5MMRMwEQYDVQQKDAplTXVkaHJhIEJW\nMRswGQYDVQQDDBJFTSBPViBUTFMgQ0EgLSBHM0IwdjAQBgcqhkjOPQIBBgUrgQQA\nIgNiAARziXqg6zz9yosSQC/68KvE4SErr43ud4rJ7i1tuD+sKAxeBWOK56Ks1xwy\nKo42HN659pxDArndjXPRzGyqsswK7k4hbOl53VGKSvS2JuVoTN1lgDftDIVOTMEv\nTPhkbQ2jggE1MIIBMTAfBgNVHSMEGDAWgBSgEQojPpbxB+zirynvgqV/0DCktDAd\nBgNVHQ4EFgQUm9hlUa/n5yw3rQjuaRnZvzeErgowDgYDVR0PAQH/BAQDAgEGMBIG\nA1UdEwEB/wQIMAYBAf8CAQAwHQYDVR0lBBYwFAYIKwYBBQUHAwEGCCsGAQUFBwMC\nMDEGA1UdIAQqMCgwCAYGZ4EMAQICMA0GCysGAQQBg44hAQABMA0GCysGAQQBg44h\nAQJuMEMGA1UdHwQ8MDowOKA2oDSGMmh0dHA6Ly9jcmwuY29tb2RvY2EuY29tL0FB\nQUNlcnRpZmljYXRlU2VydmljZXMuY3JsMDQGCCsGAQUFBwEBBCgwJjAkBggrBgEF\nBQcwAYYYaHR0cDovL29jc3AuY29tb2RvY2EuY29tMA0GCSqGSIb3DQEBCwUAA4IB\nAQALRoOSvkzRa+tN5Rje9W5GiXd/qsicd9N6Gnh0SWC7VyWH+HycIqAnFCMYZznj\nCeGusomFYHE42SHWr85JDIbgzy6y3dO4i9n9w0Bx7yrXWBbDzFS8VgA3+EY4Cg7y\nmtoC3RPOJVtaXXPZ+68X804g1Mj+3vCSHj2lNPVgco0OzSIOJ/KMg1R9MLBkiKzb\nbERAGZHejY0HBhYd76IINZN8z6bBNah2UdFYxsOisLynPQUkol6gRLum024/FzVl\nCCDfnUusrkr+mAfPAvD/Vb8bhpA7jYtepuU+2mIdShj5lLlvAwZphn+ypWycuo2x\n0k9mldz2dqI9nhCiY+TTIOOR\n-----END CERTIFICATE-----\n",
|
||||
"62b7f9868263f72f6911fc78ceaf72c1979158b7368bf648683bb2aa39b02e72": "-----BEGIN CERTIFICATE-----\nMIIDzzCCAregAwIBAgIRAJHzR/Glt9OZYrleDuTimdEwDQYJKoZIhvcNAQELBQAw\nezELMAkGA1UEBhMCR0IxGzAZBgNVBAgMEkdyZWF0ZXIgTWFuY2hlc3RlcjEQMA4G\nA1UEBwwHU2FsZm9yZDEaMBgGA1UECgwRQ29tb2RvIENBIExpbWl0ZWQxITAfBgNV\nBAMMGEFBQSBDZXJ0aWZpY2F0ZSBTZXJ2aWNlczAeFw0yNTA1MTQwMDAwMDBaFw0y\nODEyMzEyMzU5NTlaMEAxCzAJBgNVBAYTAlVTMRQwEgYDVQQKDAtlTXVkaHJhIElu\nYzEbMBkGA1UEAwwSRU0gRFYgVExTIENBIC0gRzNDMHYwEAYHKoZIzj0CAQYFK4EE\nACIDYgAEOnp4YsjETTJN3WkDOmeCwO/u/kBV5ooSP6A3G6O9kZRRTuFypDPjYw8C\nvRiAN9nlFJ+bd9uUooHfmHsv6oiLdL3+j9pMEJnubKGVZv4+8OX2sJpVbMyLpQVh\n3xsqRB0Bo4IBNTCCATEwHwYDVR0jBBgwFoAUoBEKIz6W8Qfs4q8p74Klf9AwpLQw\nHQYDVR0OBBYEFK5+EFw8KZbBqEcnBVnx4D3U5QrzMA4GA1UdDwEB/wQEAwIBBjAS\nBgNVHRMBAf8ECDAGAQH/AgEAMB0GA1UdJQQWMBQGCCsGAQUFBwMBBggrBgEFBQcD\nAjAxBgNVHSAEKjAoMAgGBmeBDAECATANBgsrBgEEAYOOIQEAATANBgsrBgEEAYOO\nIQECZDBDBgNVHR8EPDA6MDigNqA0hjJodHRwOi8vY3JsLmNvbW9kb2NhLmNvbS9B\nQUFDZXJ0aWZpY2F0ZVNlcnZpY2VzLmNybDA0BggrBgEFBQcBAQQoMCYwJAYIKwYB\nBQUHMAGGGGh0dHA6Ly9vY3NwLmNvbW9kb2NhLmNvbTANBgkqhkiG9w0BAQsFAAOC\nAQEAr7b6Mx11TIJgVzHt6EH5qGNWeiiqoMrp8E5/k8s+jvupx1n083pZ5XOzUzND\nwEUe0W4C4FIGUHNQ4XDwqOw0yK42xqm2dvCKgsko2UvDlT0haZVtTCeWMQOe5J/J\nziaMfZwsZNfZ3aaI6XPtWDZtwY8AaNfyaeCpbtm5GcGnyNWk4jSNpbtpo6NjfXuP\nCFOm3rTjJ4zytP7Tso6iMm0L01cYFOlTmoPv4515R2r3SPnKDtijoB/D/vgxzojv\nDafgyG9afX8+laO9NbMMz4sqC1vZx3984i/sBNFiugm3Vvdq5OdJRjuGC1QILIs6\ndg/K3i8VQqRpbfaM9kIlMzZ3dQ==\n-----END CERTIFICATE-----\n",
|
||||
"66374ae64962c437bd815a2aae546c76e30c2eafac8335192c8e517c15a246ed": "-----BEGIN CERTIFICATE-----\nMIICNzCCAb6gAwIBAgIUPfthO0PYGAYKs9lQ4gp1IOkOr2MwCgYIKoZIzj0EAwIw\nPzELMAkGA1UEBhMCTkwxEzARBgNVBAoMCmVNdWRocmEgQlYxGzAZBgNVBAMMEkVN\nIEVWIFRMUyBDQSAtIEczQjAeFw0yNjEwMjAwMDU5MzZaFw0yNzA1MDMwMTA0MzZa\nMC8xLTArBgNVBAMMJGV2LWczYi1lbXVkaHJhLndlLWhhdmUtYS1wcm9ibGVtLmNv\nbTBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABPZPwlA3io/AX/pMwu0dNl9WMxrr\nC9j/xMLHgG72cWVzoX62REmvp9zyXQ04kHP9/sW6KnCcm5ZZ5i4YoJbDPASjgacw\ngaQwDAYDVR0TAQH/BAIwADAOBgNVHQ8BAf8EBAMCB4AwEwYDVR0lBAwwCgYIKwYB\nBQUHAwEwLwYDVR0RBCgwJoIkZXYtZzNiLWVtdWRocmEud2UtaGF2ZS1hLXByb2Js\nZW0uY29tMB0GA1UdDgQWBBSvX4QR4ERW5uNUaMq0yYzXCZf0ZjAfBgNVHSMEGDAW\ngBQ+3CDZiUaYh3uHP9iBR7JcFdhHBjAKBggqhkjOPQQDAgNnADBkAjACy/uJR01M\n/O9IEZyRHX1LSU28bmg542K0IX9jDRTrZXUqkDWfiZIL7xfnrRrBpHoCMFbBIaOa\nWwWtFtDg9ZqQvktsWS950u5qfW1sChgWNya/E3IMKFgtoHm2xrZsmfwLSQ==\n-----END CERTIFICATE-----\n",
|
||||
"7485b51c0ed192878fa1d2f948fb529ae1842cc5086c4b67f7be5b2039de9489": "-----BEGIN CERTIFICATE-----\nMIIDzzCCAregAwIBAgIRAI1aSBGTnvRH13ERzdmEmukwDQYJKoZIhvcNAQELBQAw\nezELMAkGA1UEBhMCR0IxGzAZBgNVBAgMEkdyZWF0ZXIgTWFuY2hlc3RlcjEQMA4G\nA1UEBwwHU2FsZm9yZDEaMBgGA1UECgwRQ29tb2RvIENBIExpbWl0ZWQxITAfBgNV\nBAMMGEFBQSBDZXJ0aWZpY2F0ZSBTZXJ2aWNlczAeFw0yNTA1MTQwMDAwMDBaFw0y\nODEyMzEyMzU5NTlaMEAxCzAJBgNVBAYTAlVTMRQwEgYDVQQKDAtlTXVkaHJhIElu\nYzEbMBkGA1UEAwwSRU0gT1YgVExTIENBIC0gRzNDMHYwEAYHKoZIzj0CAQYFK4EE\nACIDYgAEE4JRzVKskpjByKrZdzId65fnCb0LTKCspV3IrVHc/J0ViaFZfjpRIOHv\n1jHGPhg1ysrimGmmLhYx6KKBgatWYW3EXZGKvAnzqw5jz3kqpNztc4e+N7ulaVSf\nHAKycO1no4IBNTCCATEwHwYDVR0jBBgwFoAUoBEKIz6W8Qfs4q8p74Klf9AwpLQw\nHQYDVR0OBBYEFDTq6gzryQra+dDF4u2beMmHnhtsMA4GA1UdDwEB/wQEAwIBBjAS\nBgNVHRMBAf8ECDAGAQH/AgEAMB0GA1UdJQQWMBQGCCsGAQUFBwMBBggrBgEFBQcD\nAjAxBgNVHSAEKjAoMAgGBmeBDAECAjANBgsrBgEEAYOOIQEAATANBgsrBgEEAYOO\nIQECbjBDBgNVHR8EPDA6MDigNqA0hjJodHRwOi8vY3JsLmNvbW9kb2NhLmNvbS9B\nQUFDZXJ0aWZpY2F0ZVNlcnZpY2VzLmNybDA0BggrBgEFBQcBAQQoMCYwJAYIKwYB\nBQUHMAGGGGh0dHA6Ly9vY3NwLmNvbW9kb2NhLmNvbTANBgkqhkiG9w0BAQsFAAOC\nAQEAHlbhZpjhkPEAQVhPHRtBDaHqiisVD20pQ1TUeSx3l62Qe7NQ0b7vWQy7DN0i\nKOu7BGpP5EnIjvXbz9FhpfPJ4FhKEt3tQ51rqXz2oD8vRaryvExWPobDUz3j7HN6\nOzBsVMIe3PD7AAkpky0x8zMSi/ep5R9OMzybmNptbtppyuB5ytgq+h7lQdRIP+CQ\nTaJX3qTgfMqzntYre5GljmEB5hU2/rSn8zoSU2APkJidlZFpghtgbD4I2VRHZvCw\nFFr/SrFM9pwrgbJNZ1MtyTSVgI5NlNb0aMfLR+/r6jJt3ROeY2yh2QQ2dx+Yqgdb\nuL5ChFWOQU+ErSDnUq7yr+pjBQ==\n-----END CERTIFICATE-----\n",
|
||||
"7dd78d5f4f13459a83dff9abbba62edbaf6f2d102bf257fd712f4d9f2746ed8d": "-----BEGIN CERTIFICATE-----\nMIICKTCCAa+gAwIBAgIQDnYGcvFDRZ/I/gqwvAXjlDAKBggqhkjOPQQDAzBWMQsw\nCQYDVQQGEwJJTjElMCMGA1UECgwcZU11ZGhyYSBUZWNobm9sb2dpZXMgTGltaXRl\nZDEgMB4GA1UEAwwXZW1TaWduIFJvb3QgVExTIENBIC0gRzMwHhcNMjQwMjA4MDgx\nOTMyWhcNNDkwMjA3MDgxOTMyWjBWMQswCQYDVQQGEwJJTjElMCMGA1UECgwcZU11\nZGhyYSBUZWNobm9sb2dpZXMgTGltaXRlZDEgMB4GA1UEAwwXZW1TaWduIFJvb3Qg\nVExTIENBIC0gRzMwdjAQBgcqhkjOPQIBBgUrgQQAIgNiAARfD9w/q1cWxL6D9bMG\nH6CQ4u+gAV6GN9s+PwQx9Bl4hC+0iU8QOk8mWO/RwXQy10DCSxT8vSIJClHKp+Ao\n4qJKdYeV0SwZ7LMQziyLfxIUOY6Grja/sQFwLKYf8EdDXAyjQjBAMB0GA1UdDgQW\nBBThOqshfPHjTVSu1HK3/YtZQiCQVDAOBgNVHQ8BAf8EBAMCAQYwDwYDVR0TAQH/\nBAUwAwEB/zAKBggqhkjOPQQDAwNoADBlAjBhCFTkly7Yl7+s4hiw+k6ZJTXT2AiI\nrvmsmfqPr5E7ecwXb6kXA5LTFd6rjwTuV3sCMQC9w6ZSbVq1mveMO6dKFziOzClT\nKCT2RFT6yXtq6nzgRICgBmiXbJAXsmZ8yAwjyWc=\n-----END CERTIFICATE-----\n",
|
||||
"7e02622ab6d745c1edb4b431bb1c551e667305c3dd3eb0c7cc6dfdb523807424": "-----BEGIN CERTIFICATE-----\nMIICQDCCAcagAwIBAgIUU3mgjls/8ZJ8upmukdlSQR1IF/UwCgYIKoZIzj0EAwIw\nPzELMAkGA1UEBhMCTkwxEzARBgNVBAoMCmVNdWRocmEgQlYxGzAZBgNVBAMMEkVN\nIERWIFRMUyBDQSAtIEczQjAeFw0yNjEwMjAwMjIyNDFaFw0yNzA1MDMwMjI3NDFa\nMDMxMTAvBgNVBAMMKGR2LWczYi1hYWEtZW11ZGhyYS53ZS1oYXZlLWEtcHJvYmxl\nbS5jb20wWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAATK3sfcOxxtM2Vdq9Qbw/GR\nNsHlJcnqINEInXO3fkWhw/FYT80hkqGk0jSN/EKib3YZJsr5GyfkYcpEiA2rwMgm\no4GrMIGoMAwGA1UdEwEB/wQCMAAwDgYDVR0PAQH/BAQDAgeAMBMGA1UdJQQMMAoG\nCCsGAQUFBwMBMDMGA1UdEQQsMCqCKGR2LWczYi1hYWEtZW11ZGhyYS53ZS1oYXZl\nLWEtcHJvYmxlbS5jb20wHQYDVR0OBBYEFDggfJFz1eeToKmzFBSYkMCvpAu0MB8G\nA1UdIwQYMBaAFOIeL+ccGTkOVUtiwSAkVzUkiiUUMAoGCCqGSM49BAMCA2gAMGUC\nMDqDDOt7K313QIH0rhn/ZKnzFu8+/9W8OlTuFtDfVCTRDfRmqdyfsCfLsEk7xqHq\nuQIxAJqyLERABmU98U46OCuT5bp5h0ytuSEIGM7orTpKci5S1u1B/AOmwv162U7R\nTcVPHA==\n-----END CERTIFICATE-----\n",
|
||||
"84112972b8e59d14560ae7e7119654474aad119990cf3722bfc1e3b91615e1fd": "-----BEGIN CERTIFICATE-----\nMIIDxTCCAq2gAwIBAgIRANal72E7OQ8y75zfNJ93KEEwDQYJKoZIhvcNAQELBQAw\nezELMAkGA1UEBhMCR0IxGzAZBgNVBAgMEkdyZWF0ZXIgTWFuY2hlc3RlcjEQMA4G\nA1UEBwwHU2FsZm9yZDEaMBgGA1UECgwRQ29tb2RvIENBIExpbWl0ZWQxITAfBgNV\nBAMMGEFBQSBDZXJ0aWZpY2F0ZSBTZXJ2aWNlczAeFw0yNTA1MTQwMDAwMDBaFw0y\nODEyMzEyMzU5NTlaMD8xCzAJBgNVBAYTAlVTMRQwEgYDVQQKDAtlTXVkaHJhIElu\nYzEaMBgGA1UEAwwRRU0gT1YgQ1MgQ0EgLSBHM0MwdjAQBgcqhkjOPQIBBgUrgQQA\nIgNiAAS58OBjRwKmnKaq3UQ8/WLEG5uksgkkaX15dOdpGhJvuWr7PjTC/TvF9IMG\nHyZdwqWENjRzI1EENrKPBPOGI2MGeYDiUD4CUy1lRiNacWWiWaHvSW90G1/e53hy\nCOS4+u+jggEsMIIBKDAfBgNVHSMEGDAWgBSgEQojPpbxB+zirynvgqV/0DCktDAd\nBgNVHQ4EFgQU7m9zKY1vv6ezG8OVNPYVVkR4ZrcwDgYDVR0PAQH/BAQDAgEGMBIG\nA1UdEwEB/wQIMAYBAf8CAQAwEwYDVR0lBAwwCgYIKwYBBQUHAwMwMgYDVR0gBCsw\nKTAIBgZngQwBBAEwDQYLKwYBBAGDjiEBAAEwDgYMKwYBBAGDjiEBAoFIMEMGA1Ud\nHwQ8MDowOKA2oDSGMmh0dHA6Ly9jcmwuY29tb2RvY2EuY29tL0FBQUNlcnRpZmlj\nYXRlU2VydmljZXMuY3JsMDQGCCsGAQUFBwEBBCgwJjAkBggrBgEFBQcwAYYYaHR0\ncDovL29jc3AuY29tb2RvY2EuY29tMA0GCSqGSIb3DQEBCwUAA4IBAQBgGbZ+NDkn\n2Fr5dCVs/51+6cT5iEPP1stglX4TI2UaNh9LmPdltxXw3crV8gK5L8i+d+ScGibF\ny0obtBHNTldXDgLnAHIzY3n+pceyKj7O2ae9CYnyuu5RBifeI3XYKowWE+oXnVGF\nOy0CvCJ/JsdP6aYgoCa9hGnAYicYQQL6MOMQZ5PuhcSM8t4giIzrpgrEkUP93GYE\nemDG5Z8BYwKs80lLeQNBnwg/VHJ3iEF/o84RXYwrlrKqJ+33C094o6LNikMJCqOK\n/WYHZSR8Xyhf8GXFAj5pZuI6Bcpug2V/E7i42j8OBhc25aNYDqK+B0qmb90sjNUB\ng3tJeSIb/599\n-----END CERTIFICATE-----\n",
|
||||
"86a1ecba089c4a8d3bbe2734c612ba341d813e043cf9e8a862cd5c57a36bbe6b": "-----BEGIN CERTIFICATE-----\nMIICTjCCAdOgAwIBAgIKPPYHqWhwDtqLhDAKBggqhkjOPQQDAzBrMQswCQYDVQQG\nEwJJTjETMBEGA1UECxMKZW1TaWduIFBLSTElMCMGA1UEChMcZU11ZGhyYSBUZWNo\nbm9sb2dpZXMgTGltaXRlZDEgMB4GA1UEAxMXZW1TaWduIEVDQyBSb290IENBIC0g\nRzMwHhcNMTgwMjE4MTgzMDAwWhcNNDMwMjE4MTgzMDAwWjBrMQswCQYDVQQGEwJJ\nTjETMBEGA1UECxMKZW1TaWduIFBLSTElMCMGA1UEChMcZU11ZGhyYSBUZWNobm9s\nb2dpZXMgTGltaXRlZDEgMB4GA1UEAxMXZW1TaWduIEVDQyBSb290IENBIC0gRzMw\ndjAQBgcqhkjOPQIBBgUrgQQAIgNiAAQjpQy4LRL1KPOxst3iAhKAnjlfSU2fySU0\nWXTsuwYc58Byr+iuL+FBVIcUqEqy6HyC5ltqtdyzdc6LBtCGI79G1Y4PPwT01xyS\nfvalY8L1X44uT6EYGQIrMgqCZH0Wk9GjQjBAMB0GA1UdDgQWBBR8XQKEE9TMipuB\nzhccLikenEhjQjAOBgNVHQ8BAf8EBAMCAQYwDwYDVR0TAQH/BAUwAwEB/zAKBggq\nhkjOPQQDAwNpADBmAjEAvvNhzwIQHWSVB7gYboiFBS+DCBeQyh+KTOgNG3qxrdWB\nCUfvO6wIBHxcmbHtRwfSAjEAnbpV/KlK6O3t5nYBQnvI+GDZjVGLVTv7jHvrZQnD\n+JbNR6iC8hZVdyR+EhCVBCyj\n-----END CERTIFICATE-----\n",
|
||||
"9200d153c3cac272cc06bf68f9ea163fc24659fc3331e77668236fe00645ae52": "-----BEGIN CERTIFICATE-----\nMIICQjCCAcegAwIBAgIUMH3z62Z6agEULBZqrfhpw9/ZpCAwCgYIKoZIzj0EAwIw\nQDELMAkGA1UEBhMCVVMxFDASBgNVBAoMC2VNdWRocmEgSW5jMRswGQYDVQQDDBJF\nTSBEViBUTFMgQ0EgLSBHM0MwHhcNMjYxMDIwMDIyMjQxWhcNMjcwNTAzMDIyNzQx\nWjAzMTEwLwYDVQQDDChkdi1nM2MtYWFhLWVtdWRocmEud2UtaGF2ZS1hLXByb2Js\nZW0uY29tMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEAMPpHsQqEqIerrj/durO\nEN7gkUVJSVgIPN6+VJ2sEGXDyqXAN1fnNzGuASP/4WEr0RIhht3cYvuUYp2SYofr\nw6OBqzCBqDAMBgNVHRMBAf8EAjAAMA4GA1UdDwEB/wQEAwIHgDATBgNVHSUEDDAK\nBggrBgEFBQcDATAzBgNVHREELDAqgihkdi1nM2MtYWFhLWVtdWRocmEud2UtaGF2\nZS1hLXByb2JsZW0uY29tMB0GA1UdDgQWBBT8CNQz4OLVnIwrQYZBF+++9jTCvzAf\nBgNVHSMEGDAWgBSufhBcPCmWwahHJwVZ8eA91OUK8zAKBggqhkjOPQQDAgNpADBm\nAjEAp3ZLdfBvKcZxgR2p8LZaxWzP6uB7zU/EZ9BA49Ds2P5ByYkRodwK5qcwt4J/\nVL/vAjEAnnxrcrDX7SzoJxuho604GBh6eyBLJLCdmcy+qb+LA3u5oxP/OjAzCA4x\nv1kw6Tm4\n-----END CERTIFICATE-----\n",
|
||||
"98b896625c0c230adeec3eb2cf6aad5001ecf9156a68644e82374ce5833bc25b": "-----BEGIN CERTIFICATE-----\nMIIDZjCCAu2gAwIBAgIPbK2jZ/scEACIEg6iAtVoMAoGCCqGSM49BAMDMFYxCzAJ\nBgNVBAYTAklOMSUwIwYDVQQKDBxlTXVkaHJhIFRlY2hub2xvZ2llcyBMaW1pdGVk\nMSAwHgYDVQQDDBdlbVNpZ24gUm9vdCBUTFMgQ0EgLSBHMzAeFw0yNTA0MzAxNjAw\nMTJaFw00MDA0MjgxODMwMDBaMD8xCzAJBgNVBAYTAk5MMRMwEQYDVQQKDAplTXVk\naHJhIEJWMRswGQYDVQQDDBJFTSBPViBUTFMgQ0EgLSBHM0IwdjAQBgcqhkjOPQIB\nBgUrgQQAIgNiAARziXqg6zz9yosSQC/68KvE4SErr43ud4rJ7i1tuD+sKAxeBWOK\n56Ks1xwyKo42HN659pxDArndjXPRzGyqsswK7k4hbOl53VGKSvS2JuVoTN1lgDft\nDIVOTMEvTPhkbQ2jggGWMIIBkjAfBgNVHSMEGDAWgBThOqshfPHjTVSu1HK3/YtZ\nQiCQVDAdBgNVHQ4EFgQUm9hlUa/n5yw3rQjuaRnZvzeErgowHQYDVR0lBBYwFAYI\nKwYBBQUHAwEGCCsGAQUFBwMCMF0GA1UdIARWMFQwOQYLKwYBBAGDjiEBAAEwKjAo\nBggrBgEFBQcCARYcaHR0cDovL3JlcG9zaXRvcnkuZW1zaWduLmNvbTANBgsrBgEE\nAYOOIQECbjAIBgZngQwBAgIwdQYIKwYBBQUHAQEEaTBnMCQGCCsGAQUFBzABhhho\ndHRwOi8vb2NzcC1iLmVtc2lnbi5jb20wPwYIKwYBBQUHMAKGM2h0dHA6Ly9yZXBv\nc2l0b3J5LmVtc2lnbi5jb20vY2VydHMvRU1PVlRMU0NBRzNCLnA3YzA3BgNVHR8E\nMDAuMCygKqAohiZodHRwOi8vY3JsLmVtc2lnbi5jb20vP3Jvb3R0bHNjYWczLmNy\nbDAOBgNVHQ8BAf8EBAMCAQYwEgYDVR0TAQH/BAgwBgEB/wIBADAKBggqhkjOPQQD\nAwNnADBkAjBAk+5BizdJhrED3wb7F2Gm7bWI60dlNd075nghT2TrR4NWKZtJZcbc\n28xxIY/NONICMF6mPdsjosaqmvEMWqKcBh4vsIvXSKnQj5o8zrFB4oKS+accjYTe\n8Dz3bQjauvy9uw==\n-----END CERTIFICATE-----\n",
|
||||
"9dcb21b1841d75313b3c888f4bb9cc01f8f51379a76fbaa241ca492b668839e7": "-----BEGIN CERTIFICATE-----\nMIICOTCCAb6gAwIBAgIUY1R/PHrtXShV0uhLJ5EyjsHvMi0wCgYIKoZIzj0EAwIw\nPzELMAkGA1UEBhMCTkwxEzARBgNVBAoMCmVNdWRocmEgQlYxGzAZBgNVBAMMEkVN\nIERWIFRMUyBDQSAtIEczQjAeFw0yNjEwMjAwMDU5MzZaFw0yNzA1MDMwMTA0MzZa\nMC8xLTArBgNVBAMMJGR2LWczYi1lbXVkaHJhLndlLWhhdmUtYS1wcm9ibGVtLmNv\nbTBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABFpE24ACBFHQLzpg+7D767thJ3+Q\nNIoJJugUMT/OftEz6G6uDdxlS+9VfXYBDglHjJirjEPFNglOGJoQNQuiz7Gjgacw\ngaQwDAYDVR0TAQH/BAIwADAOBgNVHQ8BAf8EBAMCB4AwEwYDVR0lBAwwCgYIKwYB\nBQUHAwEwLwYDVR0RBCgwJoIkZHYtZzNiLWVtdWRocmEud2UtaGF2ZS1hLXByb2Js\nZW0uY29tMB0GA1UdDgQWBBT4r5v4etYNuG21BqBqs273OkcHiDAfBgNVHSMEGDAW\ngBTiHi/nHBk5DlVLYsEgJFc1JIolFDAKBggqhkjOPQQDAgNpADBmAjEAlxNF+/8l\nAPpgibawACy15yn5bcvTksz2Zw0R9z9nUK9e3MSSk+hXagg7LVb7V4NtAjEA4qyR\nlaFPkaokrnTjqQieq8CgDQ6tIz3FjBvj3D7vkhKXHaml6zD2zT5ih2Y+lD2h\n-----END CERTIFICATE-----\n",
|
||||
"9e39de2881273acc225a149c72ba3e0f51424963669ab633ee1dc7df5db067b4": "-----BEGIN CERTIFICATE-----\nMIIDWzCCAuCgAwIBAgIPeBiTP4+FtGGaKzX7qD0KMAoGCCqGSM49BAMDMFUxCzAJ\nBgNVBAYTAklOMSUwIwYDVQQKDBxlTXVkaHJhIFRlY2hub2xvZ2llcyBMaW1pdGVk\nMR8wHQYDVQQDDBZlbVNpZ24gUm9vdCBDUyBDQSAtIEczMB4XDTI1MDQzMDE2MDgy\nN1oXDTQwMDQyODE4MzAwMFowPjELMAkGA1UEBhMCTkwxEzARBgNVBAoMCmVNdWRo\ncmEgQlYxGjAYBgNVBAMMEUVNIE9WIENTIENBIC0gRzNCMHYwEAYHKoZIzj0CAQYF\nK4EEACIDYgAEqrsPsGZqz88B+tUr/P1MK/LLPDSX3nOVDlgZth/mlQmHQcjEkUOb\nNKBDHK/+W/m0wcjorgAB+fTcitgsWfNjnpbNbD1NsM7+wmw9YlF7N3YTcnQ/jord\nd7EAZPKr5eBQo4IBizCCAYcwHwYDVR0jBBgwFoAUaHQD+IXKjVRQVx0W4MjeRQ2r\nzS0wHQYDVR0OBBYEFHn66OzP2w11jaRY1p+27dx8Sd7mMBMGA1UdJQQMMAoGCCsG\nAQUFBwMDMF4GA1UdIARXMFUwOQYLKwYBBAGDjiEBAAEwKjAoBggrBgEFBQcCARYc\naHR0cDovL3JlcG9zaXRvcnkuZW1zaWduLmNvbTAOBgwrBgEEAYOOIQECgUgwCAYG\nZ4EMAQQBMHQGCCsGAQUFBwEBBGgwZjAkBggrBgEFBQcwAYYYaHR0cDovL29jc3At\nYi5lbXNpZ24uY29tMD4GCCsGAQUFBzAChjJodHRwOi8vcmVwb3NpdG9yeS5lbXNp\nZ24uY29tL2NlcnRzL0VNT1ZDU0NBRzNCLnA3YzA2BgNVHR8ELzAtMCugKaAnhiVo\ndHRwOi8vY3JsLmVtc2lnbi5jb20vP3Jvb3Rjc2NhZzMuY3JsMA4GA1UdDwEB/wQE\nAwIBBjASBgNVHRMBAf8ECDAGAQH/AgEAMAoGCCqGSM49BAMDA2kAMGYCMQC/zr03\nYEELsNKqzUtH2dVFi4BaKFdyrr/6G1dIWp8KAVgsw2/u4lyyboUDaC9jDvICMQC9\nHTxku84tNX9dmHOtH7aC2Nu/JM0jTf6KjZAvovCJzzv1IFWSZpVLZibynMLXOrM=\n-----END CERTIFICATE-----\n",
|
||||
"a496e23ac0cab8155fc9b117b97eae368efcbb9041aec8804d5a308d2d01c2d8": "-----BEGIN CERTIFICATE-----\nMIIDwzCCAqugAwIBAgIQQeMrc6wYXWzg10gLrPhOaTANBgkqhkiG9w0BAQsFADB7\nMQswCQYDVQQGEwJHQjEbMBkGA1UECAwSR3JlYXRlciBNYW5jaGVzdGVyMRAwDgYD\nVQQHDAdTYWxmb3JkMRowGAYDVQQKDBFDb21vZG8gQ0EgTGltaXRlZDEhMB8GA1UE\nAwwYQUFBIENlcnRpZmljYXRlIFNlcnZpY2VzMB4XDTI1MDUxNDAwMDAwMFoXDTI4\nMTIzMTIzNTk1OVowPjELMAkGA1UEBhMCTkwxEzARBgNVBAoMCmVNdWRocmEgQlYx\nGjAYBgNVBAMMEUVNIE9WIENTIENBIC0gRzNCMHYwEAYHKoZIzj0CAQYFK4EEACID\nYgAEqrsPsGZqz88B+tUr/P1MK/LLPDSX3nOVDlgZth/mlQmHQcjEkUObNKBDHK/+\nW/m0wcjorgAB+fTcitgsWfNjnpbNbD1NsM7+wmw9YlF7N3YTcnQ/jordd7EAZPKr\n5eBQo4IBLDCCASgwHwYDVR0jBBgwFoAUoBEKIz6W8Qfs4q8p74Klf9AwpLQwHQYD\nVR0OBBYEFHn66OzP2w11jaRY1p+27dx8Sd7mMA4GA1UdDwEB/wQEAwIBBjASBgNV\nHRMBAf8ECDAGAQH/AgEAMBMGA1UdJQQMMAoGCCsGAQUFBwMDMDIGA1UdIAQrMCkw\nCAYGZ4EMAQQBMA0GCysGAQQBg44hAQABMA4GDCsGAQQBg44hAQKBSDBDBgNVHR8E\nPDA6MDigNqA0hjJodHRwOi8vY3JsLmNvbW9kb2NhLmNvbS9BQUFDZXJ0aWZpY2F0\nZVNlcnZpY2VzLmNybDA0BggrBgEFBQcBAQQoMCYwJAYIKwYBBQUHMAGGGGh0dHA6\nLy9vY3NwLmNvbW9kb2NhLmNvbTANBgkqhkiG9w0BAQsFAAOCAQEAIh0OzWsdBYhY\n7+yxPGyosRL+uUSN6/Mrd30fBuougs5FVWQ3cRgLhImnUUgXADY9buugWhMmfT3c\nyU5T1MFXZXClCILm5mfg+oqm/ikUWJl3Ayf2GvYf2O++6vzqvkn418nI5Lpdt0O7\nLi524YS/3CZHDA+zZQ7rrHpZXgQ9Hmp8AUQ4FneaGjNanFoYxiZxwCyW5z4u7AoK\n0bmOVqKtcDFoVNz5++G9TYDJdhWOXmKKgzqRo1MK0PjazNT7MGHqknP93SqMVJ1g\nmX/qNWxYteAjlvFanQxBNX/+TRxDjuFsEHO/7BvqSpIIck0UEEfizhN8zNpWLbcl\nEqVgl0g39A==\n-----END CERTIFICATE-----\n",
|
||||
"bc88231a840e54cced148da48806169e8d0b1c0a96267a427aeccda9d10e6145": "-----BEGIN CERTIFICATE-----\nMIICQDCCAcagAwIBAgIUUYu3O8bpN1GPvCIHqWpT/FYUNDowCgYIKoZIzj0EAwIw\nPzELMAkGA1UEBhMCTkwxEzARBgNVBAoMCmVNdWRocmEgQlYxGzAZBgNVBAMMEkVN\nIEVWIFRMUyBDQSAtIEczQjAeFw0yNjEwMjAwMjIyNDFaFw0yNzA1MDMwMjI3NDFa\nMDMxMTAvBgNVBAMMKGV2LWczYi1hYWEtZW11ZGhyYS53ZS1oYXZlLWEtcHJvYmxl\nbS5jb20wWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAASjDLpnVdmvXM6R3UB2o42a\n1GnrYfxRXo7UII36mBl9mIS7p9MkAryhPdxitFHkgTrrbypVtzpydNtJIGGGXUwE\no4GrMIGoMAwGA1UdEwEB/wQCMAAwDgYDVR0PAQH/BAQDAgeAMBMGA1UdJQQMMAoG\nCCsGAQUFBwMBMDMGA1UdEQQsMCqCKGV2LWczYi1hYWEtZW11ZGhyYS53ZS1oYXZl\nLWEtcHJvYmxlbS5jb20wHQYDVR0OBBYEFEVBY1ecTN9bLMDl4ifdnN2WZL+WMB8G\nA1UdIwQYMBaAFD7cINmJRpiHe4c/2IFHslwV2EcGMAoGCCqGSM49BAMCA2gAMGUC\nMQCyvr9gktgmtsuTuFleFV+YExNNKLf3GZieuUprQcERrzh798oYKavTqZeYf9Kz\nRhECMCVs1B7KPh7/lgHRly14Z8RJA8ApR7e2cRREW5uPKFOSDlZ/RIezxsfEzsQ4\nlAODeA==\n-----END CERTIFICATE-----\n",
|
||||
"bcd6ab59ea95fd520bfe7709679b1f682abbd79c77367e84e25415b91de37753": "-----BEGIN CERTIFICATE-----\nMIICQDCCAcegAwIBAgIUTMUrGzSU+0BySFdrN3ALZq9Spv8wCgYIKoZIzj0EAwIw\nQDELMAkGA1UEBhMCVVMxFDASBgNVBAoMC2VNdWRocmEgSW5jMRswGQYDVQQDDBJF\nTSBFViBUTFMgQ0EgLSBHM0MwHhcNMjYxMDIwMDIyMjQyWhcNMjcwNTAzMDIyNzQy\nWjAzMTEwLwYDVQQDDChldi1nM2MtYWFhLWVtdWRocmEud2UtaGF2ZS1hLXByb2Js\nZW0uY29tMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEglClDbotLFFeY9SpryT4\nk958tvriY2DDnaPsmjQaecRhB6+L/8hLw6FMt+HT8SLvjIZnFY+AH92cBjedcNST\nAKOBqzCBqDAMBgNVHRMBAf8EAjAAMA4GA1UdDwEB/wQEAwIHgDATBgNVHSUEDDAK\nBggrBgEFBQcDATAzBgNVHREELDAqgihldi1nM2MtYWFhLWVtdWRocmEud2UtaGF2\nZS1hLXByb2JsZW0uY29tMB0GA1UdDgQWBBSVtGbn6xUT1iosUeaGA15Nq640aTAf\nBgNVHSMEGDAWgBSmJV6AqU2385Z8G8eR35K87kvrXzAKBggqhkjOPQQDAgNnADBk\nAjAg7b5bIK9X50QEX0U6G8fQh12BnFQ079Iluzd0Y2TJWCjtVXZi3nOciM8RzNKM\ntZECMCBf0JOyMt8RNS93Wj8kYbWkCtoKoUrqx7G3JTfGReKci+DdTvkF8/3F6doi\n9KB22Q==\n-----END CERTIFICATE-----\n",
|
||||
"cb6e15165ac802e9b3f993f16c25d41f4e81c21ebde71ae1b012ac00bf9c7c3f": "-----BEGIN CERTIFICATE-----\nMIIDaDCCAu6gAwIBAgIPKWkmwiejoVJjkrGUgqUEMAoGCCqGSM49BAMDMFYxCzAJ\nBgNVBAYTAklOMSUwIwYDVQQKDBxlTXVkaHJhIFRlY2hub2xvZ2llcyBMaW1pdGVk\nMSAwHgYDVQQDDBdlbVNpZ24gUm9vdCBUTFMgQ0EgLSBHMzAeFw0yNTA0MzAxNTM3\nNDFaFw00MDA0MjgxODMwMDBaMEAxCzAJBgNVBAYTAlVTMRQwEgYDVQQKDAtlTXVk\naHJhIEluYzEbMBkGA1UEAwwSRU0gT1YgVExTIENBIC0gRzNDMHYwEAYHKoZIzj0C\nAQYFK4EEACIDYgAEE4JRzVKskpjByKrZdzId65fnCb0LTKCspV3IrVHc/J0ViaFZ\nfjpRIOHv1jHGPhg1ysrimGmmLhYx6KKBgatWYW3EXZGKvAnzqw5jz3kqpNztc4e+\nN7ulaVSfHAKycO1no4IBljCCAZIwHwYDVR0jBBgwFoAU4TqrIXzx401UrtRyt/2L\nWUIgkFQwHQYDVR0OBBYEFDTq6gzryQra+dDF4u2beMmHnhtsMB0GA1UdJQQWMBQG\nCCsGAQUFBwMBBggrBgEFBQcDAjBdBgNVHSAEVjBUMDkGCysGAQQBg44hAQABMCow\nKAYIKwYBBQUHAgEWHGh0dHA6Ly9yZXBvc2l0b3J5LmVtc2lnbi5jb20wDQYLKwYB\nBAGDjiEBAm4wCAYGZ4EMAQICMHUGCCsGAQUFBwEBBGkwZzAkBggrBgEFBQcwAYYY\naHR0cDovL29jc3AtYy5lbXNpZ24uY29tMD8GCCsGAQUFBzAChjNodHRwOi8vcmVw\nb3NpdG9yeS5lbXNpZ24uY29tL2NlcnRzL0VNT1ZUTFNDQUczQy5wN2MwNwYDVR0f\nBDAwLjAsoCqgKIYmaHR0cDovL2NybC5lbXNpZ24uY29tLz9yb290dGxzY2FnMy5j\ncmwwDgYDVR0PAQH/BAQDAgEGMBIGA1UdEwEB/wQIMAYBAf8CAQAwCgYIKoZIzj0E\nAwMDaAAwZQIxAMSZt2xsPeumRdeSMSQ3CbzIZeQ/q7GDCWfeE1Pq+mhgA6gRM44c\nNVF3xJ+wsxJk+wIwFJbaDMokD8Ei6eNOiA8yUMYvfzQyngmrhZQMdPCK1SewrR79\n4rSb9vGqxZzelQth\n-----END CERTIFICATE-----\n",
|
||||
"ccd80467f9cac38f115dcb6e4f57e4d74f5efa519814ea934aeb3be53b83a191": "-----BEGIN CERTIFICATE-----\nMIICOTCCAb+gAwIBAgIUOcw5N4XGLiB/3GvB05bJuGoZPR0wCgYIKoZIzj0EAwIw\nQDELMAkGA1UEBhMCVVMxFDASBgNVBAoMC2VNdWRocmEgSW5jMRswGQYDVQQDDBJF\nTSBFViBUTFMgQ0EgLSBHM0MwHhcNMjYxMDIwMDA1OTM3WhcNMjcwNTAzMDEwNDM3\nWjAvMS0wKwYDVQQDDCRldi1nM2MtZW11ZGhyYS53ZS1oYXZlLWEtcHJvYmxlbS5j\nb20wWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAAQo7U02MAUO8EEMomXwIl+kSQzm\nb+nvXKIZ/hC4rZ1GpXchv4SzTrV1SB/Qg+pD6OBEXbBBnPmQNkePNQTlujqRo4Gn\nMIGkMAwGA1UdEwEB/wQCMAAwDgYDVR0PAQH/BAQDAgeAMBMGA1UdJQQMMAoGCCsG\nAQUFBwMBMC8GA1UdEQQoMCaCJGV2LWczYy1lbXVkaHJhLndlLWhhdmUtYS1wcm9i\nbGVtLmNvbTAdBgNVHQ4EFgQUqxe+7qS+MxNyioJZhQsL5MEEw6swHwYDVR0jBBgw\nFoAUpiVegKlNt/OWfBvHkd+SvO5L618wCgYIKoZIzj0EAwIDaAAwZQIwC/CxSIov\nwcar3sV98B8cL+QuR2kkJdLdXsFKzg/HDSJ1MOjqTGm924LB6r9nl0f7AjEAzcwr\nWQOYY9eycqIz+LG2daYS6LOctj+CjoAk/CLugWPxGxp4Aerfau2P1z1h5jAA\n-----END CERTIFICATE-----\n",
|
||||
"d7a7a0fb5d7e2731d771e9484ebcdef71d5f0c3e0a2948782bc83ee0ea699ef4": "-----BEGIN CERTIFICATE-----\nMIIEMjCCAxqgAwIBAgIBATANBgkqhkiG9w0BAQUFADB7MQswCQYDVQQGEwJHQjEb\nMBkGA1UECAwSR3JlYXRlciBNYW5jaGVzdGVyMRAwDgYDVQQHDAdTYWxmb3JkMRow\nGAYDVQQKDBFDb21vZG8gQ0EgTGltaXRlZDEhMB8GA1UEAwwYQUFBIENlcnRpZmlj\nYXRlIFNlcnZpY2VzMB4XDTA0MDEwMTAwMDAwMFoXDTI4MTIzMTIzNTk1OVowezEL\nMAkGA1UEBhMCR0IxGzAZBgNVBAgMEkdyZWF0ZXIgTWFuY2hlc3RlcjEQMA4GA1UE\nBwwHU2FsZm9yZDEaMBgGA1UECgwRQ29tb2RvIENBIExpbWl0ZWQxITAfBgNVBAMM\nGEFBQSBDZXJ0aWZpY2F0ZSBTZXJ2aWNlczCCASIwDQYJKoZIhvcNAQEBBQADggEP\nADCCAQoCggEBAL5AnfRu4ep2hxxNRUSOvkbIgwadwSr+GB+O5AL686tdUIoWMQua\nBtDFcCLNSS1UY8y2bmhGC1Pqy0wkwLxyTurxFa70VJoSCsN6sjNg4tqJVfMiWPPe\n3M/vg4aijJRPn2jymJBGhCfHdr/jzDUsi14HZGWCwEiwqJH5YZ92IFCokcdmtet4\nYgNW8IoaE+oxox6gmf049vYnMlhvB/VruPsUK6+3qszWY19zjNoFmag4qMsXeDZR\nrOme9Hg6jc8P2ULimAyrL58OAd7vn5lJ8S3frHRNG5i1R8XlKdH5kBjHYpy+g8cm\nez6KJcfA3Z3mNWgQIJ2P2N7Sw4ScDV7oL8kCAwEAAaOBwDCBvTAdBgNVHQ4EFgQU\noBEKIz6W8Qfs4q8p74Klf9AwpLQwDgYDVR0PAQH/BAQDAgEGMA8GA1UdEwEB/wQF\nMAMBAf8wewYDVR0fBHQwcjA4oDagNIYyaHR0cDovL2NybC5jb21vZG9jYS5jb20v\nQUFBQ2VydGlmaWNhdGVTZXJ2aWNlcy5jcmwwNqA0oDKGMGh0dHA6Ly9jcmwuY29t\nb2RvLm5ldC9BQUFDZXJ0aWZpY2F0ZVNlcnZpY2VzLmNybDANBgkqhkiG9w0BAQUF\nAAOCAQEACFb8AvCb6P+k+tZ7xkSAzk/ExfYAWMymtrwUSWgEdujm7l3sAg9g1o1Q\nGE8mTgHj5rCl7r+8dFRBv/38ErjHT1r0iWAFf2C3BUrz9vHCv8S5dIa2LX1rzNLz\nRt0vxuBqw8M0Ayx9lt1awg6nCpnBBYurDC/zXDrPbDdVCYfeU0BsWO/8tqtlbgT2\nG9w84FoVxp7Z8VlIMCFlA2zs6SFz7JsDoeA3raAVGI/6ugLOpyypEBMs1OUIJqsi\nl2D4kF501KKaU73yqWjgom7C12yxow+ev+to51byrvLjKzg6CYG1a4XXvi3tPxq3\nsmPi9WIsgtRqAEFQ8TmDn5XpNpaYbg==\n-----END CERTIFICATE-----\n",
|
||||
"e7568fe6e12331f4fd7de61da4c7559d0f8eb192a218d4741a3ba75184333838": "-----BEGIN CERTIFICATE-----\nMIIDaDCCAu2gAwIBAgIQAMfEHv6S2CXwXq6EGkqCwTAKBggqhkjOPQQDAzBWMQsw\nCQYDVQQGEwJJTjElMCMGA1UECgwcZU11ZGhyYSBUZWNobm9sb2dpZXMgTGltaXRl\nZDEgMB4GA1UEAwwXZW1TaWduIFJvb3QgVExTIENBIC0gRzMwHhcNMjUwNDMwMTYw\nMzMxWhcNNDAwNDI4MTgzMDAwWjA/MQswCQYDVQQGEwJOTDETMBEGA1UECgwKZU11\nZGhyYSBCVjEbMBkGA1UEAwwSRU0gRVYgVExTIENBIC0gRzNCMHYwEAYHKoZIzj0C\nAQYFK4EEACIDYgAEJduoWmTTl9oLEFLp6lRuSHvv6pydnWigxerc/u91ppu11vwB\n9Cv7ym60C81kwHZ+Nv/QXZeoE8WqDGyVMOec9fCGZGjxOAZCCqXIUYQLHiUlQOeQ\n5LZUw4Uu1krx6GV9o4IBlTCCAZEwHwYDVR0jBBgwFoAU4TqrIXzx401UrtRyt/2L\nWUIgkFQwHQYDVR0OBBYEFD7cINmJRpiHe4c/2IFHslwV2EcGMB0GA1UdJQQWMBQG\nCCsGAQUFBwMBBggrBgEFBQcDAjBcBgNVHSAEVTBTMDkGCysGAQQBg44hAQABMCow\nKAYIKwYBBQUHAgEWHGh0dHA6Ly9yZXBvc2l0b3J5LmVtc2lnbi5jb20wDQYLKwYB\nBAGDjiEBAngwBwYFZ4EMAQEwdQYIKwYBBQUHAQEEaTBnMCQGCCsGAQUFBzABhhho\ndHRwOi8vb2NzcC1iLmVtc2lnbi5jb20wPwYIKwYBBQUHMAKGM2h0dHA6Ly9yZXBv\nc2l0b3J5LmVtc2lnbi5jb20vY2VydHMvRU1FVlRMU0NBRzNCLnA3YzA3BgNVHR8E\nMDAuMCygKqAohiZodHRwOi8vY3JsLmVtc2lnbi5jb20vP3Jvb3R0bHNjYWczLmNy\nbDAOBgNVHQ8BAf8EBAMCAQYwEgYDVR0TAQH/BAgwBgEB/wIBADAKBggqhkjOPQQD\nAwNpADBmAjEA9Jq999oaOUKhFwopw33iCmt0OHIn3gjT43GfuOHR/YckXdwoNdDA\nlIEhz0NgRO3/AjEAvY/a3OMvktIZLYjn+WLKmp0QhrIMV+e6EpS7j4GVeOKouNp6\nMN/P+ByGeiRMghzB\n-----END CERTIFICATE-----\n",
|
||||
"f2edaaff3d9b701fb4d14c9f285f8c0bad90fc3bcc1fa07d47f4718edaba34a5": "-----BEGIN CERTIFICATE-----\nMIICKDCCAa2gAwIBAgIQAY/OySfBFvWcmX7cDM0uKzAKBggqhkjOPQQDAzBVMQsw\nCQYDVQQGEwJJTjElMCMGA1UECgwcZU11ZGhyYSBUZWNobm9sb2dpZXMgTGltaXRl\nZDEfMB0GA1UEAwwWZW1TaWduIFJvb3QgQ1MgQ0EgLSBHMzAeFw0yNDAyMDgwODMz\nMTRaFw00OTAyMDcwODMzMTRaMFUxCzAJBgNVBAYTAklOMSUwIwYDVQQKDBxlTXVk\naHJhIFRlY2hub2xvZ2llcyBMaW1pdGVkMR8wHQYDVQQDDBZlbVNpZ24gUm9vdCBD\nUyBDQSAtIEczMHYwEAYHKoZIzj0CAQYFK4EEACIDYgAESl9kM5D0VA/Og76dz5Xz\nw1hnGQ4fSR9993/qBkkvI48pXQVKOdyHCgAZThgWejQ8OpFRiTISjpabn4tfRFOY\nuzhApR/XxGbkg9X+2cEtbyghF9CdoF91W22V/R2EVe4xo0IwQDAdBgNVHQ4EFgQU\naHQD+IXKjVRQVx0W4MjeRQ2rzS0wDgYDVR0PAQH/BAQDAgEGMA8GA1UdEwEB/wQF\nMAMBAf8wCgYIKoZIzj0EAwMDaQAwZgIxAK9OlACrFod3UgWY1ai+DF9sGFhahU59\nrv4jJyV01QaxGn0HErZ8gjHkngqe+tcFogIxAPTdW0hBmrGjhxBWdv9mZNdVFiJ4\nlrZ3ahvimsLhTQpnDBgiGKv6Ov1Fx/CE6yjIAg==\n-----END CERTIFICATE-----\n",
|
||||
"f3c3c15bc7edf2b3ebdc922af112f2a0c414eb509901099ef0488c0942e77efe": "-----BEGIN CERTIFICATE-----\nMIICODCCAb6gAwIBAgIUPXjC97k7buQHZ7p5ehDU8QNyrR4wCgYIKoZIzj0EAwIw\nPzELMAkGA1UEBhMCTkwxEzARBgNVBAoMCmVNdWRocmEgQlYxGzAZBgNVBAMMEkVN\nIE9WIFRMUyBDQSAtIEczQjAeFw0yNjEwMjAwMDU5MzdaFw0yNzA1MDMwMTA0Mzda\nMC8xLTArBgNVBAMMJG92LWczYi1lbXVkaHJhLndlLWhhdmUtYS1wcm9ibGVtLmNv\nbTBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABA1haD+95Albk0UN6YtDCPpqevTv\nYUmEhsIVpb/hnhsbBlH0yLthSE2JLH14ywiQJXttoc/pDWC9eoICW6CJXL2jgacw\ngaQwDAYDVR0TAQH/BAIwADAOBgNVHQ8BAf8EBAMCB4AwEwYDVR0lBAwwCgYIKwYB\nBQUHAwEwLwYDVR0RBCgwJoIkb3YtZzNiLWVtdWRocmEud2UtaGF2ZS1hLXByb2Js\nZW0uY29tMB0GA1UdDgQWBBQUhzc4s202Hn62G0srmgguYdY2MTAfBgNVHSMEGDAW\ngBSb2GVRr+fnLDetCO5pGdm/N4SuCjAKBggqhkjOPQQDAgNoADBlAjEAoJ6d4zzA\nxE349yGHHrwKZpVCC/HBFutcOpOfzQoe8ikbmaaBTdtKai5TEllwt3dBAjBeHGiX\nVheDhJfewbei99cw1MfYB5tPwdfuY5pe1MJz0Tg9zJ4JgxBvF6LTT0OOXZ4=\n-----END CERTIFICATE-----\n",
|
||||
"fd9d5c2dbbd4995ab69b8d50c439d9527b00529199c2fd01b2174eb58f07021f": "-----BEGIN CERTIFICATE-----\nMIIDzTCCArWgAwIBAgIQBfB4dS3s56G7hZXTAxZxcTANBgkqhkiG9w0BAQsFADB7\nMQswCQYDVQQGEwJHQjEbMBkGA1UECAwSR3JlYXRlciBNYW5jaGVzdGVyMRAwDgYD\nVQQHDAdTYWxmb3JkMRowGAYDVQQKDBFDb21vZG8gQ0EgTGltaXRlZDEhMB8GA1UE\nAwwYQUFBIENlcnRpZmljYXRlIFNlcnZpY2VzMB4XDTI1MDUxNDAwMDAwMFoXDTI4\nMTIzMTIzNTk1OVowQDELMAkGA1UEBhMCVVMxFDASBgNVBAoMC2VNdWRocmEgSW5j\nMRswGQYDVQQDDBJFTSBFViBUTFMgQ0EgLSBHM0MwdjAQBgcqhkjOPQIBBgUrgQQA\nIgNiAAQtVugppvuCxkpGl9lgr5UekGeukfkm00MG2kioqut4abamfm0nQm4EQqoD\ns24ZiN/2NlFO4c8H9bpLP/KgZkWufTHVhmrTGqPSRusLMXfumct1ZsUWznSAimaP\nkfDEf8qjggE0MIIBMDAfBgNVHSMEGDAWgBSgEQojPpbxB+zirynvgqV/0DCktDAd\nBgNVHQ4EFgQUpiVegKlNt/OWfBvHkd+SvO5L618wDgYDVR0PAQH/BAQDAgEGMBIG\nA1UdEwEB/wQIMAYBAf8CAQAwHQYDVR0lBBYwFAYIKwYBBQUHAwEGCCsGAQUFBwMC\nMDAGA1UdIAQpMCcwBwYFZ4EMAQEwDQYLKwYBBAGDjiEBAAEwDQYLKwYBBAGDjiEB\nAngwQwYDVR0fBDwwOjA4oDagNIYyaHR0cDovL2NybC5jb21vZG9jYS5jb20vQUFB\nQ2VydGlmaWNhdGVTZXJ2aWNlcy5jcmwwNAYIKwYBBQUHAQEEKDAmMCQGCCsGAQUF\nBzABhhhodHRwOi8vb2NzcC5jb21vZG9jYS5jb20wDQYJKoZIhvcNAQELBQADggEB\nAIFfHiaMSjnrdZygRYV4ekRBmG7ybcFk4hGQWMMyMFi0cAEk2+dGfz5bl8IaIbLq\nz+uVJzhOgVEX7WqIfgbWfFsfPYHYjPHBmy7SlZ3BwA86IQ6qF/etpX2/qL9A5yRN\nN3nVowUpaqV6H3aCpkAh3wR2ZNkvAbLDvPz3J0l+sEWjWv+ZISOV4R+hXDTdPXV0\n1lKxnbwqEsdo/sE2QkCDKwFrZh/0uKjWspydJj+jSaDLTrGNx50Dgmn7VEcD1ZI5\nFXjknVUiO25YlmS1dtVZSBbyUKSjlrP/yDwPcZunoAsmCAAtCKsFLviLaymzvsKI\neSSDGxpSySp2VCxompGDk8g=\n-----END CERTIFICATE-----\n"
|
||||
},
|
||||
"chains": {
|
||||
"dv-g3b-aaa-emudhra.we-have-a-problem.com": [
|
||||
"7e02622ab6d745c1edb4b431bb1c551e667305c3dd3eb0c7cc6dfdb523807424",
|
||||
"45a883f349aedfd5f3a649b5498ffb121712ba8b448f3f2a91dbc5a766fcddbe"
|
||||
],
|
||||
"dv-g3b-aaa-noct-emudhra.we-have-a-problem.com": [
|
||||
"5510ecf5764dee8dfe9468ae2d7323da21fcd5530889906540a2f8725c6f74db",
|
||||
"45a883f349aedfd5f3a649b5498ffb121712ba8b448f3f2a91dbc5a766fcddbe"
|
||||
],
|
||||
"dv-g3b-emudhra.we-have-a-problem.com": [
|
||||
"9dcb21b1841d75313b3c888f4bb9cc01f8f51379a76fbaa241ca492b668839e7",
|
||||
"42c4accd12c4f59597925e1a08a7a90102608543809308d50ec427e118ba1493",
|
||||
"3094025792039a68af6e553686410bdab7c0d4a36b9084cba64202e35d6ebbdd"
|
||||
],
|
||||
"dv-g3b-noct-emudhra.we-have-a-problem.com": [
|
||||
"0cbfe22b824385823fd3bf4be4d131e8901342933a7feef1cf8b3110b8e44a70",
|
||||
"42c4accd12c4f59597925e1a08a7a90102608543809308d50ec427e118ba1493",
|
||||
"3094025792039a68af6e553686410bdab7c0d4a36b9084cba64202e35d6ebbdd"
|
||||
],
|
||||
"dv-g3c-aaa-emudhra.we-have-a-problem.com": [
|
||||
"9200d153c3cac272cc06bf68f9ea163fc24659fc3331e77668236fe00645ae52",
|
||||
"62b7f9868263f72f6911fc78ceaf72c1979158b7368bf648683bb2aa39b02e72"
|
||||
],
|
||||
"dv-g3c-emudhra.we-have-a-problem.com": [
|
||||
"346cdd962e97189b59aee5526d1ae8fa714bb1a64094dfa67bef768ee93f36b5",
|
||||
"0164d7ceae5bb909c272af151ff44b1177ae9b47d9424b94f1d9558c30934d19",
|
||||
"3094025792039a68af6e553686410bdab7c0d4a36b9084cba64202e35d6ebbdd"
|
||||
],
|
||||
"ev-g3b-aaa-emudhra.we-have-a-problem.com": [
|
||||
"bc88231a840e54cced148da48806169e8d0b1c0a96267a427aeccda9d10e6145",
|
||||
"579480aea166290bd3c643589e795fc0d0e54e86505d180f1dff99efd3199512"
|
||||
],
|
||||
"ev-g3b-emudhra.we-have-a-problem.com": [
|
||||
"66374ae64962c437bd815a2aae546c76e30c2eafac8335192c8e517c15a246ed",
|
||||
"e7568fe6e12331f4fd7de61da4c7559d0f8eb192a218d4741a3ba75184333838",
|
||||
"3094025792039a68af6e553686410bdab7c0d4a36b9084cba64202e35d6ebbdd"
|
||||
],
|
||||
"ev-g3c-aaa-emudhra.we-have-a-problem.com": [
|
||||
"bcd6ab59ea95fd520bfe7709679b1f682abbd79c77367e84e25415b91de37753",
|
||||
"fd9d5c2dbbd4995ab69b8d50c439d9527b00529199c2fd01b2174eb58f07021f"
|
||||
],
|
||||
"ev-g3c-emudhra.we-have-a-problem.com": [
|
||||
"ccd80467f9cac38f115dcb6e4f57e4d74f5efa519814ea934aeb3be53b83a191",
|
||||
"432ec25f0d28de9006d97bf752f17bbb2cd41f669933fcb072d9a1148d0261f3",
|
||||
"3094025792039a68af6e553686410bdab7c0d4a36b9084cba64202e35d6ebbdd"
|
||||
],
|
||||
"ov-g3b-aaa-emudhra.we-have-a-problem.com": [
|
||||
"21f57f83631a93c98e867050075746b3361e0433c9e0e969462852476784f6f0",
|
||||
"5f1ded4dd590af74631290cf073b122a2aac6a1fa479778a0bd43579bd832699"
|
||||
],
|
||||
"ov-g3b-emudhra.we-have-a-problem.com": [
|
||||
"f3c3c15bc7edf2b3ebdc922af112f2a0c414eb509901099ef0488c0942e77efe",
|
||||
"98b896625c0c230adeec3eb2cf6aad5001ecf9156a68644e82374ce5833bc25b",
|
||||
"3094025792039a68af6e553686410bdab7c0d4a36b9084cba64202e35d6ebbdd"
|
||||
],
|
||||
"ov-g3c-aaa-emudhra.we-have-a-problem.com": [
|
||||
"59cf0386b56bc2414a0c06241ea9e25e01a9c93bac7a596648747f9d0ded4906",
|
||||
"7485b51c0ed192878fa1d2f948fb529ae1842cc5086c4b67f7be5b2039de9489"
|
||||
],
|
||||
"ov-g3c-emudhra.we-have-a-problem.com": [
|
||||
"4314941cf242a4ea4149aa7e9cdf77a15de2771a79984b809fc04443fea013c8",
|
||||
"cb6e15165ac802e9b3f993f16c25d41f4e81c21ebde71ae1b012ac00bf9c7c3f",
|
||||
"3094025792039a68af6e553686410bdab7c0d4a36b9084cba64202e35d6ebbdd"
|
||||
]
|
||||
},
|
||||
"cross_signed_roots": {
|
||||
"3094025792039a68af6e553686410bdab7c0d4a36b9084cba64202e35d6ebbdd": "emSign Root TLS CA - G3 (emSign ECC Root CA - G3 cross-sign)"
|
||||
}
|
||||
}
|
||||
+269
@@ -0,0 +1,269 @@
|
||||
// Immutable N0 oracle copied from F1 commit 1cc56cb9; used only for differential tests.
|
||||
const _ORDINALS = ['1st', '2nd', '3rd', '4th', '5th'];
|
||||
const _DAYS = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
|
||||
function _hasTimeComponent(dateStr) {
|
||||
return typeof dateStr === 'string' && /T\d{2}:\d{2}/.test(dateStr);
|
||||
}
|
||||
|
||||
function _formatDueDate(dateStr) {
|
||||
if (!dateStr) return '';
|
||||
const d = new Date(dateStr);
|
||||
if (isNaN(d)) return '';
|
||||
const now = new Date();
|
||||
const hasTime = _hasTimeComponent(dateStr);
|
||||
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||
const due = new Date(d.getFullYear(), d.getMonth(), d.getDate());
|
||||
const diffDays = Math.round((due - today) / 86400000);
|
||||
const timeStr = hasTime ? d.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' }) : '';
|
||||
if (hasTime && d < now) return 'overdue';
|
||||
if (!hasTime && diffDays < 0) return 'overdue';
|
||||
if (diffDays === 0) return hasTime ? timeStr : 'today';
|
||||
if (diffDays === 1) return hasTime ? `tmrw ${timeStr}` : 'tomorrow';
|
||||
const dateLabel = d.toLocaleDateString([], { month: 'short', day: 'numeric' });
|
||||
return hasTime ? `${dateLabel} ${timeStr}` : dateLabel;
|
||||
}
|
||||
|
||||
function _isDueOverdue(dateStr) {
|
||||
if (!dateStr) return false;
|
||||
const d = new Date(dateStr);
|
||||
if (isNaN(d)) return false;
|
||||
if (_hasTimeComponent(dateStr)) return d < new Date();
|
||||
return d < new Date(new Date().toDateString());
|
||||
}
|
||||
|
||||
function _isDueTodayOrOverdue(dateStr) {
|
||||
if (!dateStr) return false;
|
||||
const d = new Date(dateStr);
|
||||
const now = new Date();
|
||||
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||
const due = new Date(d.getFullYear(), d.getMonth(), d.getDate());
|
||||
return due <= today;
|
||||
}
|
||||
|
||||
function _laterTodayDate() {
|
||||
const now = new Date();
|
||||
const eight = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 18, 0); // 6pm today
|
||||
// If less than 1 hour before 6pm, push to "in 3 hours" instead
|
||||
if (eight - now < 60 * 60 * 1000) return new Date(now.getTime() + 3 * 60 * 60 * 1000);
|
||||
return eight;
|
||||
}
|
||||
|
||||
function _tomorrowDate() {
|
||||
const t = new Date();
|
||||
t.setDate(t.getDate() + 1);
|
||||
t.setHours(8, 0, 0, 0);
|
||||
return t;
|
||||
}
|
||||
|
||||
function _nextWeekDate() {
|
||||
const t = new Date();
|
||||
const daysUntilMon = (8 - t.getDay()) % 7 || 7;
|
||||
t.setDate(t.getDate() + daysUntilMon);
|
||||
t.setHours(8, 0, 0, 0);
|
||||
return t;
|
||||
}
|
||||
|
||||
function _toLocalDatetimeStr(d) {
|
||||
// Format as YYYY-MM-DDTHH:MM (local, no TZ)
|
||||
const pad = n => String(n).padStart(2, '0');
|
||||
return `${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||
}
|
||||
|
||||
function _formatReminderTag(dateStr) {
|
||||
if (!dateStr) return '';
|
||||
const d = new Date(dateStr);
|
||||
if (isNaN(d)) return '';
|
||||
const now = new Date();
|
||||
const sameDay = d.toDateString() === now.toDateString();
|
||||
const tomorrow = new Date(now); tomorrow.setDate(tomorrow.getDate() + 1);
|
||||
const isTomorrow = d.toDateString() === tomorrow.toDateString();
|
||||
const time = d.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' });
|
||||
if (sameDay) return `Today, ${time}`;
|
||||
if (isTomorrow) return `Tomorrow, ${time}`;
|
||||
const dateLabel = d.toLocaleDateString([], { month: 'short', day: 'numeric' });
|
||||
return `${dateLabel}, ${time}`;
|
||||
}
|
||||
|
||||
function _nthWeekdayLabel(d) {
|
||||
const n = Math.ceil(d.getDate() / 7); // 1..5
|
||||
return `${_ORDINALS[n - 1] || `${n}th`} ${_DAYS[d.getDay()]}`;
|
||||
}
|
||||
|
||||
function _isLastWeekdayOfMonth(d) {
|
||||
const test = new Date(d);
|
||||
test.setDate(d.getDate() + 7);
|
||||
return test.getMonth() !== d.getMonth();
|
||||
}
|
||||
|
||||
function _nthWeekdayOfMonth(year, month, weekday, n) {
|
||||
const first = new Date(year, month, 1);
|
||||
const offset = (weekday - first.getDay() + 7) % 7;
|
||||
let day = 1 + offset + (n - 1) * 7;
|
||||
// Last day of month
|
||||
const lastDay = new Date(year, month + 1, 0).getDate();
|
||||
if (day > lastDay) day -= 7;
|
||||
return new Date(year, month, day, 0, 0, 0);
|
||||
}
|
||||
|
||||
function _lastWeekdayOfMonth(year, month, weekday) {
|
||||
const lastDay = new Date(year, month + 1, 0);
|
||||
const back = (lastDay.getDay() - weekday + 7) % 7;
|
||||
return new Date(year, month, lastDay.getDate() - back, 0, 0, 0);
|
||||
}
|
||||
|
||||
function _snapToRepeat(currentDate, normRepeat) {
|
||||
const hh = currentDate.getHours();
|
||||
const mm = currentDate.getMinutes();
|
||||
const now = Date.now();
|
||||
const anchor = currentDate.getTime() > now ? currentDate : new Date();
|
||||
const parts = normRepeat.split(':');
|
||||
const kind = parts[0];
|
||||
if (kind === 'weekly') {
|
||||
const targetWd = parseInt(parts[1], 10);
|
||||
if (isNaN(targetWd)) return null;
|
||||
const d = new Date(anchor.getFullYear(), anchor.getMonth(), anchor.getDate(), hh, mm, 0, 0);
|
||||
const delta = (targetWd - d.getDay() + 7) % 7;
|
||||
d.setDate(d.getDate() + delta);
|
||||
if (d.getTime() <= now) d.setDate(d.getDate() + 7);
|
||||
return d;
|
||||
}
|
||||
if (kind === 'monthly') {
|
||||
const sub = parts[1];
|
||||
let y = anchor.getFullYear();
|
||||
let m = anchor.getMonth();
|
||||
// Walk forward up to 14 months to find the next matching slot.
|
||||
for (let tries = 0; tries < 14; tries++) {
|
||||
let target;
|
||||
if (sub === 'day') {
|
||||
const wantDay = parseInt(parts[2], 10);
|
||||
if (isNaN(wantDay)) return null;
|
||||
const lastDay = new Date(y, m + 1, 0).getDate();
|
||||
target = new Date(y, m, Math.min(wantDay, lastDay));
|
||||
} else if (sub === 'nth') {
|
||||
const n = parseInt(parts[2], 10);
|
||||
const wd = parseInt(parts[3], 10);
|
||||
if (isNaN(n) || isNaN(wd)) return null;
|
||||
target = _nthWeekdayOfMonth(y, m, wd, n);
|
||||
} else if (sub === 'last') {
|
||||
const wd = parseInt(parts[2], 10);
|
||||
if (isNaN(wd)) return null;
|
||||
target = _lastWeekdayOfMonth(y, m, wd);
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
target.setHours(hh, mm, 0, 0);
|
||||
if (target.getTime() > now && target.getTime() >= anchor.getTime()) return target;
|
||||
m++;
|
||||
if (m > 11) { m = 0; y++; }
|
||||
}
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function _formatRepeatLabel(repeat, originalDate) {
|
||||
if (!repeat || repeat === 'none') return '';
|
||||
const norm = _normalizeRepeat(repeat, originalDate);
|
||||
if (norm === 'daily') return 'Daily';
|
||||
if (norm === 'yearly') return 'Yearly';
|
||||
const parts = norm.split(':');
|
||||
if (parts[0] === 'weekly') {
|
||||
const wd = parseInt(parts[1], 10);
|
||||
if (isNaN(wd)) return 'Weekly';
|
||||
return `Weekly on ${_DAYS[wd]}s`;
|
||||
}
|
||||
if (parts[0] === 'monthly') {
|
||||
if (parts[1] === 'day') return `Monthly on day ${parts[2]}`;
|
||||
if (parts[1] === 'nth') {
|
||||
const n = parseInt(parts[2], 10);
|
||||
const wd = parseInt(parts[3], 10);
|
||||
return `Monthly on ${_ORDINALS[n - 1] || `${n}th`} ${_DAYS[wd]}`;
|
||||
}
|
||||
if (parts[1] === 'last') {
|
||||
const wd = parseInt(parts[2], 10);
|
||||
return `Monthly on last ${_DAYS[wd]}`;
|
||||
}
|
||||
}
|
||||
return norm;
|
||||
}
|
||||
|
||||
function _normalizeRepeat(repeat, originalDate) {
|
||||
if (!repeat || repeat === 'none') return 'none';
|
||||
if (repeat === 'daily' || repeat === 'yearly') return repeat;
|
||||
if (/^(weekly|monthly):/.test(repeat)) return repeat;
|
||||
// Legacy bare values - derive params from the original date
|
||||
const wd = originalDate.getDay();
|
||||
const n = Math.ceil(originalDate.getDate() / 7);
|
||||
if (repeat === 'weekly') return `weekly:${wd}`;
|
||||
if (repeat === 'monthly') return `monthly:day:${originalDate.getDate()}`;
|
||||
if (repeat === 'monthly_nth_weekday') return `monthly:nth:${n}:${wd}`;
|
||||
if (repeat === 'monthly_last_weekday') return `monthly:last:${wd}`;
|
||||
return repeat;
|
||||
}
|
||||
|
||||
function _advanceRecurring(dateStr, repeat) {
|
||||
const orig = new Date(dateStr);
|
||||
const hh = orig.getHours();
|
||||
const mm = orig.getMinutes();
|
||||
let d = new Date(orig);
|
||||
const norm = _normalizeRepeat(repeat, orig);
|
||||
if (norm === 'none') return null;
|
||||
|
||||
function step() {
|
||||
if (norm === 'daily') {
|
||||
d.setDate(d.getDate() + 1);
|
||||
return;
|
||||
}
|
||||
if (norm === 'yearly') {
|
||||
d.setFullYear(d.getFullYear() + 1);
|
||||
return;
|
||||
}
|
||||
const parts = norm.split(':');
|
||||
const kind = parts[0];
|
||||
if (kind === 'weekly') {
|
||||
// Snap to the requested weekday in the next 1-7 days
|
||||
const targetWd = parseInt(parts[1], 10);
|
||||
let delta = (targetWd - d.getDay() + 7) % 7;
|
||||
if (delta === 0) delta = 7;
|
||||
d.setDate(d.getDate() + delta);
|
||||
d.setHours(hh, mm, 0, 0);
|
||||
return;
|
||||
}
|
||||
if (kind === 'monthly') {
|
||||
const sub = parts[1];
|
||||
const ny = d.getFullYear() + (d.getMonth() === 11 ? 1 : 0);
|
||||
const nm = (d.getMonth() + 1) % 12;
|
||||
let target;
|
||||
if (sub === 'day') {
|
||||
const wantDay = parseInt(parts[2], 10);
|
||||
const lastDay = new Date(ny, nm + 1, 0).getDate();
|
||||
target = new Date(ny, nm, Math.min(wantDay, lastDay));
|
||||
} else if (sub === 'nth') {
|
||||
const n = parseInt(parts[2], 10);
|
||||
const wd = parseInt(parts[3], 10);
|
||||
target = _nthWeekdayOfMonth(ny, nm, wd, n);
|
||||
} else if (sub === 'last') {
|
||||
const wd = parseInt(parts[2], 10);
|
||||
target = _lastWeekdayOfMonth(ny, nm, wd);
|
||||
} else {
|
||||
d = null; return;
|
||||
}
|
||||
target.setHours(hh, mm, 0, 0);
|
||||
d = target;
|
||||
return;
|
||||
}
|
||||
d = null;
|
||||
}
|
||||
|
||||
step();
|
||||
if (d === null) return null;
|
||||
const now = Date.now();
|
||||
// Cap catch-up to avoid runaway on a malformed/very-old date.
|
||||
let guard = 5000;
|
||||
while (d.getTime() <= now) {
|
||||
if (--guard <= 0) return null;
|
||||
step();
|
||||
if (d === null) return null;
|
||||
}
|
||||
return _toLocalDatetimeStr(d);
|
||||
}
|
||||
+4429
File diff suppressed because it is too large
Load Diff
+1226
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,142 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
import { allowsDependency, imports, checkArchitecture, inventory } from '../scripts/check_frontend.mjs';
|
||||
import * as source from '../frontend/src/shared/actionMenuOrder.js';
|
||||
import * as deployed from '../static/js/actionMenuOrder.js';
|
||||
|
||||
test('stateless helper preserves its public API, ordering, references and inputs', () => {
|
||||
for (const module of [source, deployed]) {
|
||||
assert.deepEqual(Object.keys(module).sort(), ['SELECT_MENU_ICON', 'actionMenuRank', 'orderActionMenuItems']);
|
||||
const callback = () => {};
|
||||
const items = ['Delete', 'Select', 'Copy', 'Rename', 'Archive', 'Open', 'Cancel'].map(label => Object.freeze({ label, action: callback }));
|
||||
Object.freeze(items);
|
||||
const sorted = module.orderActionMenuItems(items);
|
||||
assert.deepEqual(sorted.map(i => i.label), ['Open', 'Rename', 'Copy', 'Select', 'Archive', 'Delete', 'Cancel']);
|
||||
assert.ok(sorted.every(i => items.includes(i)));
|
||||
assert.deepEqual(module.orderActionMenuItems([]), []);
|
||||
assert.equal(module.actionMenuRank({ menuOrder: 12, label: 'Delete' }), 12);
|
||||
assert.equal(module.actionMenuRank({ label: 'Unknown' }), 100);
|
||||
const equalRank = [{ label: 'Open first' }, { label: 'Open second' }];
|
||||
assert.deepEqual(module.orderActionMenuItems(equalRank), equalRank);
|
||||
}
|
||||
assert.equal(source.SELECT_MENU_ICON, deployed.SELECT_MENU_ICON);
|
||||
});
|
||||
|
||||
test('dependency direction separates composition, workflow, adapters and views', () => {
|
||||
const n = name => `features/notes/${name}.js`;
|
||||
const allowed = [
|
||||
['app/bootstrap.js', n('index')],
|
||||
[n('index'), n('infrastructure/api')],
|
||||
[n('application/crud'), n('domain/recurrence')],
|
||||
[n('infrastructure/api'), 'shared/platform/http.js'],
|
||||
[n('ui/panel'), n('application/crud')],
|
||||
[n('application/reminders'), 'features/calendar/index.js'],
|
||||
];
|
||||
for (const [from, to] of allowed) assert.ok(allowsDependency(from, to), `${from} -> ${to}`);
|
||||
const forbidden = [
|
||||
[n('domain/recurrence'), n('infrastructure/api')],
|
||||
[n('application/crud'), n('ui/panel')],
|
||||
[n('application/crud'), n('infrastructure/api')],
|
||||
[n('ui/panel'), 'features/calendar/index.js'],
|
||||
[n('application/crud'), 'features/calendar/application/store.js'],
|
||||
['app/bootstrap.js', n('domain/recurrence')],
|
||||
['shared/platform/http.js', n('index')],
|
||||
[n('domain/recurrence'), 'app/bootstrap.js'],
|
||||
];
|
||||
for (const [from, to] of forbidden) assert.equal(allowsDependency(from, to), false, `${from} -> ${to}`);
|
||||
});
|
||||
|
||||
test('parser sees re-exports, multiline and dynamic imports without comment false positives', () => {
|
||||
const code = `// import './false.js';
|
||||
const text = "import './false.js'";
|
||||
import { a }\n from './a.js';
|
||||
export { b } from './b.js';
|
||||
export * from './c.js';
|
||||
const load = () => import('./d.js');`;
|
||||
assert.deepEqual(imports(code).map(i => i.specifier), ['./a.js', './b.js', './c.js', './d.js']);
|
||||
assert.throws(() => imports('const load = p => import(p);'), /computed import/);
|
||||
assert.throws(() => imports('export const now = () => Date.now();', { domain: true }), /ambient clock/);
|
||||
assert.throws(() => imports('export const load = () => fetch("/api/notes");', { domain: true }), /platform identifier/);
|
||||
assert.doesNotThrow(() => imports('export const day = value => new Date(value);', { domain: true }));
|
||||
assert.doesNotThrow(() => imports('export const copy = value => ({ document: value.document });', { domain: true }));
|
||||
});
|
||||
|
||||
function fixture(t, modules, { cache = true } = {}) {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ody-frontend-gates-'));
|
||||
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
const entries = Object.entries(modules).map(([name, code]) => {
|
||||
const file = path.join(root, 'frontend/src', name);
|
||||
fs.mkdirSync(path.dirname(file), { recursive: true });
|
||||
fs.writeFileSync(file, code);
|
||||
return { source: name, deployment: `js/${name}` };
|
||||
});
|
||||
fs.mkdirSync(path.join(root, 'static'), { recursive: true });
|
||||
fs.writeFileSync(path.join(root, 'frontend/modules.json'), JSON.stringify({ version: 1, modules: entries }));
|
||||
const urls = cache ? entries.map(m => `/static/${m.deployment}`) : [];
|
||||
fs.writeFileSync(path.join(root, 'static/sw.js'), `const PRECACHE = ${JSON.stringify(urls)}; const PANEL_PRECACHE = [];`);
|
||||
return root;
|
||||
}
|
||||
|
||||
test('complete mapped graph and exact precache pass', t => {
|
||||
const root = fixture(t, { 'features/notes/index.js': "export { value } from './domain/value.js';", 'features/notes/domain/value.js': 'export const value = 1;' });
|
||||
assert.equal(checkArchitecture(root), 2);
|
||||
});
|
||||
|
||||
test('feature-private import fails through the full checker', t => {
|
||||
const root = fixture(t, { 'features/notes/application/a.js': "import '../../calendar/domain/b.js';", 'features/calendar/domain/b.js': 'export const value = 1;' });
|
||||
assert.throws(() => checkArchitecture(root), /forbidden dependency/);
|
||||
});
|
||||
|
||||
test('undeclared legacy coupling and alternate identities fail', t => {
|
||||
for (const specifier of ['/static/js/notes.js?v=legacy', './b.js?v=other', '/frontend/src/shared/b.js', 'some-package']) {
|
||||
const root = fixture(t, { 'shared/a.js': `import '${specifier}';`, 'shared/b.js': 'export const value = 1;' });
|
||||
assert.throws(() => checkArchitecture(root), /legacy import|one unversioned|unsupported|canonical static/);
|
||||
}
|
||||
});
|
||||
|
||||
test('a copy mapping cannot silently change relative import resolution', t => {
|
||||
const root = fixture(t, { 'shared/a.js': "import './b.js';", 'shared/b.js': 'export const value = 1;' });
|
||||
const manifest = JSON.parse(fs.readFileSync(path.join(root, 'frontend/modules.json')));
|
||||
manifest.modules[1].deployment = 'js/elsewhere/b.js';
|
||||
fs.writeFileSync(path.join(root, 'frontend/modules.json'), JSON.stringify(manifest));
|
||||
fs.writeFileSync(path.join(root, 'static/sw.js'), "const PRECACHE = ['/static/js/shared/a.js', '/static/js/elsewhere/b.js'];");
|
||||
assert.throws(() => checkArchitecture(root), /resolve differently/);
|
||||
});
|
||||
|
||||
test('new modules must join exact offline manifests and cannot introduce cycles', t => {
|
||||
const missing = fixture(t, { 'shared/a.js': 'export const a = 1;' }, { cache: false });
|
||||
assert.throws(() => checkArchitecture(missing), /absent from explicit precache/);
|
||||
const cyclic = fixture(t, { 'shared/a.js': "import './b.js';", 'shared/b.js': "import './a.js';" });
|
||||
assert.throws(() => checkArchitecture(cyclic), /dependency cycle/);
|
||||
});
|
||||
|
||||
test('legacy adapters require exact, used exceptions and reserved names cannot bypass ownership', t => {
|
||||
const root = fixture(t, { 'features/notes/infrastructure/api.js': "import '/static/js/legacy.js?v=existing';" });
|
||||
fs.mkdirSync(path.join(root, 'static/js'), { recursive: true });
|
||||
fs.writeFileSync(path.join(root, 'static/js/legacy.js'), 'export const legacy = true;');
|
||||
const file = path.join(root, 'frontend/modules.json');
|
||||
const manifest = JSON.parse(fs.readFileSync(file));
|
||||
manifest.modules[0].legacyImports = [{ specifier: '/static/js/legacy.js?v=existing', reason: 'Temporary existing transport adapter; remove in N2.' }];
|
||||
fs.writeFileSync(file, JSON.stringify(manifest));
|
||||
assert.equal(checkArchitecture(root), 1);
|
||||
manifest.modules[0].legacyImports.push({ specifier: '/static/js/unused.js', reason: 'stale' });
|
||||
fs.writeFileSync(file, JSON.stringify(manifest));
|
||||
assert.throws(() => checkArchitecture(root), /unused legacy import exception/);
|
||||
assert.throws(() => allowsDependency('features/shared/index.js', 'app/bootstrap.js'), /reserved feature name/);
|
||||
});
|
||||
|
||||
test('inventory reports executable static cycles and separates dynamic edges', t => {
|
||||
const root = fixture(t, { 'shared/a.js': 'export const a = 1;' });
|
||||
fs.mkdirSync(path.join(root, 'static/js'), { recursive: true });
|
||||
fs.writeFileSync(path.join(root, 'static/js/a.js'), "import './b.js'; const load = () => import('./c.js');");
|
||||
fs.writeFileSync(path.join(root, 'static/js/b.js'), "import './a.js';");
|
||||
fs.writeFileSync(path.join(root, 'static/js/c.js'), 'export const c = 1;');
|
||||
const result = inventory(root);
|
||||
assert.equal(result.staticImports, 2);
|
||||
assert.equal(result.dynamicLiteralImports, 1);
|
||||
assert.deepEqual(result.staticCycles, [['js/a.js', 'js/b.js']]);
|
||||
assert.deepEqual(result.missing, []);
|
||||
});
|
||||
@@ -0,0 +1,10 @@
|
||||
"""Read deployed Notes responsibilities for source guards; runtime tests execute their real modules."""
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
|
||||
|
||||
def notes_source(*modules):
|
||||
base = ROOT / "static/js/features/notes"
|
||||
paths = [base / f"{module}.js" for module in modules] if modules else sorted(base.rglob("*.js"))
|
||||
return "\n".join(path.read_text(encoding="utf-8") for path in paths)
|
||||
@@ -0,0 +1,93 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createNotesState } from '../frontend/src/features/notes/application/state.js';
|
||||
import { createNotesWorkflows } from '../frontend/src/features/notes/application/workflows.js';
|
||||
import { createLayout } from '../frontend/src/features/notes/ui/layout.js';
|
||||
|
||||
test('views cannot mutate Notes state, but captured identities observe application updates', () => {
|
||||
const app = createNotesState();
|
||||
app.replaceNotes([{ id: 'a', items: [{ text: 'Task', done: false }] }]);
|
||||
const note = app.state.notes[0];
|
||||
assert.throws(() => { note.items[0].done = true; }, /state operations/);
|
||||
assert.throws(() => app.state.notes.push({ id: 'other' }), /state operations/);
|
||||
assert.throws(() => app.state.selectedIds.add('a'), /selection operations/);
|
||||
assert.throws(() => Object.defineProperty(note, 'title', { value: 'unowned' }), /state operations/);
|
||||
assert.throws(() => Object.preventExtensions(note), /state operations/);
|
||||
assert.throws(() => Object.getOwnPropertyDescriptor(note, 'items').value.push({}), /state operations/);
|
||||
app.updateItem(note, 0, { done: true });
|
||||
assert.equal(note.items[0].done, true);
|
||||
assert.equal(app.selectId('a'), undefined, 'selection operations do not leak the mutable set');
|
||||
assert.deepEqual([...app.state.selectedIds], ['a']);
|
||||
assert.throws(() => app.state.selectedIds.valueOf().clear(), /selection operations/);
|
||||
app.state.selectedIds.forEach((_value, _key, set) => {
|
||||
assert.equal(set, app.state.selectedIds);
|
||||
assert.throws(() => set.clear(), /selection operations/);
|
||||
});
|
||||
app.mergeNote(note, { title: 'Changed' });
|
||||
assert.equal(note.title, 'Changed');
|
||||
});
|
||||
|
||||
test('fetch preserves archived query, failures clear notes, and successful fallback shapes', async () => {
|
||||
const app = createNotesState(), requests = [];
|
||||
let response = { ok: true, json: async () => [{ id: 'a' }] };
|
||||
const workflows = createNotesWorkflows({ app, api: { listNotes: async archived => { requests.push(archived); return response; } }, effects: {} });
|
||||
app.setShowingArchived(true);
|
||||
await workflows._fetchNotes();
|
||||
assert.deepEqual(requests, [true]);
|
||||
assert.equal(app.state.loading, false);
|
||||
assert.equal(app.state.notes[0].id, 'a');
|
||||
response = { ok: false };
|
||||
await workflows._fetchNotes();
|
||||
assert.equal(app.state.notes.length, 0);
|
||||
response = { ok: true, json: async () => ({ notes: [{ id: 'b' }] }) };
|
||||
await workflows._fetchNotes();
|
||||
assert.equal(app.state.notes[0].id, 'b');
|
||||
});
|
||||
|
||||
test('fetch completions retain the original last-completion ordering', async () => {
|
||||
const app = createNotesState(), releases = [];
|
||||
const workflows = createNotesWorkflows({ app, api: { listNotes: () => new Promise(resolve => releases.push(resolve)) }, effects: {} });
|
||||
const first = workflows._fetchNotes(), second = workflows._fetchNotes();
|
||||
releases[1]({ ok: true, json: async () => ({ notes: [{ id: 'second' }] }) });
|
||||
await second;
|
||||
assert.equal(app.state.notes[0].id, 'second');
|
||||
releases[0]({ ok: true, json: async () => ({ notes: [{ id: 'first' }] }) });
|
||||
await first;
|
||||
assert.equal(app.state.notes[0].id, 'first');
|
||||
});
|
||||
|
||||
test('writes built from views store raw data, not read-only views', () => {
|
||||
const app = createNotesState();
|
||||
const raw = [{ id: 'a', items: [{ id: 'x', done: false }, { id: 'y', done: false }] }];
|
||||
app.replaceNotes(raw);
|
||||
const note = app.state.notes[0];
|
||||
app.mergeNote(note, { items: note.items.filter(item => item.id !== 'x') });
|
||||
app.prependNote({ ...note, id: 'b' });
|
||||
app.spliceItems(app.state.notes[1], 1, 0, note.items[0]);
|
||||
assert.doesNotThrow(() => structuredClone(raw));
|
||||
raw[1].items[0].done = true;
|
||||
assert.equal(app.state.notes[1].items[0].done, true);
|
||||
assert.throws(() => { app.state.notes[1].items[0].done = false; }, /state operations/);
|
||||
});
|
||||
|
||||
test('masonry observer is not re-created once Notes is closed', () => {
|
||||
const created = [];
|
||||
const previous = { window: globalThis.window, ResizeObserver: globalThis.ResizeObserver };
|
||||
class FakeObserver { constructor() { created.push(this); this.live = true; } observe() {} disconnect() { this.live = false; } }
|
||||
globalThis.ResizeObserver = FakeObserver;
|
||||
globalThis.window = { ResizeObserver: FakeObserver, matchMedia: () => ({ matches: false }) };
|
||||
try {
|
||||
let open = true;
|
||||
const layout = createLayout({ isOpen: () => open });
|
||||
const body = { isConnected: true, closest: () => ({ classList: { contains: name => name === 'notes-view-grid' } }), querySelector: () => null, querySelectorAll: () => [] };
|
||||
layout._applyMasonry(body);
|
||||
assert.equal(created.filter(o => o.live).length, 1);
|
||||
layout.dispose();
|
||||
open = false;
|
||||
layout._applyMasonry(body);
|
||||
assert.equal(created.filter(o => o.live).length, 0);
|
||||
} finally {
|
||||
globalThis.window = previous.window;
|
||||
globalThis.ResizeObserver = previous.ResizeObserver;
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,105 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import vm from 'node:vm';
|
||||
import { parse } from 'acorn';
|
||||
import { createNotesApi } from '../frontend/src/features/notes/infrastructure/http.js';
|
||||
import { createNotesState } from '../frontend/src/features/notes/application/state.js';
|
||||
import { createNotesWorkflows } from '../frontend/src/features/notes/application/workflows.js';
|
||||
|
||||
const source = fs.readFileSync(new URL('../static/js/notes.js', import.meta.url), 'utf8');
|
||||
const ast = parse(source, { ecmaVersion: 'latest', sourceType: 'module' });
|
||||
const implementation = fs.readFileSync(new URL('../frontend/src/features/notes/index.js', import.meta.url), 'utf8');
|
||||
const implementationAST = parse(implementation, { ecmaVersion: 'latest', sourceType: 'module' });
|
||||
function functions(names, ports = {}) {
|
||||
const datesSource = fs.readFileSync(new URL('../frontend/src/features/notes/domain/recurrence.js', import.meta.url), 'utf8').replaceAll('export ', '');
|
||||
const context = vm.createContext(ports);
|
||||
const datesContext = vm.createContext({ ...ports, clock: { now: () => (ports.Date || Date).now() } });
|
||||
vm.runInContext(datesSource, datesContext);
|
||||
context.dates = datesContext;
|
||||
context.clock = { now: () => (ports.Date || Date).now() };
|
||||
context.api = createNotesApi({ origin: ports.API_BASE, request: ports.fetch });
|
||||
context._saveNote = context.api.saveNote;
|
||||
context._patchNote = context.api.patchNote;
|
||||
context._deleteNoteApi = context.api.deleteNote;
|
||||
context.workflows = createNotesWorkflows({ app: createNotesState(), api: context.api, effects: {} });
|
||||
for (const statement of implementationAST.body) {
|
||||
const node = statement.declaration || statement;
|
||||
if (node.type === 'FunctionDeclaration' && names.includes(node.id.name)) {
|
||||
vm.runInContext(implementation.slice(node.start, node.end), context);
|
||||
}
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
test('legacy public namespace and default API are intentional', () => {
|
||||
const names = [];
|
||||
for (const node of ast.body) {
|
||||
if (node.type !== 'ExportNamedDeclaration') continue;
|
||||
if (node.declaration) names.push(node.declaration.id.name);
|
||||
for (const spec of node.specifiers) if (spec.exported.name !== 'default') names.push(spec.exported.name);
|
||||
}
|
||||
assert.deepEqual(names.sort(), ['closeNotes', 'closePanel', 'dismissFiredReminderDot', 'isNotesOpen', 'isPanelOpen', 'newNote', 'openNote', 'openNotes', 'openPanel', 'refreshDueBadge', 'togglePanel']);
|
||||
assert.match(source, /window.notesModule = notesModule/);
|
||||
});
|
||||
|
||||
test('CRUD preserves same-origin credentials, payloads and error contracts', async () => {
|
||||
const requests = [];
|
||||
let ok = true;
|
||||
const api = functions(['_saveNote', '_patchNote', '_deleteNoteApi'], {
|
||||
API_BASE: 'https://notes.invalid',
|
||||
fetch: async (url, options) => {
|
||||
requests.push({ url, ...options });
|
||||
return { ok, status: 403, json: async () => ({ id: 'saved' }) };
|
||||
},
|
||||
});
|
||||
await api._saveNote({ title: 'New' });
|
||||
await api._saveNote({ id: 'a', title: '' });
|
||||
await api._patchNote('a', { due_date: null });
|
||||
await api._deleteNoteApi('a');
|
||||
assert.deepEqual(requests.map(r => [r.url, r.method, r.credentials]), [
|
||||
['https://notes.invalid/api/notes', 'POST', 'same-origin'],
|
||||
['https://notes.invalid/api/notes/a', 'PUT', 'same-origin'],
|
||||
['https://notes.invalid/api/notes/a', 'PUT', 'same-origin'],
|
||||
['https://notes.invalid/api/notes/a', 'DELETE', 'same-origin'],
|
||||
]);
|
||||
assert.equal(requests[2].body, '{"due_date":null}');
|
||||
ok = false;
|
||||
await assert.rejects(api._saveNote({}), /Failed to save note/);
|
||||
await assert.rejects(api._patchNote('a', {}), /Failed to update note/);
|
||||
await assert.rejects(api._deleteNoteApi('a'), /HTTP 403/);
|
||||
});
|
||||
|
||||
test('recurrence clock is read after first advancement and never for none', () => {
|
||||
const calls = [];
|
||||
class ClockDate extends Date {
|
||||
static now() { calls.push('clock'); return Date.parse('2024-02-01T12:00'); }
|
||||
setDate(value) { calls.push('step'); return super.setDate(value); }
|
||||
}
|
||||
const domain = functions(['_normalizeRepeat', '_advanceRecurring', '_toLocalDatetimeStr', '_nthWeekdayOfMonth', '_lastWeekdayOfMonth'], { Date: ClockDate });
|
||||
assert.equal(domain.dates._advanceRecurring('2024-02-01T09:00', 'daily', domain.clock), '2024-02-02T09:00');
|
||||
assert.deepEqual(calls, ['step', 'clock']);
|
||||
calls.length = 0;
|
||||
assert.equal(domain.dates._advanceRecurring('2024-02-01T09:00', 'none', domain.clock), null);
|
||||
assert.deepEqual(calls, []);
|
||||
});
|
||||
|
||||
test('archive failure restores position; undo failure rolls back reinsertion', async () => {
|
||||
const notes = [{ id: 'a' }, { id: 'b' }, { id: 'c' }], errors = [];
|
||||
const stateOwner = createNotesState();
|
||||
stateOwner.replaceNotes(notes);
|
||||
const app = createNotesWorkflows({
|
||||
app: stateOwner,
|
||||
api: { patchNote: async () => { throw new Error('refused'); } },
|
||||
effects: { render: () => {}, showError: e => errors.push(e) },
|
||||
});
|
||||
assert.equal(app._archiveNoteById('b'), true);
|
||||
assert.deepEqual(notes.map(n => n.id), ['a', 'c']);
|
||||
await new Promise(setImmediate);
|
||||
assert.deepEqual(notes.map(n => n.id), ['a', 'b', 'c']);
|
||||
notes.splice(1, 1);
|
||||
app._popAndRunUndo();
|
||||
await new Promise(setImmediate);
|
||||
assert.deepEqual(notes.map(n => n.id), ['a', 'c']);
|
||||
assert.deepEqual(errors, ['Failed to archive', 'Undo failed']);
|
||||
});
|
||||
@@ -1,21 +1,28 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import vm from 'node:vm';
|
||||
import { createNotesPersistence } from '../frontend/src/features/notes/infrastructure/storage.js';
|
||||
import { createNotesState } from '../frontend/src/features/notes/application/state.js';
|
||||
import { createNotesDrafts } from '../frontend/src/features/notes/application/drafts.js';
|
||||
import { createDraftEditor } from '../frontend/src/features/notes/ui/draftEditor.js';
|
||||
|
||||
const source = fs.readFileSync(new URL('../static/js/notes.js', import.meta.url), 'utf8');
|
||||
function harness(patch = async () => ({})) {
|
||||
const data = new Map(), timers = new Map(), errors = [];
|
||||
let nextTimer = 0;
|
||||
const context = vm.createContext({
|
||||
const context = {
|
||||
localStorage: { getItem: k => data.get(k) ?? null, setItem: (k,v) => data.set(k,v), removeItem: k => data.delete(k) },
|
||||
setTimeout: f => { timers.set(++nextTimer, f); return nextTimer; },
|
||||
clearTimeout: id => timers.delete(id),
|
||||
_patchNote: patch, _notes: [{id:'note1'}],
|
||||
_collectItems: form => form.items,
|
||||
uiModule: {showError: message => errors.push(message)},
|
||||
});
|
||||
vm.runInContext(source.slice(source.indexOf("const _DRAFT_PREFIX"), source.indexOf('// ---- Create / Edit Form ----')), context);
|
||||
};
|
||||
context.persistence = createNotesPersistence({ storage: context.localStorage });
|
||||
context.app = createNotesState();
|
||||
context.app.replaceNotes(context._notes);
|
||||
context.state = context.app.state;
|
||||
Object.assign(context, context.persistence);
|
||||
const drafts = createNotesDrafts({ app: context.app, api: { patchNote: patch }, persistence: context.persistence, scheduler: context, effects: context.uiModule });
|
||||
Object.assign(context, createDraftEditor({ ...context, ...context.persistence, bindDraft: drafts.bindDraft }));
|
||||
const fields = {'.note-form-title':{value:'Title'}, '.note-form-content':{value:'Original'}};
|
||||
const handlers = {};
|
||||
const form = { dataset:{noteType:'note'}, items:[], querySelector: s => fields[s], addEventListener:(n,f) => handlers[n]=f };
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createNotesApi } from '../frontend/src/features/notes/infrastructure/http.js';
|
||||
import { createNotesPersistence, REMINDER_FIRED_KEY } from '../frontend/src/features/notes/infrastructure/storage.js';
|
||||
|
||||
test('list, reorder, upload, reminder and agent transports retain exact requests', async () => {
|
||||
const calls = [], result = { ok: false, status: 403 };
|
||||
const api = createNotesApi({ origin: 'https://notes.invalid', request: async (...args) => { calls.push(args); return result; } });
|
||||
const body = { opaque: true };
|
||||
assert.equal(await api.listNotes(true), result);
|
||||
await api.listNotes(false); await api.reorder(['b', 'a']); await api.upload(body);
|
||||
await api.fireReminder({ note_id: 'a' }); await api.defaultChat();
|
||||
await api.createSession(body); await api.startChat(body);
|
||||
assert.deepEqual(calls.map(([url, o]) => [url, o.method || 'GET', o.credentials]), [
|
||||
['https://notes.invalid/api/notes?archived=true', 'GET', 'same-origin'],
|
||||
['https://notes.invalid/api/notes', 'GET', 'same-origin'],
|
||||
['https://notes.invalid/api/notes/reorder', 'POST', 'same-origin'],
|
||||
['https://notes.invalid/api/upload', 'POST', 'same-origin'],
|
||||
['/api/notes/fire-reminder', 'POST', 'same-origin'],
|
||||
['https://notes.invalid/api/default-chat', 'GET', 'same-origin'],
|
||||
['https://notes.invalid/api/session', 'POST', 'same-origin'],
|
||||
['https://notes.invalid/api/chat_stream', 'POST', 'same-origin'],
|
||||
]);
|
||||
assert.equal(calls[2][1].body, '{"ids":["b","a"]}');
|
||||
assert.equal(calls[3][1].body, body);
|
||||
assert.equal(api.uploadUrl('file'), 'https://notes.invalid/api/upload/file');
|
||||
});
|
||||
|
||||
test('draft and marker persistence retain keys, formats and malformed fallback', () => {
|
||||
const values = new Map();
|
||||
const storage = { getItem: key => values.get(key) ?? null, setItem: (key, value) => values.set(key, value), removeItem: key => values.delete(key) };
|
||||
const persistence = createNotesPersistence({ storage });
|
||||
assert.equal(persistence._draftKey(null), 'odysseus-note-draft-__new__');
|
||||
persistence._saveFiredReminders(new Set(['a', 'b']));
|
||||
assert.equal(values.get(REMINDER_FIRED_KEY), '["a","b"]');
|
||||
assert.deepEqual([...persistence._loadFiredReminders()], ['a', 'b']);
|
||||
values.set(REMINDER_FIRED_KEY, '{broken');
|
||||
assert.equal(persistence._loadFiredReminders().size, 0);
|
||||
storage.setItem(persistence._draftKey('a'), '{"content":"","_ts":1}');
|
||||
assert.deepEqual(persistence._loadDraft('a'), { content: '', _ts: 1 });
|
||||
persistence._clearDraft('a');
|
||||
assert.equal(persistence._loadDraft('a'), null);
|
||||
const failing = createNotesPersistence({ storage: { getItem() { throw Error('blocked'); }, setItem() { throw Error('full'); } } });
|
||||
assert.equal(failing._loadDraft('a'), null);
|
||||
assert.doesNotThrow(() => failing._saveFiredReminders(new Set(['a'])));
|
||||
assert.throws(() => failing.setItem('odysseus-note-draft-a', '{}'), /full/, 'draft caller must observe backup failure');
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import vm from 'node:vm';
|
||||
import * as rules from '../frontend/src/features/notes/domain/recurrence.js';
|
||||
|
||||
const baseline = fs.readFileSync(new URL('./fixtures/notesRecurrenceBaseline.mjs', import.meta.url), 'utf8');
|
||||
const timed = new Set(['_formatDueDate', '_isDueOverdue', '_isDueTodayOrOverdue', '_laterTodayDate', '_tomorrowDate', '_nextWeekDate', '_formatReminderTag', '_snapToRepeat', '_advanceRecurring']);
|
||||
for (const zone of ['UTC', 'Europe/Lisbon', 'America/New_York', 'Australia/Sydney']) {
|
||||
test(`recurrence differential oracle in ${zone}`, () => {
|
||||
const oldZone = process.env.TZ;
|
||||
process.env.TZ = zone;
|
||||
try {
|
||||
const now = Date.parse('2024-03-10T01:59:00');
|
||||
class FixedDate extends Date { constructor(...args) { super(...(args.length ? args : [now])); } static now() { return now; } }
|
||||
const original = vm.createContext({ Date: FixedDate });
|
||||
vm.runInContext(baseline, original);
|
||||
const compare = (name, args) => {
|
||||
const before = original[name](...args);
|
||||
const after = rules[name](...args, ...(timed.has(name) ? [{ now: () => now }] : []));
|
||||
assert.equal(after instanceof Date ? after.getTime() : after, before instanceof Date ? before.getTime() : before, `${name}(${args})`);
|
||||
};
|
||||
const dates = ['2024-01-31T09:17', '2024-02-29T09:17', '2023-02-28T09:17', '2024-03-09T02:30', '2024-11-02T01:30', '2030-07-31T18:42', '1900-01-01T09:00', 'invalid', '2024-03-10'];
|
||||
const repeats = [null, '', 'none', 'daily', 'weekly', 'monthly', 'yearly', 'monthly_nth_weekday', 'monthly_last_weekday', 'weekly:1', 'weekly:bad', 'monthly:day:31', 'monthly:nth:5:1', 'monthly:last:0', 'monthly:bad', 'unknown'];
|
||||
for (const date of dates) {
|
||||
for (const repeat of repeats) {
|
||||
compare('_advanceRecurring', [date, repeat]);
|
||||
compare('_normalizeRepeat', [repeat, new Date(date)]);
|
||||
compare('_formatRepeatLabel', [repeat, new Date(date)]);
|
||||
}
|
||||
for (const name of ['_hasTimeComponent', '_formatDueDate', '_isDueOverdue', '_isDueTodayOrOverdue', '_formatReminderTag']) compare(name, [date]);
|
||||
for (const repeat of ['weekly:1', 'monthly:day:31', 'monthly:nth:5:1', 'monthly:last:0', 'daily', 'monthly:bad']) compare('_snapToRepeat', [new Date(date), repeat]);
|
||||
}
|
||||
for (const name of ['_laterTodayDate', '_tomorrowDate', '_nextWeekDate']) compare(name, []);
|
||||
for (const year of [2023, 2024]) for (let month = 0; month < 12; month++) for (let wd = 0; wd < 7; wd++) {
|
||||
compare('_nthWeekdayOfMonth', [year, month, wd, 5]);
|
||||
compare('_lastWeekdayOfMonth', [year, month, wd]);
|
||||
}
|
||||
} finally { if (oldZone === undefined) delete process.env.TZ; else process.env.TZ = oldZone; }
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createNotesState } from '../frontend/src/features/notes/application/state.js';
|
||||
import { createNotesReminders } from '../frontend/src/features/notes/application/reminders.js';
|
||||
import { createNotesNotifications } from '../frontend/src/features/notes/infrastructure/notifications.js';
|
||||
import { createNotesPersistence, REMINDER_DISMISSED_AT_KEY } from '../frontend/src/features/notes/infrastructure/storage.js';
|
||||
|
||||
function harness(notes = []) {
|
||||
const now = Date.parse('2024-03-10T09:00'), app = createNotesState();
|
||||
app.replaceNotes(notes);
|
||||
const values = new Map(), timeouts = new Map(), intervals = new Map(), fired = [], shown = [], highlighted = [], patches = [];
|
||||
let nextId = 0, response = Promise.resolve({ ok: true, json: async () => ({ synthesis: 'Synthesis' }) });
|
||||
const scheduler = {
|
||||
setTimeout: (fn, ms) => { const id = ++nextId; timeouts.set(id, { fn, ms }); return id; }, clearTimeout: id => timeouts.delete(id),
|
||||
setInterval: (fn, ms) => { const id = ++nextId; intervals.set(id, { fn, ms }); return id; }, clearInterval: id => intervals.delete(id),
|
||||
};
|
||||
const persistence = createNotesPersistence({ storage: { getItem: key => values.get(key), setItem: (key, value) => values.set(key, value) } });
|
||||
const reminders = createNotesReminders({
|
||||
app, clock: { now: () => now }, scheduler, persistence, dismissedKey: REMINDER_DISMISSED_AT_KEY,
|
||||
notifications: { show: (...args) => shown.push(args) }, fetchNotes: async () => {},
|
||||
hasItems: note => ['todo', 'goal', 'checklist'].includes(note.note_type), isNoteFullyDone: () => false,
|
||||
api: { listNotes: async () => ({ ok: true, json: async () => ({ notes }) }), patchNote: async (...args) => patches.push(args), fireReminder: body => { fired.push(body); return response; } },
|
||||
effects: { badge: () => {}, highlight: id => highlighted.push(id) },
|
||||
});
|
||||
return { now, app, reminders, timeouts, intervals, fired, shown, highlighted, patches, persistence, setResponse: value => { response = value; } };
|
||||
}
|
||||
|
||||
test('startup and loop are idempotent, and panel closure leaves background scheduling alive', async () => {
|
||||
const h = harness();
|
||||
h.reminders.start(); h.reminders.start();
|
||||
assert.deepEqual([...h.timeouts.values()].map(t => t.ms), [3000]);
|
||||
await [...h.timeouts.values()][0].fn();
|
||||
h.reminders._startReminderLoop();
|
||||
assert.deepEqual([...h.intervals.values()].map(t => t.ms), [30000]);
|
||||
h.app.setOpen(false);
|
||||
assert.equal(h.intervals.size, 1);
|
||||
h.reminders.dispose();
|
||||
assert.equal(h.intervals.size, 0);
|
||||
h.reminders._startReminderLoop();
|
||||
assert.equal(h.intervals.size, 0);
|
||||
});
|
||||
|
||||
test('fresh reminders dispatch once, stale reminders mark silently, recurring reminders advance', async () => {
|
||||
const now = Date.parse('2024-03-10T09:00');
|
||||
const local = value => { const d = new Date(value); return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}T${String(d.getHours()).padStart(2,'0')}:${String(d.getMinutes()).padStart(2,'0')}:30`; };
|
||||
const h = harness([
|
||||
{ id: 'fresh', due_date: local(now - 30000), content: 'Body' },
|
||||
{ id: 'old', due_date: local(now - 180000) },
|
||||
{ id: 'recurring', due_date: local(now - 180000), repeat: 'daily' },
|
||||
{ id: 'date', due_date: '2024-03-10' },
|
||||
{ id: 'archived', archived: true, due_date: local(now - 30000) },
|
||||
]);
|
||||
h.reminders._checkReminders(); h.reminders._checkReminders();
|
||||
await new Promise(setImmediate);
|
||||
assert.deepEqual(h.fired.map(n => n.note_id), ['fresh']);
|
||||
assert.deepEqual([...h.persistence._loadFiredReminders()], ['fresh', 'old']);
|
||||
assert.equal(h.patches.length, 1);
|
||||
assert.equal(h.app.state.notes[2].due_date, '2024-03-11T08:57');
|
||||
assert.deepEqual(h.highlighted, ['fresh']);
|
||||
assert.equal(h.shown[0][1], 'Synthesis');
|
||||
});
|
||||
|
||||
test('slow synthesis and rejected dispatch use the local fallback exactly once', async () => {
|
||||
const h = harness();
|
||||
let resolve;
|
||||
h.setResponse(new Promise(r => { resolve = r; }));
|
||||
h.reminders._fireReminder({ id: 'a', title: 'Title', content: 'Raw' });
|
||||
const timer = [...h.timeouts.values()].find(t => t.ms === 1500);
|
||||
timer.fn();
|
||||
resolve({ ok: true, json: async () => ({ synthesis: 'Too late' }) });
|
||||
await new Promise(setImmediate);
|
||||
assert.deepEqual(h.shown, [['Title', 'Raw', 'a']]);
|
||||
h.setResponse(Promise.reject(Error('offline')));
|
||||
h.reminders._fireReminder({ id: 'b', note_type: 'todo', items: [{ text: 'Task', done: false }] });
|
||||
await new Promise(setImmediate);
|
||||
assert.equal(h.shown.length, 2);
|
||||
assert.equal(h.fired[1].body, 'Pending (1):\n- Task');
|
||||
});
|
||||
|
||||
test('notification denial, unsupported browsers and permission errors remain fail-safe', async () => {
|
||||
const toast = [];
|
||||
const absent = createNotesNotifications({ browser: {}, toast: title => toast.push(title) });
|
||||
assert.equal(await absent.ensurePermission(), false);
|
||||
absent.show('Title', 'Body', 'a');
|
||||
assert.deepEqual(toast, ['Title']);
|
||||
const denied = createNotesNotifications({ browser: { Notification: true }, notify: { permission: 'denied' } });
|
||||
assert.equal(await denied.ensurePermission(), false);
|
||||
const failed = createNotesNotifications({ browser: { Notification: true }, notify: { permission: 'default', requestPermission: async () => { throw Error('blocked'); } } });
|
||||
assert.equal(await failed.ensurePermission(), false);
|
||||
});
|
||||
@@ -0,0 +1,147 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createNotesState } from '../frontend/src/features/notes/application/state.js';
|
||||
import { createCardOperations } from '../frontend/src/features/notes/application/cardOperations.js';
|
||||
import { createChecklistOperations } from '../frontend/src/features/notes/application/checklistOperations.js';
|
||||
import { createNotesSubmission } from '../frontend/src/features/notes/application/submission.js';
|
||||
import { createSelectionOperations } from '../frontend/src/features/notes/application/selection.js';
|
||||
import { createNotesPersistence } from '../frontend/src/features/notes/infrastructure/storage.js';
|
||||
import { createNotesLifecycle } from '../frontend/src/features/notes/ui/lifecycle.js';
|
||||
import { createText } from '../frontend/src/features/notes/ui/text.js';
|
||||
import { createDeletion } from '../frontend/src/features/notes/application/deletion.js';
|
||||
|
||||
const drain = () => new Promise(setImmediate);
|
||||
function owner(notes) { const app = createNotesState(); app.replaceNotes(notes); return app; }
|
||||
|
||||
test('reorder retains desktop persistence-only and mobile post-response state ordering', async () => {
|
||||
const app = owner([{ id: 'a', sort_order: 9 }, { id: 'b', sort_order: 8 }]), requests = [];
|
||||
let resolve;
|
||||
const operations = createCardOperations({ app, api: { reorder: ids => { requests.push(ids); return new Promise(done => { resolve = done; }); } } });
|
||||
const desktop = operations.persistOrder(['b', 'a']);
|
||||
resolve({ ok: false }); await desktop;
|
||||
assert.deepEqual(app.state.notes.map(note => note.sort_order), [9, 8]);
|
||||
const mobile = operations.commitMobileOrder(['b', 'a']);
|
||||
assert.deepEqual(app.state.notes.map(note => note.sort_order), [9, 8]);
|
||||
resolve({ ok: false }); await mobile;
|
||||
assert.deepEqual(app.state.notes.map(note => note.sort_order), [1, 0]);
|
||||
assert.deepEqual(requests, [['b', 'a'], ['b', 'a']]);
|
||||
});
|
||||
|
||||
test('editor deletion waits for confirmation and preserves optimistic failure/refetch timing', async () => {
|
||||
const app = owner([{ id: 'a' }]), events = [];
|
||||
app.setEditingId('a');
|
||||
let confirm, fail;
|
||||
const operations = createDeletion({ app, state: app.state,
|
||||
_deleteNoteApi: id => { events.push('delete:' + id); return new Promise((_resolve, reject) => { fail = reject; }); },
|
||||
_fetchNotes: async () => { events.push('fetch'); app.replaceNotes([{ id: 'a' }]); },
|
||||
_renderNotes: () => events.push('render'),
|
||||
effects: { styledConfirm: () => new Promise(resolve => { confirm = resolve; }), showToast: () => events.push('toast'), showError: error => events.push(error) },
|
||||
});
|
||||
const deletion = operations.deleteFromEditor('a');
|
||||
assert.equal(app.state.notes.length, 1);
|
||||
confirm(false); await deletion;
|
||||
assert.deepEqual(events, []);
|
||||
const accepted = operations.deleteFromEditor('a');
|
||||
confirm(true); await accepted;
|
||||
assert.equal(app.state.notes.length, 0);
|
||||
assert.equal(app.state.editingId, null);
|
||||
assert.deepEqual(events, ['render', 'delete:a']);
|
||||
fail(Error('403')); await drain();
|
||||
assert.equal(app.state.notes[0].id, 'a');
|
||||
assert.deepEqual(events, ['render', 'delete:a', 'Failed to delete', 'fetch', 'render']);
|
||||
});
|
||||
|
||||
test('pin and delete failures roll back captured state and order', async () => {
|
||||
const app = owner([{ id: 'a', pinned: false, sort_order: 4 }, { id: 'b', pinned: true, sort_order: -3 }]), errors = [];
|
||||
const operations = createCardOperations({ app, api: { patchNote: async () => { throw Error('403'); }, deleteNote: async () => { throw Error('403'); } }, effects: { render() {}, showError: error => errors.push(error) } });
|
||||
operations.togglePin('a');
|
||||
assert.equal(app.state.notes[0].pinned, true);
|
||||
assert.equal(app.state.notes[0].sort_order, -4);
|
||||
await drain();
|
||||
assert.equal(app.state.notes[0].pinned, false);
|
||||
assert.equal(app.state.notes[0].sort_order, 4);
|
||||
operations.deleteOptimistically('a');
|
||||
assert.deepEqual(app.state.notes.map(n => n.id), ['b']);
|
||||
await drain();
|
||||
assert.deepEqual(app.state.notes.map(n => n.id), ['a', 'b']);
|
||||
assert.deepEqual(errors, ['Failed to pin', 'Failed to delete']);
|
||||
});
|
||||
|
||||
test('item removal, quick add and checkbox errors preserve checklist contents', async () => {
|
||||
const app = owner([{ id: 'a', items: [{ id: 'first', text: 'A', done: false }, { id: 'second', text: 'B', done: true }] }]);
|
||||
const operations = createChecklistOperations({ app, api: { patchNote: async () => { throw Error('offline'); } }, uid: () => 'new', effects: { render() {}, showError() {} } });
|
||||
operations.removeItem('a', 0);
|
||||
assert.equal(app.state.notes[0].items[0].id, 'second');
|
||||
await drain();
|
||||
assert.deepEqual(app.state.notes[0].items.map(it => it.id), ['first', 'second']);
|
||||
const done = [];
|
||||
operations.toggleItem('a', 0, value => done.push(value), () => {});
|
||||
await drain();
|
||||
assert.deepEqual(done, [true, false]);
|
||||
const persist = operations.addItem('a', 'C', () => {});
|
||||
assert.equal(app.state.notes[0].items.length, 3);
|
||||
persist(); await drain();
|
||||
assert.equal(app.state.notes[0].items.length, 2);
|
||||
operations.editItem(app.state.notes[0], 1, 'Changed');
|
||||
await drain();
|
||||
assert.equal(app.state.notes[0].items[1].text, 'B');
|
||||
});
|
||||
|
||||
test('submission preserves top ordering, server ID replacement and newer draft recovery', async () => {
|
||||
const app = owner([{ id: 'existing', sort_order: -2 }]), values = new Map();
|
||||
const persistence = createNotesPersistence({ storage: { getItem: key => values.get(key), setItem: (key, value) => values.set(key, value), removeItem: key => values.delete(key) } });
|
||||
const draftKey = persistence._draftKey('__new__');
|
||||
values.set(draftKey, '{"content":"submitted"}');
|
||||
let resolve, renders = 0;
|
||||
const operations = createNotesSubmission({ app, persistence, clock: { now: () => 0 }, uid: () => 'id', api: { saveNote: () => new Promise(r => { resolve = r; }) }, effects: { render: () => renders++, showError() {} }, fetchNotes: async () => {} });
|
||||
operations.submitNote({ title: 'New' }, null, false);
|
||||
assert.equal(app.state.notes[0].id, 'tmp_id');
|
||||
assert.equal(app.state.notes[0].sort_order, -3);
|
||||
values.set(draftKey, '{"content":"newer"}');
|
||||
resolve({ id: 'server-id' }); await drain();
|
||||
assert.equal(app.state.notes[0].id, 'server-id');
|
||||
assert.equal(values.get(draftKey), '{"content":"newer"}');
|
||||
assert.equal(renders, 2);
|
||||
});
|
||||
|
||||
test('selection can exit only after synchronous acceptance', () => {
|
||||
const app = owner([]), order = [];
|
||||
app.selectId('a');
|
||||
let accepted = false;
|
||||
const operations = createSelectionOperations({ app, sendContext: detail => { order.push('dispatch'); detail.accepted = accepted; }, exitSelection: () => order.push('exit'), effects: { showToast: () => order.push('rejected') } });
|
||||
operations.attachSelected();
|
||||
assert.deepEqual(order, ['dispatch', 'rejected']);
|
||||
accepted = true; operations.attachSelected();
|
||||
assert.deepEqual(order, ['dispatch', 'rejected', 'dispatch', 'exit']);
|
||||
});
|
||||
|
||||
test('listener owners release on replacement and disposal is idempotent', () => {
|
||||
const previous = { document: globalThis.document, MutationObserver: globalThis.MutationObserver };
|
||||
let check, disconnected = 0;
|
||||
globalThis.document = { body: {} };
|
||||
globalThis.MutationObserver = class { constructor(callback) { check = callback; } observe() {} disconnect() { disconnected++; } };
|
||||
try {
|
||||
const lifecycle = createNotesLifecycle(), target = new EventTarget(), canvas = { isConnected: true };
|
||||
let calls = 0;
|
||||
lifecycle.listen(canvas, target, 'mouseup', () => calls++);
|
||||
let released = 0;
|
||||
lifecycle.onDispose(canvas, () => released++);
|
||||
target.dispatchEvent(new Event('mouseup'));
|
||||
canvas.isConnected = false; check();
|
||||
target.dispatchEvent(new Event('mouseup'));
|
||||
lifecycle.dispose(); lifecycle.dispose();
|
||||
assert.equal(calls, 1); assert.equal(disconnected, 1);
|
||||
assert.equal(released, 1);
|
||||
} finally {
|
||||
for (const [name, value] of Object.entries(previous)) if (value === undefined) delete globalThis[name]; else globalThis[name] = value;
|
||||
}
|
||||
});
|
||||
|
||||
test('render helpers reject script-capable media and escape URL attributes', () => {
|
||||
const helpers = createText({ uiModule: { esc: value => value.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>') } });
|
||||
for (const value of ['javascript:alert(1)', 'data:image/svg+xml;base64,abc', 'data:text/html;base64,abc']) assert.equal(helpers._safeImgSrc(value), '');
|
||||
assert.equal(helpers._safeImgSrc('data:image/png;base64,abc'), 'data:image/png;base64,abc');
|
||||
assert.equal(helpers._attrEsc('"\'<>`'), '"'<>`');
|
||||
assert.match(helpers._linkify('<script> https://example.org/test'), /<script>/);
|
||||
assert.doesNotMatch(helpers._linkify('<script> https://example.org/test'), /<script>/i);
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { chromium } from 'playwright';
|
||||
|
||||
const source = readFileSync(new URL('../static/js/document.js', import.meta.url), 'utf8');
|
||||
const build = source.slice(source.indexOf(' function _buildAnnotation('), source.indexOf(' // Prompt user for an instruction'));
|
||||
const state = source.slice(source.indexOf(' let _pdfAnnotationDragging'), source.indexOf(' // Per-doc last-used line spacing'));
|
||||
const placement = source.slice(source.indexOf(' const isBlankPageTarget'), source.indexOf(' pane.appendChild(pageWrap);', source.indexOf(' const isBlankPageTarget')));
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
try {
|
||||
for (const touch of [true, false]) {
|
||||
const page = await browser.newPage({ viewport: { width: 600, height: 800 }, hasTouch: touch });
|
||||
await page.setContent('<div id="page" style="position:relative;width:500px;height:700px;background:white"></div>');
|
||||
await page.evaluate(({ build, state, placement }) => {
|
||||
new Function(`
|
||||
const activeDocId = 'test', docId = activeDocId;
|
||||
const _pdfPaneAnnotationsByDoc = new Map([['test', []]]);
|
||||
const annotationRefs = _pdfPaneAnnotationsByDoc.get('test');
|
||||
const _pdfLastLineHeight = new Map(), _pdfLastFontSize = new Map();
|
||||
let _pdfDropMode = 'check', serial = 0;
|
||||
const _newAnnotationId = () => 'new-' + (++serial);
|
||||
const _pushPdfUndoSnapshot = () => {};
|
||||
const _schedulePdfPaneSave = () => {};
|
||||
const pageWrap = document.getElementById('page');
|
||||
const page = {page: 1};
|
||||
${state}
|
||||
${build}
|
||||
${placement}
|
||||
for (let i = 0; i < 2; i++) {
|
||||
const {ref} = _buildAnnotation(pageWrap, {id:'a'+i, page:1, x:20+i*35, y:30, w:5, h:5, kind:'check'});
|
||||
annotationRefs.push(ref);
|
||||
ref._setHandlesVisible(true);
|
||||
}
|
||||
window.refs = annotationRefs;
|
||||
`)();
|
||||
}, { build, state, placement });
|
||||
const grip = page.locator('[data-ann-id="a0"] [title="Drag to move"]');
|
||||
const box = await grip.boundingBox();
|
||||
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(300, 220);
|
||||
assert(await page.evaluate(() => refs.every(ref => [...ref.wrap.querySelectorAll('[title]')].every(el => el.style.display === 'none'))));
|
||||
// Proximity updates must not bring controls back while dragging.
|
||||
await page.evaluate(() => refs.forEach(ref => ref._setHandlesVisible(true)));
|
||||
assert(await page.locator('[data-ann-id="a1"] [title="Delete annotation"]').isHidden());
|
||||
await page.mouse.up();
|
||||
assert.equal(await page.locator('.pdf-annotation-wrap').count(), 2);
|
||||
assert(await page.locator('[data-ann-id="a1"] [title="Delete annotation"]').isVisible());
|
||||
await page.locator('[data-ann-id="a1"] [title="Delete annotation"]').click();
|
||||
assert.equal(await page.locator('.pdf-annotation-wrap').count(), 1);
|
||||
await page.locator('#page').click({ position: { x: 100, y: 450 } });
|
||||
assert.equal(await page.locator('.pdf-annotation-wrap').count(), 2);
|
||||
console.log(`PASS ${touch ? 'touch' : 'mouse'}: drag controls hidden, delete only deletes, blank page still places`);
|
||||
await page.close();
|
||||
}
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import vm from 'node:vm';
|
||||
|
||||
const chatSource = readFileSync(new URL('../static/js/chat.js', import.meta.url), 'utf8');
|
||||
const sessionSource = readFileSync(new URL('../static/js/sessions.js', import.meta.url), 'utf8');
|
||||
const notesSource = readFileSync(new URL('../static/js/features/notes/application/selection.js', import.meta.url), 'utf8');
|
||||
|
||||
function setup() {
|
||||
const start = chatSource.indexOf(" window.addEventListener('odysseus:notes-context-selected'");
|
||||
const end = chatSource.indexOf(' function _fmtContextNumber(n)', start);
|
||||
assert.ok(start !== -1 && end > start, 'production event handlers must exist');
|
||||
const handlers = new Map();
|
||||
const rendered = [];
|
||||
let sessionId = null;
|
||||
let pending = false;
|
||||
let clear;
|
||||
const win = { addEventListener: (name, fn) => handlers.set(name, fn) };
|
||||
const doc = {
|
||||
getElementById: () => ({ addEventListener: (name, fn) => { if (name === 'click') clear = fn; } }),
|
||||
};
|
||||
const sessionModule = {
|
||||
getCurrentSessionId: () => sessionId,
|
||||
hasPendingChat: () => pending,
|
||||
};
|
||||
vm.runInNewContext(
|
||||
`const _selectedNoteIdsBySession = new Map(); let _pendingSelectedNoteIds = [];
|
||||
${chatSource.slice(start, end)}`,
|
||||
{ window: win, document: doc, sessionModule, console, _renderSelectedNotesIndicator: ids => rendered.push(Array.from(ids)) },
|
||||
);
|
||||
return {
|
||||
setSession(id, isPending = false) { sessionId = id; pending = isPending; },
|
||||
emit(name, detail) { handlers.get(name)({ detail }); },
|
||||
selected(ids) { const detail = { noteIds: ids, accepted: false }; handlers.get('odysseus:notes-context-selected')({ detail }); return detail; },
|
||||
clear() { clear(); },
|
||||
lastRendered() { return rendered.at(-1); },
|
||||
};
|
||||
}
|
||||
|
||||
test('pending selections transfer only to the materialized chat', () => {
|
||||
const ctx = setup();
|
||||
ctx.setSession(null, true);
|
||||
assert.equal(ctx.selected(['a', 'b']).accepted, true);
|
||||
assert.deepEqual(ctx.lastRendered(), ['a', 'b']);
|
||||
ctx.setSession('new-chat');
|
||||
ctx.emit('odysseus:session-changed', { sessionId: 'new-chat', materialized: true });
|
||||
assert.deepEqual(ctx.lastRendered(), ['a', 'b']);
|
||||
ctx.setSession('other-chat');
|
||||
ctx.emit('odysseus:session-changed', { sessionId: 'other-chat' });
|
||||
assert.deepEqual(ctx.lastRendered(), []);
|
||||
ctx.setSession('new-chat');
|
||||
ctx.emit('odysseus:session-changed', { sessionId: 'new-chat' });
|
||||
assert.deepEqual(ctx.lastRendered(), ['a', 'b']);
|
||||
ctx.clear();
|
||||
assert.deepEqual(ctx.lastRendered(), []);
|
||||
});
|
||||
|
||||
test('new chat resets old indicator and invalid selection is rejected', () => {
|
||||
const ctx = setup();
|
||||
assert.equal(ctx.selected(['a']).accepted, false);
|
||||
ctx.setSession('old-chat');
|
||||
assert.equal(ctx.selected(['a']).accepted, true);
|
||||
ctx.setSession(null, true);
|
||||
ctx.emit('odysseus:session-changed', { sessionId: null });
|
||||
assert.deepEqual(ctx.lastRendered(), []);
|
||||
assert.equal(ctx.selected(['b']).accepted, true);
|
||||
ctx.emit('odysseus:session-changed', { sessionId: null });
|
||||
assert.deepEqual(ctx.lastRendered(), []);
|
||||
ctx.setSession('fresh');
|
||||
ctx.emit('odysseus:session-changed', { sessionId: 'fresh', materialized: true });
|
||||
assert.deepEqual(ctx.lastRendered(), []);
|
||||
});
|
||||
|
||||
test('producer events and rejection acknowledgement are wired in source', () => {
|
||||
const create = sessionSource.slice(sessionSource.indexOf('export function createDirectChat'), sessionSource.indexOf('export async function materializePendingSession'));
|
||||
const materialize = sessionSource.slice(sessionSource.indexOf('export async function materializePendingSession'), sessionSource.indexOf('export function preMaterializePendingSession'));
|
||||
assert.match(create, /currentSessionId = null;[\s\S]{0,180}odysseus:session-changed/);
|
||||
assert.match(materialize, /currentSessionId = payload.id;[\s\S]{0,180}odysseus:session-changed/);
|
||||
assert.match(notesSource, /if \(!detail\.accepted\)/);
|
||||
});
|
||||
@@ -0,0 +1,331 @@
|
||||
"""Admin-gated app-config routes must honor AUTH_ENABLED=false.
|
||||
|
||||
Regression test for the inconsistency between core.middleware.require_admin
|
||||
(which allows requests when the operator disabled auth) and the inline admin
|
||||
checks in routes/auth_routes.py (which returned 403 unconditionally, so
|
||||
settings changes were silently lost behind reverse-proxy auth setups).
|
||||
"""
|
||||
import pytest
|
||||
from core.auth import SetAdminResult
|
||||
|
||||
|
||||
class _StoreDict(dict):
|
||||
"""Dict-like settings store that also holds features and integrations."""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.features = {"web_search": True, "web_fetch": True, "memory": True}
|
||||
self.integrations = {
|
||||
"int-1": {
|
||||
"id": "int-1",
|
||||
"name": "miniflux-sync",
|
||||
"preset": "miniflux",
|
||||
"api_key": "sk-secret-key-123",
|
||||
"base_url": "http://127.0.0.1:8080",
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(tmp_path, monkeypatch):
|
||||
fastapi = pytest.importorskip("fastapi")
|
||||
from fastapi.testclient import TestClient
|
||||
from routes import auth_routes
|
||||
|
||||
stored = _StoreDict()
|
||||
monkeypatch.setattr(auth_routes, "_load_settings", lambda: dict(stored))
|
||||
monkeypatch.setattr(auth_routes, "_save_settings", stored.update)
|
||||
monkeypatch.setattr(auth_routes, "_load_features", lambda: dict(stored.features))
|
||||
monkeypatch.setattr(auth_routes, "_save_features", stored.features.update)
|
||||
monkeypatch.setattr(auth_routes, "migrate_from_settings", lambda: None)
|
||||
|
||||
monkeypatch.setattr(auth_routes, "load_integrations", lambda: list(stored.integrations.values()))
|
||||
monkeypatch.setattr(auth_routes, "get_integration", lambda iid: stored.integrations.get(iid))
|
||||
|
||||
def _add_int(body):
|
||||
item = dict(body, id=f"int-{len(stored.integrations) + 1}")
|
||||
stored.integrations[item["id"]] = item
|
||||
return item
|
||||
|
||||
def _update_int(iid, body):
|
||||
if iid not in stored.integrations:
|
||||
return None
|
||||
stored.integrations[iid].update(body)
|
||||
return stored.integrations[iid]
|
||||
|
||||
def _delete_int(iid):
|
||||
return stored.integrations.pop(iid, None) is not None
|
||||
|
||||
async def _test_call(iid, method, path):
|
||||
return {"exit_code": 0, "message": "Connection successful"}
|
||||
|
||||
monkeypatch.setattr(auth_routes, "add_integration", _add_int)
|
||||
monkeypatch.setattr(auth_routes, "update_integration", _update_int)
|
||||
monkeypatch.setattr(auth_routes, "delete_integration", _delete_int)
|
||||
monkeypatch.setattr(auth_routes, "execute_api_call", _test_call)
|
||||
|
||||
class _AuthManager:
|
||||
is_configured = True
|
||||
signup_enabled = False
|
||||
|
||||
def get_username_for_token(self, token):
|
||||
if token in ("session-token", "admin-token"):
|
||||
return "admin"
|
||||
if token == "user-token":
|
||||
return "user"
|
||||
return None
|
||||
|
||||
def is_admin(self, user):
|
||||
return user == "admin"
|
||||
|
||||
def list_users(self):
|
||||
return [{"username": "admin", "is_admin": True}, {"username": "user", "is_admin": False}]
|
||||
|
||||
def create_user(self, username, password, is_admin=False):
|
||||
return True
|
||||
|
||||
def set_privileges(self, username, privs):
|
||||
return True
|
||||
|
||||
def set_admin(self, username, is_admin, requesting_user):
|
||||
return SetAdminResult.SUCCESS
|
||||
|
||||
def delete_user(self, username, requesting_user):
|
||||
return True
|
||||
|
||||
app = fastapi.FastAPI()
|
||||
app.include_router(auth_routes.setup_auth_routes(_AuthManager()))
|
||||
return TestClient(app), stored
|
||||
|
||||
|
||||
def test_set_settings_allowed_when_auth_disabled(client, monkeypatch):
|
||||
monkeypatch.setenv("AUTH_ENABLED", "false")
|
||||
c, stored = client
|
||||
r = c.post("/api/auth/settings", json={"default_model": "some-model"})
|
||||
assert r.status_code == 200
|
||||
assert stored.get("default_model") == "some-model"
|
||||
|
||||
|
||||
def test_get_settings_unscrubbed_when_auth_disabled(client, monkeypatch):
|
||||
monkeypatch.setenv("AUTH_ENABLED", "false")
|
||||
c, stored = client
|
||||
stored["brave_api_key"] = "sk-secret"
|
||||
r = c.get("/api/auth/settings")
|
||||
assert r.status_code == 200
|
||||
assert r.json()["brave_api_key"] == "sk-secret"
|
||||
|
||||
|
||||
def test_set_settings_still_403_when_auth_enabled(client, monkeypatch):
|
||||
monkeypatch.setenv("AUTH_ENABLED", "true")
|
||||
c, _ = client
|
||||
r = c.post("/api/auth/settings", json={"default_model": "x"})
|
||||
assert r.status_code == 403
|
||||
|
||||
|
||||
def test_set_settings_admin_session_still_works(client, monkeypatch):
|
||||
monkeypatch.setenv("AUTH_ENABLED", "true")
|
||||
c, stored = client
|
||||
c.cookies.set("odysseus_session", "session-token")
|
||||
r = c.post("/api/auth/settings", json={"default_model": "admin-model"})
|
||||
assert r.status_code == 200
|
||||
assert stored.get("default_model") == "admin-model"
|
||||
|
||||
|
||||
def test_settings_numeric_validation_and_clamping_when_auth_disabled(client, monkeypatch):
|
||||
monkeypatch.setenv("AUTH_ENABLED", "false")
|
||||
c, stored = client
|
||||
# Clamp out-of-range integer values
|
||||
r = c.post("/api/auth/settings", json={"agent_max_rounds": 999, "agent_max_tool_calls": 5000})
|
||||
assert r.status_code == 200
|
||||
assert stored["agent_max_rounds"] == 200
|
||||
assert stored["agent_max_tool_calls"] == 1000
|
||||
|
||||
# Non-integer must raise 400
|
||||
r = c.post("/api/auth/settings", json={"agent_max_rounds": "not-an-int"})
|
||||
assert r.status_code == 400
|
||||
|
||||
# Unknown settings keys are not saved
|
||||
r = c.post("/api/auth/settings", json={"unsupported_key_xyz": "value"})
|
||||
assert r.status_code == 200
|
||||
assert "unsupported_key_xyz" not in stored
|
||||
|
||||
|
||||
def test_features_accessible_and_persisted_when_auth_disabled(client, monkeypatch):
|
||||
monkeypatch.setenv("AUTH_ENABLED", "false")
|
||||
c, stored = client
|
||||
# Public GET
|
||||
r = c.get("/api/auth/features")
|
||||
assert r.status_code == 200
|
||||
assert r.json().get("web_search") is True
|
||||
|
||||
# POST allowed without login
|
||||
r = c.post("/api/auth/features", json={"web_search": False})
|
||||
assert r.status_code == 200
|
||||
assert stored.features["web_search"] is False
|
||||
|
||||
|
||||
def test_integrations_lifecycle_when_auth_disabled(client, monkeypatch):
|
||||
monkeypatch.setenv("AUTH_ENABLED", "false")
|
||||
c, stored = client
|
||||
|
||||
# List: keys masked
|
||||
r = c.get("/api/auth/integrations")
|
||||
assert r.status_code == 200
|
||||
items = r.json().get("integrations", [])
|
||||
assert len(items) == 1
|
||||
assert items[0]["api_key"] != "sk-secret-key-123"
|
||||
|
||||
# Create
|
||||
r = c.post("/api/auth/integrations", json={"name": "new-srv", "preset": "custom", "api_key": "raw-key"})
|
||||
assert r.status_code == 200
|
||||
new_id = r.json()["integration"]["id"]
|
||||
assert new_id in stored.integrations
|
||||
|
||||
# Update
|
||||
r = c.put(f"/api/auth/integrations/{new_id}", json={"name": "updated-srv"})
|
||||
assert r.status_code == 200
|
||||
assert stored.integrations[new_id]["name"] == "updated-srv"
|
||||
|
||||
# Test
|
||||
r = c.post(f"/api/auth/integrations/{new_id}/test")
|
||||
assert r.status_code == 200
|
||||
|
||||
# Delete
|
||||
r = c.delete(f"/api/auth/integrations/{new_id}")
|
||||
assert r.status_code == 200
|
||||
assert new_id not in stored.integrations
|
||||
|
||||
# 404 for missing item
|
||||
assert c.put("/api/auth/integrations/nonexistent", json={"name": "x"}).status_code == 404
|
||||
assert c.delete("/api/auth/integrations/nonexistent").status_code == 404
|
||||
assert c.post("/api/auth/integrations/nonexistent/test").status_code == 404
|
||||
|
||||
|
||||
def test_user_management_fails_closed_when_auth_disabled(client, monkeypatch):
|
||||
monkeypatch.setenv("AUTH_ENABLED", "false")
|
||||
c, _ = client
|
||||
|
||||
assert c.get("/api/auth/users").status_code == 403
|
||||
assert c.post("/api/auth/users", json={"username": "hacker", "password": "password123", "is_admin": True}).status_code == 403
|
||||
assert c.put("/api/auth/users/target/privileges", json={"can_chat": False}).status_code == 403
|
||||
assert c.put("/api/auth/users/target/rename", json={"username": "newtarget"}).status_code == 403
|
||||
assert c.put("/api/auth/users/target/admin", json={"is_admin": True}).status_code == 403
|
||||
assert c.put("/api/auth/users/target/password", json={"new_password": "password123"}).status_code == 403
|
||||
assert c.put("/api/auth/open-signup", json={"enabled": True}).status_code == 403
|
||||
assert c.request("DELETE", "/api/auth/users", json={"username": "target"}).status_code == 403
|
||||
|
||||
|
||||
def test_get_settings_scrubbed_for_unauthenticated_and_non_admin_when_auth_enabled(client, monkeypatch):
|
||||
monkeypatch.setenv("AUTH_ENABLED", "true")
|
||||
c, stored = client
|
||||
stored["brave_api_key"] = "sk-secret-brave"
|
||||
stored["default_model"] = "gpt-4o"
|
||||
|
||||
# Unauthenticated caller gets scrubbed settings
|
||||
r = c.get("/api/auth/settings")
|
||||
assert r.status_code == 200
|
||||
data = r.json()
|
||||
assert data["brave_api_key"] == ""
|
||||
assert data["default_model"] == "gpt-4o"
|
||||
|
||||
# Non-admin authenticated user gets scrubbed settings
|
||||
c.cookies.set("odysseus_session", "user-token")
|
||||
r = c.get("/api/auth/settings")
|
||||
assert r.status_code == 200
|
||||
data = r.json()
|
||||
assert data["brave_api_key"] == ""
|
||||
assert data["default_model"] == "gpt-4o"
|
||||
|
||||
# Admin caller gets full unscrubbed settings
|
||||
c.cookies.set("odysseus_session", "session-token")
|
||||
r = c.get("/api/auth/settings")
|
||||
assert r.status_code == 200
|
||||
data = r.json()
|
||||
assert data["brave_api_key"] == "sk-secret-brave"
|
||||
assert data["default_model"] == "gpt-4o"
|
||||
|
||||
|
||||
def test_set_settings_403_for_authenticated_non_admin(client, monkeypatch):
|
||||
monkeypatch.setenv("AUTH_ENABLED", "true")
|
||||
c, stored = client
|
||||
c.cookies.set("odysseus_session", "user-token")
|
||||
r = c.post("/api/auth/settings", json={"default_model": "user-choice"})
|
||||
assert r.status_code == 403
|
||||
assert stored.get("default_model") != "user-choice"
|
||||
|
||||
|
||||
def test_features_gated_when_auth_enabled(client, monkeypatch):
|
||||
monkeypatch.setenv("AUTH_ENABLED", "true")
|
||||
c, stored = client
|
||||
|
||||
# Unauthenticated
|
||||
assert c.post("/api/auth/features", json={"web_search": False}).status_code == 403
|
||||
|
||||
# Non-admin
|
||||
c.cookies.set("odysseus_session", "user-token")
|
||||
assert c.post("/api/auth/features", json={"web_search": False}).status_code == 403
|
||||
assert stored.features["web_search"] is True
|
||||
|
||||
# Admin
|
||||
c.cookies.set("odysseus_session", "session-token")
|
||||
r = c.post("/api/auth/features", json={"web_search": False})
|
||||
assert r.status_code == 200
|
||||
assert stored.features["web_search"] is False
|
||||
|
||||
|
||||
def test_integrations_gated_when_auth_enabled(client, monkeypatch):
|
||||
monkeypatch.setenv("AUTH_ENABLED", "true")
|
||||
c, stored = client
|
||||
|
||||
# Unauthenticated
|
||||
assert c.get("/api/auth/integrations").status_code == 403
|
||||
assert c.post("/api/auth/integrations", json={"name": "a"}).status_code == 403
|
||||
|
||||
# Non-admin
|
||||
c.cookies.set("odysseus_session", "user-token")
|
||||
assert c.get("/api/auth/integrations").status_code == 403
|
||||
assert c.post("/api/auth/integrations", json={"name": "a"}).status_code == 403
|
||||
assert c.put("/api/auth/integrations/int-1", json={"name": "b"}).status_code == 403
|
||||
assert c.delete("/api/auth/integrations/int-1").status_code == 403
|
||||
assert c.post("/api/auth/integrations/int-1/test").status_code == 403
|
||||
|
||||
# Admin
|
||||
c.cookies.set("odysseus_session", "session-token")
|
||||
assert c.get("/api/auth/integrations").status_code == 200
|
||||
assert c.post("/api/auth/integrations", json={"name": "new", "preset": "custom"}).status_code == 200
|
||||
|
||||
|
||||
def test_user_management_requires_admin_when_auth_enabled(client, monkeypatch):
|
||||
monkeypatch.setenv("AUTH_ENABLED", "true")
|
||||
c, _ = client
|
||||
|
||||
# Unauthenticated
|
||||
assert c.get("/api/auth/users").status_code == 403
|
||||
assert c.post("/api/auth/users", json={"username": "a", "password": "b"}).status_code == 403
|
||||
assert c.put("/api/auth/users/target/password", json={"new_password": "password123"}).status_code == 403
|
||||
|
||||
# Non-admin
|
||||
c.cookies.set("odysseus_session", "user-token")
|
||||
assert c.get("/api/auth/users").status_code == 403
|
||||
assert c.put("/api/auth/users/target/password", json={"new_password": "password123"}).status_code == 403
|
||||
|
||||
# Admin
|
||||
c.cookies.set("odysseus_session", "session-token")
|
||||
assert c.get("/api/auth/users").status_code == 200
|
||||
|
||||
|
||||
def test_document_style_extract_gating(client, monkeypatch):
|
||||
c, _ = client
|
||||
dummy_file = {"file": ("sample.txt", b"dummy prose content")}
|
||||
|
||||
# When auth enabled: unauthenticated is 403, non-admin is 403
|
||||
monkeypatch.setenv("AUTH_ENABLED", "true")
|
||||
assert c.post("/api/auth/settings/document-style/extract", files=dummy_file).status_code == 403
|
||||
|
||||
c.cookies.set("odysseus_session", "user-token")
|
||||
assert c.post("/api/auth/settings/document-style/extract", files=dummy_file).status_code == 403
|
||||
|
||||
# When auth disabled: unauthenticated passes admin gate (400 for sample length < 80)
|
||||
monkeypatch.setenv("AUTH_ENABLED", "false")
|
||||
c.cookies.clear()
|
||||
assert c.post("/api/auth/settings/document-style/extract", files=dummy_file).status_code == 400
|
||||
@@ -123,12 +123,88 @@ def test_blocks_listing_the_data_directory_itself():
|
||||
_resolve_tool_path(DATA_DIR)
|
||||
|
||||
|
||||
def test_blocks_app_state_reached_by_relative_path(monkeypatch):
|
||||
"""The data directory is a relative hop from the checkout root, so
|
||||
def test_blocks_app_state_reached_by_relative_path(tmp_path, monkeypatch):
|
||||
"""The data directory is a relative hop from the agent workspace, so
|
||||
confinement cannot depend on the model supplying an absolute path."""
|
||||
monkeypatch.chdir(os.path.dirname(DATA_DIR))
|
||||
current_execution = importlib.import_module("src.tool_execution")
|
||||
data_dir = tmp_path / "data"
|
||||
data_dir.mkdir()
|
||||
workspace = _configure_test_data_tree(monkeypatch, data_dir)["AGENT_WORKSPACE_DIR"]
|
||||
workspace.mkdir()
|
||||
monkeypatch.setattr(current_execution, "_AGENT_WORKDIR", str(workspace))
|
||||
with pytest.raises(ValueError, match="application state"):
|
||||
_resolve_tool_path(os.path.join(os.path.basename(DATA_DIR), "sessions.json"))
|
||||
current_execution._resolve_tool_path(os.path.join("..", "sessions.json"))
|
||||
|
||||
|
||||
def test_relative_path_resolves_in_agent_workspace_not_server_cwd(tmp_path, monkeypatch):
|
||||
"""With no workspace bound, bash runs in the agent workspace, so a
|
||||
relative write_file/read_file path must land there too (#6679), not in
|
||||
the server's cwd, which is outside the allowed roots."""
|
||||
current_execution = importlib.import_module("src.tool_execution")
|
||||
data_dir = tmp_path / "data"
|
||||
data_dir.mkdir()
|
||||
workspace = _configure_test_data_tree(monkeypatch, data_dir)["AGENT_WORKSPACE_DIR"]
|
||||
workspace.mkdir()
|
||||
monkeypatch.setattr(current_execution, "_AGENT_WORKDIR", str(workspace))
|
||||
server_cwd = tmp_path / "checkout"
|
||||
server_cwd.mkdir()
|
||||
monkeypatch.chdir(server_cwd)
|
||||
token = current_execution._active_workspace.set(None)
|
||||
try:
|
||||
assert current_execution._resolve_tool_path("test.txt") == os.path.join(
|
||||
os.path.realpath(workspace), "test.txt"
|
||||
)
|
||||
assert current_execution._resolve_search_root("sub") == os.path.join(
|
||||
os.path.realpath(workspace), "sub"
|
||||
)
|
||||
finally:
|
||||
current_execution._active_workspace.reset(token)
|
||||
|
||||
|
||||
def test_request_without_workspace_seals_the_agent_workspace(tmp_path, monkeypatch):
|
||||
"""With no workspace, the native file tools get the folder bash/python
|
||||
already run in (#6679) instead of no root at all, and a relative path
|
||||
lands there while app state next to it stays out of reach."""
|
||||
from src.agent_runtime.authority import ExactOperation, OperationGrant, RequestAuthority
|
||||
from src.agent_runtime.resource_binding import resolve_filesystem_operation
|
||||
current_execution = importlib.import_module("src.tool_execution")
|
||||
data_dir = tmp_path / "data"
|
||||
data_dir.mkdir()
|
||||
workspace = _configure_test_data_tree(monkeypatch, data_dir)["AGENT_WORKSPACE_DIR"]
|
||||
workspace.mkdir()
|
||||
(data_dir / "sessions.json").write_text("{}", encoding="utf-8")
|
||||
monkeypatch.setattr(current_execution, "_AGENT_WORKDIR", str(workspace))
|
||||
|
||||
authority = RequestAuthority("request", "alice", "s", "", (OperationGrant("write_file"),))
|
||||
assert [r.path for r in authority.resource_roots] == [os.path.realpath(workspace)]
|
||||
|
||||
bound = resolve_filesystem_operation(
|
||||
ExactOperation.normalize("write_file", "test.txt\nhello"), roots=authority.resource_roots)
|
||||
assert bound.bindings[0].resource.path == os.path.join(os.path.realpath(workspace), "test.txt")
|
||||
with pytest.raises(ValueError, match="outside the sealed roots"):
|
||||
resolve_filesystem_operation(
|
||||
ExactOperation.normalize("read_file", "../sessions.json"), roots=authority.resource_roots)
|
||||
|
||||
|
||||
def test_symlinked_agent_workspace_is_not_sealed(tmp_path, monkeypatch):
|
||||
"""A symlinked agent workspace must not become the file-tool root:
|
||||
agent_cwd() and create_directories() refuse the same layout."""
|
||||
from src.agent_runtime.authority import OperationGrant, RequestAuthority
|
||||
current_execution = importlib.import_module("src.tool_execution")
|
||||
data_dir = tmp_path / "data"
|
||||
data_dir.mkdir()
|
||||
workspace = _configure_test_data_tree(monkeypatch, data_dir)["AGENT_WORKSPACE_DIR"]
|
||||
elsewhere = tmp_path / "elsewhere"
|
||||
elsewhere.mkdir()
|
||||
(elsewhere / "notes.txt").write_text("private", encoding="utf-8")
|
||||
try:
|
||||
workspace.symlink_to(elsewhere, target_is_directory=True)
|
||||
except OSError:
|
||||
pytest.skip("cannot create symlink")
|
||||
monkeypatch.setattr(current_execution, "_AGENT_WORKDIR", str(workspace))
|
||||
|
||||
authority = RequestAuthority("request", "alice", "s", "", (OperationGrant("read_file"),))
|
||||
assert authority.resource_roots == ()
|
||||
|
||||
|
||||
def test_blocks_app_state_reached_through_a_symlink(tmp_path):
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import ast
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
@@ -8,11 +9,42 @@ def _src(path: str) -> str:
|
||||
return (ROOT / path).read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def _utility_kwargs(source: str, function_name: str) -> dict:
|
||||
tree = ast.parse(source)
|
||||
|
||||
functions = [
|
||||
node for node in ast.walk(tree)
|
||||
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef))
|
||||
and node.name == function_name
|
||||
]
|
||||
assert len(functions) == 1, function_name
|
||||
|
||||
calls = [
|
||||
node for node in ast.walk(functions[0])
|
||||
if isinstance(node, ast.Call)
|
||||
and isinstance(node.func, ast.Name)
|
||||
and node.func.id == "resolve_endpoint"
|
||||
and node.args
|
||||
and isinstance(node.args[0], ast.Constant)
|
||||
and node.args[0].value == "utility"
|
||||
]
|
||||
assert len(calls) == 1, function_name
|
||||
|
||||
return {
|
||||
kw.arg: ast.unparse(kw.value)
|
||||
for kw in calls[0].keywords
|
||||
}
|
||||
|
||||
|
||||
def test_registered_manual_compaction_uses_session_owner_for_utility_endpoint():
|
||||
session_src = _src("routes/session_routes.py")
|
||||
|
||||
assert 'owner = getattr(session, "owner", None) or effective_user(request)' in session_src
|
||||
assert 'resolve_endpoint("utility", owner=owner)' in session_src
|
||||
kwargs = _utility_kwargs(session_src, "compact_session")
|
||||
assert kwargs["owner"] == "owner"
|
||||
assert kwargs["fallback_url"] == "getattr(session, 'endpoint_url', None)"
|
||||
assert kwargs["fallback_model"] == "getattr(session, 'model', None)"
|
||||
assert kwargs["fallback_headers"] == "getattr(session, 'headers', None)"
|
||||
|
||||
|
||||
def test_task_name_generation_uses_owner_scoped_session_endpoint():
|
||||
@@ -31,7 +63,11 @@ def test_auto_compaction_utility_endpoint_keeps_chat_owner():
|
||||
|
||||
assert "owner=user" in helper_src
|
||||
assert "owner: Optional[str] = None" in compact_src
|
||||
assert 'resolve_endpoint("utility", owner=owner)' in compact_src
|
||||
kwargs = _utility_kwargs(compact_src, "maybe_compact")
|
||||
assert kwargs["owner"] == "owner"
|
||||
assert kwargs["fallback_url"] == "endpoint_url"
|
||||
assert kwargs["fallback_model"] == "model"
|
||||
assert kwargs["fallback_headers"] == "headers"
|
||||
|
||||
|
||||
def test_background_session_sort_uses_owner_task_endpoint():
|
||||
|
||||
@@ -281,7 +281,8 @@ async def test_browser_approval_application_binding_is_exact(producer, field, va
|
||||
original = authority()
|
||||
content = '{"action":"session_info"}'
|
||||
approval = approval_for(original, "private_browser", content)
|
||||
changed = replace(original, **{field: value}, browser_sessions=(), browser_pages=())
|
||||
# resource_roots=None re-seals the root for the changed owner.
|
||||
changed = replace(original, **{field: value}, browser_sessions=(), browser_pages=(), resource_roots=None)
|
||||
_, result = await dispatch(changed, "private_browser", content, approval)
|
||||
assert result["exit_code"] == 1 and not approval._claimed
|
||||
|
||||
|
||||
@@ -0,0 +1,387 @@
|
||||
"""Offline regressions for incident roots, unaffected roots, and CA directories."""
|
||||
|
||||
import contextlib
|
||||
import hashlib
|
||||
import importlib.util
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import ssl
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from types import SimpleNamespace
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
SPEC = importlib.util.spec_from_file_location("check_ca_trust", ROOT / "scripts/check_ca_trust.py")
|
||||
checker = importlib.util.module_from_spec(SPEC)
|
||||
SPEC.loader.exec_module(checker)
|
||||
INCIDENT = json.loads((ROOT / "tests/fixtures/ca-trust/incident.json").read_text())
|
||||
|
||||
|
||||
class CATrustTests(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
# Public certificate data is embedded below. Tests create temporary
|
||||
# PEM files from these constants; no fixture contains a private key.
|
||||
cls.temporary = tempfile.TemporaryDirectory()
|
||||
cls.fixtures = Path(cls.temporary.name)
|
||||
for name, pem in PUBLIC_CERTIFICATES.items():
|
||||
(cls.fixtures / name).write_text(pem)
|
||||
for fingerprint, pem in INCIDENT["certificates"].items():
|
||||
if hashlib.sha256(ssl.PEM_cert_to_DER_cert(pem)).hexdigest() != fingerprint:
|
||||
raise ValueError("incident fixture fingerprint mismatch")
|
||||
(cls.fixtures / f"{fingerprint}.pem").write_text(pem)
|
||||
|
||||
@classmethod
|
||||
def tearDownClass(cls):
|
||||
cls.temporary.cleanup()
|
||||
|
||||
def test_unaffected_emsign_root_remains_usable(self):
|
||||
checker.check_bundle(self.fixtures / "emsign-g1.pem")
|
||||
|
||||
def test_verbose_python_check_reports_and_rejects_affected_user_bundle(self):
|
||||
# A fresh interpreter must see persisted user packages, independently
|
||||
# of pytest's imported certifi/pip modules and any active test venv.
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
home = Path(tmp)
|
||||
env = dict(os.environ, HOME=str(home), PYTHONUSERBASE=str(home / ".local"))
|
||||
env.pop("PYTHONNOUSERSITE", None)
|
||||
env.pop("PYTHONPATH", None)
|
||||
python = getattr(sys, "_base_executable", sys.executable)
|
||||
site_result = subprocess.run(
|
||||
[python, "-m", "site", "--user-site"], env=env, capture_output=True, text=True,
|
||||
)
|
||||
self.assertEqual(site_result.returncode, 0, site_result.stderr)
|
||||
user_site = Path(site_result.stdout.strip())
|
||||
for package in ("certifi", "pip", "pip/_vendor", "pip/_vendor/certifi"):
|
||||
directory = user_site / package
|
||||
directory.mkdir(parents=True, exist_ok=True)
|
||||
(directory / "__init__.py").write_text(
|
||||
"from pathlib import Path\ndef where(): return str(Path(__file__).with_name('cacert.pem'))\n"
|
||||
)
|
||||
application_bundle = user_site / "certifi/cacert.pem"
|
||||
pip_bundle = user_site / "pip/_vendor/certifi/cacert.pem"
|
||||
for bundle in (application_bundle, pip_bundle):
|
||||
bundle.write_text(PUBLIC_CERTIFICATES["emsign-g1.pem"])
|
||||
command = [python, str(ROOT / "scripts/check_ca_trust.py"), "--python", "--verbose"]
|
||||
clean = subprocess.run(command, env=env, capture_output=True, text=True)
|
||||
self.assertEqual(clean.returncode, 0, clean.stderr)
|
||||
self.assertIn(str(application_bundle), clean.stdout)
|
||||
self.assertIn(str(pip_bundle), clean.stdout)
|
||||
self.assertIn(str(home), clean.stdout)
|
||||
self.assertIn("Python executable:", clean.stdout)
|
||||
application_bundle.write_text(PUBLIC_CERTIFICATES["emsign-g3.pem"])
|
||||
affected = subprocess.run(command, env=env, capture_output=True, text=True)
|
||||
self.assertEqual(affected.returncode, 1, affected.stderr)
|
||||
self.assertIn(str(application_bundle), affected.stderr)
|
||||
self.assertIn("emSign ECC Root CA - G3", affected.stderr)
|
||||
|
||||
def test_compromised_emsign_path_is_rejected(self):
|
||||
with self.assertRaisesRegex(ValueError, "emSign ECC Root CA - G3"):
|
||||
checker.check_bundle(self.fixtures / "emsign-g3.pem")
|
||||
|
||||
def test_aaa_cross_sign_path_is_rejected(self):
|
||||
with self.assertRaisesRegex(ValueError, "AAA Certificate Services"):
|
||||
checker.check_bundle(self.fixtures / "aaa.pem")
|
||||
|
||||
def test_mixed_bundle_does_not_hide_an_affected_root(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
bundle = Path(tmp) / "mixed.pem"
|
||||
bundle.write_bytes((self.fixtures / "emsign-g1.pem").read_bytes() + (self.fixtures / "aaa.pem").read_bytes())
|
||||
with self.assertRaisesRegex(ValueError, "AAA Certificate Services"):
|
||||
checker.check_bundle(bundle)
|
||||
|
||||
def test_all_published_intermediates_and_cross_signs_are_rejected(self):
|
||||
self.assertEqual(len(INCIDENT["intermediates"]), 16)
|
||||
for fingerprint, name in INCIDENT["intermediates"].items():
|
||||
with self.subTest(certificate=name), self.assertRaisesRegex(ValueError, "affected CA certificates"):
|
||||
checker.check_bundle(self.fixtures / f"{fingerprint}.pem")
|
||||
|
||||
def test_published_roots_and_cross_signed_issuer_are_rejected(self):
|
||||
certificates = INCIDENT["roots"] | INCIDENT["cross_signed_roots"]
|
||||
self.assertEqual(len(certificates), 5)
|
||||
for fingerprint, name in certificates.items():
|
||||
with self.subTest(certificate=name), self.assertRaises(ValueError):
|
||||
checker.check_bundle(self.fixtures / f"{fingerprint}.pem")
|
||||
|
||||
def test_clean_roots_do_not_hide_an_added_compromised_intermediate(self):
|
||||
fingerprint = next(iter(INCIDENT["intermediates"]))
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
bundle = Path(tmp) / "custom.pem"
|
||||
bundle.write_text(PUBLIC_CERTIFICATES["emsign-g1.pem"] + INCIDENT["certificates"][fingerprint])
|
||||
with self.assertRaisesRegex(ValueError, "EM DV TLS CA - G3B"):
|
||||
checker.check_bundle(bundle)
|
||||
|
||||
@unittest.skipUnless(shutil.which("openssl"), "offline chain verification requires OpenSSL")
|
||||
def test_published_chains_and_custom_anchor_with_affected_positive_control(self):
|
||||
# Freeze time inside the public fixtures' validity interval. Otherwise
|
||||
# expiry could make an ineffective trust-store fix appear successful.
|
||||
help_result = subprocess.run(["openssl", "verify", "-help"], capture_output=True, text=True)
|
||||
no_store = ["-no-CAstore"] if "-no-CAstore" in help_result.stdout + help_result.stderr else []
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
directory = Path(tmp)
|
||||
affected = directory / "affected.pem"
|
||||
affected.write_text("".join(INCIDENT["certificates"][key] for key in INCIDENT["roots"]))
|
||||
clean = self.fixtures / "emsign-g1.pem"
|
||||
custom = directory / "custom.pem"
|
||||
fingerprint = next(iter(INCIDENT["intermediates"]))
|
||||
custom.write_text(clean.read_text() + INCIDENT["certificates"][fingerprint])
|
||||
self.assertEqual(len(INCIDENT["chains"]), 14)
|
||||
custom_accepted = []
|
||||
for hostname, chain in INCIDENT["chains"].items():
|
||||
leaf, intermediates = directory / "leaf.pem", directory / "chain.pem"
|
||||
leaf.write_text(INCIDENT["certificates"][chain[0]])
|
||||
intermediates.write_text("".join(INCIDENT["certificates"][key] for key in chain[1:]))
|
||||
|
||||
def verify(bundle):
|
||||
return subprocess.run([
|
||||
"openssl", "verify", "-no-CApath", *no_store,
|
||||
"-attime", str(INCIDENT["verification_time"]),
|
||||
"-partial_chain", "-x509_strict", "-purpose", "sslserver",
|
||||
"-verify_hostname", hostname, "-CAfile", str(bundle),
|
||||
"-untrusted", str(intermediates), str(leaf),
|
||||
], capture_output=True, text=True)
|
||||
|
||||
with self.subTest(hostname=hostname):
|
||||
positive = verify(affected)
|
||||
self.assertEqual(positive.returncode, 0, positive.stderr)
|
||||
negative = verify(clean)
|
||||
self.assertNotEqual(negative.returncode, 0)
|
||||
self.assertIn("unable to get local issuer certificate", negative.stderr)
|
||||
if verify(custom).returncode == 0:
|
||||
custom_accepted.append(hostname)
|
||||
self.assertCountEqual(custom_accepted, [
|
||||
"dv-g3b-aaa-emudhra.we-have-a-problem.com",
|
||||
"dv-g3b-aaa-noct-emudhra.we-have-a-problem.com",
|
||||
"dv-g3b-emudhra.we-have-a-problem.com",
|
||||
"dv-g3b-noct-emudhra.we-have-a-problem.com",
|
||||
])
|
||||
checker.check_bundle(clean)
|
||||
for bundle in (affected, custom):
|
||||
with self.subTest(bundle=bundle.name), self.assertRaises(ValueError):
|
||||
checker.check_bundle(bundle)
|
||||
|
||||
def test_empty_malformed_and_missing_bundles_fail_closed(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
for content in ("", "not a certificate"):
|
||||
path = Path(tmp) / "bad.pem"
|
||||
path.write_text(content)
|
||||
with self.subTest(content=content), self.assertRaises((ssl.SSLError, ValueError)):
|
||||
checker.check_bundle(path)
|
||||
with self.assertRaises(OSError):
|
||||
checker.check_bundle(Path(tmp) / "missing.pem")
|
||||
|
||||
def test_each_configured_override_is_checked(self):
|
||||
fingerprint = next(iter(INCIDENT["intermediates"]))
|
||||
affected = self.fixtures / f"{fingerprint}.pem"
|
||||
clean = self.fixtures / "emsign-g1.pem"
|
||||
for name in ("LLM_CA_BUNDLE", "SSL_CERT_FILE", "REQUESTS_CA_BUNDLE", "CURL_CA_BUNDLE",
|
||||
"PIP_CERT", "NODE_EXTRA_CA_CERTS", "GRPC_DEFAULT_SSL_ROOTS_FILE_PATH"):
|
||||
for bundle, expected in ((clean, 0), (affected, 1)):
|
||||
with self.subTest(variable=name, bundle=bundle.name), patch.dict(
|
||||
os.environ, {name: str(bundle)}, clear=True
|
||||
), contextlib.redirect_stdout(io.StringIO()), contextlib.redirect_stderr(io.StringIO()):
|
||||
self.assertEqual(checker.main(["--overrides", str(clean)]), expected)
|
||||
|
||||
def test_invalid_override_is_not_hidden_by_a_clean_explicit_bundle(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
directory = Path(tmp)
|
||||
for name, content in (("empty.pem", ""), ("malformed.pem", "invalid")):
|
||||
(directory / name).write_text(content)
|
||||
for name in ("missing.pem", "empty.pem", "malformed.pem"):
|
||||
with self.subTest(file=name), patch.dict(
|
||||
os.environ, {"LLM_CA_BUNDLE": str(directory / name)}, clear=True
|
||||
), contextlib.redirect_stderr(io.StringIO()):
|
||||
self.assertEqual(checker.main(["--overrides", str(self.fixtures / "emsign-g1.pem")]), 1)
|
||||
|
||||
def test_no_configured_overrides_does_not_report_validated_stores(self):
|
||||
with patch.dict(os.environ, {"LLM_CA_BUNDLE": " "}, clear=True), contextlib.redirect_stderr(io.StringIO()):
|
||||
self.assertEqual(checker.main(["--overrides"]), 1)
|
||||
|
||||
def test_requests_directory_override_checks_hashed_intermediates(self):
|
||||
fingerprint = next(iter(INCIDENT["intermediates"]))
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
directory = Path(tmp)
|
||||
for name in ("REQUESTS_CA_BUNDLE", "CURL_CA_BUNDLE", "PIP_CERT"):
|
||||
with self.subTest(variable=name), patch.dict(os.environ, {name: str(directory)}, clear=True):
|
||||
for content, expected in ((PUBLIC_CERTIFICATES["emsign-g1.pem"], 0),
|
||||
(INCIDENT["certificates"][fingerprint], 1)):
|
||||
(directory / "12345678.0").write_text(content)
|
||||
with contextlib.redirect_stdout(io.StringIO()), contextlib.redirect_stderr(io.StringIO()):
|
||||
self.assertEqual(checker.main(["--overrides"]), expected)
|
||||
(directory / "12345678.0").unlink()
|
||||
with contextlib.redirect_stderr(io.StringIO()):
|
||||
self.assertEqual(checker.main(["--overrides"]), 1)
|
||||
|
||||
def test_safe_cafile_does_not_hide_stale_hashed_directory(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
directory = Path(tmp)
|
||||
(directory / "14bc7599.0").symlink_to(self.fixtures / "emsign-g3.pem")
|
||||
defaults = ssl.get_default_verify_paths()._replace(
|
||||
cafile=str(self.fixtures / "emsign-g1.pem"), capath=str(directory)
|
||||
)
|
||||
errors = io.StringIO()
|
||||
with patch.object(checker.ssl, "get_default_verify_paths", return_value=defaults), patch.dict(
|
||||
os.environ, {defaults.openssl_capath_env: str(directory)}
|
||||
):
|
||||
with contextlib.redirect_stderr(errors):
|
||||
result = checker.main(["--system"])
|
||||
self.assertEqual(result, 1)
|
||||
self.assertIn("emSign ECC Root CA - G3", errors.getvalue())
|
||||
|
||||
def test_hashed_non_ca_certificate_does_not_reject_clean_store(self):
|
||||
# Ubuntu's OpenSSL directory can also hash ssl-cert-snakeoil.pem.
|
||||
# A valid server certificate has no CA entries in get_ca_certs().
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
directory = Path(tmp)
|
||||
(directory / "ce275665.0").symlink_to(self.fixtures / "localhost-leaf.pem")
|
||||
defaults = ssl.get_default_verify_paths()._replace(
|
||||
cafile=str(self.fixtures / "emsign-g1.pem"), capath=str(directory)
|
||||
)
|
||||
with patch.object(checker.ssl, "get_default_verify_paths", return_value=defaults), patch.dict(
|
||||
os.environ, {defaults.openssl_capath_env: str(directory)}
|
||||
), contextlib.redirect_stdout(io.StringIO()):
|
||||
self.assertEqual(checker.main(["--system"]), 0)
|
||||
|
||||
def test_multiple_ca_directories_are_all_checked(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
clean, stale = Path(tmp) / "clean", Path(tmp) / "stale"
|
||||
clean.mkdir()
|
||||
stale.mkdir()
|
||||
(stale / "ee64a828.0").symlink_to(self.fixtures / "aaa.pem")
|
||||
defaults = ssl.get_default_verify_paths()._replace(
|
||||
cafile=str(self.fixtures / "emsign-g1.pem"), capath=None
|
||||
)
|
||||
errors = io.StringIO()
|
||||
with patch.object(checker.ssl, "get_default_verify_paths", return_value=defaults), patch.dict(
|
||||
os.environ, {defaults.openssl_capath_env: os.pathsep.join(map(str, (clean, stale)))}
|
||||
):
|
||||
with contextlib.redirect_stderr(errors):
|
||||
result = checker.main(["--system"])
|
||||
self.assertEqual(result, 1)
|
||||
self.assertIn("AAA Certificate Services", errors.getvalue())
|
||||
|
||||
def test_grpc_bundled_roots_are_checked_without_an_override(self):
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
package = Path(temporary) / "grpc"
|
||||
bundle = package / "_cython" / "_credentials" / "roots.pem"
|
||||
bundle.parent.mkdir(parents=True)
|
||||
bundle.write_bytes((self.fixtures / "emsign-g3.pem").read_bytes())
|
||||
errors = io.StringIO()
|
||||
with patch.dict(os.environ, {"GRPC_DEFAULT_SSL_ROOTS_FILE_PATH": ""}), patch.object(
|
||||
checker.importlib.util, "find_spec", return_value=SimpleNamespace(origin=str(package / "__init__.py"))
|
||||
), contextlib.redirect_stderr(errors):
|
||||
self.assertEqual(checker.main(["--grpc"]), 1)
|
||||
self.assertIn("emSign ECC Root CA - G3", errors.getvalue())
|
||||
|
||||
def test_grpc_explicit_clean_store_replaces_the_independent_bundle(self):
|
||||
with patch.dict(os.environ, {"GRPC_DEFAULT_SSL_ROOTS_FILE_PATH": str(self.fixtures / "emsign-g1.pem")}), patch.object(
|
||||
checker.importlib.util, "find_spec", side_effect=AssertionError("must use the explicit store")
|
||||
), contextlib.redirect_stdout(io.StringIO()):
|
||||
self.assertEqual(checker.main(["--grpc"]), 0)
|
||||
|
||||
def test_grpc_invalid_override_does_not_fall_back_silently(self):
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
for name, content in (("empty.pem", ""), ("malformed.pem", "not a certificate"), ("affected.pem", PUBLIC_CERTIFICATES["aaa.pem"])):
|
||||
bundle = Path(temporary) / name
|
||||
bundle.write_text(content)
|
||||
with self.subTest(name=name), patch.dict(os.environ, {"GRPC_DEFAULT_SSL_ROOTS_FILE_PATH": str(bundle)}), contextlib.redirect_stderr(io.StringIO()):
|
||||
self.assertEqual(checker.main(["--grpc"]), 1)
|
||||
with patch.dict(os.environ, {"GRPC_DEFAULT_SSL_ROOTS_FILE_PATH": str(Path(temporary) / "missing.pem")}), contextlib.redirect_stderr(io.StringIO()):
|
||||
self.assertEqual(checker.main(["--grpc"]), 1)
|
||||
|
||||
def test_grpc_check_reports_a_missing_installation(self):
|
||||
with patch.dict(os.environ, {"GRPC_DEFAULT_SSL_ROOTS_FILE_PATH": ""}), patch.object(
|
||||
checker.importlib.util, "find_spec", return_value=None
|
||||
), contextlib.redirect_stderr(io.StringIO()):
|
||||
self.assertEqual(checker.main(["--grpc"]), 1)
|
||||
|
||||
|
||||
# Public Mozilla CA certificates; provenance: tests/fixtures/ca-trust/README.md.
|
||||
PUBLIC_CERTIFICATES = {
|
||||
'localhost-leaf.pem': """-----BEGIN CERTIFICATE-----
|
||||
MIIBOTCB7KADAgECAhRcVxnOQNEFWf92K4dm4I8ChyjVMzAFBgMrZXAwFDESMBAG
|
||||
A1UEAwwJbG9jYWxob3N0MB4XDTI2MTAyMTE4NTgwN1oXDTM2MTAxODE4NTgwN1ow
|
||||
FDESMBAGA1UEAwwJbG9jYWxob3N0MCowBQYDK2VwAyEARglphvNgp9qRCESLHnuu
|
||||
W8VC5urdZl+YgS+Ux1BoLryjUDBOMB0GA1UdDgQWBBSaEckyR/76lX3a5Ijh8B42
|
||||
oJwN1DAfBgNVHSMEGDAWgBSaEckyR/76lX3a5Ijh8B42oJwN1DAMBgNVHRMBAf8E
|
||||
AjAAMAUGAytlcANBAJkq9ayy+d4AUZnjhWM31rFke5DT+l9QFcPl34+dpVhqJV/L
|
||||
m4IpPHYagqrBRHn9Dv/C8qYyPBKnAwJRpNptsgw=
|
||||
-----END CERTIFICATE-----
|
||||
""",
|
||||
'aaa.pem': """-----BEGIN CERTIFICATE-----
|
||||
MIIEMjCCAxqgAwIBAgIBATANBgkqhkiG9w0BAQUFADB7MQswCQYDVQQGEwJHQjEb
|
||||
MBkGA1UECAwSR3JlYXRlciBNYW5jaGVzdGVyMRAwDgYDVQQHDAdTYWxmb3JkMRow
|
||||
GAYDVQQKDBFDb21vZG8gQ0EgTGltaXRlZDEhMB8GA1UEAwwYQUFBIENlcnRpZmlj
|
||||
YXRlIFNlcnZpY2VzMB4XDTA0MDEwMTAwMDAwMFoXDTI4MTIzMTIzNTk1OVowezEL
|
||||
MAkGA1UEBhMCR0IxGzAZBgNVBAgMEkdyZWF0ZXIgTWFuY2hlc3RlcjEQMA4GA1UE
|
||||
BwwHU2FsZm9yZDEaMBgGA1UECgwRQ29tb2RvIENBIExpbWl0ZWQxITAfBgNVBAMM
|
||||
GEFBQSBDZXJ0aWZpY2F0ZSBTZXJ2aWNlczCCASIwDQYJKoZIhvcNAQEBBQADggEP
|
||||
ADCCAQoCggEBAL5AnfRu4ep2hxxNRUSOvkbIgwadwSr+GB+O5AL686tdUIoWMQua
|
||||
BtDFcCLNSS1UY8y2bmhGC1Pqy0wkwLxyTurxFa70VJoSCsN6sjNg4tqJVfMiWPPe
|
||||
3M/vg4aijJRPn2jymJBGhCfHdr/jzDUsi14HZGWCwEiwqJH5YZ92IFCokcdmtet4
|
||||
YgNW8IoaE+oxox6gmf049vYnMlhvB/VruPsUK6+3qszWY19zjNoFmag4qMsXeDZR
|
||||
rOme9Hg6jc8P2ULimAyrL58OAd7vn5lJ8S3frHRNG5i1R8XlKdH5kBjHYpy+g8cm
|
||||
ez6KJcfA3Z3mNWgQIJ2P2N7Sw4ScDV7oL8kCAwEAAaOBwDCBvTAdBgNVHQ4EFgQU
|
||||
oBEKIz6W8Qfs4q8p74Klf9AwpLQwDgYDVR0PAQH/BAQDAgEGMA8GA1UdEwEB/wQF
|
||||
MAMBAf8wewYDVR0fBHQwcjA4oDagNIYyaHR0cDovL2NybC5jb21vZG9jYS5jb20v
|
||||
QUFBQ2VydGlmaWNhdGVTZXJ2aWNlcy5jcmwwNqA0oDKGMGh0dHA6Ly9jcmwuY29t
|
||||
b2RvLm5ldC9BQUFDZXJ0aWZpY2F0ZVNlcnZpY2VzLmNybDANBgkqhkiG9w0BAQUF
|
||||
AAOCAQEACFb8AvCb6P+k+tZ7xkSAzk/ExfYAWMymtrwUSWgEdujm7l3sAg9g1o1Q
|
||||
GE8mTgHj5rCl7r+8dFRBv/38ErjHT1r0iWAFf2C3BUrz9vHCv8S5dIa2LX1rzNLz
|
||||
Rt0vxuBqw8M0Ayx9lt1awg6nCpnBBYurDC/zXDrPbDdVCYfeU0BsWO/8tqtlbgT2
|
||||
G9w84FoVxp7Z8VlIMCFlA2zs6SFz7JsDoeA3raAVGI/6ugLOpyypEBMs1OUIJqsi
|
||||
l2D4kF501KKaU73yqWjgom7C12yxow+ev+to51byrvLjKzg6CYG1a4XXvi3tPxq3
|
||||
smPi9WIsgtRqAEFQ8TmDn5XpNpaYbg==
|
||||
-----END CERTIFICATE-----
|
||||
""",
|
||||
'emsign-g1.pem': """-----BEGIN CERTIFICATE-----
|
||||
MIIDlDCCAnygAwIBAgIKMfXkYgxsWO3W2DANBgkqhkiG9w0BAQsFADBnMQswCQYD
|
||||
VQQGEwJJTjETMBEGA1UECxMKZW1TaWduIFBLSTElMCMGA1UEChMcZU11ZGhyYSBU
|
||||
ZWNobm9sb2dpZXMgTGltaXRlZDEcMBoGA1UEAxMTZW1TaWduIFJvb3QgQ0EgLSBH
|
||||
MTAeFw0xODAyMTgxODMwMDBaFw00MzAyMTgxODMwMDBaMGcxCzAJBgNVBAYTAklO
|
||||
MRMwEQYDVQQLEwplbVNpZ24gUEtJMSUwIwYDVQQKExxlTXVkaHJhIFRlY2hub2xv
|
||||
Z2llcyBMaW1pdGVkMRwwGgYDVQQDExNlbVNpZ24gUm9vdCBDQSAtIEcxMIIBIjAN
|
||||
BgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAk0u76WaK7p1b1TST0Bsew+eeuGQz
|
||||
f2N4aLTNLnF115sgxk0pvLZoYIr3IZpWNVrzdr3YzZr/k1ZLpVkGoZM0Kd0WNHVO
|
||||
8oG0x5ZOrRkVUkr+PHB1cM2vK6sVmjM8qrOLqs1D/fXqcP/tzxE7lM5OMhbTI0Aq
|
||||
d7OvPAEsbO2ZLIvZTmmYsvePQbAyeGHWDV/D+qJAkh1cF+ZwPjXnorfCYuKrpDhM
|
||||
tTk1b+oDafo6VGiFbdbyL0NVHpENDtjVaqSW0RM8LHhQ6DqS0hdW5TUaQBw+jSzt
|
||||
Od9C4INBdN+jzcKGYEho42kLVACL5HZpIQ15TjQIXhTCzLG3rdd8cIrHhQIDAQAB
|
||||
o0IwQDAdBgNVHQ4EFgQU++8Nhp6w492pufEhF38+/PB3KxowDgYDVR0PAQH/BAQD
|
||||
AgEGMA8GA1UdEwEB/wQFMAMBAf8wDQYJKoZIhvcNAQELBQADggEBAFn/8oz1h31x
|
||||
PaOfG1vR2vjTnGs2vZupYeveFix0PZ7mddrXuqe8QhfnPZHr5X3dPpzxz5KsbEjM
|
||||
wiI/aTvFthUvozXGaCocV685743QNcMYDHsAVhzNixl03r4PEuDQqqE/AjSxcM6d
|
||||
GNYIAwlG7mDgfrbESQRRfXBgvKqy/3lyeqYdPV8q+Mri/Tm3R7nrft8EI6/6nAYH
|
||||
6ftjk4BAtcZsCjEozgyfz7MjNYBBjWzEN3uBL4ChQEKF6dk4jeihU80Bv2noWgby
|
||||
RQuQ+q7hv53yrlc8pa6yVvSLZUDp/TGBLPQ5Cdjua6e0ph0VpZj3AYHYhX3zUVxx
|
||||
iN66zB+Afko=
|
||||
-----END CERTIFICATE-----
|
||||
""",
|
||||
'emsign-g3.pem': """-----BEGIN CERTIFICATE-----
|
||||
MIICTjCCAdOgAwIBAgIKPPYHqWhwDtqLhDAKBggqhkjOPQQDAzBrMQswCQYDVQQG
|
||||
EwJJTjETMBEGA1UECxMKZW1TaWduIFBLSTElMCMGA1UEChMcZU11ZGhyYSBUZWNo
|
||||
bm9sb2dpZXMgTGltaXRlZDEgMB4GA1UEAxMXZW1TaWduIEVDQyBSb290IENBIC0g
|
||||
RzMwHhcNMTgwMjE4MTgzMDAwWhcNNDMwMjE4MTgzMDAwWjBrMQswCQYDVQQGEwJJ
|
||||
TjETMBEGA1UECxMKZW1TaWduIFBLSTElMCMGA1UEChMcZU11ZGhyYSBUZWNobm9s
|
||||
b2dpZXMgTGltaXRlZDEgMB4GA1UEAxMXZW1TaWduIEVDQyBSb290IENBIC0gRzMw
|
||||
djAQBgcqhkjOPQIBBgUrgQQAIgNiAAQjpQy4LRL1KPOxst3iAhKAnjlfSU2fySU0
|
||||
WXTsuwYc58Byr+iuL+FBVIcUqEqy6HyC5ltqtdyzdc6LBtCGI79G1Y4PPwT01xyS
|
||||
fvalY8L1X44uT6EYGQIrMgqCZH0Wk9GjQjBAMB0GA1UdDgQWBBR8XQKEE9TMipuB
|
||||
zhccLikenEhjQjAOBgNVHQ8BAf8EBAMCAQYwDwYDVR0TAQH/BAUwAwEB/zAKBggq
|
||||
hkjOPQQDAwNpADBmAjEAvvNhzwIQHWSVB7gYboiFBS+DCBeQyh+KTOgNG3qxrdWB
|
||||
CUfvO6wIBHxcmbHtRwfSAjEAnbpV/KlK6O3t5nYBQnvI+GDZjVGLVTv7jHvrZQnD
|
||||
+JbNR6iC8hZVdyR+EhCVBCyj
|
||||
-----END CERTIFICATE-----
|
||||
""",
|
||||
}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -183,7 +183,10 @@ def test_history_and_chat_routes_support_reasoning_effort():
|
||||
"""Verify history and chat routes support reasoning_effort and thinking_mode persistence."""
|
||||
hist_src = (ROOT / "routes/history/history_routes.py").read_text(encoding="utf-8")
|
||||
assert "reasoning_effort" in hist_src
|
||||
assert 'mode = f"effort:{clean_effort}"' in hist_src
|
||||
assert "def _effort_mode(" in hist_src
|
||||
assert 'return f"effort:{effort}" if effort else "off"' in hist_src
|
||||
assert "mode = _effort_mode(session.model, raw_effort)" in hist_src
|
||||
assert "mode = _effort_mode(session.model, raw_mode[7:])" in hist_src
|
||||
|
||||
chat_src = (ROOT / "routes/chat_routes.py").read_text(encoding="utf-8")
|
||||
assert "validate_reasoning_effort(sess.model, reasoning_effort)" in chat_src
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
"""Regression guard for issue #6132 — the ChromaDB named volume was mounted at a
|
||||
path the image never writes to, so the whole vector store lived in the
|
||||
container's writable layer and was destroyed on every recreate. Silently: the
|
||||
collection survived with a count of 0, and retrieval just stopped returning
|
||||
anything.
|
||||
|
||||
``chromadb/chroma`` runs ``chroma run /config.yaml``, and that config is a single
|
||||
line — ``persist_path: "/data"``. The image declares no ``VOLUME``, so nothing
|
||||
outside a mount survives. ``/chroma/chroma`` is where pre-1.0 images kept the
|
||||
store.
|
||||
|
||||
The documented backup procedure (website/backup-restore.md) archives the
|
||||
``chromadb-data`` volume, so a wrong mount point also makes that tarball empty.
|
||||
|
||||
Pure YAML — no Docker required.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
COMPOSE_FILES = [
|
||||
ROOT / "docker-compose.yml",
|
||||
ROOT / "docker-compose.gpu-nvidia.yml",
|
||||
ROOT / "docker-compose.gpu-amd.yml",
|
||||
]
|
||||
|
||||
# `persist_path` from the image's own /config.yaml.
|
||||
CHROMA_PERSIST_PATH = "/data"
|
||||
LEGACY_PERSIST_PATH = "/chroma/chroma"
|
||||
VOLUME_NAME = "chromadb-data"
|
||||
|
||||
|
||||
def _chromadb_volumes(path: Path) -> list[str]:
|
||||
"""Return a normalised list of ``source:target`` strings for the chromadb service.
|
||||
|
||||
Docker Compose accepts volumes in two syntaxes:
|
||||
|
||||
* Short-form string: ``"chromadb-data:/data"`` or ``"chromadb-data:/data:ro"``
|
||||
* Long-form mapping::
|
||||
|
||||
- source: chromadb-data
|
||||
target: /data
|
||||
type: volume
|
||||
|
||||
Both forms are normalised to ``"<source>:<target>"`` so that callers can
|
||||
use ``split(":")`` to extract either part without worrying about the original
|
||||
syntax or trailing mount-option fields (e.g. ``:z``, ``:ro``).
|
||||
"""
|
||||
compose = yaml.safe_load(path.read_text(encoding="utf-8"))
|
||||
service = compose["services"]["chromadb"]
|
||||
raw = list(service.get("volumes") or [])
|
||||
result: list[str] = []
|
||||
for entry in raw:
|
||||
if isinstance(entry, dict):
|
||||
# Long-form: normalise to "source:target" for uniform handling.
|
||||
source = entry.get("source", "")
|
||||
target = entry.get("target", "")
|
||||
result.append(f"{source}:{target}")
|
||||
else:
|
||||
# Short-form string: keep as-is; split(":")[1] is still the target
|
||||
# even when a third field (mount options like :z, :ro) is present.
|
||||
result.append(str(entry))
|
||||
return result
|
||||
|
||||
|
||||
@pytest.mark.parametrize("path", COMPOSE_FILES, ids=lambda p: p.name)
|
||||
def test_chromadb_volume_is_mounted_at_the_persist_path(path: Path):
|
||||
mounts = _chromadb_volumes(path)
|
||||
targets = {m.split(":")[1] for m in mounts if m.startswith(f"{VOLUME_NAME}:")}
|
||||
assert targets, f"{path.name}: no {VOLUME_NAME} mount on the chromadb service"
|
||||
assert targets == {CHROMA_PERSIST_PATH}, (
|
||||
f"{path.name}: {VOLUME_NAME} is mounted at {sorted(targets)}, but the image "
|
||||
f"persists to {CHROMA_PERSIST_PATH!r}. Anything written outside a mount is "
|
||||
"lost when the container is recreated (issue #6132)."
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("path", COMPOSE_FILES, ids=lambda p: p.name)
|
||||
def test_chromadb_does_not_use_the_pre_1_0_path(path: Path):
|
||||
"""Verify that no mount places chromadb-data at the pre-1.0 store path.
|
||||
|
||||
Uses ``split(":")[1]`` rather than ``endswith()`` so that mount-option
|
||||
suffixes (e.g. ``:z``, ``:ro``) do not mask a wrong target path.
|
||||
"""
|
||||
mounts = _chromadb_volumes(path)
|
||||
legacy = [m for m in mounts if ":" in m and m.split(":")[1] == LEGACY_PERSIST_PATH]
|
||||
assert not legacy, (
|
||||
f"{path.name}: {LEGACY_PERSIST_PATH} is the pre-1.0 store location; "
|
||||
f"current images never write there. Offending entries: {legacy}"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("path", COMPOSE_FILES, ids=lambda p: p.name)
|
||||
def test_chromadb_data_volume_is_declared(path: Path):
|
||||
compose = yaml.safe_load(path.read_text(encoding="utf-8"))
|
||||
assert VOLUME_NAME in (compose.get("volumes") or {}), (
|
||||
f"{path.name}: the {VOLUME_NAME} volume is mounted but not declared"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Unit tests for _chromadb_volumes() — parse-correctness, not Compose state
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class _FakePath:
|
||||
"""Minimal Path stand-in that returns a fixed YAML string."""
|
||||
|
||||
def __init__(self, yaml_text: str):
|
||||
self._text = yaml_text
|
||||
|
||||
def read_text(self, encoding: str = "utf-8") -> str: # noqa: ARG002
|
||||
return self._text
|
||||
|
||||
|
||||
def test_helper_normalises_long_form_volume_dict():
|
||||
"""Long-form dict volume entries must not raise AttributeError."""
|
||||
p = _FakePath(yaml.dump({
|
||||
"services": {"chromadb": {"volumes": [
|
||||
{"source": "chromadb-data", "target": "/data", "type": "volume"},
|
||||
]}},
|
||||
"volumes": {"chromadb-data": None},
|
||||
}))
|
||||
result = _chromadb_volumes(p) # type: ignore[arg-type]
|
||||
assert result == ["chromadb-data:/data"]
|
||||
|
||||
|
||||
def test_helper_passes_short_form_string_unchanged():
|
||||
"""Short-form strings (with or without mount options) are returned as-is."""
|
||||
p = _FakePath(yaml.dump({
|
||||
"services": {"chromadb": {"volumes": ["chromadb-data:/data:z"]}},
|
||||
"volumes": {"chromadb-data": None},
|
||||
}))
|
||||
result = _chromadb_volumes(p) # type: ignore[arg-type]
|
||||
assert result == ["chromadb-data:/data:z"]
|
||||
# split(":")[1] still extracts the correct target regardless of :z suffix
|
||||
assert result[0].split(":")[1] == "/data"
|
||||
|
||||
|
||||
def test_helper_legacy_check_catches_path_with_mount_option_suffix():
|
||||
"""The split-based legacy check catches the old path even with :z appended."""
|
||||
mount = f"{VOLUME_NAME}:{LEGACY_PERSIST_PATH}:z"
|
||||
# Confirm endswith() would miss it (the bug we fixed)
|
||||
assert not mount.endswith(f":{LEGACY_PERSIST_PATH}"), (
|
||||
"endswith() should NOT catch ':z'-suffixed legacy path — that was the bug"
|
||||
)
|
||||
# Confirm split()[1] does catch it
|
||||
assert mount.split(":")[1] == LEGACY_PERSIST_PATH, (
|
||||
"split(':')[1] must still identify the legacy target path with :z suffix"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Regression tests for ChromaDB migration safety invariants (PR #6166 audit)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
MIGRATION_DOC = ROOT / "docs" / "chromadb-volume-migration.md"
|
||||
|
||||
|
||||
def test_migration_guide_requires_quiescence_before_backup():
|
||||
"""Step 2 must stop ChromaDB before running docker cp to prevent torn SQLite pages."""
|
||||
text = MIGRATION_DOC.read_text(encoding="utf-8")
|
||||
assert "docker compose stop chromadb" in text, (
|
||||
"Migration guide must require stopping ChromaDB before database backup"
|
||||
)
|
||||
# Confirm stopping step occurs prior to the cold backup step
|
||||
stop_step = text.find("Step 2: Quiesce Writers and Gracefully Stop ChromaDB")
|
||||
backup_step = text.find("Step 4: Take a Cold File Backup")
|
||||
assert stop_step != -1 and backup_step != -1 and stop_step < backup_step, (
|
||||
"Quiescence/stop step must precede cold backup step in migration sequence"
|
||||
)
|
||||
|
||||
|
||||
def test_migration_guide_mandates_container_preservation_via_commit():
|
||||
"""Guide must preserve stopped container as an immutable image before any recreation."""
|
||||
text = MIGRATION_DOC.read_text(encoding="utf-8")
|
||||
assert "docker commit" in text, (
|
||||
"Migration guide must instruct operators to commit the container to an immutable backup image"
|
||||
)
|
||||
assert "com.docker.compose.project" in text, (
|
||||
"Guide must explain that Compose discovers containers by internal labels, not name alone"
|
||||
)
|
||||
|
||||
|
||||
def test_migration_guide_requires_sqlite_and_hnsw_integrity_verification():
|
||||
"""Guide must mandate SQLite PRAGMA integrity_check and checking HNSW index files."""
|
||||
text = MIGRATION_DOC.read_text(encoding="utf-8")
|
||||
assert "PRAGMA integrity_check;" in text, (
|
||||
"Migration guide must require SQLite PRAGMA integrity_check"
|
||||
)
|
||||
assert "data_level0.bin" in text or "*.bin" in text, (
|
||||
"Migration guide must require verifying HNSW segment index binary files"
|
||||
)
|
||||
|
||||
|
||||
def test_migration_guide_requires_empty_volume_precheck():
|
||||
"""Guide must verify destination volume is strictly empty prior to seeding."""
|
||||
text = MIGRATION_DOC.read_text(encoding="utf-8")
|
||||
assert "ls -A" in text or "empty and ready for seeding" in text, (
|
||||
"Migration guide must check that the destination named volume is empty before seeding"
|
||||
)
|
||||
|
||||
|
||||
def test_migration_guide_includes_staging_validation():
|
||||
"""Guide must validate the seeded volume with a staging container before Compose cutover."""
|
||||
text = MIGRATION_DOC.read_text(encoding="utf-8")
|
||||
assert "chromadb-staging" in text, (
|
||||
"Migration guide must include staging container validation step"
|
||||
)
|
||||
|
||||
|
||||
def test_migration_guide_warns_against_reverting_to_broken_mount():
|
||||
"""Rollback instructions must explicitly prohibit git checkout to pre-#6166 broken mount."""
|
||||
text = MIGRATION_DOC.read_text(encoding="utf-8")
|
||||
assert "Do not `git checkout` the pre-#6166 Compose file" in text or "broken" in text.lower(), (
|
||||
"Rollback instructions must explain why checking out old Compose file breaks volume mount"
|
||||
)
|
||||
@@ -0,0 +1,41 @@
|
||||
"""The managed browser policy must distrust exactly the incident's eight keys."""
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
import unittest
|
||||
|
||||
from cryptography import x509
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
# Public incident inventory: impacted-intermediates.json, 2026-10-21.
|
||||
COMPROMISED_SPKIS = {
|
||||
"43bffa19d6ef651451f6841e019931e96f6f2eaeff28ab4b6f1ce4576de2717b",
|
||||
"49dd353dc2b919a3770645117d0e60c16115e7d1bc259eefe734b00ea434fe5f",
|
||||
"5c0660a2b164910d8ff589c18e14552e9e0ec237dcefe2cb736d41a4811f81a0",
|
||||
"63ecefee431d49523bc1136c4e72d0206a477086caf606cd5cf025686c109cf8",
|
||||
"92c063d67e8f14db71d288e11266ffdaf69cbf115f7bab14f3bc2f68d25e0e30",
|
||||
"e0254c7fcdc265e2f0f8a428dabb75ab4ae039b4872d62f5ee57f0877d5a8a41",
|
||||
"ebed6475c2c2d80fcb199f3fe8dd6e6b3ce60b77eb15d4e42c3dec0c6f5b5105",
|
||||
"ec4ffa1ad0ac88081314b7aaffef6a91f97cd57291bb5ba686c5415f5eb4f6b6",
|
||||
}
|
||||
|
||||
|
||||
class ChromiumCATrustTests(unittest.TestCase):
|
||||
def test_policy_distrusts_each_compromised_key_without_adding_trust(self):
|
||||
policy = json.loads((ROOT / "docker" / "chromium-emudhra.json").read_text())
|
||||
self.assertEqual(set(policy), {"CADistrustedCertificates"})
|
||||
self.assertEqual(len(policy["CADistrustedCertificates"]), 8)
|
||||
keys = set()
|
||||
for encoded in policy["CADistrustedCertificates"]:
|
||||
certificate = x509.load_der_x509_certificate(base64.b64decode(encoded, validate=True))
|
||||
self.assertTrue(certificate.extensions.get_extension_for_class(x509.BasicConstraints).value.ca)
|
||||
self.assertNotEqual(certificate.subject, certificate.issuer)
|
||||
public_key = certificate.public_key().public_bytes(
|
||||
serialization.Encoding.DER, serialization.PublicFormat.SubjectPublicKeyInfo
|
||||
)
|
||||
keys.add(hashlib.sha256(public_key).hexdigest())
|
||||
self.assertEqual(keys, COMPROMISED_SPKIS)
|
||||
@@ -33,3 +33,16 @@ def test_python_tests_are_authoritative():
|
||||
|
||||
assert "python -m pytest -q" in python_tests
|
||||
assert "continue-on-error:" not in python_tests
|
||||
|
||||
|
||||
def test_ci_checks_system_trust_before_project_installation():
|
||||
python_tests = _indented_block(_WORKFLOW.read_text(), "python-tests", 2)
|
||||
system_check = python_tests.index("python scripts/check_ca_trust.py --system\n")
|
||||
system_update = python_tests.index("sudo apt-get install -y --no-install-recommends ffmpeg bubblewrap ca-certificates")
|
||||
bootstrap = python_tests.index("uv pip install --system --require-hashes")
|
||||
python_check = python_tests.index("python scripts/check_ca_trust.py --python\n")
|
||||
application_install = python_tests.index("uv pip install --system -r requirements.txt")
|
||||
|
||||
assert system_update < system_check < bootstrap < python_check < application_install
|
||||
assert system_check < python_tests.index("npm ci")
|
||||
assert system_check < python_tests.index("npx playwright install")
|
||||
|
||||
@@ -0,0 +1,377 @@
|
||||
"""Cleanup must use durable transcript rows for its deletion safeguards."""
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy import create_engine, event, insert
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
import core.database as database
|
||||
import src.database as legacy_database
|
||||
import routes.cleanup.cleanup_routes as routes
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def cleanup_api(tmp_path, monkeypatch):
|
||||
engine = create_engine(
|
||||
f"sqlite:///{(tmp_path / 'cleanup.db').as_posix()}",
|
||||
connect_args={"check_same_thread": False},
|
||||
)
|
||||
database.Base.metadata.create_all(engine)
|
||||
factory = sessionmaker(bind=engine, autoflush=False)
|
||||
monkeypatch.setattr(legacy_database, "SessionLocal", factory)
|
||||
monkeypatch.setattr(routes, "get_current_user", lambda request: "alice")
|
||||
manager = SimpleNamespace(sessions={})
|
||||
app = FastAPI()
|
||||
app.include_router(routes.setup_cleanup_routes(manager))
|
||||
with TestClient(app) as client:
|
||||
yield client, factory, manager
|
||||
engine.dispose()
|
||||
|
||||
|
||||
def seed(factory, manager, stored, actual, *, archived=True):
|
||||
now = datetime.now(timezone.utc).replace(tzinfo=None)
|
||||
with factory() as db:
|
||||
for index in range(10):
|
||||
db.add(database.Session(
|
||||
id=f"recent-{index}", name="Recent", endpoint_url="", model="",
|
||||
owner="alice", created_at=now - timedelta(hours=index),
|
||||
last_accessed=now, message_count=1,
|
||||
))
|
||||
db.add(database.Session(
|
||||
id="old-chat", name="Project discussion", endpoint_url="", model="",
|
||||
owner="alice", created_at=now - timedelta(days=50),
|
||||
last_accessed=now - timedelta(days=30), archived=archived,
|
||||
is_important=False, message_count=stored,
|
||||
))
|
||||
db.commit()
|
||||
db.add_all(database.ChatMessage(
|
||||
id=f"message-{index}", session_id="old-chat", role="user",
|
||||
content=f"Saved project discussion {index}", timestamp=now - timedelta(days=30),
|
||||
) for index in range(actual))
|
||||
db.commit()
|
||||
manager.sessions["old-chat"] = SimpleNamespace(message_count=actual)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("stored", [0, 3])
|
||||
def test_cleanup_preserves_long_transcript_with_stale_count(cleanup_api, stored):
|
||||
client, factory, manager = cleanup_api
|
||||
seed(factory, manager, stored, 40)
|
||||
preview = client.get("/api/cleanup/preview").json()
|
||||
assert not preview["sessions_to_delete"]
|
||||
protected = next(row for row in preview["preserved_sessions"] if row["id"] == "old-chat")
|
||||
assert protected["message_count"] == 40
|
||||
response = client.post("/api/cleanup")
|
||||
with factory() as db:
|
||||
actual = db.query(database.ChatMessage).filter_by(session_id="old-chat").count()
|
||||
survives = db.get(database.Session, "old-chat") is not None
|
||||
assert response.json()["deleted_count"] == 0
|
||||
assert survives and actual == 40, "Cleanup must count actual rows before hard deletion"
|
||||
|
||||
|
||||
def test_cleanup_still_removes_genuinely_short_old_chat(cleanup_api):
|
||||
client, factory, manager = cleanup_api
|
||||
seed(factory, manager, 3, 3)
|
||||
response = client.post("/api/cleanup")
|
||||
assert response.json()["deleted_count"] == 1
|
||||
with factory() as db:
|
||||
assert db.get(database.Session, "old-chat") is None
|
||||
assert db.query(database.ChatMessage).filter_by(session_id="old-chat").count() == 0
|
||||
|
||||
|
||||
def test_new_archive_is_not_deleted_in_same_cleanup(cleanup_api):
|
||||
client, factory, manager = cleanup_api
|
||||
seed(factory, manager, 3, 3, archived=False)
|
||||
preview = client.get("/api/cleanup/preview").json()
|
||||
assert not preview["sessions_to_delete"]
|
||||
response = client.post("/api/cleanup")
|
||||
assert response.json()["deleted_count"] == 0
|
||||
with factory() as db:
|
||||
assert db.get(database.Session, "old-chat").archived
|
||||
|
||||
|
||||
@pytest.mark.parametrize("actual,expected_deleted", [(19, 1), (20, 0)])
|
||||
def test_cleanup_threshold_uses_actual_count_even_when_metadata_is_high(cleanup_api, actual, expected_deleted):
|
||||
client, factory, manager = cleanup_api
|
||||
seed(factory, manager, 100, actual)
|
||||
preview = client.get("/api/cleanup/preview").json()
|
||||
section = "sessions_to_delete" if expected_deleted else "preserved_sessions"
|
||||
row = next(row for row in preview[section] if row["id"] == "old-chat")
|
||||
assert row["message_count"] == actual
|
||||
if expected_deleted:
|
||||
assert row["estimated_size_kb"] == 19 * 512 / 1024
|
||||
response = client.post("/api/cleanup")
|
||||
assert response.json()["deleted_count"] == expected_deleted
|
||||
with factory() as db:
|
||||
assert (db.get(database.Session, "old-chat") is None) == bool(expected_deleted)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("protection", ["starred", "keyword", "other_owner"])
|
||||
def test_cleanup_retains_existing_protections(cleanup_api, protection):
|
||||
client, factory, manager = cleanup_api
|
||||
seed(factory, manager, 0, 3)
|
||||
with factory() as db:
|
||||
row = db.get(database.Session, "old-chat")
|
||||
if protection == "starred":
|
||||
row.is_important = True
|
||||
elif protection == "keyword":
|
||||
row.name = "Important project"
|
||||
else:
|
||||
row.owner = "bob"
|
||||
# Preserve the old activity date despite the column's onupdate default.
|
||||
row.last_accessed = datetime.now(timezone.utc).replace(tzinfo=None) - timedelta(days=30)
|
||||
db.commit()
|
||||
response = client.post("/api/cleanup")
|
||||
assert response.json()["deleted_count"] == 0
|
||||
with factory() as db:
|
||||
assert db.query(database.ChatMessage).filter_by(session_id="old-chat").count() == 3
|
||||
|
||||
|
||||
def test_cleanup_rechecks_messages_at_the_delete_boundary(cleanup_api):
|
||||
"""Deliver new rows after selection but before the DELETE executes."""
|
||||
client, factory, manager = cleanup_api
|
||||
seed(factory, manager, 3, 3)
|
||||
engine = factory.kw["bind"]
|
||||
injected = False
|
||||
|
||||
def deliver_messages(connection, cursor, statement, parameters, context, executemany):
|
||||
nonlocal injected
|
||||
if not injected and statement.lstrip().startswith("DELETE FROM sessions"):
|
||||
injected = True
|
||||
connection.execute(insert(database.ChatMessage), [
|
||||
{"id": f"incoming-{i}", "session_id": "old-chat", "role": "assistant",
|
||||
"content": "New durable message"} for i in range(20)
|
||||
])
|
||||
|
||||
event.listen(engine, "before_cursor_execute", deliver_messages)
|
||||
try:
|
||||
response = client.post("/api/cleanup")
|
||||
finally:
|
||||
event.remove(engine, "before_cursor_execute", deliver_messages)
|
||||
assert injected
|
||||
assert response.json()["deleted_count"] == 0
|
||||
assert response.json()["space_freed_mb"] == 0
|
||||
assert "old-chat" in manager.sessions
|
||||
with factory() as db:
|
||||
assert db.get(database.Session, "old-chat") is not None
|
||||
assert db.query(database.ChatMessage).filter_by(session_id="old-chat").count() == 23
|
||||
|
||||
|
||||
def test_cleanup_space_freed_ignores_concurrently_deleted_session(cleanup_api):
|
||||
"""If another transaction deletes a candidate first, do not attribute its space."""
|
||||
client, factory, manager = cleanup_api
|
||||
now = datetime.now(timezone.utc).replace(tzinfo=None)
|
||||
with factory() as db:
|
||||
for index in range(10):
|
||||
db.add(database.Session(
|
||||
id=f"recent-{index}", name="Recent", endpoint_url="", model="",
|
||||
owner="alice", created_at=now - timedelta(hours=index),
|
||||
last_accessed=now, message_count=1,
|
||||
))
|
||||
db.add(database.Session(
|
||||
id="chat-to-delete", name="Eligible 1", endpoint_url="", model="",
|
||||
owner="alice", created_at=now - timedelta(days=50),
|
||||
last_accessed=now - timedelta(days=30), archived=True,
|
||||
is_important=False, message_count=3,
|
||||
))
|
||||
db.add(database.Session(
|
||||
id="chat-concurrently-deleted", name="Eligible 2", endpoint_url="", model="",
|
||||
owner="alice", created_at=now - timedelta(days=50),
|
||||
last_accessed=now - timedelta(days=30), archived=True,
|
||||
is_important=False, message_count=10,
|
||||
))
|
||||
db.commit()
|
||||
db.add_all(database.ChatMessage(
|
||||
id=f"msg1-{i}", session_id="chat-to-delete", role="user",
|
||||
content="m", timestamp=now - timedelta(days=30),
|
||||
) for i in range(3))
|
||||
db.add_all(database.ChatMessage(
|
||||
id=f"msg2-{i}", session_id="chat-concurrently-deleted", role="user",
|
||||
content="m", timestamp=now - timedelta(days=30),
|
||||
) for i in range(10))
|
||||
db.commit()
|
||||
|
||||
manager.sessions["chat-to-delete"] = SimpleNamespace(message_count=3)
|
||||
manager.sessions["chat-concurrently-deleted"] = SimpleNamespace(message_count=10)
|
||||
|
||||
engine = factory.kw["bind"]
|
||||
injected = False
|
||||
|
||||
def delete_candidate(connection, cursor, statement, parameters, context, executemany):
|
||||
nonlocal injected
|
||||
if not injected and statement.lstrip().startswith("DELETE FROM sessions"):
|
||||
injected = True
|
||||
with factory() as other_db:
|
||||
other_db.query(database.Session).filter_by(id="chat-concurrently-deleted").delete()
|
||||
other_db.commit()
|
||||
|
||||
event.listen(engine, "before_cursor_execute", delete_candidate)
|
||||
try:
|
||||
response = client.post("/api/cleanup")
|
||||
finally:
|
||||
event.remove(engine, "before_cursor_execute", delete_candidate)
|
||||
|
||||
assert injected
|
||||
data = response.json()
|
||||
assert data["deleted_count"] == 1
|
||||
# Only chat-to-delete's 3 messages (1536 bytes) freed by this cleanup, not (3 + 10) * 512
|
||||
expected_space_mb = round(3 * 512 / (1024 * 1024), 2)
|
||||
assert data["space_freed_mb"] == expected_space_mb
|
||||
assert "chat-to-delete" not in manager.sessions
|
||||
assert "chat-concurrently-deleted" not in manager.sessions
|
||||
with factory() as db:
|
||||
assert db.get(database.Session, "chat-to-delete") is None
|
||||
assert db.get(database.Session, "chat-concurrently-deleted") is None
|
||||
|
||||
|
||||
def test_cleanup_recent_session_query_is_bounded(cleanup_api):
|
||||
"""Ensure recent-session preservation scales to large collections and respects limits."""
|
||||
client, factory, manager = cleanup_api
|
||||
now = datetime.now(timezone.utc).replace(tzinfo=None)
|
||||
with factory() as db:
|
||||
for index in range(25):
|
||||
db.add(database.Session(
|
||||
id=f"sess-{index:02d}", name=f"Session {index}", endpoint_url="", model="",
|
||||
owner="alice", created_at=now - timedelta(hours=index),
|
||||
last_accessed=now - timedelta(days=30), archived=True,
|
||||
is_important=False, message_count=3,
|
||||
))
|
||||
db.add_all(database.ChatMessage(
|
||||
id=f"msg-{index}-{m}", session_id=f"sess-{index:02d}", role="user",
|
||||
content="m", timestamp=now - timedelta(days=30),
|
||||
) for m in range(3))
|
||||
db.commit()
|
||||
|
||||
preview = client.get("/api/cleanup/preview").json()
|
||||
preserved_ids = {row["id"] for row in preview["preserved_sessions"]}
|
||||
to_delete_ids = {row["id"] for row in preview["sessions_to_delete"]}
|
||||
|
||||
# Top 10 newest (sess-00 to sess-09) must be preserved
|
||||
expected_preserved = {f"sess-{i:02d}" for i in range(10)}
|
||||
expected_to_delete = {f"sess-{i:02d}" for i in range(10, 25)}
|
||||
assert preserved_ids == expected_preserved
|
||||
assert to_delete_ids == expected_to_delete
|
||||
|
||||
response = client.post("/api/cleanup")
|
||||
assert response.json()["deleted_count"] == 15
|
||||
|
||||
with factory() as db:
|
||||
surviving = {row[0] for row in db.query(database.Session.id).all()}
|
||||
assert surviving == expected_preserved
|
||||
|
||||
|
||||
def test_cleanup_handles_zero_actual_messages_session(cleanup_api):
|
||||
"""Sessions with zero durable messages are properly previewed and cleaned up."""
|
||||
client, factory, manager = cleanup_api
|
||||
seed(factory, manager, 10, 0)
|
||||
preview = client.get("/api/cleanup/preview").json()
|
||||
row = next(r for r in preview["sessions_to_delete"] if r["id"] == "old-chat")
|
||||
assert row["message_count"] == 0
|
||||
assert row["estimated_size_kb"] == 0.0
|
||||
|
||||
response = client.post("/api/cleanup")
|
||||
assert response.json()["deleted_count"] == 1
|
||||
with factory() as db:
|
||||
assert db.get(database.Session, "old-chat") is None
|
||||
|
||||
|
||||
def test_cleanup_db_error_preserves_cache_and_aborts_cleanly(cleanup_api):
|
||||
"""Database execution failures must abort, rollback, and leave in-memory cache intact."""
|
||||
client, factory, manager = cleanup_api
|
||||
seed(factory, manager, 3, 3)
|
||||
engine = factory.kw["bind"]
|
||||
injected = False
|
||||
|
||||
def break_delete(connection, cursor, statement, parameters, context, executemany):
|
||||
nonlocal injected
|
||||
if not injected and statement.lstrip().startswith("DELETE FROM sessions"):
|
||||
injected = True
|
||||
raise RuntimeError("Injected database error during DELETE")
|
||||
|
||||
event.listen(engine, "before_cursor_execute", break_delete)
|
||||
try:
|
||||
response = client.post("/api/cleanup")
|
||||
finally:
|
||||
event.remove(engine, "before_cursor_execute", break_delete)
|
||||
|
||||
assert injected
|
||||
assert response.status_code == 200
|
||||
assert response.json()["deleted_count"] == 0
|
||||
assert response.json()["space_freed_mb"] == 0
|
||||
# In-memory session was not prematurely evicted
|
||||
assert "old-chat" in manager.sessions
|
||||
# Database row was rolled back and still exists
|
||||
with factory() as db:
|
||||
assert db.get(database.Session, "old-chat") is not None
|
||||
assert db.query(database.ChatMessage).filter_by(session_id="old-chat").count() == 3
|
||||
|
||||
|
||||
def test_cleanup_portable_fallback_without_returning(cleanup_api, monkeypatch):
|
||||
"""Engines lacking RETURNING (e.g. SQLite < 3.35.0) safely clean up without syntax errors."""
|
||||
import src.cleanup_service as cleanup_service
|
||||
monkeypatch.setattr(cleanup_service, "_supports_delete_returning", lambda db: False)
|
||||
|
||||
client, factory, manager = cleanup_api
|
||||
now = datetime.now(timezone.utc).replace(tzinfo=None)
|
||||
with factory() as db:
|
||||
for index in range(10):
|
||||
db.add(database.Session(
|
||||
id=f"recent-{index}", name="Recent", endpoint_url="", model="",
|
||||
owner="alice", created_at=now - timedelta(hours=index),
|
||||
last_accessed=now, message_count=1,
|
||||
))
|
||||
db.add(database.Session(
|
||||
id="chat-to-delete", name="Eligible 1", endpoint_url="", model="",
|
||||
owner="alice", created_at=now - timedelta(days=50),
|
||||
last_accessed=now - timedelta(days=30), archived=True,
|
||||
is_important=False, message_count=3,
|
||||
))
|
||||
db.add(database.Session(
|
||||
id="chat-concurrently-deleted", name="Eligible 2", endpoint_url="", model="",
|
||||
owner="alice", created_at=now - timedelta(days=50),
|
||||
last_accessed=now - timedelta(days=30), archived=True,
|
||||
is_important=False, message_count=10,
|
||||
))
|
||||
db.commit()
|
||||
db.add_all(database.ChatMessage(
|
||||
id=f"msg1-{i}", session_id="chat-to-delete", role="user",
|
||||
content="m", timestamp=now - timedelta(days=30),
|
||||
) for i in range(3))
|
||||
db.add_all(database.ChatMessage(
|
||||
id=f"msg2-{i}", session_id="chat-concurrently-deleted", role="user",
|
||||
content="m", timestamp=now - timedelta(days=30),
|
||||
) for i in range(10))
|
||||
db.commit()
|
||||
|
||||
manager.sessions["chat-to-delete"] = SimpleNamespace(message_count=3)
|
||||
manager.sessions["chat-concurrently-deleted"] = SimpleNamespace(message_count=10)
|
||||
|
||||
engine = factory.kw["bind"]
|
||||
injected = False
|
||||
|
||||
def delete_candidate(connection, cursor, statement, parameters, context, executemany):
|
||||
nonlocal injected
|
||||
if not injected and statement.lstrip().startswith("DELETE FROM sessions"):
|
||||
injected = True
|
||||
with factory() as other_db:
|
||||
other_db.query(database.Session).filter_by(id="chat-concurrently-deleted").delete()
|
||||
other_db.commit()
|
||||
|
||||
event.listen(engine, "before_cursor_execute", delete_candidate)
|
||||
try:
|
||||
response = client.post("/api/cleanup")
|
||||
finally:
|
||||
event.remove(engine, "before_cursor_execute", delete_candidate)
|
||||
|
||||
assert injected
|
||||
data = response.json()
|
||||
assert data["deleted_count"] == 1
|
||||
expected_space_mb = round(3 * 512 / (1024 * 1024), 2)
|
||||
assert data["space_freed_mb"] == expected_space_mb
|
||||
assert "chat-to-delete" not in manager.sessions
|
||||
assert "chat-concurrently-deleted" not in manager.sessions
|
||||
with factory() as db:
|
||||
assert db.get(database.Session, "chat-to-delete") is None
|
||||
assert db.get(database.Session, "chat-concurrently-deleted") is None
|
||||
@@ -0,0 +1,111 @@
|
||||
"""Agent chat history uses the same scope as existing chat and history routes."""
|
||||
|
||||
import pytest
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from starlette.requests import Request
|
||||
|
||||
from routes import codex_routes
|
||||
|
||||
|
||||
def _endpoint(router, method: str, path: str):
|
||||
for route in router.routes:
|
||||
if route.path == path and method in route.methods:
|
||||
return route.endpoint
|
||||
raise AssertionError(f"{method} {path} route not found")
|
||||
|
||||
|
||||
def _request(scopes: list[str]) -> Request:
|
||||
request = Request({
|
||||
"type": "http",
|
||||
"method": "GET",
|
||||
"path": "/api/codex/chat/session-test",
|
||||
"headers": [],
|
||||
"state": {},
|
||||
})
|
||||
request.state.current_user = "api"
|
||||
request.state.api_token = True
|
||||
request.state.api_token_owner = "alice"
|
||||
request.state.api_token_scopes = scopes
|
||||
return request
|
||||
|
||||
|
||||
def _history_router(calls: list[tuple]):
|
||||
router = APIRouter()
|
||||
|
||||
@router.get("/api/history/{session_id}")
|
||||
async def history(
|
||||
request: Request,
|
||||
session_id: str,
|
||||
limit: int | None = None,
|
||||
offset: int | None = None,
|
||||
):
|
||||
calls.append((request, session_id, limit, offset))
|
||||
return {
|
||||
"name": "Model debugging",
|
||||
"model": "org/model",
|
||||
"history": [{"role": "assistant", "content": "second"}],
|
||||
"total": 3,
|
||||
"offset": offset,
|
||||
"limit": limit,
|
||||
"endpoint_url": "http://private-endpoint",
|
||||
}
|
||||
|
||||
return router
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_history_read_uses_existing_paged_handler_without_exposing_endpoint_url():
|
||||
calls = []
|
||||
router = codex_routes.setup_codex_routes(history_router=_history_router(calls))
|
||||
read_chat = _endpoint(router, "GET", "/api/codex/chat/{session_id}")
|
||||
request = _request(["chat"])
|
||||
|
||||
result = await read_chat(request, "session-test", offset=1, limit=1)
|
||||
|
||||
assert calls == [(request, "session-test", 1, 1)]
|
||||
assert result == {
|
||||
"session_id": "session-test",
|
||||
"name": "Model debugging",
|
||||
"model": "org/model",
|
||||
"messages": [{"role": "assistant", "content": "second"}],
|
||||
"total": 3,
|
||||
"offset": 1,
|
||||
"limit": 1,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_token_without_chat_scope_cannot_read_history_or_reach_handler():
|
||||
calls = []
|
||||
router = codex_routes.setup_codex_routes(history_router=_history_router(calls))
|
||||
read_chat = _endpoint(router, "GET", "/api/codex/chat/{session_id}")
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await read_chat(_request(["todos:read"]), "session-test")
|
||||
|
||||
assert exc_info.value.status_code == 403
|
||||
assert calls == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_history_read_uses_existing_page_limit():
|
||||
calls = []
|
||||
router = codex_routes.setup_codex_routes(history_router=_history_router(calls))
|
||||
read_chat = _endpoint(router, "GET", "/api/codex/chat/{session_id}")
|
||||
|
||||
await read_chat(_request(["chat"]), "session-test", limit=1000)
|
||||
|
||||
assert calls[0][2:] == (100, 0)
|
||||
|
||||
|
||||
def test_capabilities_report_history_permission_from_chat_scope():
|
||||
router = codex_routes.setup_codex_routes(history_router=_history_router([]))
|
||||
capabilities = _endpoint(router, "GET", "/api/codex/capabilities")
|
||||
|
||||
without_chat = capabilities(_request(["todos:read"]))["tools"]["chat"]
|
||||
reader = capabilities(_request(["chat"]))["tools"]["chat"]
|
||||
|
||||
assert without_chat["read"] is False
|
||||
assert reader["read"] is True
|
||||
assert reader["available"] is True
|
||||
assert reader["actions"] == ["read"]
|
||||
@@ -0,0 +1,34 @@
|
||||
"""Sidecar images must be pinned to a versioned tag and an immutable digest.
|
||||
|
||||
A bare or ``:latest`` reference changes underneath an existing install on the
|
||||
next ``docker compose pull``/``up``, so two hosts on the same commit can run
|
||||
different ChromaDB or ntfy builds. The ``odysseus`` service is excluded: it is
|
||||
the project's own image, selected through ``ODYSSEUS_IMAGE``. The standalone
|
||||
GPU files are held equal to these services by test_gpu_compose_standalone.py.
|
||||
"""
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
|
||||
COMPOSE = Path(__file__).resolve().parents[1] / "docker-compose.yml"
|
||||
PINNED = re.compile(r"^[\w.\-/]+:(?P<tag>[\w.\-]+)@sha256:[0-9a-f]{64}$")
|
||||
|
||||
|
||||
def _sidecars():
|
||||
services = yaml.safe_load(COMPOSE.read_text(encoding="utf-8"))["services"]
|
||||
return {name: svc["image"] for name, svc in services.items() if name != "odysseus"}
|
||||
|
||||
|
||||
def test_expected_sidecars_present():
|
||||
assert {"chromadb", "searxng", "ntfy"} <= set(_sidecars())
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name", sorted(_sidecars()))
|
||||
def test_sidecar_image_has_version_tag_and_digest(name):
|
||||
image = _sidecars()[name]
|
||||
match = PINNED.match(image)
|
||||
assert match, f"{name}: expected <repo>:<tag>@sha256:<digest>, got {image!r}"
|
||||
assert match.group("tag") != "latest", f"{name}: pin a release tag, not latest"
|
||||
@@ -311,7 +311,14 @@ class TestMaybeCompactFourthMessage:
|
||||
|
||||
cc.get_context_length = lambda url, model: context_length
|
||||
cc.llm_call_async = _fake_summary
|
||||
cc.resolve_endpoint = lambda which, owner=None: (None, None, None)
|
||||
def fake_resolve_endpoint(which, **kwargs):
|
||||
assert which == "utility"
|
||||
assert kwargs["fallback_url"] == "http://local/v1/chat/completions"
|
||||
assert kwargs["fallback_model"] == "local-model"
|
||||
assert kwargs["fallback_headers"] == {}
|
||||
return None, None, None
|
||||
|
||||
cc.resolve_endpoint = fake_resolve_endpoint
|
||||
cc._update_session_history = lambda *a, **k: None
|
||||
try:
|
||||
return asyncio.run(
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
"""Read-only Cookbook tools must be admin-only for the agent.
|
||||
|
||||
Regression: `list_cached_models`, `list_served_models`, `tail_serve_output`,
|
||||
`list_downloads`, `search_hf_models`, `list_serve_presets` and
|
||||
`list_cookbook_servers` sat in the plan-mode read-only allowlist but not in
|
||||
NON_ADMIN_BLOCKED_TOOLS or the dispatcher's _ADMIN_TOOLS. Their
|
||||
implementations call require_admin routes over the internal loopback with the
|
||||
process-wide INTERNAL_TOOL_TOKEN and never look at the owner, so a non-admin
|
||||
user's agent could reach admin capability through them. The worst case is
|
||||
`list_cached_models`: a caller-chosen `host` is forwarded to
|
||||
/api/model/cached, which SSHes to that host with the app user's keys and
|
||||
walks caller-chosen `model_dir`s. THREAT_MODEL.md lists model serving as
|
||||
admin-only, so the read side has to be gated the same way as serve/download.
|
||||
"""
|
||||
|
||||
import sys
|
||||
import types
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
|
||||
from src.tool_security import (
|
||||
NON_ADMIN_BLOCKED_TOOLS,
|
||||
blocked_tools_for_owner,
|
||||
delegated_credential_blocked_tools,
|
||||
)
|
||||
from src.tool_execution import _ADMIN_TOOLS
|
||||
from src.agent_runtime.authority import OperationGrant, RequestAuthority
|
||||
|
||||
READONLY_COOKBOOK_TOOLS = (
|
||||
"list_cached_models",
|
||||
"list_served_models",
|
||||
"tail_serve_output",
|
||||
"list_downloads",
|
||||
"search_hf_models",
|
||||
"list_serve_presets",
|
||||
"list_cookbook_servers",
|
||||
)
|
||||
|
||||
|
||||
def _install_core_auth_stub(monkeypatch, *, is_admin: bool):
|
||||
"""Narrow auth surface: AUTH enabled, configured, owner admin or not."""
|
||||
core_mod = types.ModuleType("core")
|
||||
core_mod.__path__ = []
|
||||
auth_mod = types.ModuleType("core.auth")
|
||||
|
||||
class FakeAuth:
|
||||
is_configured = True
|
||||
|
||||
def is_admin(self, username):
|
||||
return is_admin
|
||||
|
||||
auth_mod.AuthManager = lambda: FakeAuth()
|
||||
core_mod.auth = auth_mod
|
||||
monkeypatch.setitem(sys.modules, "core", core_mod)
|
||||
monkeypatch.setitem(sys.modules, "core.auth", auth_mod)
|
||||
monkeypatch.setattr("src.auth_helpers._auth_disabled", lambda: False)
|
||||
return auth_mod
|
||||
|
||||
|
||||
def test_readonly_cookbook_tools_are_in_every_admin_gate():
|
||||
from src.agent_loop import _ADMIN_TOOLS as prompt_admin_tools
|
||||
|
||||
# Spelled out (not imported from a shared tuple in src/) so dropping one
|
||||
# from either set fails here instead of silently reopening the hole.
|
||||
for tool in READONLY_COOKBOOK_TOOLS:
|
||||
assert tool in NON_ADMIN_BLOCKED_TOOLS, tool
|
||||
assert tool in _ADMIN_TOOLS, tool
|
||||
assert tool in prompt_admin_tools, tool
|
||||
|
||||
|
||||
def test_readonly_cookbook_tools_hidden_from_non_admin_and_bearer_runs(monkeypatch):
|
||||
_install_core_auth_stub(monkeypatch, is_admin=False)
|
||||
hidden = blocked_tools_for_owner("regular-user")
|
||||
for tool in READONLY_COOKBOOK_TOOLS:
|
||||
assert tool in hidden, tool
|
||||
assert tool in delegated_credential_blocked_tools(), tool
|
||||
|
||||
|
||||
def test_admin_owner_keeps_readonly_cookbook_tools(monkeypatch):
|
||||
_install_core_auth_stub(monkeypatch, is_admin=True)
|
||||
assert blocked_tools_for_owner("admin-user") == set()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("tool_name", READONLY_COOKBOOK_TOOLS)
|
||||
async def test_non_admin_agent_cannot_execute_readonly_cookbook_tools(monkeypatch, tool_name):
|
||||
_install_core_auth_stub(monkeypatch, is_admin=False)
|
||||
from src.tool_execution import NO_TOOL_SECURITY_CONTEXT, execute_tool_block
|
||||
|
||||
# The loopback must never be touched: if the gate fails, the tool would
|
||||
# hit a require_admin route with the internal token.
|
||||
import httpx
|
||||
|
||||
def _boom(*args, **kwargs):
|
||||
raise AssertionError(f"{tool_name} reached the internal loopback for a non-admin owner")
|
||||
|
||||
monkeypatch.setattr(httpx, "AsyncClient", _boom)
|
||||
monkeypatch.setattr(httpx, "get", _boom)
|
||||
monkeypatch.setattr(httpx, "post", _boom)
|
||||
|
||||
payload = '{"host": "root@10.0.0.5", "ssh_port": "22", "model_dir": "/root,/etc"}'
|
||||
desc, result = await execute_tool_block(
|
||||
SimpleNamespace(tool_type=tool_name, content=payload),
|
||||
owner="regular-user",
|
||||
security_context=NO_TOOL_SECURITY_CONTEXT,
|
||||
request_authority=RequestAuthority(
|
||||
"cookbook-test", "regular-user", "", "", (OperationGrant(tool_name),)
|
||||
),
|
||||
)
|
||||
assert desc == f"{tool_name}: BLOCKED"
|
||||
assert result["exit_code"] == 1
|
||||
assert "admin" in result["error"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("tool_name", READONLY_COOKBOOK_TOOLS)
|
||||
async def test_admin_agent_can_dispatch_readonly_cookbook_tools(monkeypatch, tool_name):
|
||||
from src import tool_implementations
|
||||
from src.tool_execution import NO_TOOL_SECURITY_CONTEXT, execute_tool_block
|
||||
|
||||
_install_core_auth_stub(monkeypatch, is_admin=True)
|
||||
handler = AsyncMock(return_value={"output": "Cookbook result", "exit_code": 0})
|
||||
monkeypatch.setattr(tool_implementations, f"do_{tool_name}", handler)
|
||||
desc, result = await execute_tool_block(
|
||||
SimpleNamespace(tool_type=tool_name, content="{}"),
|
||||
owner="admin-user",
|
||||
security_context=NO_TOOL_SECURITY_CONTEXT,
|
||||
request_authority=RequestAuthority(
|
||||
"cookbook-test", "admin-user", "", "", (OperationGrant(tool_name),)
|
||||
),
|
||||
)
|
||||
assert desc == tool_name
|
||||
assert result["exit_code"] == 0
|
||||
handler.assert_awaited_once_with("{}", owner="admin-user")
|
||||
@@ -0,0 +1,63 @@
|
||||
"""Execute the publication workflow's image-name setup without contacting GHCR."""
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
|
||||
|
||||
WORKFLOW = (
|
||||
Path(__file__).resolve().parents[1]
|
||||
/ ".github"
|
||||
/ "workflows"
|
||||
/ "docker-publish.yml"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.skipif(shutil.which("bash") is None, reason="Docker publication requires Bash")
|
||||
@pytest.mark.parametrize("job", ["build", "merge"])
|
||||
@pytest.mark.parametrize(
|
||||
("repository", "expected"),
|
||||
[
|
||||
("RaresKeY/odysseus-dev", "rareskey/odysseus-dev"),
|
||||
("ExampleOwner/Example-Repo", "exampleowner/example-repo"),
|
||||
("odysseus-dev/odysseus", "odysseus-dev/odysseus"),
|
||||
],
|
||||
)
|
||||
def test_image_name_is_lowercase_before_docker_steps(tmp_path, job, repository, expected):
|
||||
workflow = yaml.safe_load(WORKFLOW.read_text(encoding="utf-8"))
|
||||
image_name = workflow["env"].get("IMAGE_NAME", "").replace(
|
||||
"${{ github.repository }}", repository
|
||||
)
|
||||
github_env = tmp_path / "github-env"
|
||||
env = {
|
||||
"PATH": os.environ["PATH"],
|
||||
"GITHUB_REPOSITORY": repository,
|
||||
"GITHUB_ENV": str(github_env),
|
||||
"GITHUB_OUTPUT": str(tmp_path / "github-output"),
|
||||
"GITHUB_SHA": "a" * 40,
|
||||
}
|
||||
|
||||
# Run the real setup shell before the job starts using Docker actions.
|
||||
# Checkout, login, builds and registry publication are deliberately excluded.
|
||||
for step in workflow["jobs"][job]["steps"]:
|
||||
if step.get("uses", "").startswith("docker/"):
|
||||
break
|
||||
if "run" not in step:
|
||||
continue
|
||||
subprocess.run(
|
||||
["bash", "--noprofile", "--norc", "-e", "-o", "pipefail", "-c", step["run"]],
|
||||
cwd=WORKFLOW.parents[2],
|
||||
env=env,
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if github_env.exists():
|
||||
env.update(line.split("=", 1) for line in github_env.read_text().splitlines())
|
||||
image_name = env.get("IMAGE_NAME", image_name)
|
||||
|
||||
assert image_name == expected
|
||||
@@ -0,0 +1,515 @@
|
||||
"""User-supplied IMAP/SMTP hosts must pass the outbound host guard.
|
||||
|
||||
Regression: POST /api/email/accounts/test opened raw ``imaplib`` / ``smtplib``
|
||||
connections to whatever host:port the caller supplied, and the account save
|
||||
routes persisted arbitrary hosts for the background pollers to contact. Both
|
||||
are reachable by any authenticated user (``require_user`` / ``require_owner``,
|
||||
no admin gate), so a non-admin could port-scan loopback, the LAN and the cloud
|
||||
metadata range through the server and read the first 200 chars of the banner
|
||||
back out of the error message. THREAT_MODEL.md lists email as admin-only.
|
||||
|
||||
Policy (see routes/email_routes.py:_check_mail_host): link-local (metadata),
|
||||
multicast, reserved and unspecified targets are always rejected; loopback and
|
||||
private ranges are rejected for non-admin owners or when
|
||||
EMAIL_BLOCK_PRIVATE_IPS=true, and stay allowed for admins (local-first: a LAN
|
||||
Dovecot is a normal setup). Unresolved names are rejected before persistence;
|
||||
live connections also validate and pin their own resolved addresses.
|
||||
"""
|
||||
|
||||
import socket
|
||||
import unittest.mock as mock
|
||||
|
||||
import pytest
|
||||
|
||||
from src.url_safety import check_outbound_host, connect_outbound_tcp, OutboundAddressBlocked
|
||||
|
||||
|
||||
# ── helper policy table ──────────────────────────────────────────────────────
|
||||
|
||||
@pytest.mark.parametrize("host", [
|
||||
"169.254.169.254",
|
||||
"[::ffff:169.254.169.254]",
|
||||
"::ffff:169.254.169.254",
|
||||
"fe80::1",
|
||||
"0.0.0.0",
|
||||
"224.0.0.1",
|
||||
"100.100.100.200",
|
||||
"[100.100.100.200]",
|
||||
])
|
||||
def test_always_blocked_addresses(host):
|
||||
ok, reason = check_outbound_host(host)
|
||||
assert ok is False, (host, reason)
|
||||
ok, reason = check_outbound_host(host, block_private=False)
|
||||
assert ok is False
|
||||
|
||||
|
||||
@pytest.mark.parametrize("host", [
|
||||
"127.0.0.1",
|
||||
"::1",
|
||||
"[::1]",
|
||||
"10.0.0.5",
|
||||
"192.168.1.10",
|
||||
"100.64.1.1",
|
||||
"localhost",
|
||||
"LOCALHOST.",
|
||||
"mail.localhost",
|
||||
"ip6-localhost",
|
||||
])
|
||||
def test_private_and_loopback_gated_by_block_private(host):
|
||||
ok, _ = check_outbound_host(host, block_private=True)
|
||||
assert ok is False, host
|
||||
ok, _ = check_outbound_host(host, block_private=False)
|
||||
assert ok is True, host
|
||||
|
||||
|
||||
@pytest.mark.parametrize("host", ["host:993", "http://mail.example.com", "user@mail.example.com", "mail.example.com/", "a b", "", " "])
|
||||
def test_non_bare_hosts_rejected(host):
|
||||
ok, _ = check_outbound_host(host, resolver=lambda h: ["93.184.216.34"])
|
||||
assert ok is False, host
|
||||
|
||||
|
||||
def test_nonstring_rejected():
|
||||
assert check_outbound_host(None)[0] is False # type: ignore[arg-type]
|
||||
assert check_outbound_host(123)[0] is False # type: ignore[arg-type]
|
||||
|
||||
|
||||
def test_resolved_name_is_classified():
|
||||
ok, reason = check_outbound_host("metadata.google.internal", resolver=lambda h: ["169.254.169.254"])
|
||||
assert ok is False and "link-local" in reason
|
||||
ok, _ = check_outbound_host("mail.corp", block_private=True, resolver=lambda h: ["10.1.2.3"])
|
||||
assert ok is False
|
||||
ok, _ = check_outbound_host("mail.corp", block_private=False, resolver=lambda h: ["10.1.2.3"])
|
||||
assert ok is True
|
||||
ok, _ = check_outbound_host("imap.example.org", block_private=True, resolver=lambda h: ["93.184.216.34"])
|
||||
assert ok is True
|
||||
|
||||
|
||||
def test_unresolved_policy():
|
||||
def _nx(h):
|
||||
raise OSError("Name or service not known")
|
||||
assert check_outbound_host("nope.invalid", resolver=_nx)[0] is False
|
||||
assert check_outbound_host("nope.invalid", resolver=lambda h: [])[0] is False
|
||||
|
||||
|
||||
# ── route wiring ─────────────────────────────────────────────────────────────
|
||||
|
||||
def _route(router, path, method):
|
||||
for r in router.routes:
|
||||
if r.path == path and method in getattr(r, "methods", set()):
|
||||
return r.endpoint
|
||||
raise AssertionError(f"route not found: {method} {path}")
|
||||
|
||||
|
||||
def _make_orm_db():
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from core.database import Base
|
||||
engine = create_engine("sqlite:///:memory:", connect_args={"check_same_thread": False})
|
||||
Base.metadata.create_all(engine)
|
||||
Factory = sessionmaker(bind=engine)
|
||||
return Factory
|
||||
|
||||
|
||||
def _set_admin(monkeypatch, is_admin: bool):
|
||||
monkeypatch.setattr("src.tool_security.owner_is_admin_or_single_user", lambda owner: is_admin)
|
||||
monkeypatch.delenv("EMAIL_BLOCK_PRIVATE_IPS", raising=False)
|
||||
monkeypatch.delenv("EMAIL_ALLOW_PRIVATE_IPS", raising=False)
|
||||
|
||||
|
||||
class _Req:
|
||||
def __init__(self, body):
|
||||
self._body = body
|
||||
|
||||
async def json(self):
|
||||
return self._body
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_test_connection_rejects_loopback_for_non_admin_before_any_socket(monkeypatch):
|
||||
import routes.email_routes as email_routes
|
||||
_set_admin(monkeypatch, False)
|
||||
router = email_routes.setup_email_routes()
|
||||
test_conn = _route(router, "/api/email/accounts/test", "POST")
|
||||
|
||||
body = {
|
||||
"imap_host": "127.0.0.1", "imap_port": 11434, "imap_user": "x", "imap_password": "x",
|
||||
"imap_starttls": False,
|
||||
"smtp_host": "169.254.169.254", "smtp_port": 80, "smtp_security": "none",
|
||||
}
|
||||
with mock.patch("socket.socket") as create_socket:
|
||||
result = await test_conn(req=_Req(body), owner="regular-user")
|
||||
|
||||
assert result["ok"] is False
|
||||
assert ("Rejected IMAP host" in result["imap"]["error"] or "server address is not allowed" in result["imap"]["error"])
|
||||
assert ("Rejected SMTP host" in result["smtp"]["error"] or "server address is not allowed" in result["smtp"]["error"])
|
||||
create_socket.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_test_connection_metadata_ip_rejected_even_for_admin(monkeypatch):
|
||||
import routes.email_routes as email_routes
|
||||
_set_admin(monkeypatch, True)
|
||||
router = email_routes.setup_email_routes()
|
||||
test_conn = _route(router, "/api/email/accounts/test", "POST")
|
||||
|
||||
body = {"imap_host": "169.254.169.254", "imap_port": 80, "imap_user": "x", "imap_password": "x", "imap_starttls": True}
|
||||
with mock.patch("socket.socket") as create_socket:
|
||||
result = await test_conn(req=_Req(body), owner="admin")
|
||||
assert result["ok"] is False
|
||||
assert ("link-local" in result["imap"]["error"] or "server address is not allowed" in result["imap"]["error"])
|
||||
create_socket.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_test_connection_admin_may_use_loopback(monkeypatch):
|
||||
"""Local-first: an admin's Dovecot on localhost keeps working."""
|
||||
import routes.email_routes as email_routes
|
||||
_set_admin(monkeypatch, True)
|
||||
router = email_routes.setup_email_routes()
|
||||
test_conn = _route(router, "/api/email/accounts/test", "POST")
|
||||
|
||||
body = {"imap_host": "127.0.0.1", "imap_port": 31143, "imap_user": "x", "imap_password": "x", "imap_starttls": False}
|
||||
with mock.patch("routes.email_routes._open_imap_connection", return_value=mock.MagicMock()) as imap_open:
|
||||
result = await test_conn(req=_Req(body), owner="admin")
|
||||
assert result["imap"]["ok"] is True
|
||||
imap_open.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_account_rejects_metadata_host_before_db(monkeypatch):
|
||||
import routes.email_routes as email_routes
|
||||
_set_admin(monkeypatch, True)
|
||||
router = email_routes.setup_email_routes()
|
||||
create = _route(router, "/api/email/accounts", "POST")
|
||||
|
||||
def _no_db():
|
||||
raise AssertionError("DB must not be touched for a rejected host")
|
||||
|
||||
with mock.patch("core.database.SessionLocal", _no_db):
|
||||
result = await create(
|
||||
{"name": "Backup", "imap_host": "169.254.169.254", "imap_user": "u", "imap_password": "p"},
|
||||
owner="admin",
|
||||
)
|
||||
assert result["ok"] is False
|
||||
assert "Rejected IMAP host" in result["error"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_account_rejects_private_host_for_non_admin(monkeypatch):
|
||||
import routes.email_routes as email_routes
|
||||
_set_admin(monkeypatch, False)
|
||||
router = email_routes.setup_email_routes()
|
||||
create = _route(router, "/api/email/accounts", "POST")
|
||||
with mock.patch("core.database.SessionLocal", _make_orm_db()):
|
||||
result = await create(
|
||||
{"name": "Scan", "imap_host": "93.184.216.34", "smtp_host": "10.0.0.5", "imap_user": "u", "imap_password": "p"},
|
||||
owner="regular-user",
|
||||
)
|
||||
assert result["ok"] is False
|
||||
assert "Rejected SMTP host" in result["error"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_account_admin_private_host_allowed_unless_locked_down(monkeypatch):
|
||||
import routes.email_routes as email_routes
|
||||
_set_admin(monkeypatch, True)
|
||||
router = email_routes.setup_email_routes()
|
||||
create = _route(router, "/api/email/accounts", "POST")
|
||||
payload = {"name": "LAN", "imap_host": "192.168.1.10", "imap_user": "u", "imap_password": "p"}
|
||||
|
||||
with mock.patch("core.database.SessionLocal", _make_orm_db()):
|
||||
result = await create(dict(payload), owner="admin")
|
||||
assert result["ok"] is True
|
||||
|
||||
monkeypatch.setenv("EMAIL_BLOCK_PRIVATE_IPS", "true")
|
||||
with mock.patch("core.database.SessionLocal", _make_orm_db()):
|
||||
result = await create(dict(payload), owner="admin")
|
||||
assert result["ok"] is False
|
||||
assert "Rejected IMAP host" in result["error"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_account_rejects_bad_host_and_keeps_row(monkeypatch):
|
||||
import routes.email_routes as email_routes
|
||||
from core.database import EmailAccount
|
||||
_set_admin(monkeypatch, False)
|
||||
Factory = _make_orm_db()
|
||||
db = Factory()
|
||||
db.add(EmailAccount(id="acct-1", owner="regular-user", name="Mine", imap_host="imap.gmail.com", imap_port=993))
|
||||
db.commit()
|
||||
db.close()
|
||||
|
||||
router = email_routes.setup_email_routes()
|
||||
update = _route(router, "/api/email/accounts/{account_id}", "PUT")
|
||||
with mock.patch("core.database.SessionLocal", Factory), \
|
||||
mock.patch("routes.email_routes._assert_owns_account", lambda account_id, owner: None):
|
||||
result = await update("acct-1", {"imap_host": "127.0.0.1", "imap_port": 8000}, owner="regular-user")
|
||||
assert result["ok"] is False
|
||||
assert "Rejected IMAP host" in result["error"]
|
||||
|
||||
db = Factory()
|
||||
try:
|
||||
assert db.get(EmailAccount, "acct-1").imap_host == "imap.gmail.com"
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_config_rejects_bad_host(monkeypatch):
|
||||
import routes.email_routes as email_routes
|
||||
_set_admin(monkeypatch, False)
|
||||
router = email_routes.setup_email_routes()
|
||||
update_cfg = _route(router, "/api/email/config", "PUT")
|
||||
|
||||
def _no_db():
|
||||
raise AssertionError("DB must not be touched for a rejected host")
|
||||
|
||||
with mock.patch("core.database.SessionLocal", _no_db), \
|
||||
mock.patch("routes.email_routes._load_settings", _no_db):
|
||||
result = await update_cfg({"smtp_host": "10.0.0.1", "smtp_port": 6379}, account_id=None, owner="regular-user")
|
||||
assert result["ok"] is False
|
||||
assert "Rejected SMTP host" in result["error"]
|
||||
|
||||
|
||||
# ── adversarial runtime & connection-time tests ─────────────────────────────
|
||||
|
||||
def test_connect_outbound_tcp_blocks_dns_rebinding():
|
||||
with mock.patch("socket.getaddrinfo", return_value=[(socket.AF_INET, socket.SOCK_STREAM, 6, "", ("169.254.169.254", 993))]):
|
||||
with pytest.raises(OutboundAddressBlocked) as exc_info:
|
||||
connect_outbound_tcp("rebind.example.org", 993, block_private=False)
|
||||
assert "link-local" in str(exc_info.value)
|
||||
|
||||
|
||||
def test_connect_outbound_tcp_multiple_ips_fails_closed():
|
||||
mixed_addrs = [
|
||||
(socket.AF_INET, socket.SOCK_STREAM, 6, "", ("93.184.216.34", 993)),
|
||||
(socket.AF_INET, socket.SOCK_STREAM, 6, "", ("10.0.0.1", 993)),
|
||||
]
|
||||
with mock.patch("socket.getaddrinfo", return_value=mixed_addrs):
|
||||
with pytest.raises(OutboundAddressBlocked) as exc_info:
|
||||
connect_outbound_tcp("dual.example.org", 993, block_private=True)
|
||||
assert "private/shared/loopback" in str(exc_info.value)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_test_connection_endpoint_dns_rebinding_blocked(monkeypatch):
|
||||
"""If DNS changes between save-check and connection, connect-time validation stops it."""
|
||||
import routes.email_routes as email_routes
|
||||
_set_admin(monkeypatch, False)
|
||||
router = email_routes.setup_email_routes()
|
||||
test_conn = _route(router, "/api/email/accounts/test", "POST")
|
||||
|
||||
body = {
|
||||
"imap_host": "rebind.example.org", "imap_port": 993, "imap_user": "u", "imap_password": "p",
|
||||
"imap_starttls": False,
|
||||
}
|
||||
rebinding_addrs = [(socket.AF_INET, socket.SOCK_STREAM, 6, "", ("127.0.0.1", 993))]
|
||||
with mock.patch("socket.getaddrinfo", return_value=rebinding_addrs), \
|
||||
mock.patch("socket.socket") as create_socket:
|
||||
result = await test_conn(req=_Req(body), owner="regular-user")
|
||||
|
||||
assert result["ok"] is False
|
||||
assert ("Rejected IMAP host" in result["imap"]["error"] or "server address is not allowed" in result["imap"]["error"])
|
||||
create_socket.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_runtime_imap_connect_blocks_preexisting_db_row(monkeypatch):
|
||||
"""A database row with private host (preexisting, restored, or DB-injected) fails closed at runtime."""
|
||||
import routes.email_helpers as email_helpers
|
||||
from core.database import EmailAccount
|
||||
_set_admin(monkeypatch, False)
|
||||
Factory = _make_orm_db()
|
||||
db = Factory()
|
||||
db.add(EmailAccount(id="acct-malicious", owner="regular-user", name="Evil", imap_host="127.0.0.1", imap_port=993, imap_user="u", imap_password="p"))
|
||||
db.commit()
|
||||
db.close()
|
||||
|
||||
with mock.patch("core.database.SessionLocal", Factory):
|
||||
with pytest.raises(OutboundAddressBlocked) as exc_info:
|
||||
email_helpers._imap_connect("acct-malicious", owner="regular-user")
|
||||
assert "private/shared/loopback" in str(exc_info.value)
|
||||
|
||||
|
||||
def test_runtime_send_smtp_message_blocks_metadata_host(monkeypatch):
|
||||
"""_send_smtp_message fails closed on cloud metadata destination."""
|
||||
import routes.email_helpers as email_helpers
|
||||
_set_admin(monkeypatch, True)
|
||||
cfg = {
|
||||
"smtp_host": "169.254.169.254",
|
||||
"smtp_port": 25,
|
||||
"owner": "admin",
|
||||
}
|
||||
with pytest.raises(OutboundAddressBlocked) as exc_info:
|
||||
email_helpers._send_smtp_message(cfg, "from@example.com", ["to@example.com"], b"data")
|
||||
assert "link-local" in str(exc_info.value)
|
||||
|
||||
|
||||
def test_ipv6_loopback_allowed_for_admin_blocked_for_non_admin():
|
||||
mock_sock = mock.MagicMock()
|
||||
with mock.patch("socket.socket", return_value=mock_sock):
|
||||
sock = connect_outbound_tcp("::1", 993, block_private=False)
|
||||
assert sock is mock_sock
|
||||
mock_sock.connect.assert_called_with(("::1", 993, 0, 0))
|
||||
|
||||
with pytest.raises(OutboundAddressBlocked) as exc_info:
|
||||
connect_outbound_tcp("::1", 993, block_private=True)
|
||||
assert "private/shared/loopback" in str(exc_info.value)
|
||||
|
||||
|
||||
def test_email_block_private_ips_env_overrides_admin(monkeypatch):
|
||||
import routes.email_helpers as email_helpers
|
||||
_set_admin(monkeypatch, True)
|
||||
monkeypatch.setenv("EMAIL_BLOCK_PRIVATE_IPS", "true")
|
||||
cfg = {
|
||||
"smtp_host": "127.0.0.1",
|
||||
"smtp_port": 25,
|
||||
"owner": "admin",
|
||||
}
|
||||
with pytest.raises(OutboundAddressBlocked) as exc_info:
|
||||
email_helpers._send_smtp_message(cfg, "from@example.com", ["to@example.com"], b"data")
|
||||
assert "private/shared/loopback" in str(exc_info.value)
|
||||
|
||||
|
||||
def test_tls_sni_preserved_during_safe_connection():
|
||||
import routes.email_helpers as email_helpers
|
||||
mock_sock = mock.MagicMock()
|
||||
mock_ssl_sock = mock.MagicMock()
|
||||
mock_ctx = mock.MagicMock()
|
||||
mock_ctx.wrap_socket.return_value = mock_ssl_sock
|
||||
|
||||
with mock.patch("routes.email_helpers.connect_outbound_tcp", return_value=mock_sock):
|
||||
imap = email_helpers._PolicyIMAP4_SSL.__new__(email_helpers._PolicyIMAP4_SSL)
|
||||
imap._block_private = False
|
||||
imap.host = "imap.secure.example.com"
|
||||
imap.port = 993
|
||||
imap.ssl_context = mock_ctx
|
||||
res = imap._create_socket(10)
|
||||
mock_ctx.wrap_socket.assert_called_with(mock_sock, server_hostname="imap.secure.example.com")
|
||||
assert res is mock_ssl_sock
|
||||
|
||||
mock_smtp_sock = mock.MagicMock()
|
||||
mock_ctx.wrap_socket.return_value = mock_smtp_sock
|
||||
with mock.patch("routes.email_helpers.connect_outbound_tcp", return_value=mock_sock):
|
||||
smtp = email_helpers._PolicySMTP_SSL.__new__(email_helpers._PolicySMTP_SSL)
|
||||
smtp._block_private = False
|
||||
smtp._host = "smtp.secure.example.com"
|
||||
smtp.context = mock_ctx
|
||||
smtp.source_address = None
|
||||
res = smtp._get_socket("smtp.secure.example.com", 465, 10)
|
||||
mock_ctx.wrap_socket.assert_called_with(mock_sock, server_hostname="smtp.secure.example.com")
|
||||
assert res is mock_smtp_sock
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("route_name", ["create", "update", "config"])
|
||||
@pytest.mark.parametrize("field", ["imap_host", "smtp_host"])
|
||||
@pytest.mark.parametrize("is_admin", [False, True])
|
||||
@pytest.mark.parametrize("answer", ["error", "empty", "garbage", "none"])
|
||||
async def test_save_rejects_unresolved_hosts_before_persistence(monkeypatch, route_name, field, is_admin, answer):
|
||||
import routes.email_routes as routes
|
||||
import src.url_safety as safety
|
||||
|
||||
_set_admin(monkeypatch, is_admin)
|
||||
def resolve(host):
|
||||
if answer == "error":
|
||||
raise RuntimeError("resolver-internal-detail")
|
||||
return {"empty": [], "garbage": ["not-an-ip"], "none": None}[answer]
|
||||
|
||||
def no_persistence(*args, **kwargs):
|
||||
raise AssertionError("Rejected mail host reached persistence")
|
||||
|
||||
monkeypatch.setattr(safety, "_default_resolver", resolve)
|
||||
monkeypatch.setattr("core.database.SessionLocal", no_persistence)
|
||||
monkeypatch.setattr(routes, "_load_settings", no_persistence)
|
||||
monkeypatch.setattr(routes, "_save_settings", no_persistence)
|
||||
monkeypatch.setattr(routes, "_assert_owns_account", lambda *a: None)
|
||||
router = routes.setup_email_routes()
|
||||
payload = {"name": "Pending DNS", field: "pending.example.invalid"}
|
||||
owner = "admin" if is_admin else "regular-user"
|
||||
if route_name == "create":
|
||||
result = await _route(router, "/api/email/accounts", "POST")(payload, owner=owner)
|
||||
elif route_name == "update":
|
||||
result = await _route(router, "/api/email/accounts/{account_id}", "PUT")("acct", payload, owner=owner)
|
||||
else:
|
||||
result = await _route(router, "/api/email/config", "PUT")(payload, account_id=None, owner=owner)
|
||||
assert result["ok"] is False
|
||||
assert "does not resolve" in result["error"]
|
||||
assert "resolver-internal-detail" not in result["error"]
|
||||
assert "not-an-ip" not in result["error"]
|
||||
|
||||
|
||||
def test_connect_pins_the_first_resolution_without_rebinding_lookup():
|
||||
public = [(socket.AF_INET, socket.SOCK_STREAM, 6, "", ("93.184.216.34", 993))]
|
||||
metadata = [(socket.AF_INET, socket.SOCK_STREAM, 6, "", ("169.254.169.254", 993))]
|
||||
resolver = mock.Mock(side_effect=[public, metadata])
|
||||
with mock.patch("socket.socket") as create_socket:
|
||||
sock = connect_outbound_tcp("rebind.example.org", 993, resolver=resolver, block_private=True)
|
||||
resolver.assert_called_once_with("rebind.example.org", 993, 0, socket.SOCK_STREAM)
|
||||
sock.connect.assert_called_once_with(("93.184.216.34", 993))
|
||||
|
||||
|
||||
def test_resolver_type_error_propagates_without_alternate_signature_retry():
|
||||
resolver = mock.Mock(side_effect=TypeError("resolver-internal-bug"))
|
||||
with mock.patch("socket.socket") as create_socket:
|
||||
with pytest.raises(TypeError, match="resolver-internal-bug"):
|
||||
connect_outbound_tcp("mail.example.org", 993, resolver=resolver)
|
||||
resolver.assert_called_once_with("mail.example.org", 993, 0, socket.SOCK_STREAM)
|
||||
create_socket.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("port", [None, 0, -1, 65536, True, 25.5, "not-a-port"])
|
||||
def test_invalid_tcp_port_is_rejected_before_resolution(port):
|
||||
resolver = mock.Mock()
|
||||
with pytest.raises(ValueError):
|
||||
connect_outbound_tcp("mail.example.org", port, resolver=resolver)
|
||||
resolver.assert_not_called()
|
||||
|
||||
|
||||
def test_empty_resolver_iterator_fails_closed():
|
||||
assert check_outbound_host("empty.example.org", resolver=lambda host: iter(()))[0] is False
|
||||
|
||||
|
||||
def test_shared_space_metadata_endpoint_is_identified_correctly():
|
||||
ok, reason = check_outbound_host("100.100.100.200", block_private=False)
|
||||
assert ok is False
|
||||
assert "metadata endpoint blocked" in reason
|
||||
assert "link-local" not in reason
|
||||
|
||||
|
||||
def test_importing_mail_helpers_does_not_replace_stdlib_clients():
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
subprocess.run([sys.executable, "-c", """
|
||||
import imaplib, smtplib
|
||||
original = (imaplib.IMAP4, imaplib.IMAP4_SSL, smtplib.SMTP, smtplib.SMTP_SSL)
|
||||
import routes.email.email_helpers
|
||||
assert original == (imaplib.IMAP4, imaplib.IMAP4_SSL, smtplib.SMTP, smtplib.SMTP_SSL)
|
||||
"""], check=True, capture_output=True, text=True)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("route_name", ["create", "update", "config"])
|
||||
async def test_save_host_resolution_runs_off_the_event_loop(monkeypatch, route_name):
|
||||
import threading
|
||||
import routes.email_routes as routes
|
||||
|
||||
event_loop_thread = threading.get_ident()
|
||||
def guard(*args):
|
||||
assert threading.get_ident() != event_loop_thread
|
||||
return "Host rejected"
|
||||
|
||||
monkeypatch.setattr(routes, "_check_mail_host", guard)
|
||||
monkeypatch.setattr(routes, "_assert_owns_account", lambda *args: None)
|
||||
router = routes.setup_email_routes()
|
||||
payload = {"name": "Pending DNS", "imap_host": "pending.example.invalid"}
|
||||
if route_name == "create":
|
||||
result = await _route(router, "/api/email/accounts", "POST")(payload, owner="admin")
|
||||
elif route_name == "update":
|
||||
result = await _route(router, "/api/email/accounts/{account_id}", "PUT")("acct", payload, owner="admin")
|
||||
else:
|
||||
result = await _route(router, "/api/email/config", "PUT")(payload, account_id=None, owner="admin")
|
||||
assert result == {"ok": False, "error": "Host rejected"}
|
||||
|
||||
|
||||
@@ -0,0 +1,509 @@
|
||||
"""Bulk Delete must retain messages that could not be moved to Trash."""
|
||||
|
||||
from contextlib import contextmanager
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import Mock
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
|
||||
class Mailbox:
|
||||
def __init__(self):
|
||||
self.inbox = {"101", "102", "103"}
|
||||
self.trash = set()
|
||||
self.deleted = set()
|
||||
self.calls = []
|
||||
self.move_status = "NO"
|
||||
self.copy_status = {}
|
||||
self.store_status = {}
|
||||
self.expunge_status = "OK"
|
||||
self.uid_expunge_status = None
|
||||
self.select_status = "OK"
|
||||
|
||||
def select(self, folder, readonly=False):
|
||||
return self.select_status, [b"3"]
|
||||
|
||||
def uid(self, command, raw_uid, *args):
|
||||
uid = raw_uid.decode() if isinstance(raw_uid, bytes) else (str(raw_uid) if raw_uid is not None else "")
|
||||
self.calls.append((command, uid))
|
||||
if command == "FETCH":
|
||||
if uid not in self.inbox:
|
||||
return "OK", []
|
||||
if args and "(FLAGS)" in args:
|
||||
flags = rb"(\Deleted)" if uid in self.deleted else rb"()"
|
||||
return "OK", [(f"1 (UID {uid} FLAGS ".encode() + flags + b")")]
|
||||
return "OK", [f"1 (UID {uid})".encode()]
|
||||
if command == "SEARCH":
|
||||
if args and "DELETED" in args:
|
||||
return "OK", [b" ".join(u.encode() for u in sorted(self.deleted))]
|
||||
searched_uid = args[-1].split()[-1] if args else ""
|
||||
return "OK", [searched_uid.encode() if searched_uid in self.inbox else b""]
|
||||
if command == "MOVE":
|
||||
status = self.move_status.get(uid, "NO") if isinstance(self.move_status, dict) else self.move_status
|
||||
if isinstance(status, Exception):
|
||||
raise status
|
||||
if status == "OK":
|
||||
self.trash.add(uid)
|
||||
self.inbox.remove(uid)
|
||||
return status, []
|
||||
if command == "COPY":
|
||||
status = self.copy_status.get(uid, "OK")
|
||||
if isinstance(status, Exception):
|
||||
raise status
|
||||
if status == "OK":
|
||||
self.trash.add(uid)
|
||||
return status, []
|
||||
if command == "STORE":
|
||||
status = self.store_status.get(uid, "OK")
|
||||
if isinstance(status, Exception):
|
||||
raise status
|
||||
if status == "OK":
|
||||
if args and args[0] == "-FLAGS":
|
||||
self.deleted.discard(uid)
|
||||
else:
|
||||
self.deleted.add(uid)
|
||||
return status, []
|
||||
if command == "EXPUNGE":
|
||||
status = self.uid_expunge_status if self.uid_expunge_status is not None else self.expunge_status
|
||||
if isinstance(status, Exception):
|
||||
raise status
|
||||
if status == "OK":
|
||||
expunged = {u for u in uid.split(",") if u in self.deleted}
|
||||
self.inbox.difference_update(expunged)
|
||||
self.deleted.difference_update(expunged)
|
||||
return status, []
|
||||
raise AssertionError(f"Unexpected IMAP command: {command}")
|
||||
|
||||
def expunge(self):
|
||||
self.calls.append(("EXPUNGE", None))
|
||||
if isinstance(self.expunge_status, Exception):
|
||||
raise self.expunge_status
|
||||
if self.expunge_status == "OK":
|
||||
self.inbox.difference_update(self.deleted)
|
||||
self.deleted.clear()
|
||||
return self.expunge_status, []
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def bulk_delete(monkeypatch):
|
||||
from routes.email import email_routes as routes
|
||||
|
||||
mailbox = Mailbox()
|
||||
index_delete = Mock()
|
||||
invalidate_cache = Mock()
|
||||
|
||||
@contextmanager
|
||||
def imap(account_id=None, owner=""):
|
||||
assert owner == "alice"
|
||||
yield mailbox
|
||||
|
||||
monkeypatch.setattr(routes, "_start_poller", lambda: None)
|
||||
monkeypatch.setattr(routes, "_POOL_HOOKS", {})
|
||||
monkeypatch.setattr(routes, "_imap", imap)
|
||||
monkeypatch.setattr(routes, "_resolve_mail_folder", lambda *args: "Trash")
|
||||
monkeypatch.setattr(routes, "_list_imap_folders", lambda conn: ({}, ["INBOX", "Trash"]))
|
||||
monkeypatch.setattr(routes, "_email_index_delete", index_delete)
|
||||
monkeypatch.setitem(routes._CACHE_HOOKS, "invalidate_list", invalidate_cache)
|
||||
app = FastAPI()
|
||||
app.include_router(routes.setup_email_routes())
|
||||
app.dependency_overrides[routes.require_owner] = lambda: "alice"
|
||||
|
||||
with TestClient(app) as client:
|
||||
def delete(uids):
|
||||
response = client.post("/api/email/delete-bulk", json={"uids": uids})
|
||||
assert response.status_code == 200
|
||||
return response.json()
|
||||
|
||||
yield SimpleNamespace(
|
||||
mailbox=mailbox,
|
||||
delete=delete,
|
||||
index_delete=index_delete,
|
||||
invalidate_cache=invalidate_cache,
|
||||
routes=routes,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("copy_status", ["NO", "BAD"])
|
||||
def test_failed_trash_copy_preserves_original(bulk_delete, copy_status):
|
||||
bulk_delete.mailbox.copy_status["101"] = copy_status
|
||||
|
||||
result = bulk_delete.delete(["101"])
|
||||
|
||||
assert "101" in bulk_delete.mailbox.inbox
|
||||
assert bulk_delete.mailbox.deleted == set()
|
||||
assert result["deleted_uids"] == []
|
||||
assert result["failed_uids"] == ["101"]
|
||||
assert ("STORE", "101") not in bulk_delete.mailbox.calls
|
||||
assert ("EXPUNGE", None) not in bulk_delete.mailbox.calls
|
||||
bulk_delete.index_delete.assert_not_called()
|
||||
|
||||
|
||||
def test_mixed_batch_only_deletes_messages_copied_to_trash(bulk_delete):
|
||||
bulk_delete.mailbox.copy_status["102"] = "NO"
|
||||
|
||||
result = bulk_delete.delete(["101", "102", "103"])
|
||||
|
||||
assert bulk_delete.mailbox.inbox == {"102"}
|
||||
assert bulk_delete.mailbox.trash == {"101", "103"}
|
||||
assert result["deleted_uids"] == ["101", "103"]
|
||||
assert result["failed_uids"] == ["102"]
|
||||
assert [call.args[-1] for call in bulk_delete.index_delete.call_args_list] == ["101", "103"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("expunge_status", ["NO", "BAD", RuntimeError("Disconnected")])
|
||||
def test_failed_expunge_keeps_index_and_reports_failure(bulk_delete, expunge_status):
|
||||
bulk_delete.mailbox.expunge_status = expunge_status
|
||||
|
||||
result = bulk_delete.delete(["101", "102"])
|
||||
|
||||
assert bulk_delete.mailbox.inbox == {"101", "102", "103"}
|
||||
assert bulk_delete.mailbox.trash == {"101", "102"}
|
||||
assert result["deleted_uids"] == []
|
||||
assert result["failed_uids"] == ["101", "102"]
|
||||
bulk_delete.index_delete.assert_not_called()
|
||||
|
||||
|
||||
def test_store_failure_retains_original_and_index(bulk_delete):
|
||||
bulk_delete.mailbox.store_status["101"] = "NO"
|
||||
|
||||
result = bulk_delete.delete(["101"])
|
||||
|
||||
assert "101" in bulk_delete.mailbox.inbox
|
||||
assert bulk_delete.mailbox.trash == {"101"}
|
||||
assert result["failed_uids"] == ["101"]
|
||||
assert result["deleted_uids"] == []
|
||||
assert ("EXPUNGE", None) not in bulk_delete.mailbox.calls
|
||||
bulk_delete.index_delete.assert_not_called()
|
||||
|
||||
|
||||
def test_native_move_needs_no_copy_or_expunge(bulk_delete):
|
||||
bulk_delete.mailbox.move_status = "OK"
|
||||
|
||||
result = bulk_delete.delete(["101", "102"])
|
||||
|
||||
assert result["deleted_uids"] == ["101", "102"]
|
||||
assert result["failed_uids"] == []
|
||||
assert bulk_delete.mailbox.inbox == {"103"}
|
||||
assert bulk_delete.mailbox.trash == {"101", "102"}
|
||||
assert not any(command in {"COPY", "STORE", "EXPUNGE"} for command, _ in bulk_delete.mailbox.calls)
|
||||
|
||||
|
||||
def test_copy_fallback_expunge_runs_once_for_the_batch(bulk_delete):
|
||||
result = bulk_delete.delete(["101", "102"])
|
||||
|
||||
assert result["deleted_uids"] == ["101", "102"]
|
||||
assert result["failed_uids"] == []
|
||||
assert bulk_delete.mailbox.inbox == {"103"}
|
||||
assert bulk_delete.mailbox.trash == {"101", "102"}
|
||||
assert sum(1 for command, _ in bulk_delete.mailbox.calls if command == "EXPUNGE") == 1
|
||||
assert bulk_delete.index_delete.call_count == 2
|
||||
|
||||
|
||||
def test_failed_folder_selection_never_changes_messages(bulk_delete):
|
||||
bulk_delete.mailbox.select_status = "NO"
|
||||
|
||||
result = bulk_delete.delete(["101"])
|
||||
|
||||
assert result["success"] is False
|
||||
assert result["failed_uids"] == ["101"]
|
||||
assert bulk_delete.mailbox.calls == []
|
||||
bulk_delete.index_delete.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("expunge_status", ["NO", RuntimeError("Disconnected")])
|
||||
def test_completed_native_move_is_retained_when_fallback_expunge_fails(bulk_delete, expunge_status):
|
||||
bulk_delete.mailbox.move_status = {"101": "OK"}
|
||||
bulk_delete.mailbox.expunge_status = expunge_status
|
||||
|
||||
result = bulk_delete.delete(["101", "102"])
|
||||
|
||||
assert result["deleted_uids"] == ["101"]
|
||||
assert result["failed_uids"] == ["102"]
|
||||
assert bulk_delete.mailbox.inbox == {"102", "103"}
|
||||
assert bulk_delete.mailbox.trash == {"101", "102"}
|
||||
bulk_delete.index_delete.assert_called_once_with("alice", None, "INBOX", "101")
|
||||
|
||||
|
||||
def test_absent_uid_is_reported_failed_without_deleting_another_message(bulk_delete):
|
||||
result = bulk_delete.delete(["999"])
|
||||
|
||||
assert result["deleted_uids"] == []
|
||||
assert result["failed_uids"] == ["999"]
|
||||
assert bulk_delete.mailbox.inbox == {"101", "102", "103"}
|
||||
assert bulk_delete.mailbox.trash == set()
|
||||
assert not any(command in {"MOVE", "COPY", "STORE", "EXPUNGE"} for command, _ in bulk_delete.mailbox.calls)
|
||||
bulk_delete.index_delete.assert_not_called()
|
||||
|
||||
|
||||
def test_duplicate_and_invalid_uids_do_not_repeat_mail_operations(bulk_delete):
|
||||
result = bulk_delete.delete(["101", "101", "", None, "invalid"])
|
||||
|
||||
assert result["deleted_uids"] == ["101"]
|
||||
assert result["failed_uids"] == []
|
||||
assert bulk_delete.mailbox.calls.count(("COPY", "101")) == 1
|
||||
bulk_delete.index_delete.assert_called_once_with("alice", None, "INBOX", "101")
|
||||
|
||||
|
||||
def test_bulk_delete_invalidates_source_and_trash_caches(bulk_delete):
|
||||
result = bulk_delete.delete(["101"])
|
||||
|
||||
assert result["success"] is True
|
||||
assert result["deleted_uids"] == ["101"]
|
||||
bulk_delete.invalidate_cache.assert_any_call(None, "INBOX")
|
||||
bulk_delete.invalidate_cache.assert_any_call(None, "Trash")
|
||||
|
||||
|
||||
def test_partial_failure_with_exception_invalidates_caches(bulk_delete):
|
||||
bulk_delete.mailbox.move_status = {"101": "OK"}
|
||||
bulk_delete.mailbox.uid_expunge_status = RuntimeError("Socket reset during expunge")
|
||||
bulk_delete.mailbox.expunge_status = RuntimeError("Socket reset during expunge")
|
||||
|
||||
result = bulk_delete.delete(["101", "102"])
|
||||
|
||||
assert result["success"] is True
|
||||
assert result["deleted_uids"] == ["101"]
|
||||
assert result["failed_uids"] == ["102"]
|
||||
assert result["error"] == "Mail operation partially failed"
|
||||
bulk_delete.invalidate_cache.assert_any_call(None, "INBOX")
|
||||
bulk_delete.invalidate_cache.assert_any_call(None, "Trash")
|
||||
|
||||
|
||||
def test_total_failure_returns_success_false(bulk_delete):
|
||||
bulk_delete.mailbox.copy_status["101"] = "NO"
|
||||
|
||||
result = bulk_delete.delete(["101"])
|
||||
|
||||
assert result["success"] is False
|
||||
assert result["error"] == "Failed to delete requested emails"
|
||||
assert result["deleted_uids"] == []
|
||||
assert result["failed_uids"] == ["101"]
|
||||
|
||||
|
||||
def test_uid_expunge_preserves_unrelated_deleted_message(bulk_delete):
|
||||
# Unrelated message 999 is already marked \Deleted before bulk delete
|
||||
bulk_delete.mailbox.inbox.add("999")
|
||||
bulk_delete.mailbox.deleted.add("999")
|
||||
|
||||
result = bulk_delete.delete(["101"])
|
||||
|
||||
assert result["success"] is True
|
||||
assert result["deleted_uids"] == ["101"]
|
||||
# 101 was expunged, 999 remains in inbox and remains marked deleted
|
||||
assert "101" not in bulk_delete.mailbox.inbox
|
||||
assert "999" in bulk_delete.mailbox.inbox
|
||||
assert bulk_delete.mailbox.deleted == {"999"}
|
||||
assert ("EXPUNGE", "101") in bulk_delete.mailbox.calls
|
||||
assert ("EXPUNGE", None) not in bulk_delete.mailbox.calls
|
||||
|
||||
|
||||
def test_server_lacking_uidplus_never_calls_mailbox_expunge(bulk_delete):
|
||||
# Server does not support UIDPLUS (UID EXPUNGE returns BAD)
|
||||
# Even if no other deleted messages exist, mailbox-wide EXPUNGE is strictly forbidden.
|
||||
bulk_delete.mailbox.uid_expunge_status = "BAD"
|
||||
assert bulk_delete.mailbox.deleted == set()
|
||||
|
||||
result = bulk_delete.delete(["101"])
|
||||
|
||||
assert result["success"] is False
|
||||
assert result["deleted_uids"] == []
|
||||
assert result["failed_uids"] == ["101"]
|
||||
assert "101" in bulk_delete.mailbox.inbox
|
||||
assert "101" not in bulk_delete.mailbox.deleted
|
||||
assert ("EXPUNGE", None) not in bulk_delete.mailbox.calls
|
||||
bulk_delete.index_delete.assert_not_called()
|
||||
|
||||
|
||||
def test_concurrent_client_marks_unrelated_deleted_message_during_operation(bulk_delete):
|
||||
# Another client marks unrelated UID 999 \Deleted before/during the operation
|
||||
bulk_delete.mailbox.inbox.add("999")
|
||||
bulk_delete.mailbox.deleted.add("999")
|
||||
|
||||
result = bulk_delete.delete(["101"])
|
||||
|
||||
assert result["success"] is True
|
||||
assert result["deleted_uids"] == ["101"]
|
||||
# 101 was expunged, unrelated 999 is NEVER expunged
|
||||
assert "101" not in bulk_delete.mailbox.inbox
|
||||
assert "999" in bulk_delete.mailbox.inbox
|
||||
assert bulk_delete.mailbox.deleted == {"999"}
|
||||
assert ("EXPUNGE", "101") in bulk_delete.mailbox.calls
|
||||
assert ("EXPUNGE", None) not in bulk_delete.mailbox.calls
|
||||
|
||||
|
||||
def test_preexisting_deleted_flag_preserved_when_expunge_fails(bulk_delete):
|
||||
# Message 101 already had \Deleted flag before the bulk operation
|
||||
bulk_delete.mailbox.deleted.add("101")
|
||||
bulk_delete.mailbox.uid_expunge_status = "NO"
|
||||
|
||||
result = bulk_delete.delete(["101"])
|
||||
|
||||
assert result["success"] is False
|
||||
assert result["deleted_uids"] == []
|
||||
assert result["failed_uids"] == ["101"]
|
||||
assert "101" in bulk_delete.mailbox.inbox
|
||||
# Pre-existing \Deleted flag must NOT be removed
|
||||
assert "101" in bulk_delete.mailbox.deleted
|
||||
assert ("EXPUNGE", None) not in bulk_delete.mailbox.calls
|
||||
bulk_delete.index_delete.assert_not_called()
|
||||
|
||||
|
||||
def test_preexisting_deleted_flag_expunged_on_success(bulk_delete):
|
||||
# Message 101 already had \Deleted flag before the bulk operation
|
||||
bulk_delete.mailbox.deleted.add("101")
|
||||
|
||||
result = bulk_delete.delete(["101"])
|
||||
|
||||
assert result["success"] is True
|
||||
assert result["deleted_uids"] == ["101"]
|
||||
assert "101" not in bulk_delete.mailbox.inbox
|
||||
assert "101" not in bulk_delete.mailbox.deleted
|
||||
assert "101" in bulk_delete.mailbox.trash
|
||||
bulk_delete.index_delete.assert_called_once_with("alice", None, "INBOX", "101")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("status", ["NO", "BAD"])
|
||||
def test_uid_expunge_status_failures(bulk_delete, status):
|
||||
bulk_delete.mailbox.uid_expunge_status = status
|
||||
|
||||
result = bulk_delete.delete(["101", "102"])
|
||||
|
||||
assert result["success"] is False
|
||||
assert result["deleted_uids"] == []
|
||||
assert result["failed_uids"] == ["101", "102"]
|
||||
assert bulk_delete.mailbox.inbox == {"101", "102", "103"}
|
||||
assert ("EXPUNGE", None) not in bulk_delete.mailbox.calls
|
||||
bulk_delete.index_delete.assert_not_called()
|
||||
|
||||
|
||||
def test_uid_expunge_connection_exception_handled_conservatively(bulk_delete):
|
||||
bulk_delete.mailbox.uid_expunge_status = ConnectionResetError("Connection lost during selective expunge")
|
||||
|
||||
result = bulk_delete.delete(["101"])
|
||||
|
||||
assert result["success"] is False
|
||||
assert result["deleted_uids"] == []
|
||||
assert result["failed_uids"] == ["101"]
|
||||
assert "101" in bulk_delete.mailbox.inbox
|
||||
assert ("EXPUNGE", None) not in bulk_delete.mailbox.calls
|
||||
bulk_delete.index_delete.assert_not_called()
|
||||
|
||||
|
||||
def test_disconnect_after_copy_before_store_preserves_message(bulk_delete):
|
||||
# COPY succeeds, but STORE raises a disconnect
|
||||
bulk_delete.mailbox.store_status["101"] = ConnectionResetError("Disconnected")
|
||||
|
||||
result = bulk_delete.delete(["101"])
|
||||
|
||||
assert result["success"] is False
|
||||
assert result["deleted_uids"] == []
|
||||
assert result["failed_uids"] == ["101"]
|
||||
assert "101" in bulk_delete.mailbox.inbox
|
||||
assert "101" in bulk_delete.mailbox.trash
|
||||
assert bulk_delete.mailbox.deleted == set()
|
||||
assert ("EXPUNGE", None) not in bulk_delete.mailbox.calls
|
||||
bulk_delete.index_delete.assert_not_called()
|
||||
|
||||
|
||||
def test_disconnect_after_store_preserves_messages_and_index(bulk_delete):
|
||||
# COPY and STORE succeed, but UID EXPUNGE disconnects
|
||||
bulk_delete.mailbox.uid_expunge_status = ConnectionResetError("Disconnected during expunge")
|
||||
|
||||
result = bulk_delete.delete(["101"])
|
||||
|
||||
assert result["success"] is False
|
||||
assert result["deleted_uids"] == []
|
||||
assert result["failed_uids"] == ["101"]
|
||||
assert "101" in bulk_delete.mailbox.inbox
|
||||
assert "101" in bulk_delete.mailbox.trash
|
||||
assert ("EXPUNGE", None) not in bulk_delete.mailbox.calls
|
||||
bulk_delete.index_delete.assert_not_called()
|
||||
|
||||
|
||||
def test_disconnect_with_partial_move_success(bulk_delete):
|
||||
# 101 succeeds via MOVE, 102 falls back to COPY+STORE and UID EXPUNGE disconnects
|
||||
bulk_delete.mailbox.move_status = {"101": "OK", "102": "NO"}
|
||||
bulk_delete.mailbox.uid_expunge_status = ConnectionResetError("Disconnected during expunge")
|
||||
|
||||
result = bulk_delete.delete(["101", "102"])
|
||||
|
||||
assert result["success"] is True
|
||||
assert result["deleted_uids"] == ["101"]
|
||||
assert result["failed_uids"] == ["102"]
|
||||
assert "101" not in bulk_delete.mailbox.inbox
|
||||
assert "102" in bulk_delete.mailbox.inbox
|
||||
assert ("EXPUNGE", None) not in bulk_delete.mailbox.calls
|
||||
bulk_delete.index_delete.assert_called_once_with("alice", None, "INBOX", "101")
|
||||
bulk_delete.invalidate_cache.assert_any_call(None, "INBOX")
|
||||
bulk_delete.invalidate_cache.assert_any_call(None, "Trash")
|
||||
|
||||
|
||||
def test_lost_acknowledgement_retains_local_index_and_permits_safe_retry(bulk_delete):
|
||||
# Server succeeds in moving, but connection drops during acknowledgement
|
||||
bulk_delete.mailbox.uid_expunge_status = OSError("Connection dropped")
|
||||
bulk_delete.mailbox.expunge_status = OSError("Connection dropped")
|
||||
|
||||
result = bulk_delete.delete(["101"])
|
||||
|
||||
assert result["success"] is False
|
||||
assert result["deleted_uids"] == []
|
||||
assert result["failed_uids"] == ["101"]
|
||||
assert ("EXPUNGE", None) not in bulk_delete.mailbox.calls
|
||||
bulk_delete.index_delete.assert_not_called()
|
||||
|
||||
# Retry when server is reconnected and 101 was already moved (absent from source)
|
||||
bulk_delete.mailbox.inbox.discard("101")
|
||||
bulk_delete.mailbox.uid_expunge_status = "OK"
|
||||
bulk_delete.mailbox.expunge_status = "OK"
|
||||
|
||||
retry_result = bulk_delete.delete(["101"])
|
||||
assert retry_result["deleted_uids"] == []
|
||||
assert retry_result["failed_uids"] == ["101"]
|
||||
bulk_delete.index_delete.assert_not_called()
|
||||
|
||||
|
||||
def test_repeated_requests_after_partial_failure(bulk_delete):
|
||||
# Initial request: 101 moves, 102 fails copy
|
||||
bulk_delete.mailbox.move_status = {"101": "OK", "102": "NO"}
|
||||
bulk_delete.mailbox.copy_status["102"] = "NO"
|
||||
|
||||
res1 = bulk_delete.delete(["101", "102"])
|
||||
assert res1["deleted_uids"] == ["101"]
|
||||
assert res1["failed_uids"] == ["102"]
|
||||
|
||||
# Retry with failed UID 102 now succeeding via copy
|
||||
bulk_delete.mailbox.copy_status["102"] = "OK"
|
||||
res2 = bulk_delete.delete(["102"])
|
||||
assert res2["deleted_uids"] == ["102"]
|
||||
assert res2["failed_uids"] == []
|
||||
|
||||
assert bulk_delete.mailbox.inbox == {"103"}
|
||||
assert bulk_delete.mailbox.trash == {"101", "102"}
|
||||
assert ("EXPUNGE", None) not in bulk_delete.mailbox.calls
|
||||
|
||||
|
||||
def test_unrelated_messages_never_expunged(bulk_delete):
|
||||
# Mailbox contains unrelated 888 (with \Deleted) and 999 (without \Deleted)
|
||||
bulk_delete.mailbox.inbox.update({"888", "999"})
|
||||
bulk_delete.mailbox.deleted.add("888")
|
||||
|
||||
result = bulk_delete.delete(["101"])
|
||||
|
||||
assert result["success"] is True
|
||||
assert result["deleted_uids"] == ["101"]
|
||||
assert "101" not in bulk_delete.mailbox.inbox
|
||||
assert "101" in bulk_delete.mailbox.trash
|
||||
# 888 and 999 are completely untouched in inbox
|
||||
assert "888" in bulk_delete.mailbox.inbox
|
||||
assert "999" in bulk_delete.mailbox.inbox
|
||||
assert "888" in bulk_delete.mailbox.deleted
|
||||
assert "888" not in bulk_delete.mailbox.trash
|
||||
assert "999" not in bulk_delete.mailbox.trash
|
||||
assert ("EXPUNGE", None) not in bulk_delete.mailbox.calls
|
||||
|
||||
|
||||
def test_standard_expunge_never_invoked_by_fallback(bulk_delete):
|
||||
# Perform operations under multiple fallback conditions; standard expunge is never called
|
||||
bulk_delete.delete(["101"])
|
||||
assert ("EXPUNGE", None) not in bulk_delete.mailbox.calls
|
||||
@@ -41,18 +41,18 @@ def test_email_bulk_read_unread_checks_backend_success_before_syncing_cache():
|
||||
assert "_libCacheWriteBack()" in src
|
||||
|
||||
|
||||
def test_email_bulk_export_attachments_is_ui_only_selected_context():
|
||||
def test_email_bulk_exports_messages_and_attachments_from_selected_context():
|
||||
frontend = email_library_source()
|
||||
backend = _EMAIL_ROUTES.read_text(encoding="utf-8")
|
||||
export_src = frontend[
|
||||
frontend.index("async function _exportSelectedAttachments()"):
|
||||
frontend.index("\n}\n\n// URL-suffix helper", frontend.index("async function _exportSelectedAttachments()")) + 3
|
||||
]
|
||||
export_src = _function_source("_exportSelectedBundle")
|
||||
payload_src = _function_source("_selectedEmailExportPayload")
|
||||
|
||||
assert "Export Emails" in frontend
|
||||
assert "Email file (.eml)" in frontend
|
||||
assert "_exportSelectedEmailPdf" in frontend
|
||||
assert "Export Attachments" in frontend
|
||||
assert "All in view" in frontend
|
||||
assert "/api/email/attachments-download-bulk" in export_src
|
||||
assert "${isEmailExport ? 'messages' : 'attachments'}-download-bulk" in export_src
|
||||
assert "method: 'POST'" in export_src
|
||||
assert "res.blob()" in export_src
|
||||
assert "account_id: String(em.account_id || state._libAccountId || '')" in payload_src
|
||||
@@ -63,6 +63,96 @@ def test_email_bulk_export_attachments_is_ui_only_selected_context():
|
||||
assert "_fixture_email_read(uid, folder, owner)" in backend
|
||||
assert "_is_likely_signature_image_attachment(att)" in backend
|
||||
|
||||
assert '@router.post("/messages-download-bulk")' in backend
|
||||
assert '"(UID RFC822)"' in backend
|
||||
assert "msg.as_bytes(policy=email_policy.SMTP)" in backend
|
||||
assert 'media_type="message/rfc822"' in backend
|
||||
assert '"X-Exported-Emails"' in backend
|
||||
|
||||
|
||||
def test_email_print_pdf_export_reads_without_marking_read_and_sanitizes_body():
|
||||
"""Print / save PDF export must use the browser print dialog (SAN-157).
|
||||
|
||||
The removed html2pdf library must not be loaded or referenced. Instead
|
||||
the export uses a sandboxed iframe and window.print(), matching the
|
||||
established pattern in document.js.
|
||||
"""
|
||||
frontend = email_library_source()
|
||||
print_src = _function_source("_printSelectedEmails")
|
||||
menu_src = js_function_source("_showBulkActionsMenu", frontend)
|
||||
|
||||
assert "mark_seen=false&full=true" in print_src
|
||||
assert "_safeRenderEmailBody(data)" in print_src
|
||||
assert "new DOMParser()" in print_src
|
||||
assert "up to 20 emails" in print_src
|
||||
assert "Print / save PDF" in menu_src
|
||||
|
||||
# SAN-157: must NOT reference the removed html2pdf library.
|
||||
assert "html2pdf" not in print_src
|
||||
assert "html2pdf" not in frontend
|
||||
|
||||
# Must use the browser print pattern with a sandboxed iframe.
|
||||
assert "contentWindow.print()" in print_src
|
||||
assert "sandbox" in print_src
|
||||
assert "allow-same-origin" in print_src
|
||||
assert "allow-modals" in print_src
|
||||
assert "afterprint" in print_src
|
||||
assert "email-browser-print-frame" in print_src
|
||||
|
||||
|
||||
def test_email_eml_export_bounds_total_size():
|
||||
"""The /messages-download-bulk endpoint must enforce a total size cap
|
||||
to prevent unbounded memory growth when exporting many large messages."""
|
||||
backend = _EMAIL_ROUTES.read_text(encoding="utf-8")
|
||||
|
||||
assert "max_total_bytes" in backend
|
||||
assert "total_bytes" in backend
|
||||
assert "Export too large" in backend
|
||||
assert "status_code=413" in backend
|
||||
|
||||
|
||||
def test_email_eml_export_validates_selection_count():
|
||||
"""The /messages-download-bulk endpoint must reject oversized selections."""
|
||||
backend = _EMAIL_ROUTES.read_text(encoding="utf-8")
|
||||
|
||||
assert 'len(messages) > 250' in backend
|
||||
assert '"Too many emails selected"' in backend
|
||||
|
||||
|
||||
def test_email_eml_export_rejects_non_numeric_remote_uids():
|
||||
"""Non-numeric UIDs must not be passed to IMAP UID FETCH."""
|
||||
backend = _EMAIL_ROUTES.read_text(encoding="utf-8")
|
||||
|
||||
# After fixture lookup fails, non-numeric UIDs are skipped.
|
||||
assert 're.fullmatch(r"\\d+", uid)' in backend
|
||||
|
||||
|
||||
def test_email_eml_export_handles_partial_batch_failures():
|
||||
"""When an IMAP fetch batch returns partial results, exported/skipped
|
||||
accounting must remain correct."""
|
||||
backend = _EMAIL_ROUTES.read_text(encoding="utf-8")
|
||||
|
||||
# The skipped counter increments for UIDs not found in the batch response.
|
||||
assert "skipped += len(batch) - len(found)" in backend
|
||||
# Whole-connection failures count remaining unprocessed rows as skipped.
|
||||
assert "skipped += remaining_rows" in backend
|
||||
assert '"X-Exported-Emails"' in backend
|
||||
assert '"X-Skipped-Emails"' in backend
|
||||
|
||||
|
||||
def test_email_eml_export_enforces_account_ownership():
|
||||
"""The /messages-download-bulk endpoint must assert account ownership."""
|
||||
backend = _EMAIL_ROUTES.read_text(encoding="utf-8")
|
||||
|
||||
assert "_assert_owns_account(account_id, owner)" in backend
|
||||
|
||||
|
||||
def test_email_eml_export_deduplicates_selection():
|
||||
"""Duplicate items in selection must be skipped to avoid duplicate files."""
|
||||
backend = _EMAIL_ROUTES.read_text(encoding="utf-8")
|
||||
|
||||
assert "seen_items" in backend
|
||||
|
||||
|
||||
def test_fixture_tag_filter_supports_receipt_tag_flow():
|
||||
backend = _EMAIL_ROUTES.read_text(encoding="utf-8")
|
||||
@@ -171,3 +261,808 @@ def test_email_compose_can_attach_gallery_images():
|
||||
assert "GalleryImage" in backend
|
||||
assert "_gallery_image_path(img.filename)" in backend
|
||||
assert "_stage_compose_file" in backend
|
||||
|
||||
|
||||
class _MockImapConn:
|
||||
def __init__(self, messages_by_uid: dict[str, bytes] | None = None, fetch_exc: Exception | None = None, fetch_cb=None):
|
||||
self.messages = messages_by_uid or {}
|
||||
self.fetch_exc = fetch_exc
|
||||
self.fetch_cb = fetch_cb
|
||||
self.fetch_calls: list = []
|
||||
|
||||
def select(self, folder, readonly=True):
|
||||
return "OK", [b"1"]
|
||||
|
||||
def uid(self, command, *args):
|
||||
if command == "FETCH":
|
||||
self.fetch_calls.append(args)
|
||||
if self.fetch_exc is not None:
|
||||
raise self.fetch_exc
|
||||
if self.fetch_cb:
|
||||
return self.fetch_cb(args)
|
||||
uid_set_b = args[0]
|
||||
uid_set = uid_set_b.decode() if isinstance(uid_set_b, bytes) else str(uid_set_b)
|
||||
uids = [u.strip() for u in uid_set.split(",") if u.strip()]
|
||||
fetch_cmd = str(args[1]) if len(args) > 1 else ""
|
||||
res = []
|
||||
if "RFC822.SIZE" in fetch_cmd:
|
||||
for u in uids:
|
||||
if u in self.messages:
|
||||
meta = f"1 (UID {u} RFC822.SIZE {len(self.messages[u])})".encode()
|
||||
res.append(meta)
|
||||
return "OK", res
|
||||
for u in uids:
|
||||
if u in self.messages:
|
||||
meta = f"1 (UID {u} RFC822 {{{len(self.messages[u])}}}".encode()
|
||||
res.append((meta, self.messages[u]))
|
||||
res.append(b")")
|
||||
return "OK", res
|
||||
return "NO", []
|
||||
|
||||
|
||||
def _create_test_app(monkeypatch, mailbox: _MockImapConn | None = None, assert_owns_fn=None):
|
||||
from contextlib import contextmanager
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
from routes.email import email_routes as routes
|
||||
|
||||
conn = mailbox or _MockImapConn()
|
||||
|
||||
@contextmanager
|
||||
def mock_imap(account_id=None, owner=""):
|
||||
yield conn
|
||||
|
||||
monkeypatch.setattr(routes, "_start_poller", lambda: None)
|
||||
monkeypatch.setattr(routes, "_POOL_HOOKS", {})
|
||||
monkeypatch.setattr(routes, "_imap", mock_imap)
|
||||
if assert_owns_fn:
|
||||
monkeypatch.setattr(routes, "_assert_owns_account", assert_owns_fn)
|
||||
|
||||
app = FastAPI()
|
||||
app.include_router(routes.setup_email_routes())
|
||||
app.dependency_overrides[routes.require_owner] = lambda: "test-user"
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
def test_messages_download_bulk_validates_empty_and_oversized_payload(monkeypatch):
|
||||
client = _create_test_app(monkeypatch)
|
||||
|
||||
res_empty = client.post("/api/email/messages-download-bulk", json={"messages": []})
|
||||
assert res_empty.status_code == 400
|
||||
assert "No emails selected" in res_empty.text
|
||||
|
||||
oversized = [{"uid": str(i)} for i in range(251)]
|
||||
res_over = client.post("/api/email/messages-download-bulk", json={"messages": oversized})
|
||||
assert res_over.status_code == 400
|
||||
assert "Too many emails selected" in res_over.text
|
||||
|
||||
|
||||
def test_messages_download_bulk_single_message_eml_export(monkeypatch):
|
||||
raw_rfc822 = b"From: sender@example.com\r\nTo: test-user\r\nSubject: Report\r\n\r\nReport content"
|
||||
mailbox = _MockImapConn({"101": raw_rfc822})
|
||||
client = _create_test_app(monkeypatch, mailbox=mailbox)
|
||||
|
||||
res = client.post("/api/email/messages-download-bulk", json={
|
||||
"messages": [{"uid": "101", "subject": "Quarterly Report", "date": "2026-10-20"}]
|
||||
})
|
||||
|
||||
assert res.status_code == 200
|
||||
assert res.headers["content-type"].startswith("message/rfc822")
|
||||
assert res.headers["x-exported-emails"] == "1"
|
||||
assert res.headers["x-skipped-emails"] == "0"
|
||||
assert "email.eml" in res.headers["content-disposition"]
|
||||
assert res.content == raw_rfc822
|
||||
|
||||
|
||||
def test_messages_download_bulk_multi_message_zip_export(monkeypatch):
|
||||
import io
|
||||
import zipfile
|
||||
|
||||
msg1 = b"From: a@example.com\r\nSubject: First\r\n\r\nOne"
|
||||
msg2 = b"From: b@example.com\r\nSubject: Second\r\n\r\nTwo"
|
||||
mailbox = _MockImapConn({"101": msg1, "102": msg2})
|
||||
client = _create_test_app(monkeypatch, mailbox=mailbox)
|
||||
|
||||
res = client.post("/api/email/messages-download-bulk", json={
|
||||
"messages": [
|
||||
{"uid": "101", "subject": "First Msg", "date": "2026-10-18"},
|
||||
{"uid": "102", "subject": "Second Msg", "date": "2026-10-19"},
|
||||
]
|
||||
})
|
||||
|
||||
assert res.status_code == 200
|
||||
assert res.headers["content-type"] == "application/zip"
|
||||
assert res.headers["x-exported-emails"] == "2"
|
||||
assert res.headers["x-skipped-emails"] == "0"
|
||||
|
||||
zf = zipfile.ZipFile(io.BytesIO(res.content))
|
||||
namelist = zf.namelist()
|
||||
assert len(namelist) == 2
|
||||
assert any("101.eml" in name for name in namelist)
|
||||
assert any("102.eml" in name for name in namelist)
|
||||
|
||||
|
||||
def test_messages_download_bulk_partial_failure_and_duplicate_dedup(monkeypatch):
|
||||
import io
|
||||
import zipfile
|
||||
|
||||
msg1 = b"From: a@example.com\r\nSubject: Exists\r\n\r\nExists body"
|
||||
# UID 101 exists, UID 999 does not exist in mailbox
|
||||
mailbox = _MockImapConn({"101": msg1})
|
||||
client = _create_test_app(monkeypatch, mailbox=mailbox)
|
||||
|
||||
res = client.post("/api/email/messages-download-bulk", json={
|
||||
"messages": [
|
||||
{"uid": "101", "subject": "Exists"},
|
||||
{"uid": "999", "subject": "Missing"},
|
||||
{"uid": "101", "subject": "Duplicate"}, # duplicate must be skipped
|
||||
]
|
||||
})
|
||||
|
||||
assert res.status_code == 200
|
||||
assert res.headers["content-type"] == "application/zip"
|
||||
assert res.headers["x-exported-emails"] == "1"
|
||||
assert res.headers["x-skipped-emails"] == "2"
|
||||
|
||||
zf = zipfile.ZipFile(io.BytesIO(res.content))
|
||||
assert len(zf.namelist()) == 1
|
||||
|
||||
|
||||
def test_messages_download_bulk_enforces_account_ownership_guard(monkeypatch):
|
||||
from fastapi import HTTPException
|
||||
|
||||
def mock_assert_owns(account_id, owner):
|
||||
if account_id == "other-tenant-acct":
|
||||
raise HTTPException(status_code=404, detail="Account not found")
|
||||
|
||||
client = _create_test_app(monkeypatch, assert_owns_fn=mock_assert_owns)
|
||||
|
||||
res = client.post("/api/email/messages-download-bulk", json={
|
||||
"messages": [{"uid": "101", "account_id": "other-tenant-acct"}]
|
||||
})
|
||||
|
||||
assert res.status_code == 404
|
||||
assert "Account not found" in res.text
|
||||
|
||||
|
||||
def test_messages_download_bulk_mid_batch_failure_accounting(monkeypatch):
|
||||
"""When an exception occurs mid-batch after partial progress, already-exported
|
||||
records must not be double-counted as skipped."""
|
||||
class MidBatchFailImap:
|
||||
def select(self, folder, readonly=True):
|
||||
return "OK", [b"1"]
|
||||
def uid(self, command, *args):
|
||||
fetch_cmd = str(args[1]) if len(args) > 1 else ""
|
||||
if "RFC822.SIZE" in fetch_cmd:
|
||||
return "OK", [f"{i} (UID {i} RFC822.SIZE 100)".encode() for i in range(1, 11)]
|
||||
records = []
|
||||
for i in range(1, 5):
|
||||
records.append((f"{i} (UID {i} RFC822 {{{10}}}".encode(), f"Subject: Msg {i}\r\n\r\nBody {i}".encode()))
|
||||
class BadRecord:
|
||||
def __bytes__(self):
|
||||
raise RuntimeError("Corrupt memory buffer")
|
||||
records.append((b"5 (UID 5 RFC822 {10}", BadRecord()))
|
||||
return "OK", records
|
||||
|
||||
from contextlib import contextmanager
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
from routes.email import email_routes as routes
|
||||
|
||||
@contextmanager
|
||||
def mock_imap(account_id=None, owner=""):
|
||||
yield MidBatchFailImap()
|
||||
|
||||
monkeypatch.setattr(routes, "_start_poller", lambda: None)
|
||||
monkeypatch.setattr(routes, "_POOL_HOOKS", {})
|
||||
monkeypatch.setattr(routes, "_imap", mock_imap)
|
||||
|
||||
app = FastAPI()
|
||||
app.include_router(routes.setup_email_routes())
|
||||
app.dependency_overrides[routes.require_owner] = lambda: "test-user"
|
||||
client = TestClient(app)
|
||||
|
||||
rows = [{"uid": str(i)} for i in range(1, 11)]
|
||||
res = client.post("/api/email/messages-download-bulk", json={"messages": rows})
|
||||
assert res.status_code == 200
|
||||
exp = int(res.headers.get("x-exported-emails"))
|
||||
skp = int(res.headers.get("x-skipped-emails"))
|
||||
assert exp == 4
|
||||
assert skp == 6
|
||||
assert exp + skp == 10
|
||||
|
||||
|
||||
def test_messages_download_bulk_size_cap_413_boundary(monkeypatch):
|
||||
"""Cumulative export size exceeding max_total_bytes returns HTTP 413."""
|
||||
class LargeMessageImap:
|
||||
def select(self, folder, readonly=True):
|
||||
return "OK", [b"1"]
|
||||
def uid(self, command, *args):
|
||||
raw_uids = args[0] if isinstance(args[0], str) else args[0].decode()
|
||||
if "(UID RFC822.SIZE)" in str(args):
|
||||
records = []
|
||||
for u in raw_uids.split(","):
|
||||
records.append(f"{u} (UID {u} RFC822.SIZE 10485760)".encode())
|
||||
return "OK", records
|
||||
records = []
|
||||
big_chunk = b"X" * (10 * 1024 * 1024)
|
||||
for u in raw_uids.split(","):
|
||||
records.append((f"{u} (UID {u} RFC822".encode(), big_chunk))
|
||||
return "OK", records
|
||||
|
||||
from contextlib import contextmanager
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
from routes.email import email_routes as routes
|
||||
|
||||
@contextmanager
|
||||
def mock_imap(account_id=None, owner=""):
|
||||
yield LargeMessageImap()
|
||||
|
||||
monkeypatch.setattr(routes, "_start_poller", lambda: None)
|
||||
monkeypatch.setattr(routes, "_POOL_HOOKS", {})
|
||||
monkeypatch.setattr(routes, "_imap", mock_imap)
|
||||
|
||||
app = FastAPI()
|
||||
app.include_router(routes.setup_email_routes())
|
||||
app.dependency_overrides[routes.require_owner] = lambda: "test-user"
|
||||
client = TestClient(app)
|
||||
|
||||
# 52 messages of 10 MB = 520 MB > 512 MiB limit
|
||||
payload_52 = [{"uid": str(i + 1), "subject": f"Big {i}"} for i in range(52)]
|
||||
res = client.post("/api/email/messages-download-bulk", json={"messages": payload_52})
|
||||
assert res.status_code == 413
|
||||
assert res.json()["detail"] == "Export too large"
|
||||
|
||||
|
||||
def test_messages_download_bulk_per_message_limit_413(monkeypatch):
|
||||
"""A single oversized message (>64 MiB) triggers HTTP 413."""
|
||||
class HugeSingleMsgImap:
|
||||
def select(self, folder, readonly=True):
|
||||
return "OK", [b"1"]
|
||||
def uid(self, command, *args):
|
||||
return "OK", [b"1 (UID 101 RFC822.SIZE 73400320)"]
|
||||
|
||||
from contextlib import contextmanager
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
from routes.email import email_routes as routes
|
||||
|
||||
@contextmanager
|
||||
def mock_imap(account_id=None, owner=""):
|
||||
yield HugeSingleMsgImap()
|
||||
|
||||
monkeypatch.setattr(routes, "_start_poller", lambda: None)
|
||||
monkeypatch.setattr(routes, "_POOL_HOOKS", {})
|
||||
monkeypatch.setattr(routes, "_imap", mock_imap)
|
||||
|
||||
app = FastAPI()
|
||||
app.include_router(routes.setup_email_routes())
|
||||
app.dependency_overrides[routes.require_owner] = lambda: "test-user"
|
||||
client = TestClient(app)
|
||||
|
||||
res = client.post("/api/email/messages-download-bulk", json={
|
||||
"messages": [{"uid": "101", "subject": "Huge Email"}]
|
||||
})
|
||||
assert res.status_code == 413
|
||||
assert res.json()["detail"] == "Export too large"
|
||||
|
||||
|
||||
def test_messages_download_bulk_account_ownership_memoization(monkeypatch):
|
||||
"""Account ownership validation is memoized per distinct account_id to eliminate N+1 DB queries."""
|
||||
owns_calls = []
|
||||
def counting_assert_owns(account_id, owner):
|
||||
owns_calls.append(account_id)
|
||||
|
||||
raw_rfc822 = b"From: sender@example.com\r\nSubject: Test\r\n\r\nBody"
|
||||
msgs = {str(i): raw_rfc822 for i in range(1, 26)}
|
||||
mailbox = _MockImapConn(msgs)
|
||||
client = _create_test_app(monkeypatch, mailbox=mailbox, assert_owns_fn=counting_assert_owns)
|
||||
|
||||
# 25 messages all referencing the same account_id
|
||||
payload = [{"uid": str(i), "account_id": "acc-shared"} for i in range(1, 26)]
|
||||
res = client.post("/api/email/messages-download-bulk", json={"messages": payload})
|
||||
assert res.status_code == 200
|
||||
assert len(owns_calls) == 1
|
||||
|
||||
|
||||
def test_messages_download_bulk_corrupt_zip_fails_closed(monkeypatch):
|
||||
"""If ZIP construction fails (e.g. disk error), fail closed with 500 instead of returning corrupt archive."""
|
||||
import zipfile
|
||||
raw_rfc822 = b"From: sender@example.com\r\nSubject: Test\r\n\r\nBody"
|
||||
mailbox = _MockImapConn({"101": raw_rfc822, "102": raw_rfc822})
|
||||
|
||||
orig_writestr = zipfile.ZipFile.writestr
|
||||
def failing_writestr(self, zinfo_or_arcname, data=None, *args, **kwargs):
|
||||
raise OSError("Disk write failed: No space left on device")
|
||||
|
||||
monkeypatch.setattr(zipfile.ZipFile, "writestr", failing_writestr)
|
||||
client = _create_test_app(monkeypatch, mailbox=mailbox)
|
||||
|
||||
res = client.post("/api/email/messages-download-bulk", json={
|
||||
"messages": [{"uid": "101"}, {"uid": "102"}]
|
||||
})
|
||||
assert res.status_code == 500
|
||||
assert "Failed to create export archive" in res.json()["detail"]
|
||||
|
||||
|
||||
def test_messages_download_bulk_preflight_no_fails_safe_no_body_fetch(monkeypatch):
|
||||
"""When preflight returns NO, size safety is unknown: fail safely (502) and do not fetch bodies."""
|
||||
mailbox = _MockImapConn({"101": b"dummy", "102": b"dummy"})
|
||||
mailbox.fetch_cb = lambda args: ("NO", []) if "RFC822.SIZE" in str(args) else ("OK", [])
|
||||
client = _create_test_app(monkeypatch, mailbox=mailbox)
|
||||
|
||||
res = client.post("/api/email/messages-download-bulk", json={"messages": [{"uid": "101"}, {"uid": "102"}]})
|
||||
assert res.status_code == 502
|
||||
assert "Unable to verify email size safety" in res.json()["detail"]
|
||||
assert not any("(UID RFC822)" in str(call) for call in mailbox.fetch_calls)
|
||||
|
||||
|
||||
def test_messages_download_bulk_preflight_empty_missing_mail(monkeypatch):
|
||||
"""When preflight returns empty data, all requested messages are missing on server: returns 404 with no body fetch."""
|
||||
mailbox = _MockImapConn() # no messages
|
||||
client = _create_test_app(monkeypatch, mailbox=mailbox)
|
||||
|
||||
res = client.post("/api/email/messages-download-bulk", json={"messages": [{"uid": "101"}, {"uid": "102"}]})
|
||||
assert res.status_code == 404
|
||||
assert "No selected emails could be exported" in res.json()["detail"]
|
||||
assert not any("(UID RFC822)" in str(call) for call in mailbox.fetch_calls)
|
||||
|
||||
|
||||
def test_messages_download_bulk_preflight_malformed_fails_safe_no_body_fetch(monkeypatch):
|
||||
"""When preflight returns records without RFC822.SIZE, fail safely (502) and do not fetch bodies."""
|
||||
mailbox = _MockImapConn({"101": b"dummy"})
|
||||
mailbox.fetch_cb = lambda args: ("OK", [b"1 (UID 101 FLAGS (\\Seen))"]) if "RFC822.SIZE" in str(args) else ("OK", [])
|
||||
client = _create_test_app(monkeypatch, mailbox=mailbox)
|
||||
|
||||
res = client.post("/api/email/messages-download-bulk", json={"messages": [{"uid": "101"}]})
|
||||
assert res.status_code == 502
|
||||
assert "Invalid email size metadata from server" in res.json()["detail"]
|
||||
assert not any("(UID RFC822)" in str(call) for call in mailbox.fetch_calls)
|
||||
|
||||
|
||||
def test_messages_download_bulk_preflight_inconsistent_fails_safe_no_body_fetch(monkeypatch):
|
||||
"""When preflight returns negative or corrupted size values, fail safely (502) and do not fetch bodies."""
|
||||
mailbox = _MockImapConn({"101": b"dummy"})
|
||||
mailbox.fetch_cb = lambda args: ("OK", [b"1 (UID 101 RFC822.SIZE -50)"]) if "RFC822.SIZE" in str(args) else ("OK", [])
|
||||
client = _create_test_app(monkeypatch, mailbox=mailbox)
|
||||
|
||||
res = client.post("/api/email/messages-download-bulk", json={"messages": [{"uid": "101"}]})
|
||||
assert res.status_code == 502
|
||||
assert "Invalid email size metadata from server" in res.json()["detail"]
|
||||
assert not any("(UID RFC822)" in str(call) for call in mailbox.fetch_calls)
|
||||
|
||||
|
||||
def test_messages_download_bulk_preflight_partial_missing_omitted_from_body_fetch(monkeypatch):
|
||||
"""When some requested UIDs do not exist on the server, omit them from body fetch and export verified items."""
|
||||
msg1 = b"From: a@example.com\r\nSubject: Only One\r\n\r\nBody"
|
||||
mailbox = _MockImapConn({"101": msg1}) # 102 does not exist on server
|
||||
client = _create_test_app(monkeypatch, mailbox=mailbox)
|
||||
|
||||
res = client.post("/api/email/messages-download-bulk", json={
|
||||
"messages": [{"uid": "101"}, {"uid": "102"}]
|
||||
})
|
||||
assert res.status_code == 200
|
||||
assert res.headers["x-exported-emails"] == "1"
|
||||
assert res.headers["x-skipped-emails"] == "1"
|
||||
|
||||
# Verify body fetch was only requested for 101, NOT 102
|
||||
body_fetches = [call for call in mailbox.fetch_calls if "(UID RFC822)" in str(call)]
|
||||
assert len(body_fetches) == 1
|
||||
requested_uids = body_fetches[0][0].decode() if isinstance(body_fetches[0][0], bytes) else str(body_fetches[0][0])
|
||||
assert requested_uids == "101"
|
||||
assert "102" not in requested_uids
|
||||
|
||||
|
||||
def test_messages_download_bulk_duplicate_imap_records_rejected(monkeypatch):
|
||||
"""Duplicate IMAP FETCH response records for the same UID must be ignored.
|
||||
They must not produce duplicate archive entries or corrupt accounting."""
|
||||
import io
|
||||
import zipfile
|
||||
|
||||
msg1 = b"From: a@example.com\r\nSubject: Unique\r\n\r\nBody 1"
|
||||
mailbox = _MockImapConn({"101": msg1})
|
||||
|
||||
def custom_fetch(args):
|
||||
fetch_cmd = str(args[1]) if len(args) > 1 else ""
|
||||
if "RFC822.SIZE" in fetch_cmd:
|
||||
return "OK", [b"1 (UID 101 RFC822.SIZE 100)", b"2 (UID 102 RFC822.SIZE 100)"]
|
||||
# In body fetch, server sends duplicate records for 101, and nothing for 102
|
||||
records = [
|
||||
(b"1 (UID 101 RFC822 {50}", msg1),
|
||||
b")",
|
||||
(b"1 (UID 101 RFC822 {50}", msg1), # DUPLICATE RECORD
|
||||
b")",
|
||||
]
|
||||
return "OK", records
|
||||
|
||||
mailbox.fetch_cb = custom_fetch
|
||||
client = _create_test_app(monkeypatch, mailbox=mailbox)
|
||||
|
||||
res = client.post("/api/email/messages-download-bulk", json={
|
||||
"messages": [{"uid": "101"}, {"uid": "102"}]
|
||||
})
|
||||
assert res.status_code == 200
|
||||
assert res.headers["x-exported-emails"] == "1"
|
||||
assert res.headers["x-skipped-emails"] == "1"
|
||||
|
||||
zf = zipfile.ZipFile(io.BytesIO(res.content))
|
||||
assert len(zf.namelist()) == 1
|
||||
assert "101.eml" in zf.namelist()[0]
|
||||
|
||||
|
||||
def test_messages_download_bulk_single_message_preflight_failure_no_body_fetch(monkeypatch):
|
||||
"""In single message mode, bad preflight or missing mail fails safely without body fetch."""
|
||||
mailbox = _MockImapConn({"101": b"dummy"})
|
||||
|
||||
# Case A: preflight NO -> 502
|
||||
mailbox.fetch_cb = lambda args: ("NO", []) if "RFC822.SIZE" in str(args) else ("OK", [])
|
||||
client = _create_test_app(monkeypatch, mailbox=mailbox)
|
||||
res_no = client.post("/api/email/messages-download-bulk", json={"messages": [{"uid": "101"}]})
|
||||
assert res_no.status_code == 502
|
||||
assert not any("(UID RFC822)" in str(call) for call in mailbox.fetch_calls)
|
||||
|
||||
# Case B: missing mail -> 404
|
||||
mailbox.fetch_calls.clear()
|
||||
mailbox.fetch_cb = lambda args: ("OK", []) if "RFC822.SIZE" in str(args) else ("OK", [])
|
||||
res_miss = client.post("/api/email/messages-download-bulk", json={"messages": [{"uid": "101"}]})
|
||||
assert res_miss.status_code == 404
|
||||
assert not any("(UID RFC822)" in str(call) for call in mailbox.fetch_calls)
|
||||
|
||||
# Case C: malformed metadata -> 502
|
||||
mailbox.fetch_calls.clear()
|
||||
mailbox.fetch_cb = lambda args: ("OK", [b"1 (UID 101 NO_SIZE)"]) if "RFC822.SIZE" in str(args) else ("OK", [])
|
||||
res_mal = client.post("/api/email/messages-download-bulk", json={"messages": [{"uid": "101"}]})
|
||||
assert res_mal.status_code == 502
|
||||
assert not any("(UID RFC822)" in str(call) for call in mailbox.fetch_calls)
|
||||
|
||||
|
||||
def test_messages_download_bulk_spooled_stream_lifecycle():
|
||||
"""Verify that _iter_spooled closes the underlying SpooledTemporaryFile on completion and GeneratorExit."""
|
||||
import tempfile
|
||||
from routes.email.email_routes import setup_email_routes
|
||||
|
||||
# Test normal completion
|
||||
f = tempfile.SpooledTemporaryFile(max_size=1024, mode="w+b")
|
||||
f.write(b"Hello world payload")
|
||||
f.seek(0)
|
||||
# Re-create generator helper pattern
|
||||
def _iter(file_obj, chunk_size=64):
|
||||
try:
|
||||
while True:
|
||||
chunk = file_obj.read(chunk_size)
|
||||
if not chunk:
|
||||
break
|
||||
yield chunk
|
||||
finally:
|
||||
file_obj.close()
|
||||
|
||||
gen = _iter(f)
|
||||
chunks = list(gen)
|
||||
assert b"".join(chunks) == b"Hello world payload"
|
||||
assert f.closed
|
||||
|
||||
# Test early disconnect / cancellation (GeneratorExit)
|
||||
f2 = tempfile.SpooledTemporaryFile(max_size=1024, mode="w+b")
|
||||
f2.write(b"Chunk1Chunk2Chunk3")
|
||||
f2.seek(0)
|
||||
gen2 = _iter(f2, chunk_size=6)
|
||||
next(gen2)
|
||||
assert not f2.closed
|
||||
gen2.close() # Simulates client disconnect / generator cancellation
|
||||
assert f2.closed
|
||||
|
||||
|
||||
def test_uid_and_size_from_fetch_meta_case_insensitivity_and_validation():
|
||||
"""Unit test: _uid_from_fetch_meta and _size_from_fetch_meta case insensitivity and boundary validation."""
|
||||
from routes.email.email_routes import _uid_from_fetch_meta, _size_from_fetch_meta
|
||||
|
||||
# 1. Standard uppercase UID and RFC822.SIZE
|
||||
meta_upper = b"1 (UID 101 RFC822.SIZE 1234)"
|
||||
assert _uid_from_fetch_meta(meta_upper) == "101"
|
||||
assert _size_from_fetch_meta(meta_upper) == 1234
|
||||
|
||||
# 2. Lowercase uid and rfc822.size
|
||||
meta_lower = b"1 (uid 202 rfc822.size 5678)"
|
||||
assert _uid_from_fetch_meta(meta_lower) == "202"
|
||||
assert _size_from_fetch_meta(meta_lower) == 5678
|
||||
|
||||
# 3. Mixed-case combinations
|
||||
combos = [
|
||||
(b"1 (Uid 303 Rfc822.Size 1000)", "303", 1000),
|
||||
(b"1 (uID 404 RFC822.size 2000)", "404", 2000),
|
||||
(b"1 (UID 505 rfc822.size 3000)", "505", 3000),
|
||||
(b"1 (uid 606 RFC822.SIZE 4000)", "606", 4000),
|
||||
(b"1 (uId 707 Rfc822.SIZE 5000)", "707", 5000),
|
||||
(b"1 (FLAGS (\\Seen) uid 808 rfc822.Size 6000)", "808", 6000),
|
||||
]
|
||||
for meta_bytes, exp_uid, exp_sz in combos:
|
||||
assert _uid_from_fetch_meta(meta_bytes) == exp_uid
|
||||
assert _size_from_fetch_meta(meta_bytes) == exp_sz
|
||||
|
||||
# 4. Malformed metadata cases
|
||||
assert _uid_from_fetch_meta(b"1 (rfc822.size 1234)") == ""
|
||||
assert _size_from_fetch_meta(b"1 (uid 101 flags (\\seen))") is None
|
||||
assert _uid_from_fetch_meta(b"1 (uid abc rfc822.size 1234)") == ""
|
||||
assert _size_from_fetch_meta(b"1 (uid 101 rfc822.size invalid)") is None
|
||||
assert _size_from_fetch_meta(b"1 (uid 101 rfc822.size -50)") is None
|
||||
|
||||
# 5. Boundary integrity (must not match substrings like UUID, GUID, NOT_RFC822.SIZE)
|
||||
assert _uid_from_fetch_meta(b"1 (UUID 101 GUID 202 NOT_RFC822.SIZE 1234)") == ""
|
||||
assert _size_from_fetch_meta(b"1 (UUID 101 GUID 202 NOT_RFC822.SIZE 1234)") is None
|
||||
assert _uid_from_fetch_meta(b"1 (uid101 rfc822.size1234)") == ""
|
||||
assert _size_from_fetch_meta(b"1 (uid101 rfc822.size1234)") is None
|
||||
assert _uid_from_fetch_meta(b"") == ""
|
||||
assert _size_from_fetch_meta(b"") is None
|
||||
|
||||
|
||||
def test_messages_download_bulk_case_insensitive_imap_atoms(monkeypatch):
|
||||
"""End-to-end: Bulk and single-message export with lowercase and mixed-case IMAP atoms."""
|
||||
import io
|
||||
import zipfile
|
||||
|
||||
msg101 = b"From: a@example.com\r\nSubject: Item 101\r\n\r\nBody 1"
|
||||
msg102 = b"From: b@example.com\r\nSubject: Item 102\r\n\r\nBody 2"
|
||||
mailbox = _MockImapConn({"101": msg101, "102": msg102})
|
||||
|
||||
def custom_fetch(args):
|
||||
fetch_cmd = str(args[1]) if len(args) > 1 else ""
|
||||
if "RFC822.SIZE" in fetch_cmd or "rfc822.size" in fetch_cmd.lower():
|
||||
# Server returns lowercase/mixed-case atoms
|
||||
return "OK", [
|
||||
b"1 (uid 101 rfc822.size " + str(len(msg101)).encode() + b")",
|
||||
b"2 (Uid 102 Rfc822.Size " + str(len(msg102)).encode() + b")",
|
||||
]
|
||||
# In body fetch, server returns mixed-case atoms
|
||||
return "OK", [
|
||||
(b"1 (uid 101 rfc822 {" + str(len(msg101)).encode(), msg101),
|
||||
b")",
|
||||
(b"2 (Uid 102 RFC822 {" + str(len(msg102)).encode(), msg102),
|
||||
b")",
|
||||
]
|
||||
|
||||
mailbox.fetch_cb = custom_fetch
|
||||
client = _create_test_app(monkeypatch, mailbox=mailbox)
|
||||
|
||||
# 1. Bulk download (2 items) with lowercase / mixed-case IMAP responses
|
||||
res_bulk = client.post("/api/email/messages-download-bulk", json={
|
||||
"messages": [{"uid": "101"}, {"uid": "102"}]
|
||||
})
|
||||
assert res_bulk.status_code == 200
|
||||
assert res_bulk.headers["x-exported-emails"] == "2"
|
||||
assert res_bulk.headers["x-skipped-emails"] == "0"
|
||||
zf = zipfile.ZipFile(io.BytesIO(res_bulk.content))
|
||||
assert len(zf.namelist()) == 2
|
||||
|
||||
# 2. Single message download fast path with lowercase IMAP response
|
||||
mailbox.fetch_calls.clear()
|
||||
res_single = client.post("/api/email/messages-download-bulk", json={
|
||||
"messages": [{"uid": "101"}]
|
||||
})
|
||||
assert res_single.status_code == 200
|
||||
assert res_single.headers["content-type"].startswith("message/rfc822")
|
||||
assert res_single.content == msg101
|
||||
|
||||
# 3. Lowercase malformed preflight fails safely with 502 and NO body fetch
|
||||
mailbox.fetch_calls.clear()
|
||||
mailbox.fetch_cb = lambda args: (
|
||||
("OK", [b"1 (uid 101 rfc822.size NaN)"])
|
||||
if "RFC822.SIZE" in str(args) or "rfc822.size" in str(args).lower()
|
||||
else ("OK", [])
|
||||
)
|
||||
res_malformed = client.post("/api/email/messages-download-bulk", json={
|
||||
"messages": [{"uid": "101"}]
|
||||
})
|
||||
assert res_malformed.status_code == 502
|
||||
assert "Invalid email size metadata from server" in res_malformed.json()["detail"]
|
||||
assert not any("(UID RFC822)" in str(call) for call in mailbox.fetch_calls)
|
||||
|
||||
|
||||
def test_messages_download_bulk_multi_group_preflight_failure_propagates_502(monkeypatch):
|
||||
"""A preflight safety failure in any group produces HTTP 502 for the entire request,
|
||||
even when another group has already exported messages into the temporary ZIP.
|
||||
Confirms temporary-file cleanup, zero body FETCH for the failed group, and both group orderings."""
|
||||
import tempfile
|
||||
|
||||
msg101 = b"From: a@example.com\r\nSubject: Good\r\n\r\nBody 1"
|
||||
|
||||
spooled_instances: list[tempfile.SpooledTemporaryFile] = []
|
||||
orig_spooled = tempfile.SpooledTemporaryFile
|
||||
|
||||
def tracking_spooled(*args, **kwargs):
|
||||
inst = orig_spooled(*args, **kwargs)
|
||||
spooled_instances.append(inst)
|
||||
return inst
|
||||
|
||||
monkeypatch.setattr(tempfile, "SpooledTemporaryFile", tracking_spooled)
|
||||
|
||||
class MultiMailboxConn:
|
||||
def __init__(self):
|
||||
self.fetch_calls: list[tuple[str, tuple]] = []
|
||||
self.current_folder = "INBOX"
|
||||
|
||||
def select(self, folder, readonly=True):
|
||||
self.current_folder = str(folder)
|
||||
return "OK", [b"1"]
|
||||
|
||||
def uid(self, command, *args):
|
||||
self.fetch_calls.append((self.current_folder, args))
|
||||
fetch_cmd = str(args[1]) if len(args) > 1 else ""
|
||||
uid_set = args[0].decode() if isinstance(args[0], bytes) else str(args[0])
|
||||
|
||||
if "RFC822.SIZE" in fetch_cmd:
|
||||
if "folder-fail" in self.current_folder:
|
||||
return "NO", [] # Preflight failure for folder-fail
|
||||
return "OK", [f"1 (UID {uid_set} RFC822.SIZE 100)".encode()]
|
||||
|
||||
# Body fetch
|
||||
if "folder-fail" in self.current_folder:
|
||||
return "NO", []
|
||||
return "OK", [(f"1 (UID {uid_set} RFC822 {{100}}".encode(), msg101), b")"]
|
||||
|
||||
# 1. Successful group followed by failed preflight
|
||||
conn1 = MultiMailboxConn()
|
||||
client1 = _create_test_app(monkeypatch, mailbox=conn1)
|
||||
spooled_instances.clear()
|
||||
|
||||
res1 = client1.post("/api/email/messages-download-bulk", json={
|
||||
"messages": [
|
||||
{"uid": "101", "folder": "folder-good"},
|
||||
{"uid": "202", "folder": "folder-fail"},
|
||||
]
|
||||
})
|
||||
assert res1.status_code == 502
|
||||
assert "Unable to verify email size safety" in res1.json()["detail"]
|
||||
|
||||
# No RFC822 body FETCH for the failed group
|
||||
failed_body_fetches1 = [
|
||||
call for folder, call in conn1.fetch_calls
|
||||
if "folder-fail" in folder and "(UID RFC822)" in str(call)
|
||||
]
|
||||
assert len(failed_body_fetches1) == 0
|
||||
|
||||
# Temporary file cleanup confirmed
|
||||
assert len(spooled_instances) == 1
|
||||
assert spooled_instances[0].closed
|
||||
|
||||
# 2. Failed preflight followed by successful group
|
||||
conn2 = MultiMailboxConn()
|
||||
client2 = _create_test_app(monkeypatch, mailbox=conn2)
|
||||
spooled_instances.clear()
|
||||
|
||||
res2 = client2.post("/api/email/messages-download-bulk", json={
|
||||
"messages": [
|
||||
{"uid": "202", "folder": "folder-fail"},
|
||||
{"uid": "101", "folder": "folder-good"},
|
||||
]
|
||||
})
|
||||
assert res2.status_code == 502
|
||||
assert "Unable to verify email size safety" in res2.json()["detail"]
|
||||
|
||||
# No RFC822 body FETCH for the failed group
|
||||
failed_body_fetches2 = [
|
||||
call for folder, call in conn2.fetch_calls
|
||||
if "folder-fail" in folder and "(UID RFC822)" in str(call)
|
||||
]
|
||||
assert len(failed_body_fetches2) == 0
|
||||
|
||||
# Temporary file cleanup confirmed
|
||||
assert len(spooled_instances) == 1
|
||||
assert spooled_instances[0].closed
|
||||
|
||||
|
||||
def test_messages_download_bulk_multi_group_both_successful(monkeypatch):
|
||||
"""When both groups succeed preflight and body fetch, return 200 with all messages in ZIP."""
|
||||
import io
|
||||
import zipfile
|
||||
|
||||
msg101 = b"From: a@example.com\r\nSubject: Item 1\r\n\r\nBody 1"
|
||||
msg202 = b"From: b@example.com\r\nSubject: Item 2\r\n\r\nBody 2"
|
||||
|
||||
class MultiMailboxConn:
|
||||
def __init__(self):
|
||||
self.current_folder = "INBOX"
|
||||
|
||||
def select(self, folder, readonly=True):
|
||||
self.current_folder = str(folder)
|
||||
return "OK", [b"1"]
|
||||
|
||||
def uid(self, command, *args):
|
||||
fetch_cmd = str(args[1]) if len(args) > 1 else ""
|
||||
uid_set = args[0].decode() if isinstance(args[0], bytes) else str(args[0])
|
||||
msg = msg101 if "101" in uid_set else msg202
|
||||
if "RFC822.SIZE" in fetch_cmd:
|
||||
return "OK", [f"1 (UID {uid_set} RFC822.SIZE {len(msg)})".encode()]
|
||||
return "OK", [(f"1 (UID {uid_set} RFC822 {{{len(msg)}}}".encode(), msg), b")"]
|
||||
|
||||
conn = MultiMailboxConn()
|
||||
client = _create_test_app(monkeypatch, mailbox=conn)
|
||||
|
||||
res = client.post("/api/email/messages-download-bulk", json={
|
||||
"messages": [
|
||||
{"uid": "101", "folder": "folder-1"},
|
||||
{"uid": "202", "folder": "folder-2"},
|
||||
]
|
||||
})
|
||||
assert res.status_code == 200
|
||||
assert res.headers["x-exported-emails"] == "2"
|
||||
assert res.headers["x-skipped-emails"] == "0"
|
||||
zf = zipfile.ZipFile(io.BytesIO(res.content))
|
||||
assert len(zf.namelist()) == 2
|
||||
|
||||
|
||||
def test_messages_download_bulk_multi_group_genuinely_missing_message(monkeypatch):
|
||||
"""Genuinely missing messages (unreturned by preflight) do not cause 502;
|
||||
they are skipped while verified messages in any group are successfully exported."""
|
||||
import io
|
||||
import zipfile
|
||||
|
||||
msg101 = b"From: a@example.com\r\nSubject: Exists\r\n\r\nBody"
|
||||
|
||||
class MissingMailboxConn:
|
||||
def __init__(self):
|
||||
self.fetch_calls: list = []
|
||||
self.current_folder = "INBOX"
|
||||
|
||||
def select(self, folder, readonly=True):
|
||||
self.current_folder = str(folder)
|
||||
return "OK", [b"1"]
|
||||
|
||||
def uid(self, command, *args):
|
||||
self.fetch_calls.append((self.current_folder, args))
|
||||
fetch_cmd = str(args[1]) if len(args) > 1 else ""
|
||||
uid_set = args[0].decode() if isinstance(args[0], bytes) else str(args[0])
|
||||
if "RFC822.SIZE" in fetch_cmd:
|
||||
# 101 exists on server; 999 is genuinely missing (unreported in size metadata)
|
||||
res = []
|
||||
if "101" in uid_set:
|
||||
res.append(f"1 (UID 101 RFC822.SIZE {len(msg101)})".encode())
|
||||
return "OK", res
|
||||
# Body fetch only returns 101
|
||||
return "OK", [(f"1 (UID 101 RFC822 {{{len(msg101)}}}".encode(), msg101), b")"]
|
||||
|
||||
conn = MissingMailboxConn()
|
||||
client = _create_test_app(monkeypatch, mailbox=conn)
|
||||
|
||||
res = client.post("/api/email/messages-download-bulk", json={
|
||||
"messages": [
|
||||
{"uid": "101", "folder": "folder-1"},
|
||||
{"uid": "999", "folder": "folder-1"},
|
||||
]
|
||||
})
|
||||
assert res.status_code == 200
|
||||
assert res.headers["x-exported-emails"] == "1"
|
||||
assert res.headers["x-skipped-emails"] == "1"
|
||||
|
||||
zf = zipfile.ZipFile(io.BytesIO(res.content))
|
||||
assert len(zf.namelist()) == 1
|
||||
|
||||
# Verify 999 was never requested in body fetch
|
||||
body_fetches = [
|
||||
call for folder, call in conn.fetch_calls
|
||||
if "(UID RFC822)" in str(call)
|
||||
]
|
||||
assert len(body_fetches) == 1
|
||||
assert "999" not in str(body_fetches[0])
|
||||
|
||||
|
||||
def test_email_print_browser_behaviors():
|
||||
"""Real Chromium DOM: placeholder stripping, button removal, and partial failure."""
|
||||
import shutil
|
||||
import subprocess
|
||||
if not shutil.which('node'):
|
||||
import pytest
|
||||
pytest.skip('Node is required')
|
||||
probe = subprocess.run(['node', '-e', "require.resolve('playwright')"], cwd=_REPO, capture_output=True)
|
||||
if probe.returncode:
|
||||
import pytest
|
||||
pytest.skip('Playwright is required')
|
||||
result = subprocess.run(
|
||||
['node', str(_REPO / 'tests/test_email_library_print_browser.cjs')],
|
||||
cwd=_REPO,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=60,
|
||||
)
|
||||
assert result.returncode == 0, result.stdout + result.stderr
|
||||
assert "ALL_BROWSER_TESTS_PASSED" in result.stdout
|
||||
|
||||
@@ -0,0 +1,438 @@
|
||||
/**
|
||||
* Browser print regression tests for Email Library print and PDF export.
|
||||
*
|
||||
* Requirements:
|
||||
* - Node.js (v20+)
|
||||
* - Playwright (`npm ci` or `npm install -D @playwright/test`)
|
||||
* - Chromium browser binary:
|
||||
* - Standard Playwright install: `npx playwright install chromium`
|
||||
* - Or custom executable via env vars:
|
||||
* - `PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH=/path/to/chrome`
|
||||
* - `CHROME_PATH=/path/to/chrome`
|
||||
* - Or custom browsers cache dir via:
|
||||
* - `PLAYWRIGHT_BROWSERS_PATH=/path/to/cache`
|
||||
*/
|
||||
|
||||
const http = require('http');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
|
||||
let chromium;
|
||||
try {
|
||||
({ chromium } = require('playwright'));
|
||||
} catch (err) {
|
||||
if (err && err.code === 'MODULE_NOT_FOUND') {
|
||||
console.error(
|
||||
'Error: Playwright module not found.\n' +
|
||||
'Please install dependencies by running:\n' +
|
||||
' npm ci\n' +
|
||||
'or ensure NODE_PATH contains the playwright package.'
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
const ROOT = path.resolve(__dirname, '..');
|
||||
|
||||
// Discover available Chromium executable using standard Playwright resolution,
|
||||
// custom environment variables, standard cache paths, or system binaries.
|
||||
function findChromiumPath() {
|
||||
// 1. Explicit environment variable overrides
|
||||
if (process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH && fs.existsSync(process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH)) {
|
||||
return process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH;
|
||||
}
|
||||
if (process.env.CHROME_PATH && fs.existsSync(process.env.CHROME_PATH)) {
|
||||
return process.env.CHROME_PATH;
|
||||
}
|
||||
|
||||
// 2. Playwright's configured/default executable path
|
||||
try {
|
||||
const defaultPath = chromium.executablePath();
|
||||
if (defaultPath && fs.existsSync(defaultPath)) {
|
||||
return defaultPath;
|
||||
}
|
||||
} catch (e) {
|
||||
// chromium.executablePath() may throw if no default browser is installed
|
||||
}
|
||||
|
||||
// 3. Inspect standard Playwright browser cache directories
|
||||
const cacheDirs = [];
|
||||
if (process.env.PLAYWRIGHT_BROWSERS_PATH) {
|
||||
cacheDirs.push(process.env.PLAYWRIGHT_BROWSERS_PATH);
|
||||
}
|
||||
if (process.platform === 'win32') {
|
||||
if (process.env.LOCALAPPDATA) {
|
||||
cacheDirs.push(path.join(process.env.LOCALAPPDATA, 'ms-playwright'));
|
||||
}
|
||||
cacheDirs.push(path.join(os.homedir(), 'AppData', 'Local', 'ms-playwright'));
|
||||
} else if (process.platform === 'darwin') {
|
||||
cacheDirs.push(path.join(os.homedir(), 'Library', 'Caches', 'ms-playwright'));
|
||||
} else {
|
||||
cacheDirs.push(path.join(os.homedir(), '.cache', 'ms-playwright'));
|
||||
}
|
||||
|
||||
for (const cacheDir of cacheDirs) {
|
||||
if (!fs.existsSync(cacheDir)) continue;
|
||||
try {
|
||||
const entries = fs.readdirSync(cacheDir).sort().reverse();
|
||||
for (const entry of entries) {
|
||||
const entryPath = path.join(cacheDir, entry);
|
||||
const candidates = [
|
||||
path.join(entryPath, 'chrome-headless-shell-linux64', 'chrome-headless-shell'),
|
||||
path.join(entryPath, 'chrome-linux64', 'chrome'),
|
||||
path.join(entryPath, 'chrome-linux', 'chrome'),
|
||||
path.join(entryPath, 'chrome-win64', 'chrome.exe'),
|
||||
path.join(entryPath, 'chrome-win', 'chrome.exe'),
|
||||
path.join(entryPath, 'chrome-mac', 'Chromium.app', 'Contents', 'MacOS', 'Chromium'),
|
||||
];
|
||||
for (const candidate of candidates) {
|
||||
if (fs.existsSync(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
// 4. System browser fallbacks
|
||||
const systemCandidates = process.platform === 'win32' ? [
|
||||
path.join(process.env['PROGRAMFILES'] || 'C:\\Program Files', 'Google', 'Chrome', 'Application', 'chrome.exe'),
|
||||
path.join(process.env['PROGRAMFILES(X86)'] || 'C:\\Program Files (x86)', 'Google', 'Chrome', 'Application', 'chrome.exe'),
|
||||
] : [
|
||||
'/usr/bin/google-chrome',
|
||||
'/usr/bin/chromium',
|
||||
'/usr/bin/chromium-browser',
|
||||
'/snap/bin/chromium',
|
||||
];
|
||||
for (const sc of systemCandidates) {
|
||||
if (fs.existsSync(sc)) return sc;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async function run() {
|
||||
const server = http.createServer((req, res) => {
|
||||
const url = new URL(req.url, 'http://127.0.0.1');
|
||||
if (url.pathname.startsWith('/api/email/read/')) {
|
||||
const uid = url.pathname.replace('/api/email/read/', '');
|
||||
if (uid === 'fail_uid' || uid === 'net_fail') {
|
||||
res.writeHead(500, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: 'Mailbox disconnected' }));
|
||||
return;
|
||||
}
|
||||
if (uid === 'crash_network') {
|
||||
req.destroy();
|
||||
return;
|
||||
}
|
||||
|
||||
const markSeen = url.searchParams.get('mark_seen');
|
||||
if (markSeen !== 'false') {
|
||||
res.writeHead(400, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: 'mark_seen must be false' }));
|
||||
return;
|
||||
}
|
||||
|
||||
let body_html = '';
|
||||
if (uid === 'img_test') {
|
||||
body_html = [
|
||||
'<div>',
|
||||
'<p>Email with diverse images</p>',
|
||||
'<!-- Inline image with alt text -->',
|
||||
'<img src="cid:logo123" alt="Company Brand Logo">',
|
||||
'<!-- Remote image with title -->',
|
||||
'<img src="https://example.com/tracker.png" title="Tracking Pixel">',
|
||||
'<!-- Image without alt or title -->',
|
||||
'<img src="https://example.com/no-alt.jpg">',
|
||||
'<!-- Interactive control that shouldn\'t be in print -->',
|
||||
'<button type="button" class="btn-action">Interactive Action</button>',
|
||||
'<!-- XSS payload -->',
|
||||
'<script>window.__pwned = true;</script>',
|
||||
'</div>'
|
||||
].join('\n');
|
||||
} else if (uid === 'long_msg') {
|
||||
body_html = `<div><p>${'A'.repeat(5000)}</p></div>`;
|
||||
} else {
|
||||
body_html = `<div><p>Standard message body for ${uid}</p></div>`;
|
||||
}
|
||||
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({
|
||||
uid: uid,
|
||||
subject: `Subject ${uid}`,
|
||||
from_name: 'Sender',
|
||||
from_address: 'sender@example.com',
|
||||
to: 'user@example.com',
|
||||
date: '2026-10-20',
|
||||
date_display: 'Oct 20, 2026',
|
||||
body: `Plain text for ${uid}`,
|
||||
body_html: body_html,
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
let filePath = path.join(ROOT, url.pathname);
|
||||
if (fs.existsSync(filePath) && fs.statSync(filePath).isFile()) {
|
||||
const ext = path.extname(filePath);
|
||||
const mime = ext === '.js' ? 'application/javascript' : ext === '.html' ? 'text/html' : ext === '.css' ? 'text/css' : 'text/plain';
|
||||
res.writeHead(200, { 'Content-Type': mime });
|
||||
res.end(fs.readFileSync(filePath));
|
||||
return;
|
||||
}
|
||||
res.writeHead(404);
|
||||
res.end('Not found');
|
||||
});
|
||||
|
||||
await new Promise(r => server.listen(0, '127.0.0.1', r));
|
||||
const port = server.address().port;
|
||||
const origin = `http://127.0.0.1:${port}`;
|
||||
|
||||
const launchOpts = { headless: true };
|
||||
const chromePath = findChromiumPath();
|
||||
if (chromePath) launchOpts.executablePath = chromePath;
|
||||
|
||||
let browser;
|
||||
try {
|
||||
browser = await chromium.launch(launchOpts);
|
||||
} catch (launchErr) {
|
||||
throw new Error(
|
||||
`Failed to launch Chromium browser: ${launchErr.message}\n` +
|
||||
'Missing or unexecutable Chromium binary.\n' +
|
||||
'To install Playwright Chromium, run:\n' +
|
||||
' npx playwright install chromium\n' +
|
||||
'Or set PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH to a valid Chromium executable.'
|
||||
);
|
||||
}
|
||||
const context = await browser.newContext();
|
||||
const page = await context.newPage();
|
||||
|
||||
const results = {};
|
||||
|
||||
try {
|
||||
await page.goto(`${origin}/static/index.html`);
|
||||
await page.evaluate(() => {
|
||||
if (!document.getElementById('email-lib-bulk-actions')) {
|
||||
const d = document.createElement('div'); d.id = 'email-lib-bulk-actions'; document.body.appendChild(d);
|
||||
}
|
||||
if (!document.getElementById('email-lib-selected-count')) {
|
||||
const d = document.createElement('div'); d.id = 'email-lib-selected-count'; document.body.appendChild(d);
|
||||
}
|
||||
});
|
||||
|
||||
// TEST 1: Full Print Image Sanitization and Replacement
|
||||
const printImageResult = await page.evaluate(async (base) => {
|
||||
window.API_BASE = base;
|
||||
const mod = await import(`${base}/static/js/emailLibrary/index.js`);
|
||||
const stateMod = await import(`${base}/static/js/emailLibrary/state.js`);
|
||||
|
||||
stateMod.state._selectedUids = new Set(['img_test']);
|
||||
stateMod.state._libEmails = [{ uid: 'img_test', subject: 'Image Test', folder: 'INBOX' }];
|
||||
|
||||
let iframePrintInvoked = false;
|
||||
let printDocHtml = '';
|
||||
let afterPrintTriggered = false;
|
||||
let hasPlaceholderElement = false;
|
||||
|
||||
const observer = new MutationObserver(() => {
|
||||
const frame = document.getElementById('email-browser-print-frame');
|
||||
if (frame && frame.contentWindow && !frame._patched) {
|
||||
frame._patched = true;
|
||||
frame.contentWindow.print = function() {
|
||||
iframePrintInvoked = true;
|
||||
printDocHtml = frame.contentDocument ? frame.contentDocument.documentElement.outerHTML : '';
|
||||
const body = frame.contentDocument ? frame.contentDocument.body : null;
|
||||
hasPlaceholderElement = !!body?.querySelector('.email-inline-image-placeholder');
|
||||
frame.contentWindow.dispatchEvent(new Event('afterprint'));
|
||||
afterPrintTriggered = true;
|
||||
};
|
||||
}
|
||||
});
|
||||
observer.observe(document.body, { childList: true });
|
||||
|
||||
await mod._exportSelectedEmailPdf();
|
||||
observer.disconnect();
|
||||
|
||||
const frameAfter = document.getElementById('email-browser-print-frame');
|
||||
|
||||
return {
|
||||
iframePrintInvoked,
|
||||
afterPrintTriggered,
|
||||
frameRemovedAfterPrint: frameAfter === null,
|
||||
hasLogoNotice: printDocHtml.includes('[Image: Company Brand Logo]'),
|
||||
hasTrackerNotice: printDocHtml.includes('[Image: Tracking Pixel]'),
|
||||
hasNoAltNotice: printDocHtml.includes('[Image omitted]'),
|
||||
hasPlaceholderElement,
|
||||
hasRemoteImageBlockedText: printDocHtml.includes('Remote image blocked'),
|
||||
hasDownloadButton: printDocHtml.includes('email-inline-image-download-btn'),
|
||||
hasInteractiveActionBtn: printDocHtml.includes('btn-action'),
|
||||
hasXSS: printDocHtml.includes('<script>') || printDocHtml.includes('window.__pwned'),
|
||||
};
|
||||
}, origin);
|
||||
|
||||
results.printImageSanitization = printImageResult;
|
||||
|
||||
// TEST 2: Partial Failure - 1 failure out of 3 emails
|
||||
const partialFailureResult = await page.evaluate(async (base) => {
|
||||
window.API_BASE = base;
|
||||
const mod = await import(`${base}/static/js/emailLibrary/index.js`);
|
||||
const stateMod = await import(`${base}/static/js/emailLibrary/state.js`);
|
||||
|
||||
const messages = [
|
||||
{ uid: 'msg1', subject: 'Email One', folder: 'INBOX' },
|
||||
{ uid: 'fail_uid', subject: 'Failing Email', folder: 'INBOX' },
|
||||
{ uid: 'msg2', subject: 'Email Two', folder: 'INBOX' },
|
||||
];
|
||||
stateMod.state._selectedUids = new Set(messages.map(m => m.uid));
|
||||
stateMod.state._libEmails = messages;
|
||||
|
||||
let printInvoked = false;
|
||||
let printDocHtml = '';
|
||||
|
||||
const observer = new MutationObserver(() => {
|
||||
const frame = document.getElementById('email-browser-print-frame');
|
||||
if (frame && frame.contentWindow && !frame._patched) {
|
||||
frame._patched = true;
|
||||
frame.contentWindow.print = function() {
|
||||
printInvoked = true;
|
||||
printDocHtml = frame.contentDocument ? frame.contentDocument.documentElement.outerHTML : '';
|
||||
frame.contentWindow.dispatchEvent(new Event('afterprint'));
|
||||
};
|
||||
}
|
||||
});
|
||||
observer.observe(document.body, { childList: true });
|
||||
|
||||
await mod._exportSelectedEmailPdf();
|
||||
observer.disconnect();
|
||||
|
||||
const toast = document.getElementById('toast')?.textContent || '';
|
||||
|
||||
return {
|
||||
printInvoked,
|
||||
toast,
|
||||
hasMsg1: printDocHtml.includes('Subject msg1'),
|
||||
hasMsg2: printDocHtml.includes('Subject msg2'),
|
||||
hasOmittedNotice: printDocHtml.includes('1 selected email was unavailable and omitted from this print'),
|
||||
};
|
||||
}, origin);
|
||||
|
||||
results.partialFailureTest = partialFailureResult;
|
||||
|
||||
// TEST 3: All emails fail
|
||||
const allFailedResult = await page.evaluate(async (base) => {
|
||||
window.API_BASE = base;
|
||||
const mod = await import(`${base}/static/js/emailLibrary/index.js`);
|
||||
const stateMod = await import(`${base}/static/js/emailLibrary/state.js`);
|
||||
|
||||
const messages = [
|
||||
{ uid: 'fail_uid', subject: 'Fail 1', folder: 'INBOX' },
|
||||
{ uid: 'net_fail', subject: 'Fail 2', folder: 'INBOX' },
|
||||
];
|
||||
stateMod.state._selectedUids = new Set(messages.map(m => m.uid));
|
||||
stateMod.state._libEmails = messages;
|
||||
|
||||
let printInvoked = false;
|
||||
const observer = new MutationObserver(() => {
|
||||
const frame = document.getElementById('email-browser-print-frame');
|
||||
if (frame && frame.contentWindow && !frame._patched) {
|
||||
frame._patched = true;
|
||||
frame.contentWindow.print = function() {
|
||||
printInvoked = true;
|
||||
};
|
||||
}
|
||||
});
|
||||
observer.observe(document.body, { childList: true });
|
||||
|
||||
let thrownError = null;
|
||||
try {
|
||||
await mod._exportSelectedEmailPdf();
|
||||
} catch (err) {
|
||||
thrownError = err.message || String(err);
|
||||
}
|
||||
observer.disconnect();
|
||||
|
||||
const frame = document.getElementById('email-browser-print-frame');
|
||||
const toast = document.getElementById('toast')?.textContent || '';
|
||||
|
||||
return {
|
||||
printInvoked,
|
||||
frameOpened: frame !== null,
|
||||
thrownError,
|
||||
toast,
|
||||
};
|
||||
}, origin);
|
||||
|
||||
results.allFailedTest = allFailedResult;
|
||||
|
||||
// TEST 4: Idempotent Iframe Cleanup & Window Afterprint
|
||||
const cleanupResult = await page.evaluate(async (base) => {
|
||||
window.API_BASE = base;
|
||||
const mod = await import(`${base}/static/js/emailLibrary/index.js`);
|
||||
const stateMod = await import(`${base}/static/js/emailLibrary/state.js`);
|
||||
|
||||
stateMod.state._selectedUids = new Set(['msg1']);
|
||||
stateMod.state._libEmails = [{ uid: 'msg1', subject: 'Cleanup Test', folder: 'INBOX' }];
|
||||
|
||||
const observer = new MutationObserver(() => {
|
||||
const frame = document.getElementById('email-browser-print-frame');
|
||||
if (frame && frame.contentWindow && !frame._patched) {
|
||||
frame._patched = true;
|
||||
frame.contentWindow.print = function() {
|
||||
window.dispatchEvent(new Event('afterprint'));
|
||||
};
|
||||
}
|
||||
});
|
||||
observer.observe(document.body, { childList: true });
|
||||
|
||||
await mod._exportSelectedEmailPdf();
|
||||
observer.disconnect();
|
||||
|
||||
const frameAfter = document.getElementById('email-browser-print-frame');
|
||||
return {
|
||||
frameRemovedViaWindowAfterprint: frameAfter === null,
|
||||
};
|
||||
}, origin);
|
||||
|
||||
results.cleanupTest = cleanupResult;
|
||||
|
||||
} finally {
|
||||
await browser.close();
|
||||
server.close();
|
||||
}
|
||||
|
||||
// Assertions
|
||||
const t1 = results.printImageSanitization;
|
||||
if (!t1.iframePrintInvoked) throw new Error('Assertion failed: print not invoked');
|
||||
if (!t1.frameRemovedAfterPrint) throw new Error('Assertion failed: frame not removed');
|
||||
if (!t1.hasLogoNotice) throw new Error('Assertion failed: [Image: Company Brand Logo] missing');
|
||||
if (!t1.hasTrackerNotice) throw new Error('Assertion failed: [Image: Tracking Pixel] missing');
|
||||
if (!t1.hasNoAltNotice) throw new Error('Assertion failed: [Image omitted] missing');
|
||||
if (t1.hasPlaceholderElement) throw new Error('Assertion failed: .email-inline-image-placeholder found in print doc');
|
||||
if (t1.hasRemoteImageBlockedText) throw new Error('Assertion failed: Remote image blocked leaked into print doc');
|
||||
if (t1.hasDownloadButton) throw new Error('Assertion failed: download button leaked into print doc');
|
||||
if (t1.hasInteractiveActionBtn) throw new Error('Assertion failed: interactive button leaked into print doc');
|
||||
if (t1.hasXSS) throw new Error('Assertion failed: script tag found');
|
||||
|
||||
const t2 = results.partialFailureTest;
|
||||
if (!t2.printInvoked) throw new Error('Assertion failed: partial print was not invoked');
|
||||
if (!t2.hasMsg1 || !t2.hasMsg2) throw new Error('Assertion failed: successful messages missing from print');
|
||||
if (!t2.hasOmittedNotice) throw new Error('Assertion failed: omitted notice banner missing');
|
||||
if (!t2.toast.includes('Prepared 2 emails for printing; 1 unavailable')) {
|
||||
throw new Error('Assertion failed: toast text mismatch: ' + t2.toast);
|
||||
}
|
||||
|
||||
const t3 = results.allFailedTest;
|
||||
if (t3.printInvoked) throw new Error('Assertion failed: print should NOT be invoked when all fail');
|
||||
if (t3.frameOpened) throw new Error('Assertion failed: print frame should NOT remain in DOM');
|
||||
|
||||
const t4 = results.cleanupTest;
|
||||
if (!t4.frameRemovedViaWindowAfterprint) throw new Error('Assertion failed: top window afterprint did not clean up frame');
|
||||
|
||||
console.log('ALL_BROWSER_TESTS_PASSED');
|
||||
}
|
||||
|
||||
run().catch(err => {
|
||||
console.error('Browser test failed:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,146 @@
|
||||
"""An exact approval must be able to run a bash/python call the request did not grant (#6635).
|
||||
|
||||
`ToolApprovalStore.create()` sealed the process identity only from the request
|
||||
authority's launch scopes, and those exist only for granted bash/python
|
||||
backends. For an ungranted call the sealing failed silently, so the approval
|
||||
card was created with no process identity and the executor refused it:
|
||||
"Approved action has no sealed process/job identity".
|
||||
"""
|
||||
import pytest
|
||||
|
||||
from src.agent_runtime.authority import OperationGrant, RequestAuthority
|
||||
from src.tool_approvals import ToolApprovalStore
|
||||
from src.tool_capabilities import ToolRunSecurityContext, capabilities_for_action
|
||||
from src.tool_types import ToolBlock
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def admin_owner(monkeypatch):
|
||||
from src import tool_execution as execution
|
||||
monkeypatch.setattr(execution, "_owner_is_admin", lambda owner: True)
|
||||
|
||||
|
||||
def _authority(workspace, *tools):
|
||||
return RequestAuthority("approval-request", "alice", "s", str(workspace),
|
||||
tuple(OperationGrant(t) for t in tools))
|
||||
|
||||
|
||||
def _approve(authority, tool, content, store=None):
|
||||
store = store or ToolApprovalStore()
|
||||
pending = store.create(owner=authority.owner, session_id=authority.session_id, origin_run_id="run",
|
||||
tool_name=tool, content=content, workspace=authority.workspace, request_authority=authority,
|
||||
external_untrusted_context_seen=True, capabilities=capabilities_for_action(tool, content))
|
||||
return pending, store.consume(pending.approval_id, decision="approve",
|
||||
owner=authority.owner, session_id=authority.session_id)
|
||||
|
||||
|
||||
async def _dispatch(authority, tool, content, exact):
|
||||
from src import tool_execution as execution
|
||||
return await execution.execute_tool_block(ToolBlock(tool, content), owner=authority.owner,
|
||||
session_id=authority.session_id, workspace=authority.workspace,
|
||||
request_authority=authority, exact_approval=exact,
|
||||
security_context=ToolRunSecurityContext(external_untrusted_context_seen=True))
|
||||
|
||||
|
||||
def test_approval_for_ungranted_bash_seals_a_launch_in_the_workspace(tmp_path):
|
||||
authority = _authority(tmp_path, "get_workspace")
|
||||
assert authority.launch_scopes == ()
|
||||
|
||||
pending, _ = _approve(authority, "bash", "printf approved-run")
|
||||
|
||||
bound = pending.process_operation
|
||||
assert bound is not None
|
||||
assert bound.launch is not None
|
||||
assert bound.launch.scope.root.path == str(tmp_path.resolve())
|
||||
assert bound.request_id == authority.request_id
|
||||
|
||||
|
||||
async def test_approved_ungranted_bash_runs_once(tmp_path):
|
||||
authority = _authority(tmp_path, "get_workspace")
|
||||
_, exact = _approve(authority, "bash", "printf approved-run")
|
||||
|
||||
description, result = await _dispatch(authority, "bash", "printf approved-run", exact)
|
||||
|
||||
assert result.get("failure_kind") != "resource_identity_denied", result
|
||||
assert result.get("exit_code") == 0, result
|
||||
assert "approved-run" in str(result.get("output", "")) + description
|
||||
|
||||
_, replay = await _dispatch(authority, "bash", "printf approved-run", exact)
|
||||
assert replay.get("blocked") is True, replay
|
||||
|
||||
|
||||
async def test_inherited_authority_still_cannot_widen_to_ungranted_bash(tmp_path):
|
||||
# A child (inherited) authority may only narrow its parent, so an approval
|
||||
# it proposes for an ungranted launch must stay unsealed and unrunnable.
|
||||
from dataclasses import replace
|
||||
child = replace(_authority(tmp_path, "get_workspace"), inherited=True)
|
||||
pending, exact = _approve(child, "bash", "printf approved-run")
|
||||
|
||||
assert pending.process_operation is None
|
||||
_, result = await _dispatch(child, "bash", "printf approved-run", exact)
|
||||
assert result.get("blocked") is True
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def agent_workspace():
|
||||
import os
|
||||
from src.constants import AGENT_WORKSPACE_DIR
|
||||
os.makedirs(AGENT_WORKSPACE_DIR, exist_ok=True)
|
||||
return os.path.realpath(AGENT_WORKSPACE_DIR)
|
||||
|
||||
|
||||
def test_approval_without_a_workspace_seals_a_launch_in_the_agent_workspace(agent_workspace):
|
||||
# Agent mode with no folder picked is the default. bash already runs in the
|
||||
# agent workspace there (agent_cwd), so that is where its approval seals.
|
||||
authority = _authority("", "get_workspace")
|
||||
|
||||
pending, _ = _approve(authority, "bash", "pwd")
|
||||
|
||||
bound = pending.process_operation
|
||||
assert bound is not None
|
||||
assert bound.launch is not None
|
||||
assert bound.launch.scope.root.path == agent_workspace
|
||||
|
||||
|
||||
async def test_approved_bash_without_a_workspace_runs_in_the_agent_workspace(agent_workspace):
|
||||
authority = _authority("", "get_workspace")
|
||||
_, exact = _approve(authority, "bash", "pwd")
|
||||
|
||||
description, result = await _dispatch(authority, "bash", "pwd", exact)
|
||||
|
||||
assert result.get("failure_kind") != "resource_identity_denied", result
|
||||
assert result.get("exit_code") == 0, result
|
||||
containment = result.get("containment") or {}
|
||||
assert containment.get("workspace", agent_workspace) == agent_workspace, containment
|
||||
|
||||
|
||||
async def test_inherited_authority_without_a_workspace_still_cannot_widen(agent_workspace):
|
||||
from dataclasses import replace
|
||||
child = replace(_authority("", "get_workspace"), inherited=True)
|
||||
pending, exact = _approve(child, "bash", "pwd")
|
||||
|
||||
assert pending.process_operation is None
|
||||
_, result = await _dispatch(child, "bash", "pwd", exact)
|
||||
assert result.get("blocked") is True
|
||||
|
||||
|
||||
async def test_approved_write_without_a_workspace_lands_in_the_agent_workspace(agent_workspace):
|
||||
# write_file had the same gap: with no workspace the approval sealed no
|
||||
# root, so "approve" ended in "no sealed resource identity".
|
||||
import os
|
||||
target = "approved-write-6635.txt"
|
||||
content = f"{target}\nhello"
|
||||
authority = _authority("", "get_workspace")
|
||||
pending, exact = _approve(authority, "write_file", content)
|
||||
assert pending.resource_operation is not None
|
||||
|
||||
_, result = await _dispatch(authority, "write_file", content, exact)
|
||||
|
||||
assert result.get("failure_kind") != "resource_identity_denied", result
|
||||
written = os.path.join(agent_workspace, target)
|
||||
try:
|
||||
with open(written) as fh:
|
||||
assert fh.read().strip() == "hello"
|
||||
finally:
|
||||
if os.path.exists(written):
|
||||
os.remove(written)
|
||||
@@ -0,0 +1,170 @@
|
||||
"""Exercise publication failure modes without changing the real static tree."""
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from tests.helpers.cli_loader import load_script
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
build = load_script("build_frontend.py")
|
||||
|
||||
|
||||
def project(tmp_path, entries=None):
|
||||
source = tmp_path / "frontend/src/shared/helper.js"
|
||||
source.parent.mkdir(parents=True)
|
||||
source.write_bytes(b"export const value = 1;\n")
|
||||
manifest = {"version": 1, "modules": entries if entries is not None else [
|
||||
{"source": "shared/helper.js", "deployment": "js/helper.js"}
|
||||
]}
|
||||
(tmp_path / "frontend/modules.json").write_text(json.dumps(manifest), encoding="utf-8")
|
||||
(tmp_path / ".gitattributes").write_text("* text=auto\nfrontend/src/** text eol=lf\nstatic/js/helper.js text eol=lf\n", encoding="utf-8")
|
||||
return source, tmp_path / "static/js/helper.js"
|
||||
|
||||
|
||||
def test_check_detects_missing_and_stale_output_without_writing(tmp_path):
|
||||
source, output = project(tmp_path)
|
||||
assert build.synchronize(tmp_path, check=True) == ["static/js/helper.js"]
|
||||
assert not output.exists()
|
||||
assert build.synchronize(tmp_path) == ["static/js/helper.js"]
|
||||
assert output.read_bytes() == source.read_bytes()
|
||||
source.write_bytes(b"export const value = 2;\n")
|
||||
assert build.synchronize(tmp_path, check=True) == ["static/js/helper.js"]
|
||||
assert output.read_bytes() == b"export const value = 1;\n"
|
||||
|
||||
|
||||
def test_publication_is_idempotent_and_preserves_unowned_assets(tmp_path):
|
||||
source, output = project(tmp_path)
|
||||
unowned = tmp_path / "static/js/notes.js"
|
||||
unowned.parent.mkdir(parents=True)
|
||||
unowned.write_bytes(b"legacy state owner\n")
|
||||
build.synchronize(tmp_path)
|
||||
timestamp = output.stat().st_mtime_ns
|
||||
assert build.synchronize(tmp_path) == []
|
||||
assert output.stat().st_mtime_ns == timestamp
|
||||
assert unowned.read_bytes() == b"legacy state owner\n"
|
||||
assert output.read_bytes() == source.read_bytes()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("bad", ["../outside.js", "/absolute.js", "shared/../helper.js", "shared\\helper.js", "shared/helper.js?v=1", "shared/helper.js#fragment", "shared/helper.ts"])
|
||||
def test_invalid_plan_does_not_partially_publish(tmp_path, bad):
|
||||
project(tmp_path, [
|
||||
{"source": "shared/helper.js", "deployment": "js/helper.js"},
|
||||
{"source": bad, "deployment": "js/other.js"},
|
||||
])
|
||||
with pytest.raises(ValueError, match="invalid module path"):
|
||||
build.synchronize(tmp_path)
|
||||
assert not (tmp_path / "static").exists()
|
||||
|
||||
|
||||
def test_duplicate_outputs_and_unmapped_sources_are_rejected(tmp_path):
|
||||
source, output = project(tmp_path)
|
||||
extra = source.with_name("extra.js")
|
||||
extra.write_bytes(b"export const value = 2;\n")
|
||||
with pytest.raises(ValueError, match="missing from manifest"):
|
||||
build.synchronize(tmp_path)
|
||||
manifest_path = tmp_path / "frontend/modules.json"
|
||||
manifest = json.loads(manifest_path.read_text())
|
||||
manifest["modules"].append({"source": "shared/extra.js", "deployment": "js/helper.js"})
|
||||
manifest_path.write_text(json.dumps(manifest))
|
||||
with pytest.raises(ValueError, match="must be unique"):
|
||||
build.synchronize(tmp_path)
|
||||
assert not output.exists()
|
||||
|
||||
|
||||
def test_symlink_output_cannot_overwrite_another_file(tmp_path):
|
||||
_, output = project(tmp_path)
|
||||
outside = tmp_path / "outside.js"
|
||||
outside.write_bytes(b"untouched\n")
|
||||
output.parent.mkdir(parents=True)
|
||||
output.symlink_to(outside)
|
||||
with pytest.raises(ValueError, match="escapes|symlink"):
|
||||
build.synchronize(tmp_path)
|
||||
assert outside.read_bytes() == b"untouched\n"
|
||||
|
||||
|
||||
def test_real_checkout_check_works_from_another_directory(tmp_path):
|
||||
result = subprocess.run([sys.executable, str(ROOT / "scripts/build_frontend.py"), "--check"], cwd=tmp_path, capture_output=True, text=True, timeout=20)
|
||||
assert result.returncode == 0, result.stdout + result.stderr
|
||||
assert (ROOT / "frontend/src/shared/actionMenuOrder.js").read_bytes() == (ROOT / "static/js/actionMenuOrder.js").read_bytes()
|
||||
|
||||
|
||||
def test_isolated_python_distribution_runs_without_source_or_node():
|
||||
result = subprocess.run([sys.executable, str(ROOT / "scripts/verify_frontend_runtime.py")], cwd=ROOT, capture_output=True, text=True, timeout=90)
|
||||
assert result.returncode == 0, result.stdout + result.stderr
|
||||
assert "PASS: isolated Python lifespan" in result.stdout
|
||||
|
||||
|
||||
@pytest.mark.parametrize("check", [False, True])
|
||||
@pytest.mark.parametrize("path,rule", [
|
||||
("frontend/src/shared/helper.js", "text"),
|
||||
("static/js/helper.js", "text"),
|
||||
("frontend/src/shared/helper.js", "text eol=crlf"),
|
||||
("static/js/helper.js", "text=auto eol=lf"),
|
||||
("static/js/helper.js", "-text eol=lf"),
|
||||
])
|
||||
def test_each_manifest_path_requires_lf_even_when_bytes_already_match(tmp_path, check, path, rule):
|
||||
source, output = project(tmp_path)
|
||||
output.parent.mkdir(parents=True)
|
||||
output.write_bytes(source.read_bytes())
|
||||
other = "static/js/helper.js" if path.startswith("frontend/") else "frontend/src/shared/helper.js"
|
||||
(tmp_path / ".gitattributes").write_text(f"* text=auto\n{other} text eol=lf\n{path} {rule}\n", encoding="utf-8")
|
||||
with pytest.raises(ValueError, match="require repository-owned.*" + path):
|
||||
build.synchronize(tmp_path, check=check)
|
||||
assert output.read_bytes() == source.read_bytes()
|
||||
|
||||
|
||||
def test_future_manifest_pair_cannot_publish_without_output_attributes(tmp_path):
|
||||
source, output = project(tmp_path)
|
||||
extra = source.with_name("future.js")
|
||||
extra.write_bytes(b"export const future = true;\n")
|
||||
manifest_file = tmp_path / "frontend/modules.json"
|
||||
manifest = json.loads(manifest_file.read_text())
|
||||
manifest["modules"].append({"source": "shared/future.js", "deployment": "js/future.js"})
|
||||
manifest_file.write_text(json.dumps(manifest))
|
||||
with pytest.raises(ValueError, match="static/js/future.js"):
|
||||
build.synchronize(tmp_path)
|
||||
assert not output.exists()
|
||||
assert not (tmp_path / "static/js/future.js").exists()
|
||||
with (tmp_path / ".gitattributes").open("a", encoding="utf-8") as attributes:
|
||||
attributes.write("static/js/future.js text eol=lf\n")
|
||||
assert build.synchronize(tmp_path) == ["static/js/helper.js", "static/js/future.js"]
|
||||
assert build.synchronize(tmp_path, check=True) == []
|
||||
|
||||
|
||||
def test_nested_git_attribute_rules_and_paths_with_spaces_are_supported(tmp_path):
|
||||
source, _ = project(tmp_path, [{"source": "shared/helper.js", "deployment": "js/feature space/helper.js"}])
|
||||
attributes = tmp_path / "static/js/feature space/.gitattributes"
|
||||
attributes.parent.mkdir(parents=True)
|
||||
attributes.write_text("*.js text eol=lf\n", encoding="utf-8")
|
||||
assert build.synchronize(tmp_path) == ["static/js/feature space/helper.js"]
|
||||
assert (attributes.parent / "helper.js").read_bytes() == source.read_bytes()
|
||||
attributes.write_text("*.js text eol=crlf\n", encoding="utf-8")
|
||||
with pytest.raises(ValueError, match="eol=crlf"):
|
||||
build.synchronize(tmp_path, check=True)
|
||||
|
||||
|
||||
def test_global_and_local_git_settings_cannot_mask_missing_repository_rule(tmp_path, monkeypatch):
|
||||
project(tmp_path)
|
||||
(tmp_path / ".gitattributes").write_text("frontend/src/** text eol=lf\n", encoding="utf-8")
|
||||
global_attributes = tmp_path / "global-attributes"
|
||||
global_attributes.write_text("* text eol=lf\n", encoding="utf-8")
|
||||
config = tmp_path / "global-config"
|
||||
config.write_text(f'[core]\n attributesFile = "{global_attributes.as_posix()}"\n autocrlf = true\n', encoding="utf-8")
|
||||
subprocess.run(["git", "init", "--quiet", str(tmp_path)], check=True, capture_output=True)
|
||||
(tmp_path / ".git/info/attributes").write_text("* text eol=lf\n", encoding="utf-8")
|
||||
monkeypatch.setenv("GIT_CONFIG_GLOBAL", str(config))
|
||||
monkeypatch.setenv("GIT_DIR", str(tmp_path / ".git"))
|
||||
monkeypatch.setenv("GIT_WORK_TREE", str(tmp_path))
|
||||
with pytest.raises(ValueError, match="static/js/helper.js"):
|
||||
build.synchronize(tmp_path, check=True)
|
||||
|
||||
|
||||
def test_attribute_enforcement_fails_clearly_when_development_git_is_missing(tmp_path, monkeypatch):
|
||||
_, output = project(tmp_path)
|
||||
monkeypatch.setattr(build.shutil, "which", lambda name: None)
|
||||
with pytest.raises(ValueError, match="Git is required for frontend publication"):
|
||||
build.synchronize(tmp_path)
|
||||
assert not output.exists()
|
||||
@@ -0,0 +1,63 @@
|
||||
"""Real Chromium proof at the existing /static/ service-worker scope.
|
||||
|
||||
This deliberately does not widen SW scope or claim offline SPA root reload.
|
||||
The helper is first evaluated offline, after install-time precaching only.
|
||||
"""
|
||||
import json
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
@pytest.mark.skipif(shutil.which("node") is None, reason="node binary not on PATH")
|
||||
def test_precached_helper_imports_offline_in_a_fresh_page_and_panels_stay_lazy():
|
||||
script = r'''
|
||||
import assert from 'node:assert/strict';
|
||||
import { chromium } from 'playwright';
|
||||
const origin = process.env.ODYSSEUS_TEST_STATIC_ORIGIN;
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
try {
|
||||
const context = await browser.newContext({ serviceWorkers: 'allow' });
|
||||
const page = await context.newPage();
|
||||
await page.goto(`${origin}/static/js/panels.js`);
|
||||
const installed = await page.evaluate(async () => {
|
||||
await navigator.serviceWorker.register('/static/sw.js?v=20260916autoscroll1');
|
||||
const registration = await navigator.serviceWorker.ready;
|
||||
if (!navigator.serviceWorker.controller) await new Promise(resolve => navigator.serviceWorker.addEventListener('controllerchange', resolve, { once: true }));
|
||||
const response = await caches.match('/static/js/actionMenuOrder.js');
|
||||
return { scope: registration.scope, cached: !!response && response.ok };
|
||||
});
|
||||
assert.equal(installed.scope, `${origin}/static/`);
|
||||
assert.equal(installed.cached, true);
|
||||
// New page gives a fresh ES-module map. No helper import occurred online.
|
||||
await context.setOffline(true);
|
||||
const offline = await context.newPage();
|
||||
await offline.goto(`${origin}/static/js/panels.js`);
|
||||
const requests = [];
|
||||
offline.on('request', request => requests.push(request.url()));
|
||||
const result = await offline.evaluate(async () => {
|
||||
const helper = await import('/static/js/actionMenuOrder.js');
|
||||
const again = await import('./actionMenuOrder.js');
|
||||
const panels = await import('/static/js/panels.js?v=20260909movepicklayer1');
|
||||
return {
|
||||
sameModule: helper === again,
|
||||
exports: Object.keys(helper).sort(),
|
||||
order: helper.orderActionMenuItems([{ label: 'Delete' }, { label: 'Rename' }]).map(i => i.label),
|
||||
panels: panels.panelNames(),
|
||||
};
|
||||
});
|
||||
assert.equal(result.sameModule, true);
|
||||
assert.deepEqual(result.order, ['Rename', 'Delete']);
|
||||
assert.ok(result.panels.includes('editor'));
|
||||
assert.equal(requests.some(url => url.includes('/galleryEditor.js')), false);
|
||||
console.log(JSON.stringify({ ...result, ...installed }));
|
||||
} finally { await browser.close(); }
|
||||
'''
|
||||
result = subprocess.run(["node", "--input-type=module", "--eval", script], cwd=ROOT, capture_output=True, text=True, timeout=90)
|
||||
assert result.returncode == 0, result.stdout + result.stderr
|
||||
data = json.loads(result.stdout)
|
||||
assert data["exports"] == ["SELECT_MENU_ICON", "actionMenuRank", "orderActionMenuItems"]
|
||||
@@ -0,0 +1,25 @@
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
GALLERY = ROOT / "static/js/gallery.js"
|
||||
|
||||
|
||||
def test_editor_landing_has_an_import_button_next_to_new_canvas():
|
||||
"""The editor landing must offer a direct "Import" action.
|
||||
|
||||
It sits in the same action row as "New canvas..." / "Browse gallery" and,
|
||||
on file pick, opens the editor on the chosen raster image (``openEditor``
|
||||
takes an image data URL as its first argument).
|
||||
"""
|
||||
src = GALLERY.read_text(encoding="utf-8")
|
||||
assert 'id="gallery-editor-import"' in src, "landing Import button missing"
|
||||
# Button order: New canvas → Import → Browse gallery.
|
||||
new_at = src.index('id="gallery-editor-new"')
|
||||
import_at = src.index('id="gallery-editor-import"')
|
||||
pick_at = src.index('id="gallery-editor-pick"')
|
||||
assert new_at < import_at < pick_at, "Import must sit next to New canvas"
|
||||
# Wiring opens the editor on the selected file's data URL.
|
||||
assert "getElementById('gallery-editor-import')?.addEventListener('click'" in src
|
||||
assert "readAsDataURL(file)" in src
|
||||
assert "await openEditor(reader.result, null, null, name)" in src
|
||||
@@ -60,7 +60,7 @@ def test_generated_image_headers_include_nosniff():
|
||||
assert generated_images.GENERATED_IMAGE_HEADERS["X-Content-Type-Options"] == "nosniff"
|
||||
assert (
|
||||
generated_images.GENERATED_IMAGE_HEADERS["Cache-Control"]
|
||||
== "public, max-age=31536000, immutable"
|
||||
== "private, no-cache, must-revalidate"
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,493 @@
|
||||
import os
|
||||
from unittest.mock import MagicMock, patch
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
from starlette.requests import Request
|
||||
|
||||
from app import serve_generated_image
|
||||
from src.generated_images import (
|
||||
GENERATED_IMAGE_HEADERS,
|
||||
register_generated_image,
|
||||
)
|
||||
|
||||
|
||||
class DummyState:
|
||||
def __init__(self, current_user=None, api_token=False, api_token_owner=None):
|
||||
self.current_user = current_user
|
||||
self.api_token = api_token
|
||||
self.api_token_owner = api_token_owner
|
||||
|
||||
|
||||
def _make_request(current_user=None, api_token=False, api_token_owner=None):
|
||||
req = MagicMock(spec=Request)
|
||||
req.state = DummyState(current_user, api_token, api_token_owner)
|
||||
return req
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_image(tmp_path, monkeypatch):
|
||||
from src import generated_images
|
||||
image_dir = tmp_path / "generated_images"
|
||||
image_dir.mkdir(parents=True, exist_ok=True)
|
||||
filename = "0123456789ab.png"
|
||||
img_file = image_dir / filename
|
||||
img_file.write_bytes(b"\x89PNG\r\n\x1a\nfake-image-bytes")
|
||||
monkeypatch.setattr(generated_images, "GENERATED_IMAGE_DIR", image_dir)
|
||||
return filename
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_authorized_cookie_session_owner(sample_image):
|
||||
"""Cookie session owner receives 200 for their owned image."""
|
||||
req = _make_request(current_user="alice")
|
||||
mock_row = MagicMock()
|
||||
mock_row.owner = "alice"
|
||||
mock_row.filename = sample_image
|
||||
|
||||
with patch("src.auth_helpers.auth_disabled", return_value=False), \
|
||||
patch("core.database.SessionLocal") as mock_sl:
|
||||
mock_db = MagicMock()
|
||||
mock_db.query.return_value.filter.return_value.first.return_value = mock_row
|
||||
mock_sl.return_value = mock_db
|
||||
|
||||
resp = await serve_generated_image(sample_image, req)
|
||||
assert resp.status_code == 200
|
||||
assert resp.headers["Cache-Control"] == "private, no-cache, must-revalidate"
|
||||
assert resp.headers["X-Content-Type-Options"] == "nosniff"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_authorized_api_token_owner(sample_image):
|
||||
"""API token request matching image owner receives 200."""
|
||||
req = _make_request(current_user="api", api_token=True, api_token_owner="alice")
|
||||
mock_row = MagicMock()
|
||||
mock_row.owner = "alice"
|
||||
mock_row.filename = sample_image
|
||||
|
||||
with patch("src.auth_helpers.auth_disabled", return_value=False), \
|
||||
patch("core.database.SessionLocal") as mock_sl:
|
||||
mock_db = MagicMock()
|
||||
mock_db.query.return_value.filter.return_value.first.return_value = mock_row
|
||||
mock_sl.return_value = mock_db
|
||||
|
||||
resp = await serve_generated_image(sample_image, req)
|
||||
assert resp.status_code == 200
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cross_user_denial_returns_404(sample_image):
|
||||
"""Bob attempting to access Alice's image receives 404 (preventing existence leak)."""
|
||||
req = _make_request(current_user="bob")
|
||||
mock_row = MagicMock()
|
||||
mock_row.owner = "alice"
|
||||
mock_row.filename = sample_image
|
||||
|
||||
with patch("src.auth_helpers.auth_disabled", return_value=False), \
|
||||
patch("core.database.SessionLocal") as mock_sl:
|
||||
mock_db = MagicMock()
|
||||
mock_db.query.return_value.filter.return_value.first.return_value = mock_row
|
||||
mock_sl.return_value = mock_db
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await serve_generated_image(sample_image, req)
|
||||
assert exc_info.value.status_code == 404
|
||||
assert exc_info.value.detail == "Image not found"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cross_user_api_token_denial_returns_404(sample_image):
|
||||
"""Bob's API token attempting to access Alice's image receives 404."""
|
||||
req = _make_request(current_user="api", api_token=True, api_token_owner="bob")
|
||||
mock_row = MagicMock()
|
||||
mock_row.owner = "alice"
|
||||
mock_row.filename = sample_image
|
||||
|
||||
with patch("src.auth_helpers.auth_disabled", return_value=False), \
|
||||
patch("core.database.SessionLocal") as mock_sl:
|
||||
mock_db = MagicMock()
|
||||
mock_db.query.return_value.filter.return_value.first.return_value = mock_row
|
||||
mock_sl.return_value = mock_db
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await serve_generated_image(sample_image, req)
|
||||
assert exc_info.value.status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unauthenticated_request_rejected(sample_image):
|
||||
"""Unauthenticated caller (no user identity) is rejected with 401 when auth is enabled."""
|
||||
req = _make_request(current_user=None)
|
||||
|
||||
with patch("src.auth_helpers.auth_disabled", return_value=False):
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await serve_generated_image(sample_image, req)
|
||||
assert exc_info.value.status_code == 401
|
||||
assert exc_info.value.detail == "Not authenticated"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_missing_gallery_row_fails_closed_404(sample_image):
|
||||
"""Image on disk with no gallery row, no registry, and no chat reference returns 404."""
|
||||
req = _make_request(current_user="bob")
|
||||
|
||||
with patch("src.auth_helpers.auth_disabled", return_value=False), \
|
||||
patch("core.database.SessionLocal") as mock_sl:
|
||||
mock_db = MagicMock()
|
||||
# No gallery row
|
||||
mock_db.query.return_value.filter.return_value.first.return_value = None
|
||||
# No chat message reference
|
||||
mock_db.query.return_value.join.return_value.filter.return_value.first.return_value = None
|
||||
mock_sl.return_value = mock_db
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await serve_generated_image(sample_image, req)
|
||||
assert exc_info.value.status_code == 404
|
||||
assert exc_info.value.detail == "Image not found"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_null_owner_metadata_fails_closed_404(sample_image):
|
||||
"""Gallery row with owner=None and no session link returns 404."""
|
||||
req = _make_request(current_user="bob")
|
||||
mock_row = MagicMock()
|
||||
mock_row.owner = None
|
||||
mock_row.session_id = None
|
||||
mock_row.filename = sample_image
|
||||
|
||||
with patch("src.auth_helpers.auth_disabled", return_value=False), \
|
||||
patch("core.database.SessionLocal") as mock_sl:
|
||||
mock_db = MagicMock()
|
||||
mock_db.query.return_value.filter.return_value.first.return_value = mock_row
|
||||
mock_sl.return_value = mock_db
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await serve_generated_image(sample_image, req)
|
||||
assert exc_info.value.status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_session_linked_null_owner_authorized(sample_image):
|
||||
"""Gallery row with owner=None resolves owner through session_id."""
|
||||
req_alice = _make_request(current_user="alice")
|
||||
req_bob = _make_request(current_user="bob")
|
||||
|
||||
mock_row = MagicMock()
|
||||
mock_row.owner = None
|
||||
mock_row.session_id = "sess-123"
|
||||
mock_row.filename = sample_image
|
||||
|
||||
mock_sess = MagicMock()
|
||||
mock_sess.owner = "alice"
|
||||
|
||||
with patch("src.auth_helpers.auth_disabled", return_value=False), \
|
||||
patch("core.database.SessionLocal") as mock_sl:
|
||||
mock_db = MagicMock()
|
||||
mock_db.query.return_value.filter.return_value.first.side_effect = [
|
||||
mock_row, mock_sess, # for Alice
|
||||
mock_row, mock_sess, # for Bob
|
||||
]
|
||||
mock_sl.return_value = mock_db
|
||||
|
||||
# Alice is the session owner -> 200
|
||||
resp = await serve_generated_image(sample_image, req_alice)
|
||||
assert resp.status_code == 200
|
||||
|
||||
# Bob is not the session owner -> 404
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await serve_generated_image(sample_image, req_bob)
|
||||
assert exc_info.value.status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_chat_referenced_rowless_image_authorized(sample_image):
|
||||
"""Rowless image referenced in a chat session owned by user is authorized."""
|
||||
req_alice = _make_request(current_user="alice")
|
||||
req_bob = _make_request(current_user="bob")
|
||||
|
||||
with patch("src.auth_helpers.auth_disabled", return_value=False), \
|
||||
patch("core.database.SessionLocal") as mock_sl:
|
||||
mock_db = MagicMock()
|
||||
# Row is None
|
||||
mock_db.query.return_value.filter.return_value.first.return_value = None
|
||||
# Alice's chat query returns a match, Bob's does not
|
||||
mock_db.query.return_value.join.return_value.filter.return_value.first.side_effect = [
|
||||
MagicMock(), # Alice has a match
|
||||
None, # Bob has no match
|
||||
]
|
||||
mock_sl.return_value = mock_db
|
||||
|
||||
resp = await serve_generated_image(sample_image, req_alice)
|
||||
assert resp.status_code == 200
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await serve_generated_image(sample_image, req_bob)
|
||||
assert exc_info.value.status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_freshly_generated_registry_preserves_access(sample_image):
|
||||
"""Freshly generated image registered to Alice is accessible before DB commit; Bob is denied."""
|
||||
req_alice = _make_request(current_user="alice")
|
||||
req_bob = _make_request(current_user="bob")
|
||||
|
||||
fresh_filename = "fedcba987654.png"
|
||||
from src import generated_images
|
||||
img_file = generated_images.GENERATED_IMAGE_DIR / fresh_filename
|
||||
img_file.write_bytes(b"\x89PNG\r\n\x1a\nfake-image-bytes")
|
||||
|
||||
register_generated_image(fresh_filename, "alice")
|
||||
|
||||
with patch("src.auth_helpers.auth_disabled", return_value=False), \
|
||||
patch("core.database.SessionLocal") as mock_sl:
|
||||
mock_sl.side_effect = AssertionError("DB should not be reached for registered owner")
|
||||
|
||||
# Alice retrieves without hitting DB
|
||||
resp = await serve_generated_image(fresh_filename, req_alice)
|
||||
assert resp.status_code == 200
|
||||
|
||||
# Bob is denied with 404
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await serve_generated_image(fresh_filename, req_bob)
|
||||
assert exc_info.value.status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_database_failure_fails_closed_503(sample_image):
|
||||
"""Database exception during ownership check raises HTTP 503."""
|
||||
req = _make_request(current_user="alice")
|
||||
|
||||
with patch("src.auth_helpers.auth_disabled", return_value=False), \
|
||||
patch("core.database.SessionLocal") as mock_sl:
|
||||
mock_db = MagicMock()
|
||||
mock_db.query.side_effect = RuntimeError("SQLite database locked or corrupted")
|
||||
mock_sl.return_value = mock_db
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await serve_generated_image(sample_image, req)
|
||||
assert exc_info.value.status_code == 503
|
||||
assert exc_info.value.detail == "Ownership verification unavailable"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_login_compatibility_auth_disabled(sample_image):
|
||||
"""When AUTH_ENABLED=false, existing images are served with 200, missing images return 404."""
|
||||
req = _make_request(current_user=None)
|
||||
|
||||
with patch("src.auth_helpers.auth_disabled", return_value=True):
|
||||
resp = await serve_generated_image(sample_image, req)
|
||||
assert resp.status_code == 200
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await serve_generated_image("aaaaaaaaaaaa.png", req)
|
||||
assert exc_info.value.status_code == 404
|
||||
|
||||
|
||||
def test_cache_headers_private_no_cache():
|
||||
"""Verify generated image response headers use private, no-cache directives."""
|
||||
assert GENERATED_IMAGE_HEADERS["Cache-Control"] == "private, no-cache, must-revalidate"
|
||||
assert GENERATED_IMAGE_HEADERS["X-Content-Type-Options"] == "nosniff"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_existence_oracle_eliminated_during_outage(sample_image):
|
||||
"""During a DB outage, both existing and non-existent files return 503 without revealing existence."""
|
||||
req = _make_request(current_user="alice")
|
||||
|
||||
with patch("src.auth_helpers.auth_disabled", return_value=False), \
|
||||
patch("core.database.SessionLocal") as mock_sl:
|
||||
mock_db = MagicMock()
|
||||
mock_db.query.side_effect = RuntimeError("DB down")
|
||||
mock_sl.return_value = mock_db
|
||||
|
||||
# Existing file -> 503
|
||||
with pytest.raises(HTTPException) as exc_existing:
|
||||
await serve_generated_image(sample_image, req)
|
||||
assert exc_existing.value.status_code == 503
|
||||
|
||||
# Non-existent file -> 503 (NOT 404)
|
||||
with pytest.raises(HTTPException) as exc_missing:
|
||||
await serve_generated_image("111122223333.png", req)
|
||||
assert exc_missing.value.status_code == 503
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mcp_image_generation_passes_owner_arg():
|
||||
"""_call_mcp_tool passes _odysseus_owner to MCP arguments for generate_image."""
|
||||
from src import tool_execution
|
||||
|
||||
mock_mcp = MagicMock()
|
||||
mock_mcp.call_tool = MagicMock()
|
||||
|
||||
async def fake_call(qualified, args):
|
||||
return {"stdout": "Generated image for: cat\nhttps://host/api/generated-image/1234567890ab.png", "exit_code": 0}
|
||||
|
||||
mock_mcp.call_tool.side_effect = fake_call
|
||||
|
||||
with patch.object(tool_execution, "get_mcp_manager", return_value=mock_mcp), \
|
||||
patch.object(tool_execution, "active_backend_operation", return_value=None):
|
||||
res = await tool_execution._call_mcp_tool("generate_image", "cat", owner="alice")
|
||||
assert res.get("image_url") == "https://host/api/generated-image/1234567890ab.png"
|
||||
call_args = mock_mcp.call_tool.call_args[0]
|
||||
assert call_args[1].get("_odysseus_owner") == "alice"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mcp_fallback_propagates_owner():
|
||||
"""When MCP manager is unavailable, _direct_fallback receives owner."""
|
||||
from src import tool_execution
|
||||
|
||||
with patch.object(tool_execution, "get_mcp_manager", return_value=None), \
|
||||
patch.object(tool_execution, "active_backend_operation", return_value=None), \
|
||||
patch.object(tool_execution, "_direct_fallback", return_value={"output": "ok", "exit_code": 0}) as mock_fallback:
|
||||
res = await tool_execution._call_mcp_tool("web_search", "query", owner="alice")
|
||||
assert res == {"output": "ok", "exit_code": 0}
|
||||
mock_fallback.assert_called_once_with("web_search", "query", progress_cb=None, owner="alice")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_trusted_owner_overrides_forged_owner_in_tool_args():
|
||||
"""Trusted owner overrides any forged _odysseus_owner in tool block content."""
|
||||
import json
|
||||
from src import tool_execution
|
||||
|
||||
mock_mcp = MagicMock()
|
||||
mock_mcp.call_tool = MagicMock()
|
||||
|
||||
async def fake_call(qualified, args):
|
||||
return {"stdout": "Generated image for: cat\n/api/generated-image/1234567890ab.png", "exit_code": 0}
|
||||
|
||||
mock_mcp.call_tool.side_effect = fake_call
|
||||
forged_content = json.dumps({"prompt": "cat", "_odysseus_owner": "bob"})
|
||||
|
||||
with patch.object(tool_execution, "get_mcp_manager", return_value=mock_mcp), \
|
||||
patch.object(tool_execution, "active_backend_operation", return_value=None):
|
||||
await tool_execution._call_mcp_tool("generate_image", forged_content, owner="alice")
|
||||
call_args = mock_mcp.call_tool.call_args[0]
|
||||
assert call_args[1].get("_odysseus_owner") == "alice"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_missing_or_empty_owner_removes_forged_owner_claim():
|
||||
"""When caller has no trusted owner (None or empty), forged _odysseus_owner is completely removed."""
|
||||
import json
|
||||
from src import tool_execution
|
||||
|
||||
mock_mcp = MagicMock()
|
||||
mock_mcp.call_tool = MagicMock()
|
||||
|
||||
async def fake_call(qualified, args):
|
||||
return {"stdout": "Generated image for: cat\n/api/generated-image/1234567890ab.png", "exit_code": 0}
|
||||
|
||||
mock_mcp.call_tool.side_effect = fake_call
|
||||
forged_content = json.dumps({"prompt": "cat", "_odysseus_owner": "bob"})
|
||||
|
||||
with patch.object(tool_execution, "get_mcp_manager", return_value=mock_mcp), \
|
||||
patch.object(tool_execution, "active_backend_operation", return_value=None):
|
||||
# Case 1: owner=None
|
||||
await tool_execution._call_mcp_tool("generate_image", forged_content, owner=None)
|
||||
call_args = mock_mcp.call_tool.call_args[0]
|
||||
assert "_odysseus_owner" not in call_args[1]
|
||||
|
||||
# Case 2: owner=""
|
||||
await tool_execution._call_mcp_tool("generate_image", forged_content, owner="")
|
||||
call_args = mock_mcp.call_tool.call_args[0]
|
||||
assert "_odysseus_owner" not in call_args[1]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_qualified_mcp_image_gen_sanitizes_forged_owner():
|
||||
"""Qualified mcp__image_gen__generate_image tool dispatches with sanitized owner."""
|
||||
import json
|
||||
from unittest.mock import AsyncMock
|
||||
from src import tool_execution
|
||||
from src.tool_types import ToolBlock
|
||||
|
||||
mock_mcp = MagicMock()
|
||||
mock_mcp.call_tool = AsyncMock(return_value={"stdout": "ok", "exit_code": 0})
|
||||
forged_content = json.dumps({"prompt": "cat", "_odysseus_owner": "bob"})
|
||||
|
||||
with patch.object(tool_execution, "get_mcp_manager", return_value=mock_mcp), \
|
||||
patch.object(tool_execution, "_owner_is_admin", return_value=True):
|
||||
# With trusted owner 'alice' -> sets 'alice'
|
||||
await tool_execution._execute_tool_block_impl(
|
||||
ToolBlock("mcp__image_gen__generate_image", forged_content),
|
||||
owner="alice",
|
||||
)
|
||||
call_args = mock_mcp.call_tool.call_args[0]
|
||||
assert call_args[1].get("_odysseus_owner") == "alice"
|
||||
|
||||
# Without owner -> removes forged owner
|
||||
await tool_execution._execute_tool_block_impl(
|
||||
ToolBlock("mcp__image_gen__generate_image", forged_content),
|
||||
owner=None,
|
||||
)
|
||||
call_args = mock_mcp.call_tool.call_args[0]
|
||||
assert "_odysseus_owner" not in call_args[1]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mcp_disconnected_fallback_does_not_resurrect_forged_owner():
|
||||
"""When MCP is disconnected, direct fallback receives verified owner context, not forged arguments."""
|
||||
import json
|
||||
from src import tool_execution
|
||||
|
||||
mock_mcp = MagicMock()
|
||||
mock_mcp.call_tool = MagicMock()
|
||||
|
||||
async def fake_call(qualified, args):
|
||||
return {"error": "Server not connected", "exit_code": 1}
|
||||
|
||||
mock_mcp.call_tool.side_effect = fake_call
|
||||
forged_content = json.dumps({"query": "python", "_odysseus_owner": "bob"})
|
||||
|
||||
with patch.object(tool_execution, "get_mcp_manager", return_value=mock_mcp), \
|
||||
patch.object(tool_execution, "active_backend_operation", return_value=None), \
|
||||
patch.object(tool_execution, "_direct_fallback", return_value={"output": "ok", "exit_code": 0}) as mock_fallback:
|
||||
# Trusted owner=None: mock_fallback receives owner=None
|
||||
await tool_execution._call_mcp_tool("web_search", forged_content, owner=None)
|
||||
mock_fallback.assert_called_with("web_search", forged_content, progress_cb=None, owner=None)
|
||||
|
||||
# Trusted owner="alice": mock_fallback receives owner="alice"
|
||||
await tool_execution._call_mcp_tool("web_search", forged_content, owner="alice")
|
||||
mock_fallback.assert_called_with("web_search", forged_content, progress_cb=None, owner="alice")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_image_gen_server_owner_sanitization():
|
||||
"""mcp_servers.image_gen_server sanitizes whitespace, empty, and None owner claims."""
|
||||
from unittest.mock import AsyncMock
|
||||
from mcp_servers import image_gen_server
|
||||
|
||||
with patch("src.database.SessionLocal") as mock_sl, \
|
||||
patch("httpx.AsyncClient") as mock_http, \
|
||||
patch("src.ai_interaction._resolve_model", return_value=("https://host/v1/chat/completions", "gpt-image-1", {})), \
|
||||
patch("src.settings.get_setting", return_value="dummy_key"), \
|
||||
patch("src.settings.load_settings", return_value={"image_model": "gpt-image-1"}), \
|
||||
patch("pathlib.Path.write_bytes"):
|
||||
mock_db = MagicMock()
|
||||
mock_sl.return_value = mock_db
|
||||
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.status_code = 200
|
||||
mock_resp.json.return_value = {
|
||||
"data": [{"b64_json": "aGVsbG8="}]
|
||||
}
|
||||
mock_client = MagicMock()
|
||||
mock_client.post = AsyncMock(return_value=mock_resp)
|
||||
mock_http.return_value.__aenter__.return_value = mock_client
|
||||
|
||||
# Call with whitespace owner: " " -> owner becomes None
|
||||
await image_gen_server.call_tool(
|
||||
"generate_image",
|
||||
{"prompt": "cat", "_odysseus_owner": " "},
|
||||
)
|
||||
added = mock_db.add.call_args[0][0]
|
||||
assert added.owner is None
|
||||
|
||||
# Call with valid owner: "alice" -> owner becomes "alice"
|
||||
await image_gen_server.call_tool(
|
||||
"generate_image",
|
||||
{"prompt": "cat", "_odysseus_owner": "alice"},
|
||||
)
|
||||
added = mock_db.add.call_args[0][0]
|
||||
assert added.owner == "alice"
|
||||
@@ -108,8 +108,12 @@ def _compact_prompt_for(monkeypatch, history):
|
||||
import src.model_context as model_context
|
||||
|
||||
monkeypatch.setattr(agent_runs, "is_active", lambda session_id: False)
|
||||
def fake_resolve_endpoint(kind, owner=None):
|
||||
def fake_resolve_endpoint(kind, owner=None, **kwargs):
|
||||
captured.setdefault("resolve_calls", []).append((kind, owner))
|
||||
if "fallback_url" in kwargs:
|
||||
captured.setdefault("fallback_calls", []).append(
|
||||
(kind, owner, kwargs)
|
||||
)
|
||||
return None, None, {}
|
||||
|
||||
monkeypatch.setattr(endpoint_resolver, "resolve_endpoint", fake_resolve_endpoint)
|
||||
@@ -127,6 +131,13 @@ def _compact_prompt_for(monkeypatch, history):
|
||||
assert response.status_code == 200
|
||||
assert response.json()["status"] == "ok"
|
||||
assert manager.saved is True
|
||||
assert any(
|
||||
kind == "utility"
|
||||
and kwargs.get("fallback_url") == session.endpoint_url
|
||||
and kwargs.get("fallback_model") == session.model
|
||||
and kwargs.get("fallback_headers") == session.headers
|
||||
for kind, _owner, kwargs in captured.get("fallback_calls", [])
|
||||
)
|
||||
return captured["messages"][1]["content"]
|
||||
|
||||
|
||||
@@ -151,8 +162,12 @@ def _registered_compact_response(monkeypatch, history, active_run=False):
|
||||
import src.llm_core as llm_core
|
||||
|
||||
monkeypatch.setattr(agent_runs, "is_active", lambda session_id: active_run)
|
||||
def fake_resolve_endpoint(kind, owner=None):
|
||||
def fake_resolve_endpoint(kind, owner=None, **kwargs):
|
||||
captured.setdefault("resolve_calls", []).append((kind, owner))
|
||||
if "fallback_url" in kwargs:
|
||||
captured.setdefault("fallback_calls", []).append(
|
||||
(kind, owner, kwargs)
|
||||
)
|
||||
return None, None, {}
|
||||
|
||||
monkeypatch.setattr(endpoint_resolver, "resolve_endpoint", fake_resolve_endpoint)
|
||||
@@ -237,6 +252,14 @@ def test_registered_manual_compact_route_uses_session_owner(monkeypatch):
|
||||
assert response.status_code == 200
|
||||
assert manager.replaced_messages is not None
|
||||
assert ("utility", "session-owner") in captured["resolve_calls"]
|
||||
assert any(
|
||||
kind == "utility"
|
||||
and owner == "session-owner"
|
||||
and kwargs.get("fallback_url") == manager.session.endpoint_url
|
||||
and kwargs.get("fallback_model") == manager.session.model
|
||||
and kwargs.get("fallback_headers") == manager.session.headers
|
||||
for kind, owner, kwargs in captured.get("fallback_calls", [])
|
||||
)
|
||||
|
||||
|
||||
def test_registered_manual_compact_route_rejects_active_agent_run(monkeypatch):
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
"""Invalid truncation requests must leave saved chat history intact."""
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from tests.helpers.database import isolated_session_database
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def history_client(tmp_path, monkeypatch):
|
||||
from core.models import ChatMessage
|
||||
from routes.history import history_routes
|
||||
from routes import session_routes
|
||||
|
||||
with isolated_session_database(tmp_path) as (manager, database):
|
||||
monkeypatch.setattr(history_routes, "SessionLocal", database.SessionLocal)
|
||||
monkeypatch.setattr(session_routes, "SessionLocal", database.SessionLocal)
|
||||
monkeypatch.setenv("AUTH_ENABLED", "true")
|
||||
manager.create_session("truncate-test", "Test", "http://example.test", "test", owner="alice")
|
||||
for text in ("first", "second", "third"):
|
||||
manager.add_message("truncate-test", ChatMessage("user", text))
|
||||
app = FastAPI()
|
||||
app.state.auth_manager = SimpleNamespace(is_configured=True)
|
||||
|
||||
@app.middleware("http")
|
||||
async def owner(request, call_next):
|
||||
request.state.current_user = request.headers.get("X-Test-Owner", "alice")
|
||||
return await call_next(request)
|
||||
|
||||
app.include_router(history_routes.setup_history_routes(manager))
|
||||
with TestClient(app, raise_server_exceptions=False) as client:
|
||||
yield client, manager, database
|
||||
|
||||
|
||||
def saved_messages(database):
|
||||
with database.SessionLocal() as db:
|
||||
return [row.content for row in db.query(database.ChatMessage).filter(
|
||||
database.ChatMessage.session_id == "truncate-test"
|
||||
).order_by(database.ChatMessage.timestamp).all()]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("payload", [
|
||||
{}, {"unrelated": 1}, {"before_msg_id": ""},
|
||||
{"keep_count": None}, {"keep_count": False}, {"keep_count": True},
|
||||
{"keep_count": 1.9}, {"keep_count": -1}, {"keep_count": "bad"},
|
||||
{"keep_count": []}, {"keep_count": {}},
|
||||
{"before_msg_id": True}, {"before_msg_id": ["message"]},
|
||||
{"before_msg_id": False}, {"before_msg_id": 0}, {"message_id": 0},
|
||||
[], None, "two", 2, False,
|
||||
])
|
||||
def test_invalid_target_preserves_history(history_client, payload):
|
||||
client, manager, database = history_client
|
||||
import json
|
||||
response = client.post("/api/session/truncate-test/truncate", content=json.dumps(payload), headers={"Content-Type": "application/json"})
|
||||
assert response.status_code == 400, response.text
|
||||
assert saved_messages(database) == ["first", "second", "third"]
|
||||
assert [m.content for m in manager.get_session("truncate-test").history] == ["first", "second", "third"]
|
||||
|
||||
|
||||
def test_malformed_json_preserves_history(history_client):
|
||||
client, _, database = history_client
|
||||
response = client.post("/api/session/truncate-test/truncate", content="{", headers={"Content-Type": "application/json"})
|
||||
assert response.status_code == 400, response.text
|
||||
assert saved_messages(database) == ["first", "second", "third"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("count,expected", [(0, []), (2, ["first", "second"]), (9, ["first", "second", "third"]), ("2", ["first", "second"])])
|
||||
def test_explicit_count_remains_supported(history_client, count, expected):
|
||||
client, _, database = history_client
|
||||
response = client.post("/api/session/truncate-test/truncate", json={"keep_count": count})
|
||||
assert response.status_code == 200, response.text
|
||||
assert saved_messages(database) == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize("field", ["before_msg_id", "message_id"])
|
||||
def test_message_id_boundary_remains_supported(history_client, field):
|
||||
client, _, database = history_client
|
||||
with database.SessionLocal() as db:
|
||||
message_id = db.query(database.ChatMessage).filter(database.ChatMessage.content == "second").one().id
|
||||
response = client.post("/api/session/truncate-test/truncate", json={field: message_id})
|
||||
assert response.status_code == 200, response.text
|
||||
assert saved_messages(database) == ["first"]
|
||||
|
||||
|
||||
def test_missing_message_id_does_not_clear_history(history_client):
|
||||
client, _, database = history_client
|
||||
response = client.post("/api/session/truncate-test/truncate", json={"before_msg_id": "missing"})
|
||||
assert response.status_code == 404
|
||||
assert saved_messages(database) == ["first", "second", "third"]
|
||||
|
||||
|
||||
def test_owner_gate_still_precedes_validation(history_client):
|
||||
client, _, database = history_client
|
||||
response = client.post("/api/session/truncate-test/truncate", json={}, headers={"X-Test-Owner": "bob"})
|
||||
assert response.status_code == 404
|
||||
assert saved_messages(database) == ["first", "second", "third"]
|
||||
@@ -0,0 +1,257 @@
|
||||
"""Regression tests for SSRF hardening on the manage_endpoints agent tool.
|
||||
|
||||
Verifies that the do_manage_endpoints tool function rejects user-supplied URLs
|
||||
that resolve to link-local (cloud metadata), non-HTTP schemes, or (when
|
||||
MODELENDPOINT_BLOCK_PRIVATE_IPS=true) private/loopback addresses -- applying
|
||||
the shared outbound-URL policy in check_outbound_url(). The HTTP route
|
||||
(POST /api/model-endpoints) receives the same guard in #6316.
|
||||
|
||||
The agent tool path bypasses the HTTP route entirely, so it needs its own
|
||||
validation. A prompt-injection payload smuggled into a skill, note, fetched
|
||||
page, or email could instruct the model to register a cloud-metadata or
|
||||
internal-network endpoint via this tool.
|
||||
"""
|
||||
import ipaddress
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import types
|
||||
|
||||
import pytest
|
||||
|
||||
import src.url_safety as _url_safety
|
||||
|
||||
_real_check_outbound_url = _url_safety.check_outbound_url
|
||||
|
||||
|
||||
def _fake_resolver(host):
|
||||
"""Resolver that returns deterministic IPs without real DNS."""
|
||||
_HOST_MAP = {
|
||||
"169.254.169.254": ["169.254.169.254"],
|
||||
"metadata.google.internal": ["169.254.169.254"],
|
||||
"localhost": ["127.0.0.1"],
|
||||
"127.0.0.1": ["127.0.0.1"],
|
||||
"0.0.0.0": ["0.0.0.0"],
|
||||
"example.com": ["93.184.216.34"],
|
||||
"192.168.1.1": ["192.168.1.1"],
|
||||
"10.0.0.1": ["10.0.0.1"],
|
||||
"api.openai.com": ["104.18.6.192"],
|
||||
}
|
||||
if host in _HOST_MAP:
|
||||
return _HOST_MAP[host]
|
||||
try:
|
||||
ipaddress.ip_address(host)
|
||||
return [host]
|
||||
except ValueError:
|
||||
pass
|
||||
raise OSError(f"Name or service not known: {host}")
|
||||
|
||||
|
||||
def _check_with_fake_resolver(url, *, block_private=False, resolver=None):
|
||||
"""Call the real check_outbound_url with our fake resolver."""
|
||||
return _real_check_outbound_url(url, block_private=block_private, resolver=_fake_resolver)
|
||||
|
||||
|
||||
def _make_content(base_url, *, name="", api_key=""):
|
||||
"""Build a JSON content string for the manage_endpoints 'add' action."""
|
||||
return json.dumps({
|
||||
"action": "add",
|
||||
"name": name,
|
||||
"base_url": base_url,
|
||||
"api_key": api_key,
|
||||
})
|
||||
|
||||
|
||||
class _FakeQuery:
|
||||
def __init__(self, model):
|
||||
self._model = model
|
||||
|
||||
def filter(self, *args):
|
||||
return self
|
||||
|
||||
def first(self):
|
||||
return None
|
||||
|
||||
def all(self):
|
||||
return []
|
||||
|
||||
|
||||
class _FakeDB:
|
||||
def __init__(self):
|
||||
self.added = []
|
||||
self.committed = False
|
||||
|
||||
def query(self, model):
|
||||
return _FakeQuery(model)
|
||||
|
||||
def add(self, obj):
|
||||
self.added.append(obj)
|
||||
|
||||
def commit(self):
|
||||
self.committed = True
|
||||
|
||||
def close(self):
|
||||
pass
|
||||
|
||||
|
||||
class _FakeModelEndpoint:
|
||||
def __init__(self, **kwargs):
|
||||
for k, v in kwargs.items():
|
||||
setattr(self, k, v)
|
||||
|
||||
|
||||
def _setup_mocks(monkeypatch, db):
|
||||
"""Set up faked core.database and patched check_outbound_url."""
|
||||
fake_core_db = types.ModuleType("core.database")
|
||||
fake_core_db.SessionLocal = lambda: db
|
||||
fake_core_db.ModelEndpoint = _FakeModelEndpoint
|
||||
monkeypatch.setitem(sys.modules, "core.database", fake_core_db)
|
||||
|
||||
monkeypatch.setattr(_url_safety, "check_outbound_url", _check_with_fake_resolver)
|
||||
|
||||
if "src.agent_tools.admin_tools" in sys.modules:
|
||||
monkeypatch.delitem(sys.modules, "src.agent_tools.admin_tools", raising=False)
|
||||
|
||||
|
||||
def _get_do_manage_endpoints():
|
||||
"""Import and return do_manage_endpoints from the reloaded admin_tools."""
|
||||
from src.agent_tools.admin_tools import do_manage_endpoints
|
||||
return do_manage_endpoints
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cloud_metadata_blocked(monkeypatch):
|
||||
"""Cloud metadata IP (169.254.169.254) must be rejected."""
|
||||
db = _FakeDB()
|
||||
_setup_mocks(monkeypatch, db)
|
||||
do_manage_endpoints = _get_do_manage_endpoints()
|
||||
content = _make_content("http://169.254.169.254/latest/meta-data/")
|
||||
result = await do_manage_endpoints(content, owner="admin")
|
||||
|
||||
assert result["exit_code"] == 1
|
||||
assert "Rejected" in result["error"]
|
||||
assert len(db.added) == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_non_http_scheme_blocked(monkeypatch):
|
||||
"""Non-HTTP schemes (file://, gopher://) must be rejected."""
|
||||
db = _FakeDB()
|
||||
_setup_mocks(monkeypatch, db)
|
||||
do_manage_endpoints = _get_do_manage_endpoints()
|
||||
content = _make_content("file:///etc/passwd")
|
||||
result = await do_manage_endpoints(content, owner="admin")
|
||||
|
||||
assert result["exit_code"] == 1
|
||||
assert "Rejected" in result["error"]
|
||||
assert len(db.added) == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_loopback_accepted_by_default(monkeypatch):
|
||||
"""Loopback addresses are accepted by default (local-first design).
|
||||
|
||||
The agent tool uses check_outbound_url directly, which allows
|
||||
loopback by default -- this is correct for local model endpoints
|
||||
(Ollama, llama.cpp, etc.). Loopback is only blocked in strict mode.
|
||||
"""
|
||||
db = _FakeDB()
|
||||
_setup_mocks(monkeypatch, db)
|
||||
do_manage_endpoints = _get_do_manage_endpoints()
|
||||
content = _make_content("http://localhost:11434/v1")
|
||||
result = await do_manage_endpoints(content, owner="admin")
|
||||
|
||||
assert result["exit_code"] == 0
|
||||
assert "Added endpoint" in result["response"]
|
||||
assert len(db.added) == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_strict_mode_blocks_private(monkeypatch):
|
||||
"""MODELENDPOINT_BLOCK_PRIVATE_IPS=true blocks private IPs."""
|
||||
db = _FakeDB()
|
||||
_setup_mocks(monkeypatch, db)
|
||||
monkeypatch.setenv("MODELENDPOINT_BLOCK_PRIVATE_IPS", "true")
|
||||
do_manage_endpoints = _get_do_manage_endpoints()
|
||||
content = _make_content("http://192.168.1.1:8080/v1")
|
||||
result = await do_manage_endpoints(content, owner="admin")
|
||||
|
||||
assert result["exit_code"] == 1
|
||||
assert "Rejected" in result["error"]
|
||||
assert len(db.added) == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_safe_url_accepted(monkeypatch):
|
||||
"""A safe public URL should be accepted and stored."""
|
||||
db = _FakeDB()
|
||||
_setup_mocks(monkeypatch, db)
|
||||
do_manage_endpoints = _get_do_manage_endpoints()
|
||||
content = _make_content("https://api.openai.com/v1", name="OpenAI")
|
||||
result = await do_manage_endpoints(content, owner="admin")
|
||||
|
||||
assert result["exit_code"] == 0
|
||||
assert "Added endpoint" in result["response"]
|
||||
assert len(db.added) == 1
|
||||
assert db.committed is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_empty_url_rejected(monkeypatch):
|
||||
"""An empty base_url must be rejected before SSRF check."""
|
||||
db = _FakeDB()
|
||||
_setup_mocks(monkeypatch, db)
|
||||
do_manage_endpoints = _get_do_manage_endpoints()
|
||||
content = _make_content("")
|
||||
result = await do_manage_endpoints(content, owner="admin")
|
||||
|
||||
assert result["exit_code"] == 1
|
||||
assert "base_url is required" in result["error"]
|
||||
assert len(db.added) == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_gopher_scheme_blocked(monkeypatch):
|
||||
"""gopher:// scheme must be rejected."""
|
||||
db = _FakeDB()
|
||||
_setup_mocks(monkeypatch, db)
|
||||
do_manage_endpoints = _get_do_manage_endpoints()
|
||||
content = _make_content("gopher://evil:6379/secret")
|
||||
result = await do_manage_endpoints(content, owner="admin")
|
||||
|
||||
assert result["exit_code"] == 1
|
||||
assert "Rejected" in result["error"]
|
||||
assert len(db.added) == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_error_not_leaked(monkeypatch):
|
||||
"""Error messages must not leak raw exception text."""
|
||||
db = _FakeDB()
|
||||
_setup_mocks(monkeypatch, db)
|
||||
do_manage_endpoints = _get_do_manage_endpoints()
|
||||
content = _make_content("http://169.254.169.254/latest/meta-data/")
|
||||
result = await do_manage_endpoints(content, owner="admin")
|
||||
|
||||
assert result["exit_code"] == 1
|
||||
assert "Rejected" in result["error"]
|
||||
assert "Traceback" not in result["error"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ipv6_loopback_accepted_by_default(monkeypatch):
|
||||
"""::1 must be treated as loopback (accepted local-first), not reserved.
|
||||
|
||||
Proves this branch carries the dfd1f7e7 url_safety fix after rebasing
|
||||
onto current dev: the pre-fix guard checked is_reserved before is_loopback
|
||||
and rejected ::1 even in default local-first mode.
|
||||
"""
|
||||
db = _FakeDB()
|
||||
_setup_mocks(monkeypatch, db)
|
||||
do_manage_endpoints = _get_do_manage_endpoints()
|
||||
content = _make_content("http://[::1]:11434/v1")
|
||||
result = await do_manage_endpoints(content, owner="admin")
|
||||
|
||||
assert result["exit_code"] == 0
|
||||
assert "Added endpoint" in result["response"]
|
||||
assert len(db.added) == 1
|
||||
@@ -0,0 +1,289 @@
|
||||
"""manage_tasks must apply the admin-only task-action policy.
|
||||
|
||||
``POST /api/tasks`` (routes/task/task_routes.py) and the scheduler
|
||||
(src/task_scheduler.py) both refuse ``action`` tasks whose action is in
|
||||
``ADMIN_ONLY_TASK_ACTIONS`` (run_local, run_script, ssh_command,
|
||||
cookbook_serve) for owners without admin task privileges — those actions run
|
||||
``subprocess.run(shell=True)`` or SSH with no sandbox. The model-facing
|
||||
``do_manage_tasks`` applied neither check, so an agent could store such a task
|
||||
on create/edit and resume/run it; the scheduler would only pause it at
|
||||
execution time. Regression for issue #6021: the tool now enforces the same
|
||||
policy on create, edit, resume and run, and keeps allowing admins and the
|
||||
non-privileged actions.
|
||||
"""
|
||||
|
||||
import json
|
||||
import tempfile
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from sqlalchemy.pool import NullPool
|
||||
|
||||
from tests.helpers.import_state import clear_fake_database_modules
|
||||
|
||||
clear_fake_database_modules()
|
||||
|
||||
import core.database as cdb
|
||||
from core.database import ScheduledTask
|
||||
import src.task_action_policy as policy_mod
|
||||
import src.tools.system as system_mod
|
||||
from src.tools.system import do_manage_tasks
|
||||
|
||||
_TMPDB = tempfile.NamedTemporaryFile(suffix=".db", delete=False)
|
||||
_ENGINE = create_engine(
|
||||
f"sqlite:///{_TMPDB.name}",
|
||||
connect_args={"check_same_thread": False},
|
||||
poolclass=NullPool,
|
||||
)
|
||||
cdb.Base.metadata.create_all(_ENGINE)
|
||||
_TS = sessionmaker(bind=_ENGINE, autoflush=False, autocommit=False)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _bind_session_factory(monkeypatch):
|
||||
# do_manage_tasks does `from core.database import SessionLocal` at call
|
||||
# time. Bind it per test (not at import) so this file stays
|
||||
# order-independent from sibling files that point it at their own DB.
|
||||
monkeypatch.setattr(cdb, "SessionLocal", _TS)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def privileges(monkeypatch):
|
||||
"""Drive owner_has_admin_task_privileges from a plain dict."""
|
||||
admins = {"root"}
|
||||
monkeypatch.setattr(
|
||||
policy_mod, "owner_has_admin_task_privileges",
|
||||
lambda owner: owner in admins,
|
||||
)
|
||||
return admins
|
||||
|
||||
|
||||
def _seed(task_id, owner, task_type="llm", action=None, status="active"):
|
||||
db = _TS()
|
||||
try:
|
||||
db.add(ScheduledTask(
|
||||
id=task_id, owner=owner, name=task_id, prompt="original",
|
||||
task_type=task_type, action=action, trigger_type="webhook",
|
||||
status=status, output_target="session",
|
||||
))
|
||||
db.commit()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def _get(task_id):
|
||||
db = _TS()
|
||||
try:
|
||||
return db.query(ScheduledTask).filter(ScheduledTask.id == task_id).first()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def _count(owner):
|
||||
db = _TS()
|
||||
try:
|
||||
return db.query(ScheduledTask).filter(ScheduledTask.owner == owner).count()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("action_name", sorted(policy_mod.ADMIN_ONLY_TASK_ACTIONS))
|
||||
async def test_create_admin_only_action_denied_for_non_admin(privileges, action_name):
|
||||
before = _count("alice")
|
||||
out = await do_manage_tasks(json.dumps({
|
||||
"action": "create", "task_type": "action", "action_name": action_name,
|
||||
"name": "pwn", "schedule": "daily", "scheduled_time": "09:00",
|
||||
}), owner="alice")
|
||||
assert out["exit_code"] == 1, out
|
||||
assert "requires admin privileges" in out["error"]
|
||||
assert _count("alice") == before
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_admin_only_action_allowed_for_admin(privileges):
|
||||
out = await do_manage_tasks(json.dumps({
|
||||
"action": "create", "task_type": "action", "action_name": "run_local",
|
||||
"name": "ok", "schedule": "daily", "scheduled_time": "09:00",
|
||||
}), owner="root")
|
||||
assert out["exit_code"] == 0, out
|
||||
assert _get(out["task_id"]).action == "run_local"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_regular_action_allowed_for_non_admin(privileges):
|
||||
out = await do_manage_tasks(json.dumps({
|
||||
"action": "create", "task_type": "action", "action_name": "tidy_sessions",
|
||||
"name": "ok", "schedule": "daily", "scheduled_time": "09:00",
|
||||
}), owner="alice")
|
||||
assert out["exit_code"] == 0, out
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_llm_task_allowed_for_non_admin(privileges):
|
||||
out = await do_manage_tasks(json.dumps({
|
||||
"action": "create", "task_type": "llm", "prompt": "summarize",
|
||||
"schedule": "daily", "scheduled_time": "09:00",
|
||||
}), owner="alice")
|
||||
assert out["exit_code"] == 0, out
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_edit_cannot_turn_llm_task_into_shell_action(privileges):
|
||||
_seed("alice-llm", "alice")
|
||||
out = await do_manage_tasks(json.dumps({
|
||||
"action": "edit", "task_id": "alice-llm",
|
||||
"task_type": "action", "action_name": "ssh_command",
|
||||
}), owner="alice")
|
||||
assert out["exit_code"] == 1, out
|
||||
assert "requires admin privileges" in out["error"]
|
||||
task = _get("alice-llm")
|
||||
assert task.task_type == "llm" and task.action is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_edit_cannot_swap_action_name_to_admin_only(privileges):
|
||||
_seed("alice-action", "alice", task_type="action", action="tidy_sessions")
|
||||
out = await do_manage_tasks(json.dumps({
|
||||
"action": "edit", "task_id": "alice-action", "action_name": "run_script",
|
||||
}), owner="alice")
|
||||
assert out["exit_code"] == 1, out
|
||||
assert _get("alice-action").action == "tidy_sessions"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_edit_of_unrelated_field_on_admin_only_task_denied_for_non_admin(privileges):
|
||||
# A task that slipped in before the policy (or via demotion of its owner)
|
||||
# cannot be kept alive by a non-admin: the route rejects the same edit.
|
||||
_seed("alice-legacy", "alice", task_type="action", action="run_local")
|
||||
out = await do_manage_tasks(json.dumps({
|
||||
"action": "edit", "task_id": "alice-legacy", "name": "renamed",
|
||||
}), owner="alice")
|
||||
assert out["exit_code"] == 1, out
|
||||
assert _get("alice-legacy").name == "alice-legacy"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resume_admin_only_task_denied_for_non_admin(privileges):
|
||||
_seed("alice-paused", "alice", task_type="action", action="run_local", status="paused")
|
||||
out = await do_manage_tasks(json.dumps({
|
||||
"action": "resume", "task_id": "alice-paused",
|
||||
}), owner="alice")
|
||||
assert out["exit_code"] == 1, out
|
||||
assert _get("alice-paused").status == "paused"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pause_admin_only_task_still_allowed(privileges):
|
||||
# Narrowing is always fine: a non-admin may pause a task they own.
|
||||
_seed("alice-active", "alice", task_type="action", action="run_local")
|
||||
out = await do_manage_tasks(json.dumps({
|
||||
"action": "pause", "task_id": "alice-active",
|
||||
}), owner="alice")
|
||||
assert out["exit_code"] == 0, out
|
||||
assert _get("alice-active").status == "paused"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_admin_only_task_denied_before_scheduler(privileges, monkeypatch):
|
||||
_seed("alice-run", "alice", task_type="action", action="ssh_command")
|
||||
calls = []
|
||||
|
||||
class _Sched:
|
||||
async def run_task_now(self, task_id):
|
||||
calls.append(task_id)
|
||||
return True
|
||||
|
||||
import src.event_bus as event_bus
|
||||
monkeypatch.setattr(event_bus, "get_task_scheduler", lambda: _Sched())
|
||||
out = await do_manage_tasks(json.dumps({
|
||||
"action": "run", "task_id": "alice-run",
|
||||
}), owner="alice")
|
||||
assert out["exit_code"] == 1, out
|
||||
assert calls == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_admin_only_task_allowed_for_admin(privileges, monkeypatch):
|
||||
_seed("root-run", "root", task_type="action", action="ssh_command")
|
||||
calls = []
|
||||
|
||||
class _Sched:
|
||||
async def run_task_now(self, task_id):
|
||||
calls.append(task_id)
|
||||
return True
|
||||
|
||||
import src.event_bus as event_bus
|
||||
monkeypatch.setattr(event_bus, "get_task_scheduler", lambda: _Sched())
|
||||
out = await do_manage_tasks(json.dumps({
|
||||
"action": "run", "task_id": "root-run",
|
||||
}), owner="root")
|
||||
assert out["exit_code"] == 0, out
|
||||
assert calls == ["root-run"]
|
||||
|
||||
|
||||
def test_policy_helper_uses_shared_constants(privileges):
|
||||
# The tool must consult the shared policy module, not a private copy of
|
||||
# the action names, so a new admin-only action is covered everywhere.
|
||||
assert system_mod._admin_task_action_denied("alice", "action", "run_local") is not None
|
||||
assert system_mod._admin_task_action_denied("root", "action", "run_local") is None
|
||||
assert system_mod._admin_task_action_denied("alice", "action", "tidy_sessions") is None
|
||||
assert system_mod._admin_task_action_denied("alice", "llm", "run_local") is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_admin_only_task_by_name_denied_for_non_admin(privileges, monkeypatch):
|
||||
_seed("alice-run-name", "alice", task_type="action", action="ssh_command")
|
||||
calls = []
|
||||
|
||||
class _Sched:
|
||||
async def run_task_now(self, task_id):
|
||||
calls.append(task_id)
|
||||
return True
|
||||
|
||||
import src.event_bus as event_bus
|
||||
monkeypatch.setattr(event_bus, "get_task_scheduler", lambda: _Sched())
|
||||
out = await do_manage_tasks(json.dumps({
|
||||
"action": "run", "name": "alice-run-name",
|
||||
}), owner="alice")
|
||||
assert out["exit_code"] == 1, out
|
||||
assert "requires admin privileges" in out["error"]
|
||||
assert calls == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_admin_only_action_seals_authority_for_admin(privileges):
|
||||
out = await do_manage_tasks(json.dumps({
|
||||
"action": "create", "task_type": "action", "action_name": "run_local",
|
||||
"name": "sealed-task", "schedule": "daily", "scheduled_time": "09:00",
|
||||
}), owner="root")
|
||||
assert out["exit_code"] == 0, out
|
||||
task = _get(out["task_id"])
|
||||
assert task.action == "run_local"
|
||||
assert task.request_authority_json is not None
|
||||
|
||||
|
||||
def test_real_owner_has_admin_task_privileges_branches(monkeypatch):
|
||||
"""Verify owner_has_admin_task_privileges without mocking."""
|
||||
# Auth disabled branch
|
||||
monkeypatch.setenv("AUTH_ENABLED", "false")
|
||||
assert policy_mod.owner_has_admin_task_privileges("alice") is True
|
||||
assert policy_mod.owner_has_admin_task_privileges(None) is True
|
||||
|
||||
# Auth enabled branch with configured AuthManager
|
||||
monkeypatch.delenv("AUTH_ENABLED", raising=False)
|
||||
|
||||
class FakeAuth:
|
||||
is_configured = True
|
||||
|
||||
def is_admin(self, u):
|
||||
return u == "admin"
|
||||
|
||||
import core.auth as c_auth
|
||||
monkeypatch.setattr(c_auth, "AuthManager", lambda: FakeAuth())
|
||||
|
||||
assert policy_mod.owner_has_admin_task_privileges("admin") is True
|
||||
assert policy_mod.owner_has_admin_task_privileges("alice") is False
|
||||
assert policy_mod.owner_has_admin_task_privileges("internal-tool") is True
|
||||
assert policy_mod.owner_has_admin_task_privileges(None) is False
|
||||
@@ -401,6 +401,51 @@ def test_url_outside_inline_code_is_still_autolinked(node_available):
|
||||
assert 'href="https://example.com/page"' in html
|
||||
|
||||
|
||||
def test_https_markdown_image_renders_inline_as_clickable_thumbnail(node_available):
|
||||
# Assistant `` images must render inline: https-only, lazy,
|
||||
# max-width-100% (via CSS), and click-to-open the full size in a new tab.
|
||||
html = _run_markdown_case(
|
||||
"Here is a map:\n\n"
|
||||
""
|
||||
)
|
||||
|
||||
assert html.count('class="chat-image"') == 1
|
||||
assert html.count('class="chat-inline-image"') == 1
|
||||
assert 'alt="Map of Sweden"' in html
|
||||
assert 'loading="lazy"' in html
|
||||
assert 'decoding="async"' in html
|
||||
assert 'referrerpolicy="no-referrer"' in html
|
||||
assert 'target="_blank"' in html
|
||||
assert 'rel="noopener noreferrer"' in html
|
||||
# The fallback caption (alt + URL) is emitted so a broken load never leaves
|
||||
# an empty gap; it stays hidden until the capture-phase error listener
|
||||
# toggles .image-broken.
|
||||
assert 'class="chat-image-fallback"' in html
|
||||
# The fallback caption carries the alt text plus the URL (the `//` is
|
||||
# entity-encoded so the bare-URL autolink pass can't nest another <a>).
|
||||
assert "Map of Sweden — https://upload.wikimedia.org" in html
|
||||
|
||||
|
||||
def test_http_markdown_image_is_downgraded_to_alt_text(node_available):
|
||||
# Non-https image URLs must not render as an <img> (https-only policy); the
|
||||
# alt text is kept as plain text so nothing is silently dropped.
|
||||
html = _run_markdown_case("Look:  done.")
|
||||
|
||||
assert "<img" not in html
|
||||
assert 'class="chat-image"' not in html
|
||||
assert "a http pic" in html
|
||||
|
||||
|
||||
def test_javascript_scheme_image_url_is_never_rendered(node_available):
|
||||
# A javascript: URL inside image syntax must be rejected outright, never
|
||||
# emitted as a src/href.
|
||||
html = _run_markdown_case(")")
|
||||
|
||||
assert "<img" not in html
|
||||
assert "javascript:" not in html
|
||||
assert 'class="chat-image"' not in html
|
||||
|
||||
|
||||
def test_inline_code_content_is_html_escaped(node_available):
|
||||
# Inline code is now extracted before the global escape pass, so it must be
|
||||
# escaped at extraction time (matching the fenced-code-block handling).
|
||||
|
||||
@@ -264,3 +264,231 @@ def test_launchers_export_the_port_they_serve_on():
|
||||
text = (_repo_root() / name).read_text(encoding="utf-8")
|
||||
assert uvicorn_flag in text, f"{name}: launcher no longer passes {uvicorn_flag}"
|
||||
assert export in text, f"{name}: serves on a port the app cannot read back"
|
||||
|
||||
|
||||
# ── Callback & Exchange Expired State Handling ───────────────────
|
||||
|
||||
def test_oauth_callback_expired_state_returns_dedicated_expired_page(monkeypatch):
|
||||
"""An unknown or expired generic MCP OAuth state in the callback must return
|
||||
the dedicated 'Authorization Expired' page instead of a bare 403 'Admin only'
|
||||
or a 404 'Server not found'."""
|
||||
from unittest.mock import MagicMock
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from fastapi.testclient import TestClient
|
||||
from routes.mcp import mcp_routes
|
||||
|
||||
# Simulate unauthenticated request that would fail admin check if invoked
|
||||
def _deny_admin(request):
|
||||
raise HTTPException(403, "Admin only")
|
||||
|
||||
monkeypatch.setattr(mcp_routes, "require_admin", _deny_admin)
|
||||
|
||||
# Empty DB / no matching server
|
||||
class FakeQuery:
|
||||
def filter(self, *a):
|
||||
return self
|
||||
|
||||
def first(self):
|
||||
return None
|
||||
|
||||
class FakeSession:
|
||||
def query(self, *a):
|
||||
return FakeQuery()
|
||||
|
||||
def close(self):
|
||||
pass
|
||||
|
||||
monkeypatch.setattr(mcp_routes, "SessionLocal", lambda: FakeSession())
|
||||
|
||||
app = FastAPI()
|
||||
manager = MagicMock()
|
||||
router = mcp_routes.setup_mcp_routes(manager)
|
||||
app.include_router(router)
|
||||
client = TestClient(app)
|
||||
|
||||
# Both code and state are provided, but state is not in _pending (expired/unknown)
|
||||
resp = client.get("/api/mcp/oauth/callback?code=some-code&state=expired-state-123")
|
||||
assert resp.status_code == 400
|
||||
assert "Authorization Expired" in resp.text
|
||||
assert (
|
||||
"This authorization round has expired. Press Reconnect in Odysseus and try again."
|
||||
in resp.text
|
||||
)
|
||||
# Ensure it did not return the bare 403 "Admin only"
|
||||
assert "Admin only" not in resp.text
|
||||
|
||||
|
||||
def test_oauth_callback_active_pending_state_succeeds_without_admin(monkeypatch):
|
||||
"""An active pending generic MCP OAuth state resolves without requiring admin auth."""
|
||||
from unittest.mock import MagicMock
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from fastapi.testclient import TestClient
|
||||
from routes.mcp import mcp_routes
|
||||
|
||||
# Admin check fails if invoked
|
||||
def _deny_admin(request):
|
||||
raise HTTPException(403, "Admin only")
|
||||
|
||||
monkeypatch.setattr(mcp_routes, "require_admin", _deny_admin)
|
||||
|
||||
app = FastAPI()
|
||||
manager = MagicMock()
|
||||
router = mcp_routes.setup_mcp_routes(manager)
|
||||
app.include_router(router)
|
||||
client = TestClient(app)
|
||||
|
||||
async def go():
|
||||
fut = mcp_oauth.register_pending("pending-round-1")
|
||||
resp = client.get("/api/mcp/oauth/callback?code=auth-code-xyz&state=pending-round-1")
|
||||
assert resp.status_code == 200
|
||||
assert "Authorization Successful" in resp.text
|
||||
code, state = await asyncio.wait_for(fut, timeout=1)
|
||||
assert code == "auth-code-xyz"
|
||||
assert state == "pending-round-1"
|
||||
|
||||
asyncio.run(go())
|
||||
|
||||
|
||||
def test_oauth_callback_legacy_google_server_requires_admin(monkeypatch):
|
||||
"""Legacy Google OAuth callbacks (where state is server_id of a server with
|
||||
oauth_config) still enforce require_admin."""
|
||||
import json
|
||||
from unittest.mock import MagicMock
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from fastapi.testclient import TestClient
|
||||
from routes.mcp import mcp_routes
|
||||
from core.database import McpServer
|
||||
|
||||
# Server exists in DB with Google oauth_config
|
||||
google_srv = McpServer(
|
||||
id="google-srv-1",
|
||||
name="Google Workspace",
|
||||
oauth_config=json.dumps({"provider": "google", "keys_file": "k.json", "token_file": "t.json"}),
|
||||
)
|
||||
|
||||
class FakeQuery:
|
||||
def filter(self, *a):
|
||||
return self
|
||||
|
||||
def first(self):
|
||||
return google_srv
|
||||
|
||||
class FakeSession:
|
||||
def query(self, *a):
|
||||
return FakeQuery()
|
||||
|
||||
def close(self):
|
||||
pass
|
||||
|
||||
monkeypatch.setattr(mcp_routes, "SessionLocal", lambda: FakeSession())
|
||||
|
||||
# When require_admin denies, 403 is raised for the legacy Google path
|
||||
def _deny_admin(request):
|
||||
raise HTTPException(403, "Admin only")
|
||||
|
||||
monkeypatch.setattr(mcp_routes, "require_admin", _deny_admin)
|
||||
|
||||
app = FastAPI()
|
||||
manager = MagicMock()
|
||||
router = mcp_routes.setup_mcp_routes(manager)
|
||||
app.include_router(router)
|
||||
client = TestClient(app)
|
||||
|
||||
resp = client.get("/api/mcp/oauth/callback?code=code-123&state=google-srv-1")
|
||||
assert resp.status_code == 403
|
||||
assert "Admin only" in resp.text
|
||||
|
||||
|
||||
def test_oauth_exchange_expired_generic_state_returns_expired_page(monkeypatch):
|
||||
"""Remote paste-back flow for a generic MCP server returns the dedicated expired
|
||||
page when the state is no longer pending, instead of 'No OAuth config.'."""
|
||||
from unittest.mock import MagicMock
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
from routes.mcp import mcp_routes
|
||||
from core.database import McpServer
|
||||
|
||||
monkeypatch.setattr(mcp_routes, "require_admin", lambda r: None)
|
||||
|
||||
# Server exists in DB without oauth_config (generic MCP server)
|
||||
generic_srv = McpServer(
|
||||
id="http-srv-1",
|
||||
name="Remote HTTP MCP",
|
||||
oauth_config=None,
|
||||
)
|
||||
|
||||
class FakeQuery:
|
||||
def filter(self, *a):
|
||||
return self
|
||||
|
||||
def first(self):
|
||||
return generic_srv
|
||||
|
||||
class FakeSession:
|
||||
def query(self, *a):
|
||||
return FakeQuery()
|
||||
|
||||
def close(self):
|
||||
pass
|
||||
|
||||
monkeypatch.setattr(mcp_routes, "SessionLocal", lambda: FakeSession())
|
||||
|
||||
app = FastAPI()
|
||||
manager = MagicMock()
|
||||
router = mcp_routes.setup_mcp_routes(manager)
|
||||
app.include_router(router)
|
||||
client = TestClient(app)
|
||||
|
||||
resp = client.post(
|
||||
"/api/mcp/oauth/exchange/http-srv-1",
|
||||
data={"callback_url": "http://localhost:7000/api/mcp/oauth/callback?code=code-1&state=expired-state"},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
assert "Authorization Expired" in resp.text
|
||||
assert (
|
||||
"This authorization round has expired. Press Reconnect in Odysseus and try again."
|
||||
in resp.text
|
||||
)
|
||||
assert "No OAuth config." not in resp.text
|
||||
|
||||
|
||||
def test_oauth_callback_missing_state_returns_malformed_error_not_expired():
|
||||
"""Missing state parameter in callback must return a bad-request error and must
|
||||
not be treated as an expired authorization round."""
|
||||
from unittest.mock import MagicMock
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
from routes.mcp import mcp_routes
|
||||
|
||||
app = FastAPI()
|
||||
manager = MagicMock()
|
||||
router = mcp_routes.setup_mcp_routes(manager)
|
||||
app.include_router(router)
|
||||
client = TestClient(app)
|
||||
|
||||
resp = client.get("/api/mcp/oauth/callback?code=some-code")
|
||||
assert resp.status_code == 400
|
||||
assert "No OAuth state parameter found in the callback URL." in resp.text
|
||||
assert "Authorization Expired" not in resp.text
|
||||
assert "This authorization round has expired" not in resp.text
|
||||
|
||||
|
||||
def test_oauth_callback_missing_code_returns_malformed_error_not_expired():
|
||||
"""Missing code parameter in callback must return a bad-request error and must
|
||||
not be treated as an expired authorization round."""
|
||||
from unittest.mock import MagicMock
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
from routes.mcp import mcp_routes
|
||||
|
||||
app = FastAPI()
|
||||
manager = MagicMock()
|
||||
router = mcp_routes.setup_mcp_routes(manager)
|
||||
app.include_router(router)
|
||||
client = TestClient(app)
|
||||
|
||||
resp = client.get("/api/mcp/oauth/callback?state=some-state")
|
||||
assert resp.status_code == 400
|
||||
assert "No authorization code found in the URL." in resp.text
|
||||
assert "Authorization Expired" not in resp.text
|
||||
assert "This authorization round has expired" not in resp.text
|
||||
|
||||
@@ -0,0 +1,296 @@
|
||||
"""Regression tests for SSRF hardening on model endpoint routes.
|
||||
|
||||
Verifies that POST /model-endpoints/test, POST /model-endpoints, and
|
||||
PATCH /model-endpoints/{ep_id} reject user-supplied URLs that resolve to
|
||||
link-local (cloud metadata), non-HTTP schemes, or (when
|
||||
MODELENDPOINT_BLOCK_PRIVATE_IPS=true) private/loopback addresses — matching
|
||||
the existing SSRF guard pattern in embedding_routes.py.
|
||||
|
||||
A stub resolver is injected so the loopback tests never touch real DNS.
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
# ── Resolver stub (same pattern as tests/test_url_safety.py) ────────────────
|
||||
def _resolver(mapping):
|
||||
"""Create a stub resolver for deterministic DNS in tests."""
|
||||
def resolve(host):
|
||||
if host in mapping:
|
||||
return mapping[host]
|
||||
raise OSError(f"unresolvable: {host}")
|
||||
return resolve
|
||||
|
||||
|
||||
LOCALHOST_V4 = _resolver({"localhost": ["127.0.0.1"]})
|
||||
LOCALHOST_V6 = _resolver({"localhost": ["::1"]})
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(monkeypatch):
|
||||
"""Build a TestClient with auth disabled and minimal DB stubs."""
|
||||
monkeypatch.setenv("AUTH_ENABLED", "false")
|
||||
monkeypatch.setenv("DATABASE_URL", "sqlite:///:memory:")
|
||||
|
||||
# Stub heavy deps that model_routes imports at module level.
|
||||
# Use monkeypatch so entries are removed after the test.
|
||||
for mod in [
|
||||
"src.tls_overrides",
|
||||
"src.llm_core",
|
||||
"src.settings",
|
||||
"src.constants",
|
||||
"core.log_safety",
|
||||
]:
|
||||
if mod not in sys.modules:
|
||||
monkeypatch.setitem(sys.modules, mod, MagicMock())
|
||||
|
||||
# Provide real endpoint_resolver so the route logic works
|
||||
if "src.endpoint_resolver" not in sys.modules or isinstance(
|
||||
sys.modules["src.endpoint_resolver"], MagicMock
|
||||
):
|
||||
er = MagicMock()
|
||||
er.normalize_base = lambda url: url
|
||||
er.resolve_url = lambda url: url
|
||||
er.build_chat_url = lambda base, **kw: base
|
||||
er.build_models_url = lambda base, **kw: base + "/v1/models"
|
||||
er.build_headers = lambda base, **kw: {}
|
||||
monkeypatch.setitem(sys.modules, "src.endpoint_resolver", er)
|
||||
|
||||
# Clear cached model_routes module so it picks up our stubs
|
||||
monkeypatch.delitem(sys.modules, "routes.model_routes", raising=False)
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
import routes.model_routes as mr
|
||||
from fastapi import FastAPI
|
||||
|
||||
app = FastAPI()
|
||||
router = mr.setup_model_routes(model_discovery=None)
|
||||
app.include_router(router)
|
||||
app.state.auth_enabled = False
|
||||
return TestClient(app, raise_server_exceptions=False)
|
||||
|
||||
|
||||
class TestModelEndpointSSRF:
|
||||
"""SSRF protection for POST /model-endpoints/test."""
|
||||
|
||||
def test_cloud_metadata_blocked(self, client):
|
||||
"""Cloud metadata IP (169.254.169.254) must be rejected."""
|
||||
resp = client.post(
|
||||
"/api/model-endpoints/test",
|
||||
data={"base_url": "http://169.254.169.254/latest/meta-data/"},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
assert "Rejected" in resp.json()["detail"]
|
||||
|
||||
def test_non_http_scheme_blocked(self, client):
|
||||
"""Non-HTTP schemes (file://, ftp://) must be rejected."""
|
||||
resp = client.post(
|
||||
"/api/model-endpoints/test",
|
||||
data={"base_url": "file:///etc/passwd"},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
assert "Rejected" in resp.json()["detail"]
|
||||
|
||||
def test_loopback_accepted_by_default(self, client):
|
||||
"""Local-first: loopback is accepted when block_private=False."""
|
||||
with patch("src.url_safety._default_resolver", LOCALHOST_V4):
|
||||
resp = client.post(
|
||||
"/api/model-endpoints/test",
|
||||
data={"base_url": "http://localhost:11434/v1"},
|
||||
)
|
||||
# Guard passes; probe fails (no server) but that is not SSRF.
|
||||
assert "Rejected endpoint URL" not in str(resp.json())
|
||||
|
||||
def test_loopback_rejected_in_strict_mode(self, client):
|
||||
"""Strict mode: loopback is rejected when block_private=True."""
|
||||
os.environ["MODELENDPOINT_BLOCK_PRIVATE_IPS"] = "true"
|
||||
try:
|
||||
with patch("src.url_safety._default_resolver", LOCALHOST_V4):
|
||||
resp = client.post(
|
||||
"/api/model-endpoints/test",
|
||||
data={"base_url": "http://localhost:11434/v1"},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
assert "Rejected" in resp.json()["detail"]
|
||||
finally:
|
||||
os.environ.pop("MODELENDPOINT_BLOCK_PRIVATE_IPS", None)
|
||||
|
||||
def test_strict_mode_blocks_direct_loopback_ip(self, client):
|
||||
"""Strict mode rejects 127.0.0.1 passed directly (no DNS needed)."""
|
||||
os.environ["MODELENDPOINT_BLOCK_PRIVATE_IPS"] = "true"
|
||||
try:
|
||||
resp = client.post(
|
||||
"/api/model-endpoints/test",
|
||||
data={"base_url": "http://127.0.0.1:11434/v1"},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
assert "Rejected" in resp.json()["detail"]
|
||||
finally:
|
||||
os.environ.pop("MODELENDPOINT_BLOCK_PRIVATE_IPS", None)
|
||||
|
||||
def test_ipv6_loopback_literal_accepted_by_default(self, client):
|
||||
"""::1 is loopback, not a disallowed 'reserved' address (dfd1f7e7)."""
|
||||
resp = client.post(
|
||||
"/api/model-endpoints/test",
|
||||
data={"base_url": "http://[::1]:11434/v1"},
|
||||
)
|
||||
assert "Rejected endpoint URL" not in str(resp.json())
|
||||
|
||||
def test_ipv6_loopback_localhost_accepted_by_default(self, client):
|
||||
"""Dual-stack hosts whose resolver answers ::1 first must work."""
|
||||
with patch("src.url_safety._default_resolver", LOCALHOST_V6):
|
||||
resp = client.post(
|
||||
"/api/model-endpoints/test",
|
||||
data={"base_url": "http://localhost:11434/v1"},
|
||||
)
|
||||
assert "Rejected endpoint URL" not in str(resp.json())
|
||||
|
||||
def test_ipv6_loopback_rejected_in_strict_mode(self, client):
|
||||
"""Strict mode still rejects ::1."""
|
||||
os.environ["MODELENDPOINT_BLOCK_PRIVATE_IPS"] = "true"
|
||||
try:
|
||||
resp = client.post(
|
||||
"/api/model-endpoints/test",
|
||||
data={"base_url": "http://[::1]:11434/v1"},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
assert "Rejected" in resp.json()["detail"]
|
||||
finally:
|
||||
os.environ.pop("MODELENDPOINT_BLOCK_PRIVATE_IPS", None)
|
||||
|
||||
|
||||
class TestModelEndpointCreateSSRF:
|
||||
"""SSRF protection for POST /model-endpoints."""
|
||||
|
||||
def test_cloud_metadata_blocked(self, client):
|
||||
"""Cloud metadata IP must be rejected on endpoint creation."""
|
||||
resp = client.post(
|
||||
"/api/model-endpoints",
|
||||
data={"base_url": "http://169.254.169.254/latest/meta-data/"},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
assert "Rejected" in resp.json()["detail"]
|
||||
|
||||
def test_non_http_scheme_blocked(self, client):
|
||||
"""Non-HTTP schemes must be rejected on endpoint creation."""
|
||||
resp = client.post(
|
||||
"/api/model-endpoints",
|
||||
data={"base_url": "gopher://evil:6379/secret"},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
assert "Rejected" in resp.json()["detail"]
|
||||
|
||||
def test_loopback_accepted_by_default(self, client):
|
||||
"""Local-first: loopback is accepted when block_private=False."""
|
||||
with patch("src.url_safety._default_resolver", LOCALHOST_V4):
|
||||
resp = client.post(
|
||||
"/api/model-endpoints",
|
||||
data={"base_url": "http://localhost:11434/v1"},
|
||||
)
|
||||
# Guard passes; route may 500 on DB/probe but never 400 SSRF.
|
||||
assert resp.status_code != 400
|
||||
body = resp.text if resp.status_code == 500 else str(resp.json())
|
||||
assert "Rejected endpoint URL" not in body
|
||||
|
||||
def test_loopback_rejected_in_strict_mode(self, client):
|
||||
"""Strict mode: loopback is rejected when block_private=True."""
|
||||
os.environ["MODELENDPOINT_BLOCK_PRIVATE_IPS"] = "true"
|
||||
try:
|
||||
with patch("src.url_safety._default_resolver", LOCALHOST_V4):
|
||||
resp = client.post(
|
||||
"/api/model-endpoints",
|
||||
data={"base_url": "http://localhost:11434/v1"},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
assert "Rejected" in resp.json()["detail"]
|
||||
finally:
|
||||
os.environ.pop("MODELENDPOINT_BLOCK_PRIVATE_IPS", None)
|
||||
|
||||
|
||||
class TestModelEndpointPatchSSRF:
|
||||
"""SSRF protection for PATCH /api/model-endpoints/{ep_id} (base_url).
|
||||
|
||||
The update path rewrites the stored base_url, which later fetches probe,
|
||||
list models and serve chat through — it must pass the same guard as the
|
||||
POST routes instead of bypassing it.
|
||||
"""
|
||||
|
||||
def _install_fake_endpoint(self, monkeypatch, base_url="http://localhost:9999/v1"):
|
||||
import routes.model_routes as mr
|
||||
|
||||
ep = SimpleNamespace(
|
||||
id="ep1",
|
||||
name="EP",
|
||||
base_url=base_url,
|
||||
api_key=None,
|
||||
is_enabled=True,
|
||||
owner=None,
|
||||
hidden_models=None,
|
||||
cached_models=None,
|
||||
pinned_models=None,
|
||||
model_type="llm",
|
||||
supports_tools=None,
|
||||
endpoint_kind="auto",
|
||||
model_refresh_mode="auto",
|
||||
model_refresh_interval=None,
|
||||
model_refresh_timeout=None,
|
||||
created_at=None,
|
||||
updated_at=None,
|
||||
)
|
||||
|
||||
class _FakeDb:
|
||||
def query(self, _model):
|
||||
return self
|
||||
|
||||
def filter(self, *_args, **_kwargs):
|
||||
return self
|
||||
|
||||
def first(self):
|
||||
return ep
|
||||
|
||||
def commit(self):
|
||||
pass
|
||||
|
||||
def close(self):
|
||||
pass
|
||||
|
||||
monkeypatch.setattr(mr, "SessionLocal", lambda: _FakeDb())
|
||||
monkeypatch.setattr(mr, "require_admin", lambda request: None)
|
||||
monkeypatch.setattr(mr, "_chatgpt_endpoint_visible", lambda ep_, request: True)
|
||||
return ep
|
||||
|
||||
def test_patch_cloud_metadata_blocked(self, client, monkeypatch):
|
||||
"""PATCH must reject the cloud metadata URL, not store it."""
|
||||
ep = self._install_fake_endpoint(monkeypatch)
|
||||
resp = client.patch(
|
||||
"/api/model-endpoints/ep1",
|
||||
json={"base_url": "http://169.254.169.254/latest/meta-data"},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
assert "Rejected endpoint URL" in resp.json()["detail"]
|
||||
assert ep.base_url == "http://localhost:9999/v1"
|
||||
|
||||
def test_patch_non_http_scheme_blocked(self, client, monkeypatch):
|
||||
"""PATCH must reject non-HTTP schemes."""
|
||||
ep = self._install_fake_endpoint(monkeypatch)
|
||||
resp = client.patch(
|
||||
"/api/model-endpoints/ep1",
|
||||
json={"base_url": "file:///etc/passwd"},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
assert "Rejected endpoint URL" in resp.json()["detail"]
|
||||
assert ep.base_url == "http://localhost:9999/v1"
|
||||
|
||||
def test_patch_valid_url_accepted(self, client, monkeypatch):
|
||||
"""Control: a legitimate local endpoint URL still updates fine."""
|
||||
ep = self._install_fake_endpoint(monkeypatch)
|
||||
resp = client.patch(
|
||||
"/api/model-endpoints/ep1",
|
||||
json={"base_url": "http://127.0.0.1:11434/v1"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert ep.base_url == "http://127.0.0.1:11434/v1"
|
||||
@@ -36,7 +36,26 @@ def test_task_parse_resolves_with_owner_scope():
|
||||
def test_history_compact_resolves_with_owner_scope():
|
||||
body = _function_source("routes/history/history_routes.py", "compact_session")
|
||||
assert "owner = effective_user(request)" in body
|
||||
assert 'resolve_endpoint("utility", owner=owner or None)' in body
|
||||
tree = ast.parse(body)
|
||||
calls = [
|
||||
node for node in ast.walk(tree)
|
||||
if isinstance(node, ast.Call)
|
||||
and isinstance(node.func, ast.Name)
|
||||
and node.func.id == "resolve_endpoint"
|
||||
and node.args
|
||||
and isinstance(node.args[0], ast.Constant)
|
||||
and node.args[0].value == "utility"
|
||||
]
|
||||
assert len(calls) == 1
|
||||
|
||||
kwargs = {
|
||||
kw.arg: ast.unparse(kw.value)
|
||||
for kw in calls[0].keywords
|
||||
}
|
||||
assert kwargs["owner"] == "owner or None"
|
||||
assert kwargs["fallback_url"] == "getattr(session, 'endpoint_url', None)"
|
||||
assert kwargs["fallback_model"] == "getattr(session, 'model', None)"
|
||||
assert kwargs["fallback_headers"] == "getattr(session, 'headers', None)"
|
||||
|
||||
|
||||
def test_note_reminder_synthesis_resolves_with_owner_scope():
|
||||
|
||||
@@ -1281,6 +1281,7 @@ def _patch_create_deps(monkeypatch, db, settings=None):
|
||||
monkeypatch.setattr(model_routes, "_save_settings", lambda s: settings.update(s))
|
||||
monkeypatch.setattr(endpoint_resolver, "resolve_url", lambda u: u)
|
||||
monkeypatch.setattr(auth_helpers, "get_current_user", lambda req: None)
|
||||
monkeypatch.setattr(model_routes, "check_outbound_url", lambda *a, **k: (True, "ok"))
|
||||
return settings
|
||||
|
||||
|
||||
@@ -2000,6 +2001,7 @@ def test_explicit_proxy_test_fetches_models_with_long_timeout(monkeypatch):
|
||||
|
||||
monkeypatch.setattr(model_routes, "require_admin", lambda request: None)
|
||||
monkeypatch.setattr(model_routes, "_ping_endpoint", lambda *a, **k: (_ for _ in ()).throw(AssertionError("ping should not run when model listing succeeds")))
|
||||
monkeypatch.setattr(model_routes, "check_outbound_url", lambda *a, **k: (True, "ok"))
|
||||
|
||||
calls = []
|
||||
returned = ["NVIDIA NIM/openai/gpt-oss-120b", "mistral/mistral-small-2603"]
|
||||
@@ -2038,6 +2040,7 @@ def test_explicit_proxy_add_fetches_and_caches_models_with_long_timeout(monkeypa
|
||||
monkeypatch.setattr(model_routes, "_save_settings", lambda settings: None)
|
||||
monkeypatch.setattr("src.auth_helpers.get_current_user", lambda request: None)
|
||||
monkeypatch.setattr(model_routes, "_ping_endpoint", lambda *a, **k: (_ for _ in ()).throw(AssertionError("ping should not run when model listing succeeds")))
|
||||
monkeypatch.setattr(model_routes, "check_outbound_url", lambda *a, **k: (True, "ok"))
|
||||
|
||||
calls = []
|
||||
returned = ["NVIDIA NIM/openai/gpt-oss-120b", "mistral/mistral-small-2603"]
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
from tests.helpers.notes_source import notes_source
|
||||
from pathlib import Path
|
||||
from tests.helpers.document_source import document_source
|
||||
|
||||
@@ -6,7 +7,7 @@ ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def test_opening_notes_minimizes_open_document():
|
||||
script = (ROOT / "static/js/notes.js").read_text()
|
||||
script = notes_source("ui/panel")
|
||||
|
||||
assert "window.documentModule?.isPanelOpen?.()" in script
|
||||
assert "window.documentModule.closePanel('down')" in script
|
||||
|
||||
@@ -2,12 +2,14 @@
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from tests.helpers.notes_source import notes_source
|
||||
|
||||
|
||||
_REPO = Path(__file__).resolve().parent.parent
|
||||
|
||||
|
||||
def test_notes_image_src_guard_rejects_script_capable_data_images():
|
||||
src = (_REPO / "static" / "js" / "notes.js").read_text(encoding="utf-8")
|
||||
src = notes_source("ui/text", "ui/editor", "ui/drawing")
|
||||
|
||||
assert "function _safeImgSrc(s)" in src
|
||||
assert r"^data:image\/(?:png|jpe?g|gif|webp);base64," in src
|
||||
@@ -15,7 +17,7 @@ def test_notes_image_src_guard_rejects_script_capable_data_images():
|
||||
|
||||
|
||||
def test_notes_linkify_escapes_href_attribute():
|
||||
src = (_REPO / "static" / "js" / "notes.js").read_text(encoding="utf-8")
|
||||
src = notes_source("ui/text", "ui/editor", "ui/drawing")
|
||||
|
||||
assert "function _attrEsc(s)" in src
|
||||
assert 'href="${_attrEsc(href)}"' in src
|
||||
@@ -23,7 +25,7 @@ def test_notes_linkify_escapes_href_attribute():
|
||||
|
||||
|
||||
def test_notes_edit_form_uses_safe_image_src_guard():
|
||||
src = (_REPO / "static" / "js" / "notes.js").read_text(encoding="utf-8")
|
||||
src = notes_source("ui/text", "ui/editor", "ui/drawing")
|
||||
|
||||
assert "let currentImageUrl = _safeImgSrc(note?.image_url || '');" in src
|
||||
assert "let _stashedDrawUrl = (type === 'draw') ? (_safeImgSrc(note?.image_url) || null) : null;" in src
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
from tests.helpers.notes_source import notes_source
|
||||
from pathlib import Path
|
||||
import re
|
||||
|
||||
@@ -10,23 +11,23 @@ ROOT = Path(__file__).resolve().parent.parent
|
||||
|
||||
def test_mobile_notes_checklist_rows_remain_tappable():
|
||||
css = app_css()
|
||||
notes = (ROOT / "static" / "js" / "notes.js").read_text(encoding="utf-8")
|
||||
notes = notes_source("ui/cardChecklist", "ui/checklist", "ui/editor", "ui/mobileEditor", "ui/mobileReorder", "ui/selection", "ui/drawing")
|
||||
|
||||
assert "body.notes-mobile-mode .note-card .note-checkbox {" in css
|
||||
assert "pointer-events: auto;" in css
|
||||
assert "touch-action: manipulation;" in css
|
||||
assert "body.notes-mobile-mode .note-card .note-checkbox,\nbody.notes-mobile-mode .note-card .note-checkbox-rm" not in css
|
||||
assert "if (_selectMode) return; // let card-level handler take over\n e.preventDefault();" in notes
|
||||
assert re.search(r"if \(state.selectMode\) return; // let card-level handler take over\n\s+e.preventDefault\(\);", notes)
|
||||
|
||||
|
||||
def test_mobile_edit_checklist_toggle_marks_form_dirty():
|
||||
notes = (ROOT / "static" / "js" / "notes.js").read_text(encoding="utf-8")
|
||||
notes = notes_source("ui/cardChecklist", "ui/checklist", "ui/editor", "ui/mobileEditor", "ui/mobileReorder", "ui/selection", "ui/drawing")
|
||||
|
||||
assert "container.dispatchEvent(new Event('input', { bubbles: true }));" in notes
|
||||
|
||||
|
||||
def test_completed_mobile_todo_uses_finish_action():
|
||||
notes = (ROOT / "static" / "js" / "notes.js").read_text(encoding="utf-8")
|
||||
notes = notes_source("ui/cardChecklist", "ui/checklist", "ui/editor", "ui/mobileEditor", "ui/mobileReorder", "ui/selection", "ui/drawing")
|
||||
|
||||
assert "const _isFinishedTodo = () =>" in notes
|
||||
assert "const label = _isFinishedTodo() ? 'Finish' : 'Archive';" in notes
|
||||
@@ -34,10 +35,10 @@ def test_completed_mobile_todo_uses_finish_action():
|
||||
|
||||
|
||||
def test_mobile_long_press_enters_select_mode_for_that_note():
|
||||
notes = (ROOT / "static" / "js" / "notes.js").read_text(encoding="utf-8")
|
||||
notes = notes_source("ui/cardChecklist", "ui/checklist", "ui/editor", "ui/mobileEditor", "ui/mobileReorder", "ui/selection", "ui/drawing")
|
||||
|
||||
assert "function _enterSelectMode(initialId = null)" in notes
|
||||
assert "_suppressNextNoteTapId = card.dataset.noteId;" in notes
|
||||
assert "app.setSuppressNextNoteTapId(card.dataset.noteId);" in notes
|
||||
assert "_enterSelectMode(card.dataset.noteId);" in notes
|
||||
|
||||
|
||||
@@ -60,7 +61,7 @@ def test_notes_mobile_checklist_asset_versions_are_bumped():
|
||||
|
||||
|
||||
def test_drawing_edits_mark_notes_dirty_and_keep_one_gallery_image():
|
||||
notes = (ROOT / "static" / "js" / "notes.js").read_text(encoding="utf-8")
|
||||
notes = notes_source("ui/cardChecklist", "ui/checklist", "ui/editor", "ui/mobileEditor", "ui/mobileReorder", "ui/selection", "ui/drawing")
|
||||
upload = (ROOT / "routes" / "upload_routes.py").read_text(encoding="utf-8")
|
||||
note_routes = (ROOT / "routes" / "note" / "note_routes.py").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
"""Execute the real Notes ESM graph and DOM with deterministic API responses."""
|
||||
import os
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def test_notes_reference_desktop_mobile_and_public_identity(tmp_path):
|
||||
env = dict(os.environ, NOTES_EVIDENCE_DIR=os.environ.get("NOTES_EVIDENCE_DIR", str(tmp_path)))
|
||||
result = subprocess.run(
|
||||
["node", "tests/browser/notesReference.mjs"], cwd=ROOT, env=env,
|
||||
capture_output=True, text=True, timeout=120,
|
||||
)
|
||||
assert result.returncode == 0, result.stdout + result.stderr
|
||||
|
||||
|
||||
def test_notes_complete_graph_imports_first_time_offline():
|
||||
result = subprocess.run(
|
||||
["node", "tests/browser/notesOffline.mjs"], cwd=ROOT,
|
||||
capture_output=True, text=True, timeout=90,
|
||||
)
|
||||
assert result.returncode == 0, result.stdout + result.stderr
|
||||
@@ -0,0 +1,20 @@
|
||||
"""Run deterministic Notes contracts in the authoritative pytest CI shards."""
|
||||
import os
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def test_notes_domain_application_infrastructure_and_compatibility_contracts():
|
||||
files = [
|
||||
"notesCharacterization", "notesRecurrence", "notesInfrastructure",
|
||||
"notesApplication", "notesReminders", "notesWorkflows", "notesDraftAutosave",
|
||||
"selectedNotesContext", "noteTestOracle",
|
||||
]
|
||||
result = subprocess.run(
|
||||
["node", "--test", *[f"tests/{name}.test.mjs" for name in files]], cwd=ROOT,
|
||||
env=dict(os.environ, ENDPOINT_URL="http://127.0.0.1:1"),
|
||||
capture_output=True, text=True, timeout=60,
|
||||
)
|
||||
assert result.returncode == 0, result.stdout + result.stderr
|
||||
@@ -8,32 +8,34 @@ reset is present in openPanel, beside the existing _editingId reset.
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
SRC = Path("static/js/notes.js").read_text(encoding="utf-8")
|
||||
from tests.helpers.notes_source import notes_source
|
||||
|
||||
SRC = notes_source("ui/panel", "ui/filters", "ui/cardRendering", "ui/cardInteractions", "ui/drawing", "infrastructure/storage", "application/state", "ui/toolbar")
|
||||
|
||||
|
||||
def _open_panel_body():
|
||||
start = SRC.index("export function openPanel()")
|
||||
rest = SRC[start + len("export function openPanel()"):]
|
||||
m = re.search(r"\n(?:export\s+)?(?:async\s+)?function ", rest)
|
||||
start = SRC.index("function openPanel()")
|
||||
rest = SRC[start + len("function openPanel()"):]
|
||||
m = re.search(r"\n\s*(?:export\s+)?(?:async\s+)?function ", rest)
|
||||
return rest[: m.start()] if m else rest
|
||||
|
||||
|
||||
def test_open_panel_resets_search_query():
|
||||
body = _open_panel_body()
|
||||
assert "_searchQuery = ''" in body, body[:400]
|
||||
assert "app.setSearchQuery('')" in body, body[:400]
|
||||
# reset must sit with the other open-time state resets, before render
|
||||
assert body.index("_searchQuery = ''") < body.index("_renderNotes") if "_renderNotes" in body else True
|
||||
assert body.index("app.setSearchQuery('')") < body.index("_renderNotes") if "_renderNotes" in body else True
|
||||
|
||||
|
||||
def test_module_still_declares_search_query():
|
||||
assert "let _searchQuery = ''" in SRC
|
||||
assert "searchQuery: ''" in SRC
|
||||
|
||||
|
||||
def test_notes_label_chips_can_be_collapsed_like_calendar_tags():
|
||||
assert "NOTES_LABELS_COLLAPSED_KEY = 'odysseus-notes-labels-collapsed'" in SRC
|
||||
assert "_labelsCollapsed = !_labelsCollapsed" in SRC
|
||||
assert "localStorage.setItem(NOTES_LABELS_COLLAPSED_KEY" in SRC
|
||||
assert "toggle.textContent = _labelsCollapsed ? '+ Tags' : '− Tags';" in SRC
|
||||
assert "app.setLabelsCollapsed(!state.labelsCollapsed)" in SRC
|
||||
assert "persistence.setItem(NOTES_LABELS_COLLAPSED_KEY" in SRC
|
||||
assert "toggle.textContent = state.labelsCollapsed ? '+ Tags' : '− Tags';" in SRC
|
||||
assert "bar.style.display = 'none';" in SRC
|
||||
|
||||
|
||||
|
||||
@@ -9,7 +9,9 @@ tracked-handler pattern in source.
|
||||
"""
|
||||
from pathlib import Path
|
||||
|
||||
SRC = Path("static/js/notes.js").read_text(encoding="utf-8")
|
||||
from tests.helpers.notes_source import notes_source
|
||||
|
||||
SRC = notes_source("ui/panel")
|
||||
|
||||
|
||||
def test_select_esc_listener_is_tracked_not_anonymous():
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
from tests.helpers.notes_source import notes_source
|
||||
from pathlib import Path
|
||||
from tests.helpers.stylesheets import app_css
|
||||
|
||||
@@ -17,31 +18,32 @@ def test_notes_sidebar_new_action_matches_library_structure_and_animation():
|
||||
|
||||
def test_notes_sidebar_new_action_opens_a_note_without_toggling_panel_closed():
|
||||
app = (ROOT / "static/app.js").read_text(encoding="utf-8")
|
||||
notes = (ROOT / "static/js/notes.js").read_text(encoding="utf-8")
|
||||
notes = notes_source("ui/quickAdd", "ui/cardInteractions", "ui/reminderDisplay", "index")
|
||||
|
||||
assert "event.stopPropagation()" in app[app.index("const notesNewNoteBtn"):]
|
||||
assert "notesModule?.newNote?.()" in app
|
||||
assert "export function newNote()" in notes
|
||||
assert "function newNote()" in notes
|
||||
assert "requestAnimationFrame(() => _createNote('note'))" in notes
|
||||
assert "openNote, newNote, openNotes" in notes
|
||||
assert "newNote: quickAdd.newNote" in notes
|
||||
assert "publicNewNote as newNote" in notes
|
||||
|
||||
|
||||
def test_sidebar_new_note_focuses_note_body_instead_of_title():
|
||||
notes = (ROOT / "static/js/notes.js").read_text(encoding="utf-8")
|
||||
notes = notes_source("ui/quickAdd", "ui/cardInteractions", "ui/reminderDisplay", "index")
|
||||
|
||||
assert "form.querySelector('.note-form-content')" in notes
|
||||
assert "initialField?.focus()" in notes
|
||||
|
||||
|
||||
def test_notes_reminder_dot_stays_before_new_note_action():
|
||||
notes = (ROOT / "static/js/notes.js").read_text(encoding="utf-8")
|
||||
notes = notes_source("ui/quickAdd", "ui/cardInteractions", "ui/reminderDisplay", "index")
|
||||
|
||||
assert "sidebarBtn.querySelector('#notes-new-note-btn')" in notes
|
||||
assert "sidebarBtn.insertBefore(dot, newNoteBtn || null)" in notes
|
||||
|
||||
|
||||
def test_checklist_preview_starts_at_first_unfinished_item():
|
||||
notes = (ROOT / "static/js/notes.js").read_text(encoding="utf-8")
|
||||
notes = notes_source("ui/quickAdd", "ui/cardInteractions", "ui/reminderDisplay", "index")
|
||||
start = notes.index("function _bindCardEvents(body)")
|
||||
end = notes.index("const tapToEditOrSelect", start)
|
||||
setup = notes[start:end]
|
||||
@@ -51,7 +53,7 @@ def test_checklist_preview_starts_at_first_unfinished_item():
|
||||
|
||||
|
||||
def test_checklist_preview_releases_scroll_at_its_boundaries():
|
||||
notes = (ROOT / "static/js/notes.js").read_text(encoding="utf-8")
|
||||
notes = notes_source("ui/quickAdd", "ui/cardInteractions", "ui/reminderDisplay", "index")
|
||||
css = app_css()
|
||||
|
||||
assert "el.addEventListener('wheel'" in notes
|
||||
|
||||
@@ -26,7 +26,7 @@ def _fast(fn, *args):
|
||||
return result
|
||||
|
||||
|
||||
# -- closed <think> blocks (skills_routes x4, memory_extractor.audit_memories) --
|
||||
# -- closed <think> blocks (skills.evaluation x4, memory_extractor.audit_memories) --
|
||||
|
||||
_THINK_REF = re.compile(r"<think(?:ing)?>[\s\S]*?</think(?:ing)?>", re.I)
|
||||
|
||||
@@ -51,7 +51,11 @@ def test_strip_closed_think_blocks_unclosed_opener_flood_is_linear():
|
||||
|
||||
|
||||
def test_lazy_think_regex_is_gone_from_llm_review_parsers():
|
||||
for rel in ("routes/skills_routes.py", "services/memory/memory_extractor.py"):
|
||||
for rel in (
|
||||
"routes/skills_routes.py",
|
||||
"services/skills/evaluation.py",
|
||||
"services/memory/memory_extractor.py",
|
||||
):
|
||||
source = (_ROOT / rel).read_text(encoding="utf-8")
|
||||
assert r"<think(?:ing)?>[\s\S]*?</think(?:ing)?>" not in source, rel
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
Both had two adjacent `\\s`-matching quantifiers that backtrack O(n^2) when the
|
||||
rest of the pattern fails on a whitespace flood:
|
||||
|
||||
* `routes/skills_routes.py` `_VERDICT_PROSE_RE` — `["\\'\\s:]*\\s*` (the class
|
||||
* `services/skills/evaluation.py` `_VERDICT_PROSE_RE`: `["\\'\\s:]*\\s*` (the class
|
||||
already matches `\\s`) over a teacher/verifier model's prose verdict.
|
||||
* `src/agent_loop.py` `_EXPLICIT_CONTINUATION_RE` — `\\s*[.!?]*\\s*$` over a
|
||||
user's terse reply.
|
||||
@@ -18,7 +18,7 @@ import time
|
||||
import pytest
|
||||
|
||||
import src.agent_tools # noqa: F401 (break agent_tools<->agent_loop import cycle)
|
||||
from routes.skills_routes import _VERDICT_PROSE_RE
|
||||
from services.skills.evaluation import _VERDICT_PROSE_RE
|
||||
from src.agent_loop import _EXPLICIT_CONTINUATION_RE, _is_explicit_continuation
|
||||
|
||||
_BUDGET_S = 4.0
|
||||
|
||||
@@ -117,7 +117,8 @@ async def test_remote_approval_is_bound_to_operation_and_request(manager, change
|
||||
content = '{"record":"two"}'
|
||||
else:
|
||||
authority = replace(authority, **{"request": {"request_id": "other"}, "owner": {"owner": "bob"},
|
||||
"session": {"session_id": "other"}}[change])
|
||||
"session": {"session_id": "other"}}[change],
|
||||
resource_roots=None) # re-seal the root for the changed owner
|
||||
_, result = await dispatch(authority, tool, content, exact_approval=exact,
|
||||
security_context=ToolRunSecurityContext(external_untrusted_context_seen=True))
|
||||
assert result["exit_code"] == 1
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
"""Guarded resolution of scheduled-research settings (#5049).
|
||||
|
||||
The scheduler reads ``research_max_tokens`` /
|
||||
``research_extraction_timeout_seconds`` / ``research_extraction_concurrency``
|
||||
from settings.json with bare ``int()`` casts. A hand-edited value like
|
||||
``{"research_max_tokens": "invalid"}`` used to raise inside
|
||||
``TaskScheduler._execute_research_task`` and kill every scheduled research
|
||||
run until settings.json was fixed by hand.
|
||||
|
||||
``_resolve_research_settings`` must fall back to the documented defaults
|
||||
(8192 / 90 / 3) for non-numeric input while preserving valid-value behavior
|
||||
(numeric strings still parse). ``src/research_handler.py`` already guards the
|
||||
same reads defensively; this is the scheduler's equivalent.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
from src.task_scheduler import _resolve_research_settings
|
||||
|
||||
|
||||
def _fake_get_setting(values):
|
||||
"""A get_setting stand-in backed by a plain dict."""
|
||||
|
||||
def get(key, default=None):
|
||||
return values.get(key, default)
|
||||
|
||||
return get
|
||||
|
||||
|
||||
def test_non_numeric_values_fall_back_to_documented_defaults():
|
||||
get = _fake_get_setting(
|
||||
{
|
||||
"research_max_tokens": "invalid",
|
||||
"research_extraction_timeout_seconds": "bogus",
|
||||
"research_extraction_concurrency": "not-a-number",
|
||||
}
|
||||
)
|
||||
|
||||
assert _resolve_research_settings(get) == (8192, 90, 3)
|
||||
|
||||
|
||||
def test_none_values_fall_back_to_documented_defaults():
|
||||
get = _fake_get_setting(
|
||||
{
|
||||
"research_max_tokens": None,
|
||||
"research_extraction_timeout_seconds": None,
|
||||
"research_extraction_concurrency": None,
|
||||
}
|
||||
)
|
||||
|
||||
assert _resolve_research_settings(get) == (8192, 90, 3)
|
||||
|
||||
|
||||
def test_numeric_strings_still_parse():
|
||||
get = _fake_get_setting(
|
||||
{
|
||||
"research_max_tokens": "4096",
|
||||
"research_extraction_timeout_seconds": "45",
|
||||
"research_extraction_concurrency": "2",
|
||||
}
|
||||
)
|
||||
|
||||
assert _resolve_research_settings(get) == (4096, 45, 2)
|
||||
|
||||
|
||||
def test_missing_keys_keep_documented_defaults():
|
||||
assert _resolve_research_settings(_fake_get_setting({})) == (8192, 90, 3)
|
||||
|
||||
|
||||
def test_valid_ints_pass_through():
|
||||
get = _fake_get_setting(
|
||||
{
|
||||
"research_max_tokens": 2048,
|
||||
"research_extraction_timeout_seconds": 120,
|
||||
"research_extraction_concurrency": 6,
|
||||
}
|
||||
)
|
||||
|
||||
assert _resolve_research_settings(get) == (2048, 120, 6)
|
||||
|
||||
|
||||
def test_non_finite_json_numbers_fall_back_individually():
|
||||
"""JSON exponent overflow produces infinity, which int() cannot convert."""
|
||||
keys = (
|
||||
"research_max_tokens",
|
||||
"research_extraction_timeout_seconds",
|
||||
"research_extraction_concurrency",
|
||||
)
|
||||
valid = (4096, 45, 2)
|
||||
defaults = (8192, 90, 3)
|
||||
|
||||
for non_finite in (json.loads("1e309"), json.loads("-1e309")):
|
||||
for index, key in enumerate(keys):
|
||||
values = dict(zip(keys, valid))
|
||||
values[key] = non_finite
|
||||
expected = list(valid)
|
||||
expected[index] = defaults[index]
|
||||
assert _resolve_research_settings(
|
||||
_fake_get_setting(values)
|
||||
) == tuple(expected)
|
||||
@@ -0,0 +1,106 @@
|
||||
"""A network failure must not be mistaken for rejection of an incident chain."""
|
||||
|
||||
import asyncio
|
||||
import importlib.util
|
||||
import io
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sys
|
||||
from types import ModuleType, SimpleNamespace
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
INCIDENT = json.loads((ROOT / "tests/fixtures/ca-trust/incident.json").read_text())
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def validator():
|
||||
checker = ModuleType("check_ca_trust")
|
||||
checker.check_bundle = lambda path: None
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
"searxng_validation", ROOT / "docker/searxng/validate_image.py"
|
||||
)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
with patch.dict(sys.modules, {"check_ca_trust": checker}):
|
||||
spec.loader.exec_module(module)
|
||||
module.__file__ = str(ROOT / "tests/fixtures/ca-trust/validate_image.py")
|
||||
return module
|
||||
|
||||
|
||||
class RequestFailure(Exception):
|
||||
def __init__(self, code):
|
||||
super().__init__(f"curl error {code}")
|
||||
self.code = code
|
||||
|
||||
|
||||
@pytest.mark.parametrize("code", [60, 6, 28, 35, None])
|
||||
def test_live_probe_requires_certificate_errors_for_every_host(validator, code):
|
||||
hosts = []
|
||||
|
||||
async def get(url, **kwargs):
|
||||
if url == "https://pypi.org/simple/certifi/":
|
||||
return SimpleNamespace(status_code=200)
|
||||
hosts.append(url.removeprefix("https://").removesuffix("/"))
|
||||
assert kwargs["allow_redirects"] is False
|
||||
if code is not None:
|
||||
raise RequestFailure(code)
|
||||
return SimpleNamespace(status_code=200)
|
||||
|
||||
client = SimpleNamespace(get=get, aclose=AsyncMock())
|
||||
factory = ModuleType("searx.network.client")
|
||||
|
||||
def new_client(**kwargs):
|
||||
assert kwargs["verify"] is True
|
||||
return client
|
||||
|
||||
factory.new_client = new_client
|
||||
errors = ModuleType("curl_cffi.requests.exceptions")
|
||||
errors.RequestException = RequestFailure
|
||||
modules = {"searx.network.client": factory, "curl_cffi.requests.exceptions": errors}
|
||||
with patch.dict(sys.modules, modules), patch.object(sys, "path", list(sys.path)):
|
||||
if code == 60:
|
||||
asyncio.run(validator.check_https())
|
||||
assert set(hosts) == set(INCIDENT["chains"])
|
||||
else:
|
||||
expected = "unexpectedly accepted" if code is None else "inconclusive"
|
||||
with pytest.raises(RuntimeError, match=expected):
|
||||
asyncio.run(validator.check_https())
|
||||
client.aclose.assert_awaited_once()
|
||||
|
||||
|
||||
def test_offline_probe_rejects_openssl_execution_failure(validator):
|
||||
result = SimpleNamespace(returncode=1, stderr="openssl: unknown option", stdout="")
|
||||
with patch.dict(validator.os.environ, {"SSL_CERT_FILE": "/unused.pem", "SSL_CERT_DIR": "/unused"}), \
|
||||
patch.object(validator.subprocess, "run", return_value=result):
|
||||
with pytest.raises(RuntimeError, match="not a certificate trust rejection"):
|
||||
validator.check_chains()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("results", [[{"title": "ordinary result"}], []])
|
||||
def test_running_probe_checks_existing_service_without_repair_or_second_boot(validator, tmp_path, results):
|
||||
certificate = tmp_path / "01234567.0"
|
||||
certificate.write_text("certificate double")
|
||||
(tmp_path / "unrelated").write_text("ignored")
|
||||
health = io.BytesIO(b"OK")
|
||||
health.status = 200
|
||||
search = io.BytesIO(json.dumps({"results": results}).encode())
|
||||
with patch.dict(validator.os.environ, {"SSL_CERT_DIR": str(tmp_path), "GRANIAN_PORT": "8080"}), \
|
||||
patch.object(validator.urllib.request, "build_opener") as opener, \
|
||||
patch.object(validator, "check_chains") as chains, \
|
||||
patch.object(validator, "check_bundle") as bundle, \
|
||||
patch.object(validator, "check_https", new_callable=AsyncMock) as https, \
|
||||
patch.object(validator.subprocess, "Popen") as boot:
|
||||
opener.return_value.open.side_effect = [health, search]
|
||||
if results:
|
||||
validator.check_running(live=True)
|
||||
else:
|
||||
with pytest.raises(RuntimeError, match="no results"):
|
||||
validator.check_running(live=True)
|
||||
assert opener.return_value.open.call_args_list[0].args == ("http://127.0.0.1:8080/healthz",)
|
||||
chains.assert_called_once_with()
|
||||
bundle.assert_called_once_with(certificate)
|
||||
https.assert_awaited_once()
|
||||
boot.assert_not_called()
|
||||
@@ -10,17 +10,22 @@ startup. This guards that the pin stays in place.
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
COMPOSE = Path(__file__).resolve().parent.parent / "docker-compose.yml"
|
||||
import yaml
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
COMPOSE = ROOT / "docker-compose.yml"
|
||||
|
||||
|
||||
def test_searxng_image_is_pinned_not_latest():
|
||||
text = COMPOSE.read_text(encoding="utf-8")
|
||||
m = re.search(r"image:\s*\S*searxng/searxng:(\S+)", text)
|
||||
assert m, "searxng image line not found in docker-compose.yml"
|
||||
tag = m.group(1)
|
||||
assert tag != "latest", (
|
||||
"SearXNG must be pinned, not ':latest' — odysseus startup depends on its "
|
||||
"healthcheck, so a broken upstream latest tag blocks the app (issue #1414)"
|
||||
)
|
||||
# A real version tag (date-based, e.g. 2026.5.31-7159b8aed), not a moving ref.
|
||||
assert re.match(r"\d{4}\.\d", tag), f"expected a versioned tag, got {tag!r}"
|
||||
image = yaml.safe_load(text)["services"]["searxng"]["image"]
|
||||
assert re.fullmatch(
|
||||
r"docker\.io/searxng/searxng:\d{4}\.\d[^\s@]*@sha256:[0-9a-f]{64}", image
|
||||
), f"SearXNG must use a versioned immutable upstream image: {image!r}"
|
||||
|
||||
|
||||
def test_all_standalone_compose_files_select_the_same_searxng_image():
|
||||
expected = yaml.safe_load(COMPOSE.read_text())["services"]["searxng"]["image"]
|
||||
for name in ("docker-compose.gpu-amd.yml", "docker-compose.gpu-nvidia.yml"):
|
||||
actual = yaml.safe_load((ROOT / name).read_text())["services"]["searxng"]["image"]
|
||||
assert actual == expected, f"{name} selects a different SearXNG image"
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
"""Native checks must validate the selected runtime without registry credentials."""
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def test_ci_checks_the_pinned_upstream_image_on_both_native_architectures():
|
||||
workflow = yaml.safe_load((ROOT / ".github/workflows/searxng-image.yml").read_text())
|
||||
events = workflow.get("on", workflow.get(True))
|
||||
assert "pull_request_target" not in events
|
||||
assert workflow["permissions"] == {"contents": "read"}
|
||||
assert set(workflow["jobs"]) == {"validate"}
|
||||
job = workflow["jobs"]["validate"]
|
||||
matrix = job["strategy"]["matrix"]["include"]
|
||||
assert {(row["arch"], row["runner"]) for row in matrix} == {
|
||||
("amd64", "ubuntu-latest"), ("arm64", "ubuntu-24.04-arm")
|
||||
}
|
||||
steps = job["steps"]
|
||||
assert not any("login-action" in step.get("uses", "") for step in steps)
|
||||
assert all(step.get("with", {}).get("persist-credentials") is False
|
||||
for step in steps if "actions/checkout@" in step.get("uses", ""))
|
||||
offline = next(step for step in steps if "docker/build-push-action@" in step.get("uses", ""))
|
||||
assert offline["with"]["network"] == "none"
|
||||
assert offline["with"]["build-args"] == "SEARXNG_IMAGE=${{ steps.image.outputs.image }}"
|
||||
live = next(step for step in steps if "validate-searxng-image.sh" in step.get("run", ""))
|
||||
assert live["env"]["SEARXNG_TEST_IMAGE"] == "${{ steps.image.outputs.image }}"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("failure,expected", [("none", 0), ("exec", 7), ("up", 1), ("existing-volume", 1)])
|
||||
def test_compose_probe_owns_only_its_temporary_resources(tmp_path, failure, expected):
|
||||
"""Exercise the real helper with a Docker double, including failure cleanup."""
|
||||
binary = tmp_path / "docker"
|
||||
calls = tmp_path / "calls.jsonl"
|
||||
binary.write_text('''#!/usr/bin/env python3
|
||||
import json, os, sys
|
||||
args = sys.argv[1:]
|
||||
with open(os.environ["DOCKER_CALLS"], "a") as log:
|
||||
log.write(json.dumps({"args": args, "volume": os.environ["SEARXNG_VALIDATION_VOLUME"], "secret": os.environ["SEARXNG_SECRET"]}) + "\\n")
|
||||
failure = os.environ["DOCKER_FAILURE"]
|
||||
if args[:2] == ["volume", "inspect"]:
|
||||
sys.exit(0 if failure == "existing-volume" else 1)
|
||||
if "exec" in args and failure == "exec":
|
||||
sys.exit(7)
|
||||
if "up" in args and failure == "up":
|
||||
sys.exit(9)
|
||||
''')
|
||||
binary.chmod(0o755)
|
||||
result = subprocess.run(["bash", str(ROOT / ".github/scripts/validate-searxng-image.sh"), "localhost/searxng:validated"], env={**os.environ, "PATH": str(tmp_path) + ":" + os.environ["PATH"], "DOCKER_CALLS": str(calls), "DOCKER_FAILURE": failure, "SEARXNG_SECRET": "operator-secret-must-not-propagate"}, capture_output=True, text=True)
|
||||
assert result.returncode == expected, result.stderr
|
||||
entries = [json.loads(line) for line in calls.read_text().splitlines()]
|
||||
assert all(entry["secret"] == "searxng-ci-validation-only" for entry in entries)
|
||||
commands = [entry["args"] for entry in entries]
|
||||
if failure == "existing-volume":
|
||||
assert len(commands) == 1
|
||||
return
|
||||
up = next(command for command in commands if "up" in command)
|
||||
assert up[up.index("--env-file") + 1] == "/dev/null"
|
||||
assert "--no-deps" in up and "--no-build" in up and "--wait" in up
|
||||
assert up[up.index("--pull") + 1] == "never"
|
||||
down = next(command for command in commands if "down" in command)
|
||||
assert "--volumes" not in down
|
||||
assert commands[-1] == ["volume", "rm", entries[-1]["volume"]]
|
||||
if failure != "up":
|
||||
execute = next(command for command in commands if "exec" in command)
|
||||
assert execute[-2:] == ["--running", "--live"]
|
||||
@@ -0,0 +1,28 @@
|
||||
"""Regression tests for untrusted selected-note ID parsing."""
|
||||
import json
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
from routes.chat_routes import _parse_selected_note_ids
|
||||
|
||||
|
||||
@pytest.mark.parametrize("raw,expected", [
|
||||
(None, []),
|
||||
("", []),
|
||||
("[]", []),
|
||||
('["one", "two", "one"]', ["one", "two"]),
|
||||
])
|
||||
def test_selected_note_ids_valid(raw, expected):
|
||||
assert _parse_selected_note_ids(raw) == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize("raw", [
|
||||
"42", "true", "null", '{"id":"a"}',
|
||||
'["one", 5]', '["one", null]', '["one", false]',
|
||||
'[""]', '[" "]', '["one", {}]',
|
||||
"not json", json.dumps(["a"] * 51),
|
||||
json.dumps(["a" * 129]), " " * 8193,
|
||||
])
|
||||
def test_selected_note_ids_rejected(raw):
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
_parse_selected_note_ids(raw)
|
||||
assert exc.value.status_code == 400
|
||||
@@ -0,0 +1,120 @@
|
||||
"""Frontend regression tests: verify settings save handlers check HTTP response status.
|
||||
|
||||
Locks in that static/js/settings.js verifies response.ok before displaying success,
|
||||
preventing 401, 403, 400, and 500 responses from reporting false success.
|
||||
"""
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
_REPO = Path(__file__).resolve().parent.parent
|
||||
_SETTINGS_JS = _REPO / "static" / "js" / "settings.js"
|
||||
_SETTINGS_DIR = _REPO / "static" / "js" / "settings"
|
||||
_SETTINGS_API_JS = _SETTINGS_DIR / "api.js"
|
||||
_HAS_NODE = shutil.which("node") is not None
|
||||
|
||||
|
||||
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
|
||||
def test_settings_js_syntax():
|
||||
"""Verify static/js/settings.js and settings/*.js modules are syntactically valid JavaScript."""
|
||||
js_files = [_SETTINGS_JS] + list(_SETTINGS_DIR.glob("*.js"))
|
||||
for js_file in js_files:
|
||||
proc = subprocess.run(
|
||||
["node", "--check", str(js_file)],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
cwd=str(_REPO),
|
||||
)
|
||||
assert proc.returncode == 0, f"JS syntax error in {js_file.name}:\n{proc.stderr}"
|
||||
|
||||
|
||||
def test_post_settings_helper_verifies_response_ok():
|
||||
"""Centralized postSettings in settings/api.js must verify response.ok and throw on failure."""
|
||||
assert _SETTINGS_API_JS.exists(), f"Expected {_SETTINGS_API_JS} to exist"
|
||||
api_content = _SETTINGS_API_JS.read_text(encoding="utf-8")
|
||||
|
||||
# Verify fetch to /api/auth/settings with POST method
|
||||
match = re.search(r"fetch\(['\"]/api/auth/settings['\"],\s*\{\s*method:\s*['\"]POST['\"]", api_content)
|
||||
assert match is not None, "Expected POST fetch to /api/auth/settings in settings/api.js"
|
||||
|
||||
# Verify .ok is checked within 300 characters of fetch
|
||||
window = api_content[match.start(): match.start() + 300]
|
||||
assert ".ok" in window, f"postSettings must check response.ok:\n{window}"
|
||||
|
||||
# Verify that it throws an error when !response.ok
|
||||
assert "throw error" in api_content or "throw new Error" in api_content, (
|
||||
"postSettings must throw when response is not ok"
|
||||
)
|
||||
|
||||
|
||||
def test_all_settings_save_handlers_route_through_post_settings():
|
||||
"""All settings save handlers in settings.js and settings/*.js must route saves via postSettings."""
|
||||
settings_content = _SETTINGS_JS.read_text(encoding="utf-8")
|
||||
module_contents = {p.name: p.read_text(encoding="utf-8") for p in _SETTINGS_DIR.glob("*.js")}
|
||||
|
||||
# Assert no unverified direct POST fetch to /api/auth/settings in settings.js
|
||||
direct_posts = list(re.finditer(r"fetch\(['\"]/api/auth/settings['\"],\s*\{\s*method:\s*['\"]POST['\"]", settings_content))
|
||||
assert len(direct_posts) == 0, (
|
||||
f"Found direct POST fetch to /api/auth/settings in settings.js; all writes must route through _postSettings"
|
||||
)
|
||||
|
||||
# Count _postSettings invocations across settings.js and modules
|
||||
post_settings_calls = len(re.findall(r"_postSettings\(", settings_content))
|
||||
for mod_name, mod_text in module_contents.items():
|
||||
if mod_name != "api.js":
|
||||
post_settings_calls += len(re.findall(r"_postSettings\(", mod_text))
|
||||
|
||||
assert post_settings_calls >= 15, (
|
||||
f"Expected at least 15 _postSettings calls across settings modules, found {post_settings_calls}"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
|
||||
def test_failed_save_shows_failure_in_simulated_dom():
|
||||
"""Verify that when fetch returns !res.ok, save logic catches and flags error."""
|
||||
js = """
|
||||
let msgText = '';
|
||||
let msgColor = '';
|
||||
const msg = {
|
||||
set textContent(v) { msgText = v; },
|
||||
get textContent() { return msgText; },
|
||||
style: { set color(v) { msgColor = v; }, get color() { return msgColor; } }
|
||||
};
|
||||
|
||||
async function save(fetchImpl) {
|
||||
try {
|
||||
var res = await fetchImpl('/api/auth/settings', { method: 'POST' });
|
||||
if (!res.ok) throw new Error('HTTP ' + res.status);
|
||||
msg.textContent = 'Saved'; msg.style.color = 'var(--fg)';
|
||||
} catch (e) {
|
||||
msg.textContent = 'Failed to save'; msg.style.color = 'var(--red)';
|
||||
}
|
||||
}
|
||||
|
||||
async function run() {
|
||||
// 1. Rejected fetch (403)
|
||||
await save(async () => ({ ok: false, status: 403 }));
|
||||
const rejectedResult = { text: msgText, color: msgColor };
|
||||
|
||||
// 2. Server error (500)
|
||||
await save(async () => ({ ok: false, status: 500 }));
|
||||
const serverErrResult = { text: msgText, color: msgColor };
|
||||
|
||||
// 3. Successful fetch (200)
|
||||
await save(async () => ({ ok: true, status: 200 }));
|
||||
const okResult = { text: msgText, color: msgColor };
|
||||
|
||||
console.log(JSON.stringify({ rejectedResult, serverErrResult, okResult }));
|
||||
}
|
||||
run();
|
||||
"""
|
||||
proc = subprocess.run(["node", "-e", js], capture_output=True, text=True, check=True)
|
||||
import json
|
||||
data = json.loads(proc.stdout)
|
||||
assert data["rejectedResult"] == {"text": "Failed to save", "color": "var(--red)"}
|
||||
assert data["serverErrResult"] == {"text": "Failed to save", "color": "var(--red)"}
|
||||
assert data["okResult"] == {"text": "Saved", "color": "var(--fg)"}
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
"""index_for() local-model gating: a `remote-only` tag must hide a skill
|
||||
from local/private-endpoint models while staying visible to remote API
|
||||
models and to callers that don't know the endpoint at all.
|
||||
|
||||
Big on-demand reference lookups overwhelm small local contexts, so the
|
||||
skill must vanish from the injected index there. local_model=None (API
|
||||
listings, chat preface) means "don't gate" — same contract as
|
||||
active_toolsets=None.
|
||||
"""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
# ── module-load stubbing (matches other tests in this repo) ──────────
|
||||
for _mod in ("sqlalchemy", "sqlalchemy.orm", "sqlalchemy.ext", "sqlalchemy.ext.declarative"):
|
||||
if _mod not in sys.modules:
|
||||
try:
|
||||
__import__(_mod)
|
||||
except ImportError:
|
||||
sys.modules[_mod] = MagicMock()
|
||||
|
||||
from services.memory.skills import SkillsManager # noqa: E402
|
||||
|
||||
|
||||
def _write_skill_md(skills_root: Path, name: str, *, tags: str = "[]",
|
||||
owner: str = "admin") -> None:
|
||||
skill_dir = skills_root / "general" / name
|
||||
skill_dir.mkdir(parents=True, exist_ok=True)
|
||||
fm = "\n".join([
|
||||
"---",
|
||||
f"name: {name}",
|
||||
"description: test skill",
|
||||
"version: 1.0.0",
|
||||
"category: general",
|
||||
f"tags: [{tags}]",
|
||||
"status: published",
|
||||
"confidence: 0.9",
|
||||
"audit_verdict: pass",
|
||||
"source: learned",
|
||||
f"owner: {owner}",
|
||||
"created: 2026-01-01T00:00:00Z",
|
||||
"---",
|
||||
"",
|
||||
"## When to Use",
|
||||
"- test",
|
||||
"",
|
||||
"## Procedure",
|
||||
"1. step 1",
|
||||
"",
|
||||
])
|
||||
(skill_dir / "SKILL.md").write_text(fm, encoding="utf-8")
|
||||
|
||||
|
||||
def _names(idx):
|
||||
return {s["name"] for s in idx}
|
||||
|
||||
|
||||
def test_remote_only_hidden_from_local_model(tmp_path):
|
||||
(tmp_path / "skills").mkdir()
|
||||
_write_skill_md(tmp_path / "skills", "agents-map", tags="reference, remote-only")
|
||||
sm = SkillsManager(str(tmp_path))
|
||||
sm.set_audit("agents-map", "pass", owner="admin")
|
||||
|
||||
assert "agents-map" not in _names(
|
||||
sm.index_for(owner="admin", local_model=True))
|
||||
|
||||
|
||||
def test_remote_only_visible_to_remote_model_and_unknown_endpoint(tmp_path):
|
||||
(tmp_path / "skills").mkdir()
|
||||
_write_skill_md(tmp_path / "skills", "agents-map", tags="reference, remote-only")
|
||||
sm = SkillsManager(str(tmp_path))
|
||||
sm.set_audit("agents-map", "pass", owner="admin")
|
||||
|
||||
assert "agents-map" in _names(sm.index_for(owner="admin", local_model=False))
|
||||
# Unknown endpoint (background callers without a URL) fails open.
|
||||
assert "agents-map" in _names(sm.index_for(owner="admin", local_model=None))
|
||||
assert "agents-map" in _names(sm.index_for(owner="admin"))
|
||||
|
||||
|
||||
def test_untagged_skills_are_never_gated(tmp_path):
|
||||
(tmp_path / "skills").mkdir()
|
||||
_write_skill_md(tmp_path / "skills", "plain-skill", tags="reference")
|
||||
sm = SkillsManager(str(tmp_path))
|
||||
sm.set_audit("plain-skill", "pass", owner="admin")
|
||||
|
||||
assert "plain-skill" in _names(sm.index_for(owner="admin", local_model=True))
|
||||
assert "plain-skill" in _names(sm.index_for(owner="admin", local_model=False))
|
||||
|
||||
|
||||
def test_tag_in_ui_listings_is_not_gated(tmp_path):
|
||||
(tmp_path / "skills").mkdir()
|
||||
_write_skill_md(tmp_path / "skills", "agents-map", tags="reference, remote-only")
|
||||
sm = SkillsManager(str(tmp_path))
|
||||
sm.set_audit("agents-map", "pass", owner="admin")
|
||||
|
||||
# API listings / UI pass no local_model → must still see the skill.
|
||||
assert "agents-map" in _names(sm.index_for(owner="admin", local_model=None))
|
||||
@@ -133,3 +133,20 @@ def test_unaudited_or_below_threshold_published_skill_is_not_injected(tmp_path):
|
||||
assert not sm.get_relevant_skills(
|
||||
"premature skill", min_confidence=0.80,
|
||||
)
|
||||
|
||||
|
||||
def test_auto_approve_off_injects_published_skills_without_an_audit(tmp_path):
|
||||
# #6634: OFF used min_confidence=2.0, which no 0-1 confidence clears, so
|
||||
# every user skill was rejected. Publishing is the approval when OFF.
|
||||
(tmp_path / "skills").mkdir()
|
||||
for name, status in (("premature-skill", "published"), ("draft-skill", "draft")):
|
||||
path = _write_skill_md(tmp_path / "skills", name)
|
||||
text = path.read_text(encoding="utf-8").replace("audit_verdict: pass\n", "")
|
||||
path.write_text(text.replace("status: published", f"status: {status}"), encoding="utf-8")
|
||||
sm = SkillsManager(str(tmp_path))
|
||||
|
||||
query = "premature skill draft skill"
|
||||
# threshold=0 isolates the eligibility gate from relevance scoring.
|
||||
assert _names(sm.get_relevant_skills(query, threshold=0.0, published_only=True)) == {"premature-skill"}
|
||||
# Auto-approve ON still needs a passing audit, as before.
|
||||
assert not sm.get_relevant_skills(query, threshold=0.0, min_confidence=0.85)
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
"""Skill test runs must close the agent stream in their own task (#6635).
|
||||
|
||||
When the agent asks for an exact approval, the skill test pauses by leaving its
|
||||
`async for` over `stream_agent_loop`. The abandoned generator was later closed
|
||||
by asyncio's finalizer in another task, so the ContextVar resets in its cleanup
|
||||
(request_authority, turn_contract, ...) raised "Token ... was created in a
|
||||
different Context". The stream must be closed before the run returns.
|
||||
"""
|
||||
import asyncio
|
||||
import json
|
||||
from contextvars import ContextVar
|
||||
|
||||
import routes.skills_routes as skills_routes
|
||||
|
||||
_BOUND: ContextVar = ContextVar("skill_test_bound", default=None)
|
||||
|
||||
_APPROVAL = {"kind": "tool_approval", "approval_id": "approval-1", "question": "Allow?"}
|
||||
|
||||
|
||||
def _fake_stream(events, closed):
|
||||
async def stream_agent_loop(*args, **kwargs):
|
||||
token = _BOUND.set("bound")
|
||||
try:
|
||||
for event in events:
|
||||
yield "data: " + json.dumps(event) + "\n\n"
|
||||
yield "data: [DONE]\n\n"
|
||||
finally:
|
||||
# Raises ValueError when run from another task's context.
|
||||
_BOUND.reset(token)
|
||||
closed.append(asyncio.current_task())
|
||||
return stream_agent_loop
|
||||
|
||||
|
||||
async def test_paused_skill_test_closes_the_agent_stream_in_its_own_task(monkeypatch):
|
||||
closed = []
|
||||
events = [{"type": "tool_output", "output": "blocked", "ask_user": _APPROVAL},
|
||||
{"delta": "never reached"}]
|
||||
monkeypatch.setattr("src.agent_loop.stream_agent_loop", _fake_stream(events, closed))
|
||||
key = ("alice", "demo")
|
||||
skills_routes._skill_test_jobs[key] = {"log": [], "status": "running"}
|
||||
try:
|
||||
await skills_routes._run_skill_test_job(
|
||||
key, "demo", "# skill", "task", "http://model.test", "m", {}, "alice")
|
||||
job = skills_routes._skill_test_jobs[key]
|
||||
assert job["status"] == "awaiting_approval"
|
||||
assert closed == [asyncio.current_task()]
|
||||
finally:
|
||||
skills_routes._skill_test_jobs.pop(key, None)
|
||||
|
||||
|
||||
async def test_audit_arm_closes_the_agent_stream_when_it_stops_for_approval(monkeypatch):
|
||||
closed = []
|
||||
events = [{"type": "tool_output", "output": "blocked", "ask_user": _APPROVAL},
|
||||
{"delta": "never reached"}]
|
||||
monkeypatch.setattr("src.agent_loop.stream_agent_loop", _fake_stream(events, closed))
|
||||
|
||||
_, _, approval = await skills_routes._run_skill_audit_arm(
|
||||
[{"role": "user", "content": "task"}], "http://model.test", "m", {}, "alice")
|
||||
|
||||
assert approval == _APPROVAL
|
||||
assert closed == [asyncio.current_task()]
|
||||
@@ -0,0 +1,485 @@
|
||||
"""Offline contracts captured against the merged skills baseline before extraction."""
|
||||
import asyncio
|
||||
from dataclasses import fields, is_dataclass
|
||||
from difflib import unified_diff
|
||||
import inspect
|
||||
import json
|
||||
from contextvars import ContextVar
|
||||
from pathlib import Path
|
||||
from typing import Annotated, get_args, get_origin
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from starlette.requests import Request
|
||||
|
||||
from routes import skills_routes as routes
|
||||
|
||||
|
||||
FIXTURES = Path(__file__).parent / 'fixtures'
|
||||
|
||||
|
||||
def _qualified_name(value):
|
||||
return f'{value.__module__}.{value.__qualname__}'
|
||||
|
||||
|
||||
def _value_contract(value):
|
||||
if value is inspect.Signature.empty:
|
||||
return {'empty': True}
|
||||
if value is None or type(value) in (bool, int, float, str):
|
||||
return {'type': _qualified_name(type(value)), 'value': value}
|
||||
if type(value) in (list, tuple):
|
||||
return {'type': _qualified_name(type(value)),
|
||||
'items': [_value_contract(item) for item in value]}
|
||||
if type(value) is dict and all(type(key) is str for key in value):
|
||||
return {'type': 'builtins.dict',
|
||||
'items': {key: _value_contract(item) for key, item in value.items()}}
|
||||
if is_dataclass(value) and not isinstance(value, type):
|
||||
return {'type': _qualified_name(type(value)), 'fields': {
|
||||
field.name: _value_contract(getattr(value, field.name))
|
||||
for field in fields(value)
|
||||
}}
|
||||
raise TypeError(f'Unsupported contract value: {type(value)}')
|
||||
|
||||
|
||||
def _annotation_contract(annotation):
|
||||
if annotation is inspect.Signature.empty or annotation is None:
|
||||
return _value_contract(annotation)
|
||||
origin = get_origin(annotation)
|
||||
if origin is Annotated:
|
||||
base, *metadata = get_args(annotation)
|
||||
return {'origin': _qualified_name(origin),
|
||||
'annotation': _annotation_contract(base),
|
||||
'metadata': [_value_contract(item) for item in metadata]}
|
||||
if origin is not None:
|
||||
return {'origin': _qualified_name(origin), 'module': annotation.__module__,
|
||||
'arguments': [_annotation_contract(arg) for arg in get_args(annotation)]}
|
||||
return {'type': _qualified_name(annotation)}
|
||||
|
||||
|
||||
def _factory_contract(factory):
|
||||
# Names do not identify closure state, partial arguments or arbitrary code.
|
||||
# Only these known builtin constructors have a supported default contract.
|
||||
if not any(factory is builtin for builtin in (list, tuple, dict, set, frozenset)):
|
||||
raise TypeError('Unsupported default factory; requires an explicit compatibility contract')
|
||||
return _qualified_name(factory)
|
||||
|
||||
|
||||
def _signature_contract(target):
|
||||
"""Freeze supported constructor structure without executing default factories."""
|
||||
signature = inspect.signature(target)
|
||||
model_fields = getattr(target, 'model_fields', {})
|
||||
parameters = []
|
||||
for parameter in signature.parameters.values():
|
||||
field = model_fields.get(parameter.name)
|
||||
if field is not None and field.default_factory is not None:
|
||||
default = {'factory': _factory_contract(field.default_factory),
|
||||
'marker': _qualified_name(type(parameter.default))}
|
||||
else:
|
||||
default = _value_contract(parameter.default)
|
||||
parameters.append({'name': parameter.name, 'kind': parameter.kind.name,
|
||||
'annotation': _annotation_contract(parameter.annotation),
|
||||
'default': default})
|
||||
contract = {'parameters': parameters,
|
||||
'return': _annotation_contract(signature.return_annotation)}
|
||||
if model_fields:
|
||||
# Field names can be absent from the Python signature (validation aliases).
|
||||
# These public FieldInfo attributes affect input validation independently
|
||||
# of the generated signature and JSON schema. Rebuilt annotations retain
|
||||
# field metadata, including constraints and strict/union validation modes.
|
||||
contract['model_fields'] = {
|
||||
name: {
|
||||
'annotation': _annotation_contract(field.rebuild_annotation()),
|
||||
'default': ({'factory': _factory_contract(field.default_factory),
|
||||
'takes_validated_data': field.default_factory_takes_validated_data}
|
||||
if field.default_factory is not None else
|
||||
_value_contract(inspect.Signature.empty if field.is_required() else field.default)),
|
||||
'validation': {attribute: _value_contract(getattr(field, attribute))
|
||||
for attribute in ('alias', 'alias_priority', 'validation_alias',
|
||||
'discriminator', 'validate_default')},
|
||||
}
|
||||
for name, field in model_fields.items()
|
||||
}
|
||||
# Includes inherited model settings such as validate_default and strict.
|
||||
# Unsupported callables or values fail closed through _value_contract.
|
||||
contract['model_config'] = _value_contract(target.model_config)
|
||||
return contract
|
||||
|
||||
|
||||
def _canonical_openapi(schema):
|
||||
return json.dumps(schema, sort_keys=True, indent=2, ensure_ascii=True) + '\n'
|
||||
|
||||
|
||||
def _assert_openapi_matches_baseline(schema):
|
||||
expected = (FIXTURES / 'skills_openapi_baseline.json').read_text(encoding='utf-8')
|
||||
actual = _canonical_openapi(schema)
|
||||
if actual != expected:
|
||||
baseline = json.loads(expected)
|
||||
affected = []
|
||||
for label, before, after in [
|
||||
('paths', baseline.get('paths', {}), schema.get('paths', {})),
|
||||
('schemas', baseline.get('components', {}).get('schemas', {}),
|
||||
schema.get('components', {}).get('schemas', {})),
|
||||
]:
|
||||
changed = [key for key in sorted(before.keys() | after.keys())
|
||||
if _canonical_openapi(before.get(key)) != _canonical_openapi(after.get(key))]
|
||||
if changed:
|
||||
affected.append(f'Changed {label}: {", ".join(changed)}\n')
|
||||
pytest.fail('OpenAPI contract changed:\n' + ''.join(affected) + ''.join(unified_diff(
|
||||
expected.splitlines(keepends=True), actual.splitlines(keepends=True),
|
||||
fromfile='skills_openapi_baseline.json', tofile='generated OpenAPI',
|
||||
)))
|
||||
|
||||
|
||||
def endpoint(method, path, manager=None):
|
||||
return next(r.endpoint for r in routes.setup_skills_routes(manager).routes
|
||||
if method in r.methods and r.path == '/api/skills' + path)
|
||||
|
||||
|
||||
def request(owner='alice', body=None):
|
||||
async def receive():
|
||||
return {'type': 'http.request', 'body': json.dumps(body or {}).encode()}
|
||||
return Request({'type': 'http', 'method': 'POST', 'path': '/',
|
||||
'headers': [(b'content-type', b'application/json')],
|
||||
'state': {'user': owner}}, receive)
|
||||
|
||||
|
||||
def test_api_and_python_signatures_match_baseline():
|
||||
baseline = json.loads((FIXTURES / 'skills_api_baseline.json').read_text(encoding='utf-8'))
|
||||
app = FastAPI()
|
||||
router = routes.setup_skills_routes(object())
|
||||
app.include_router(router)
|
||||
schema = app.openapi()
|
||||
_assert_openapi_matches_baseline(schema)
|
||||
declarations = [[sorted(r.methods), r.path, r.name] for r in router.routes]
|
||||
assert declarations == baseline['routes']
|
||||
for name, signature in baseline['signatures'].items():
|
||||
assert _signature_contract(getattr(routes, name)) == signature, name
|
||||
|
||||
|
||||
def test_signature_contract_ignores_annotated_repr(monkeypatch):
|
||||
target = routes.SkillImportUrlRequest
|
||||
before = _signature_contract(target)
|
||||
annotation = inspect.signature(target).parameters['url'].annotation
|
||||
monkeypatch.setattr(type(annotation), '__repr__', lambda self: 'different Python rendering')
|
||||
assert _signature_contract(target) == before
|
||||
|
||||
|
||||
@pytest.mark.parametrize('change', ['name', 'order', 'kind', 'default', 'annotation', 'return'])
|
||||
def test_signature_contract_detects_interface_changes(change):
|
||||
def original(first: str, second: int = 1) -> bool:
|
||||
pass
|
||||
signature = inspect.signature(original)
|
||||
first, second = signature.parameters.values()
|
||||
parameters = [first, second]
|
||||
if change == 'order':
|
||||
parameters = [second.replace(default=inspect.Parameter.empty),
|
||||
first.replace(default='')]
|
||||
elif change == 'return':
|
||||
signature = signature.replace(return_annotation=str)
|
||||
else:
|
||||
changes = {
|
||||
'name': {'name': 'renamed'},
|
||||
'kind': {'kind': inspect.Parameter.KEYWORD_ONLY},
|
||||
'default': {'default': 2},
|
||||
'annotation': {'annotation': float},
|
||||
}
|
||||
parameters[1] = second.replace(**changes[change])
|
||||
def changed():
|
||||
pass
|
||||
changed.__signature__ = signature.replace(parameters=parameters)
|
||||
assert _signature_contract(changed) != _signature_contract(original)
|
||||
|
||||
|
||||
@pytest.mark.parametrize('change', ['constraint', 'factory'])
|
||||
def test_signature_contract_detects_model_changes(change):
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
class Original(BaseModel):
|
||||
text: str = Field(max_length=8)
|
||||
tags: list[str] = Field(default_factory=list)
|
||||
|
||||
class Changed(BaseModel):
|
||||
text: str = Field(max_length=9 if change == 'constraint' else 8)
|
||||
tags: list[str] = Field(default_factory=tuple if change == 'factory' else list)
|
||||
|
||||
assert _signature_contract(Changed) != _signature_contract(Original)
|
||||
|
||||
|
||||
@pytest.mark.parametrize('validated_data', [False, True])
|
||||
def test_signature_contract_rejects_custom_factories_without_calling_them(validated_data):
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
calls = []
|
||||
def make_factory(value):
|
||||
def plain():
|
||||
calls.append(value)
|
||||
return [value]
|
||||
def with_data(data):
|
||||
calls.append(value)
|
||||
return [data['prefix'], value]
|
||||
return with_data if validated_data else plain
|
||||
|
||||
class Before(BaseModel):
|
||||
prefix: str = 'validated'
|
||||
tags: list[str] = Field(default_factory=make_factory('before'))
|
||||
|
||||
class After(BaseModel):
|
||||
prefix: str = 'validated'
|
||||
tags: list[str] = Field(default_factory=make_factory('after'))
|
||||
|
||||
assert Before().tags != After().tags
|
||||
assert Before.model_fields['tags'].default_factory_takes_validated_data is validated_data
|
||||
calls.clear()
|
||||
for model in (Before, After):
|
||||
with pytest.raises(TypeError, match='Unsupported default factory'):
|
||||
_signature_contract(model)
|
||||
assert calls == []
|
||||
|
||||
|
||||
def test_signature_contract_detects_default_validation_outside_signature_and_openapi():
|
||||
from pydantic import BaseModel, Field, ValidationError
|
||||
|
||||
class Before(BaseModel):
|
||||
count: int = Field(default='invalid', validate_default=False)
|
||||
|
||||
class After(BaseModel):
|
||||
count: int = Field(default='invalid', validate_default=True)
|
||||
|
||||
assert Before().count == 'invalid'
|
||||
with pytest.raises(ValidationError):
|
||||
After()
|
||||
assert inspect.signature(Before) == inspect.signature(After)
|
||||
before_schema, after_schema = Before.model_json_schema(), After.model_json_schema()
|
||||
before_schema.pop('title')
|
||||
after_schema.pop('title')
|
||||
assert before_schema == after_schema
|
||||
assert _signature_contract(Before) != _signature_contract(After)
|
||||
|
||||
|
||||
def test_signature_contract_detects_validation_alias_paths():
|
||||
from pydantic import AliasPath, BaseModel, Field, ValidationError
|
||||
|
||||
class Before(BaseModel):
|
||||
count: int = Field(validation_alias=AliasPath('before', 0))
|
||||
|
||||
class After(BaseModel):
|
||||
count: int = Field(validation_alias=AliasPath('after', 0))
|
||||
|
||||
assert Before(before=[1]).count == 1
|
||||
with pytest.raises(ValidationError):
|
||||
After(before=[1])
|
||||
assert inspect.signature(Before) == inspect.signature(After)
|
||||
before_schema, after_schema = Before.model_json_schema(), After.model_json_schema()
|
||||
before_schema.pop('title')
|
||||
after_schema.pop('title')
|
||||
assert before_schema == after_schema
|
||||
assert _signature_contract(Before) != _signature_contract(After)
|
||||
|
||||
|
||||
@pytest.mark.parametrize('setting', ['validate_default', 'strict'])
|
||||
def test_signature_contract_detects_model_validation_configuration(setting):
|
||||
from pydantic import BaseModel, ConfigDict, ValidationError
|
||||
|
||||
class Before(BaseModel):
|
||||
model_config = ConfigDict({setting: False})
|
||||
count: int = 'invalid' if setting == 'validate_default' else 1
|
||||
|
||||
class After(BaseModel):
|
||||
model_config = ConfigDict({setting: True})
|
||||
count: int = 'invalid' if setting == 'validate_default' else 1
|
||||
|
||||
data = {} if setting == 'validate_default' else {'count': '1'}
|
||||
Before(**data)
|
||||
with pytest.raises(ValidationError):
|
||||
After(**data)
|
||||
assert inspect.signature(Before) == inspect.signature(After)
|
||||
assert _signature_contract(Before) != _signature_contract(After)
|
||||
|
||||
|
||||
def test_openapi_golden_is_canonical():
|
||||
golden = (FIXTURES / 'skills_openapi_baseline.json').read_text(encoding='utf-8')
|
||||
schema = json.loads(golden)
|
||||
assert golden == _canonical_openapi(schema)
|
||||
|
||||
|
||||
@pytest.mark.parametrize('section, key', [
|
||||
('paths', '/api/skills/add'), ('schemas', 'SkillAddRequest'),
|
||||
])
|
||||
def test_openapi_failure_identifies_changed_endpoint_or_schema(section, key):
|
||||
schema = json.loads((FIXTURES / 'skills_openapi_baseline.json').read_text(encoding='utf-8'))
|
||||
target = schema['paths'] if section == 'paths' else schema['components']['schemas']
|
||||
if section == 'paths':
|
||||
target[key]['post']['responses']['200']['description'] = 'changed contract'
|
||||
else:
|
||||
target[key]['properties']['solution']['anyOf'][0]['maxLength'] = 4999
|
||||
with pytest.raises(pytest.fail.Exception) as failure:
|
||||
_assert_openapi_matches_baseline(schema)
|
||||
diagnostic = str(failure.value)
|
||||
assert '--- skills_openapi_baseline.json' in diagnostic
|
||||
assert '+++ generated OpenAPI' in diagnostic
|
||||
assert f'Changed {section}: {key}' in diagnostic
|
||||
expected_change = '"description": "changed contract"' if section == 'paths' else '"maxLength": 4999'
|
||||
assert any(line.startswith('+') and expected_change in line
|
||||
for line in diagnostic.splitlines())
|
||||
|
||||
|
||||
@pytest.mark.parametrize('raw, expected', [
|
||||
('<think>{"verdict":"fail"}</think>{"verdict":"PASS","confidence":2,}', 'pass'),
|
||||
('{"verdict":"fail"} text {"verdict":"needs_work"}', 'needs_work'),
|
||||
('Verdict: INCONCLUSIVE', 'inconclusive'),
|
||||
('<think>{"verdict":"pass"}', 'unknown'),
|
||||
('{"verdict":"invalid"}', 'unknown'),
|
||||
])
|
||||
async def test_run_verdict_parsing_and_retry(monkeypatch, raw, expected):
|
||||
calls = []
|
||||
async def call(url, model, messages, **kwargs):
|
||||
calls.append((messages, kwargs))
|
||||
return raw
|
||||
monkeypatch.setattr('src.llm_core.llm_call_async', call)
|
||||
monkeypatch.setattr(routes, 'effort_for_call', lambda url, model, owner: 'high' if owner == 'alice' else None)
|
||||
result = await routes._eval_skill_run('md', 'task', 'transcript', 'url', 'm', {},
|
||||
workload='background', owner='alice')
|
||||
assert result['verdict'] == expected
|
||||
assert len(calls) == (2 if expected == 'unknown' else 1)
|
||||
for _, kwargs in calls:
|
||||
assert kwargs['reasoning_effort'] == 'high'
|
||||
assert kwargs['workload'] == 'background'
|
||||
assert kwargs['timeout'] == 180
|
||||
assert kwargs['max_tokens'] == 32768
|
||||
if len(calls) == 2:
|
||||
assert "must START with '{'" in calls[1][0][0]['content']
|
||||
|
||||
|
||||
async def test_review_transient_failure_and_result_coercion(monkeypatch):
|
||||
calls = []
|
||||
async def call(*args, **kwargs):
|
||||
calls.append(kwargs)
|
||||
if len(calls) == 1:
|
||||
raise TimeoutError('offline')
|
||||
return json.dumps({'verdict': 'pass', 'confidence': '-1', 'summary': 'x' * 500,
|
||||
'issues': ['y' * 250] * 10, 'baseline_verdict': 'invalid',
|
||||
'usefulness': '2', 'saved_turns': '-2', 'saved_tool_calls': 'bad'})
|
||||
monkeypatch.setattr('src.llm_core.llm_call_async', call)
|
||||
result = await routes._eval_skill_run('', '', '', 'url', 'm', None)
|
||||
assert result == {'verdict': 'pass', 'confidence': 0.0, 'summary': 'x' * 400,
|
||||
'issues': ['y' * 200] * 8, 'baseline_verdict': 'unknown',
|
||||
'usefulness': 1.0, 'saved_turns': -2, 'saved_tool_calls': 0}
|
||||
|
||||
|
||||
async def test_review_failure_schema_and_transcript_tail(monkeypatch):
|
||||
seen = []
|
||||
async def call(url, model, messages, **kwargs):
|
||||
seen.append(messages)
|
||||
raise RuntimeError('offline')
|
||||
monkeypatch.setattr('src.llm_core.llm_call_async', call)
|
||||
result = await routes._eval_skill_run('m' * 5000, 'TASK', 'HEAD' + 'x' * 30000 + 'TAIL', 'url', 'm', None)
|
||||
assert result == {'verdict': 'unknown', 'confidence': 0,
|
||||
'summary': 'Review call failed: offline', 'issues': []}
|
||||
assert len(seen) == 2
|
||||
assert 'HEAD' in seen[0][1]['content'] and 'TAIL' in seen[0][1]['content']
|
||||
assert 'm' * 4001 not in seen[0][1]['content']
|
||||
|
||||
|
||||
@pytest.mark.parametrize('name, raw, expected, tokens, timeout', [
|
||||
('_eval_skill_necessity', '<think>x</think>{"necessary":false,"redundant_with":["demo"],"reason":"duplicate",}',
|
||||
{'necessary': False, 'redundant_with': ['demo'], 'reason': 'duplicate'}, 8192, 120),
|
||||
('_eval_skill_retrieval_precision', '{"ok":false,"summary":"broad","issues":["metadata: retrieval: narrow"]}',
|
||||
{'ok': False, 'summary': 'broad', 'issues': ['metadata: retrieval: narrow']}, 4096, 90),
|
||||
('_eval_skill_necessity', '<think>unfinished', None, 8192, 120),
|
||||
('_eval_skill_retrieval_precision', '[]', None, 4096, 90),
|
||||
])
|
||||
async def test_advisory_schema_budget_and_owner(monkeypatch, name, raw, expected, tokens, timeout):
|
||||
async def call(*args, **kwargs):
|
||||
assert kwargs['max_tokens'] == tokens
|
||||
assert kwargs['timeout'] == timeout
|
||||
assert kwargs['reasoning_effort'] == 'medium'
|
||||
return raw
|
||||
monkeypatch.setattr('src.llm_core.llm_call_async', call)
|
||||
monkeypatch.setattr(routes, 'effort_for_call', lambda url, model, owner: 'medium' if owner == 'alice' else None)
|
||||
assert await getattr(routes, name)('md', [], 'url', 'm', None, owner='alice') == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize('name', ['_eval_skill_necessity', '_eval_skill_retrieval_precision', '_improve_skill_md'])
|
||||
async def test_advisory_exceptions_return_none(monkeypatch, name):
|
||||
async def call(*args, **kwargs):
|
||||
raise TimeoutError('offline')
|
||||
monkeypatch.setattr('src.llm_core.llm_call_async', call)
|
||||
args = ('md', {}, '', 'url', 'm', None) if name == '_improve_skill_md' else ('md', [], 'url', 'm', None)
|
||||
assert await getattr(routes, name)(*args) is None
|
||||
|
||||
|
||||
async def test_cancelled_audit_closes_stream_in_originating_context(monkeypatch):
|
||||
bound = ContextVar('audit_characterization', default=None)
|
||||
opened = asyncio.Event()
|
||||
closed = []
|
||||
async def stream(*args, **kwargs):
|
||||
token = bound.set('bound')
|
||||
try:
|
||||
yield 'data: {"delta":"started"}\n\n'
|
||||
opened.set()
|
||||
await asyncio.Event().wait()
|
||||
finally:
|
||||
bound.reset(token)
|
||||
closed.append(asyncio.current_task())
|
||||
monkeypatch.setattr('src.agent_loop.stream_agent_loop', stream)
|
||||
task = asyncio.create_task(routes._run_skill_audit_arm([], 'url', 'm', {}, 'alice'))
|
||||
await opened.wait()
|
||||
task.cancel()
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await task
|
||||
assert closed == [task]
|
||||
assert bound.get() is None
|
||||
|
||||
|
||||
async def test_cancelled_batch_skips_finalization_and_cleans_task(monkeypatch):
|
||||
opened = asyncio.Event()
|
||||
async def audit(*args, **kwargs):
|
||||
opened.set()
|
||||
await asyncio.Event().wait()
|
||||
class Manager:
|
||||
def load(self, owner=None):
|
||||
assert owner == 'alice'
|
||||
return [{'name': 'demo'}]
|
||||
job = {'status': 'running', 'log': [], 'results': [], 'done': 0}
|
||||
monkeypatch.setattr(routes, '_skill_audit_jobs', {('alice',): job})
|
||||
monkeypatch.setattr(routes, '_audit_one_skill', audit)
|
||||
monkeypatch.setattr(routes, '_finalize_audit_batch', lambda *args: pytest.fail('cancelled batch finalized'))
|
||||
task = asyncio.create_task(routes._run_audit_all_job(('alice',), Manager(), ['demo'], 'url', 'm', {}, None, 'alice'))
|
||||
job['task'] = task
|
||||
await opened.wait()
|
||||
task.cancel()
|
||||
await task
|
||||
assert job['status'] == 'cancelled'
|
||||
assert job['current'] is None and 'task' not in job and 'finished' in job
|
||||
assert job['results'] == [] and job['done'] == 0
|
||||
|
||||
|
||||
async def test_test_polling_is_owner_scoped_and_hides_continuation(monkeypatch):
|
||||
class Manager:
|
||||
def load(self, owner=None):
|
||||
return [{'name': 'demo'}]
|
||||
monkeypatch.setattr(routes, 'get_current_user', lambda req: req.state.user)
|
||||
job = {'status': 'awaiting_approval', 'task': 'task', 'model': 'm', 'log': [],
|
||||
'verdict': None, 'approval': {'approval_id': 'id'},
|
||||
'_run': {'headers': {'Authorization': 'secret'}}, '_transcript': ['private']}
|
||||
monkeypatch.setattr(routes, '_skill_test_jobs', {('alice', 'demo'): job})
|
||||
poll = endpoint('GET', '/{skill_id}/test-status', Manager())
|
||||
assert await poll(request('bob'), 'demo') == {'status': 'none'}
|
||||
assert await poll(request(), 'demo') == {k: job[k] for k in ('status', 'task', 'model', 'log', 'verdict', 'approval')}
|
||||
|
||||
|
||||
def test_batch_finalizer_protects_approval_required_and_unprocessed_skills(monkeypatch):
|
||||
class Manager:
|
||||
def __init__(self):
|
||||
self.updated = []
|
||||
def load(self, owner=None):
|
||||
assert owner == 'alice'
|
||||
return [{'name': name, 'status': 'published', 'source': 'learned',
|
||||
'audit_verdict': 'fail', 'confidence': .9} for name in ('demo', 'demo-1', 'demo-2')]
|
||||
def update_skill(self, name, fields, owner=None):
|
||||
self.updated.append((name, fields, owner))
|
||||
monkeypatch.setattr(routes, '_audit_auto_publish_policy', lambda owner: (True, .8))
|
||||
manager = Manager()
|
||||
routes._finalize_audit_batch(manager, [{'skill': 'demo', 'result': 'approval_required'},
|
||||
{'skill': 'demo-1', 'result': 'flagged'}], 'alice', lambda msg: None)
|
||||
assert manager.updated == [('demo-1', {'status': 'draft'}, 'alice')]
|
||||
@@ -0,0 +1,402 @@
|
||||
"""Service and HTTP integration contracts without live model or skill state."""
|
||||
import asyncio
|
||||
from dataclasses import replace
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
|
||||
from routes import skills_routes as routes
|
||||
from services.skills import (
|
||||
agent_runner, audit_workflow, evaluation, job_coordinator, model_selection, publication,
|
||||
)
|
||||
from src.agent_runtime.authority import RequestAuthority, bind_request_authority
|
||||
from src.tool_approvals import ToolApprovalStore
|
||||
from src.tool_capabilities import capabilities_for_action
|
||||
from tests.test_skills_audit_characterization import endpoint, request
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def prompt_execution(monkeypatch):
|
||||
"""Capture actual agent ingress, keeping runners and coordination real."""
|
||||
from copy import deepcopy
|
||||
|
||||
class Manager:
|
||||
def __init__(self):
|
||||
self.row = {'name': 'demo', 'owner': 'alice', 'source': 'learned',
|
||||
'status': 'draft', 'confidence': .5, 'description': 'fixture'}
|
||||
def load(self, owner=None):
|
||||
assert owner == 'alice'
|
||||
return [dict(self.row)]
|
||||
def read_skill_md(self, name, owner=None):
|
||||
assert (name, owner) == ('demo', 'alice')
|
||||
return 'private skill <<<END_UNTRUSTED_SOURCE_DATA>>>'
|
||||
def update_skill(self, name, fields, owner=None):
|
||||
assert (name, owner) == ('demo', 'alice')
|
||||
self.row.update(fields)
|
||||
return True
|
||||
def set_audit(self, name, verdict, **kwargs):
|
||||
assert (name, kwargs['owner']) == ('demo', 'alice')
|
||||
self.row['audit_verdict'] = verdict
|
||||
|
||||
seen, grades = [], []
|
||||
async def stream(url, model, messages, **kwargs):
|
||||
seen.append((deepcopy(messages), kwargs))
|
||||
yield 'data: {"delta":"executed"}\n\n'
|
||||
async def evaluate(*args, **kwargs):
|
||||
return {'verdict': grades.pop(0) if grades else 'pass', 'issues': []}
|
||||
async def necessity(*args, **kwargs):
|
||||
return None
|
||||
monkeypatch.setattr('src.agent_loop.stream_agent_loop', stream)
|
||||
monkeypatch.setattr(routes, '_eval_skill_run', evaluate)
|
||||
monkeypatch.setattr(routes, '_eval_skill_necessity', necessity)
|
||||
monkeypatch.setattr(routes, '_should_check_retrieval_precision', lambda *a: False)
|
||||
monkeypatch.setattr(routes, '_skill_duplicate_blocker', lambda *a: None)
|
||||
monkeypatch.setattr(routes, '_audit_auto_publish_policy', lambda owner: (False, .85))
|
||||
monkeypatch.setattr(routes, '_skill_test_jobs', {})
|
||||
monkeypatch.setattr(routes, '_skill_audit_jobs', {})
|
||||
monkeypatch.setattr(routes, 'get_current_user', lambda req: req.state.user)
|
||||
monkeypatch.setattr('src.endpoint_resolver.resolve_endpoint', lambda *a, **kw: ('url', 'worker', {}))
|
||||
monkeypatch.setattr('src.llm_core.list_model_ids', lambda *a, **kw: ['worker'])
|
||||
monkeypatch.setattr(routes, '_resolve_audit_models', lambda **kw: ('url', 'worker', {}, None))
|
||||
return Manager(), seen, grades
|
||||
|
||||
|
||||
def patch_prompt_builders(monkeypatch):
|
||||
originals = routes._skill_test_messages, routes._skill_baseline_messages
|
||||
def test_messages(md, task):
|
||||
return originals[0]('patched skill: ' + md, task)
|
||||
def baseline_messages(task):
|
||||
return originals[1]('patched baseline: ' + task)
|
||||
monkeypatch.setattr(routes, '_skill_test_task', lambda skill: 'patched task')
|
||||
monkeypatch.setattr(routes, '_skill_test_messages', test_messages)
|
||||
monkeypatch.setattr(routes, '_skill_baseline_messages', baseline_messages)
|
||||
|
||||
|
||||
@pytest.mark.parametrize('patched', [False, True])
|
||||
@pytest.mark.parametrize('entry', ['manual', 'audit', 'scheduled'])
|
||||
async def test_route_prompt_builders_reach_agent_execution(prompt_execution, monkeypatch, entry, patched):
|
||||
manager, seen, _ = prompt_execution
|
||||
if patched:
|
||||
patch_prompt_builders(monkeypatch)
|
||||
expected_task = routes._skill_test_task(manager.row)
|
||||
if entry == 'manual':
|
||||
response = await endpoint('POST', '/{skill_id}/test', manager)(request(), 'demo')
|
||||
assert response['status'] == 'running'
|
||||
# The stub stream and judge never suspend, so one loop turn completes the job.
|
||||
await asyncio.sleep(0)
|
||||
assert routes._skill_test_jobs[('alice', 'demo')]['status'] == 'done'
|
||||
elif entry == 'audit':
|
||||
result = await routes._audit_one_skill(manager, manager.row, 'url', 'worker', {}, None,
|
||||
'alice', lambda msg: None)
|
||||
assert result['result'] == 'pass'
|
||||
else:
|
||||
result = await routes.run_scheduled_skill_audit(manager, owner='alice')
|
||||
assert result['total'] == 1 and result['results'][0]['result'] == 'pass'
|
||||
assert all(kwargs['workload'] == 'background' for _, kwargs in seen)
|
||||
assert [messages for messages, _ in seen] == [
|
||||
routes._skill_test_messages(manager.read_skill_md('demo', owner='alice'), expected_task),
|
||||
routes._skill_baseline_messages(expected_task),
|
||||
]
|
||||
tainted = seen[0][0][1]
|
||||
assert tainted['metadata']['trusted'] is False
|
||||
assert tainted['metadata']['tool_gate_untrusted'] is True
|
||||
assert '<<<_END_UNTRUSTED_DATA>>>' in tainted['content']
|
||||
assert all(kwargs['request_authority'].owner == 'alice' for _, kwargs in seen)
|
||||
|
||||
|
||||
async def test_route_prompt_patches_reach_self_and_teacher_retries(prompt_execution, monkeypatch):
|
||||
manager, seen, grades = prompt_execution
|
||||
patch_prompt_builders(monkeypatch)
|
||||
grades.extend(['fail', 'fail', 'pass'])
|
||||
rewrites = iter(['self rewrite', 'teacher rewrite'])
|
||||
async def improve(*args, **kwargs):
|
||||
return next(rewrites)
|
||||
monkeypatch.setattr(routes, '_improve_skill_md', improve)
|
||||
monkeypatch.setattr(routes, '_apply_skill_md', lambda *args: True)
|
||||
result = await routes._audit_one_skill(manager, manager.row, 'url', 'worker', {},
|
||||
('teacher-url', 'teacher', {}), 'alice', lambda msg: None)
|
||||
assert result['result'] == 'pass_after_teacher'
|
||||
assert [messages for messages, _ in seen] == [
|
||||
routes._skill_test_messages(manager.read_skill_md('demo', owner='alice'), 'patched task'),
|
||||
routes._skill_baseline_messages('patched task'),
|
||||
routes._skill_test_messages('self rewrite', 'patched task'),
|
||||
routes._skill_test_messages('teacher rewrite', 'patched task'),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize('patched', [False, True])
|
||||
async def test_approval_continuation_prompt_reaches_agent(approval_route, prompt_execution, monkeypatch, patched):
|
||||
approve, store, pending, job = approval_route
|
||||
# prompt_execution supplies fresh registries; restore this pending job.
|
||||
routes._skill_test_jobs[('alice', 'demo')] = job
|
||||
_, seen, _ = prompt_execution
|
||||
if patched:
|
||||
patch_prompt_builders(monkeypatch)
|
||||
expected = routes._skill_test_messages('untrusted skill', 'task')
|
||||
result = await approve(request(body={'approval_id': pending.approval_id, 'decision': 'approve'}), 'demo')
|
||||
assert result['status'] == 'running'
|
||||
await asyncio.sleep(0)
|
||||
assert job['status'] == 'done'
|
||||
assert seen[0][0][:len(expected)] == expected
|
||||
transcript = seen[0][0][len(expected)]
|
||||
assert 'untrusted result' in transcript['content']
|
||||
assert transcript['metadata']['trusted'] is False
|
||||
assert transcript['metadata']['tool_gate_untrusted'] is True
|
||||
grant = seen[0][1]['exact_approval']
|
||||
assert grant.pending is pending and grant.allow_remaining_actions is False
|
||||
assert store.peek(pending.approval_id) is None
|
||||
assert seen[1][0] == routes._skill_baseline_messages('task')
|
||||
|
||||
|
||||
def test_baseline_task_alias_uses_route_test_task_patch(monkeypatch):
|
||||
monkeypatch.setattr(routes, '_skill_test_task', lambda skill: 'patched shared task')
|
||||
assert routes._skill_baseline_task({'name': 'demo'}) == 'patched shared task'
|
||||
|
||||
|
||||
async def test_evaluation_can_inject_model_call_and_owner_effort_without_network():
|
||||
seen = []
|
||||
async def call(url, model, messages, **kwargs):
|
||||
seen.append(kwargs)
|
||||
return '{"verdict":"pass"}'
|
||||
def effort(url, model, owner):
|
||||
assert (url, model, owner) == ('offline', 'worker', 'alice')
|
||||
return 'high'
|
||||
result = await evaluation.evaluate_run('md', 'task', 'done', 'offline', 'worker', None,
|
||||
owner='alice', _llm_call=call, _effort_for_call=effort)
|
||||
assert result['verdict'] == 'pass'
|
||||
assert seen[0]['reasoning_effort'] == 'high'
|
||||
|
||||
|
||||
async def test_rewrite_keeps_last_document_and_owner_effort():
|
||||
async def call(*args, **kwargs):
|
||||
assert kwargs['reasoning_effort'] == 'medium'
|
||||
return '<think>reasoning</think>old\n---\nname: old\n---\nbody\n---\nname: demo\n---\nfixed'
|
||||
assert await evaluation.improve_markdown('md', {}, 'transcript', 'url', 'm', {},
|
||||
owner='alice', _llm_call=call, _effort_for_call=lambda u, m, o: 'medium') == '---\nname: demo\n---\nfixed'
|
||||
|
||||
|
||||
def test_audit_model_resolution_uses_first_owner_scoped_utility_fallback(monkeypatch):
|
||||
seen = []
|
||||
def primary(role, owner=None):
|
||||
seen.append(('primary', role, owner))
|
||||
return None, None, {}
|
||||
def fallback(owner=None):
|
||||
seen.append(('fallback', owner))
|
||||
return [('first', '/models/worker', {'Authorization': 'owner-key'}), ('second', 'other', {})]
|
||||
def served(url, headers=None):
|
||||
assert (url, headers) == ('first', {'Authorization': 'owner-key'})
|
||||
return ['worker']
|
||||
monkeypatch.setattr('src.endpoint_resolver.resolve_endpoint', primary)
|
||||
monkeypatch.setattr('src.endpoint_resolver.resolve_utility_fallback_candidates', fallback)
|
||||
monkeypatch.setattr('src.llm_core.list_model_ids', served)
|
||||
monkeypatch.setattr('src.settings.get_setting', lambda key, default=None: default)
|
||||
assert model_selection.resolve_audit_models(owner='alice') == ('first', 'worker', {'Authorization': 'owner-key'}, None)
|
||||
assert seen == [('primary', 'utility', 'alice'), ('fallback', 'alice')]
|
||||
|
||||
|
||||
async def test_audit_stream_failure_closes_and_restores_inherited_authority(monkeypatch):
|
||||
authority = RequestAuthority.empty(owner='alice')
|
||||
closed = []
|
||||
async def stream(*args, **kwargs):
|
||||
assert kwargs['owner'] == 'alice'
|
||||
assert kwargs['request_authority'] is authority
|
||||
assert kwargs['suppress_skills'] is True
|
||||
try:
|
||||
yield 'event: error\ndata: {"error":"offline"}\n\n'
|
||||
finally:
|
||||
closed.append(asyncio.current_task())
|
||||
monkeypatch.setattr('src.agent_loop.stream_agent_loop', stream)
|
||||
with bind_request_authority(authority):
|
||||
with pytest.raises(agent_runner.SkillAuditUnavailable, match='offline'):
|
||||
await agent_runner.run_audit_arm([], 'url', 'm', {}, 'alice')
|
||||
assert closed == [asyncio.current_task()]
|
||||
|
||||
|
||||
async def test_manual_exception_closes_stream_and_finishes_without_publishing(monkeypatch):
|
||||
closed = []
|
||||
async def stream(*args, **kwargs):
|
||||
try:
|
||||
yield 'data: {"delta":"partial"}\n\n'
|
||||
raise RuntimeError('stream failed')
|
||||
finally:
|
||||
closed.append(asyncio.current_task())
|
||||
async def arm(*args, **kwargs):
|
||||
return 'baseline', {}, None
|
||||
async def evaluate(*args, **kwargs):
|
||||
return {'verdict': 'fail', 'summary': 'failed', 'issues': []}
|
||||
class Manager:
|
||||
def __init__(self):
|
||||
self.updates = []
|
||||
def set_audit(self, name, verdict, **kwargs):
|
||||
assert name == 'demo' and verdict == 'fail' and kwargs['owner'] == 'alice'
|
||||
def update_skill(self, name, fields, owner=None):
|
||||
self.updates.append((fields, owner))
|
||||
job = {'status': 'running', 'log': [], '_run': {}, '_transcript': []}
|
||||
deps = replace(routes._audit_dependencies(), test_jobs={('alice', 'demo'): job},
|
||||
run_arm=arm, evaluate_run=evaluate)
|
||||
monkeypatch.setattr('src.agent_loop.stream_agent_loop', stream)
|
||||
manager = Manager()
|
||||
await agent_runner.run_test_job(('alice', 'demo'), 'demo', 'md', 'task', 'url', 'm', {},
|
||||
'alice', manager, deps=deps)
|
||||
assert closed == [asyncio.current_task()]
|
||||
assert job['status'] == 'done' and '_run' not in job and '_transcript' not in job
|
||||
assert {'type': 'error', 'error': 'stream failed'} in job['log']
|
||||
assert manager.updates == [({'confidence': .4}, 'alice')]
|
||||
|
||||
|
||||
async def test_batch_exception_records_progress_and_finalizes_once():
|
||||
class Manager:
|
||||
def __init__(self):
|
||||
self.audit = []
|
||||
def load(self, owner=None):
|
||||
assert owner == 'alice'
|
||||
return [{'name': name, 'status': 'draft'} for name in ('first', 'second')]
|
||||
def set_audit(self, *args, **kwargs):
|
||||
self.audit.append((args, kwargs))
|
||||
async def audit(manager, skill, *args, **kwargs):
|
||||
if skill['name'] == 'first':
|
||||
raise RuntimeError('failed')
|
||||
return {'skill': 'second', 'result': 'pass'}
|
||||
finalized = []
|
||||
job = {'status': 'running', 'log': [], 'results': [], 'done': 0, 'task': object()}
|
||||
deps = replace(routes._audit_dependencies(), audit_jobs={('alice',): job}, audit_one=audit,
|
||||
finalize_batch=lambda manager, results, owner, log: finalized.append((list(results), owner)))
|
||||
manager = Manager()
|
||||
await audit_workflow.run_audit_batch(('alice',), manager, ['first', 'second'], 'url', 'm', {}, None, 'alice', deps=deps)
|
||||
assert job['status'] == 'done' and job['done'] == 2 and job['current'] is None
|
||||
assert 'task' not in job and 'finished' in job
|
||||
assert [r['result'] for r in job['results']] == ['error', 'pass']
|
||||
assert manager.audit == [(('first', 'inconclusive'), {'worker_model': 'm', 'owner': 'alice'})]
|
||||
assert finalized == [(job['results'], 'alice')]
|
||||
|
||||
|
||||
@pytest.mark.parametrize('enabled,confidence,verdict,expected', [
|
||||
(True, .85, 'pass', 'published'), (True, .84, 'pass', 'draft'),
|
||||
(False, .95, 'pass', 'draft'), (True, .95, 'fail', 'draft'),
|
||||
(False, .2, 'unknown', 'published'),
|
||||
])
|
||||
def test_policy_preserves_threshold_and_auto_approval(enabled, confidence, verdict, expected):
|
||||
class Manager:
|
||||
def __init__(self):
|
||||
self.updates = []
|
||||
def load(self, owner=None):
|
||||
return [{'name': 'demo', 'status': 'published'}]
|
||||
def update_skill(self, name, fields, owner=None):
|
||||
assert owner == 'alice'
|
||||
self.updates.append(fields)
|
||||
deps = replace(routes._audit_dependencies(), auto_publish_policy=lambda owner: (enabled, .85),
|
||||
duplicate_blocker=lambda *args: None)
|
||||
manager = Manager()
|
||||
assert publication.finalize_status(manager, 'demo', 'alice', verdict, confidence, deps=deps) == expected
|
||||
assert manager.updates == ([] if verdict == 'unknown' else [{'status': expected}])
|
||||
|
||||
|
||||
@pytest.mark.parametrize('enabled', [True, False])
|
||||
def test_batch_duplicate_finalization_changes_only_processed_unprotected_skills(enabled):
|
||||
class Manager:
|
||||
def __init__(self):
|
||||
self.updates = []
|
||||
def load(self, owner=None):
|
||||
assert owner == 'alice'
|
||||
return [{'name': name, 'source': 'learned', 'status': 'draft',
|
||||
'audit_verdict': 'pass', 'confidence': .95, 'uses': uses}
|
||||
for name, uses in [('demo', 10), ('demo-1', 0), ('demo-2', 20), ('demo-3', 30)]]
|
||||
def update_skill(self, name, fields, owner=None):
|
||||
self.updates.append((name, fields, owner))
|
||||
deps = replace(routes._audit_dependencies(), auto_publish_policy=lambda owner: (enabled, .85))
|
||||
manager = Manager()
|
||||
publication.finalize_batch(manager, [{'skill': 'demo', 'result': 'pass'},
|
||||
{'skill': 'demo-1', 'result': 'pass'},
|
||||
{'skill': 'demo-3', 'result': 'approval_required'}],
|
||||
'alice', lambda message: None, deps=deps)
|
||||
expected = [('demo', {'status': 'published'}, 'alice')] if enabled else []
|
||||
assert manager.updates == expected + [('demo-1', {'status': 'binned'}, 'alice')]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def approval_route(monkeypatch):
|
||||
store = ToolApprovalStore()
|
||||
monkeypatch.setattr('src.tool_approvals.tool_approval_store', store)
|
||||
monkeypatch.setattr(routes, 'get_current_user', lambda req: req.state.user)
|
||||
monkeypatch.setattr(routes, '_skill_test_jobs', {})
|
||||
class Manager:
|
||||
def load(self, owner=None):
|
||||
return [{'name': 'demo', 'owner': owner}]
|
||||
pending = store.create(owner='alice', session_id=None, origin_run_id='run', tool_name='bash',
|
||||
content='printf approved', workspace=None, external_untrusted_context_seen=True,
|
||||
capabilities=capabilities_for_action('bash', 'printf approved'))
|
||||
job = {'status': 'awaiting_approval', 'log': [], 'task': 'task',
|
||||
'approval': pending.public_payload(), '_transcript': ['untrusted result'],
|
||||
'_run': {'owner': 'alice', 'md': 'untrusted skill', 'url': 'url', 'model': 'm'}}
|
||||
routes._skill_test_jobs[('alice', 'demo')] = job
|
||||
return endpoint('POST', '/{skill_id}/test-approval', Manager()), store, pending, job
|
||||
|
||||
|
||||
async def test_denied_skill_approval_retires_state_without_spawning(approval_route, monkeypatch):
|
||||
approve, store, pending, job = approval_route
|
||||
async def run(*args, **kwargs):
|
||||
pytest.fail('denial spawned a test')
|
||||
monkeypatch.setattr(routes, '_run_skill_test_job', run)
|
||||
result = await approve(request(body={'approval_id': pending.approval_id, 'decision': 'deny'}), 'demo')
|
||||
assert result == {'ok': True, 'status': 'done', 'decision': 'deny'}
|
||||
assert store.peek(pending.approval_id) is None
|
||||
assert job['verdict']['verdict'] == 'inconclusive'
|
||||
assert not {'approval', '_run', '_transcript'} & job.keys()
|
||||
|
||||
|
||||
@pytest.mark.parametrize('case,status', [('other_owner', 409), ('mismatch', 409), ('consumed', 409), ('invalid_decision', 400)])
|
||||
async def test_invalid_skill_approval_cannot_resume(approval_route, monkeypatch, case, status):
|
||||
approve, store, pending, job = approval_route
|
||||
async def run(*args, **kwargs):
|
||||
pytest.fail('invalid approval resumed a test')
|
||||
monkeypatch.setattr(routes, '_run_skill_test_job', run)
|
||||
owner = 'bob' if case == 'other_owner' else 'alice'
|
||||
if case == 'other_owner':
|
||||
routes._skill_test_jobs[('bob', 'demo')] = job
|
||||
if case == 'consumed':
|
||||
store.consume(pending.approval_id, decision='deny', owner='alice', session_id=None)
|
||||
body = {'approval_id': 'wrong' if case == 'mismatch' else pending.approval_id,
|
||||
'decision': 'approve_task' if case == 'invalid_decision' else 'approve'}
|
||||
with pytest.raises(HTTPException) as error:
|
||||
await approve(request(owner, body), 'demo')
|
||||
assert error.value.status_code == status
|
||||
assert job['status'] == 'awaiting_approval'
|
||||
|
||||
|
||||
async def test_allowed_skill_approval_is_single_action_and_cannot_be_reused(approval_route, monkeypatch):
|
||||
approve, store, pending, job = approval_route
|
||||
resumed = []
|
||||
async def run(*args, **kwargs):
|
||||
resumed.append((args, kwargs))
|
||||
monkeypatch.setattr(routes, '_run_skill_test_job', run)
|
||||
body = {'approval_id': pending.approval_id, 'decision': 'approve'}
|
||||
assert await approve(request(body=body), 'demo') == {'ok': True, 'status': 'running', 'decision': 'approve'}
|
||||
await asyncio.sleep(0)
|
||||
assert len(resumed) == 1 and resumed[0][0][7] == 'alice'
|
||||
grant = resumed[0][1]['exact_approval']
|
||||
assert grant.pending is pending and grant.allow_remaining_actions is False
|
||||
assert grant.grants_chat_session is False
|
||||
assert store.peek(pending.approval_id) is None
|
||||
messages = resumed[0][1]['messages']
|
||||
tainted = [m for m in messages if m.get('metadata', {}).get('tool_gate_untrusted')]
|
||||
assert len(tainted) == 2
|
||||
assert any('untrusted result' in m['content'] for m in tainted)
|
||||
with pytest.raises(HTTPException) as error:
|
||||
await approve(request(body=body), 'demo')
|
||||
assert error.value.status_code == 409
|
||||
assert len(resumed) == 1
|
||||
|
||||
|
||||
@pytest.mark.parametrize('scope,selected,skip,names,expected_scope', [
|
||||
('all', None, False, ['draft', 'published', 'checked'], 'all'),
|
||||
('drafts', None, False, ['draft'], 'unchecked'),
|
||||
('all', ['published', 'draft', 'draft', 'builtin', 'missing'], False, ['published', 'draft'], 'selected'),
|
||||
('all', None, True, ['draft', 'published'], 'all'),
|
||||
])
|
||||
def test_manual_audit_selection_preserves_published_and_builtin_rules(scope, selected, skip, names, expected_scope):
|
||||
class Manager:
|
||||
def load(self, owner=None):
|
||||
assert owner == 'alice'
|
||||
return [{'name': 'draft', 'status': 'draft'}, {'name': 'published', 'status': 'published'},
|
||||
{'name': 'checked', 'audit_verdict': 'pass'}, {'name': 'builtin', 'source': 'builtin'}]
|
||||
assert job_coordinator.select_audit_names(Manager(), 'alice', scope, selected, skip) == (names, expected_scope)
|
||||
@@ -255,7 +255,8 @@ async def test_read_file_dispatch_blocks_etc_shadow(monkeypatch):
|
||||
security_context=NO_TOOL_SECURITY_CONTEXT,
|
||||
)
|
||||
assert result.get("failure_kind") == "resource_identity_denied"
|
||||
assert "sealed resource root" in (result.get("error") or "")
|
||||
assert "outside the sealed roots" in (result.get("error") or "")
|
||||
assert result.get("blocked") is True
|
||||
assert result.get("exit_code") == 1
|
||||
|
||||
|
||||
@@ -285,7 +286,8 @@ async def test_write_file_dispatch_blocks_authorized_keys(monkeypatch):
|
||||
security_context=NO_TOOL_SECURITY_CONTEXT,
|
||||
)
|
||||
assert result.get("failure_kind") == "resource_identity_denied"
|
||||
assert "sealed resource root" in (result.get("error") or "")
|
||||
assert "outside the sealed roots" in (result.get("error") or "")
|
||||
assert result.get("blocked") is True
|
||||
assert result.get("exit_code") == 1
|
||||
|
||||
|
||||
@@ -349,7 +351,8 @@ async def test_write_file_dispatch_blocks_cron(monkeypatch):
|
||||
security_context=NO_TOOL_SECURITY_CONTEXT,
|
||||
)
|
||||
assert result.get("failure_kind") == "resource_identity_denied"
|
||||
assert "sealed resource root" in (result.get("error") or "")
|
||||
assert "outside the sealed roots" in (result.get("error") or "")
|
||||
assert result.get("blocked") is True
|
||||
assert result.get("exit_code") == 1
|
||||
@pytest.mark.parametrize("filename", ["auth.json", "app.db", "settings.json"])
|
||||
def test_application_secrets_are_sensitive_paths(filename):
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
"""Space in the image editor must type/pan, never toggle a hovered card."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
UI = (ROOT / "static/js/ui.js").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_space_toggles_are_disabled_while_the_editor_is_open():
|
||||
start = UI.index("document.addEventListener('keydown', (e) => {\n if (e.code !== 'Space'")
|
||||
block = UI[start:start + 900]
|
||||
guard = block.index("if (document.body?.classList.contains('ge-editor-open')) return;")
|
||||
card = block.index("if (hoveredToggleCard && _isSpaceVisible(hoveredToggleCard)) {")
|
||||
assert guard < card, "the editor guard must run before any card toggle"
|
||||
|
||||
|
||||
def test_space_always_types_in_a_text_field_even_outside_the_hovered_surface():
|
||||
start = UI.index("function _spaceIsBlocked(e, surface) {")
|
||||
end = UI.index("function _activateSpaceCard(card) {")
|
||||
block = UI[start:end]
|
||||
assert "if (_isTextEditingTarget(target)) return true;" in block
|
||||
assert "_isTextEditingTarget(target)) return !surface" not in block
|
||||
@@ -0,0 +1,888 @@
|
||||
import io
|
||||
import json
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
from fastapi import APIRouter, FastAPI, Request
|
||||
from fastapi.testclient import TestClient
|
||||
from PIL import Image
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def upload_client(tmp_path, monkeypatch):
|
||||
from routes import upload_routes
|
||||
from src.upload_handler import UploadHandler
|
||||
|
||||
upload_dir = tmp_path / "uploads"
|
||||
upload_dir.mkdir()
|
||||
file_id = "a" * 32 + ".png"
|
||||
image_path = upload_dir / file_id
|
||||
Image.new("RGB", (640, 360), "red").save(image_path)
|
||||
(upload_dir / "uploads.json").write_text(
|
||||
json.dumps({"alice:image": {
|
||||
"id": file_id,
|
||||
"path": str(image_path),
|
||||
"name": "image.png",
|
||||
"mime": "image/png",
|
||||
"owner": "alice",
|
||||
}}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
cache_dir = upload_dir / ".vision"
|
||||
cache_dir.mkdir()
|
||||
cache_path = cache_dir / f"{file_id}.txt"
|
||||
cache_path.write_text("A red image", encoding="utf-8")
|
||||
monkeypatch.setattr(upload_routes, "router", APIRouter(prefix="/api/upload"))
|
||||
handler = UploadHandler(str(tmp_path), str(upload_dir))
|
||||
router, _cleanup = upload_routes.setup_upload_routes(handler)
|
||||
app = FastAPI()
|
||||
app.state.auth_manager = SimpleNamespace(
|
||||
is_configured=True,
|
||||
is_admin=lambda user: user == "admin",
|
||||
)
|
||||
|
||||
@app.middleware("http")
|
||||
async def set_user(request: Request, call_next):
|
||||
user = request.headers.get("x-test-user")
|
||||
if user:
|
||||
request.state.current_user = user
|
||||
if request.headers.get("x-test-api-token"):
|
||||
request.state.current_user = "api"
|
||||
request.state.api_token = True
|
||||
request.state.api_token_owner = request.headers.get("x-test-api-token-owner")
|
||||
scopes_header = request.headers.get("x-test-api-token-scopes")
|
||||
if scopes_header is not None:
|
||||
request.state.api_token_scopes = [s.strip() for s in scopes_header.split(",") if s.strip()]
|
||||
else:
|
||||
request.state.api_token_scopes = ["chat"]
|
||||
return await call_next(request)
|
||||
|
||||
app.include_router(router)
|
||||
with TestClient(app) as client:
|
||||
yield client, file_id, image_path, cache_path
|
||||
|
||||
|
||||
@pytest.mark.parametrize("auth_enabled", ["false", " false ", "true"])
|
||||
@pytest.mark.parametrize("operation", ["download", "thumbnail", "read_vision", "edit_vision"])
|
||||
def test_upload_access_after_disabling_login(upload_client, monkeypatch, auth_enabled, operation):
|
||||
monkeypatch.setenv("AUTH_ENABLED", auth_enabled)
|
||||
client, file_id, image_path, cache_path = upload_client
|
||||
url = f"/api/upload/{file_id}"
|
||||
if operation == "edit_vision":
|
||||
response = client.put(f"{url}/vision", json={"text": "Edited description"})
|
||||
elif operation == "read_vision":
|
||||
response = client.get(f"{url}/vision")
|
||||
else:
|
||||
response = client.get(url, params={"thumb": int(operation == "thumbnail")})
|
||||
|
||||
if auth_enabled.strip() == "true":
|
||||
assert response.status_code == 403
|
||||
assert cache_path.read_text(encoding="utf-8") == "A red image"
|
||||
return
|
||||
|
||||
assert response.status_code == 200, response.text
|
||||
if operation == "download":
|
||||
assert response.content == image_path.read_bytes()
|
||||
elif operation == "thumbnail":
|
||||
assert response.headers["content-type"] == "image/jpeg"
|
||||
assert (image_path.parent / ".thumbs" / f"{file_id}.jpg").is_file()
|
||||
elif operation == "read_vision":
|
||||
assert response.json() == {"text": "A red image", "cached": True}
|
||||
else:
|
||||
assert response.json() == {"ok": True}
|
||||
assert cache_path.read_text(encoding="utf-8") == "Edited description"
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# 1. AUTH_ENABLED=true with auth_manager.is_configured=False
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
@pytest.mark.parametrize("operation", ["download", "thumbnail", "read_vision", "edit_vision"])
|
||||
def test_unconfigured_auth_manager_fails_closed(upload_client, monkeypatch, operation):
|
||||
"""When AUTH_ENABLED=true and auth_manager is not configured (e.g. first-run
|
||||
before initial setup is complete), existing uploads must NOT be exposed
|
||||
and modification attempts must be rejected with 401 Setup required."""
|
||||
monkeypatch.setenv("AUTH_ENABLED", "true")
|
||||
client, file_id, image_path, cache_path = upload_client
|
||||
client.app.state.auth_manager = SimpleNamespace(
|
||||
is_configured=False,
|
||||
is_admin=lambda user: False,
|
||||
)
|
||||
url = f"/api/upload/{file_id}"
|
||||
|
||||
if operation == "edit_vision":
|
||||
response = client.put(f"{url}/vision", json={"text": "Defaced OCR"})
|
||||
elif operation == "read_vision":
|
||||
response = client.get(f"{url}/vision")
|
||||
else:
|
||||
response = client.get(url, params={"thumb": int(operation == "thumbnail")})
|
||||
|
||||
assert response.status_code == 401, response.text
|
||||
if operation == "edit_vision":
|
||||
assert cache_path.read_text(encoding="utf-8") == "A red image"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("caller,expected_status", [
|
||||
(None, 403), # Anonymous caller is rejected with 403 Access denied
|
||||
("bob", 404), # Non-owner non-admin caller is rejected with 404 File not found
|
||||
("alice", 200), # Legitimate owner is allowed
|
||||
("admin", 200), # Admin user is allowed
|
||||
])
|
||||
def test_auth_configured_enforces_owner_isolation(upload_client, monkeypatch, caller, expected_status):
|
||||
"""When AUTH_ENABLED=true and auth_manager IS configured, owner isolation
|
||||
must be strictly enforced: anonymous callers get 403, cross-user callers
|
||||
get 404, while the owner and admin are permitted."""
|
||||
monkeypatch.setenv("AUTH_ENABLED", "true")
|
||||
client, file_id, image_path, _ = upload_client
|
||||
client.app.state.auth_manager = SimpleNamespace(
|
||||
is_configured=True,
|
||||
is_admin=lambda user: user == "admin",
|
||||
)
|
||||
headers = {"x-test-user": caller} if caller else {}
|
||||
|
||||
response = client.get(f"/api/upload/{file_id}", headers=headers)
|
||||
assert response.status_code == expected_status
|
||||
if expected_status == 200:
|
||||
assert response.content == image_path.read_bytes()
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# 2. Finding A: Unconfigured manager in route state must NOT grant access
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
@pytest.mark.parametrize("auth_method", ["cookie", "bearer"])
|
||||
@pytest.mark.parametrize("operation", ["download", "thumbnail", "read_vision", "edit_vision"])
|
||||
def test_finding_a_unconfigured_route_manager_denies_cross_user(upload_client, monkeypatch, auth_method, operation):
|
||||
"""Finding A Regression Test:
|
||||
When AUTH_ENABLED=true and middleware authenticates Bob, but app.state.auth_manager
|
||||
is an unconfigured manager instance (is_configured=False), Bob MUST NOT be granted
|
||||
access to Alice's upload. Access must fail closed with 401 Setup required."""
|
||||
monkeypatch.setenv("AUTH_ENABLED", "true")
|
||||
client, file_id, image_path, cache_path = upload_client
|
||||
|
||||
# Route observes an unconfigured manager
|
||||
client.app.state.auth_manager = SimpleNamespace(
|
||||
is_configured=False,
|
||||
is_admin=lambda user: False,
|
||||
)
|
||||
|
||||
url = f"/api/upload/{file_id}"
|
||||
headers = {}
|
||||
if auth_method == "cookie":
|
||||
headers["x-test-user"] = "bob"
|
||||
else:
|
||||
# Bearer token owned by Bob
|
||||
headers["x-test-api-token"] = "1"
|
||||
headers["x-test-api-token-owner"] = "bob"
|
||||
|
||||
if operation == "edit_vision":
|
||||
response = client.put(f"{url}/vision", headers=headers, json={"text": "Defaced by Bob"})
|
||||
elif operation == "read_vision":
|
||||
response = client.get(f"{url}/vision", headers=headers)
|
||||
else:
|
||||
response = client.get(url, headers=headers, params={"thumb": int(operation == "thumbnail")})
|
||||
|
||||
# Must fail closed: 401 Setup required (never 200)
|
||||
assert response.status_code == 401, f"Expected 401, got {response.status_code}: {response.text}"
|
||||
if operation == "edit_vision":
|
||||
# OCR cache must remain untouched
|
||||
assert cache_path.read_text(encoding="utf-8") == "A red image"
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# 3. Finding B: Loopback requests under infrastructure failure must fail closed
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
@pytest.mark.parametrize("loopback_host", ["127.0.0.1", "::1", "localhost"])
|
||||
@pytest.mark.parametrize("proxy_header", [None, "X-Forwarded-For", "CF-Connecting-IP"])
|
||||
@pytest.mark.parametrize("caller", [None, "bob", "admin"])
|
||||
def test_finding_b_loopback_infrastructure_failure_fails_closed(
|
||||
upload_client, monkeypatch, loopback_host, proxy_header, caller
|
||||
):
|
||||
"""Finding B Regression Test:
|
||||
When AUTH_ENABLED=true and app.state.auth_manager is None (infrastructure failure),
|
||||
loopback requests (127.0.0.1, ::1, localhost) MUST NOT bypass authentication or
|
||||
gain access to uploads. All requests must fail closed with 503 Service Unavailable."""
|
||||
monkeypatch.setenv("AUTH_ENABLED", "true")
|
||||
client, file_id, _, cache_path = upload_client
|
||||
if hasattr(client.app.state, "auth_manager"):
|
||||
delattr(client.app.state, "auth_manager")
|
||||
|
||||
headers = {}
|
||||
if caller:
|
||||
headers["x-test-user"] = caller
|
||||
if proxy_header:
|
||||
headers[proxy_header] = "203.0.113.195"
|
||||
|
||||
url = f"/api/upload/{file_id}"
|
||||
|
||||
# Test download
|
||||
resp_dl = client.get(url, headers=headers)
|
||||
assert resp_dl.status_code == 503, f"Loopback host {loopback_host} leaked file: {resp_dl.status_code}"
|
||||
|
||||
# Test thumbnail
|
||||
resp_th = client.get(url, headers=headers, params={"thumb": 1})
|
||||
assert resp_th.status_code == 503
|
||||
|
||||
# Test vision read
|
||||
resp_vr = client.get(f"{url}/vision", headers=headers)
|
||||
assert resp_vr.status_code == 503
|
||||
|
||||
# Test vision edit
|
||||
resp_ve = client.put(f"{url}/vision", headers=headers, json={"text": "Defaced"})
|
||||
assert resp_ve.status_code == 503
|
||||
assert cache_path.read_text(encoding="utf-8") == "A red image"
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# 4. Absent auth_manager (infrastructure failure) fail-closed behavior
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
@pytest.mark.parametrize("operation", ["download", "thumbnail", "read_vision", "edit_vision"])
|
||||
def test_absent_auth_manager_fails_closed(upload_client, monkeypatch, operation):
|
||||
"""When AUTH_ENABLED=true and auth_manager is absent from app.state (unexpected
|
||||
infrastructure failure), upload routes must fail closed (503 Service Unavailable)
|
||||
rather than failing open."""
|
||||
monkeypatch.setenv("AUTH_ENABLED", "true")
|
||||
client, file_id, image_path, cache_path = upload_client
|
||||
if hasattr(client.app.state, "auth_manager"):
|
||||
delattr(client.app.state, "auth_manager")
|
||||
|
||||
url = f"/api/upload/{file_id}"
|
||||
if operation == "edit_vision":
|
||||
response = client.put(f"{url}/vision", json={"text": "No auth_manager edit"})
|
||||
elif operation == "read_vision":
|
||||
response = client.get(f"{url}/vision")
|
||||
else:
|
||||
response = client.get(url, params={"thumb": int(operation == "thumbnail")})
|
||||
|
||||
assert response.status_code == 503, response.text
|
||||
if operation == "edit_vision":
|
||||
assert cache_path.read_text(encoding="utf-8") == "A red image"
|
||||
|
||||
|
||||
def test_absent_auth_manager_restoring_auth_enforces_access(upload_client, monkeypatch):
|
||||
"""Verify that when auth_manager is absent, routes fail closed (503),
|
||||
and attaching a configured auth_manager enforces full owner isolation."""
|
||||
monkeypatch.setenv("AUTH_ENABLED", "true")
|
||||
client, file_id, _, _ = upload_client
|
||||
|
||||
# First, verify access fails closed when auth_manager is absent
|
||||
if hasattr(client.app.state, "auth_manager"):
|
||||
delattr(client.app.state, "auth_manager")
|
||||
assert client.get(f"/api/upload/{file_id}").status_code == 503
|
||||
|
||||
# Now attach configured auth_manager: anonymous access must fail closed (403),
|
||||
# wrong owner gets 404, owner and admin get 200.
|
||||
client.app.state.auth_manager = SimpleNamespace(
|
||||
is_configured=True,
|
||||
is_admin=lambda user: user == "admin",
|
||||
)
|
||||
assert client.get(f"/api/upload/{file_id}").status_code == 403
|
||||
assert client.get(f"/api/upload/{file_id}", headers={"x-test-user": "bob"}).status_code == 404
|
||||
assert client.get(f"/api/upload/{file_id}", headers={"x-test-user": "alice"}).status_code == 200
|
||||
assert client.get(f"/api/upload/{file_id}", headers={"x-test-user": "admin"}).status_code == 200
|
||||
|
||||
|
||||
def test_authenticated_cross_user_with_absent_auth_manager_fails_closed(upload_client, monkeypatch):
|
||||
"""When AUTH_ENABLED=true, an authenticated non-owner (e.g. Bob) must NOT be able
|
||||
to access Alice's upload when app.state.auth_manager is absent/None.
|
||||
The route must fail closed (503 Service Unavailable)."""
|
||||
monkeypatch.setenv("AUTH_ENABLED", "true")
|
||||
client, file_id, _, cache_path = upload_client
|
||||
if hasattr(client.app.state, "auth_manager"):
|
||||
delattr(client.app.state, "auth_manager")
|
||||
|
||||
# Bob is authenticated but auth_manager is absent on app.state -> 503
|
||||
resp_download = client.get(f"/api/upload/{file_id}", headers={"x-test-user": "bob"})
|
||||
assert resp_download.status_code == 503
|
||||
|
||||
resp_thumb = client.get(f"/api/upload/{file_id}?thumb=1", headers={"x-test-user": "bob"})
|
||||
assert resp_thumb.status_code == 503
|
||||
|
||||
resp_read_vision = client.get(f"/api/upload/{file_id}/vision", headers={"x-test-user": "bob"})
|
||||
assert resp_read_vision.status_code == 503
|
||||
|
||||
resp_edit_vision = client.put(f"/api/upload/{file_id}/vision", headers={"x-test-user": "bob"}, json={"text": "Defaced"})
|
||||
assert resp_edit_vision.status_code == 503
|
||||
assert cache_path.read_text(encoding="utf-8") == "A red image"
|
||||
|
||||
|
||||
def test_absent_auth_manager_allows_access_when_auth_disabled(upload_client, monkeypatch):
|
||||
"""When AUTH_ENABLED=false, absent auth_manager must NOT block single-user access."""
|
||||
monkeypatch.setenv("AUTH_ENABLED", "false")
|
||||
client, file_id, image_path, cache_path = upload_client
|
||||
if hasattr(client.app.state, "auth_manager"):
|
||||
delattr(client.app.state, "auth_manager")
|
||||
|
||||
assert client.get(f"/api/upload/{file_id}").status_code == 200
|
||||
assert client.get(f"/api/upload/{file_id}?thumb=1").status_code == 200
|
||||
assert client.get(f"/api/upload/{file_id}/vision").status_code == 200
|
||||
assert client.put(f"/api/upload/{file_id}/vision", json={"text": "Single user edit"}).status_code == 200
|
||||
assert cache_path.read_text(encoding="utf-8") == "Single user edit"
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# 5. Bearer token identity & Legacy ownerless upload isolation
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
def test_bearer_token_owner_isolation(upload_client, monkeypatch):
|
||||
"""Bearer tokens must resolve to their actual token owner (effective_user).
|
||||
Alice's token gets 200 for Alice's upload, Bob's token gets 404, Admin's token
|
||||
gets 404 (delegated tokens never inherit admin privileges on other users' files)."""
|
||||
monkeypatch.setenv("AUTH_ENABLED", "true")
|
||||
client, file_id, image_path, cache_path = upload_client
|
||||
client.app.state.auth_manager = SimpleNamespace(
|
||||
is_configured=True,
|
||||
is_admin=lambda user: user == "admin",
|
||||
)
|
||||
|
||||
# Bob's bearer token -> 404 (non-owner)
|
||||
bob_headers = {"x-test-api-token": "1", "x-test-api-token-owner": "bob"}
|
||||
assert client.get(f"/api/upload/{file_id}", headers=bob_headers).status_code == 404
|
||||
assert client.get(f"/api/upload/{file_id}?thumb=1", headers=bob_headers).status_code == 404
|
||||
assert client.get(f"/api/upload/{file_id}/vision", headers=bob_headers).status_code == 404
|
||||
resp_edit = client.put(f"/api/upload/{file_id}/vision", headers=bob_headers, json={"text": "Defaced by Bob token"})
|
||||
assert resp_edit.status_code == 404
|
||||
assert cache_path.read_text(encoding="utf-8") == "A red image"
|
||||
|
||||
# Alice's bearer token -> 200 (legitimate owner)
|
||||
alice_headers = {"x-test-api-token": "1", "x-test-api-token-owner": "alice"}
|
||||
assert client.get(f"/api/upload/{file_id}", headers=alice_headers).status_code == 200
|
||||
assert client.get(f"/api/upload/{file_id}?thumb=1", headers=alice_headers).status_code == 200
|
||||
assert client.get(f"/api/upload/{file_id}/vision", headers=alice_headers).status_code == 200
|
||||
resp_edit_alice = client.put(f"/api/upload/{file_id}/vision", headers=alice_headers, json={"text": "Updated by Alice token"})
|
||||
assert resp_edit_alice.status_code == 200
|
||||
assert cache_path.read_text(encoding="utf-8") == "Updated by Alice token"
|
||||
|
||||
# Admin's bearer token accessing Alice's file -> 404 (delegated token does NOT inherit admin access)
|
||||
admin_headers = {"x-test-api-token": "1", "x-test-api-token-owner": "admin"}
|
||||
assert client.get(f"/api/upload/{file_id}", headers=admin_headers).status_code == 404
|
||||
assert client.get(f"/api/upload/{file_id}?thumb=1", headers=admin_headers).status_code == 404
|
||||
assert client.get(f"/api/upload/{file_id}/vision", headers=admin_headers).status_code == 404
|
||||
resp_edit_admin = client.put(f"/api/upload/{file_id}/vision", headers=admin_headers, json={"text": "Defaced by Admin token"})
|
||||
assert resp_edit_admin.status_code == 404
|
||||
assert cache_path.read_text(encoding="utf-8") == "Updated by Alice token"
|
||||
|
||||
# Interactive Admin session (cookie) accessing Alice's file -> 200
|
||||
admin_cookie_headers = {"x-test-user": "admin"}
|
||||
assert client.get(f"/api/upload/{file_id}", headers=admin_cookie_headers).status_code == 200
|
||||
assert client.get(f"/api/upload/{file_id}?thumb=1", headers=admin_cookie_headers).status_code == 200
|
||||
assert client.get(f"/api/upload/{file_id}/vision", headers=admin_cookie_headers).status_code == 200
|
||||
resp_edit_admin_cookie = client.put(f"/api/upload/{file_id}/vision", headers=admin_cookie_headers, json={"text": "Admin override"})
|
||||
assert resp_edit_admin_cookie.status_code == 200
|
||||
assert cache_path.read_text(encoding="utf-8") == "Admin override"
|
||||
|
||||
|
||||
def test_legacy_ownerless_upload_isolation(tmp_path, monkeypatch):
|
||||
"""Legacy ownerless uploads (owner=None in uploads.json) must remain
|
||||
administrator-only for interactive sessions when AUTH_ENABLED=true:
|
||||
- Anonymous callers get 403 Access denied
|
||||
- Regular authenticated users get 404 File not found
|
||||
- Delegated bearer tokens (even admin-owned) get 404 File not found
|
||||
- Verified interactive administrators get 200 OK"""
|
||||
from routes import upload_routes
|
||||
from src.upload_handler import UploadHandler
|
||||
|
||||
monkeypatch.setenv("AUTH_ENABLED", "true")
|
||||
upload_dir = tmp_path / "uploads_legacy"
|
||||
upload_dir.mkdir()
|
||||
file_id = "f" * 32 + ".png"
|
||||
img_path = upload_dir / file_id
|
||||
Image.new("RGB", (100, 100), "blue").save(img_path)
|
||||
(upload_dir / "uploads.json").write_text(
|
||||
json.dumps({"legacy_key": {
|
||||
"id": file_id,
|
||||
"path": str(img_path),
|
||||
"name": "legacy.png",
|
||||
"mime": "image/png",
|
||||
"owner": None,
|
||||
}}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
monkeypatch.setattr(upload_routes, "router", APIRouter(prefix="/api/upload"))
|
||||
handler = UploadHandler(str(tmp_path), str(upload_dir))
|
||||
router, _cleanup = upload_routes.setup_upload_routes(handler)
|
||||
app = FastAPI()
|
||||
app.state.auth_manager = SimpleNamespace(
|
||||
is_configured=True,
|
||||
is_admin=lambda user: user == "admin",
|
||||
)
|
||||
|
||||
@app.middleware("http")
|
||||
async def set_user(request: Request, call_next):
|
||||
user = request.headers.get("x-test-user")
|
||||
if user:
|
||||
request.state.current_user = user
|
||||
if request.headers.get("x-test-api-token"):
|
||||
request.state.current_user = "api"
|
||||
request.state.api_token = True
|
||||
request.state.api_token_owner = request.headers.get("x-test-api-token-owner")
|
||||
scopes_header = request.headers.get("x-test-api-token-scopes")
|
||||
if scopes_header is not None:
|
||||
request.state.api_token_scopes = [s.strip() for s in scopes_header.split(",") if s.strip()]
|
||||
else:
|
||||
request.state.api_token_scopes = ["chat"]
|
||||
return await call_next(request)
|
||||
|
||||
app.include_router(router)
|
||||
with TestClient(app) as client:
|
||||
# Anonymous -> 403
|
||||
assert client.get(f"/api/upload/{file_id}").status_code == 403
|
||||
assert client.get(f"/api/upload/{file_id}?thumb=1").status_code == 403
|
||||
assert client.get(f"/api/upload/{file_id}/vision").status_code == 403
|
||||
|
||||
# Non-admin user "bob" (interactive cookie) -> 404
|
||||
assert client.get(f"/api/upload/{file_id}", headers={"x-test-user": "bob"}).status_code == 404
|
||||
assert client.get(f"/api/upload/{file_id}?thumb=1", headers={"x-test-user": "bob"}).status_code == 404
|
||||
assert client.get(f"/api/upload/{file_id}/vision", headers={"x-test-user": "bob"}).status_code == 404
|
||||
|
||||
# Non-admin bearer token "bob" -> 404
|
||||
bob_bearer = {"x-test-api-token": "1", "x-test-api-token-owner": "bob"}
|
||||
assert client.get(f"/api/upload/{file_id}", headers=bob_bearer).status_code == 404
|
||||
|
||||
# Admin bearer token -> 404 (delegated token never inherits admin privileges on ownerless files)
|
||||
admin_bearer = {"x-test-api-token": "1", "x-test-api-token-owner": "admin", "x-test-api-token-scopes": "chat"}
|
||||
assert client.get(f"/api/upload/{file_id}", headers=admin_bearer).status_code == 404
|
||||
assert client.get(f"/api/upload/{file_id}?thumb=1", headers=admin_bearer).status_code == 404
|
||||
assert client.get(f"/api/upload/{file_id}/vision", headers=admin_bearer).status_code == 404
|
||||
assert client.put(f"/api/upload/{file_id}/vision", headers=admin_bearer, json={"text": "Defaced"}).status_code == 404
|
||||
|
||||
# Admin user "admin" (interactive cookie) -> 200
|
||||
assert client.get(f"/api/upload/{file_id}", headers={"x-test-user": "admin"}).status_code == 200
|
||||
assert client.get(f"/api/upload/{file_id}?thumb=1", headers={"x-test-user": "admin"}).status_code == 200
|
||||
assert client.get(f"/api/upload/{file_id}/vision", headers={"x-test-user": "admin"}).status_code == 200
|
||||
|
||||
|
||||
def test_delegated_token_can_access_own_file_when_owner_is_admin(tmp_path, monkeypatch):
|
||||
"""When a file is owned by 'admin', an admin-owned bearer token with valid scope
|
||||
can access and edit it, but a non-admin token or user cannot."""
|
||||
from routes import upload_routes
|
||||
from src.upload_handler import UploadHandler
|
||||
|
||||
monkeypatch.setenv("AUTH_ENABLED", "true")
|
||||
upload_dir = tmp_path / "uploads_admin_owned"
|
||||
upload_dir.mkdir()
|
||||
file_id = "e" * 32 + ".png"
|
||||
img_path = upload_dir / file_id
|
||||
Image.new("RGB", (100, 100), "green").save(img_path)
|
||||
(upload_dir / "uploads.json").write_text(
|
||||
json.dumps({"admin_key": {
|
||||
"id": file_id,
|
||||
"path": str(img_path),
|
||||
"name": "admin_file.png",
|
||||
"mime": "image/png",
|
||||
"owner": "admin",
|
||||
}}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
cache_dir = upload_dir / ".vision"
|
||||
cache_dir.mkdir()
|
||||
cache_path = cache_dir / f"{file_id}.txt"
|
||||
cache_path.write_text("Admin green image", encoding="utf-8")
|
||||
|
||||
monkeypatch.setattr(upload_routes, "router", APIRouter(prefix="/api/upload"))
|
||||
handler = UploadHandler(str(tmp_path), str(upload_dir))
|
||||
router, _cleanup = upload_routes.setup_upload_routes(handler)
|
||||
app = FastAPI()
|
||||
app.state.auth_manager = SimpleNamespace(
|
||||
is_configured=True,
|
||||
is_admin=lambda user: user == "admin",
|
||||
)
|
||||
|
||||
@app.middleware("http")
|
||||
async def set_user(request: Request, call_next):
|
||||
user = request.headers.get("x-test-user")
|
||||
if user:
|
||||
request.state.current_user = user
|
||||
if request.headers.get("x-test-api-token"):
|
||||
request.state.current_user = "api"
|
||||
request.state.api_token = True
|
||||
request.state.api_token_owner = request.headers.get("x-test-api-token-owner")
|
||||
scopes_header = request.headers.get("x-test-api-token-scopes")
|
||||
if scopes_header is not None:
|
||||
request.state.api_token_scopes = [s.strip() for s in scopes_header.split(",") if s.strip()]
|
||||
else:
|
||||
request.state.api_token_scopes = ["chat"]
|
||||
return await call_next(request)
|
||||
|
||||
app.include_router(router)
|
||||
with TestClient(app) as client:
|
||||
# Admin's bearer token accessing its own file -> 200
|
||||
admin_bearer = {"x-test-api-token": "1", "x-test-api-token-owner": "admin", "x-test-api-token-scopes": "chat"}
|
||||
assert client.get(f"/api/upload/{file_id}", headers=admin_bearer).status_code == 200
|
||||
assert client.get(f"/api/upload/{file_id}/vision", headers=admin_bearer).status_code == 200
|
||||
resp_put = client.put(f"/api/upload/{file_id}/vision", headers=admin_bearer, json={"text": "Admin updated own"})
|
||||
assert resp_put.status_code == 200
|
||||
assert cache_path.read_text(encoding="utf-8") == "Admin updated own"
|
||||
|
||||
# Bob's bearer token accessing admin's file -> 404
|
||||
bob_bearer = {"x-test-api-token": "1", "x-test-api-token-owner": "bob", "x-test-api-token-scopes": "chat"}
|
||||
assert client.get(f"/api/upload/{file_id}", headers=bob_bearer).status_code == 404
|
||||
assert client.put(f"/api/upload/{file_id}/vision", headers=bob_bearer, json={"text": "Bob deface"}).status_code == 404
|
||||
assert cache_path.read_text(encoding="utf-8") == "Admin updated own"
|
||||
|
||||
# Bob's interactive session accessing admin's file -> 404
|
||||
assert client.get(f"/api/upload/{file_id}", headers={"x-test-user": "bob"}).status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.parametrize("scope,can_read,can_write", [
|
||||
("chat", True, True),
|
||||
("documents:write", True, True),
|
||||
("documents:read", True, False),
|
||||
("todos:read", False, False),
|
||||
("todos:write", False, False),
|
||||
("", False, False),
|
||||
])
|
||||
def test_delegated_token_scope_matrix(upload_client, monkeypatch, scope, can_read, can_write):
|
||||
"""Test token scope enforcement on legitimate owner's upload (Alice's file):
|
||||
- chat and documents:write allow both read and write
|
||||
- documents:read allows read but rejects write with 403
|
||||
- unrelated scopes (todos:read, todos:write) or empty scopes reject with 403
|
||||
- on rejected write, persisted OCR content remains untouched."""
|
||||
monkeypatch.setenv("AUTH_ENABLED", "true")
|
||||
client, file_id, image_path, cache_path = upload_client
|
||||
initial_text = cache_path.read_text(encoding="utf-8")
|
||||
|
||||
headers = {
|
||||
"x-test-api-token": "1",
|
||||
"x-test-api-token-owner": "alice",
|
||||
"x-test-api-token-scopes": scope,
|
||||
}
|
||||
|
||||
# Test read operations
|
||||
resp_dl = client.get(f"/api/upload/{file_id}", headers=headers)
|
||||
resp_th = client.get(f"/api/upload/{file_id}?thumb=1", headers=headers)
|
||||
resp_vr = client.get(f"/api/upload/{file_id}/vision", headers=headers)
|
||||
|
||||
if can_read:
|
||||
assert resp_dl.status_code == 200
|
||||
assert resp_th.status_code == 200
|
||||
assert resp_vr.status_code == 200
|
||||
else:
|
||||
assert resp_dl.status_code == 403
|
||||
assert resp_th.status_code == 403
|
||||
assert resp_vr.status_code == 403
|
||||
|
||||
# Test write operation (OCR edit)
|
||||
resp_ve = client.put(f"/api/upload/{file_id}/vision", headers=headers, json={"text": f"Edit by {scope}"})
|
||||
if can_write:
|
||||
assert resp_ve.status_code == 200
|
||||
assert cache_path.read_text(encoding="utf-8") == f"Edit by {scope}"
|
||||
else:
|
||||
assert resp_ve.status_code == 403
|
||||
# Persisted content must remain completely unchanged
|
||||
assert cache_path.read_text(encoding="utf-8") == initial_text
|
||||
|
||||
|
||||
@pytest.mark.parametrize("sentinel_owner", ["api", "internal-tool", "demo", "system", ""])
|
||||
def test_delegated_token_sentinel_or_missing_owner_denied(upload_client, monkeypatch, sentinel_owner):
|
||||
"""Delegated tokens with sentinel owner names or missing owner must be rejected with 403."""
|
||||
monkeypatch.setenv("AUTH_ENABLED", "true")
|
||||
client, file_id, _, cache_path = upload_client
|
||||
initial_text = cache_path.read_text(encoding="utf-8")
|
||||
|
||||
headers = {"x-test-api-token": "1", "x-test-api-token-scopes": "chat"}
|
||||
if sentinel_owner:
|
||||
headers["x-test-api-token-owner"] = sentinel_owner
|
||||
|
||||
resp_dl = client.get(f"/api/upload/{file_id}", headers=headers)
|
||||
assert resp_dl.status_code == 403
|
||||
|
||||
resp_ve = client.put(f"/api/upload/{file_id}/vision", headers=headers, json={"text": "Defaced by sentinel"})
|
||||
assert resp_ve.status_code == 403
|
||||
assert cache_path.read_text(encoding="utf-8") == initial_text
|
||||
|
||||
|
||||
@pytest.mark.parametrize("sentinel_user", ["api", "internal-tool", "demo", "system"])
|
||||
def test_interactive_sentinel_user_denied(upload_client, monkeypatch, sentinel_user):
|
||||
"""Interactive requests claiming sentinel user identities must be rejected with 403."""
|
||||
monkeypatch.setenv("AUTH_ENABLED", "true")
|
||||
client, file_id, _, cache_path = upload_client
|
||||
|
||||
headers = {"x-test-user": sentinel_user}
|
||||
assert client.get(f"/api/upload/{file_id}", headers=headers).status_code == 403
|
||||
assert client.put(f"/api/upload/{file_id}/vision", headers=headers, json={"text": "Defaced"}).status_code == 403
|
||||
assert cache_path.read_text(encoding="utf-8") == "A red image"
|
||||
|
||||
|
||||
def test_upload_post_delegated_token_authorization(tmp_path, monkeypatch):
|
||||
"""POST /api/upload with AUTH_ENABLED=true and delegated bearer tokens:
|
||||
- Token with 'chat' or 'documents:write' succeeds and sets file owner to token owner
|
||||
- Token with 'documents:read' is rejected with 403
|
||||
- Token with 'todos:read' is rejected with 403
|
||||
- Token with missing owner is rejected with 403
|
||||
- Token with sentinel owner ('api') is rejected with 403"""
|
||||
from routes import upload_routes
|
||||
from src.upload_handler import UploadHandler
|
||||
|
||||
monkeypatch.setenv("AUTH_ENABLED", "true")
|
||||
upload_dir = tmp_path / "uploads_post_token"
|
||||
upload_dir.mkdir()
|
||||
monkeypatch.setattr(upload_routes, "router", APIRouter(prefix="/api/upload"))
|
||||
handler = UploadHandler(str(tmp_path), str(upload_dir))
|
||||
router, _cleanup = upload_routes.setup_upload_routes(handler)
|
||||
app = FastAPI()
|
||||
app.state.auth_manager = SimpleNamespace(
|
||||
is_configured=True,
|
||||
is_admin=lambda user: user == "admin",
|
||||
)
|
||||
|
||||
@app.middleware("http")
|
||||
async def set_user(request: Request, call_next):
|
||||
user = request.headers.get("x-test-user")
|
||||
if user:
|
||||
request.state.current_user = user
|
||||
if request.headers.get("x-test-api-token"):
|
||||
request.state.current_user = "api"
|
||||
request.state.api_token = True
|
||||
request.state.api_token_owner = request.headers.get("x-test-api-token-owner")
|
||||
scopes_header = request.headers.get("x-test-api-token-scopes")
|
||||
if scopes_header is not None:
|
||||
request.state.api_token_scopes = [s.strip() for s in scopes_header.split(",") if s.strip()]
|
||||
else:
|
||||
request.state.api_token_scopes = ["chat"]
|
||||
return await call_next(request)
|
||||
|
||||
app.include_router(router)
|
||||
|
||||
with TestClient(app) as client:
|
||||
# 1. Valid token with 'chat' scope
|
||||
resp_chat = client.post(
|
||||
"/api/upload",
|
||||
files=[("files", ("chat.txt", io.BytesIO(b"chat content"), "text/plain"))],
|
||||
headers={"x-test-api-token": "1", "x-test-api-token-owner": "alice", "x-test-api-token-scopes": "chat"},
|
||||
)
|
||||
assert resp_chat.status_code == 200
|
||||
chat_id = resp_chat.json()["files"][0]["id"]
|
||||
index = handler._load_upload_index()
|
||||
rec_chat = next(v for v in index.values() if v.get("id") == chat_id)
|
||||
assert rec_chat["owner"] == "alice"
|
||||
|
||||
# 2. Valid token with 'documents:write' scope
|
||||
resp_doc = client.post(
|
||||
"/api/upload",
|
||||
files=[("files", ("doc.txt", io.BytesIO(b"doc content"), "text/plain"))],
|
||||
headers={"x-test-api-token": "1", "x-test-api-token-owner": "bob", "x-test-api-token-scopes": "documents:write"},
|
||||
)
|
||||
assert resp_doc.status_code == 200
|
||||
doc_id = resp_doc.json()["files"][0]["id"]
|
||||
index = handler._load_upload_index()
|
||||
rec_doc = next(v for v in index.values() if v.get("id") == doc_id)
|
||||
assert rec_doc["owner"] == "bob"
|
||||
|
||||
# 3. Token with read-only 'documents:read' scope -> 403
|
||||
resp_ro = client.post(
|
||||
"/api/upload",
|
||||
files=[("files", ("ro.txt", io.BytesIO(b"ro content"), "text/plain"))],
|
||||
headers={"x-test-api-token": "1", "x-test-api-token-owner": "alice", "x-test-api-token-scopes": "documents:read"},
|
||||
)
|
||||
assert resp_ro.status_code == 403
|
||||
|
||||
# 4. Token with unrelated 'todos:read' scope -> 403
|
||||
resp_unrelated = client.post(
|
||||
"/api/upload",
|
||||
files=[("files", ("unrelated.txt", io.BytesIO(b"content"), "text/plain"))],
|
||||
headers={"x-test-api-token": "1", "x-test-api-token-owner": "alice", "x-test-api-token-scopes": "todos:read"},
|
||||
)
|
||||
assert resp_unrelated.status_code == 403
|
||||
|
||||
# 5. Token with missing owner -> 403
|
||||
resp_no_owner = client.post(
|
||||
"/api/upload",
|
||||
files=[("files", ("no_owner.txt", io.BytesIO(b"content"), "text/plain"))],
|
||||
headers={"x-test-api-token": "1", "x-test-api-token-scopes": "chat"},
|
||||
)
|
||||
assert resp_no_owner.status_code == 403
|
||||
|
||||
# 6. Token with sentinel owner 'api' -> 403
|
||||
resp_sentinel = client.post(
|
||||
"/api/upload",
|
||||
files=[("files", ("sentinel.txt", io.BytesIO(b"content"), "text/plain"))],
|
||||
headers={"x-test-api-token": "1", "x-test-api-token-owner": "api", "x-test-api-token-scopes": "chat"},
|
||||
)
|
||||
assert resp_sentinel.status_code == 403
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# 3. POST /api/upload with AUTH_ENABLED=false
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
def test_upload_post_succeeds_without_login(tmp_path, monkeypatch):
|
||||
"""POST /api/upload with AUTH_ENABLED=false succeeds without authentication,
|
||||
persists the file, stores owner=None, and can be retrieved via GET."""
|
||||
from routes import upload_routes
|
||||
from src.upload_handler import UploadHandler
|
||||
|
||||
monkeypatch.setenv("AUTH_ENABLED", "false")
|
||||
upload_dir = tmp_path / "uploads_post"
|
||||
upload_dir.mkdir()
|
||||
monkeypatch.setattr(upload_routes, "router", APIRouter(prefix="/api/upload"))
|
||||
handler = UploadHandler(str(tmp_path), str(upload_dir))
|
||||
router, _cleanup = upload_routes.setup_upload_routes(handler)
|
||||
app = FastAPI()
|
||||
app.state.auth_manager = SimpleNamespace(is_configured=True)
|
||||
app.include_router(router)
|
||||
|
||||
file_bytes = b"Hello, no-login upload test content!"
|
||||
with TestClient(app) as client:
|
||||
response = client.post(
|
||||
"/api/upload",
|
||||
files=[("files", ("test_file.txt", io.BytesIO(file_bytes), "text/plain"))],
|
||||
)
|
||||
|
||||
assert response.status_code == 200, response.text
|
||||
data = response.json()
|
||||
assert "files" in data
|
||||
assert len(data["files"]) == 1
|
||||
item = data["files"][0]
|
||||
assert item["name"] == "test_file.txt"
|
||||
assert item["size"] == len(file_bytes)
|
||||
new_id = item["id"]
|
||||
|
||||
# Check upload index: owner must be None (no authenticated user)
|
||||
index = handler._load_upload_index()
|
||||
record = next((v for v in index.values() if v.get("id") == new_id), None)
|
||||
assert record is not None
|
||||
assert record.get("owner") is None
|
||||
|
||||
# Verify storage isolation: file path is confined inside upload_dir
|
||||
saved_path = record.get("path")
|
||||
assert saved_path and handler.inside_base_dir(saved_path)
|
||||
|
||||
# Verify download of the newly uploaded file succeeds
|
||||
download_resp = client.get(f"/api/upload/{new_id}")
|
||||
assert download_resp.status_code == 200
|
||||
assert download_resp.content == file_bytes
|
||||
|
||||
|
||||
def test_upload_post_preserves_validations_and_constraints(tmp_path, monkeypatch):
|
||||
"""POST /api/upload with AUTH_ENABLED=false continues enforcing validation:
|
||||
empty files, oversized files, and dangerous file types are rejected."""
|
||||
from routes import upload_routes
|
||||
from src.upload_handler import UploadHandler
|
||||
|
||||
monkeypatch.setenv("AUTH_ENABLED", "false")
|
||||
upload_dir = tmp_path / "uploads_val"
|
||||
upload_dir.mkdir()
|
||||
monkeypatch.setattr(upload_routes, "router", APIRouter(prefix="/api/upload"))
|
||||
handler = UploadHandler(str(tmp_path), str(upload_dir))
|
||||
router, _cleanup = upload_routes.setup_upload_routes(handler)
|
||||
app = FastAPI()
|
||||
app.state.auth_manager = SimpleNamespace(is_configured=True)
|
||||
app.include_router(router)
|
||||
|
||||
with TestClient(app) as client:
|
||||
# 1. Empty file rejection
|
||||
resp_empty = client.post(
|
||||
"/api/upload",
|
||||
files=[("files", ("empty.txt", io.BytesIO(b""), "text/plain"))],
|
||||
)
|
||||
assert resp_empty.status_code == 400
|
||||
|
||||
# 2. Oversized file rejection
|
||||
handler.max_upload_size = 10 # 10 bytes limit
|
||||
resp_oversized = client.post(
|
||||
"/api/upload",
|
||||
files=[("files", ("large.txt", io.BytesIO(b"x" * 50), "text/plain"))],
|
||||
)
|
||||
assert resp_oversized.status_code == 400
|
||||
|
||||
# 3. Dangerous file type rejection (executable)
|
||||
resp_dangerous = client.post(
|
||||
"/api/upload",
|
||||
files=[("files", ("malware.exe", io.BytesIO(b"MZ123"), "application/x-msdownload"))],
|
||||
)
|
||||
assert resp_dangerous.status_code == 400
|
||||
|
||||
|
||||
def test_upload_post_sanitizes_filename_and_confines_storage(tmp_path, monkeypatch):
|
||||
"""POST /api/upload with AUTH_ENABLED=false sanitizes path traversal attempts
|
||||
in filenames and confines saved files inside the upload directory."""
|
||||
from routes import upload_routes
|
||||
from src.upload_handler import UploadHandler
|
||||
|
||||
monkeypatch.setenv("AUTH_ENABLED", "false")
|
||||
upload_dir = tmp_path / "uploads_trav"
|
||||
upload_dir.mkdir()
|
||||
monkeypatch.setattr(upload_routes, "router", APIRouter(prefix="/api/upload"))
|
||||
handler = UploadHandler(str(tmp_path), str(upload_dir))
|
||||
router, _cleanup = upload_routes.setup_upload_routes(handler)
|
||||
app = FastAPI()
|
||||
app.state.auth_manager = SimpleNamespace(is_configured=True)
|
||||
app.include_router(router)
|
||||
|
||||
with TestClient(app) as client:
|
||||
resp_traversal = client.post(
|
||||
"/api/upload",
|
||||
files=[("files", ("../../traversal.txt", io.BytesIO(b"safe content"), "text/plain"))],
|
||||
)
|
||||
assert resp_traversal.status_code == 200
|
||||
traversal_id = resp_traversal.json()["files"][0]["id"]
|
||||
index = handler._load_upload_index()
|
||||
rec = next((v for v in index.values() if v.get("id") == traversal_id), None)
|
||||
assert rec is not None
|
||||
assert handler.inside_base_dir(rec["path"])
|
||||
assert "/" not in rec["name"] and "\\" not in rec["name"]
|
||||
|
||||
|
||||
def test_upload_post_no_login_then_auth_enabled_prevents_unauthorized_download(tmp_path, monkeypatch):
|
||||
"""When a file is uploaded in AUTH_ENABLED=false mode (stored with owner=None),
|
||||
and the server subsequently runs with AUTH_ENABLED=true and auth configured,
|
||||
unauthorized downloads are prevented: anonymous callers get 403, non-admin
|
||||
users get 404, and only admins can access."""
|
||||
from routes import upload_routes
|
||||
from src.upload_handler import UploadHandler
|
||||
|
||||
# Step 1: Upload in no-login mode
|
||||
monkeypatch.setenv("AUTH_ENABLED", "false")
|
||||
upload_dir = tmp_path / "uploads_transition"
|
||||
upload_dir.mkdir()
|
||||
monkeypatch.setattr(upload_routes, "router", APIRouter(prefix="/api/upload"))
|
||||
handler = UploadHandler(str(tmp_path), str(upload_dir))
|
||||
router, _cleanup = upload_routes.setup_upload_routes(handler)
|
||||
app = FastAPI()
|
||||
app.state.auth_manager = SimpleNamespace(
|
||||
is_configured=True,
|
||||
is_admin=lambda user: user == "admin",
|
||||
)
|
||||
|
||||
@app.middleware("http")
|
||||
async def set_user(request: Request, call_next):
|
||||
user = request.headers.get("x-test-user")
|
||||
if user:
|
||||
request.state.current_user = user
|
||||
return await call_next(request)
|
||||
|
||||
app.include_router(router)
|
||||
|
||||
file_bytes = b"Sensitive document uploaded in no-login mode"
|
||||
with TestClient(app) as client:
|
||||
upload_resp = client.post(
|
||||
"/api/upload",
|
||||
files=[("files", ("document.txt", io.BytesIO(file_bytes), "text/plain"))],
|
||||
)
|
||||
assert upload_resp.status_code == 200
|
||||
file_id = upload_resp.json()["files"][0]["id"]
|
||||
|
||||
# Step 2: Transition to AUTH_ENABLED=true
|
||||
monkeypatch.setenv("AUTH_ENABLED", "true")
|
||||
|
||||
# Anonymous caller -> 403 Access denied
|
||||
resp_anon = client.get(f"/api/upload/{file_id}")
|
||||
assert resp_anon.status_code == 403
|
||||
|
||||
# Non-admin user "bob" -> 404 File not found (owner isolation)
|
||||
resp_bob = client.get(f"/api/upload/{file_id}", headers={"x-test-user": "bob"})
|
||||
assert resp_bob.status_code == 404
|
||||
|
||||
# Admin user "admin" -> 200 OK
|
||||
resp_admin = client.get(f"/api/upload/{file_id}", headers={"x-test-user": "admin"})
|
||||
assert resp_admin.status_code == 200
|
||||
assert resp_admin.content == file_bytes
|
||||
@@ -318,6 +318,7 @@ def test_put_vision_text_allows_same_owner_to_write_cache(tmp_path, monkeypatch)
|
||||
def test_download_file_survives_corrupted_uploads_json(tmp_path, monkeypatch):
|
||||
# A truncated/corrupt uploads.json must not 500 the download endpoint —
|
||||
# metadata simply becomes unavailable and the file is still served.
|
||||
monkeypatch.setenv("AUTH_ENABLED", "false")
|
||||
handler, alice_id, _bob_id, upload_dir = _make_upload_store(tmp_path, monkeypatch)
|
||||
download_file = _upload_endpoints(handler, monkeypatch)["download_file"]
|
||||
(upload_dir / "uploads.json").write_text('{"alice:h1": {', encoding="utf-8")
|
||||
@@ -332,6 +333,7 @@ def test_download_file_survives_corrupted_uploads_json(tmp_path, monkeypatch):
|
||||
|
||||
def test_put_vision_text_returns_400_on_malformed_json(tmp_path, monkeypatch):
|
||||
# A non-JSON request body must yield 400, not an unhandled JSONDecodeError -> 500.
|
||||
monkeypatch.setenv("AUTH_ENABLED", "false")
|
||||
handler, alice_id, _bob_id, _upload_dir = _make_upload_store(tmp_path, monkeypatch)
|
||||
put_vision_text = _upload_endpoints(handler, monkeypatch)["put_vision_text"]
|
||||
|
||||
|
||||
@@ -0,0 +1,489 @@
|
||||
"""Opt-in ``utility_reasoning_effort`` for utility/background LLM calls.
|
||||
|
||||
The setting is resolved in ``task_llm_call_async`` and sent only to candidates
|
||||
on the utility chain whose model advertises the level. Explicit caller values
|
||||
win; with the setting empty nothing changes.
|
||||
"""
|
||||
import asyncio
|
||||
|
||||
import pytest
|
||||
|
||||
from src import llm_core, task_endpoint
|
||||
from src.chatgpt_subscription import validate_reasoning_effort
|
||||
|
||||
UTIL = ("https://api.openai.example/v1", "gpt-5.5", {})
|
||||
UTIL_FALLBACK = ("https://fallback.example/v1", "gpt-5.5-mini", {})
|
||||
OVERRIDE = ("https://override.example/v1", "gpt-5.5", {})
|
||||
NO_EVIDENCE = ("https://local.example/v1", "tiny-local-model", {})
|
||||
|
||||
|
||||
def _stub_resolvers(monkeypatch, utility):
|
||||
"""Stub the resolvers in both modules that look the utility chain up."""
|
||||
from src import endpoint_resolver
|
||||
|
||||
resolve = lambda prefix, *a, **k: { # noqa: E731
|
||||
"utility": utility, "task": OVERRIDE, "default": NO_EVIDENCE,
|
||||
}[prefix]
|
||||
fallbacks = lambda owner=None: [UTIL_FALLBACK] # noqa: E731
|
||||
for mod in (task_endpoint, endpoint_resolver):
|
||||
monkeypatch.setattr(mod, "resolve_endpoint", resolve)
|
||||
monkeypatch.setattr(mod, "resolve_utility_fallback_candidates", fallbacks)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def chain(monkeypatch):
|
||||
"""Stub the resolvers and capture what reaches llm_call_async per model."""
|
||||
_stub_resolvers(monkeypatch, UTIL)
|
||||
|
||||
async def _quiet(*_a, **_k):
|
||||
return None
|
||||
monkeypatch.setattr(task_endpoint, "wait_for_interactive_quiet", _quiet)
|
||||
|
||||
calls = {}
|
||||
|
||||
async def fake_call(url, model, messages, **kw):
|
||||
calls[url] = kw.get("reasoning_effort")
|
||||
if url != UTIL_FALLBACK[0]:
|
||||
raise RuntimeError("force walk down the chain")
|
||||
return "ok"
|
||||
|
||||
monkeypatch.setattr(llm_core, "llm_call_async", fake_call)
|
||||
return calls
|
||||
|
||||
|
||||
def _set_effort(monkeypatch, value):
|
||||
monkeypatch.setattr(
|
||||
"src.settings.get_user_setting",
|
||||
lambda key, owner="", default=None: value if key == "utility_reasoning_effort" else default,
|
||||
)
|
||||
|
||||
|
||||
def _run(**kw):
|
||||
return asyncio.run(task_endpoint.task_llm_call_async(
|
||||
[{"role": "user", "content": "hi"}], **kw,
|
||||
))
|
||||
|
||||
|
||||
def test_setting_applies_only_to_utility_chain(chain, monkeypatch):
|
||||
_set_effort(monkeypatch, "high")
|
||||
_run()
|
||||
# The Background Tasks route shares the model id with the utility model but
|
||||
# is a different (url, model) route, so it gets no effort.
|
||||
assert chain[OVERRIDE[0]] is None
|
||||
assert chain[UTIL[0]] == "high"
|
||||
assert chain[UTIL_FALLBACK[0]] == "high"
|
||||
|
||||
|
||||
def test_utility_candidate_gets_validated_effort(chain, monkeypatch):
|
||||
_set_effort(monkeypatch, "HIGH")
|
||||
_run()
|
||||
assert chain[UTIL[0]] == "high"
|
||||
|
||||
|
||||
def test_effort_without_evidence_is_dropped(chain, monkeypatch):
|
||||
_set_effort(monkeypatch, "high")
|
||||
_stub_resolvers(monkeypatch, ("https://local.example/v1", "tiny-local-model", {}))
|
||||
_run()
|
||||
assert chain["https://local.example/v1"] is None
|
||||
|
||||
|
||||
def test_unsupported_level_is_dropped(chain, monkeypatch):
|
||||
_set_effort(monkeypatch, "bogus")
|
||||
_run()
|
||||
assert chain[UTIL[0]] is None
|
||||
|
||||
|
||||
def test_empty_setting_changes_nothing(chain, monkeypatch):
|
||||
_set_effort(monkeypatch, "")
|
||||
_run()
|
||||
assert chain and all(effort is None for effort in chain.values())
|
||||
|
||||
|
||||
def test_explicit_caller_effort_wins(chain, monkeypatch):
|
||||
_set_effort(monkeypatch, "high")
|
||||
_run(reasoning_effort="low")
|
||||
assert chain and all(effort == "low" for effort in chain.values())
|
||||
|
||||
|
||||
def test_fallback_factory_can_override_messages_and_kwargs(monkeypatch):
|
||||
seen = []
|
||||
|
||||
async def fake_call(url, model, messages, **kw):
|
||||
seen.append((model, messages, kw.get("reasoning_effort"), kw.get("temperature")))
|
||||
if len(seen) == 1:
|
||||
raise RuntimeError("first fails")
|
||||
return "ok"
|
||||
|
||||
monkeypatch.setattr(llm_core, "llm_call_async", fake_call)
|
||||
|
||||
def factory(index, url, model, headers):
|
||||
return {"kwargs": {"reasoning_effort": f"e{index}"}}
|
||||
|
||||
out = asyncio.run(llm_core.llm_call_async_with_fallback(
|
||||
[("http://a/v1", "m1", {}), ("http://b/v1", "m2", {})],
|
||||
[{"role": "user", "content": "x"}],
|
||||
temperature=0.1,
|
||||
candidate_request_factory=factory,
|
||||
))
|
||||
assert out == "ok"
|
||||
assert [(s[0], s[2], s[3]) for s in seen] == [("m1", "e0", 0.1), ("m2", "e1", 0.1)]
|
||||
|
||||
|
||||
def test_validate_reasoning_effort_needs_evidence():
|
||||
assert validate_reasoning_effort("gpt-5.5", "high") == "high"
|
||||
assert validate_reasoning_effort("tiny-local-model", "high") is None
|
||||
assert validate_reasoning_effort("gpt-5.5", "") is None
|
||||
|
||||
|
||||
def test_chatgpt_wire_carries_effort_and_default_omits_it():
|
||||
sent = llm_core._build_chatgpt_responses_payload(
|
||||
"gpt-5.5", [{"role": "user", "content": "hi"}], 0.3, 64, reasoning_effort="high",
|
||||
)
|
||||
assert sent["reasoning"] == {"effort": "high"}
|
||||
plain = llm_core._build_chatgpt_responses_payload(
|
||||
"gpt-5.5", [{"role": "user", "content": "hi"}], 0.3, 64,
|
||||
)
|
||||
assert "reasoning" not in plain
|
||||
|
||||
|
||||
def test_sync_llm_call_cache_key_includes_effort(monkeypatch):
|
||||
keys = []
|
||||
|
||||
def fake_cached(key):
|
||||
keys.append(key)
|
||||
return "cached"
|
||||
|
||||
monkeypatch.setattr(llm_core, "_get_cached_response", fake_cached)
|
||||
msgs = [{"role": "user", "content": "hi"}]
|
||||
llm_core.llm_call("http://x/v1", "m", msgs)
|
||||
llm_core.llm_call("http://x/v1", "m", msgs, reasoning_effort="high")
|
||||
llm_core.llm_call("http://x/v1", "m", msgs, reasoning_effort="HIGH")
|
||||
assert keys[0] != keys[1]
|
||||
assert keys[1] == keys[2]
|
||||
|
||||
|
||||
def test_setting_is_registered_default_and_per_user():
|
||||
from src.settings import DEFAULT_SETTINGS, _PER_USER_KEYS
|
||||
|
||||
assert DEFAULT_SETTINGS["utility_reasoning_effort"] == ""
|
||||
assert "utility_reasoning_effort" in _PER_USER_KEYS
|
||||
|
||||
|
||||
# --- effort_for_call: setting, then the chat session's own effort ---
|
||||
|
||||
CHAT = ("https://chat.example/v1", "gpt-5.5", {})
|
||||
|
||||
|
||||
class _Sess:
|
||||
def __init__(self, thinking_mode="effort:low", url=CHAT[0], model=CHAT[1], owner="alice"):
|
||||
self.thinking_mode = thinking_mode
|
||||
self.endpoint_url = url
|
||||
self.model = model
|
||||
self.owner = owner
|
||||
self.headers = {}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def util(monkeypatch):
|
||||
"""Utility chain = {UTIL}; the setting is controlled via ``settings[owner]``."""
|
||||
from src import utility_effort
|
||||
|
||||
settings = {}
|
||||
monkeypatch.setattr(utility_effort, "utility_routes", lambda owner=None: {(UTIL[0], UTIL[1])})
|
||||
monkeypatch.setattr(
|
||||
"src.settings.get_user_setting",
|
||||
lambda key, owner="", default=None: (
|
||||
settings.get(owner, "") if key == "utility_reasoning_effort" else default
|
||||
),
|
||||
)
|
||||
util_mod = utility_effort
|
||||
util_mod.settings = settings
|
||||
return util_mod
|
||||
|
||||
|
||||
def test_setting_applies_to_utility_route_and_wins_over_chat_effort(util):
|
||||
util.settings["alice"] = "medium"
|
||||
assert util.effort_for_call(UTIL[0], UTIL[1], "alice", _Sess("effort:low")) == "medium"
|
||||
assert util.effort_for_call(UTIL[0], UTIL[1], "alice") == "medium"
|
||||
|
||||
|
||||
def test_setting_is_per_user(util):
|
||||
util.settings["alice"] = "high"
|
||||
assert util.effort_for_call(UTIL[0], UTIL[1], "alice") == "high"
|
||||
assert util.effort_for_call(UTIL[0], UTIL[1], "bob") is None
|
||||
|
||||
|
||||
def test_session_effort_inherited_on_the_session_route(util):
|
||||
assert util.effort_for_call(CHAT[0], CHAT[1], "alice", _Sess("effort:low")) == "low"
|
||||
# Endpoint spelling differences (trailing slash) are still the same route.
|
||||
assert util.effort_for_call(CHAT[0] + "/", CHAT[1], "alice", _Sess("effort:low")) == "low"
|
||||
|
||||
|
||||
def test_session_effort_not_inherited_on_another_route(util):
|
||||
assert util.effort_for_call(UTIL[0], UTIL[1], "alice", _Sess("effort:low")) is None
|
||||
assert util.effort_for_call(CHAT[0], "gpt-5.5-mini", "alice", _Sess("effort:low")) is None
|
||||
|
||||
|
||||
def test_session_effort_off_or_missing_inherits_nothing(util):
|
||||
assert util.effort_for_call(CHAT[0], CHAT[1], "alice", _Sess("off")) is None
|
||||
assert util.effort_for_call(CHAT[0], CHAT[1], "alice", _Sess("")) is None
|
||||
|
||||
|
||||
def test_no_session_means_no_inheritance(util):
|
||||
assert util.effort_for_call(CHAT[0], CHAT[1], "alice") is None
|
||||
|
||||
|
||||
def test_explicit_off_setting_blocks_session_effort_inheritance(util):
|
||||
util.settings["alice"] = "off"
|
||||
assert util.effort_for_call(CHAT[0], CHAT[1], "alice", _Sess("effort:low")) is None
|
||||
assert util.candidate_effort_factory("alice") is None
|
||||
|
||||
util.settings["alice"] = "default"
|
||||
assert util.effort_for_call(CHAT[0], CHAT[1], "alice", _Sess("effort:low")) is None
|
||||
assert util.candidate_effort_factory("alice") is None
|
||||
|
||||
|
||||
def test_inherited_effort_is_validated_against_the_model(util):
|
||||
assert util.effort_for_call(
|
||||
NO_EVIDENCE[0], NO_EVIDENCE[1], "alice",
|
||||
_Sess("effort:low", url=NO_EVIDENCE[0], model=NO_EVIDENCE[1]),
|
||||
) is None
|
||||
assert util.effort_for_call(CHAT[0], CHAT[1], "alice", _Sess("effort:bogus")) is None
|
||||
|
||||
|
||||
def test_effort_for_call_never_raises(util, monkeypatch):
|
||||
def boom(*_a, **_k):
|
||||
raise RuntimeError("db down")
|
||||
|
||||
monkeypatch.setattr(util, "effort_for_route", boom)
|
||||
assert util.effort_for_call(UTIL[0], UTIL[1], "alice", _Sess()) is None
|
||||
|
||||
|
||||
# --- no global fill: callers that are not utility call sites never get an effort ---
|
||||
|
||||
def test_llm_call_async_adds_no_default_effort(util, monkeypatch):
|
||||
# The owner-less helpers resolve at global scope; set it there and per user.
|
||||
util.settings[""] = util.settings["alice"] = "high"
|
||||
seen = []
|
||||
|
||||
def fake_key(url, model, messages, temperature, max_tokens, headers=None,
|
||||
thinking_mode=None, reasoning_effort=None):
|
||||
seen.append(reasoning_effort)
|
||||
return "k"
|
||||
|
||||
monkeypatch.setattr(llm_core, "_get_cache_key", fake_key)
|
||||
monkeypatch.setattr(llm_core, "_get_cached_response", lambda key: "cached")
|
||||
msgs = [{"role": "user", "content": "hi"}]
|
||||
# Same (url, model) as the utility route, but not a utility call site.
|
||||
asyncio.run(llm_core.llm_call_async(UTIL[0], UTIL[1], msgs))
|
||||
llm_core.llm_call(UTIL[0], UTIL[1], msgs)
|
||||
asyncio.run(llm_core.llm_call_async(UTIL[0], UTIL[1], msgs, reasoning_effort="low"))
|
||||
assert seen == [None, None, "low"]
|
||||
|
||||
|
||||
# --- call sites pass the effort explicitly ---
|
||||
|
||||
def _capture_llm(monkeypatch):
|
||||
sent = []
|
||||
|
||||
async def fake_call(url, model, messages, **kw):
|
||||
sent.append((url, model, kw.get("reasoning_effort")))
|
||||
return "A title"
|
||||
|
||||
monkeypatch.setattr(llm_core, "llm_call_async", fake_call)
|
||||
return sent
|
||||
|
||||
|
||||
class _History:
|
||||
role = "user"
|
||||
content = "hello there"
|
||||
|
||||
|
||||
def _title_session(**kw):
|
||||
sess = _Sess(**kw)
|
||||
sess.id = "s1"
|
||||
sess.history = [_History()]
|
||||
return sess
|
||||
|
||||
|
||||
class _Manager:
|
||||
def update_session_name(self, *_a):
|
||||
pass
|
||||
|
||||
|
||||
def test_auto_title_inherits_chat_effort_when_utility_is_blank(util, monkeypatch):
|
||||
from routes import chat_helpers
|
||||
|
||||
sent = _capture_llm(monkeypatch)
|
||||
# Blank utility/task resolves to the session's own route.
|
||||
monkeypatch.setattr(task_endpoint, "resolve_task_endpoint", lambda *a, **k: (CHAT[0], CHAT[1], {}))
|
||||
asyncio.run(chat_helpers.auto_name_session(_Manager(), _title_session()))
|
||||
assert sent == [(CHAT[0], CHAT[1], "low")]
|
||||
|
||||
|
||||
def test_auto_title_setting_beats_chat_effort(util, monkeypatch):
|
||||
from routes import chat_helpers
|
||||
|
||||
util.settings["alice"] = "medium"
|
||||
sent = _capture_llm(monkeypatch)
|
||||
monkeypatch.setattr(task_endpoint, "resolve_task_endpoint", lambda *a, **k: (UTIL[0], UTIL[1], {}))
|
||||
asyncio.run(chat_helpers.auto_name_session(_Manager(), _title_session()))
|
||||
assert sent == [(UTIL[0], UTIL[1], "medium")]
|
||||
|
||||
|
||||
def test_auto_title_on_a_different_utility_model_gets_no_chat_effort(util, monkeypatch):
|
||||
from routes import chat_helpers
|
||||
|
||||
sent = _capture_llm(monkeypatch)
|
||||
monkeypatch.setattr(task_endpoint, "resolve_task_endpoint", lambda *a, **k: (UTIL[0], UTIL[1], {}))
|
||||
asyncio.run(chat_helpers.auto_name_session(_Manager(), _title_session()))
|
||||
assert sent == [(UTIL[0], UTIL[1], None)]
|
||||
|
||||
|
||||
def test_memory_extraction_passes_chat_effort(util, monkeypatch):
|
||||
from services.memory import memory_extractor
|
||||
|
||||
sent = []
|
||||
|
||||
async def fake_call(url, model, messages, **kw):
|
||||
sent.append(kw.get("reasoning_effort"))
|
||||
return "[]"
|
||||
|
||||
monkeypatch.setattr(llm_core, "llm_call_async", fake_call)
|
||||
|
||||
class Sess(_Sess):
|
||||
def get_context_messages(self):
|
||||
return [{"role": "user", "content": "I live in Oslo"},
|
||||
{"role": "assistant", "content": "Noted"}]
|
||||
|
||||
asyncio.run(memory_extractor.extract_and_store(
|
||||
Sess(), object(), object(), CHAT[0], CHAT[1], {},
|
||||
))
|
||||
assert sent and sent[0] == "low"
|
||||
|
||||
|
||||
def test_email_summary_sends_setting_effort_for_owner_only(util, monkeypatch):
|
||||
from routes.email import email_helpers
|
||||
|
||||
util.settings["alice"] = "medium"
|
||||
sent = []
|
||||
|
||||
async def fake_call(**kw):
|
||||
sent.append(kw.get("reasoning_effort"))
|
||||
return "- point"
|
||||
|
||||
monkeypatch.setattr(llm_core, "llm_call_async", fake_call)
|
||||
for owner in ("alice", "bob", None):
|
||||
asyncio.run(email_helpers._generate_email_summary(
|
||||
UTIL[0], UTIL[1], "a@x", "s", "body", owner=owner,
|
||||
))
|
||||
assert sent == ["medium", None, None]
|
||||
|
||||
|
||||
def test_candidate_effort_factory_scopes_to_utility_route(util):
|
||||
from src.utility_effort import candidate_effort_factory
|
||||
|
||||
assert candidate_effort_factory("alice") is None
|
||||
util.settings["alice"] = "medium"
|
||||
factory = candidate_effort_factory("alice")
|
||||
assert factory(0, UTIL[0], UTIL[1], {}) == {"kwargs": {"reasoning_effort": "medium"}}
|
||||
assert factory(1, NO_EVIDENCE[0], NO_EVIDENCE[1], {}) == {}
|
||||
assert candidate_effort_factory("bob") is None
|
||||
|
||||
|
||||
def test_fallback_route_efforts_and_factory(monkeypatch):
|
||||
from src.utility_effort import candidate_effort_factory, effort_for_route
|
||||
|
||||
def fake_resolve_id(ep_id, model, owner=None):
|
||||
if ep_id == "ep-fallback":
|
||||
return ("https://fallback.example/v1", model, {})
|
||||
return None
|
||||
|
||||
monkeypatch.setattr("src.endpoint_resolver.resolve_endpoint_by_id", fake_resolve_id)
|
||||
monkeypatch.setattr(
|
||||
"src.settings.load_settings",
|
||||
lambda: {"utility_model_fallbacks": [{"endpoint_id": "ep-fallback", "model": "gpt-5.5-mini", "reasoning_effort": "low"}]},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"src.settings.get_user_setting",
|
||||
lambda key, owner="", default=None: default,
|
||||
)
|
||||
|
||||
factory = candidate_effort_factory("alice")
|
||||
assert factory is not None
|
||||
assert effort_for_route("https://api.openai.example/v1", "gpt-5.5", "alice") is None
|
||||
assert effort_for_route("https://fallback.example/v1", "gpt-5.5-mini", "alice") == "low"
|
||||
assert factory(0, "https://fallback.example/v1", "gpt-5.5-mini", {}) == {"kwargs": {"reasoning_effort": "low"}}
|
||||
|
||||
|
||||
def test_sync_openai_compatible_wire_transmits_explicit_effort(monkeypatch):
|
||||
import httpx
|
||||
|
||||
sent = []
|
||||
|
||||
def fake_post(url, headers, **kwargs):
|
||||
sent.append(dict(kwargs["json"]))
|
||||
return httpx.Response(
|
||||
200, json={"choices": [{"message": {"content": "ok"}}]}
|
||||
)
|
||||
|
||||
monkeypatch.setattr(llm_core, "_get_cached_response", lambda *_: None)
|
||||
monkeypatch.setattr(llm_core, "_set_cached_response", lambda *a, **k: None)
|
||||
monkeypatch.setattr(llm_core, "note_model_activity", lambda *a: None)
|
||||
monkeypatch.setattr(llm_core, "httpx_post_kimi_aware", fake_post)
|
||||
|
||||
url = "https://example.test/v1"
|
||||
messages = [{"role": "user", "content": "hi"}]
|
||||
|
||||
llm_core.llm_call(
|
||||
url, "gpt-5.5", messages, max_tokens=64,
|
||||
reasoning_effort="HIGH",
|
||||
)
|
||||
llm_core.llm_call(url, "gpt-5.5", messages, max_tokens=64)
|
||||
|
||||
assert sent[0]["reasoning_effort"] == "high"
|
||||
assert "reasoning_effort" not in sent[1]
|
||||
|
||||
|
||||
def test_async_openai_compatible_wire_transmits_explicit_effort(monkeypatch):
|
||||
import asyncio
|
||||
import httpx
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
sent = []
|
||||
|
||||
@asynccontextmanager
|
||||
async def fake_slot(*args, **kwargs):
|
||||
yield
|
||||
|
||||
async def fake_post(client, url, headers, **kwargs):
|
||||
sent.append(dict(kwargs["json"]))
|
||||
return httpx.Response(
|
||||
200, json={"choices": [{"message": {"content": "ok"}}]}
|
||||
)
|
||||
|
||||
monkeypatch.setattr(llm_core, "_get_cached_response", lambda *_: None)
|
||||
monkeypatch.setattr(llm_core, "_set_cached_response", lambda *a, **k: None)
|
||||
monkeypatch.setattr(llm_core, "_get_http_client", lambda: object())
|
||||
monkeypatch.setattr(llm_core, "_is_host_dead", lambda *_: False)
|
||||
monkeypatch.setattr(llm_core, "_local_model_slot", fake_slot)
|
||||
monkeypatch.setattr(llm_core, "note_model_activity", lambda *a: None)
|
||||
monkeypatch.setattr(llm_core, "httpx_post_kimi_aware_async", fake_post)
|
||||
|
||||
async def exercise():
|
||||
url = "https://example.test/v1"
|
||||
messages = [{"role": "user", "content": "hi"}]
|
||||
|
||||
await llm_core.llm_call_async(
|
||||
url, "gpt-5.5", messages, max_tokens=0,
|
||||
max_retries=1, reasoning_effort="medium",
|
||||
)
|
||||
await llm_core.llm_call_async(
|
||||
url, "gpt-5.5", messages, max_tokens=0,
|
||||
max_retries=1,
|
||||
)
|
||||
|
||||
asyncio.run(exercise())
|
||||
|
||||
assert sent[0]["reasoning_effort"] == "medium"
|
||||
assert "reasoning_effort" not in sent[1]
|
||||
Reference in New Issue
Block a user