Merge current dev and refresh PR #6738 from the 7011 integration

This commit is contained in:
pewdiepie-archdaemon
2026-10-26 16:21:22 +00:00
450 changed files with 60479 additions and 11016 deletions
-369
View File
@@ -1,369 +0,0 @@
# Wave 6 test-isolation foundation
## Provenance and boundary
- Worktree: `/home/alteixeira20/Projects/Odysseus/odysseus-wave6-test-isolation`.
- Starting branch: `wave6/test-isolation-foundation`.
- Starting HEAD: `b1666951faf8285054e1ca90f11533b0fb53fb57`.
- Starting TREE: `a3dee24e3c56c22e1d3f9805f409bf16b086c3d4`.
- `git status --short --branch`: `## wave6/test-isolation-foundation` only.
- `git status --porcelain`: empty. Starting worktree clean.
- No rebase, merge, push, PR edit, or access to the Wave 4 worktree.
- All changes are test code, test helpers, or this test validation report.
No production code changed; no STOP-level exception was needed.
## Contamination map
| Source | Mutation / binding | Previous cleanup | Escaping state / effect |
| --- | --- | --- | --- |
| Both tests in `tests/test_truncate_message_count_regression.py`, through `_make_manager` | Creates an unowned `mkstemp` DB; directly sets `os.environ["DATABASE_URL"]`; reloads `core.database` then `core.session_manager` | Only the assertion's ORM session is closed | Environment, engine, factory, declarative Base, ORM classes, manager globals, and registered engine listeners survive; database files are never deleted |
| Collection of `tests/test_manage_tasks_owner_scope.py` | Creates a module-lifetime temporary DB and assigns `_TS` permanently to `core.database.SessionLocal` | Some individual rows deleted; ORM sessions closed | The collection-time factory assignment leaks to unrelated tests; truncation reload replaces it, so production handlers and `_seed` / `_get` use different databases |
| `test_checkin_digest_owner_scope.py`, `test_gallery_owner_filter_single_user.py`, `test_document_session_owner_scope.py` | Create module-lifetime DB files / engines and retain local factories | Some rows deleted / route factory restored / ORM sessions closed | Owned files and engines lack teardown; retained imported ORM identities can disagree with reloaded module globals |
| `tests/conftest.py` bootstrap | `DATABASE_URL` was set only if absent before importing `core.database` | No environment restoration | An inherited developer URL could be opened and initialized during collection |
`importlib.reload` updates the existing module dictionary in place; it does
not change the `sys.modules` module object's identity. Previously imported
symbols nevertheless keep their old class/factory identities. In particular,
`src.database` re-exports old objects, while reloaded `core.session_manager`
rebinds its `DbSession`, `DbChatMessage`, `DbDocument`, `SessionLocal`, and
`SessionManager`. Old functions still resolve globals through those changed
module dictionaries. Each database reload also registers a new SQLite connect
listener on SQLAlchemy's Engine class.
The current failures are principally in `test_manage_tasks_owner_scope.py`:
`_seed` writes through its retained `_TS`, but `do_manage_tasks` imports the
now-replaced factory from `core.database` at call time. Ownerless / cross-owner
mutations incorrectly observe "task not found"; successful mutation/list/create
witnesses likewise read or write the wrong test database. Four invalid-date
cases still pass, so the observed contamination count is 16 rather than all
20 task-owner cases.
`src.settings` has TTL caches, but those caches do not resolve DATABASE_URL.
The contaminating helper neither patches nor reloads settings. The database
URL is read directly from the environment at database import/reload time;
settings cache invalidation is not the correction for this leak.
## Baseline reproduction before editing
All baseline commands explicitly selected in-memory bootstrap storage. Python
with installed pytest/SQLAlchemy was
`/tmp/pr40-final-validation-venv/bin/python` (pytest 9.1.1, SQLAlchemy 2.1.1).
```sh
DATABASE_URL=sqlite:///:memory: /tmp/pr40-final-validation-venv/bin/python -m pytest -q -p no:cacheprovider tests/test_manage_tasks_owner_scope.py tests/test_checkin_digest_owner_scope.py tests/test_gallery_owner_filter_single_user.py tests/test_document_session_owner_scope.py tests/test_truncate_message_count_regression.py
# 30 passed, 9 warnings in 2.48s
DATABASE_URL=sqlite:///:memory: /tmp/pr40-final-validation-venv/bin/python -m pytest -q -p no:cacheprovider tests/test_truncate_message_count_regression.py tests/test_manage_tasks_owner_scope.py tests/test_checkin_digest_owner_scope.py tests/test_gallery_owner_filter_single_user.py tests/test_document_session_owner_scope.py
# 16 failed, 14 passed, 9 warnings in 2.40s
DATABASE_URL=sqlite:///:memory: /tmp/pr40-final-validation-venv/bin/python -m pytest -q -p no:cacheprovider -rx --runxfail tests/test_runtime_behavior_regressions.py -k negative_web
# 2 failed (IndexError), 1 passed, 13 deselected, 1 warning in 1.61s
```
The same 30 database tests shuffled with `random.Random(42)` before editing
reported **9 failed, 21 passed**, 9 warnings in 2.37s. Exact command:
```sh
/tmp/pr40-final-validation-venv/bin/python /tmp/odysseus-wave6-isolation-evidence/run_order.py seed:42 before-seed42 tests/test_manage_tasks_owner_scope.py tests/test_checkin_digest_owner_scope.py tests/test_gallery_owner_filter_single_user.py tests/test_document_session_owner_scope.py tests/test_truncate_message_count_regression.py
```
## Isolation correction and regression evidence
- `tests/helpers/database.py` owns a fresh TemporaryDirectory, file-backed
SQLite engine with NullPool, real schema, and factory for each invocation.
Teardown disposes the engine and removes the directory including sidecars.
- The truncation fixture temporarily patches only resource bindings in
`core.database`, `src.database`, and `core.session_manager`, with a matching
temporary environment URL. Patches are undone before resource disposal.
No module reload, module eviction, new ORM class, or listener registration.
- Four owner modules now own a disposable database per test rather than at
collection. Only the task-owner module patches the canonical factory; that
patch is scoped to the test and restored during teardown. The other modules
retain their existing handler/route wiring and ownership assertions.
- The existing pytest bootstrap always chooses in-memory storage before
production database imports and registers environment restoration with the
pytest configuration lifecycle. No new process-wide autouse cleanup fixture.
- `tests/test_database_test_isolation.py` checks real persistent truncation,
exact class/factory restoration, file removal, clean subsequent database
state, and exception teardown. Child pytest runs execute the actual original
witness entry points in both directions and check process-visible bindings
after collection and after every test. An inherited developer-DB sentinel
remains byte-identical, with no database sidecars created, and the inherited
environment URL is restored after pytest exits.
Mutation checks temporarily restored one original file at a time, ran
`test_actual_tests_restore_process_state_and_ignore_inherited_database[True]`,
and restored the corrected file in `finally`. Both mutations made the new
regression fail (pytest exit 1): original truncation was caught as
"test leaked database or module state", original task-owner collection as
"collection changed database bindings". No mutation was retained.
## Historical negative-web witnesses and production blocker
Both parameter cases in
`test_negative_web_wording_withholds_the_web_tools_unhandled` intended to prove
that inferred negative-web wording can receive a memory-only answer without
`web_search` or `web_fetch` being offered to the model:
1. `Summarise what you already know. Do not search the web.`
2. `No web search please, just tell me what you know about Python decorators.`
Both previously indexed an empty fake-model call list and xfailed on unrelated
`IndexError`. The fake also emitted provider-shaped `choices` JSON at a seam
that now consumes normalized `{"delta": ...}` SSE.
The repaired witnesses drive the unmodified public `stream_agent_loop`, mock
only model output and existing infrastructure edges, capture the actual SSE,
and require one model call, withheld web schemas, and delivered fake answer.
The existing passing "don't search online" control and positive search
control remain. The fake now emits normalized SSE.
The two historical xfails remain **strict**, with
`raises=_MemoryOnlyTurnBlocked`. That exception is emitted only when the real
loop makes zero model calls and returns the exact known disabled-web response.
IndexError, arbitrary assertions, or other exceptions no longer qualify.
This is a genuine production defect outside the lane: negative-web detection
is true for both phrasings, but `_classify_agent_request` returns `{'web'}` and
`_web_search_unavailable_for_turn` returns true after the negative has disabled
web tools. The loop short-circuits the requested memory-only response with:
> Web access is disabled for this turn. Enable web search and resend the request.
The passing "Answer from memory only, don't search online." control has
`{'memory', 'web'}` intent domains and does not short-circuit. A minimized
reproduction using those three production functions requires neither a model
nor a mock. Relevant production locations (intentionally untouched):
`src/agent_loop.py` negative detector near line 864, classifier near 10371,
negative-tool disabling near 20735, unavailable calculation near 22233, and
short-circuit response near 25101.
```sh
/tmp/pr40-final-validation-venv/bin/python -m pytest -q -p no:cacheprovider --runxfail tests/test_runtime_behavior_regressions.py -k negative_web_wording_withholds_the_web_tools_unhandled
# 2 failed with _MemoryOnlyTurnBlocked, 14 deselected, 1 warning in 0.75s
```
No production repair was attempted. Runtime intent/availability behavior,
authority, completion/evidence, and Wave 4 effects files remain untouched.
This blocker prevents a READY release-foundation verdict despite passing test
isolation validation.
## Final order validation
The focused manifest has 50 cases: the five baseline database modules plus
`test_database_test_isolation.py` and `test_runtime_behavior_regressions.py`.
Normal collection order is alphabetical by the seven explicitly selected files.
Reversed and seeded runs reorder **individual collected test cases**, including
parameterized cases. Both contamination directions also vary file collection
order. Every execution manifest is retained as JSON in the evidence directory.
| Run | Result |
| --- | --- |
| Normal | 48 passed, 2 xfailed, 0 skipped, 11 warnings in 9.01s |
| Reverse of all collected cases | 48 passed, 2 xfailed, 0 skipped, 11 warnings in 8.47s |
| Truncation module first, owner witnesses afterward | 48 passed, 2 xfailed, 0 skipped, 11 warnings in 8.00s |
| All four owner modules first, truncation afterward | 48 passed, 2 xfailed, 0 skipped, 11 warnings in 8.98s |
| Seed 1 | 48 passed, 2 xfailed, 0 skipped, 11 warnings in 9.01s |
| Seed 42 | 48 passed, 2 xfailed, 0 skipped, 11 warnings in 8.98s |
| Seed 2026 | 48 passed, 2 xfailed, 0 skipped, 11 warnings in 8.97s |
Exact executed commands:
```sh
/tmp/pr40-final-validation-venv/bin/python /tmp/odysseus-wave6-isolation-evidence/run_order.py normal after-normal tests/test_checkin_digest_owner_scope.py tests/test_database_test_isolation.py tests/test_document_session_owner_scope.py tests/test_gallery_owner_filter_single_user.py tests/test_manage_tasks_owner_scope.py tests/test_runtime_behavior_regressions.py tests/test_truncate_message_count_regression.py
/tmp/pr40-final-validation-venv/bin/python /tmp/odysseus-wave6-isolation-evidence/run_order.py reverse after-reverse tests/test_checkin_digest_owner_scope.py tests/test_database_test_isolation.py tests/test_document_session_owner_scope.py tests/test_gallery_owner_filter_single_user.py tests/test_manage_tasks_owner_scope.py tests/test_runtime_behavior_regressions.py tests/test_truncate_message_count_regression.py
/tmp/pr40-final-validation-venv/bin/python /tmp/odysseus-wave6-isolation-evidence/run_order.py normal after-truncation-first tests/test_truncate_message_count_regression.py tests/test_checkin_digest_owner_scope.py tests/test_database_test_isolation.py tests/test_document_session_owner_scope.py tests/test_gallery_owner_filter_single_user.py tests/test_manage_tasks_owner_scope.py tests/test_runtime_behavior_regressions.py
/tmp/pr40-final-validation-venv/bin/python /tmp/odysseus-wave6-isolation-evidence/run_order.py normal after-owners-first tests/test_checkin_digest_owner_scope.py tests/test_document_session_owner_scope.py tests/test_gallery_owner_filter_single_user.py tests/test_manage_tasks_owner_scope.py tests/test_truncate_message_count_regression.py tests/test_database_test_isolation.py tests/test_runtime_behavior_regressions.py
/tmp/pr40-final-validation-venv/bin/python /tmp/odysseus-wave6-isolation-evidence/run_order.py seed:1 after-seed1 tests/test_checkin_digest_owner_scope.py tests/test_database_test_isolation.py tests/test_document_session_owner_scope.py tests/test_gallery_owner_filter_single_user.py tests/test_manage_tasks_owner_scope.py tests/test_runtime_behavior_regressions.py tests/test_truncate_message_count_regression.py
/tmp/pr40-final-validation-venv/bin/python /tmp/odysseus-wave6-isolation-evidence/run_order.py seed:42 after-seed42 tests/test_checkin_digest_owner_scope.py tests/test_database_test_isolation.py tests/test_document_session_owner_scope.py tests/test_gallery_owner_filter_single_user.py tests/test_manage_tasks_owner_scope.py tests/test_runtime_behavior_regressions.py tests/test_truncate_message_count_regression.py
/tmp/pr40-final-validation-venv/bin/python /tmp/odysseus-wave6-isolation-evidence/run_order.py seed:2026 after-seed2026 tests/test_checkin_digest_owner_scope.py tests/test_database_test_isolation.py tests/test_document_session_owner_scope.py tests/test_gallery_owner_filter_single_user.py tests/test_manage_tasks_owner_scope.py tests/test_runtime_behavior_regressions.py tests/test_truncate_message_count_regression.py
```
The order driver is test-session-local; it is not installed into conftest or
production and never changes the normal suite's ordering. Its exact source
(for recreating `/tmp/odysseus-wave6-isolation-evidence/run_order.py`) is:
```python
import json
import os
from pathlib import Path
import random
import sys
import pytest
out = Path('/tmp/odysseus-wave6-isolation-evidence')
mode, label, *modules = sys.argv[1:]
class Order:
@pytest.hookimpl(trylast=True)
def pytest_collection_modifyitems(self, items):
if mode == 'reverse':
items.reverse()
elif mode.startswith('seed:'):
random.Random(int(mode.split(':')[1])).shuffle(items)
(out / f'{label}-manifest.json').write_text(json.dumps([i.nodeid for i in items], indent=2) + '\n')
os.environ['DATABASE_URL'] = 'sqlite:///:memory:'
args = ['-q', '-p', 'no:cacheprovider', '-rsx', *modules]
(out / f'{label}-command.json').write_text(json.dumps({'argv':sys.argv,'pytest_args':args,'DATABASE_URL':os.environ['DATABASE_URL']},indent=2) + '\n')
raise SystemExit(pytest.main(args, plugins=[Order()]))
```
## Adjacent and complete validation
The first complete run finished with **44 failed, 12509 passed, 62 skipped,
2 xfailed**, 184 warnings and 6 passing subtests in 391.85s. Its failure
inventory was 42 missing-Playwright-package failures and two generated
configuration-reference freshness failures. The local worktree had not had
`npm ci` run; the generator includes test-source line references, and the
bootstrap edit moved the existing references by one line.
`npm ci --ignore-scripts` installed the four already-locked packages; package
and lock files were unchanged. Cached Chromium launched successfully
(version 151.0.7922.34). A bootstrap comment now explains why isolation must
precede collection and preserves the existing referenced line locations,
without editing the generated website or the generator. This follow-up is
commit `f67d0388fd805867a0920dbffd4da056b1361e38`
(`fix(tests): preserve bootstrap reference locations`).
```sh
npm ci --ignore-scripts
# exit 0; 4 packages installed, lockfile and package policy unchanged
python scripts/generate_env_reference.py --check
# exit 0; website/configuration-reference.md is up to date (112 variables)
/tmp/pr40-final-validation-venv/bin/python -m pytest -q -p no:cacheprovider tests/test_document_module_api.py tests/test_env_reference.py
# 16 passed, 1 warning in 17.00s
```
The first full run and the 44-case focused rerun are retained separately from
the final complete run. The 44-case rerun reported **1 failed, 43 passed**
in 73.61s: the existing font/color browser witness observed its font-size
icon still showing `13` after its text had changed to size 5 (expected `24`).
An isolated reproduction of that unchanged test passed in 2.13s. This is an
observed intermittent browser UI witness; no database failure was involved,
and a production root cause has not been established. The test and editor
implementation remain unchanged, with both observations retained rather
than suppressing the failed run. No failure was hidden by skipping or deleting a test.
```sh
/tmp/pr40-final-validation-venv/bin/python -m pytest -q -p no:cacheprovider -rsx tests/test_session_manager.py tests/test_session_manager_cleanup.py tests/test_session_manager_persist_guard.py tests/test_session_mutation_lookup.py tests/test_session_discovery_message_count.py tests/test_history_order_by_timestamp_regression.py tests/test_history_display_model_hydration.py tests/test_session_search.py tests/test_session_search_batch_fetch.py tests/test_session_list_owner_scope.py tests/test_session_owner_attribution.py tests/test_session_endpoint_owner_scope.py tests/test_session_concurrent.py tests/test_app_db_permissions.py tests/test_database_utcnow.py tests/test_tool_policy.py tests/test_turn_contract.py tests/test_chat_preprocess_tool_policy.py tests/test_chat_route_tool_policy.py tests/test_resend_message_nondestructive.py
# 1262 passed, 0 skipped, 0 xfailed, 7 warnings in 40.56s
/tmp/pr40-final-validation-venv/bin/python -m pytest -q -p no:cacheprovider -rsx
# 12556 passed, 59 skipped, 2 xfailed, 184 warnings, 6 subtests passed in 468.49s (0:07:48); exit 0
python -m compileall -q app.py core routes src services scripts tests
# exit 0
git diff --check
# exit 0, no output
git diff --check b1666951faf8285054e1ca90f11533b0fb53fb57
# exit 0, no output
git diff --name-only --diff-filter=U
# exit 0, no output
rg -n '^(<<<<<<< |=======$|>>>>>>> )' <each modified file>
# exit 1, no matches
```
No test was deleted, no skip was added, and no unrelated xfail was added. The
same two historical parameter cases remain xfailed for the demonstrated
production blocker, now restricted to that actual contract failure.
The isolation commit is
`e2b847c84d27ce785565757e81ca5293896eaab3`
(`fix(tests): isolate database and module state`). The final bounded commit
contains the repaired web witnesses and this validation report. Final commit,
HEAD, TREE and clean status are supplied in the accompanying final report to
avoid placing a commit's own hash inside its committed contents.
Full logs, commands, and per-run manifests from this session are in
`/tmp/odysseus-wave6-isolation-evidence/`.
The follow-up normal focused run after preserving bootstrap reference
locations is recorded in `after-bootstrap-followup.log`. It reported **48 passed, 2 xfailed**, 11 warnings in 9.55s. Its exact command is:
```sh
/tmp/pr40-final-validation-venv/bin/python /tmp/odysseus-wave6-isolation-evidence/run_order.py normal after-bootstrap-followup tests/test_checkin_digest_owner_scope.py tests/test_database_test_isolation.py tests/test_document_session_owner_scope.py tests/test_gallery_owner_filter_single_user.py tests/test_manage_tasks_owner_scope.py tests/test_runtime_behavior_regressions.py tests/test_truncate_message_count_regression.py
```
The additional browser observation came from the 44-case manifest retained in
`full-failures-manifest.json`; every exact node ID in that diagnostic command is
retained in `full-failures-rerun-command.json`. The isolated diagnostic command:
```sh
/tmp/pr40-final-validation-venv/bin/python -m pytest -q -p no:cacheprovider tests/test_document_rich_font_color_controls.py::test_numeric_font_size_and_custom_colors_work_on_desktop_and_mobile
# 1 passed, 1 warning in 2.13s
```
## Independent review disposition
A read-only review of isolation commit e2b847c8 raised two concerns, which
were checked against the frozen source rather than accepted as proven leaks:
- `core.models._SESSION_MANAGER_INSTANCE`: `SessionManager.__init__` only
initializes its owned cache and loads metadata; it does not call the global
setter. `SessionManager.add_message` explicitly persists through its own
manager instead of calling `Session.add_message` / the singleton. Neither
truncation witness nor the helper invokes a singleton setter or API route
setup. Thus the asserted singleton mutation is absent from this lane's real
seam. Resetting that unrelated singleton would add cleanup without a proven
owned mutation and could hide a genuine leak elsewhere.
- Eager real-database import versus `clear_fake_database_modules`: the exact
pre-import block already exists on frozen b1666951. The clearing helper
intentionally leaves real modules with a string `__file__` intact and evicts
replacement fake modules lacking it. The correction neither adds that
pre-import nor changes fake-module identification. Its "real module" early
return is intended behavior, not a newly introduced cleanup conflict.
Neither concern supplied a failing reproducer. Both were resolved by the
existing source paths and the actual collection/teardown identity regression;
no speculative global cleanup was introduced.
The minimized production reproduction command was:
```sh
DATABASE_URL=sqlite:///:memory: /tmp/pr40-final-validation-venv/bin/python - <<'PYTHON'
from src.agent_loop import _explicitly_avoids_web_lookup, _classify_agent_request, _web_search_unavailable_for_turn
from src.tool_policy import WEB_TOOL_NAMES
for text in (
"Answer from memory only, don't search online.",
'Summarise what you already know. Do not search the web.',
'No web search please, just tell me what you know about Python decorators.',
):
intent = _classify_agent_request([{'role': 'user', 'content': text}], text)
domains = set(intent['domains'])
print(repr(text))
print('negative_web:', _explicitly_avoids_web_lookup(text))
print('intent_domains:', sorted(domains))
print('web_disabled_short_circuit:', _web_search_unavailable_for_turn(domains, set(WEB_TOOL_NAMES), text, None, None))
PYTHON
```
Results: negative-web detection true for all three; intent domains respectively
`['memory', 'web']`, `['web']`, `['web']`; short-circuit false, true, true.
## Final validation result and verdict
The final complete run in `full-pytest-final.log` exited 0:
**12556 passed, 59 skipped, 2 xfailed**, 184 warnings and 6 passing subtests in
468.49s. All 12617 collected cases are accounted for. The existing optional,
platform-specific and opt-in skips remain; no skip marker was added. Installing
the already-locked Node dependencies enabled three previously skipped cases,
so the skip count decreased from 62 in the first run to 59. No tests were
deleted and no unrelated xfail was introduced.
The font/color browser witness passed in the final complete run. Its failed
44-case diagnostic and passing isolated result remain documented as an
intermittent observation; no production root cause was established or fixed.
Database/environment/module isolation validation is green. Both historical
negative-web cases remain narrowly expected failures for a reproduced genuine
runtime defect outside the test-isolation boundary. These are integration
blockers; production intent/short-circuit behavior must be corrected in an
appropriate lane before removing their strict markers.
WAVE 6 TEST-ISOLATION FOUNDATION NOT READY
+66
View File
@@ -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(); }
+39
View File
@@ -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(); }
+148
View File
@@ -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(); }
+45 -45
View File
@@ -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",
-45
View File
@@ -1,45 +0,0 @@
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/style.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();
}
+17
View File
@@ -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
View File
@@ -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
View File
@@ -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);
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+142
View File
@@ -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, []);
});
+10
View File
@@ -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)
+93
View File
@@ -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;
}
});
+105
View File
@@ -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']);
});
+13 -6
View File
@@ -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 };
+47
View File
@@ -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');
});
+41
View File
@@ -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; }
});
}
+90
View File
@@ -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);
});
+147
View File
@@ -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('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;') } });
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('"\'<>`'), '&quot;&#39;&lt;&gt;&#96;');
assert.match(helpers._linkify('<script> https://example.org/test'), /&lt;script&gt;/);
assert.doesNotMatch(helpers._linkify('<script> https://example.org/test'), /<script>/i);
});
-58
View File
@@ -1,58 +0,0 @@
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();
}
+81
View File
@@ -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\)/);
});
+1 -1
View File
@@ -89,7 +89,7 @@ a status code. `scripts/odysseus-smoke --areas` prints the current list.
| Email | the fixture inbox lists, opens with its body, and the unread count drops on mark-read |
| Memory | a fact is listed, found by search, and gone after delete |
| Uploads | an attachment reads back byte for byte |
| Cookbook | hardware is detected and recommendations come back sized against it; state persists |
| Cookbook | hardware is detected; a fresh install's recommendation request returns the explicit empty-catalog Rescan guidance; state persists |
| Settings | a preference written on one session is still there after a new login |
## What is not covered, and why
-1
View File
@@ -20,4 +20,3 @@ def test_terse_timed_calendar_create_promotes_to_calendar():
assert intent.needs_tools
assert intent.category == "calendar"
+36
View File
@@ -0,0 +1,36 @@
"""Tests for src/activity.py — in-flight heavy-image-op counters (safe restart)."""
from src import activity
def test_image_op_counter_increments_and_decrements():
assert activity.heavy_image_ops_active() == 0
activity.begin_image_op("a")
activity.begin_image_op("a")
assert activity.heavy_image_ops_active() == 2
activity.end_image_op("a")
assert activity.heavy_image_ops_active() == 1
activity.end_image_op("a")
assert activity.heavy_image_ops_active() == 0
# never goes negative
activity.end_image_op("a")
assert activity.heavy_image_ops_active() == 0
def test_image_op_context_manager_and_busy():
assert activity.busy() is False
with activity.image_op("/api/image/inpaint"):
assert activity.busy() is True
snap = activity.activity()
assert snap["image_ops"] == 1
assert snap["image_ops_by_name"].get("/api/image/inpaint") == 1
assert activity.busy() is False
assert activity.activity()["image_ops"] == 0
def test_image_op_released_on_exception():
try:
with activity.image_op("boom"):
raise RuntimeError("x")
except RuntimeError:
pass
assert activity.heavy_image_ops_active() == 0
+331
View File
@@ -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
File diff suppressed because it is too large Load Diff
-72
View File
@@ -1,7 +1,5 @@
import json
import pytest
from src.agent_evidence import (
CompletionRequirements,
CompletionStatus,
@@ -21,13 +19,6 @@ def test_infers_only_explicit_output_or_edit_paths():
assert requirements.verifier_required is True
def test_email_address_is_not_an_output_artifact():
requirements = infer_completion_requirements(
"Write a summary and email it to jane.doe@example.com."
)
assert requirements.required_artifacts == ()
def test_known_workspace_inputs_are_not_inferred_as_output_artifacts():
requirements = infer_completion_requirements(
"My clues are saved in /tmp_workspace/inputs/clues.md, the required answer "
@@ -943,66 +934,3 @@ def test_exhaustion_and_awaiting_user_are_non_completion_states():
ledger = EvidenceLedger()
assert ledger.evaluate(exhausted=True).status == CompletionStatus.EXHAUSTED
assert ledger.evaluate(awaiting_user=True).status == CompletionStatus.AWAITING_USER
@pytest.mark.parametrize("tool", ["create_document", "update_document", "edit_document"])
def test_authoritative_document_write_records_document_mutation_evidence(tool):
ledger = EvidenceLedger.from_tool_events([
{"tool": tool, "command": "fix grammar", "exit_code": 0},
])
mutations = [event for event in ledger.events
if event.kind == EvidenceKind.DOCUMENT_MUTATION]
assert len(mutations) == 1
assert mutations[0].authoritative and mutations[0].success
assert ledger._supports_document_mutation() is True
def test_non_authoritative_document_write_is_not_mutation_evidence():
ledger = EvidenceLedger.from_tool_events([
{"tool": "update_document", "command": "fix grammar"},
])
assert ledger._supports_document_mutation() is False
def test_successful_private_write_supports_mutation_claim_without_artifact():
# manage_calendar:create_event has no filesystem artifact, so the completion
# verifier previously erased the model's "I created the meeting" claim. A
# successful authoritative owned-state write must now support it.
from src.agent_runtime.completion import completion_answer
ledger = EvidenceLedger.from_tool_events([
{
"round": 1,
"tool": "manage_calendar",
"command": json.dumps({
"action": "create_event",
"title": "Meeting with Sam",
"start": "2026-10-16T15:00:00",
}),
"output": "Created event [Meeting with Sam]",
"exit_code": 0,
"execution_attempted": True,
},
])
assert ledger._private_action_mutations == ["manage_calendar:create_event"]
answer, _ = completion_answer(
"I created the meeting with Sam.", ledger, ledger.evaluate())
assert "created the meeting with Sam" in answer
assert not answer.startswith("Unsupported execution claims")
def test_failed_private_write_does_not_support_mutation_claim():
ledger = EvidenceLedger.from_tool_events([
{
"round": 1,
"tool": "manage_calendar",
"command": json.dumps({"action": "create_event", "title": "x"}),
"output": "denied",
"exit_code": 1,
"execution_attempted": True,
},
])
assert ledger._private_action_mutations == []
+2 -1
View File
@@ -1873,7 +1873,8 @@ def test_tui_local_skill_request_reconciles_normal_tool_policy(monkeypatch):
},
)))
assert calls == ["manage_skills"]
# Caller hard denials are absolute and cannot be re-enabled by later TUI turn contracts.
assert calls == []
assert not any(e.get("type") == "loop_breaker_triggered" for e in events)
+80 -4
View File
@@ -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):
-85
View File
@@ -87,91 +87,6 @@ def test_ask_user_is_emitted_last_and_persisted(monkeypatch):
assert metrics["tool_events"][0]["ask_user"] == payload
def test_clean_preview_ask_user_ends_turn_without_second_round(monkeypatch):
"""The compact ``stream_preview`` runtime must treat ask_user as a turn boundary.
Regression for chat d8dca715: the preview fed the ask_user tool result
back to the model, so it burned a second provider round and persisted the
model's follow-up prose instead of the question.
"""
import src.clean_agent_preview as module
from src.tool_schemas import FUNCTION_TOOL_SCHEMAS
from src.tool_policy import ToolPolicy
from src.turn_contract import resolve_full_inventory_contract
payload = {
"question": "What would be most useful to start with?",
"options": [
{"label": "A 30-day meditation plan"},
{"label": "How to measure brain activity"},
],
"multi": False,
}
requests = []
responses = iter([
{'choices': [{'delta': {'tool_calls': [{'index': 0, 'id': 'ask-1',
'function': {'name': 'ask_user',
'arguments': json.dumps(payload, ensure_ascii=False)}}]}}]},
{'choices': [{'delta': {'content': 'SHOULD NOT BE REQUESTED'}}]},
])
class Response:
def __init__(self, body): self.body = body
async def __aenter__(self): return self
async def __aexit__(self, *args): pass
def raise_for_status(self): pass
async def aiter_lines(self):
yield 'data: ' + json.dumps(self.body)
yield 'data: [DONE]'
class Client:
def __init__(self, **kwargs): pass
async def __aenter__(self): return self
async def __aexit__(self, *args): pass
def stream(self, *args, **kwargs):
requests.append(kwargs['json'])
return Response(next(responses))
async def execute(block, **kwargs):
parsed = json.loads(block.content)
return 'ask_user', {
'ask_user': parsed,
'output': 'Awaiting their selection.',
'exit_code': 0,
}
monkeypatch.setattr(module.httpx, 'AsyncClient', Client)
monkeypatch.setattr(module, 'execute_tool_block', execute)
schema = next(s for s in FUNCTION_TOOL_SCHEMAS if s['function']['name'] == 'ask_user')
contract = resolve_full_inventory_contract(schemas=[schema], policy=ToolPolicy())
raw = _collect(module.stream_preview(
endpoint_url='http://test', model='test', headers={},
messages=[{'role': 'user', 'content': 'Help me pick a project.'}],
turn_contract=contract, session_id='test', owner='test',
disabled_tools=set(), tool_policy=ToolPolicy(),
))
events = _events(raw)
# Ending the turn means no second provider round is requested.
assert len(requests) == 1
ask_user_events = [event for event in events if event.get('type') == 'ask_user']
assert len(ask_user_events) == 1
assert ask_user_events[0]['data'] == payload
# The saved assistant text is the question, not the model's follow-up prose.
final = next(event for event in events if event.get('type') == 'final_response')
assert final['content'] == payload['question']
types = [event.get('type') for event in events]
assert types.index('ask_user') < types.index('metrics')
# Reconstructable from persisted metadata, and no phantom extra rounds.
metrics = next(event['data'] for event in events if event.get('type') == 'metrics')
assert metrics['tool_events'][0]['ask_user'] == payload
assert metrics['agent_rounds'] == 1
assert metrics['clean_v3_turn'][-1] == {
'role': 'assistant', 'content': payload['question'],
}
def test_frontend_uses_one_renderer_for_live_and_restored_cards():
chat = (ROOT / "static" / "js" / "chat.js").read_text(encoding="utf-8")
renderer = (ROOT / "static" / "js" / "chatRenderer.js").read_text(encoding="utf-8")
-1
View File
@@ -60,7 +60,6 @@ def test_login_page_static_assets_resolve_under_mount_path():
mounted_login_url = "https://example.test/odysseus/login"
for relative_asset, mounted_path in (
("static/manifest.json", "/odysseus/static/manifest.json"),
("static/icons/icon-192.png", "/odysseus/static/icons/icon-192.png"),
(
"static/fonts/FiraCode-Regular.woff2",
"/odysseus/static/fonts/FiraCode-Regular.woff2",
-42
View File
@@ -1,42 +0,0 @@
"""Spec-surface admission: the strict router picks the tools after authority is
built, so spec turns admit the router's families; compact/full stay unchanged."""
import json
from src.agent_runtime.authority import create_request_authority, ExactOperation
def _permits(auth, tool, args=None):
return auth.permits(ExactOperation.normalize(tool, json.dumps(args or {})))
def _auth(text, **kw):
return create_request_authority(text, owner="pewds", session_id="s1", workspace="", **kw)
def test_spec_surface_admits_a_router_chosen_family_tool():
auth = _auth("Search for the appointment email", spec_surface=True)
assert _permits(auth, "mcp__email__search_emails", {"action": "list", "query": "appointment"})
def test_non_spec_turn_does_not_admit_the_same_call():
auth = _auth("Search for the appointment email")
assert not _permits(auth, "mcp__email__search_emails", {"action": "list", "query": "appointment"})
def test_spec_surface_admits_browser_interaction_and_batch():
auth = _auth("do the thing", spec_surface=True)
assert _permits(auth, "private_browser", {"action": "batch"})
assert _permits(auth, "private_browser", {"action": "click", "selector": "#go"})
assert _permits(auth, "private_browser", {"action": "fill", "selector": "#q", "text": "x"})
def test_batch_is_read_but_click_stays_verb_gated_off_spec():
auth = _auth("search the web for the appointment")
assert _permits(auth, "private_browser", {"action": "batch"})
assert not _permits(auth, "private_browser", {"action": "click", "selector": "#go"})
def test_spec_surface_keeps_the_exact_read_input_seal():
auth = _auth("Read note id abc123", spec_surface=True)
assert _permits(auth, "manage_notes", {"action": "view", "id": "abc123"})
assert not _permits(auth, "manage_notes", {"action": "view", "id": "zzz999"})
+41 -4
View File
@@ -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():
@@ -49,8 +85,9 @@ def test_scheduler_fallbacks_and_research_headers_are_owner_scoped():
assert "owner=task.owner or None" in src
assert "headers_from_resolver = False" in src
assert "headers_from_resolver = True" in src
assert "from src.auth_helpers import owner_filter" in src
assert "owner_filter(ep_q, ModelEndpoint, task.owner or None)" in src
assert "resolve_owner_registered_endpoint(db2, endpoint_url, task.owner or None)" in src
assert "resolve_owner_registered_endpoint(db, endpoint_url, task.owner or None)" in src
assert src.count("resolve_endpoint_runtime(ep, owner=task.owner or None)") == 2
def test_research_routes_fallbacks_are_owner_scoped():
-20
View File
@@ -1,28 +1,8 @@
import json
import pytest
from src.clean_agent_preview import preview_tool_result_text
@pytest.mark.parametrize('snapshot', [None, '', ' '])
def test_text_and_title_survive_without_snapshot(snapshot):
result = {'url': 'https://example.org', 'title': 'Access denied',
'text': 'Unusual traffic. Complete the CAPTCHA.', 'snapshot': snapshot}
output = preview_tool_result_text({'output': json.dumps(result)}, 'private_browser', {})
assert 'Access denied' in output
assert 'Unusual traffic. Complete the CAPTCHA.' in output
from src.clean_agent_preview import browser_observation_access_blocked
assert browser_observation_access_blocked(output)
def test_snapshot_takes_precedence_over_duplicate_page_text():
output = preview_tool_result_text({'output': json.dumps({
'snapshot': '- link "Report" [ref=e1]', 'text': 'Duplicated page body',
})}, 'private_browser', {})
assert '[ref=e1]' in output
assert 'Duplicated page body' not in output
def test_legacy_page_text_retains_access_block_evidence():
result = {'url': 'https://example.org', 'title': 'Security verification',
'text': 'Unusual traffic. Complete the CAPTCHA.'}
+2 -1
View File
@@ -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
+387
View File
@@ -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()
+34 -12
View File
@@ -49,9 +49,6 @@ async def _create_with_reminder(reminder, owner):
@pytest.mark.parametrize("reminder,expected", [
(0, 0), # At-event reminder must not compare equal to False.
(0.0, 0),
("0", 0),
("5 mins", 5),
("10 mins", 10),
("2 hrs", 120),
@@ -94,15 +91,6 @@ async def test_no_reminder_when_offset_absent():
assert "reminder set" not in res.get("response", ""), res
@pytest.mark.parametrize("reminder", [False, "off", "false", None])
async def test_disabled_reminder_is_distinct_from_zero(reminder):
owner = "tester-" + uuid.uuid4().hex[:6]
res = await _create_with_reminder(reminder, owner)
assert res.get("exit_code") == 0, res
assert not res.get("has_reminder"), res
assert res.get("reminder_note_id") is None, res
async def test_update_event_can_add_reminder_after_creation():
owner = "tester-" + uuid.uuid4().hex[:6]
from src.tool_implementations import do_manage_calendar
@@ -135,3 +123,37 @@ async def test_update_event_can_add_reminder_after_creation():
assert note is not None, "update_event should create the reminder note"
finally:
db.close()
async def test_reminder_and_duration_parsers_stay_linear_on_digit_and_space_floods():
"""CodeQL py/polynomial-redos: `(\\d+)\\s*unit` rescanned a digit run from
every offset and `alarm\\s*:?\\s*\\d+` split one whitespace run two ways.
Tool arguments come from model output, so keep them O(n)."""
import time
from src.tool_implementations import do_manage_calendar
owner = "tester-" + uuid.uuid4().hex[:6]
started = time.perf_counter()
res = await do_manage_calendar(json.dumps({
"action": "create_event",
"summary": "Flood",
"dtstart": "2030-04-01T10:00:00",
"reminder_minutes": "0" * 40_000 + "x",
"duration": "0" * 40_000 + "x",
}), owner=owner)
assert time.perf_counter() - started < 2.0
assert res.get("exit_code") == 0, res
assert "reminder set" not in res.get("response", ""), res
# A reminder is set, so the "is the description only a reminder?" check runs.
started = time.perf_counter()
res = await do_manage_calendar(json.dumps({
"action": "create_event",
"summary": "Flood 2",
"dtstart": "2030-04-02T10:00:00",
"description": "alarm" + "\t" * 40_000,
"reminder_minutes": 5,
}), owner=owner)
assert time.perf_counter() - started < 2.0
assert res.get("exit_code") == 0, res
assert "reminder 5 min before" in res.get("response", ""), res
-37
View File
@@ -231,25 +231,6 @@ def test_disabled_tools_respects_missing_vs_explicit_toggles():
)
def test_request_authority_grants_web_when_turn_offers_web_tools():
"""Web tools offered for a turn must carry request authority too.
Regression: the authority capabilities gate only recognized explicit
browser wording (``_explicit_browser_intent``/``_external_discovery_intent``),
so an implicit research question with the Web toggle on offered
``web_search`` in the model's schema but rejected every call with
``request_authority_denied``. The gate must also grant ``search_browser``
whenever web tools are not disabled for the turn.
"""
source = _CHAT_ROUTES.read_text(encoding="utf-8")
assert "({'search_browser'} if (" in source, (
"request authority must be built with a search_browser capabilities gate"
)
assert "or \"web_search\" not in disabled_tools" in source, (
"authority must grant search_browser whenever web_search is offered"
)
def test_explicit_private_browser_workflow_survives_web_search_disabled():
source = _CHAT_ROUTES.read_text(encoding="utf-8")
assert "_explicit_private_browser_intent" in source
@@ -767,21 +748,3 @@ def test_frontend_sends_explicit_allow_web_search_false_in_agent_mode():
assert "fd.append('allow_web_search', el('web-toggle').checked ? 'true' : 'false')" in source, (
"Frontend must send explicit allow_web_search=false in agent mode when toggle is off"
)
def test_spec_surface_skips_the_contract_scope_tool_denial():
"""On the strict-router (spec) surface the regex turn contract must NOT
scope disabled_tools, or the spec router menu (email/notes/...) is emptied
before src/spec_router.py runs. Compact/full/none keep the denial."""
source = _CHAT_ROUTES.read_text(encoding="utf-8")
assert "if not _spec_surface:" in source, (
"the contract-scope disabled_tools.update must be guarded for spec turns"
)
idx = source.index("if not _spec_surface:")
segment = source[idx:idx + 500]
assert "disabled_tools.update(" in segment, (
"the contract-scope denial must live inside the spec guard"
)
assert "if not _turn_contract.permits(" in segment, (
"the guarded block must be the contract-scope denial"
)
+4 -1
View File
@@ -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
+220
View File
@@ -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"
)
+41
View File
@@ -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)
+13
View File
@@ -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")
+185 -354
View File
@@ -109,37 +109,12 @@ def test_deepseek_flash_keeps_tools_but_drops_unsupported_forced_choice():
}
compatible = provider_compatible_tool_choice_request(request, 'deepseek-flash')
# A forced named choice must not hide the other offered tools.
assert [tool['function']['name'] for tool in compatible['tools']] == [
'inspect_media', 'python'
]
assert [tool['function']['name'] for tool in compatible['tools']] == ['inspect_media']
assert 'tool_choice' not in compatible
assert request['tool_choice']['function']['name'] == 'inspect_media'
assert provider_compatible_tool_choice_request(request, 'qwen3.5-9b') is request
def test_deepseek_forced_read_does_not_hide_other_offered_tools():
# Regression: a required calendar read forced tool_choice=manage_calendar on
# a turn that also offered the email tools; the DeepSeek compatibility path
# used to shrink `tools` to manage_calendar only, so the model reported the
# email tools the user asked for were unavailable.
request = {
'model': 'deepseek-v4-flash',
'tools': [
{'type': 'function', 'function': {'name': 'manage_calendar'}},
{'type': 'function', 'function': {'name': 'mcp__email__search_emails'}},
{'type': 'function', 'function': {'name': 'mcp__email__read_email'}},
],
'tool_choice': {'type': 'function', 'function': {'name': 'manage_calendar'}},
}
compatible = provider_compatible_tool_choice_request(request, 'deepseek-v4-flash')
names = [tool['function']['name'] for tool in compatible['tools']]
assert names == [
'manage_calendar', 'mcp__email__search_emails', 'mcp__email__read_email'
]
assert 'tool_choice' not in compatible
def test_deepseek_v4_flash_drops_named_choice_for_thinking_mode():
request = {
'tools': [{'type': 'function', 'function': {'name': 'write_file'}}],
@@ -161,10 +136,8 @@ def test_kimi_k3_drops_named_choice_incompatible_with_thinking_mode():
compatible = provider_compatible_tool_choice_request(request, 'kimi-k3')
# The full tool surface is preserved; only the unsupported forced choice
# is dropped.
assert [tool['function']['name'] for tool in compatible['tools']] == [
'inspect_media', 'write_file'
'inspect_media'
]
assert 'tool_choice' not in compatible
@@ -237,8 +210,8 @@ def test_private_browser_covered_click_does_not_advance_dom_revision():
def test_compact_notes_preserves_create_vs_edit_and_replacement_semantics():
schema = next(s for s in FUNCTION_TOOL_SCHEMAS if s['function']['name'] == 'manage_notes')
compact = compact_schemas([schema])[0]['function']
assert 'ONE add call' in compact['description']
assert 'update with id edits an existing note, never add' in compact['description']
assert 'add creates a new note' in compact['description']
assert 'update with id' in compact['description']
assert 'replaces the whole checklist' in compact['parameters']['properties']['checklist_items']['description']
@@ -4400,20 +4373,21 @@ def test_v3_schema_uses_configured_versioned_contract_root(tmp_path, monkeypatch
(contract_root / 'eval_alltools_unseen_compare.py').write_text(
"def tools_for_mode(tools, mode):\n"
" assert mode == 'compact_contract_v5'\n"
" tools[0]['function']['parameters']['properties']['action']['description'] = 'versioned-contract-loaded'\n"
" tools[0]['function']['description'] = 'versioned-contract-loaded'\n"
" return tools\n",
encoding='utf-8',
)
monkeypatch.setenv('ODYSSEUS_TOOL_CONTRACT_ROOT', str(contract_root))
module.contract_builder.cache_clear()
try:
notes = next(
# Probe a tool whose compact description the harness does not
# replace; manage_notes now carries a full harness-owned override.
search = next(
s for s in FUNCTION_TOOL_SCHEMAS
if s['function']['name'] == 'manage_notes'
if s['function']['name'] == 'web_search'
)
compact = module.compact_schemas([notes])[0]
assert compact['function']['parameters']['properties']['action']['description'] == 'versioned-contract-loaded'
assert 'update with id edits an existing note, never add' in compact['function']['description']
compact = module.compact_schemas([search])[0]
assert compact['function']['description'].startswith('versioned-contract-loaded')
finally:
module.contract_builder.cache_clear()
@@ -4425,7 +4399,6 @@ def test_v3_document_edit_schema_has_one_unambiguous_structured_form():
assert parameters['required'] == ['edits']
assert set(parameters['properties']) == {'edits', 'more'}
assert parameters['properties']['more']['type'] == 'boolean'
assert 'more' not in parameters['required']
def test_v3_ui_schema_advertises_only_policy_executable_client_local_actions():
@@ -4560,7 +4533,7 @@ async def test_stream_emits_incremental_text_and_persistable_history(monkeypatch
raw = [chunk async for chunk in stream_preview(endpoint_url='http://test', model='test', messages=[{'role': 'user', 'content': 'hi'}], headers={}, turn_contract=contract, session_id='test', owner='test', disabled_tools=set(), tool_policy=ToolPolicy())]
events = [json.loads(s[6:]) for s in raw if '[DONE]' not in s]
assert [e['delta'] for e in events if 'delta' in e] == ['Hello', ' there']
assert requests[0]['messages'][0]['content'].startswith('You are Ajax, the assistant in Odysseus.')
assert requests[0]['messages'][0]['content'].startswith('You are Odysseus.')
metrics = next(e['data'] for e in events if e.get('type') == 'metrics')
assert metrics['clean_v3_turn'] == [{'role': 'assistant', 'content': 'Hello there'}]
assert raw[-1] == 'data: [DONE]\n\n'
@@ -4596,7 +4569,9 @@ async def test_preview_provider_stream_error_is_terminal_not_empty_answer(monkey
disabled_tools=set(), tool_policy=ToolPolicy(),
)]
assert raw[-1].startswith('event: error\ndata: ')
assert 'Qwen3_5MTPDraftModel' in raw[-1]
assert 'selected model provider failed' in raw[-1]
assert 'provider_stream_error' in raw[-1]
assert 'Qwen3_5MTPDraftModel' not in raw[-1]
assert all('returned no answer' not in chunk for chunk in raw)
assert all('"type": "metrics"' not in chunk for chunk in raw)
@@ -4696,74 +4671,6 @@ def test_ajax_c375_tool_surface_disables_progressive_thinking():
)
@pytest.mark.parametrize('name,args,reason', [
('python', {}, 'code'),
('apply_patch', {}, 'code'),
('edit_document', {}, 'document_edit'),
('draft_email_reply', {}, 'email_draft'),
('manage_tasks', {'action': 'create'}, 'task_design'),
('manage_skills', {'action': 'publish'}, 'skill_evaluation'),
('manage_tasks', {'action': 'list'}, ''),
('manage_tasks', {'action': 'pause'}, ''),
('manage_skills', {'action': 'view'}, ''),
('read_file', {}, ''),
('web_search', {}, ''),
('manage_notes', {'action': 'add'}, ''),
])
def test_tool_deliberation_is_operation_specific(name, args, reason):
from src.clean_agent_preview import tool_deliberation_reason, progressive_thinking_for_turn
assert tool_deliberation_reason(name, args) == reason
schemas = [{'function': {'name': name}}]
assert progressive_thinking_for_turn('ajax_c375', schemas, deliberate=bool(reason)) == bool(reason)
assert not progressive_thinking_for_turn('other-model', schemas, deliberate=True)
@pytest.mark.asyncio
async def test_task_creation_deliberates_before_executing_once(monkeypatch):
import src.clean_agent_preview as module
requests, executions = [], []
arguments = {'action': 'create', 'name': 'Morning news', 'prompt': 'Summarize news', 'schedule': 'daily', 'time': '09:00'}
proposal = {'choices': [{'delta': {'tool_calls': [{'index': 0, 'id': 'task-call',
'function': {'name': 'manage_tasks', 'arguments': json.dumps(arguments)}}]}}]}
responses = iter([proposal, proposal, {'choices': [{'delta': {'content': 'Created the task.'}}]}])
class Response:
def __init__(self, payload): self.payload = payload
async def __aenter__(self): return self
async def __aexit__(self, *args): pass
def raise_for_status(self): pass
async def aiter_lines(self):
yield 'data: ' + json.dumps(self.payload)
yield 'data: [DONE]'
class Client:
def __init__(self, **kwargs): pass
async def __aenter__(self): return self
async def __aexit__(self, *args): pass
def stream(self, *args, **kwargs):
requests.append(kwargs['json'])
return Response(next(responses))
async def execute(block, **kwargs):
executions.append(block)
return 'manage_tasks', {'output': 'Created task task-1', 'exit_code': 0}
monkeypatch.setattr(module.httpx, 'AsyncClient', Client)
monkeypatch.setattr(module, 'execute_tool_block', execute)
schemas = [s for s in FUNCTION_TOOL_SCHEMAS if s['function']['name'] == 'manage_tasks']
contract = resolve_full_inventory_contract(schemas=schemas, policy=ToolPolicy())
raw = [chunk async for chunk in stream_preview(
endpoint_url='http://test', model='ajax_c375',
messages=[{'role': 'user', 'content': 'Create a daily task at 9am to summarize news.'}],
headers={}, turn_contract=contract, session_id='test', owner='test',
disabled_tools=set(), tool_policy=ToolPolicy(), max_rounds=4,
)]
assert len(executions) == 1, '\n'.join(raw)
assert [r['chat_template_kwargs']['enable_thinking'] for r in requests] == [False, True, False]
assert not any(m.get('tool_calls') for m in requests[1]['messages'])
assert not any('event: error' in chunk for chunk in raw)
@pytest.mark.asyncio
async def test_calendar_list_structured_result_owns_linked_terminal_render(monkeypatch):
import src.clean_agent_preview as module
@@ -5527,9 +5434,7 @@ async def test_model_choice_budget_preserves_evidence_for_final_answer_without_e
@pytest.mark.asyncio
@pytest.mark.parametrize('thinking_mode', ['auto', 'off', 'on'])
@pytest.mark.parametrize('token_limit', [None, 1024])
async def test_failed_static_fetch_recovers_once_through_rendered_browser(monkeypatch, thinking_mode, token_limit):
async def test_failed_static_fetch_recovers_once_through_rendered_browser(monkeypatch):
from dataclasses import replace
import src.clean_agent_preview as module
@@ -5591,17 +5496,13 @@ async def test_failed_static_fetch_recovers_once_through_rendered_browser(monkey
routing_experiment='recent_model_choice',
)
raw = [chunk async for chunk in stream_preview(
endpoint_url='http://test', model='ajax_c375', thinking_mode=thinking_mode,
endpoint_url='http://test', model='test',
messages=[{'role': 'user', 'content': 'Tell me more about that Reuters report.'}],
headers={}, turn_contract=contract, session_id='test', owner='test',
disabled_tools=set(), tool_policy=ToolPolicy(), max_rounds=4, max_tokens=token_limit,
disabled_tools=set(), tool_policy=ToolPolicy(), max_rounds=4,
)]
assert executions == ['web_fetch', 'private_browser', 'web_fetch']
thinking = [r['chat_template_kwargs']['enable_thinking'] for r in requests]
# Compact owns the protocol, including for old sessions with On/Off saved.
assert thinking == [False, True, True, False]
assert requests[1]['max_tokens'] == (token_limit or 4096)
assert all(
schema['function']['name'] != 'web_fetch'
for schema in requests[1].get('tools', [])
@@ -5678,8 +5579,7 @@ async def test_duplicate_fetch_does_not_hide_distinct_pagination_request(monkeyp
@pytest.mark.asyncio
@pytest.mark.parametrize('empty_challenge', [False, True])
async def test_blocked_search_engine_browser_forces_native_web_search(monkeypatch, empty_challenge):
async def test_blocked_search_engine_browser_forces_native_web_search(monkeypatch):
import src.clean_agent_preview as module
responses = iter([
@@ -5701,8 +5601,10 @@ async def test_blocked_search_engine_browser_forces_native_web_search(monkeypatc
},
}]}}]},
{'choices': [{'delta': {'content': (
'I found the [AI update](https://example.com/ai) page. '
'The browser search was blocked, so I used native search to locate it.'
'Recent AI developments include new model releases and policy updates. '
'The search results identify the relevant primary sources and dates for each item, '
'including publication details, concrete findings, and links readers can inspect '
'for the full context behind each development.'
)}}]},
])
requests = []
@@ -5731,9 +5633,6 @@ async def test_blocked_search_engine_browser_forces_native_web_search(monkeypatc
if block.tool_type == 'private_browser':
return 'private_browser', {
'output': json.dumps({
'url': 'https://www.google.com/sorry/index?continue=search',
'title': 'Client Challenge', 'snapshot': '(empty page)',
} if empty_challenge else {
'url': 'https://www.google.com/sorry/index?continue=search',
'text': 'Our systems have detected unusual traffic. Complete the reCAPTCHA.',
}),
@@ -5759,7 +5658,7 @@ async def test_blocked_search_engine_browser_forces_native_web_search(monkeypatc
)
raw = [chunk async for chunk in stream_preview(
endpoint_url='http://test', model='test',
messages=[{'role': 'user', 'content': 'Open browser and find the AI update page on example.com. Give me its link.'}],
messages=[{'role': 'user', 'content': 'Open browser and find an AI model release.'}],
headers={}, turn_contract=contract, session_id='test', owner='test',
disabled_tools=set(), tool_policy=ToolPolicy(), max_rounds=4,
)]
@@ -5767,7 +5666,6 @@ async def test_blocked_search_engine_browser_forces_native_web_search(monkeypatc
assert executions == ['private_browser', 'web_search']
assert requests[1]['tool_choice'] == 'required'
assert [s['function']['name'] for s in requests[1]['tools']] == ['web_search']
assert not any(chunk.startswith('event: error') for chunk in raw), raw
events = [json.loads(chunk[6:]) for chunk in raw if '[DONE]' not in chunk]
assert any(
event.get('type') == 'tool_loop_recovery'
@@ -5830,10 +5728,9 @@ async def test_native_stream_reserves_remaining_budget_for_required_artifact(mon
async def execute(block, **kwargs):
executed.append(block.tool_type)
if block.tool_type == 'python' and '/tmp_workspace/results/out.md' in block.content:
return block.tool_type, {'output': 'ok', 'exit_code': 0,
'materialized_artifacts': ['/tmp_workspace/results']}
return block.tool_type, {"output": "ok", "exit_code": 0}
return block.tool_type, {"output": "ok", "exit_code": 0,
"materialized_artifacts": ["/tmp_workspace/results"]
if block.tool_type == "python" and "out.md" in block.content else []}
monkeypatch.setattr(module.httpx, "AsyncClient", Client)
monkeypatch.setattr(module, "execute_tool_block", execute)
@@ -5945,10 +5842,9 @@ async def test_native_stream_reserves_wall_time_for_required_artifact(monkeypatc
executed.append(block.tool_type)
if block.tool_type == "web_search":
now[0] = 450.0
if block.tool_type == 'python' and '/tmp_workspace/results/out.md' in block.content:
return block.tool_type, {'output': 'ok', 'exit_code': 0,
'materialized_artifacts': ['/tmp_workspace/results']}
return block.tool_type, {"output": "ok", "exit_code": 0}
return block.tool_type, {"output": "ok", "exit_code": 0,
"materialized_artifacts": ["/tmp_workspace/results"]
if block.tool_type == "python" and "out.md" in block.content else []}
monkeypatch.setattr(module.time, "monotonic", lambda: now[0])
monkeypatch.setattr(module.httpx, "AsyncClient", Client)
@@ -6540,15 +6436,14 @@ async def test_native_stream_permanently_withholds_tool_when_model_ignores_dupli
@pytest.mark.asyncio
@pytest.mark.parametrize('ignores_final_instruction', [False, True])
async def test_native_stream_terminates_after_calling_a_permanently_suppressed_tool(monkeypatch, ignores_final_instruction):
async def test_native_stream_terminates_after_calling_a_permanently_suppressed_tool(monkeypatch):
import src.clean_agent_preview as module
arguments = json.dumps({"path": "/workspace/fixtures/video.mp4"})
responses = iter([
{"choices": [{"delta": {"tool_calls": [{"index": 0, "id": f"inspect-{index}", "function": {
"name": "inspect_media", "arguments": arguments,
}}]}}]}
for index in range(1, 5 if ignores_final_instruction else 4)
for index in range(1, 4)
] + [{"choices": [{"delta": {"content": "Final answer from existing evidence."}}]}])
class Response:
@@ -6600,15 +6495,9 @@ async def test_native_stream_terminates_after_calling_a_permanently_suppressed_t
assert 'tools' not in requests[3]
assert 'best concise final answer' in requests[3]['messages'][-1]['content'].lower()
final = [event for event in events if event.get("type") == "final_response"]
assert final == []
metrics = next(event['data'] for event in events if event.get('type') == 'metrics')
if ignores_final_instruction:
assert len(final) == 1
assert 'model repeated a tool call' in final[0]['content']
assert metrics['clean_v3_turn'][-1]['content'] == final[0]['content']
else:
assert final == []
assert metrics['clean_v3_turn'][-1]['content'] == 'Final answer from existing evidence.'
assert raw[-1] == 'data: [DONE]\n\n'
assert metrics['clean_v3_turn'][-1]['content'] == 'Final answer from existing evidence.'
@pytest.mark.asyncio
@@ -7403,175 +7292,6 @@ async def test_tool_round_leadin_is_replaced_by_terminal_synthesis(monkeypatch):
'The most epic option is the DYVLINGE swivel chair.',
]
@pytest.mark.asyncio
async def test_denied_call_after_successful_evidence_recovers_instead_of_refusing(monkeypatch):
"""A blocked call must not discard evidence an earlier call already produced."""
import src.clean_agent_preview as module
def call(index, name, arguments):
return {'index': index, 'id': f'call-{index}', 'function': {
'name': name, 'arguments': json.dumps(arguments),
}}
packets = iter([
{'choices': [{'delta': {'tool_calls': [
call(0, 'web_search', {'query': 'EEG headset prices'})]}}]},
{'choices': [{'delta': {'tool_calls': [
call(0, 'web_fetch', {'url': 'https://vendor.example/eeg'})]}}]},
{'choices': [{'delta': {'content': (
'Based on the search result, the EEG headset starts at $299 from the '
'vendor. I could not open the vendor page because that operation was '
'not permitted.'
)}}]},
])
requests = []
class Response:
def __init__(self, payload): self.payload = payload
async def __aenter__(self): return self
async def __aexit__(self, *args): pass
def raise_for_status(self): pass
async def aiter_lines(self):
yield 'data: ' + json.dumps(self.payload)
yield 'data: [DONE]'
class Client:
def __init__(self, **kwargs): pass
async def __aenter__(self): return self
async def __aexit__(self, *args): pass
def stream(self, *args, **kwargs):
requests.append(kwargs['json'])
return Response(next(packets))
async def execute(block, **kwargs):
if block.tool_type == 'web_search':
return 'web_search', {
'output': '[1] EEG Headset Prices\n https://vendor.example/eeg',
'exit_code': 0,
'evidence_status': 'available',
}
return 'web_fetch', {
'output': 'The exact operation is outside server request authority.',
'exit_code': 1,
'blocked': True,
'failure_kind': 'request_authority_denied',
}
monkeypatch.setattr(module.httpx, 'AsyncClient', Client)
monkeypatch.setattr(module, 'execute_tool_block', execute)
schemas = [
schema for schema in FUNCTION_TOOL_SCHEMAS
if schema['function']['name'] in {'web_search', 'web_fetch'}
]
contract = resolve_full_inventory_contract(schemas=schemas, policy=ToolPolicy())
raw = [chunk async for chunk in stream_preview(
endpoint_url='http://test', model='test',
messages=[{'role': 'user', 'content': (
'Search the web for the latest EEG headset prices and open the vendor page.'
)}],
headers={}, turn_contract=contract, session_id='test', owner='test',
disabled_tools=set(), tool_policy=ToolPolicy(), max_rounds=5,
)]
events = [json.loads(chunk[6:]) for chunk in raw if '[DONE]' not in chunk]
assert any(
event.get('type') == 'completion_recovery'
and event.get('reason') == 'denial_after_successful_evidence'
for event in events
)
visible = ''.join(
str(e.get('delta') or e.get('content') or '') for e in events
)
assert '$299' in visible
assert denied_response() not in visible
# The recovery round must synthesize without tools.
assert requests[-1].get('tools') in (None, [])
@pytest.mark.asyncio
@pytest.mark.parametrize('thinking_mode, expected_thinking', [
('on', {'type': 'enabled'}),
(None, {'type': 'enabled'}),
('off', {'type': 'disabled'}),
])
async def test_deepseek_v4_preview_reserves_reasoning_budget_and_honors_toggle(
monkeypatch, thinking_mode, expected_thinking,
):
import src.clean_agent_preview as module
requests = []
class Response:
def __init__(self, payload): self.payload = payload
async def __aenter__(self): return self
async def __aexit__(self, *args): pass
def raise_for_status(self): pass
async def aiter_lines(self):
yield 'data: ' + json.dumps(self.payload)
yield 'data: [DONE]'
class Client:
def __init__(self, **kwargs): pass
async def __aenter__(self): return self
async def __aexit__(self, *args): pass
def stream(self, *args, **kwargs):
requests.append(kwargs['json'])
return Response({'choices': [{'delta': {
'reasoning_content': 'Let me reason about this.',
'content': 'The answer is 42.',
}}]})
monkeypatch.setattr(module.httpx, 'AsyncClient', Client)
contract = resolve_full_inventory_contract(schemas=[], policy=ToolPolicy())
kwargs = {} if thinking_mode is None else {'thinking_mode': thinking_mode}
raw = [chunk async for chunk in stream_preview(
endpoint_url='https://api.deepseek.com/v1/chat/completions',
model='deepseek-v4-flash',
messages=[{'role': 'user', 'content': 'What is the answer?'}],
headers={}, turn_contract=contract, session_id='test', owner='test',
disabled_tools=set(), tool_policy=ToolPolicy(), **kwargs,
)]
assert requests, 'a provider request should have been sent'
sent = requests[0]
if expected_thinking == {'type': 'disabled'}:
assert sent.get('reasoning_effort') == 'none'
else:
# Reasoning-capable models must reserve budget above the 768 default.
assert sent['max_tokens'] >= 4096
assert sent.get('reasoning_effort')
assert sent.get('thinking') == expected_thinking
assert any('The answer is 42.' in chunk for chunk in raw)
def test_workspace_process_denial_uses_an_honest_refusal(tmp_path):
from src.clean_agent_preview import (
workspace_process_denial,
WORKSPACE_REQUIRED_REFUSAL,
denied_response,
)
# A resource-identity / sealed-workspace denial is recognised from any of
# the tool-event fields (error / output JSON / failure_kind).
assert workspace_process_denial([
{'tool': 'bash', 'exit_code': 1, 'blocked': True,
'output': '{"error":"Process creation requires a sealed workspace and launch scope",'
'"failure_kind":"resource_identity_denied"}'},
])
assert workspace_process_denial([{'failure_kind': 'resource_identity_denied'}])
# A plain request-authority denial is not a workspace denial.
assert not workspace_process_denial([
{'tool': 'bash', 'blocked': True, 'failure_kind': 'request_authority_denied',
'error': 'The exact operation is outside server request authority.'},
])
assert not workspace_process_denial([])
assert 'workspace' in WORKSPACE_REQUIRED_REFUSAL.lower()
assert WORKSPACE_REQUIRED_REFUSAL != denied_response()
# The terminal-denial branch picks the workspace message when appropriate.
source = (Path(__file__).resolve().parents[1] / 'src/clean_agent_preview.py').read_text()
assert 'if workspace_process_denial(executions)' in source
assert 'WORKSPACE_REQUIRED_REFUSAL' in source
def test_spec_router_think_decision_and_reason(monkeypatch):
import sys
import types
@@ -7622,55 +7342,166 @@ def test_spec_router_think_decision_and_reason(monkeypatch):
assert 'spec_router_think_reason(' in source
def test_qwen_think_split_streams_thinking_then_answer_without_buffering():
from src.clean_agent_preview import qwen_think_split_events
def test_spec_direct_no_tools_decision(monkeypatch):
import sys
import types
from src.clean_agent_preview import spec_direct_no_tools_p
state = {'think_emitted': 0, 'answer_emitted': 0, 'closed': False}
events = []
content = ''
for piece in ['<think>', 'abc', '</think>', 'Hello']:
content += piece
events.extend(qwen_think_split_events(content, state))
fake = types.ModuleType('src.spec_router')
fake.LAST_DECISIONS = [
{'message': 'Yo', 'choice': 'answer_directly', 'answer_directly': 0.971},
]
monkeypatch.setitem(sys.modules, 'src.spec_router', fake)
monkeypatch.setattr(
'src.agent_loop._configured_model_tool_surface_raw',
lambda *a, **k: 'spec', raising=False,
)
thinking = ''.join(e['delta'] for e in events if e.get('thinking'))
answer = ''.join(e['delta'] for e in events if not e.get('thinking'))
assert thinking == 'abc'
assert answer == 'Hello'
# every emitted delta is non-empty and typed
assert all(e.get('delta') for e in events)
assert [e.get('thinking') for e in events] == [True, None]
# --- default OFF -> no override, identical behaviour --------------------
monkeypatch.delenv('ODYSSEUS_SPEC_DIRECT_NOTOOLS', raising=False)
assert spec_direct_no_tools_p('Yo', 'u', 'm', 'o', {}) is None
# the stored answer stays identical to visible_content_after_qwen_thinking
from src.clean_agent_preview import visible_content_after_qwen_thinking
assert visible_content_after_qwen_thinking(content) == 'Hello'
# --- ON + answer_directly p >= 0.9 -> returns the confidence ------------
monkeypatch.setenv('ODYSSEUS_SPEC_DIRECT_NOTOOLS', '1')
monkeypatch.delenv('ODYSSEUS_SPEC_DIRECT_MIN_P', raising=False)
assert spec_direct_no_tools_p('Yo', 'u', 'm', 'o', {}) == 0.971
# --- low confidence -> no override --------------------------------------
fake.LAST_DECISIONS = [
{'message': 'Yo', 'choice': 'answer_directly', 'answer_directly': 0.5},
]
assert spec_direct_no_tools_p('Yo', 'u', 'm', 'o', {}) is None
def test_qwen_think_split_holds_back_a_partial_close_tag():
from src.clean_agent_preview import qwen_think_split_events
# --- MIN_P override lowers the bar --------------------------------------
monkeypatch.setenv('ODYSSEUS_SPEC_DIRECT_MIN_P', '0.4')
assert spec_direct_no_tools_p('Yo', 'u', 'm', 'o', {}) == 0.5
monkeypatch.delenv('ODYSSEUS_SPEC_DIRECT_MIN_P', raising=False)
state = {'think_emitted': 0, 'answer_emitted': 0, 'closed': False}
events = []
content = ''
for piece in ['<think>a', '</thi', 'nk>', 'B']:
content += piece
events.extend(qwen_think_split_events(content, state))
# --- a non answer_directly choice -> no override ------------------------
fake.LAST_DECISIONS = [
{'message': 'Yo', 'choice': 'web_search', 'answer_directly': 0.971},
]
assert spec_direct_no_tools_p('Yo', 'u', 'm', 'o', {}) is None
thinking = ''.join(e['delta'] for e in events if e.get('thinking'))
answer = ''.join(e['delta'] for e in events if not e.get('thinking'))
assert thinking == 'a' # the partial '</thi' is never leaked
assert answer == 'B'
assert all(e.get('delta') for e in events)
# --- stale decision for a different message -> no override --------------
fake.LAST_DECISIONS = [
{'message': 'something else entirely', 'choice': 'answer_directly',
'answer_directly': 0.99},
]
assert spec_direct_no_tools_p('Yo', 'u', 'm', 'o', {}) is None
# --- non-spec surface -> no override ------------------------------------
fake.LAST_DECISIONS = [
{'message': 'Yo', 'choice': 'answer_directly', 'answer_directly': 0.971},
]
monkeypatch.setattr(
'src.agent_loop._configured_model_tool_surface_raw',
lambda *a, **k: 'native', raising=False,
)
assert spec_direct_no_tools_p('Yo', 'u', 'm', 'o', {}) is None
def test_qwen_think_split_is_inert_without_a_think_block():
from src.clean_agent_preview import qwen_think_split_events
# --- router error -> fail open (None) -----------------------------------
monkeypatch.setattr(
'src.agent_loop._configured_model_tool_surface_raw',
lambda *a, **k: 'spec', raising=False,
)
state = {'think_emitted': 0, 'answer_emitted': 0, 'closed': False}
assert qwen_think_split_events('plain answer', state) == []
assert qwen_think_split_events('', state) == []
assert state == {'think_emitted': 0, 'answer_emitted': 0, 'closed': False}
class _Boom:
@property
def LAST_DECISIONS(self):
raise RuntimeError('router down')
monkeypatch.setitem(sys.modules, 'src.spec_router', _Boom())
assert spec_direct_no_tools_p('Yo', 'u', 'm', 'o', {}) is None
# source-level: stream_preview wires the incremental split
# --- wiring present in stream_preview -----------------------------------
source = (Path(__file__).resolve().parents[1] / 'src/clean_agent_preview.py').read_text()
assert 'qwen_think_split_events(' in source
assert "think_answer_streamed" in source
assert 'spec_direct_no_tools_p(' in source
assert '[spec-harness] direct answer' in source
def test_action_promise_ignores_fenced_code_and_knows_browse_verbs():
import src.clean_agent_preview as module
gym_reply = (
'I will browse Google Maps to find the closest gym near Todoroki.\n\n'
'```python\n'
'import requests\n'
'from bs4 import BeautifulSoup\n'
'resp = requests.get("https://maps.google.com/search?q=gym+todoroki")\n'
'soup = BeautifulSoup(resp.text, "html.parser")\n'
'for row in soup.select(".section-result"):\n'
' print(row.get_text())\n'
'```\n'
)
assert module.action_promise_response(gym_reply)
assert module.action_promise_response('I will fetch the page and scrape the listing.')
assert module.action_promise_response('Let me search for the nearest gym now.')
assert module.action_promise_response("I'll go check Google Maps for you.")
# A real answer that includes a code block is not an action promise.
assert not module.action_promise_response(
'The closest gym to Todoroki is Gold\u2019s Gym, about 600 m away.\n\n'
'```python\n'
'print("done")\n'
'```\n'
)
assert not module.action_promise_response('Here is the script you asked for:\n```\nprint(1)\n```')
@pytest.mark.parametrize('prompt', [
'please explain how a student should break down work about customer feedback; this is general advice only, do not create or schedule anything.',
'Give general advice about customer feedback and task planning; do not create anything.',
'Explain feedback tasks without scheduling anything.',
])
def test_feedback_advice_does_not_claim_write_authority(prompt):
assert not requests_mutation(prompt)
def test_feedback_subject_does_not_hide_real_write_request():
assert requests_mutation('Schedule a customer feedback task for tomorrow.')
assert requests_mutation('Do not create anything; schedule a customer feedback task tomorrow.')
@pytest.mark.asyncio
async def test_direct_feedback_advice_is_not_replaced_with_preview_denial(monkeypatch):
import src.clean_agent_preview as module
answer = 'Set a goal, group the customer comments by theme, and summarize what you learned.'
class Response:
async def __aenter__(self): return self
async def __aexit__(self, *args): pass
def raise_for_status(self): pass
async def aiter_lines(self):
yield 'data: ' + json.dumps({'choices': [{'delta': {'content': answer}}]})
yield 'data: [DONE]'
class Client:
def __init__(self, **kwargs): pass
async def __aenter__(self): return self
async def __aexit__(self, *args): pass
def stream(self, *args, **kwargs):
assert 'tools' not in kwargs['json']
return Response()
monkeypatch.setattr(module.httpx, 'AsyncClient', Client)
contract = resolve_full_inventory_contract(schemas=[], policy=ToolPolicy())
prompt = ('please explain how a student should break down work about customer feedback; '
'this is general advice only, do not create or schedule anything.')
chunks = [chunk async for chunk in stream_preview(
endpoint_url='http://test', model='ajax-ablated500-base',
messages=[{'role': 'user', 'content': prompt}], headers={},
turn_contract=contract, session_id='test', owner='test',
disabled_tools=set(), tool_policy=ToolPolicy(),
)]
events = [json.loads(chunk[6:]) for chunk in chunks if '[DONE]' not in chunk]
assert ''.join(event.get('delta', '') for event in events) == answer
assert not any(event.get('type') == 'final_response' and event.get('content') == denied_response()
for event in events)
@@ -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
+12 -11
View File
@@ -624,7 +624,8 @@ def test_bridge_post_targets_endpoint_paths():
posted = {}
class FakeClient:
def __init__(self, timeout=None):
def __init__(self, timeout=None, *, trust_env=True):
assert trust_env is False
posted["timeout"] = timeout
async def __aenter__(self):
@@ -695,8 +696,8 @@ def test_bridge_post_treats_empty_or_error_payload_as_failure():
payloads = iter(({}, {"error": "token rejected"}))
class FakeClient:
def __init__(self, timeout=None):
pass
def __init__(self, timeout=None, *, trust_env=True):
assert trust_env is False
async def __aenter__(self):
return self
@@ -734,8 +735,8 @@ def test_bridge_post_http_or_payload_error_overrides_false_success_code():
))
class FakeClient:
def __init__(self, timeout=None):
pass
def __init__(self, timeout=None, *, trust_env=True):
assert trust_env is False
async def __aenter__(self):
return self
@@ -769,8 +770,8 @@ def test_bridge_post_rejects_malformed_exit_code():
return {"output": "looks successful", "exit_code": "zero"}
class FakeClient:
def __init__(self, timeout=None):
pass
def __init__(self, timeout=None, *, trust_env=True):
assert trust_env is False
async def __aenter__(self):
return self
@@ -804,8 +805,8 @@ def test_bridge_post_reports_empty_invalid_json_response():
raise ValueError("not JSON")
class FakeClient:
def __init__(self, timeout=None):
pass
def __init__(self, timeout=None, *, trust_env=True):
assert trust_env is False
async def __aenter__(self):
return self
@@ -833,8 +834,8 @@ def test_bridge_post_reports_empty_invalid_json_response():
def test_bridge_post_cancellation_schedules_host_operation_cancel():
class FakeClient:
def __init__(self, timeout=None):
pass
def __init__(self, timeout=None, *, trust_env=True):
assert trust_env is False
async def __aenter__(self):
return self
+21 -1
View File
@@ -61,11 +61,20 @@ def test_grep_ignore_case(repo):
assert "b.txt:2:" in r["output"]
def test_grep_glob_filter(repo):
def test_grep_glob_filter(repo, monkeypatch):
r = _run("grep", f'{{"pattern": "needle", "ignore_case": true, "glob": "*.py", "path": "{repo}"}}')
assert r["exit_code"] == 0
assert "a.py" in r["output"]
assert "b.txt" not in r["output"]
# Positive control: verify that fallback path honors the exact same glob contract
real_which = shutil.which
monkeypatch.setattr(shutil, "which", lambda name, *a, **kw: None if name == "rg" else real_which(name, *a, **kw))
r_fallback = _run("grep", f'{{"pattern": "needle", "ignore_case": true, "glob": "*.py", "path": "{repo}"}}')
assert r_fallback["exit_code"] == 0
assert "a.py" in r_fallback["output"]
assert "b.txt" not in r_fallback["output"]
def test_grep_no_match(repo):
r = _run("grep", f'{{"pattern": "zzzznotfound", "path": "{repo}"}}')
@@ -94,6 +103,17 @@ def test_grep_python_fallback_when_no_rg(repo, monkeypatch):
assert ".git/config" not in r["output"]
def test_grep_python_fallback_uses_relative_glob_paths(repo, monkeypatch):
monkeypatch.setattr(shutil, "which", lambda name: None)
r = _run(
"grep",
f'{{"pattern": "needle|python", "glob": "**/*.py", "path": "{repo}"}}',
)
assert r["exit_code"] == 0
assert "a.py" in r["output"]
assert "sub/deep/c.py" in r["output"]
@pytest.mark.skipif(shutil.which("rg") is None, reason="targets the ripgrep fast-path")
def test_grep_skips_case_variant_sensitive_files_rg(repo):
"""The rg fast-path must exclude deny-listed key files case-insensitively.
+111
View File
@@ -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"]
-36
View File
@@ -17,42 +17,6 @@ ERROR = 'event: error\ndata: {"status": 504, "error": {"message": "stream timeou
DONE = 'data: [DONE]\n\n'
@pytest.mark.asyncio
async def test_answer_deltas_are_visible_before_terminal_completion_check():
finish = asyncio.Event()
@with_completion_gate
async def stream(messages):
yield _event({'delta': 'Live answer starts. '})
await finish.wait()
yield _event({'type': 'metrics', 'data': {}})
yield DONE
output = stream([])
first = await asyncio.wait_for(anext(output), timeout=0.2)
assert json.loads(first[6:])['delta'] == 'Live answer starts. '
finish.set()
remaining = [chunk async for chunk in output]
assert _decision([first, *remaining])['can_complete'] is True
@pytest.mark.asyncio
async def test_completion_replacement_replaces_already_streamed_answer_for_turn():
@with_completion_gate
async def stream(messages):
yield _event({'delta': 'The function returns a boolean. I ran pytest and all tests passed.'})
yield _event({'type': 'metrics', 'data': {}})
yield DONE
chunks = [chunk async for chunk in stream([{'role': 'user', 'content': 'Explain this code.'}])]
frames = [data for event, data in _frames(chunks) if event == 'message' and isinstance(data, dict)]
assert any(item.get('delta') == 'The function returns a boolean.' for item in frames)
assert not any('I ran pytest' in str(item.get('delta', '')) for item in frames)
replacement = next(item for item in frames if item.get('type') == 'final_response')
assert 'I ran pytest' not in replacement['content']
assert replacement['replacement_scope'] == 'turn'
@pytest.mark.asyncio
@pytest.mark.parametrize('terminal_texts', [[], ['', ''], ['Recovered answer with literal [DONE] text.']])
async def test_terminal_round_retraction_does_not_resurrect_buffered_drafts(terminal_texts):
+34
View File
@@ -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"
+8 -1
View File
@@ -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")
+63
View File
@@ -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
+26 -4
View File
@@ -2,6 +2,7 @@ import asyncio
import json
from types import SimpleNamespace
import pytest
from PIL import Image
from src.tools.image import do_edit_image
@@ -122,7 +123,8 @@ def test_edit_image_rejects_actions_without_complete_input_contract():
assert "Use prompt, upscale or rembg" in result["error"]
def test_prompt_edit_forwards_owned_pixels_and_preserves_original(monkeypatch, tmp_path):
@pytest.mark.parametrize('incognito', [False, True])
def test_prompt_edit_forwards_owned_pixels_and_preserves_original(monkeypatch, tmp_path, incognito):
source_path = tmp_path / 'source.png'
Image.new('RGB', (3, 2), 'red').save(source_path)
original = source_path.read_bytes()
@@ -135,9 +137,9 @@ def test_prompt_edit_forwards_owned_pixels_and_preserves_original(monkeypatch, t
return {'image_id': 'edited', 'image_url': '/api/generated-image/edited.png'}
from pathlib import Path
monkeypatch.setattr('src.ai_interaction.do_edit_image', edit)
result = asyncio.run(do_edit_image(json.dumps({'image_id': 'source-id', 'action': 'prompt', 'prompt': 'Add another cow'}), owner='alice'))
result = asyncio.run(do_edit_image(json.dumps({'image_id': 'source-id', 'action': 'prompt', 'prompt': 'Add another cow'}), owner='alice', incognito=incognito))
assert result['image_id'] == 'edited'
assert calls == [('Add another cow', original, {'session_id': 'session-1', 'owner': 'alice', 'size': 'auto'})]
assert calls == [('Add another cow', original, {'session_id': 'session-1', 'owner': 'alice', 'size': 'auto', 'incognito': incognito})]
assert source_path.read_bytes() == original
denied = asyncio.run(do_edit_image('{"image_id":"source-id","action":"prompt","prompt":"edit"}', owner=None))
assert denied['error'] == 'Image not found'
@@ -165,7 +167,7 @@ def test_uploaded_image_edit_uses_owner_scoped_reference(monkeypatch, tmp_path):
monkeypatch.setattr('src.ai_interaction.do_edit_image', edit)
args = '{"image_id":"odysseus://attachment/upload.jpg","action":"prompt","prompt":"Make more realistic"}'
assert asyncio.run(do_edit_image(args, owner='alice')) == {'image_id': 'edited'}
assert calls == [('Make more realistic', str(path), {'owner': 'alice', 'size': 'auto'})]
assert calls == [('Make more realistic', str(path), {'owner': 'alice', 'size': 'auto', 'incognito': False})]
assert resolutions == [('upload.jpg', {'owner': 'alice', 'allow_admin': False})]
assert 'error' in asyncio.run(do_edit_image(args, owner='bob'))
assert len(calls) == 1
@@ -173,3 +175,23 @@ def test_uploaded_image_edit_uses_owner_scoped_reference(monkeypatch, tmp_path):
disabled = asyncio.run(do_edit_image(args, owner='alice'))
assert 'disabled' in disabled['error']
assert len(calls) == 1
def test_private_upscale_does_not_save_image_or_gallery_record(monkeypatch, tmp_path):
source_path = tmp_path / 'source.png'
Image.new('RGB', (3, 2), 'red').save(source_path)
original = source_path.read_bytes()
db = _Db(_source(source_path.name))
monkeypatch.setattr('core.database.SessionLocal', lambda: db)
monkeypatch.setattr('src.constants.GENERATED_IMAGES_DIR', str(tmp_path))
result = asyncio.run(do_edit_image(
'{"image_id":"source-id","action":"upscale","scale":2}',
owner='alice', incognito=True,
))
assert result['exit_code'] == 0
assert result['image_url'].startswith('data:image/png;base64,')
assert not result['image_id']
assert db.added == []
assert not db.committed
assert list(tmp_path.iterdir()) == [source_path]
assert source_path.read_bytes() == original
@@ -0,0 +1,83 @@
"""Server health audit fix: the local editor VLM returned 400s when asked for a
model id it does not serve (the client default 'Qwen3.5-9B-MLX-8bit', or the
stale caption model 'Qwen/Qwen3-VL-30B'). ``_resolve_model`` maps the requested
id to one the base actually serves."""
import asyncio
from src import editor_agent_model as eam
def _cache(base, ids):
eam._MODELS_CACHE[base] = set(ids)
def test_resolve_model_keeps_a_served_request():
base = "http://x:1/v1"
_cache(base, ["Qwen/Qwen3.5-9B", "other"])
assert asyncio.run(eam._resolve_model(base, "Qwen/Qwen3.5-9B", "Qwen/Qwen3.5-9B")) == "Qwen/Qwen3.5-9B"
def test_resolve_model_falls_back_when_requested_is_not_served():
base = "http://x:2/v1"
_cache(base, ["Qwen/Qwen3.5-9B"])
# e.g. the client's MLX build name, or a stale caption model id.
assert asyncio.run(eam._resolve_model(base, "Qwen3.5-9B-MLX-8bit", "Qwen/Qwen3.5-9B")) == "Qwen/Qwen3.5-9B"
assert asyncio.run(eam._resolve_model(base, "Qwen/Qwen3-VL-30B", "Qwen/Qwen3.5-9B")) == "Qwen/Qwen3.5-9B"
def test_resolve_model_picks_a_served_id_when_fallback_absent():
base = "http://x:3/v1"
_cache(base, ["B", "A"])
assert asyncio.run(eam._resolve_model(base, "nope", "also-nope")) == "A"
def test_resolve_model_keeps_request_when_served_list_unknown():
base = "http://x:4/v1"
_cache(base, [])
# Server unreachable / no models listed -> don't rewrite the request.
assert asyncio.run(eam._resolve_model(base, "some/model", "fallback")) == "some/model"
def test_self_check_resolves_the_model_before_calling(monkeypatch):
monkeypatch.setattr(eam, "local_bases", lambda: ["http://editor.test/v1"])
base = eam.local_bases()[0]
_cache(base, ["Qwen/Qwen3.5-9B"])
seen = {}
async def fake_post(b, model, messages, headers, timeout, **kwargs):
seen["model"] = model
return "DONE"
monkeypatch.setattr(eam, "_post_chat", fake_post)
out = asyncio.run(eam.self_check(
"data:image/png;base64,AAAA", "data:image/png;base64,BBBB",
"make it brighter", model="Qwen3.5-9B-MLX-8bit"))
assert seen["model"] == "Qwen/Qwen3.5-9B"
assert out["done"] is True
def test_caption_resolves_the_caption_model(monkeypatch):
monkeypatch.setattr(eam, "caption_base", lambda: "http://caption.test/v1")
seen = {}
async def fake_post(b, model, messages, headers, timeout, **kwargs):
seen["base"] = b
seen["model"] = model
return "a photo"
monkeypatch.setattr(eam, "_post_chat", fake_post)
import src.editor_agent_model as m
_cache(m.caption_base(), ["Qwen/Qwen3.5-9B"])
txt = asyncio.run(m.caption_image("data:image/png;base64,AAAA"))
assert txt == "a photo"
assert seen["model"] == "Qwen/Qwen3.5-9B"
def test_unconfigured_editor_helpers_do_not_probe_private_hosts(monkeypatch):
monkeypatch.setattr(eam, "local_bases", lambda: [])
monkeypatch.setattr(eam, "caption_base", lambda: "")
monkeypatch.setattr(eam, "probe_models", lambda *a: (_ for _ in ()).throw(AssertionError("unexpected probe")))
eam._probe_worker()
assert asyncio.run(eam.caption_image("data:image/png;base64,AAAA")) is None
result = asyncio.run(eam.self_check("data:image/png;base64,AAAA", "data:image/png;base64,BBBB", "brighter"))
assert result["reason"] == "unavailable"
+171
View File
@@ -0,0 +1,171 @@
"""Planner routing regression suite (HARNESS-owned).
Turns PHOTOSHOP's bench prompt families (vague, restyle, bg-remove/scene,
tone/auto, generative, structural) into assertions on the ROUTE CLASS the
server planner picks, so a future planner change cannot silently re-break
routing. The classes are:
ask_user -> the planner is unsure; it must ask with 2-3 choices and
apply NO operations (never a silent tone default)
deterministic -> a deterministic editor op / recipe (adjustments, filters,
crop, text, rotate, fill, recolour ...)
ai_edit_image -> a generative image-model edit (content add/remove/replace,
restyle, look)
generate_image -> text-to-image for a brand-new image
These use the pure routing helpers (``_plan_special`` / ``_generative_kind`` /
``_fallback_plan``), so no model or network call is made.
"""
import json
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from routes import editor_agent_routes as eaar
_STATE = {"width": 900, "height": 600}
_PROVIDER = True
@pytest.fixture(autouse=True)
def _reset_planner():
eaar.set_editor_planner(None)
yield
eaar.set_editor_planner(None)
@pytest.fixture()
def client():
app = FastAPI()
app.include_router(eaar.setup_editor_agent_routes())
return TestClient(app)
def _classify(prompt, provider=_PROVIDER):
"""Return (route_class, ops) for a prompt using the deterministic helpers."""
special = eaar._plan_special(prompt, _STATE, provider)
if special:
steps = list(special[0])
else:
fallback = eaar._fallback_plan(prompt)
if fallback.get("ask_user"):
return "ask_user", []
steps = list(fallback.get("steps") or [])
ops = [str(s.get("op") or "") for s in steps]
if "generate_image" in ops:
return "generate_image", ops
if "ai_edit_image" in ops:
return "ai_edit_image", ops
return "deterministic", ops
# (prompt, expected route class)
BENCH_ROUTING = [
# vague -> ask (never a silent tone tweak)
("do the thing", "ask_user"),
("give it some oomph", "ask_user"),
("make it better", "ask_user"),
# whole-image restyle -> model edit
("make it look like an oil painting", "ai_edit_image"),
("turn it into an anime", "ai_edit_image"),
("make it a comic book illustration", "ai_edit_image"),
("make it look like cyberpunk", "ai_edit_image"),
# background / scene replacement -> model edit
("put her on a beach", "ai_edit_image"),
("make the background nicer", "ai_edit_image"),
("make it snowy", "ai_edit_image"),
# generative content add / remove -> model edit
("add a full moon in the sky", "ai_edit_image"),
("remove the distractions", "ai_edit_image"),
("remove the person on the left", "ai_edit_image"),
# brand-new image -> text-to-image
("create a new image of a full moon", "generate_image"),
("generate an image of a sunset", "generate_image"),
("make a logo", "generate_image"),
# tone / auto / look / structural -> deterministic
("make it black and white", "deterministic"),
("make it pop", "deterministic"),
("make it look professional", "deterministic"),
("make only the red car blue", "deterministic"),
("make a 1080x1350 Instagram post from this", "deterministic"),
("add a title TOKYO with a drop shadow", "deterministic"),
("extend the canvas to 16:9", "deterministic"),
("save as PSD with layers", "deterministic"),
("fill the white area with red", "deterministic"),
("rotate the whole image 90 degrees", "deterministic"),
]
@pytest.mark.parametrize("prompt,expected", BENCH_ROUTING)
def test_bench_prompts_route_to_expected_class(prompt, expected):
route_class, ops = _classify(prompt)
assert route_class == expected, f"{prompt!r} -> {route_class} {ops} (want {expected})"
def test_ask_plans_offer_two_to_three_choices():
for prompt in ("do the thing", "give it some oomph", "make it better"):
plan = eaar._fallback_plan(prompt)
ask = plan.get("ask_user")
assert isinstance(ask, dict), prompt
opts = ask.get("options") or []
assert 2 <= len(opts) <= 3, (prompt, opts)
assert plan.get("steps") == []
def test_object_removal_uses_selection_plus_model_edit():
_cls, ops = _classify("remove the person on the left")
assert "ai_edit_image" in ops
assert any(op.startswith("selection.") for op in ops), ops
def test_recolour_never_uses_a_flat_fill_or_a_global_tint():
# Every recolour selects the object by description and either shifts the
# masked hue or asks the image model for the masked region — never a flat
# selection.fill and never a whole-image adjustments.apply.
for prompt in ("make only the red car blue", "make the red car white", "make the flowers bright pink"):
_cls, ops = _classify(prompt)
assert "selection.samText" in ops, (prompt, ops)
assert "selection.fill" not in ops, (prompt, ops)
assert "adjustments.apply" not in ops, (prompt, ops)
def test_chromatic_recolour_is_a_deterministic_masked_hue_shift():
cls, ops = _classify("make only the red car blue")
assert cls == "deterministic", ops
assert ops == ["selection.samText", "adjustments.applyMasked"], ops
def test_new_image_request_from_scratch_vs_edit_of_current_photo():
# A photo edit ("make it a <style>") must NOT be treated as a new image.
assert eaar._generative_kind("make it a comic book illustration") == "restyle"
assert eaar._is_new_image_request("make it a comic book illustration") is False
# A from-scratch generation still is.
assert eaar._is_new_image_request("create an illustration of a cat") is True
assert eaar._generative_kind("create an illustration of a cat") == "add"
def test_plan_route_returns_deterministic_recolour(client):
eaar.set_editor_planner(lambda messages, owner: json.dumps({"reply": "ok", "steps": []}))
r = client.post("/api/editor/plan", json={"prompt": "make only the red car blue", "state": _STATE})
assert r.status_code == 200
data = r.json()
assert data["planner"] == "deterministic"
assert [s["op"] for s in data["steps"]] == ["selection.samText", "adjustments.applyMasked"]
def test_plan_route_asks_for_a_vague_prompt(client):
eaar.set_editor_planner(lambda messages, owner: json.dumps({"reply": "ok", "steps": []}))
r = client.post("/api/editor/plan", json={"prompt": "do the thing", "state": _STATE})
assert r.status_code == 200
data = r.json()
assert data.get("ask_user"), data
assert data.get("steps") == []
def test_plan_route_routes_restyle_to_model_edit(client):
eaar.set_editor_planner(lambda messages, owner: json.dumps({"reply": "ok", "steps": []}))
r = client.post("/api/editor/plan", json={"prompt": "turn it into an anime", "state": _STATE})
assert r.status_code == 200
ops = [s["op"] for s in r.json()["steps"]]
assert "ai_edit_image" in ops, ops
@@ -11,6 +11,7 @@ from src.clean_agent_preview import (
from src.turn_contract import (
editor_request_instructions, requested_capabilities, selected_tools_for_request,
preserve_bound_editor_selected_tools, resolve_turn_contract,
targets_bound_editor_request,
)
from src.tool_schemas import FUNCTION_TOOL_SCHEMAS
from src.tool_policy import ToolPolicy
@@ -72,6 +73,16 @@ def test_direct_edits_share_the_capability_router(prompt):
assert not active_editor_suggestion_request(doc, prompt)
@pytest.mark.parametrize('prompt', [
'ga mer ut pa att vi tanker pa framtid for bjorn och att vi faktist trivs sa bra har',
'Go more into how we are thinking about the future.',
])
def test_implicit_revision_followups_route_to_active_document_only(prompt):
assert requested_capabilities(prompt, active_document=True) == {'documents'}
assert requested_capabilities(prompt, active_document=False) == frozenset()
assert targets_bound_editor_request(prompt)
def test_source_review_still_requires_tool_completion_after_research():
schemas = [s for s in FUNCTION_TOOL_SCHEMAS if s['function']['name'] in {'web_search', 'suggest_document'}]
assert required_active_editor_tool_choice(active_editor_target=True, suggestion_target=True,
+2 -2
View File
@@ -338,8 +338,8 @@ def test_hardlinked_effect_state_is_control_plane_without_scanning_the_store(tmp
assert str(store) not in listed
assert resources._control_plane_path(str(alias), snapshot=snapshot) is True
assert str(store) not in globbed, "the effect store is listed one level, never recursively inventoried"
# Multiply linked files elsewhere stay ordinary.
# Multi-linked regular files are treated as control-plane resources to prevent hardlink race/escape behavior.
elsewhere = tmp_path / "other.txt"
elsewhere.write_text("y")
os.link(elsewhere, workspace / "pnpm-style.txt")
assert resources._control_plane_path(str(workspace / "pnpm-style.txt")) is False
assert resources._control_plane_path(str(workspace / "pnpm-style.txt")) is True
+9 -1
View File
@@ -179,8 +179,16 @@ def test_cancelled_write_unsettles_an_earlier_success(run, ws, monkeypatch):
def test_mid_write_failure_unsettles_but_refusal_preserves(run, ws, monkeypatch):
run("write_file", {"path": "a.txt", "content": "hello\n"})
# A deterministic refusal before the mutation stage keeps the artifact.
# Explicit empty JSON clear/write is authoritative and succeeds.
run("write_file", {"path": "a.txt", "content": ""})
assert run.journal.effects.assessments()[-1].execution is fx.ExecutionOutcome.REPORTED_SUCCESS
# Re-seed the artifact content
run("write_file", {"path": "a.txt", "content": "hello\n"})
assert ledger(run.journal, ws).evaluate().can_complete
# A deterministic refusal before the mutation stage keeps the artifact.
run("write_file", "a.txt\n")
assert run.journal.effects.assessments()[-1].execution is fx.ExecutionOutcome.FAILED
assert ledger(run.journal, ws).evaluate().can_complete
+515
View File
@@ -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"}
+509
View File
@@ -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
+901 -6
View File
@@ -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
+438
View File
@@ -0,0 +1,438 @@
/**
* Browser print regression tests for Email Library print and PDF export.
*
* Requirements:
* - Node.js (v20+)
* - Playwright (`npm ci` or `npm install -D @playwright/test`)
* - Chromium browser binary:
* - Standard Playwright install: `npx playwright install chromium`
* - Or custom executable via env vars:
* - `PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH=/path/to/chrome`
* - `CHROME_PATH=/path/to/chrome`
* - Or custom browsers cache dir via:
* - `PLAYWRIGHT_BROWSERS_PATH=/path/to/cache`
*/
const http = require('http');
const fs = require('fs');
const path = require('path');
const os = require('os');
let chromium;
try {
({ chromium } = require('playwright'));
} catch (err) {
if (err && err.code === 'MODULE_NOT_FOUND') {
console.error(
'Error: Playwright module not found.\n' +
'Please install dependencies by running:\n' +
' npm ci\n' +
'or ensure NODE_PATH contains the playwright package.'
);
process.exit(1);
}
throw err;
}
const ROOT = path.resolve(__dirname, '..');
// Discover available Chromium executable using standard Playwright resolution,
// custom environment variables, standard cache paths, or system binaries.
function findChromiumPath() {
// 1. Explicit environment variable overrides
if (process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH && fs.existsSync(process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH)) {
return process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH;
}
if (process.env.CHROME_PATH && fs.existsSync(process.env.CHROME_PATH)) {
return process.env.CHROME_PATH;
}
// 2. Playwright's configured/default executable path
try {
const defaultPath = chromium.executablePath();
if (defaultPath && fs.existsSync(defaultPath)) {
return defaultPath;
}
} catch (e) {
// chromium.executablePath() may throw if no default browser is installed
}
// 3. Inspect standard Playwright browser cache directories
const cacheDirs = [];
if (process.env.PLAYWRIGHT_BROWSERS_PATH) {
cacheDirs.push(process.env.PLAYWRIGHT_BROWSERS_PATH);
}
if (process.platform === 'win32') {
if (process.env.LOCALAPPDATA) {
cacheDirs.push(path.join(process.env.LOCALAPPDATA, 'ms-playwright'));
}
cacheDirs.push(path.join(os.homedir(), 'AppData', 'Local', 'ms-playwright'));
} else if (process.platform === 'darwin') {
cacheDirs.push(path.join(os.homedir(), 'Library', 'Caches', 'ms-playwright'));
} else {
cacheDirs.push(path.join(os.homedir(), '.cache', 'ms-playwright'));
}
for (const cacheDir of cacheDirs) {
if (!fs.existsSync(cacheDir)) continue;
try {
const entries = fs.readdirSync(cacheDir).sort().reverse();
for (const entry of entries) {
const entryPath = path.join(cacheDir, entry);
const candidates = [
path.join(entryPath, 'chrome-headless-shell-linux64', 'chrome-headless-shell'),
path.join(entryPath, 'chrome-linux64', 'chrome'),
path.join(entryPath, 'chrome-linux', 'chrome'),
path.join(entryPath, 'chrome-win64', 'chrome.exe'),
path.join(entryPath, 'chrome-win', 'chrome.exe'),
path.join(entryPath, 'chrome-mac', 'Chromium.app', 'Contents', 'MacOS', 'Chromium'),
];
for (const candidate of candidates) {
if (fs.existsSync(candidate)) {
return candidate;
}
}
}
} catch (e) {}
}
// 4. System browser fallbacks
const systemCandidates = process.platform === 'win32' ? [
path.join(process.env['PROGRAMFILES'] || 'C:\\Program Files', 'Google', 'Chrome', 'Application', 'chrome.exe'),
path.join(process.env['PROGRAMFILES(X86)'] || 'C:\\Program Files (x86)', 'Google', 'Chrome', 'Application', 'chrome.exe'),
] : [
'/usr/bin/google-chrome',
'/usr/bin/chromium',
'/usr/bin/chromium-browser',
'/snap/bin/chromium',
];
for (const sc of systemCandidates) {
if (fs.existsSync(sc)) return sc;
}
return undefined;
}
async function run() {
const server = http.createServer((req, res) => {
const url = new URL(req.url, 'http://127.0.0.1');
if (url.pathname.startsWith('/api/email/read/')) {
const uid = url.pathname.replace('/api/email/read/', '');
if (uid === 'fail_uid' || uid === 'net_fail') {
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Mailbox disconnected' }));
return;
}
if (uid === 'crash_network') {
req.destroy();
return;
}
const markSeen = url.searchParams.get('mark_seen');
if (markSeen !== 'false') {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'mark_seen must be false' }));
return;
}
let body_html = '';
if (uid === 'img_test') {
body_html = [
'<div>',
'<p>Email with diverse images</p>',
'<!-- Inline image with alt text -->',
'<img src="cid:logo123" alt="Company Brand Logo">',
'<!-- Remote image with title -->',
'<img src="https://example.com/tracker.png" title="Tracking Pixel">',
'<!-- Image without alt or title -->',
'<img src="https://example.com/no-alt.jpg">',
'<!-- Interactive control that shouldn\'t be in print -->',
'<button type="button" class="btn-action">Interactive Action</button>',
'<!-- XSS payload -->',
'<script>window.__pwned = true;</script>',
'</div>'
].join('\n');
} else if (uid === 'long_msg') {
body_html = `<div><p>${'A'.repeat(5000)}</p></div>`;
} else {
body_html = `<div><p>Standard message body for ${uid}</p></div>`;
}
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
uid: uid,
subject: `Subject ${uid}`,
from_name: 'Sender',
from_address: 'sender@example.com',
to: 'user@example.com',
date: '2026-10-20',
date_display: 'Oct 20, 2026',
body: `Plain text for ${uid}`,
body_html: body_html,
}));
return;
}
let filePath = path.join(ROOT, url.pathname);
if (fs.existsSync(filePath) && fs.statSync(filePath).isFile()) {
const ext = path.extname(filePath);
const mime = ext === '.js' ? 'application/javascript' : ext === '.html' ? 'text/html' : ext === '.css' ? 'text/css' : 'text/plain';
res.writeHead(200, { 'Content-Type': mime });
res.end(fs.readFileSync(filePath));
return;
}
res.writeHead(404);
res.end('Not found');
});
await new Promise(r => server.listen(0, '127.0.0.1', r));
const port = server.address().port;
const origin = `http://127.0.0.1:${port}`;
const launchOpts = { headless: true };
const chromePath = findChromiumPath();
if (chromePath) launchOpts.executablePath = chromePath;
let browser;
try {
browser = await chromium.launch(launchOpts);
} catch (launchErr) {
throw new Error(
`Failed to launch Chromium browser: ${launchErr.message}\n` +
'Missing or unexecutable Chromium binary.\n' +
'To install Playwright Chromium, run:\n' +
' npx playwright install chromium\n' +
'Or set PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH to a valid Chromium executable.'
);
}
const context = await browser.newContext();
const page = await context.newPage();
const results = {};
try {
await page.goto(`${origin}/static/index.html`);
await page.evaluate(() => {
if (!document.getElementById('email-lib-bulk-actions')) {
const d = document.createElement('div'); d.id = 'email-lib-bulk-actions'; document.body.appendChild(d);
}
if (!document.getElementById('email-lib-selected-count')) {
const d = document.createElement('div'); d.id = 'email-lib-selected-count'; document.body.appendChild(d);
}
});
// TEST 1: Full Print Image Sanitization and Replacement
const printImageResult = await page.evaluate(async (base) => {
window.API_BASE = base;
const mod = await import(`${base}/static/js/emailLibrary/index.js`);
const stateMod = await import(`${base}/static/js/emailLibrary/state.js`);
stateMod.state._selectedUids = new Set(['img_test']);
stateMod.state._libEmails = [{ uid: 'img_test', subject: 'Image Test', folder: 'INBOX' }];
let iframePrintInvoked = false;
let printDocHtml = '';
let afterPrintTriggered = false;
let hasPlaceholderElement = false;
const observer = new MutationObserver(() => {
const frame = document.getElementById('email-browser-print-frame');
if (frame && frame.contentWindow && !frame._patched) {
frame._patched = true;
frame.contentWindow.print = function() {
iframePrintInvoked = true;
printDocHtml = frame.contentDocument ? frame.contentDocument.documentElement.outerHTML : '';
const body = frame.contentDocument ? frame.contentDocument.body : null;
hasPlaceholderElement = !!body?.querySelector('.email-inline-image-placeholder');
frame.contentWindow.dispatchEvent(new Event('afterprint'));
afterPrintTriggered = true;
};
}
});
observer.observe(document.body, { childList: true });
await mod._exportSelectedEmailPdf();
observer.disconnect();
const frameAfter = document.getElementById('email-browser-print-frame');
return {
iframePrintInvoked,
afterPrintTriggered,
frameRemovedAfterPrint: frameAfter === null,
hasLogoNotice: printDocHtml.includes('[Image: Company Brand Logo]'),
hasTrackerNotice: printDocHtml.includes('[Image: Tracking Pixel]'),
hasNoAltNotice: printDocHtml.includes('[Image omitted]'),
hasPlaceholderElement,
hasRemoteImageBlockedText: printDocHtml.includes('Remote image blocked'),
hasDownloadButton: printDocHtml.includes('email-inline-image-download-btn'),
hasInteractiveActionBtn: printDocHtml.includes('btn-action'),
hasXSS: printDocHtml.includes('<script>') || printDocHtml.includes('window.__pwned'),
};
}, origin);
results.printImageSanitization = printImageResult;
// TEST 2: Partial Failure - 1 failure out of 3 emails
const partialFailureResult = await page.evaluate(async (base) => {
window.API_BASE = base;
const mod = await import(`${base}/static/js/emailLibrary/index.js`);
const stateMod = await import(`${base}/static/js/emailLibrary/state.js`);
const messages = [
{ uid: 'msg1', subject: 'Email One', folder: 'INBOX' },
{ uid: 'fail_uid', subject: 'Failing Email', folder: 'INBOX' },
{ uid: 'msg2', subject: 'Email Two', folder: 'INBOX' },
];
stateMod.state._selectedUids = new Set(messages.map(m => m.uid));
stateMod.state._libEmails = messages;
let printInvoked = false;
let printDocHtml = '';
const observer = new MutationObserver(() => {
const frame = document.getElementById('email-browser-print-frame');
if (frame && frame.contentWindow && !frame._patched) {
frame._patched = true;
frame.contentWindow.print = function() {
printInvoked = true;
printDocHtml = frame.contentDocument ? frame.contentDocument.documentElement.outerHTML : '';
frame.contentWindow.dispatchEvent(new Event('afterprint'));
};
}
});
observer.observe(document.body, { childList: true });
await mod._exportSelectedEmailPdf();
observer.disconnect();
const toast = document.getElementById('toast')?.textContent || '';
return {
printInvoked,
toast,
hasMsg1: printDocHtml.includes('Subject msg1'),
hasMsg2: printDocHtml.includes('Subject msg2'),
hasOmittedNotice: printDocHtml.includes('1 selected email was unavailable and omitted from this print'),
};
}, origin);
results.partialFailureTest = partialFailureResult;
// TEST 3: All emails fail
const allFailedResult = await page.evaluate(async (base) => {
window.API_BASE = base;
const mod = await import(`${base}/static/js/emailLibrary/index.js`);
const stateMod = await import(`${base}/static/js/emailLibrary/state.js`);
const messages = [
{ uid: 'fail_uid', subject: 'Fail 1', folder: 'INBOX' },
{ uid: 'net_fail', subject: 'Fail 2', folder: 'INBOX' },
];
stateMod.state._selectedUids = new Set(messages.map(m => m.uid));
stateMod.state._libEmails = messages;
let printInvoked = false;
const observer = new MutationObserver(() => {
const frame = document.getElementById('email-browser-print-frame');
if (frame && frame.contentWindow && !frame._patched) {
frame._patched = true;
frame.contentWindow.print = function() {
printInvoked = true;
};
}
});
observer.observe(document.body, { childList: true });
let thrownError = null;
try {
await mod._exportSelectedEmailPdf();
} catch (err) {
thrownError = err.message || String(err);
}
observer.disconnect();
const frame = document.getElementById('email-browser-print-frame');
const toast = document.getElementById('toast')?.textContent || '';
return {
printInvoked,
frameOpened: frame !== null,
thrownError,
toast,
};
}, origin);
results.allFailedTest = allFailedResult;
// TEST 4: Idempotent Iframe Cleanup & Window Afterprint
const cleanupResult = await page.evaluate(async (base) => {
window.API_BASE = base;
const mod = await import(`${base}/static/js/emailLibrary/index.js`);
const stateMod = await import(`${base}/static/js/emailLibrary/state.js`);
stateMod.state._selectedUids = new Set(['msg1']);
stateMod.state._libEmails = [{ uid: 'msg1', subject: 'Cleanup Test', folder: 'INBOX' }];
const observer = new MutationObserver(() => {
const frame = document.getElementById('email-browser-print-frame');
if (frame && frame.contentWindow && !frame._patched) {
frame._patched = true;
frame.contentWindow.print = function() {
window.dispatchEvent(new Event('afterprint'));
};
}
});
observer.observe(document.body, { childList: true });
await mod._exportSelectedEmailPdf();
observer.disconnect();
const frameAfter = document.getElementById('email-browser-print-frame');
return {
frameRemovedViaWindowAfterprint: frameAfter === null,
};
}, origin);
results.cleanupTest = cleanupResult;
} finally {
await browser.close();
server.close();
}
// Assertions
const t1 = results.printImageSanitization;
if (!t1.iframePrintInvoked) throw new Error('Assertion failed: print not invoked');
if (!t1.frameRemovedAfterPrint) throw new Error('Assertion failed: frame not removed');
if (!t1.hasLogoNotice) throw new Error('Assertion failed: [Image: Company Brand Logo] missing');
if (!t1.hasTrackerNotice) throw new Error('Assertion failed: [Image: Tracking Pixel] missing');
if (!t1.hasNoAltNotice) throw new Error('Assertion failed: [Image omitted] missing');
if (t1.hasPlaceholderElement) throw new Error('Assertion failed: .email-inline-image-placeholder found in print doc');
if (t1.hasRemoteImageBlockedText) throw new Error('Assertion failed: Remote image blocked leaked into print doc');
if (t1.hasDownloadButton) throw new Error('Assertion failed: download button leaked into print doc');
if (t1.hasInteractiveActionBtn) throw new Error('Assertion failed: interactive button leaked into print doc');
if (t1.hasXSS) throw new Error('Assertion failed: script tag found');
const t2 = results.partialFailureTest;
if (!t2.printInvoked) throw new Error('Assertion failed: partial print was not invoked');
if (!t2.hasMsg1 || !t2.hasMsg2) throw new Error('Assertion failed: successful messages missing from print');
if (!t2.hasOmittedNotice) throw new Error('Assertion failed: omitted notice banner missing');
if (!t2.toast.includes('Prepared 2 emails for printing; 1 unavailable')) {
throw new Error('Assertion failed: toast text mismatch: ' + t2.toast);
}
const t3 = results.allFailedTest;
if (t3.printInvoked) throw new Error('Assertion failed: print should NOT be invoked when all fail');
if (t3.frameOpened) throw new Error('Assertion failed: print frame should NOT remain in DOM');
const t4 = results.cleanupTest;
if (!t4.frameRemovedViaWindowAfterprint) throw new Error('Assertion failed: top window afterprint did not clean up frame');
console.log('ALL_BROWSER_TESTS_PASSED');
}
run().catch(err => {
console.error('Browser test failed:', err);
process.exit(1);
});
@@ -0,0 +1,79 @@
"""Email list stale-while-revalidate (SWR) regression tests.
A plain list view must never block ~2-5 s on IMAP while we already hold a
recent snapshot or a durable index: it should return the snapshot immediately
(with ``sync.stale``) and refresh in the background. Blocking IMAP only remains
for a genuine cold start (no snapshot AND no indexed rows).
"""
import asyncio
import pathlib
from routes.email import email_routes as er
SRC = pathlib.Path(er.__file__).read_text(encoding="utf-8")
def _list_endpoint():
router = er.setup_email_routes()
for route in router.routes:
if getattr(route, "name", "") == "list_emails":
return route.endpoint
raise AssertionError("list_emails route not found")
def _call(endpoint, **over):
kwargs = dict(
folder="INBOX", limit=50, offset=0, filter="all", from_addr=None,
account_id=None, has_attachments=0, date_from=None, date_to=None,
cached_only=0, cache_bust=None, refresh=0, owner="pewds",
)
kwargs.update(over)
return asyncio.run(endpoint(**kwargs))
def test_list_returns_index_snapshot_with_a_stale_marker_without_blocking_on_imap(monkeypatch):
endpoint = _list_endpoint()
monkeypatch.setattr(
er, "_email_index_list",
lambda owner, account_id, folder, filter_, limit, offset, has_att: (
[{"uid": "11", "subject": "Indexed", "is_read": False}], 1, "2026-10-11T00:00:00Z",
),
)
# The synchronous path must never open IMAP; if it did the request would
# raise instead of returning the index snapshot immediately.
monkeypatch.setattr(er, "_imap_connect", lambda *a, **k: (_ for _ in ()).throw(
AssertionError("SWR must not open IMAP when an index snapshot exists")))
result = _call(endpoint, cache_bust="123")
assert result["total"] == 1
assert [e["uid"] for e in result["emails"]] == ["11"]
assert result["sync"]["stale"] is True
assert result["sync"]["source"] in ("index", "cache_stale")
def test_cold_start_with_no_snapshot_and_no_index_is_not_marked_stale(monkeypatch):
# With neither a snapshot nor an index, SWR must NOT fire: the request
# falls through to the real (blocking) path, so the response is not a
# stale-marked snapshot.
endpoint = _list_endpoint()
monkeypatch.setattr(er, "_email_index_list", lambda *a, **k: ([], 0, None))
monkeypatch.setattr(er, "_imap_connect", lambda *a, **k: (_ for _ in ()).throw(
RuntimeError("offline test")))
try:
result = _call(endpoint)
except Exception:
return # the cold path legitimately attempted real IMAP
assert isinstance(result, dict)
assert not (result.get("sync") or {}).get("stale")
def test_swr_contract_is_wired_into_the_list_path():
assert "_LIST_STALE" in SRC
assert "def _list_stale_put" in SRC
assert "def _schedule_list_refresh" in SRC
assert 'sync_meta["source"] = "cache_stale"' in SRC
assert 'sync_meta["stale"] = True' in SRC
# The manual-refresh contract the UI relies on must stay intact.
assert "manual_refresh = bool(refresh)" in SRC
assert "_invalidate_list_cache(account_id, folder)" in SRC
assert "cached_rows = {} if refresh else _email_index_rows" in SRC
@@ -0,0 +1,26 @@
from src.agent_evidence import infer_completion_requirements
from src.clean_agent_preview import active_email_context_message, email_identifier_error
def test_open_reader_uid_is_valid_email_evidence():
reader = active_email_context_message({
'uid': '3971', 'folder': 'INBOX', 'account': 'account-1',
})
assert email_identifier_error(
'mcp__email__read_email',
{'uid': '3971', 'folder': 'INBOX', 'account': 'account-1'},
user_text='Summarize this email', history=[reader],
) is None
assert email_identifier_error(
'mcp__email__read_email', {'uid': '3972'},
user_text='Summarize this email', history=[reader],
)
def test_pasted_email_address_does_not_become_required_artifact():
instruction = (
'Summarize this email\r\n'
'If you do not wish to make use of this service, please send a mail '
'to news.alerts@example.test.\r\n'
)
assert infer_completion_requirements(instruction).required_artifacts == ()
+1 -1
View File
@@ -386,7 +386,7 @@ def test_gallery_image_endpoint_lookups_are_owner_scoped():
assert body.count("_first_visible_image_endpoint(db, user)") >= 4
assert body.count("_visible_image_endpoint_for_base(db,") >= 2
assert "def _current_user_is_admin" in body
assert body.count('raise HTTPException(403, "Choose a registered image endpoint")') == 2
assert body.count('raise HTTPException(403, "Choose a registered image endpoint")') == 3
for marker in (
"async def gallery_ai_upscale",
"async def gallery_style_transfer",
+47 -1
View File
@@ -167,10 +167,56 @@ def test_renders_one_table_row_per_variable(generator, fake_tree, monkeypatch):
page = generator.render(found, generator.naive_line_scan(fake_tree))
assert page.startswith("---\nlayout: default\n---\n")
assert "| `ODYSSEUS_VIA_HELPER` | `5 * 1024` | `src/indirect.py:16` | A synthetic limit. |" in page
assert "| `ODYSSEUS_VIA_HELPER` | `5 * 1024` | `src/indirect.py` | A synthetic limit. |" in page
assert "### Search" in page
def _render_one(generator, root, name, monkeypatch):
monkeypatch.setitem(
generator.VARIABLE_NOTES, name, ("Search", generator.USER, "A synthetic flag.")
)
return generator.render(
{name: generator.collect(root)[name]}, generator.naive_line_scan(root)
)
def test_page_does_not_change_when_an_unrelated_edit_shifts_lines(
generator, fake_tree, monkeypatch
):
"""An edit that moves a read down a file must not make the page stale.
The page used to cite `path:line`, so any PR adding a line above a read had
to regenerate it and then conflicted with every other PR that had.
"""
before = _render_one(generator, fake_tree, "ODYSSEUS_VIA_HELPER", monkeypatch)
source = fake_tree / "src" / "indirect.py"
source.write_text("import sys\n\n\n" + source.read_text(encoding="utf-8"), encoding="utf-8")
after = _render_one(generator, fake_tree, "ODYSSEUS_VIA_HELPER", monkeypatch)
assert generator.collect(fake_tree)["ODYSSEUS_VIA_HELPER"].primary.lineno == 19
assert after == before
def test_extra_reads_are_counted_per_file(generator, tmp_path, monkeypatch):
monkeypatch.setattr(generator, "SOURCE_ROOTS", ("app.py", "src"))
_write(tmp_path, "app.py", """
import os
FIRST = os.environ.get("ODYSSEUS_READ_OFTEN", "1")
SECOND = os.environ.get("ODYSSEUS_READ_OFTEN", "1")
""")
_write(tmp_path, "src/elsewhere.py", """
import os
AGAIN = os.getenv("ODYSSEUS_READ_OFTEN")
""")
page = _render_one(generator, tmp_path, "ODYSSEUS_READ_OFTEN", monkeypatch)
assert "| `app.py` (+1 more) |" in page
def test_every_variable_read_in_the_repository_is_documented(built):
_, variables, problems = built
@@ -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)
+68 -7
View File
@@ -1,4 +1,6 @@
import asyncio
import json
import pytest
import logging
import src.tool_execution as tool_execution
from tests.runtime_evidence_helpers import server_authorized_executor
@@ -15,6 +17,60 @@ from src.tool_execution import (
execute_tool_block = server_authorized_executor(execute_tool_block)
@pytest.mark.parametrize("content", ['{"path":"/workspace/x"}', '{"path":"/workspace/x","content":null}',
'/workspace/x', '/workspace/x\n', '/workspace/x\n \t',
json.dumps({'path': '/workspace/x.py', 'content': '```python\n\n```'})])
def test_bridge_refuses_missing_or_ambiguous_empty_content(monkeypatch, tmp_path, content):
async def forbidden(*args, **kwargs):
raise AssertionError("ambiguous write reached remote producer")
monkeypatch.setattr(tool_execution, "_client_bridge", lambda context: {"url": "http://bridge", "token": "x"})
monkeypatch.setattr(tool_execution, "_bridge_post", forbidden)
async def invoke():
token = tool_execution._active_workspace.set(str(tmp_path))
try:
return await tool_execution._route_tool_via_bridge("write_file", content, "s", {})
finally:
tool_execution._active_workspace.reset(token)
_, result = asyncio.run(invoke())
assert result["exit_code"] == 1
assert "content" in result["error"] or "JSON" in result["error"]
@pytest.mark.parametrize("body", ["", " \t", "normal source"])
def test_bridge_honors_explicit_json_string_content(monkeypatch, tmp_path, body):
import base64
seen = []
async def post(bridge, endpoint, payload, **kwargs):
seen.append((endpoint, base64.b64decode(payload["content_b64"])))
return {"output": "written", "exit_code": 0}
monkeypatch.setattr(tool_execution, "_client_bridge", lambda context: {"url": "http://bridge", "token": "x"})
monkeypatch.setattr(tool_execution, "_bridge_post", post)
async def invoke():
token = tool_execution._active_workspace.set(str(tmp_path))
try:
return await tool_execution._route_tool_via_bridge("write_file", json.dumps({"path": "/workspace/x", "content": body}), "s", {})
finally:
tool_execution._active_workspace.reset(token)
_, result = asyncio.run(invoke())
assert result["exit_code"] == 0
assert seen == [("/write", body.encode())]
@pytest.mark.parametrize("content", ['/workspace/x\n', '{"path":"/workspace/x"}', '/workspace/x\n \t'])
def test_scoped_bridge_cannot_blindly_write_implicit_empty(content):
async def forbidden(*args):
raise AssertionError("scoped bridge received an ambiguous destructive write")
bridge = AgentExecutionBridge(forbidden, frozenset({'write_file'}), 'empty-intent-test')
async def invoke():
with bind_execution_bridge(bridge):
block = Block(content)
block.tool_type = 'write_file'
return await execute_tool_block(block, security_context=NO_TOOL_SECURITY_CONTEXT)
_, result = asyncio.run(invoke())
assert result['exit_code'] == 1
assert 'content' in result['error']
class Block:
tool_type = "host_shell"
@@ -159,7 +215,7 @@ def test_scoped_execution_bridge_failure_logs_compact_warning(caplog) -> None:
assert "Scoped execution bridge test-environment failed for tool=host_shell" in caplog.text
def test_tui_bridge_write_file_rejects_text_for_binary_artifact(monkeypatch) -> None:
def test_tui_bridge_write_file_rejects_text_for_binary_artifact(monkeypatch, tmp_path) -> None:
"""The bridge path must preserve rendered media just like local writes."""
called = False
@@ -172,12 +228,17 @@ def test_tui_bridge_write_file_rejects_text_for_binary_artifact(monkeypatch) ->
monkeypatch.setattr(tool_execution, "_bridge_post", fake_post)
async def invoke():
return await tool_execution._route_tool_via_bridge(
"write_file",
'{"path":"/tmp_workspace/results/screenshot.png","content":"The screenshot is complete."}',
"run-1",
{"surface": "odysseus-tui"},
)
token = tool_execution._active_workspace.set(str(tmp_path))
try:
return await tool_execution._route_tool_via_bridge(
"write_file",
'{"path":"/tmp_workspace/results/screenshot.png","content":"The screenshot is complete."}',
"run-1",
{"surface": "odysseus-tui"},
)
finally:
tool_execution._active_workspace.reset(token)
description, result = asyncio.run(invoke())
+170
View File
@@ -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"]
+155
View File
@@ -76,3 +76,158 @@ def test_mixed_endpoint_requires_configured_image_model(monkeypatch):
assert lookup(Query(), endpoint.base_url, 'alice', 'text-model') is None
assert lookup(Query(), endpoint.base_url, 'alice', 'openai/gpt-image-1') is None
assert lookup(Query(), 'https://unregistered.test/v1', 'alice', 'openai/gpt-5-image') is None
def _form_fields(content, content_type):
"""Parse simple multipart form fields (text parts only) from a request body."""
boundary = content_type.split('boundary=')[1].strip().strip('"')
fields = {}
for part in content.split(('--' + boundary).encode()):
if b'name="' not in part:
continue
head, _, body = part.partition(b'\r\n\r\n')
import re as _re
match = _re.search(rb'name="([^"]+)"', head)
if not match:
continue
fields[match.group(1).decode()] = body.rstrip(b'\r\n-').decode('latin1')
return fields
def _self_hosted_inpaint(monkeypatch, image, mask, body_extra, respond, include_mask=True):
base_url = 'http://100.69.120.65:8188/v1'
endpoint = SimpleNamespace(base_url=base_url, api_key='local', owner='alice')
monkeypatch.setattr(gallery, 'SessionLocal', lambda: SimpleNamespace(close=lambda: None))
monkeypatch.setattr(gallery, 'require_privilege', lambda *a: 'alice')
monkeypatch.setattr(gallery, '_visible_image_endpoint_for_base', lambda *a: endpoint)
monkeypatch.setattr('src.url_safety.check_outbound_url', lambda *a, **k: (True, ''))
_real_client = httpx.AsyncClient
monkeypatch.setattr(httpx, 'AsyncClient',
lambda **kw: _real_client(transport=httpx.MockTransport(respond), **kw))
class Request:
async def json(self):
payload = {'_endpoint': base_url, 'image': png(image), 'prompt': 'do a thing'}
if include_mask:
payload['mask'] = png(mask)
payload.update(body_extra)
return payload
route = next(r for r in gallery.setup_gallery_routes().routes if r.path == '/api/image/inpaint')
return asyncio.run(route.endpoint(Request()))
def test_self_hosted_inpaint_caps_oversize_and_restores_dimensions(monkeypatch):
seen = {}
def respond(request):
assert str(request.url) == 'http://100.69.120.65:8188/v1/images/edits'
seen.update(_form_fields(request.content, request.headers['content-type']))
return httpx.Response(200, json={'data': [{'b64_json': png(Image.new('RGB', (768, 576), 'blue'))}]})
out = _self_hosted_inpaint(monkeypatch, Image.new('RGB', (1600, 1200), 'red'),
Image.new('L', (1600, 1200), 255),
{'steps': 8}, respond)
# Working size is capped to a model-friendly size (<=768 long side, /16).
assert seen['size'] == '768x576'
# A requested step budget passes through up to the cap (default 8).
assert seen['steps'] == '8'
# The returned image keeps the original dimensions.
assert Image.open(io.BytesIO(base64.b64decode(out['image']))).size == (1600, 1200)
def test_self_hosted_inpaint_clamps_steps_to_the_cap(monkeypatch):
seen = {}
def respond(request):
seen.update(_form_fields(request.content, request.headers['content-type']))
return httpx.Response(200, json={'data': [{'b64_json': png(Image.new('RGB', (640, 480), 'blue'))}]})
_self_hosted_inpaint(monkeypatch, Image.new('RGB', (640, 480), 'red'),
Image.new('L', (640, 480), 255), {'steps': 20}, respond)
# Over-cap requests are clamped to the configured maximum (default 8).
assert seen['steps'] == '8'
def test_self_hosted_inpaint_leaves_small_images_unchanged(monkeypatch):
seen = {}
def respond(request):
seen.update(_form_fields(request.content, request.headers['content-type']))
return httpx.Response(200, json={'data': [{'b64_json': png(Image.new('RGB', (640, 480), 'green'))}]})
out = _self_hosted_inpaint(monkeypatch, Image.new('RGB', (640, 480), 'red'),
Image.new('L', (640, 480), 255), {}, respond)
# Under the cap: dimensions pass through untouched; with no requested count
# sent the moderate default (4) is used.
assert seen['size'] == '640x480'
assert seen['steps'] == '4'
assert Image.open(io.BytesIO(base64.b64decode(out['image']))).size == (640, 480)
def test_self_hosted_inpaint_without_mask_uses_a_full_white_mask(monkeypatch):
"""A whole-image instruction edit may omit the mask; it must not raise
KeyError -> 400 (the health-audit regression)."""
seen = {}
mask_bytes = {}
def respond(request):
seen.update(_form_fields(request.content, request.headers['content-type']))
# The provider must receive a (full-white) mask, not a missing one.
import re as _re
parts = request.content.split(b'--')
for part in parts:
if b'name="mask"' in part:
head, _, body = part.partition(b'\r\n\r\n')
mask_bytes['png'] = body.rstrip(b'\r\n-')
return httpx.Response(200, json={'data': [{'b64_json': png(Image.new('RGB', (32, 32), 'blue'))}]})
out = _self_hosted_inpaint(monkeypatch, Image.new('RGB', (32, 32), 'red'),
None, {}, respond, include_mask=False)
assert 'image' in out, out
assert seen.get('size') == '32x32'
# The synthesized mask is image-sized and all-white (whole image editable).
mask_img = Image.open(io.BytesIO(mask_bytes['png'])).convert('L')
assert mask_img.size == (32, 32)
assert mask_img.getextrema() == (255, 255)
def test_openai_inpaint_without_mask_is_a_clean_400(monkeypatch):
"""OpenAI edits need a mask; a missing one must be a clean 400, not a
KeyError traceback."""
from fastapi import HTTPException
endpoint = SimpleNamespace(base_url='https://api.openai.com/v1', api_key='sk-x', owner='alice')
monkeypatch.setattr(gallery, 'SessionLocal', lambda: SimpleNamespace(close=lambda: None))
monkeypatch.setattr(gallery, 'require_privilege', lambda *a: 'alice')
monkeypatch.setattr(gallery, '_visible_image_endpoint_for_base', lambda *a: endpoint)
monkeypatch.setattr('src.url_safety.check_outbound_url', lambda *a, **k: (True, ''))
class Request:
async def json(self):
return {'_endpoint': 'https://api.openai.com/v1',
'image': png(Image.new('RGB', (32, 32), 'red')), 'prompt': 'x'}
route = next(r for r in gallery.setup_gallery_routes().routes if r.path == '/api/image/inpaint')
try:
asyncio.run(route.endpoint(Request()))
raise AssertionError('expected a 400 for a missing mask')
except HTTPException as exc:
assert exc.status_code == 400
assert 'mask' in str(exc.detail).lower()
def test_self_hosted_inpaint_forwards_request_id(monkeypatch):
"""A client-supplied request_id is forwarded to the provider so the client
can poll GET /api/image/edit-progress for step progress."""
seen = {}
def respond(request):
seen.update(_form_fields(request.content, request.headers.get('content-type', '')))
return httpx.Response(200, json={'model': 'qwen-image-edit-2511',
'data': [{'b64_json': png(Image.new('RGBA', (32, 32), 'red'))}]})
out = _self_hosted_inpaint(monkeypatch, Image.new('RGB', (32, 32), 'red'),
Image.new('L', (32, 32), 255), {'request_id': 'job-777'}, respond)
assert 'image' in out, out
assert seen.get('request_id') == 'job-777'
+120
View File
@@ -0,0 +1,120 @@
"""Tests for the image-op progress contract (HARNESS-owned).
The 7011 side accepts an optional client-supplied ``request_id`` on heavy image
ops and exposes ``GET /api/image/edit-progress`` which proxies the provider's
``/v1/images/progress/{request_id}`` route. When the provider has no such route
the endpoint returns ``{"available": false}`` so the client keeps an
indeterminate bar.
"""
import asyncio
from types import SimpleNamespace
import httpx
import routes.gallery_routes as gallery
def _route(path):
return next(r for r in gallery.setup_gallery_routes().routes if r.path == path)
class _Req:
def __init__(self, query):
self.query_params = query
def _fake_db():
class Db:
def close(self):
pass
def query(self, *args, **kwargs):
return self
def filter(self, *args, **kwargs):
return self
def first(self):
return None
return Db()
def test_edit_progress_requires_request_id(monkeypatch):
monkeypatch.setattr(gallery, "require_privilege", lambda *a: "alice")
route = _route("/api/image/edit-progress")
try:
asyncio.run(route.endpoint(_Req({})))
except Exception as exc: # FastAPI HTTPException
assert getattr(exc, "status_code", None) == 400
else:
raise AssertionError("expected a 400 for a missing request_id")
def test_edit_progress_rejects_an_unsafe_request_id(monkeypatch):
monkeypatch.setattr(gallery, "require_privilege", lambda *a: "alice")
monkeypatch.setattr(gallery, "SessionLocal", lambda: _fake_db())
route = _route("/api/image/edit-progress")
try:
asyncio.run(route.endpoint(_Req({"request_id": "../secret"})))
except Exception as exc:
assert getattr(exc, "status_code", None) == 400
else:
raise AssertionError("expected a 400 for an unsafe request_id")
def test_edit_progress_returns_provider_payload(monkeypatch):
endpoint = SimpleNamespace(base_url="http://100.69.120.65:8188/v1", api_key="k")
monkeypatch.setattr(gallery, "SessionLocal", lambda: _fake_db())
monkeypatch.setattr(gallery, "require_privilege", lambda *a: "alice")
monkeypatch.setattr(gallery, "_first_visible_image_endpoint", lambda *a: endpoint)
seen = {}
def respond(request):
seen["url"] = str(request.url)
# Live seneca contract stages: loading | editing | compositing | done | error.
return httpx.Response(
200,
json={"stage": "editing", "step": 3, "steps": 8, "eta_s": 42.0, "progress": 0.375},
)
client = httpx.AsyncClient
monkeypatch.setattr(httpx, "AsyncClient", lambda **k: client(transport=httpx.MockTransport(respond), **k))
out = asyncio.run(_route("/api/image/edit-progress").endpoint(_Req({"request_id": "abc-123"})))
assert out["available"] is True
assert out["stage"] == "editing" # passed through unchanged
assert out["step"] == 3 and out["steps"] == 8
assert seen["url"].endswith("/images/progress/abc-123")
def test_edit_progress_available_false_when_provider_has_no_route(monkeypatch):
endpoint = SimpleNamespace(base_url="http://100.69.120.65:8188/v1", api_key="k")
monkeypatch.setattr(gallery, "SessionLocal", lambda: _fake_db())
monkeypatch.setattr(gallery, "require_privilege", lambda *a: "alice")
monkeypatch.setattr(gallery, "_first_visible_image_endpoint", lambda *a: endpoint)
client = httpx.AsyncClient
monkeypatch.setattr(
httpx, "AsyncClient",
lambda **k: client(transport=httpx.MockTransport(lambda req: httpx.Response(404)), **k),
)
out = asyncio.run(_route("/api/image/edit-progress").endpoint(_Req({"request_id": "abc"})))
assert out["available"] is False
def test_edit_progress_available_false_when_unreachable(monkeypatch):
endpoint = SimpleNamespace(base_url="http://100.69.120.65:8188/v1", api_key="k")
monkeypatch.setattr(gallery, "SessionLocal", lambda: _fake_db())
monkeypatch.setattr(gallery, "require_privilege", lambda *a: "alice")
monkeypatch.setattr(gallery, "_first_visible_image_endpoint", lambda *a: endpoint)
class Boom:
async def __aenter__(self):
raise httpx.ConnectError("nope")
async def __aexit__(self, *a):
return False
monkeypatch.setattr(httpx, "AsyncClient", lambda **k: Boom())
out = asyncio.run(_route("/api/image/edit-progress").endpoint(_Req({"request_id": "abc"})))
assert out["available"] is False
+201
View File
@@ -0,0 +1,201 @@
"""Heavy in-process image ops must not load GB-scale models by default.
remove-bg / SAM mask / Real-ESRGAN upscale+denoise used to load rembg / torch
models inside the web process and OOM-killed the host. They now default to a
graceful 503, proxy to the seneca remote ops service when
``ODYSSEUS_IMAGE_OPS_BASE`` is set (translate to the /v1/images/* contract), and
only run in-process when explicitly enabled via ODYSSEUS_ALLOW_LOCAL_IMAGE_MODELS.
"""
import asyncio
import httpx
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from routes.gallery import gallery_routes as gr
HEAVY_ROUTES = [
("/api/image/remove-bg", {"image": "x"}, "Background removal"),
("/api/image/mask", {"image": "x"}, "Smart mask"),
("/api/image/upscale-local", {"image": "x"}, "AI upscale"),
("/api/image/denoise", {"image": "x"}, "AI denoise"),
]
@pytest.fixture(autouse=True)
def _clean_env(monkeypatch):
monkeypatch.setattr(gr, "require_privilege", lambda *a, **k: None)
monkeypatch.delenv("ODYSSEUS_IMAGE_OPS_BASE", raising=False)
monkeypatch.delenv("ODYSSEUS_ALLOW_LOCAL_IMAGE_MODELS", raising=False)
@pytest.fixture()
def client():
app = FastAPI()
app.include_router(gr.setup_gallery_routes())
return TestClient(app)
def test_local_image_models_disabled_by_default():
assert gr._local_image_models_enabled() is False
@pytest.mark.parametrize("value", ["1", "true", "Yes", "ON"])
def test_local_image_models_enabled_env(monkeypatch, value):
monkeypatch.setenv("ODYSSEUS_ALLOW_LOCAL_IMAGE_MODELS", value)
assert gr._local_image_models_enabled() is True
@pytest.mark.parametrize("value", ["", "0", "no", "off", "maybe"])
def test_local_image_models_disabled_env(monkeypatch, value):
monkeypatch.setenv("ODYSSEUS_ALLOW_LOCAL_IMAGE_MODELS", value)
assert gr._local_image_models_enabled() is False
def test_rss_mb_is_positive():
assert gr._rss_mb() > 0
def test_route_heavy_image_op_returns_none_when_enabled(monkeypatch):
monkeypatch.setenv("ODYSSEUS_ALLOW_LOCAL_IMAGE_MODELS", "1")
assert asyncio.run(gr._route_heavy_image_op("/api/image/denoise", {}, label="x")) is None
@pytest.mark.parametrize("path,body,label", HEAVY_ROUTES)
def test_heavy_ops_503_when_disabled(client, path, body, label):
resp = client.post(path, json=body)
assert resp.status_code == 503
detail = resp.json().get("detail", "")
assert "temporarily unavailable" in detail
assert label in detail
def test_remove_bg_disabled_does_not_import_rembg(monkeypatch):
import sys
monkeypatch.setitem(sys.modules, "rembg", None)
app = FastAPI()
app.include_router(gr.setup_gallery_routes())
client = TestClient(app)
resp = client.post("/api/image/remove-bg", json={"image": "x"})
assert resp.status_code == 503
class _FakeResponse:
def __init__(self, payload, status_code=200):
self._payload = payload
self.status_code = status_code
def json(self):
return self._payload
class _FakeAsyncClient:
calls = []
response = _FakeResponse({})
def __init__(self, *args, **kwargs):
pass
async def __aenter__(self):
return self
async def __aexit__(self, *args):
return False
async def post(self, url, json=None, files=None, data=None):
_FakeAsyncClient.calls.append((url, {"json": json, "files": files, "data": data}))
return _FakeAsyncClient.response
def _client():
app = FastAPI()
app.include_router(gr.setup_gallery_routes())
return TestClient(app)
def test_remove_bg_proxies_to_remote_contract(monkeypatch):
monkeypatch.setenv("ODYSSEUS_IMAGE_OPS_BASE", "http://seneca.test:8188")
monkeypatch.setattr(httpx, "AsyncClient", _FakeAsyncClient)
_FakeAsyncClient.calls = []
_FakeAsyncClient.response = _FakeResponse(
{"data": [{"b64_json": "RB", "mask_b64": "M", "bbox": [0, 0, 1, 1]}], "model": "birefnet"}
)
resp = _client().post("/api/image/remove-bg", json={"image": "AAAA"})
assert resp.status_code == 200
assert resp.json()["image"] == "RB"
url, kw = _FakeAsyncClient.calls[-1]
assert url == "http://seneca.test:8188/v1/images/remove-background"
assert "image" in kw["files"]
def test_mask_proxies_to_remote_contract(monkeypatch):
monkeypatch.setenv("ODYSSEUS_IMAGE_OPS_BASE", "http://seneca.test:8188")
monkeypatch.setattr(httpx, "AsyncClient", _FakeAsyncClient)
_FakeAsyncClient.calls = []
_FakeAsyncClient.response = _FakeResponse(
{"masks": [{"b64_json": "MASK", "bbox": [10, 20, 30, 40], "score": 0.9, "label": "person"}],
"model": "sam+grounding"}
)
resp = _client().post("/api/image/mask", json={"image": "AAAA", "text": "person"})
assert resp.status_code == 200
body = resp.json()
assert body["mask"] == "MASK"
# remote [x,y,w,h] -> app [x1,y1,x2,y2]
assert body["bbox"] == [10, 20, 40, 60]
url, kw = _FakeAsyncClient.calls[-1]
assert url == "http://seneca.test:8188/v1/images/mask"
assert kw["data"]["prompt"] == "person"
def test_mask_picks_top_score_not_first(monkeypatch):
monkeypatch.setenv("ODYSSEUS_IMAGE_OPS_BASE", "http://seneca.test:8188")
monkeypatch.setattr(httpx, "AsyncClient", _FakeAsyncClient)
_FakeAsyncClient.response = _FakeResponse(
{"masks": [
{"b64_json": "LOW", "bbox": [0, 0, 1, 1], "score": 0.2},
{"b64_json": "HIGH", "bbox": [0, 0, 2, 2], "score": 0.95},
{"b64_json": "MID", "bbox": [0, 0, 3, 3], "score": 0.5},
], "model": "sam+grounding"}
)
resp = _client().post("/api/image/mask", json={"image": "AAAA", "text": "person"})
assert resp.status_code == 200
assert resp.json()["mask"] == "HIGH"
def test_mask_box_is_sent_as_xywh(monkeypatch):
monkeypatch.setenv("ODYSSEUS_IMAGE_OPS_BASE", "http://seneca.test:8188")
monkeypatch.setattr(httpx, "AsyncClient", _FakeAsyncClient)
_FakeAsyncClient.calls = []
_FakeAsyncClient.response = _FakeResponse({"masks": [{"b64_json": "M", "bbox": [0, 0, 1, 1], "score": 1.0}]})
resp = _client().post("/api/image/mask", json={"image": "AAAA", "box": [10, 20, 40, 60]})
assert resp.status_code == 200
_, kw = _FakeAsyncClient.calls[-1]
assert kw["data"]["box"] == "10,20,30,40"
def test_mask_remote_empty_masks_is_200_nothing_selected(monkeypatch):
monkeypatch.setenv("ODYSSEUS_IMAGE_OPS_BASE", "http://seneca.test:8188")
monkeypatch.setattr(httpx, "AsyncClient", _FakeAsyncClient)
_FakeAsyncClient.response = _FakeResponse({"masks": [], "model": "sam+grounding"})
resp = _client().post("/api/image/mask", json={"image": "AAAA", "text": "person"})
assert resp.status_code == 200
body = resp.json()
assert body["mask"] is None
assert body["masks"] == []
assert "Nothing selected" in body["error"]
def test_remote_proxy_surfaces_error_status(monkeypatch):
monkeypatch.setenv("ODYSSEUS_IMAGE_OPS_BASE", "http://seneca.test:8188")
monkeypatch.setattr(httpx, "AsyncClient", _FakeAsyncClient)
_FakeAsyncClient.response = _FakeResponse({}, status_code=500)
resp = _client().post("/api/image/remove-bg", json={"image": "AAAA"})
assert resp.status_code == 503
assert "temporarily unavailable" in resp.json().get("detail", "")
@@ -0,0 +1,124 @@
"""Image endpoint fallback: image models on mixed (non image-only) endpoints.
Chat image generation resolves its model by name across every enabled endpoint,
so a ``qwen-image-edit`` / ``gpt-image-1`` model registered on a shared
OpenAI-compatible gateway works there. The editor's generative ops used a
stricter image-only lookup and reported "No image generation endpoint
configured" on those same setups. These tests pin the fallback that makes the
editor find the same endpoint.
"""
from types import SimpleNamespace
import routes.gallery_routes as gallery_routes
class _Predicate:
def __init__(self, check):
self._check = check
def __call__(self, row):
return self._check(row)
def __or__(self, other):
return _Predicate(lambda row: self(row) or other(row))
class _Column:
def __init__(self, name):
self.name = name
def __eq__(self, value):
return _Predicate(lambda row: getattr(row, self.name) == value)
class _ModelEndpoint:
base_url = _Column("base_url")
model_type = _Column("model_type")
is_enabled = _Column("is_enabled")
owner = _Column("owner")
class _Query:
def __init__(self, rows):
self._rows = list(rows)
def filter(self, *predicates):
self._rows = [row for row in self._rows if all(pred(row) for pred in predicates)]
return self
def all(self):
return list(self._rows)
class _DB:
def __init__(self, rows):
self._rows = rows
def query(self, model):
assert model is _ModelEndpoint
return _Query(self._rows)
def _ep(base_url, owner=None, *, enabled=True, model_type="chat", models=None):
return SimpleNamespace(
base_url=base_url,
owner=owner,
is_enabled=enabled,
model_type=model_type,
api_key="sk-secret",
cached_models=models or [],
pinned_models=[],
)
def _patch_model(monkeypatch):
monkeypatch.setattr(gallery_routes, "ModelEndpoint", _ModelEndpoint)
def test_mixed_endpoint_with_image_model_is_found(monkeypatch):
_patch_model(monkeypatch)
rows = [_ep("https://gateway.example.com/v1", models=["qwen-image-edit", "llama-3"])]
ep = gallery_routes._first_visible_image_endpoint(_DB(rows), None)
assert ep is not None
assert ep.base_url == "https://gateway.example.com/v1"
def test_mixed_endpoint_without_image_model_is_ignored(monkeypatch):
_patch_model(monkeypatch)
rows = [_ep("https://gateway.example.com/v1", models=["llama-3", "mistral"])]
assert gallery_routes._first_visible_image_endpoint(_DB(rows), None) is None
def test_image_only_endpoint_wins_over_mixed(monkeypatch):
_patch_model(monkeypatch)
rows = [
_ep("https://gateway.example.com/v1", models=["gpt-image-1"]),
_ep("https://images.example.com/v1", model_type="image"),
]
ep = gallery_routes._first_visible_image_endpoint(_DB(rows), None)
assert ep is not None
assert ep.model_type == "image"
def test_unreachable_endpoint_reports_502():
"""The inpaint proxy must distinguish an unreachable endpoint from a provider error.
502 (gateway) is reserved for "the image server could not be reached"; 503 is
reserved for "generation is switched off on the server", which the editor maps
to its own fixed message.
"""
from pathlib import Path
src = (
Path(__file__).resolve().parents[1] / "routes" / "gallery" / "gallery_routes.py"
).read_text()
assert "Image server unreachable" in src
assert "except httpx.TransportError as exc:" in src
assert "provider HTTP" in src
assert 'raise HTTPException(503, f"Image endpoint unreachable' not in src
+39
View File
@@ -0,0 +1,39 @@
"""Source checks for the gallery Settings-tab image-model picker.
The Quick Edit model picker lives in the editor; HARNESS mirrors it into the
gallery Settings tab, fed by GET /api/editor/image-models and sharing the
same localStorage key (`ge-ai-command-model`) so the two stay in sync.
"""
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
GALLERY_JS = (ROOT / "static/js/gallery.js").read_text(encoding="utf-8")
def test_settings_tab_has_image_model_picker():
assert 'id="gallery-image-model"' in GALLERY_JS
assert "/api/editor/image-models" in GALLERY_JS
assert "ge-ai-command-model" in GALLERY_JS
def test_image_model_picker_lives_in_the_settings_container():
settings_idx = GALLERY_JS.index('id="gallery-settings-container"')
picker_idx = GALLERY_JS.index('id="gallery-image-model"')
assert picker_idx > settings_idx
def test_picker_persists_with_the_shared_localstorage_key():
assert "localStorage.setItem(_GE_MODEL_KEY" in GALLERY_JS
assert "localStorage.getItem(_GE_MODEL_KEY)" in GALLERY_JS
def test_cloud_image_models_are_listed_alongside_the_local_ones():
"""Gateway endpoints (OpenRouter) register as llm but carry image models.
The picker must surface those ids too — otherwise the only selectable
edit model is the local Qwen server.
"""
routes = (ROOT / "routes/editor_agent_routes.py").read_text(encoding="utf-8")
assert "from src.image_model_ids import looks_like_image_generation_model" in routes
assert 'image_endpoint = getattr(ep, "model_type", None) == "image"' in routes
assert "if not image_endpoint and not looks_like_image_generation_model(name):" in routes
@@ -0,0 +1,25 @@
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
GALLERY = ROOT / "static/js/gallery.js"
def test_editor_landing_has_an_import_button_next_to_new_canvas():
"""The editor landing must offer a direct "Import" action.
It sits in the same action row as "New canvas..." / "Browse gallery" and,
on file pick, opens the editor on the chosen raster image (``openEditor``
takes an image data URL as its first argument).
"""
src = GALLERY.read_text(encoding="utf-8")
assert 'id="gallery-editor-import"' in src, "landing Import button missing"
# Button order: New canvas → Import → Browse gallery.
new_at = src.index('id="gallery-editor-new"')
import_at = src.index('id="gallery-editor-import"')
pick_at = src.index('id="gallery-editor-pick"')
assert new_at < import_at < pick_at, "Import must sit next to New canvas"
# Wiring opens the editor on the selected file's data URL.
assert "getElementById('gallery-editor-import')?.addEventListener('click'" in src
assert "readAsDataURL(file)" in src
assert "await openEditor(reader.result, null, null, name)" in src
+187
View File
@@ -0,0 +1,187 @@
"""Tests for the seneca single-image op proxies (HARNESS-owned).
POST /api/image/replace-background -> provider /v1/images/replace-background
POST /api/image/outpaint -> provider /v1/images/outpaint (alias /extend)
Both forward a client-supplied JPEG/PNG as multipart with the prompt/params plus
``request_id`` (so GET /api/image/edit-progress lights the progress bar) and
return the provider image as ``{image}``.
"""
import asyncio
import base64
from types import SimpleNamespace
import httpx
import routes.gallery_routes as gallery
_IMG_B64 = base64.b64encode(b"abc-not-a-real-png").decode()
def _route(path):
return next(r for r in gallery.setup_gallery_routes().routes if r.path == path)
class _Req:
def __init__(self, body):
self._body = body
async def json(self):
return self._body
def _fake_db():
class Db:
def close(self):
pass
def query(self, *args, **kwargs):
return self
def filter(self, *args, **kwargs):
return self
def first(self):
return None
return Db()
def _setup(monkeypatch, respond):
endpoint = SimpleNamespace(base_url="http://100.69.120.65:8188/v1", api_key="k")
monkeypatch.setattr(gallery, "SessionLocal", lambda: _fake_db())
monkeypatch.setattr(gallery, "require_privilege", lambda *a: "alice")
monkeypatch.setattr(gallery, "_first_visible_image_endpoint", lambda *a: endpoint)
seen = {}
def _respond(request):
seen["url"] = str(request.url)
seen["body"] = bytes(request.content)
return respond(request)
client = httpx.AsyncClient
monkeypatch.setattr(
httpx, "AsyncClient",
lambda **k: client(transport=httpx.MockTransport(_respond), **k),
)
return seen
def _ok(_req):
return httpx.Response(200, json={"data": [{"b64_json": "AAAA"}]})
def test_replace_background_forwards_multipart_and_returns_image(monkeypatch):
seen = _setup(monkeypatch, _ok)
out = asyncio.run(_route("/api/image/replace-background").endpoint(_Req({
"image": _IMG_B64, "prompt": "put her on a beach", "steps": 8,
"harmonise": True, "shadow": False, "request_id": "rb-123",
})))
assert out["image"] == "AAAA"
assert seen["url"].endswith("/images/replace-background")
body = seen["body"]
assert b"put her on a beach" in body
assert b"rb-123" in body # request_id forwarded for progress
assert b"harmonise" in body and b"shadow" in body
assert b"8" in body # steps
def test_outpaint_forwards_ratio_size_context_and_returns_image(monkeypatch):
seen = _setup(monkeypatch, _ok)
out = asyncio.run(_route("/api/image/outpaint").endpoint(_Req({
"image": _IMG_B64, "prompt": "extend the sky", "ratio": "16:9",
"size": "1920x1080", "steps": 4, "context": "calm blue sky",
"request_id": "op-9",
})))
assert out["image"] == "AAAA"
assert seen["url"].endswith("/images/outpaint")
body = seen["body"]
for token in (b"16:9", b"1920x1080", b"calm blue sky", b"op-9"):
assert token in body, token
def test_outpaint_extend_alias_uses_the_same_provider_route(monkeypatch):
seen = _setup(monkeypatch, _ok)
out = asyncio.run(_route("/api/image/extend").endpoint(_Req({
"image": _IMG_B64, "prompt": "extend", "ratio": "9:16",
})))
assert out["image"] == "AAAA"
assert seen["url"].endswith("/images/outpaint")
def test_outpaint_reads_openai_style_url_result(monkeypatch):
def _respond(_req):
return httpx.Response(200, json={"data": [{"url": "http://100.69.120.65:8188/v1/images/x.png"}]})
_setup(monkeypatch, _respond)
monkeypatch.setattr(gallery, "_fetch_result_image_b64", lambda url: _async_return("ZZZZ"))
out = asyncio.run(_route("/api/image/outpaint").endpoint(_Req({"image": _IMG_B64, "prompt": "x"})))
assert out["image"] == "ZZZZ"
def test_replace_background_502_when_provider_returns_no_image(monkeypatch):
_setup(monkeypatch, lambda _req: httpx.Response(200, json={"data": []}))
try:
asyncio.run(_route("/api/image/replace-background").endpoint(_Req({"image": _IMG_B64})))
except Exception as exc:
assert getattr(exc, "status_code", None) == 502
else:
raise AssertionError("expected a 502 when the provider returns no image")
async def _async_return(value):
return value
def _capture_forward(monkeypatch):
"""Replace _forward_seneca_image_op with a fake that records kwargs."""
captured = {}
async def _fake_forward(base, api_key, provider_path, image_bytes, fields, *, label, timeout=300.0):
captured["label"] = label
captured["provider_path"] = provider_path
captured["timeout"] = timeout
return {"image": "AAAA"}
monkeypatch.setattr(gallery, "_forward_seneca_image_op", _fake_forward)
monkeypatch.setattr(gallery, "SessionLocal", lambda: _fake_db())
monkeypatch.setattr(gallery, "require_privilege", lambda *a: "alice")
monkeypatch.setattr(
gallery, "_first_visible_image_endpoint",
lambda *a: SimpleNamespace(base_url="http://100.69.120.65:8188/v1", api_key="k"),
)
return captured
def test_outpaint_proxy_uses_a_long_timeout(monkeypatch):
captured = _capture_forward(monkeypatch)
out = asyncio.run(_route("/api/image/outpaint").endpoint(_Req({"image": _IMG_B64, "prompt": "extend to 9:16"})))
assert out["image"] == "AAAA"
assert captured["provider_path"] == "/images/outpaint"
assert captured["timeout"] >= 900, captured
def test_outpaint_extend_alias_also_uses_the_long_timeout(monkeypatch):
captured = _capture_forward(monkeypatch)
asyncio.run(_route("/api/image/extend").endpoint(_Req({"image": _IMG_B64, "prompt": "extend"})))
assert captured["timeout"] >= 900, captured
def test_replace_background_keeps_the_short_default_timeout(monkeypatch):
captured = _capture_forward(monkeypatch)
asyncio.run(_route("/api/image/replace-background").endpoint(_Req({"image": _IMG_B64, "prompt": "beach"})))
assert captured["timeout"] == 300.0, captured
def test_outpaint_timeout_env_override(monkeypatch):
monkeypatch.setenv("ODYSSEUS_IMAGE_OUTPAINT_TIMEOUT", "720")
assert gallery._outpaint_timeout() == 720.0
monkeypatch.delenv("ODYSSEUS_IMAGE_OUTPAINT_TIMEOUT", raising=False)
assert gallery._outpaint_timeout() == 900.0
def test_graceful_shutdown_window_covers_a_long_outpaint():
from pathlib import Path
src = (Path(__file__).resolve().parent.parent / "app.py").read_text()
assert '"ODYSSEUS_GRACEFUL_SHUTDOWN_TIMEOUT", "900"' in src
+1 -1
View File
@@ -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"
)
+493
View File
@@ -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"
+25 -2
View File
@@ -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):
+98
View File
@@ -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"]
+4 -2
View File
@@ -120,8 +120,9 @@ def test_disabled_generation_is_not_restored():
@pytest.mark.asyncio
@pytest.mark.parametrize('incognito', [False, True])
@pytest.mark.parametrize('exit_code', [None, 0])
async def test_generation_dispatch_uses_owner_aware_backend(monkeypatch, exit_code):
async def test_generation_dispatch_uses_owner_aware_backend(monkeypatch, exit_code, incognito):
from types import SimpleNamespace
from src import ai_interaction, tool_execution
from src.agent_runtime.journal import ActionJournal, bind_journal
@@ -145,6 +146,7 @@ async def test_generation_dispatch_uses_owner_aware_backend(monkeypatch, exit_co
_, denied = await execute(block, owner='pewds', session_id='fixture',
disabled_tools={'generate_image'}, security_context=tool_execution.NO_TOOL_SECURITY_CONTEXT)
_, result = await execute(block, owner='pewds', session_id='fixture',
client_runtime_context={'web_ui_state': {'incognito': incognito}},
security_context=tool_execution.NO_TOOL_SECURITY_CONTEXT)
assert denied['exit_code'] != 0
assert journal.actions[0].execution_id is None
@@ -152,7 +154,7 @@ async def test_generation_dispatch_uses_owner_aware_backend(monkeypatch, exit_co
assert journal.actions[1].execution_id is not None
assert journal.actions[1].outcome['authoritative'] is (exit_code == 0)
assert result['image_id'] == 'test'
assert calls == [(block.content, {'owner': 'pewds', 'session_id': 'fixture'})]
assert calls == [(block.content, {'owner': 'pewds', 'session_id': 'fixture', **({'incognito': True} if incognito else {})})]
@pytest.mark.asyncio
+54
View File
@@ -0,0 +1,54 @@
"""The heavy image-edit CPU work must run off the event loop, and the app must
expose in-flight heavy-op counts + a graceful shutdown so a deploy restart does
not drop a 60-150 s edit."""
import base64
import io
from pathlib import Path
from PIL import Image
from routes.gallery import gallery_routes as gr
ROOT = Path(__file__).resolve().parents[1]
def _png_b64(w, h, color=(120, 160, 210)):
buf = io.BytesIO()
Image.new("RGBA", (w, h), color).save(buf, format="PNG")
return base64.b64encode(buf.getvalue()).decode("ascii")
def test_prepare_local_edit_caps_large_input():
src, mask, mw, mh, orig = gr._prepare_local_edit(_png_b64(4000, 3000), None, 768)
assert orig == (4000, 3000)
assert max(mw, mh) <= 768 and mw % 16 == 0 and mh % 16 == 0
assert mask # a full-white mask is synthesised when none is supplied
assert Image.open(io.BytesIO(src)).size == (mw, mh)
def test_prepare_local_edit_leaves_small_input_unchanged():
_src, _mask, mw, mh, orig = gr._prepare_local_edit(_png_b64(96, 96), _png_b64(96, 96, (255, 255, 255)), 768)
assert orig == (96, 96) and (mw, mh) == (96, 96)
def test_restore_local_b64_resizes_back_to_original():
raw = _png_b64(768, 576)
out = gr._restore_local_b64(raw, (4000, 3000), (768, 576))
assert Image.open(io.BytesIO(base64.b64decode(out))).size == (4000, 3000)
# a same-size result is returned untouched
assert gr._restore_local_b64(raw, (768, 576), (768, 576)) == raw
def test_inpaint_offloads_cpu_work_to_a_thread():
src = (ROOT / "routes/gallery/gallery_routes.py").read_text()
assert "await asyncio.to_thread(\n _prepare_local_edit" in src
assert "asyncio.to_thread(\n _restore_local_b64" in src
def test_app_exposes_busy_count_and_graceful_shutdown():
src = (ROOT / "app.py").read_text()
assert "busy_image_ops" in src
assert '@app.get("/api/busy")' in src
assert "_HEAVY_IMAGE_OP_PREFIXES" in src
assert "timeout_graceful_shutdown" in src
assert "activity.begin_image_op" in src and "activity.end_image_op" in src
+32
View File
@@ -33,6 +33,38 @@ def test_native_generation_respects_admin_disable(monkeypatch):
assert 'disabled' in result['error']
@pytest.mark.parametrize('editing', [False, True])
def test_incognito_images_never_write_gallery_or_files(image_transport, editing):
configure, source, requests, rows = image_transport
configure('https://openrouter.ai/api/v1')
if editing:
result = asyncio.run(ai_interaction.do_edit_image(
'Private edit', str(source), model_spec='openai/gpt-5-image',
owner='alice', session_id='private-chat', incognito=True))
else:
result = asyncio.run(ai_interaction.do_generate_image(
'Private image\nopenai/gpt-5-image', owner='alice',
session_id='private-chat', incognito=True))
assert 'error' not in result
assert result['image_url'].startswith('data:image/png;base64,')
assert base64.b64decode(result['image_url'].split(',', 1)[1]) == source.read_bytes()
assert not result['image_id']
assert not rows
assert not (source.parent / 'generated').exists()
def test_incognito_url_image_is_not_persisted(image_transport, monkeypatch):
configure, source, requests, rows = image_transport
configure('https://api.openai.com/v1', body={'data': [{'url': 'https://example.com/output.png'}]})
monkeypatch.setattr(httpx, 'get', lambda *args, **kwargs: httpx.Response(200, content=source.read_bytes()))
monkeypatch.setattr('src.url_safety.check_outbound_url', lambda *args, **kwargs: (True, ''))
result = asyncio.run(ai_interaction.do_generate_image(
'Private image\nopenai/gpt-5-image', owner='alice', incognito=True))
assert result['image_url'].startswith('data:image/png;base64,')
assert not rows
assert not (source.parent / 'generated').exists()
@pytest.fixture
def image_transport(monkeypatch, tmp_path):
rows = []
+92
View File
@@ -0,0 +1,92 @@
"""Tests for the jeff-serve decision client and the email-urgency adapter path.
Covers the calibrated-score mapping plus the two high-precision overrides
(promotions and "waiting outside") that the prompt path also applies. All
network is mocked, so these run offline.
"""
import asyncio
from src import builtin_actions as ba
from src import jeff_decision as jd
def _answer(score):
return {"urgency": {"type": "score", "score": score, "probabilities": {},
"legend": {}, "confidence": 0.9}}
def _run(coro):
return asyncio.run(coro)
def _enable(monkeypatch, score):
monkeypatch.setenv("ODYSSEUS_JEFF_EMAIL_URGENCY", "1")
async def fake(*a, **k):
return _answer(score)
monkeypatch.setattr(jd, "jeff_systemone", fake)
def test_score_value_and_choice_of():
assert jd.score_value(_answer(2.5)["urgency"]) == 2.5
assert jd.score_value({"type": "choice", "probabilities": {"a": 0.25, "b": 0.75}}) == 0.75
assert jd.choice_of({"type": "choice", "choice": "b"}) == "b"
assert jd.score_value(None) is None
assert jd.choice_of(None) is None
def test_disabled_job_does_not_call_adapter(monkeypatch):
monkeypatch.setenv("ODYSSEUS_JEFF_EMAIL_URGENCY", "0")
calls = {"n": 0}
async def fake(*a, **k):
calls["n"] += 1
return _answer(4)
monkeypatch.setattr(jd, "jeff_systemone", fake)
out = _run(ba._spec_triage_email_urgency({"subject": "x", "body": "y"}, triage_version=11))
assert out is None
assert calls["n"] == 0
def test_high_score_maps_to_urgent(monkeypatch):
_enable(monkeypatch, 4)
v = _run(ba._spec_triage_email_urgency(
{"subject": "Locked out", "from": "A <a@b>", "body": "help now", "uid": "1", "message_id": "m"},
triage_version=11))
assert v["score"] == 3 and v["tags"] == ["urgent"] and v["triage_version"] == 11
def test_low_score_maps_to_zero(monkeypatch):
_enable(monkeypatch, 0)
v = _run(ba._spec_triage_email_urgency({"subject": "FYI", "body": "no action needed", "uid": "2"},
triage_version=11))
assert v["score"] == 0 and v["tags"] == []
def test_promotion_is_overridden_to_zero(monkeypatch):
_enable(monkeypatch, 4)
v = _run(ba._spec_triage_email_urgency(
{"subject": "FLASH SALE", "body": "discount, limited time, shop now, coupon", "uid": "3"},
triage_version=11))
assert v["score"] == 0 and v["reason"].startswith("bulk")
def test_waiting_outside_forces_urgent(monkeypatch):
_enable(monkeypatch, 0.2)
v = _run(ba._spec_triage_email_urgency(
{"subject": "Deliveries", "body": "I am waiting outside, can't get in", "uid": "4"},
triage_version=11))
assert v["score"] == 3
def test_adapter_unavailable_returns_none(monkeypatch):
monkeypatch.setenv("ODYSSEUS_JEFF_EMAIL_URGENCY", "1")
async def fake(*a, **k):
return None
monkeypatch.setattr(jd, "jeff_systemone", fake)
assert _run(ba._spec_triage_email_urgency({"subject": "x", "body": "y"}, triage_version=11)) is None
-14
View File
@@ -7,7 +7,6 @@ os.environ.setdefault("DATABASE_URL", "sqlite:///:memory:")
import pytest
from src.llm_core import _supports_thinking
from src.model_profiles import supports_user_thinking_toggle
@pytest.mark.parametrize(
@@ -26,16 +25,3 @@ def test_deepseek_v4_models_support_thinking(model):
@pytest.mark.parametrize("model", ["deepseek-v3", "deepseek-chat"])
def test_other_deepseek_models_are_not_promoted_to_thinking(model):
assert _supports_thinking(model) is False
@pytest.mark.parametrize(
"model",
[
"deepseek-v4",
"deepseek-v4-flash",
"DeepSeek-V4-Flash",
"deepseek/deepseek-v4-flash",
],
)
def test_deepseek_v4_models_expose_the_user_thinking_toggle(model):
assert supports_user_thinking_toggle(model) is True
+257
View File
@@ -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
+3 -3
View File
@@ -368,7 +368,7 @@ def test_detached_container_math_typesets_with_the_real_renderer(node_available)
mdToHtml defers math to a document-scoped flush, which cannot reach a
detached node, so the export has to typeset its own container before
handing it to html2pdf. This is that container: pending spans in, real
handing it to browser printing. This is that container: pending spans in, real
KaTeX markup out, no .katex-error and nothing left pending.
"""
out = _run_node(
@@ -400,7 +400,7 @@ def test_detached_container_math_typesets_with_the_real_renderer(node_available)
assert "ody-math-pending" not in out["written"]
def test_pdf_export_typesets_its_container_before_html2pdf():
def test_pdf_export_typesets_its_container_before_print():
"""Ordering in a call site, so pin the call site. No node needed."""
source = document_source()
match = re.search(r"\n async function exportAsPdf\(\) \{(.*?)\n \}\n", source, re.S)
@@ -410,7 +410,7 @@ def test_pdf_export_typesets_its_container_before_html2pdf():
render = "await markdownModule.renderMath(container);"
assert render in body, "the export never typesets its detached container"
assert body.index("container.innerHTML = html;") < body.index(render)
assert body.index(render) < body.index("window.html2pdf()")
assert body.index(render) < body.index("frame.contentWindow.print()")
def test_md_to_html_renders_inline_once_katex_is_loaded(node_available):
+228
View File
@@ -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
+29
View File
@@ -0,0 +1,29 @@
import pytest
from services.memory.memory_extractor import _has_user_fact_evidence, _is_text_duplicate
@pytest.mark.parametrize('content,evidence,expected', [
('I live in Lisbon.', 'I live in Lisbon', True),
('I live in Lisbon. Can you find cafes?', 'I live in Lisbon', True),
('Can you tell me where I live?', 'I live in Lisbon', False),
('I live in Lisbon?', 'I live in Lisbon', False),
('Please read my records.', 'My home is in Lisbon', False),
])
def test_extracted_memories_require_user_assertion(content, evidence, expected):
assert _has_user_fact_evidence(
{'text': 'User lives in Lisbon', 'evidence': evidence},
[{'role': 'user', 'content': content}],
) is expected
def test_assistant_statement_does_not_ground_memory():
assert not _has_user_fact_evidence(
{'text': 'User lives in Lisbon', 'evidence': 'I live in Lisbon'},
[{'role': 'assistant', 'content': 'I live in Lisbon'}],
)
def test_name_dedup_accepts_refinement_but_preserves_different_slots():
assert _is_text_duplicate("User's name is Alex.", [{'text': 'Name is Alex Morgan'}])
assert not _is_text_duplicate("User's username is alex.", [{'text': "User's name is Alex."}])
@@ -54,7 +54,7 @@ class FakeSession:
def get_context_messages(self):
return [
{"role": "user", "content": "Tell me where I live."},
{"role": "user", "content": "My home is in Lisbon. My role is architect."},
{"role": "assistant", "content": "Noted."},
]
@@ -118,7 +118,7 @@ def test_vector_match_from_other_tenant_does_not_drop_users_fact(monkeypatch):
])
# The vector store reports user B's new fact as a near-duplicate of a1.
vec = FakeVector(match_id="a1")
_install_llm_stub(monkeypatch, '["My home is in Lisbon"]')
_install_llm_stub(monkeypatch, '[{"text":"My home is in Lisbon","evidence":"My home is in Lisbon"}]')
memory_extractor = _load_extractor()
@@ -147,7 +147,7 @@ def test_identity_auto_pin_stops_after_first_five_for_owner(monkeypatch):
])
_install_llm_stub(
monkeypatch,
'[{"text": "New identity that should not stay pinned", "category": "identity"}]',
'[{"text": "User works as an architect", "category": "identity", "evidence": "My role is architect"}]',
)
memory_extractor = _load_extractor()
@@ -158,7 +158,7 @@ def test_identity_auto_pin_stops_after_first_five_for_owner(monkeypatch):
))
rows = mm.load(owner="userA")
added = next(r for r in rows if r["text"] == "New identity that should not stay pinned")
added = next(r for r in rows if r["text"] == "User works as an architect")
assert added.get("category") == "identity"
assert added.get("pinned") is not True
assert sum(1 for r in rows if r.get("category") == "identity" and r.get("pinned")) == 5
@@ -31,7 +31,7 @@ class _FakeSession:
def get_context_messages(self):
return [
{"role": "user", "content": "Hi, a few things about me."},
{"role": "user", "content": "I live in Lisbon. I prefer tea over coffee."},
{"role": "assistant", "content": "Noted."},
]
@@ -54,8 +54,8 @@ def _run(coro):
def test_extraction_persists_facts_when_vector_store_fails_at_runtime(monkeypatch):
facts_json = (
'[{"text": "Alice lives in Lisbon", "category": "fact"}, '
'{"text": "Alice prefers tea over coffee", "category": "preference"}]'
'[{"text": "Alice lives in Lisbon", "category": "fact", "evidence": "I live in Lisbon"}, '
'{"text": "Alice prefers tea over coffee", "category": "preference", "evidence": "I prefer tea over coffee"}]'
)
async def _fake_llm(url, model, messages, **kwargs):
@@ -94,7 +94,7 @@ def test_healthy_vector_store_still_dedups_normally(monkeypatch):
memory, so the dedup must still fire."""
async def _fake_llm(url, model, messages, **kwargs):
return '[{"text": "Alice lives in Lisbon", "category": "fact"}]'
return '[{"text": "Alice lives in Lisbon", "category": "fact", "evidence": "I live in Lisbon"}]'
monkeypatch.setattr(src.llm_core, "llm_call_async", _fake_llm)
monkeypatch.setattr(src.event_bus, "fire_event", lambda *a, **k: None)
+296
View File
@@ -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"
+20 -1
View File
@@ -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():
+3
View File
@@ -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"]
+2 -1
View File
@@ -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
+5 -3
View File
@@ -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")

Some files were not shown because too many files have changed in this diff Show More