mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-10-27 08:12:22 +01:00
Merge current dev and refresh PR #6738 from the 7011 integration
This commit is contained in:
@@ -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
|
||||
@@ -0,0 +1,66 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { chromium } from 'playwright';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
|
||||
const origin = process.env.ODYSSEUS_NOTES_RUNTIME_ORIGIN;
|
||||
const evidence = process.env.NOTES_NATIVE_EVIDENCE_DIR;
|
||||
if (evidence) fs.mkdirSync(evidence, { recursive: true });
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const credentials = { username: 'notes-reference', password: randomUUID() + 'Aa1!' };
|
||||
try {
|
||||
for (const mobile of [false, true]) {
|
||||
const context = await browser.newContext({ viewport: mobile ? { width: 390, height: 844 } : { width: 1440, height: 1000 }, hasTouch: mobile, isMobile: mobile });
|
||||
const page = await context.newPage();
|
||||
if (!mobile) {
|
||||
const setup = await context.request.post(`${origin}/api/auth/setup`, { data: credentials });
|
||||
assert.equal(setup.ok(), true, await setup.text());
|
||||
}
|
||||
const login = await context.request.post(`${origin}/api/auth/login`, { data: credentials });
|
||||
assert.equal(login.ok(), true, await login.text());
|
||||
const response = await context.request.post(`${origin}/api/notes`, { data: { title: mobile ? 'Mobile reference' : 'Desktop reference', content: 'Native API draft', note_type: 'note', label: 'reference' } });
|
||||
assert.equal(response.ok(), true, await response.text());
|
||||
const created = await response.json();
|
||||
assert.ok(created.id);
|
||||
assert.equal(created.owner, credentials.username);
|
||||
await page.goto(origin);
|
||||
await page.waitForFunction(() => window.notesModule?.openPanel);
|
||||
await page.evaluate(() => { localStorage.setItem('odysseus-notes-first-open-hint-v1', '1'); window.notesModule.openPanel(); });
|
||||
const card = page.locator(`.note-card[data-note-id="${created.id}"]`);
|
||||
await card.waitFor();
|
||||
if (evidence) await page.screenshot({ path: path.join(evidence, mobile ? 'native-mobile.png' : 'native-desktop.png'), fullPage: true, animations: 'disabled' });
|
||||
await page.locator('#notes-select-btn').click();
|
||||
await card.locator('.note-card-cb').check({ force: mobile });
|
||||
await page.locator('#notes-send-agent').click();
|
||||
assert.ok(await page.locator('.note-card-selectmode').count());
|
||||
await page.evaluate(async () => {
|
||||
const sessions = await import('/static/js/sessions.js');
|
||||
sessions.createDirectChat('http://127.0.0.1:1', 'reference-model', null);
|
||||
});
|
||||
await page.locator('#notes-send-agent').click();
|
||||
assert.equal(await page.locator('.note-card-selectmode').count(), 0);
|
||||
assert.equal(await page.locator('#selected-notes-context-count').textContent(), '1 note selected');
|
||||
await page.evaluate(async () => (await import('/static/js/sessions.js')).createDirectChat('http://127.0.0.1:1', 'another-reference-model', null));
|
||||
assert.equal(await page.locator('#selected-notes-context-count').textContent(), '');
|
||||
if (mobile) await card.locator('.note-card-title').tap();
|
||||
else await card.locator('.note-card-title').click();
|
||||
if (mobile) await page.locator('.note-form-content-reader').tap({ position: { x: 5, y: 5 } });
|
||||
await page.locator('.note-form-content').fill('Saved by the native Notes application');
|
||||
await page.waitForFunction(id => !localStorage.getItem('odysseus-note-draft-' + id), created.id);
|
||||
const fetched = await context.request.get(`${origin}/api/notes`);
|
||||
const notes = (await fetched.json()).notes;
|
||||
assert.equal(notes.find(note => note.id === created.id).content, 'Saved by the native Notes application');
|
||||
await page.evaluate(() => window.notesModule.closePanel());
|
||||
await page.locator('#notes-pane').waitFor({ state: 'detached' });
|
||||
await context.close();
|
||||
console.log(JSON.stringify({ native: true, mobile, noteId: created.id, crud: true, selectedContext: true }));
|
||||
}
|
||||
const smoke = spawnSync(process.env.NOTES_TEST_PYTHON, ['-m', 'pytest', '-q', 'tests/smoke/test_notes_smoke.py'], {
|
||||
env: { ...process.env, APP_PORT: new URL(origin).port, ODYSSEUS_ADMIN_USER: credentials.username, ODYSSEUS_ADMIN_PASSWORD: credentials.password },
|
||||
encoding: 'utf8', timeout: 90000,
|
||||
});
|
||||
assert.equal(smoke.status, 0, smoke.stdout + smoke.stderr);
|
||||
console.log('PASS: original Notes release smoke test against the disposable authenticated instance');
|
||||
} finally { await browser.close(); }
|
||||
@@ -0,0 +1,39 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { chromium } from 'playwright';
|
||||
import fs from 'node:fs';
|
||||
|
||||
const origin = process.env.ODYSSEUS_TEST_STATIC_ORIGIN;
|
||||
const manifest = JSON.parse(fs.readFileSync(new URL('../../frontend/modules.json', import.meta.url), 'utf8'));
|
||||
const outputs = manifest.modules.map(entry => '/static/' + entry.deployment);
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
try {
|
||||
const context = await browser.newContext({ serviceWorkers: 'allow' });
|
||||
const page = await context.newPage();
|
||||
await page.goto(`${origin}/static/js/panels.js`);
|
||||
const cached = await page.evaluate(async outputs => {
|
||||
await navigator.serviceWorker.register('/static/sw.js');
|
||||
await navigator.serviceWorker.ready;
|
||||
if (!navigator.serviceWorker.controller) await new Promise(resolve => navigator.serviceWorker.addEventListener('controllerchange', resolve, { once: true }));
|
||||
const missing = [];
|
||||
for (const output of outputs) if (!(await caches.match(output))?.ok) missing.push(output);
|
||||
return missing;
|
||||
}, outputs);
|
||||
assert.deepEqual(cached, []);
|
||||
assert.equal(await page.evaluate(async () => {
|
||||
const shared = await import('/static/js/shared/ui/confetti.js');
|
||||
const compare = await import('/static/js/compare/vote.js?v=20260828resendcaldrag1');
|
||||
return shared.spawnConfetti === compare.spawnConfetti;
|
||||
}), true);
|
||||
await context.setOffline(true);
|
||||
const offline = await context.newPage();
|
||||
offline.on('requestfailed', request => console.log('offline failed:', request.url(), request.failure()));
|
||||
offline.on('console', message => { if (message.type() === 'error') console.log('browser:', message.text()); });
|
||||
await offline.goto(`${origin}/static/js/panels.js`);
|
||||
const result = await offline.evaluate(async () => {
|
||||
const facade = await import('/static/js/notes.js?v=20260911notesselectioncancel1');
|
||||
const feature = await import('/static/js/features/notes/index.js');
|
||||
return { identity: facade.default === feature.default && window.notesModule === feature.default, closed: facade.isPanelOpen() === false, exports: Object.keys(facade).sort() };
|
||||
});
|
||||
assert.equal(result.identity && result.closed, true);
|
||||
console.log(JSON.stringify({ ...result, maintainedPrecache: outputs.length }));
|
||||
} finally { await browser.close(); }
|
||||
@@ -0,0 +1,148 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { chromium } from 'playwright';
|
||||
|
||||
const origin = process.env.ODYSSEUS_TEST_STATIC_ORIGIN;
|
||||
const evidence = process.env.NOTES_EVIDENCE_DIR;
|
||||
if (evidence) fs.mkdirSync(evidence, { recursive: true });
|
||||
const baseline = process.env.NOTES_BASELINE === '1';
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
try {
|
||||
for (const device of ['desktop', 'mobile', 'tablet']) {
|
||||
const mobile = device === 'mobile';
|
||||
const context = await browser.newContext({ viewport: mobile ? { width: 390, height: 844 } : device === 'tablet' ? { width: 1024, height: 800 } : { width: 1440, height: 1000 }, hasTouch: device !== 'desktop', isMobile: mobile });
|
||||
const page = await context.newPage();
|
||||
const errors = [];
|
||||
page.on('pageerror', error => errors.push(error.stack));
|
||||
if (baseline) {
|
||||
const body = execFileSync('git', ['show', '1cc56cb9:static/js/notes.js'], { encoding: 'utf8' });
|
||||
await page.route('**/static/js/notes.js*', route => route.fulfill({ contentType: 'text/javascript', body }));
|
||||
}
|
||||
const requests = [];
|
||||
const notes = [
|
||||
{ id: 'plain', title: 'Reference note', note_type: 'note', content: 'Visit https://example.org and keep this draft.', label: 'work', sort_order: 0 },
|
||||
{ id: 'todo', title: 'Reference checklist', note_type: 'todo', items: [{ text: 'First task', done: false }, { text: 'Second task', done: true }], sort_order: 1 },
|
||||
{ id: 'goal', title: 'Reference goal', note_type: 'goal', content: 'A bounded goal', items: [{ text: 'Next step', done: false }], sort_order: 2 },
|
||||
];
|
||||
await page.route('**/api/**', async route => {
|
||||
const request = route.request();
|
||||
requests.push({ url: new URL(request.url()).pathname, method: request.method(), body: request.postData() });
|
||||
const id = new URL(request.url()).pathname.split('/').at(-1);
|
||||
if (request.method() === 'PUT') Object.assign(notes.find(n => n.id === id) || {}, request.postDataJSON());
|
||||
await route.fulfill({ json: request.method() === 'GET' ? { notes } : notes.find(n => n.id === id) || {} });
|
||||
});
|
||||
await page.goto(`${origin}/static/js/panels.js`);
|
||||
const shell = fs.readFileSync(new URL('../../static/index.html', import.meta.url), 'utf8');
|
||||
const styles = [...shell.matchAll(/<link[^>]+rel="stylesheet"[^>]*>/g)].map(m => m[0]).join('\n');
|
||||
await page.setContent(`<html><head><meta name="viewport" content="width=device-width,initial-scale=1">${styles}</head><body><div id="chat-container"></div><div id="toast"></div><button id="tool-notes-btn"><span id="notes-new-note-btn"></span></button><button id="rail-notes"></button></body></html>`);
|
||||
await page.evaluate(() => {
|
||||
localStorage.setItem('odysseus-notes-first-open-hint-v1', '1');
|
||||
window.referenceIntervals = [];
|
||||
window.referenceMouseups = new Set();
|
||||
const add = window.addEventListener.bind(window), remove = window.removeEventListener.bind(window);
|
||||
window.addEventListener = (type, fn, options) => { if (type === 'mouseup' && fn.name === '_end') window.referenceMouseups.add(fn); add(type, fn, options); };
|
||||
window.removeEventListener = (type, fn, options) => { if (type === 'mouseup' && fn.name === '_end') window.referenceMouseups.delete(fn); remove(type, fn, options); };
|
||||
const interval = window.setInterval.bind(window);
|
||||
window.setInterval = (fn, ms, ...args) => { const id = interval(fn, ms, ...args); window.referenceIntervals.push({ id, ms }); return id; };
|
||||
});
|
||||
const publicAPI = await page.evaluate(async () => {
|
||||
const mod = await import('/static/js/notes.js?v=20260911notesselectioncancel1');
|
||||
const again = await import('/static/js/notes.js?v=20260911notesselectioncancel1');
|
||||
window.referenceNotes = mod;
|
||||
mod.openPanel();
|
||||
return { names: Object.keys(mod).sort(), defaults: Object.keys(mod.default).sort(), arities: Object.fromEntries(Object.keys(mod).filter(k => k !== 'default').map(k => [k, mod[k].length])), asyncMethods: Object.keys(mod).filter(k => mod[k]?.constructor?.name === 'AsyncFunction').sort(), singleton: again === mod, bridge: window.notesModule === mod.default, aliases: mod.openNotes === mod.openPanel && mod.closeNotes === mod.closePanel && mod.isNotesOpen === mod.isPanelOpen && mod.default.openNotes === mod.openPanel };
|
||||
});
|
||||
assert.equal(publicAPI.singleton && publicAPI.bridge && publicAPI.aliases, true);
|
||||
assert.deepEqual(publicAPI.names, ['closeNotes', 'closePanel', 'default', 'dismissFiredReminderDot', 'isNotesOpen', 'isPanelOpen', 'newNote', 'openNote', 'openNotes', 'openPanel', 'refreshDueBadge', 'togglePanel']);
|
||||
assert.deepEqual(publicAPI.defaults, ['closeNotes', 'closePanel', 'isNotesOpen', 'isPanelOpen', 'newNote', 'openNote', 'openNotes', 'openPanel', 'refreshDueBadge', 'togglePanel']);
|
||||
assert.deepEqual(publicAPI.arities, { closeNotes: 1, closePanel: 1, dismissFiredReminderDot: 0, isNotesOpen: 0, isPanelOpen: 0, newNote: 0, openNote: 1, openNotes: 0, openPanel: 0, refreshDueBadge: 0, togglePanel: 0 });
|
||||
assert.deepEqual(publicAPI.asyncMethods, ['openNote', 'refreshDueBadge']);
|
||||
await page.locator('.note-card').first().waitFor();
|
||||
assert.equal(await page.locator('.note-card').count(), 3);
|
||||
if (evidence) await page.screenshot({ path: path.join(evidence, `notes-${device}.png`), fullPage: true, animations: 'disabled' });
|
||||
await page.locator('#notes-search').fill('checklist');
|
||||
assert.equal(await page.locator('.note-card').count(), 1);
|
||||
await page.locator('#notes-search').fill('');
|
||||
await page.locator('#notes-select-btn').click();
|
||||
await page.locator('.note-card-cb').first().check({ force: mobile });
|
||||
await page.locator('#notes-send-agent').click();
|
||||
assert.equal(await page.locator('.note-card-selectmode').count(), 3, 'rejected selection stays selected');
|
||||
await page.evaluate(() => window.addEventListener('odysseus:notes-context-selected', e => { window.referenceSelected = e.detail.noteIds; e.detail.accepted = true; }, { once: true }));
|
||||
await page.locator('#notes-send-agent').click();
|
||||
assert.equal(await page.locator('.note-card-selectmode').count(), 0);
|
||||
assert.equal((await page.evaluate(() => window.referenceSelected)).length, 1);
|
||||
await page.locator('#notes-search').fill('checklist');
|
||||
await page.evaluate(() => window.referenceNotes.closePanel());
|
||||
await page.locator('#notes-pane').waitFor({ state: 'detached' });
|
||||
await page.evaluate(() => window.referenceNotes.openPanel());
|
||||
await page.locator('.note-card').first().waitFor();
|
||||
assert.equal(await page.locator('#notes-search').inputValue(), '');
|
||||
assert.equal(await page.locator('.note-card').count(), 3);
|
||||
if (!baseline && device !== 'desktop') {
|
||||
const beginTouch = () => page.evaluate(() => {
|
||||
const card = document.querySelector('.note-card');
|
||||
const rect = card.getBoundingClientRect();
|
||||
const touch = new Touch({ identifier: 1, target: card, clientX: rect.left + 5, clientY: rect.top + 5 });
|
||||
card.dispatchEvent(new TouchEvent('touchstart', { touches: [touch], bubbles: true }));
|
||||
});
|
||||
if (device === 'tablet') {
|
||||
await beginTouch();
|
||||
await page.waitForFunction(() => document.querySelector('.note-card.dragging'));
|
||||
await page.evaluate(() => document.querySelector('.note-card.dragging').dispatchEvent(new TouchEvent('touchcancel', { bubbles: true })));
|
||||
await page.locator('#notes-search').fill('checklist');
|
||||
await page.locator('#notes-search').fill('');
|
||||
await beginTouch();
|
||||
await page.waitForFunction(() => document.querySelector('.note-card.dragging'));
|
||||
} else {
|
||||
await beginTouch(); // Close before the selection hold timer can fire.
|
||||
}
|
||||
await page.evaluate(() => window.referenceNotes.closePanel());
|
||||
await page.locator('#notes-pane').waitFor({ state: 'detached' });
|
||||
await page.waitForTimeout(500);
|
||||
assert.equal(await page.evaluate(() => document.documentElement.style.touchAction), '');
|
||||
await page.evaluate(() => window.referenceNotes.openPanel());
|
||||
await page.locator('.note-card').first().waitFor();
|
||||
assert.equal(await page.locator('.note-card-selectmode').count(), 0);
|
||||
}
|
||||
if (mobile) await page.locator('[data-note-id="plain"] .note-card-title').tap();
|
||||
else await page.locator('[data-note-id="plain"] .note-card-title').click();
|
||||
if (mobile) await page.locator('.note-form-content-reader').tap({ position: { x: 5, y: 5 } });
|
||||
await page.locator('.note-form-content').fill('Recovered draft');
|
||||
assert.equal(await page.evaluate(() => JSON.parse(localStorage.getItem('odysseus-note-draft-plain')).content), 'Recovered draft');
|
||||
await page.waitForFunction(() => !localStorage.getItem('odysseus-note-draft-plain'));
|
||||
assert.ok(requests.some(r => r.method === 'PUT' && JSON.parse(r.body).content === 'Recovered draft'));
|
||||
await page.locator('.note-form-type-pill[data-type="draw"]').click();
|
||||
const canvas = page.locator('.note-form-canvas');
|
||||
const box = await canvas.boundingBox();
|
||||
assert.ok(box);
|
||||
await page.mouse.move(box.x + 25, box.y + 25);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(box.x + 55, box.y + 55, { steps: 5 });
|
||||
await page.mouse.up();
|
||||
await page.locator('.note-form-draw-undo').waitFor();
|
||||
assert.equal(await page.locator('.note-form-draw-undo').isDisabled(), false);
|
||||
await page.locator('.note-form-draw-undo').click();
|
||||
assert.equal(await page.locator('.note-form-draw-undo').isDisabled(), true);
|
||||
await page.locator('.note-form-type-pill[data-type="note"]').click();
|
||||
if (!baseline) await page.waitForFunction(() => window.referenceMouseups.size === 0);
|
||||
await page.locator('.note-form-remind-btn').click();
|
||||
await page.locator('.note-reminder-menu').waitFor();
|
||||
await page.evaluate(() => window.referenceNotes.closePanel());
|
||||
await page.locator('#notes-pane').waitFor({ state: 'detached' });
|
||||
if (!baseline) {
|
||||
assert.equal(await page.locator('.note-reminder-menu').count(), 0);
|
||||
assert.equal(await page.evaluate(() => window.referenceIntervals.filter(t => t.ms === 30000).length), 1);
|
||||
const identity = await page.evaluate(async () => {
|
||||
const feature = await import('/static/js/features/notes/index.js');
|
||||
const unversioned = await import('/static/js/notes.js');
|
||||
return feature.default === window.referenceNotes.default && unversioned.default === feature.default && feature.openPanel === window.referenceNotes.openPanel;
|
||||
});
|
||||
assert.equal(identity, true);
|
||||
}
|
||||
assert.deepEqual(errors, []);
|
||||
await context.close();
|
||||
console.log(JSON.stringify({ device, mobile, ...publicAPI, requests: requests.length }));
|
||||
}
|
||||
} finally { await browser.close(); }
|
||||
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
Vendored
+17
@@ -0,0 +1,17 @@
|
||||
# CA trust regression fixtures
|
||||
|
||||
The public certificates embedded in `tests/test_ca_trust.py` exercise the October 2026 eMudhra trust-store check. They contain no private keys. `emsign-g3.pem` and `aaa.pem` are the incident's affected trust anchors; `emsign-g1.pem` is an unaffected emSign anchor that must remain usable. `localhost-leaf.pem` is a synthetic self-signed Ed25519 server certificate with `CA:FALSE`, generated solely to reproduce hashed non-CA certificates found in Ubuntu's OpenSSL directory; its private key was discarded. The test creates temporary PEM files from these public constants. The certificate fingerprints are the factual identity of each fixture.
|
||||
|
||||
Sources: the [incident root list](https://emudhra.we-have-a-problem.com/roots.json) and the public Mozilla root certificates distributed in Debian's `ca-certificates` package.
|
||||
|
||||
`incident.json` contains the four published roots, sixteen [compromised intermediate certificates and cross-signs](https://emudhra.we-have-a-problem.com/impacted-intermediates.json), and the [fourteen published server chains](https://emudhra.we-have-a-problem.com/chains.zip), retrieved on 2026-10-21 with chains refreshed on 2026-10-23. The refresh adds the two DV G3B hosts without Certificate Transparency entries and preserves the original twelve chains. Certificates are deduplicated by SHA-256 of DER; each chain lists its leaf first. The additional cross-signed `emSign Root TLS CA - G3` issuer comes from those chains and has fingerprint `3094025792039a68af6e553686410bdab7c0d4a36b9084cba64202e35d6ebbdd`. Like the intermediates, it must not be accepted as a custom trust anchor. All data is public certificate material, with no private keys.
|
||||
|
||||
Downloaded source SHA-256 values:
|
||||
|
||||
- `roots.json`: `119f32cd81a6b5a4e3c4c2df5fe07daa90dc43cafa123a62f0394f1895d92380`
|
||||
- `impacted-intermediates.json`: `f67b3abe9b4879f4e91874ed041beccd71257f1db41c588c724ccf12aa90c59e`
|
||||
- `chains.zip`: `7b949956955fc501a30f4d98d2f513d34c0bbbe92e29a968194c96e097ce2345`
|
||||
|
||||
The offline OpenSSL regression verifies server purpose, hostname and strict certificate rules with partial-chain trust, at `2026-10-23T12:00:00Z` (`1792756800`). Fixing the verification date keeps later certificate expiry from masking a broken mitigation. All fourteen chains must verify against the affected positive-control roots and fail against the unaffected G1-only store. Adding the compromised DV G3B intermediate restores four paths, including the two new hosts, which the checker must reject. The regression makes no network requests and skips chain verification if the OpenSSL executable is unavailable; certificate-inventory tests still run using Python's standard library.
|
||||
|
||||
The upstream SearXNG image validation uses the same `incident.json` inventory and fixed verification timestamp, checking each stored DER fingerprint before use. See [the image documentation](../../../docs/searxng-trust-image.md) for its validation targets and source provenance.
|
||||
+132
@@ -0,0 +1,132 @@
|
||||
{
|
||||
"verification_time": 1792756800,
|
||||
"roots": {
|
||||
"7dd78d5f4f13459a83dff9abbba62edbaf6f2d102bf257fd712f4d9f2746ed8d": "emSign Root TLS CA - G3",
|
||||
"f2edaaff3d9b701fb4d14c9f285f8c0bad90fc3bcc1fa07d47f4718edaba34a5": "emSign Root CS CA - G3",
|
||||
"d7a7a0fb5d7e2731d771e9484ebcdef71d5f0c3e0a2948782bc83ee0ea699ef4": "AAA Certificate Services",
|
||||
"86a1ecba089c4a8d3bbe2734c612ba341d813e043cf9e8a862cd5c57a36bbe6b": "emSign ECC Root CA - G3"
|
||||
},
|
||||
"intermediates": {
|
||||
"42c4accd12c4f59597925e1a08a7a90102608543809308d50ec427e118ba1493": "EM DV TLS CA - G3B (eMudhra)",
|
||||
"45a883f349aedfd5f3a649b5498ffb121712ba8b448f3f2a91dbc5a766fcddbe": "EM DV TLS CA - G3B (Sectigo)",
|
||||
"98b896625c0c230adeec3eb2cf6aad5001ecf9156a68644e82374ce5833bc25b": "EM OV TLS CA - G3B (eMudhra)",
|
||||
"5f1ded4dd590af74631290cf073b122a2aac6a1fa479778a0bd43579bd832699": "EM OV TLS CA - G3B (Sectigo)",
|
||||
"e7568fe6e12331f4fd7de61da4c7559d0f8eb192a218d4741a3ba75184333838": "EM EV TLS CA - G3B (eMudhra)",
|
||||
"579480aea166290bd3c643589e795fc0d0e54e86505d180f1dff99efd3199512": "EM EV TLS CA - G3B (Sectigo)",
|
||||
"0164d7ceae5bb909c272af151ff44b1177ae9b47d9424b94f1d9558c30934d19": "EM DV TLS CA - G3C (eMudhra)",
|
||||
"62b7f9868263f72f6911fc78ceaf72c1979158b7368bf648683bb2aa39b02e72": "EM DV TLS CA - G3C (Sectigo)",
|
||||
"cb6e15165ac802e9b3f993f16c25d41f4e81c21ebde71ae1b012ac00bf9c7c3f": "EM OV TLS CA - G3C (eMudhra)",
|
||||
"7485b51c0ed192878fa1d2f948fb529ae1842cc5086c4b67f7be5b2039de9489": "EM OV TLS CA - G3C (Sectigo)",
|
||||
"432ec25f0d28de9006d97bf752f17bbb2cd41f669933fcb072d9a1148d0261f3": "EM EV TLS CA - G3C (eMudhra)",
|
||||
"fd9d5c2dbbd4995ab69b8d50c439d9527b00529199c2fd01b2174eb58f07021f": "EM EV TLS CA - G3C (Sectigo)",
|
||||
"9e39de2881273acc225a149c72ba3e0f51424963669ab633ee1dc7df5db067b4": "EM OV CS CA - G3B (eMudhra)",
|
||||
"a496e23ac0cab8155fc9b117b97eae368efcbb9041aec8804d5a308d2d01c2d8": "EM OV CS CA - G3B (Sectigo)",
|
||||
"4b2460a466f95fd40113bcdcca2ceb29b3b093921de45474da7df48874c630b1": "EM OV CS CA - G3C (eMudhra)",
|
||||
"84112972b8e59d14560ae7e7119654474aad119990cf3722bfc1e3b91615e1fd": "EM OV CS CA - G3C (Sectigo)"
|
||||
},
|
||||
"certificates": {
|
||||
"0164d7ceae5bb909c272af151ff44b1177ae9b47d9424b94f1d9558c30934d19": "-----BEGIN CERTIFICATE-----\nMIIDaDCCAu6gAwIBAgIPWIsuPCu4zaE+GhIJJUMhMAoGCCqGSM49BAMDMFYxCzAJ\nBgNVBAYTAklOMSUwIwYDVQQKDBxlTXVkaHJhIFRlY2hub2xvZ2llcyBMaW1pdGVk\nMSAwHgYDVQQDDBdlbVNpZ24gUm9vdCBUTFMgQ0EgLSBHMzAeFw0yNTA0MzAxNTM1\nMDlaFw00MDA0MjgxODMwMDBaMEAxCzAJBgNVBAYTAlVTMRQwEgYDVQQKDAtlTXVk\naHJhIEluYzEbMBkGA1UEAwwSRU0gRFYgVExTIENBIC0gRzNDMHYwEAYHKoZIzj0C\nAQYFK4EEACIDYgAEOnp4YsjETTJN3WkDOmeCwO/u/kBV5ooSP6A3G6O9kZRRTuFy\npDPjYw8CvRiAN9nlFJ+bd9uUooHfmHsv6oiLdL3+j9pMEJnubKGVZv4+8OX2sJpV\nbMyLpQVh3xsqRB0Bo4IBljCCAZIwHwYDVR0jBBgwFoAU4TqrIXzx401UrtRyt/2L\nWUIgkFQwHQYDVR0OBBYEFK5+EFw8KZbBqEcnBVnx4D3U5QrzMB0GA1UdJQQWMBQG\nCCsGAQUFBwMBBggrBgEFBQcDAjBdBgNVHSAEVjBUMDkGCysGAQQBg44hAQABMCow\nKAYIKwYBBQUHAgEWHGh0dHA6Ly9yZXBvc2l0b3J5LmVtc2lnbi5jb20wDQYLKwYB\nBAGDjiEBAmQwCAYGZ4EMAQIBMHUGCCsGAQUFBwEBBGkwZzAkBggrBgEFBQcwAYYY\naHR0cDovL29jc3AtYy5lbXNpZ24uY29tMD8GCCsGAQUFBzAChjNodHRwOi8vcmVw\nb3NpdG9yeS5lbXNpZ24uY29tL2NlcnRzL0VNRFZUTFNDQUczQy5wN2MwNwYDVR0f\nBDAwLjAsoCqgKIYmaHR0cDovL2NybC5lbXNpZ24uY29tLz9yb290dGxzY2FnMy5j\ncmwwDgYDVR0PAQH/BAQDAgEGMBIGA1UdEwEB/wQIMAYBAf8CAQAwCgYIKoZIzj0E\nAwMDaAAwZQIxAMEN2HWMocbuWdy1d33pH6JpMvP/V/DIS5PtZbv1Q7TXEbxrcyxg\nyuddOJscYDJJlAIwB4sH1BpbScDzkdD4tJBSwrG/1Mx82vb8YaySehNuu1+1oXNd\nAEKwaJ0mVzScy2+6\n-----END CERTIFICATE-----\n",
|
||||
"0cbfe22b824385823fd3bf4be4d131e8901342933a7feef1cf8b3110b8e44a70": "-----BEGIN CERTIFICATE-----\nMIICQTCCAcigAwIBAgIUG01Z/jOotSvB49K4K27kFjpdnWMwCgYIKoZIzj0EAwIw\nPzELMAkGA1UEBhMCTkwxEzARBgNVBAoMCmVNdWRocmEgQlYxGzAZBgNVBAMMEkVN\nIERWIFRMUyBDQSAtIEczQjAeFw0yNjEwMjIxMjU3MzFaFw0yNzA1MDUxMzAyMzFa\nMDQxMjAwBgNVBAMMKWR2LWczYi1ub2N0LWVtdWRocmEud2UtaGF2ZS1hLXByb2Js\nZW0uY29tMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEI805yWr+RxanILifET+/\nGwHsXo7dp8ghA3OgwR0Mg11Bl6iYyOzldy4iU7kM+3qfDNsUrzxvkUkJZQpYieuC\n+aOBrDCBqTAMBgNVHRMBAf8EAjAAMA4GA1UdDwEB/wQEAwIHgDATBgNVHSUEDDAK\nBggrBgEFBQcDATA0BgNVHREELTArgilkdi1nM2Itbm9jdC1lbXVkaHJhLndlLWhh\ndmUtYS1wcm9ibGVtLmNvbTAdBgNVHQ4EFgQUOlRrLQGIczrmr4H+Oak06aUmJ4Yw\nHwYDVR0jBBgwFoAU4h4v5xwZOQ5VS2LBICRXNSSKJRQwCgYIKoZIzj0EAwIDZwAw\nZAIwf4ekmRgXAxXP2AjpVY2f1bO42GC7Nnc80t4OpaWJeVdXpT+X3dOsLRwlIUm0\nYbMwAjBihTx7oHfaYB3KRnFuhKqlHYmUcOe9Hjxqs6bubZohhrqMQzPocyasDhJL\nUx3HsgQ=\n-----END CERTIFICATE-----\n",
|
||||
"21f57f83631a93c98e867050075746b3361e0433c9e0e969462852476784f6f0": "-----BEGIN CERTIFICATE-----\nMIICPzCCAcagAwIBAgIUH0gWfBRnOiSjcduti/cukjDGAjIwCgYIKoZIzj0EAwIw\nPzELMAkGA1UEBhMCTkwxEzARBgNVBAoMCmVNdWRocmEgQlYxGzAZBgNVBAMMEkVN\nIE9WIFRMUyBDQSAtIEczQjAeFw0yNjEwMjAwMjIyNDJaFw0yNzA1MDMwMjI3NDJa\nMDMxMTAvBgNVBAMMKG92LWczYi1hYWEtZW11ZGhyYS53ZS1oYXZlLWEtcHJvYmxl\nbS5jb20wWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAAQ3EfjVE+6fUm9gr7UoUv6N\nYPttIdILR9OMZnAdTj+RWT1Z4fp7dpC5mat1gbIo4J6EDelcOQQz5/oGeB3gFJKw\no4GrMIGoMAwGA1UdEwEB/wQCMAAwDgYDVR0PAQH/BAQDAgeAMBMGA1UdJQQMMAoG\nCCsGAQUFBwMBMDMGA1UdEQQsMCqCKG92LWczYi1hYWEtZW11ZGhyYS53ZS1oYXZl\nLWEtcHJvYmxlbS5jb20wHQYDVR0OBBYEFKzQPp6gTBOWb0TBxZbkL5zsQn3oMB8G\nA1UdIwQYMBaAFJvYZVGv5+csN60I7mkZ2b83hK4KMAoGCCqGSM49BAMCA2cAMGQC\nMARKrRRBEWY2Ix1d80DGUvC7bWENP1Q9pf6BOGF4+84UiwV5+nAuht/+hdpAanWB\nsQIwJF2dW8tpPN6lTm1kDerzG6yJAJOz4drotaTthMF2+efuv5W2FL7Dr8qZSd1z\n201o\n-----END CERTIFICATE-----\n",
|
||||
"3094025792039a68af6e553686410bdab7c0d4a36b9084cba64202e35d6ebbdd": "-----BEGIN CERTIFICATE-----\nMIIDdDCCAvmgAwIBAgIQAJYaPvsTWVdu0EAFtGhKWzAKBggqhkjOPQQDAzBrMQsw\nCQYDVQQGEwJJTjETMBEGA1UECxMKZW1TaWduIFBLSTElMCMGA1UEChMcZU11ZGhy\nYSBUZWNobm9sb2dpZXMgTGltaXRlZDEgMB4GA1UEAxMXZW1TaWduIEVDQyBSb290\nIENBIC0gRzMwHhcNMjQwNzExMTU0MzM2WhcNMzkwNzA5MTgzMDAwWjBWMQswCQYD\nVQQGEwJJTjElMCMGA1UECgwcZU11ZGhyYSBUZWNobm9sb2dpZXMgTGltaXRlZDEg\nMB4GA1UEAwwXZW1TaWduIFJvb3QgVExTIENBIC0gRzMwdjAQBgcqhkjOPQIBBgUr\ngQQAIgNiAARfD9w/q1cWxL6D9bMGH6CQ4u+gAV6GN9s+PwQx9Bl4hC+0iU8QOk8m\nWO/RwXQy10DCSxT8vSIJClHKp+Ao4qJKdYeV0SwZ7LMQziyLfxIUOY6Grja/sQFw\nLKYf8EdDXAyjggF1MIIBcTAfBgNVHSMEGDAWgBR8XQKEE9TMipuBzhccLikenEhj\nQjAdBgNVHQ4EFgQU4TqrIXzx401UrtRyt/2LWUIgkFQwHQYDVR0lBBYwFAYIKwYB\nBQUHAwEGCCsGAQUFBwMCMD0GA1UdIAQ2MDQwMgYEVR0gADAqMCgGCCsGAQUFBwIB\nFhxodHRwOi8vcmVwb3NpdG9yeS5lbXNpZ24uY29tMHgGCCsGAQUFBwEBBGwwajAi\nBggrBgEFBQcwAYYWaHR0cDovL29jc3AuZW1zaWduLmNvbTBEBggrBgEFBQcwAoY4\naHR0cDovL3JlcG9zaXRvcnkuZW1zaWduLmNvbS9jZXJ0cy9lbVNpZ25FQ0NSb290\nQ0FHMy5jcnQwMwYDVR0fBCwwKjAooCagJIYiaHR0cDovL2NybC5lbXNpZ24uY29t\nP1Jvb3RDQUczLmNybDAOBgNVHQ8BAf8EBAMCAQYwEgYDVR0TAQH/BAgwBgEB/wIB\nATAKBggqhkjOPQQDAwNpADBmAjEAt9CIpRGp46QHQYDUBnLKw+vvK0/h9vmilEAg\nTroUl/anH4x2rXHqiCtBTBfgKDegAjEA8WpG1LTZdAIL0FSYqxXeREaeTSxEofM0\n1jJH3jGvCXC79LU9UPJ2o3TU+UIj2gMV\n-----END CERTIFICATE-----\n",
|
||||
"346cdd962e97189b59aee5526d1ae8fa714bb1a64094dfa67bef768ee93f36b5": "-----BEGIN CERTIFICATE-----\nMIICODCCAb+gAwIBAgIUbWvtwSNK/Qro223vAqyz323+VBAwCgYIKoZIzj0EAwIw\nQDELMAkGA1UEBhMCVVMxFDASBgNVBAoMC2VNdWRocmEgSW5jMRswGQYDVQQDDBJF\nTSBEViBUTFMgQ0EgLSBHM0MwHhcNMjYxMDIwMDA1OTM2WhcNMjcwNTAzMDEwNDM2\nWjAvMS0wKwYDVQQDDCRkdi1nM2MtZW11ZGhyYS53ZS1oYXZlLWEtcHJvYmxlbS5j\nb20wWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAAScdBQpNUOOCcag6YEVFccNRSFe\nUIl02iH53vDfoWPJFS005WzqF3CPDclB/kygP2gWF4pg/r5gPYdnI3/6Vdzro4Gn\nMIGkMAwGA1UdEwEB/wQCMAAwDgYDVR0PAQH/BAQDAgeAMBMGA1UdJQQMMAoGCCsG\nAQUFBwMBMC8GA1UdEQQoMCaCJGR2LWczYy1lbXVkaHJhLndlLWhhdmUtYS1wcm9i\nbGVtLmNvbTAdBgNVHQ4EFgQUBj2yGO/TzDOE/u/6QeUvcW8y1iMwHwYDVR0jBBgw\nFoAUrn4QXDwplsGoRycFWfHgPdTlCvMwCgYIKoZIzj0EAwIDZwAwZAIwBQOOQqG9\nccdzM/7XIlXLtota1esALaLmgEN/ZweosBIvk4xabHcERZInWVIwO45NAjBos8Ue\nNwDe1X3ahdK2lkxqBEoBMtUHJN18TnJKs2nK1gmXpMbmY5oKnAQQgCEZPA4=\n-----END CERTIFICATE-----\n",
|
||||
"42c4accd12c4f59597925e1a08a7a90102608543809308d50ec427e118ba1493": "-----BEGIN CERTIFICATE-----\nMIIDaDCCAu6gAwIBAgIQAKSu5njyM2K0wORgt3LyFDAKBggqhkjOPQQDAzBWMQsw\nCQYDVQQGEwJJTjElMCMGA1UECgwcZU11ZGhyYSBUZWNobm9sb2dpZXMgTGltaXRl\nZDEgMB4GA1UEAwwXZW1TaWduIFJvb3QgVExTIENBIC0gRzMwHhcNMjUwNDMwMTU1\nNDA4WhcNNDAwNDI4MTgzMDAwWjA/MQswCQYDVQQGEwJOTDETMBEGA1UECgwKZU11\nZGhyYSBCVjEbMBkGA1UEAwwSRU0gRFYgVExTIENBIC0gRzNCMHYwEAYHKoZIzj0C\nAQYFK4EEACIDYgAE557ZwEqXc8RzXlERHahawr1eZKs3S4DhLLwgOOweTw47gS/J\nhZ4eZ6+wTi+LyX90hbplqpEe6KTkW2LVzAPVa+Z4s8OMtNyTfKJGDiOEU8iSk10p\n1E+R4VYGtjzO2rRoo4IBljCCAZIwHwYDVR0jBBgwFoAU4TqrIXzx401UrtRyt/2L\nWUIgkFQwHQYDVR0OBBYEFOIeL+ccGTkOVUtiwSAkVzUkiiUUMB0GA1UdJQQWMBQG\nCCsGAQUFBwMBBggrBgEFBQcDAjBdBgNVHSAEVjBUMDkGCysGAQQBg44hAQABMCow\nKAYIKwYBBQUHAgEWHGh0dHA6Ly9yZXBvc2l0b3J5LmVtc2lnbi5jb20wDQYLKwYB\nBAGDjiEBAmQwCAYGZ4EMAQIBMHUGCCsGAQUFBwEBBGkwZzAkBggrBgEFBQcwAYYY\naHR0cDovL29jc3AtYi5lbXNpZ24uY29tMD8GCCsGAQUFBzAChjNodHRwOi8vcmVw\nb3NpdG9yeS5lbXNpZ24uY29tL2NlcnRzL0VNRFZUTFNDQUczQi5wN2MwNwYDVR0f\nBDAwLjAsoCqgKIYmaHR0cDovL2NybC5lbXNpZ24uY29tLz9yb290dGxzY2FnMy5j\ncmwwDgYDVR0PAQH/BAQDAgEGMBIGA1UdEwEB/wQIMAYBAf8CAQAwCgYIKoZIzj0E\nAwMDaAAwZQIwLmsGXi6oFMJ2klkcEMXMug6gvxQWZWkewiDWVzQyl2L6z6DuQfPO\nIbh1grvdULDCAjEAxZvLlxUavG69J6wnSjC8XBbqNIQsJ6CE2QdNOe84Ptxb6VG8\ngt2TlfJbHii+KNvK\n-----END CERTIFICATE-----\n",
|
||||
"4314941cf242a4ea4149aa7e9cdf77a15de2771a79984b809fc04443fea013c8": "-----BEGIN CERTIFICATE-----\nMIICOTCCAb+gAwIBAgIUXcJ4+VeNEICJ9JbBANtIyfP96EowCgYIKoZIzj0EAwIw\nQDELMAkGA1UEBhMCVVMxFDASBgNVBAoMC2VNdWRocmEgSW5jMRswGQYDVQQDDBJF\nTSBPViBUTFMgQ0EgLSBHM0MwHhcNMjYxMDIwMDA1OTM3WhcNMjcwNTAzMDEwNDM3\nWjAvMS0wKwYDVQQDDCRvdi1nM2MtZW11ZGhyYS53ZS1oYXZlLWEtcHJvYmxlbS5j\nb20wWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAATLEvRHdoPc0Z9ovgqlYmoKTHPU\nApwd6lAp0ou1G63SzuESyBzIW6Tl64CsKzlV9ggkZjX+L9/tOx97/1wmSTYvo4Gn\nMIGkMAwGA1UdEwEB/wQCMAAwDgYDVR0PAQH/BAQDAgeAMBMGA1UdJQQMMAoGCCsG\nAQUFBwMBMC8GA1UdEQQoMCaCJG92LWczYy1lbXVkaHJhLndlLWhhdmUtYS1wcm9i\nbGVtLmNvbTAdBgNVHQ4EFgQUrytBb+px+b58Df+d5vP8GpTW+/wwHwYDVR0jBBgw\nFoAUNOrqDOvJCtr50MXi7Zt4yYeeG2wwCgYIKoZIzj0EAwIDaAAwZQIwNdoOUOKz\nMLV1OFEoMjBeUqvkQAwsajvVWu0A23qyyvYlosyMp8aFv0gxURW6Yj6DAjEAhe0z\nkOQmmqb0eyhjh7weF4fHtwMtLi4jNFA6M/EfPTfkqE91MN9fSin5uhua5xhk\n-----END CERTIFICATE-----\n",
|
||||
"432ec25f0d28de9006d97bf752f17bbb2cd41f669933fcb072d9a1148d0261f3": "-----BEGIN CERTIFICATE-----\nMIIDaDCCAu6gAwIBAgIQAIAJkopkEHYBrwX6LxnxCTAKBggqhkjOPQQDAzBWMQsw\nCQYDVQQGEwJJTjElMCMGA1UECgwcZU11ZGhyYSBUZWNobm9sb2dpZXMgTGltaXRl\nZDEgMB4GA1UEAwwXZW1TaWduIFJvb3QgVExTIENBIC0gRzMwHhcNMjUwNDMwMTUz\nOTMxWhcNNDAwNDI4MTgzMDAwWjBAMQswCQYDVQQGEwJVUzEUMBIGA1UECgwLZU11\nZGhyYSBJbmMxGzAZBgNVBAMMEkVNIEVWIFRMUyBDQSAtIEczQzB2MBAGByqGSM49\nAgEGBSuBBAAiA2IABC1W6Cmm+4LGSkaX2WCvlR6QZ66R+SbTQwbaSKiq63hptqZ+\nbSdCbgRCqgOzbhmI3/Y2UU7hzwf1uks/8qBmRa59MdWGatMao9JG6wsxd+6Zy3Vm\nxRbOdICKZo+R8MR/yqOCAZUwggGRMB8GA1UdIwQYMBaAFOE6qyF88eNNVK7Ucrf9\ni1lCIJBUMB0GA1UdDgQWBBSmJV6AqU2385Z8G8eR35K87kvrXzAdBgNVHSUEFjAU\nBggrBgEFBQcDAQYIKwYBBQUHAwIwXAYDVR0gBFUwUzA5BgsrBgEEAYOOIQEAATAq\nMCgGCCsGAQUFBwIBFhxodHRwOi8vcmVwb3NpdG9yeS5lbXNpZ24uY29tMA0GCysG\nAQQBg44hAQJ4MAcGBWeBDAEBMHUGCCsGAQUFBwEBBGkwZzAkBggrBgEFBQcwAYYY\naHR0cDovL29jc3AtYy5lbXNpZ24uY29tMD8GCCsGAQUFBzAChjNodHRwOi8vcmVw\nb3NpdG9yeS5lbXNpZ24uY29tL2NlcnRzL0VNRVZUTFNDQUczQy5wN2MwNwYDVR0f\nBDAwLjAsoCqgKIYmaHR0cDovL2NybC5lbXNpZ24uY29tLz9yb290dGxzY2FnMy5j\ncmwwDgYDVR0PAQH/BAQDAgEGMBIGA1UdEwEB/wQIMAYBAf8CAQAwCgYIKoZIzj0E\nAwMDaAAwZQIwBy1wTnzH4lXDTy2PD91ysLjvnlVN3JbJmc1BIMEU8TOLkYVHibMc\n9HeyonikFSfmAjEA6uE3QRxlrZTz/1/lXPTEMUhoauAtycagtlNFp79kjhvNa2/f\nAal1GNxsOutLnr0Y\n-----END CERTIFICATE-----\n",
|
||||
"45a883f349aedfd5f3a649b5498ffb121712ba8b448f3f2a91dbc5a766fcddbe": "-----BEGIN CERTIFICATE-----\nMIIDzjCCAragAwIBAgIRAKAk/fS6Zlx4XOSgIEg4WLcwDQYJKoZIhvcNAQELBQAw\nezELMAkGA1UEBhMCR0IxGzAZBgNVBAgMEkdyZWF0ZXIgTWFuY2hlc3RlcjEQMA4G\nA1UEBwwHU2FsZm9yZDEaMBgGA1UECgwRQ29tb2RvIENBIExpbWl0ZWQxITAfBgNV\nBAMMGEFBQSBDZXJ0aWZpY2F0ZSBTZXJ2aWNlczAeFw0yNTA1MTQwMDAwMDBaFw0y\nODEyMzEyMzU5NTlaMD8xCzAJBgNVBAYTAk5MMRMwEQYDVQQKDAplTXVkaHJhIEJW\nMRswGQYDVQQDDBJFTSBEViBUTFMgQ0EgLSBHM0IwdjAQBgcqhkjOPQIBBgUrgQQA\nIgNiAATnntnASpdzxHNeUREdqFrCvV5kqzdLgOEsvCA47B5PDjuBL8mFnh5nr7BO\nL4vJf3SFumWqkR7opORbYtXMA9Vr5nizw4y03JN8okYOI4RTyJKTXSnUT5HhVga2\nPM7atGijggE1MIIBMTAfBgNVHSMEGDAWgBSgEQojPpbxB+zirynvgqV/0DCktDAd\nBgNVHQ4EFgQU4h4v5xwZOQ5VS2LBICRXNSSKJRQwDgYDVR0PAQH/BAQDAgEGMBIG\nA1UdEwEB/wQIMAYBAf8CAQAwHQYDVR0lBBYwFAYIKwYBBQUHAwEGCCsGAQUFBwMC\nMDEGA1UdIAQqMCgwCAYGZ4EMAQIBMA0GCysGAQQBg44hAQABMA0GCysGAQQBg44h\nAQJkMEMGA1UdHwQ8MDowOKA2oDSGMmh0dHA6Ly9jcmwuY29tb2RvY2EuY29tL0FB\nQUNlcnRpZmljYXRlU2VydmljZXMuY3JsMDQGCCsGAQUFBwEBBCgwJjAkBggrBgEF\nBQcwAYYYaHR0cDovL29jc3AuY29tb2RvY2EuY29tMA0GCSqGSIb3DQEBCwUAA4IB\nAQBzQ1IoqpdotM6gFrR4Q3YrKifOLeaj5XXunldpVE/vZn2TxC2y0s8oYiLR5ICE\nwqVXSB7N6BM1Mp+Nif7Jbhb2IswAfSR4Iik8+B0iRmi7lYoNlycCgHjmsjOmZaxM\nQkPmjXAJbG5f4V41OsvJZmS7lI3r0IuvEDVX3GAZ13on6IImSs9ybNpHQ7NCqkMQ\n7wH4uAO1dtpFycgJSdHaCOhHjrox2BTYGLdpUrYwY3EOAjlZ8UGgsxs7mG+xquMl\nbN1LFURwfnkVMP3T/DBIZ/ys2+lNFyxK3FlArrV4/000uTnVB1lO8oGORYdXoT5V\nGUyRCEIDhF5TBUuKF4E3kf1h\n-----END CERTIFICATE-----\n",
|
||||
"4b2460a466f95fd40113bcdcca2ceb29b3b093921de45474da7df48874c630b1": "-----BEGIN CERTIFICATE-----\nMIIDWzCCAuGgAwIBAgIPVEt/kiwxriIKSpIpFvbmMAoGCCqGSM49BAMDMFUxCzAJ\nBgNVBAYTAklOMSUwIwYDVQQKDBxlTXVkaHJhIFRlY2hub2xvZ2llcyBMaW1pdGVk\nMR8wHQYDVQQDDBZlbVNpZ24gUm9vdCBDUyBDQSAtIEczMB4XDTI1MDQzMDE2Mzg0\nMFoXDTQwMDQyODE4MzAwMFowPzELMAkGA1UEBhMCVVMxFDASBgNVBAoMC2VNdWRo\ncmEgSW5jMRowGAYDVQQDDBFFTSBPViBDUyBDQSAtIEczQzB2MBAGByqGSM49AgEG\nBSuBBAAiA2IABLnw4GNHAqacpqrdRDz9YsQbm6SyCSRpfXl052kaEm+5avs+NML9\nO8X0gwYfJl3CpYQ2NHMjUQQ2so8E84YjYwZ5gOJQPgJTLWVGI1pxZaJZoe9Jb3Qb\nX97neHII5Lj676OCAYswggGHMB8GA1UdIwQYMBaAFGh0A/iFyo1UUFcdFuDI3kUN\nq80tMB0GA1UdDgQWBBTub3MpjW+/p7Mbw5U09hVWRHhmtzATBgNVHSUEDDAKBggr\nBgEFBQcDAzBeBgNVHSAEVzBVMDkGCysGAQQBg44hAQABMCowKAYIKwYBBQUHAgEW\nHGh0dHA6Ly9yZXBvc2l0b3J5LmVtc2lnbi5jb20wDgYMKwYBBAGDjiEBAoFIMAgG\nBmeBDAEEATB0BggrBgEFBQcBAQRoMGYwJAYIKwYBBQUHMAGGGGh0dHA6Ly9vY3Nw\nLWMuZW1zaWduLmNvbTA+BggrBgEFBQcwAoYyaHR0cDovL3JlcG9zaXRvcnkuZW1z\naWduLmNvbS9jZXJ0cy9FTU9WQ1NDQUczQy5wN2MwNgYDVR0fBC8wLTAroCmgJ4Yl\naHR0cDovL2NybC5lbXNpZ24uY29tLz9yb290Y3NjYWczLmNybDAOBgNVHQ8BAf8E\nBAMCAQYwEgYDVR0TAQH/BAgwBgEB/wIBADAKBggqhkjOPQQDAwNoADBlAjEA8yPg\n8sntOhLwV2QXJ5LEaKSpo/fnE3uOgTadK73fDhyjiqr0Io9GVuQTYqBPbQ2TAjBg\neuApmBS486+iei03lRrp139dPw6k28jjGNvWsAU7A1N86J1zPJVDEyVH+nMAWC0=\n-----END CERTIFICATE-----\n",
|
||||
"5510ecf5764dee8dfe9468ae2d7323da21fcd5530889906540a2f8725c6f74db": "-----BEGIN CERTIFICATE-----\nMIICSTCCAdCgAwIBAgIUO7oRaIOhMWD0jFgvxusAGNi5CI0wCgYIKoZIzj0EAwIw\nPzELMAkGA1UEBhMCTkwxEzARBgNVBAoMCmVNdWRocmEgQlYxGzAZBgNVBAMMEkVN\nIERWIFRMUyBDQSAtIEczQjAeFw0yNjEwMjIxMzE0NDhaFw0yNzA1MDUxMzE5NDha\nMDgxNjA0BgNVBAMMLWR2LWczYi1hYWEtbm9jdC1lbXVkaHJhLndlLWhhdmUtYS1w\ncm9ibGVtLmNvbTBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABJ0yLMf28vw2E6pO\nc74doinU47pwTYD6xZ60nkrGkqI05DxVKXblos+V5S0+bgDjT2qw1F39EW9EUj8b\nNQmmqv6jgbAwga0wDAYDVR0TAQH/BAIwADAOBgNVHQ8BAf8EBAMCB4AwEwYDVR0l\nBAwwCgYIKwYBBQUHAwEwOAYDVR0RBDEwL4ItZHYtZzNiLWFhYS1ub2N0LWVtdWRo\ncmEud2UtaGF2ZS1hLXByb2JsZW0uY29tMB0GA1UdDgQWBBTruPPaZ1PiEFfpqdLo\n8Ra5Ah5a2jAfBgNVHSMEGDAWgBTiHi/nHBk5DlVLYsEgJFc1JIolFDAKBggqhkjO\nPQQDAgNnADBkAjBUTUTymJylZiZRV/RqMayZdUPJKRDIcn9+sUPpG66AjVgx8M+h\nDGzxxgeroOSxzSwCMBM/r1y7g371RI10nDU16T0WEAO/Udqyyuc8mJ2LCQ+Gxv3c\njU2NXGQLsvosv6T0ww==\n-----END CERTIFICATE-----\n",
|
||||
"579480aea166290bd3c643589e795fc0d0e54e86505d180f1dff99efd3199512": "-----BEGIN CERTIFICATE-----\nMIIDzTCCArWgAwIBAgIRANj4aF5PK9nYJAKoWhwetzEwDQYJKoZIhvcNAQELBQAw\nezELMAkGA1UEBhMCR0IxGzAZBgNVBAgMEkdyZWF0ZXIgTWFuY2hlc3RlcjEQMA4G\nA1UEBwwHU2FsZm9yZDEaMBgGA1UECgwRQ29tb2RvIENBIExpbWl0ZWQxITAfBgNV\nBAMMGEFBQSBDZXJ0aWZpY2F0ZSBTZXJ2aWNlczAeFw0yNTA1MTQwMDAwMDBaFw0y\nODEyMzEyMzU5NTlaMD8xCzAJBgNVBAYTAk5MMRMwEQYDVQQKDAplTXVkaHJhIEJW\nMRswGQYDVQQDDBJFTSBFViBUTFMgQ0EgLSBHM0IwdjAQBgcqhkjOPQIBBgUrgQQA\nIgNiAAQl26haZNOX2gsQUunqVG5Ie+/qnJ2daKDF6tz+73Wmm7XW/AH0K/vKbrQL\nzWTAdn42/9Bdl6gTxaoMbJUw55z18IZkaPE4BkIKpchRhAseJSVA55DktlTDhS7W\nSvHoZX2jggE0MIIBMDAfBgNVHSMEGDAWgBSgEQojPpbxB+zirynvgqV/0DCktDAd\nBgNVHQ4EFgQUPtwg2YlGmId7hz/YgUeyXBXYRwYwDgYDVR0PAQH/BAQDAgEGMBIG\nA1UdEwEB/wQIMAYBAf8CAQAwHQYDVR0lBBYwFAYIKwYBBQUHAwEGCCsGAQUFBwMC\nMDAGA1UdIAQpMCcwBwYFZ4EMAQEwDQYLKwYBBAGDjiEBAAEwDQYLKwYBBAGDjiEB\nAngwQwYDVR0fBDwwOjA4oDagNIYyaHR0cDovL2NybC5jb21vZG9jYS5jb20vQUFB\nQ2VydGlmaWNhdGVTZXJ2aWNlcy5jcmwwNAYIKwYBBQUHAQEEKDAmMCQGCCsGAQUF\nBzABhhhodHRwOi8vb2NzcC5jb21vZG9jYS5jb20wDQYJKoZIhvcNAQELBQADggEB\nAJNFuAFW9Gnw/8+kCO4KIlZdnM1Sh2u+8cK0EYAHhXpb5FlGvYkcARigo5oS7VoM\n7FOYlTueV6yICZKNAYawZPJPhNCGK+rp3cQBEz6d0n8OZEYZov9lb3ll8ORgSDuf\nFVIW6Gagg/UnOamLBTOR8eD5V73SCme/B2cHUDw+m5E8cc+0Ujzz3Cn3ex5JrhXk\n2T3s8PoVzEmhTgGboFfcWO1EYhPPrYwdxPYRQIXAXUXhLEOVNL9ASbZJLFI8E1IM\nm7NwxVN2s38jWpaCUaaF0tg7hfiy7oe13rzJfHsdqldJVTTUTPWVx8TC4ju0VLet\nbAlwhwFT6AP8uIVMGUHNw24=\n-----END CERTIFICATE-----\n",
|
||||
"59cf0386b56bc2414a0c06241ea9e25e01a9c93bac7a596648747f9d0ded4906": "-----BEGIN CERTIFICATE-----\nMIICQTCCAcegAwIBAgIULuWnWW6kFV6QZxp2ls8r18NCjXkwCgYIKoZIzj0EAwIw\nQDELMAkGA1UEBhMCVVMxFDASBgNVBAoMC2VNdWRocmEgSW5jMRswGQYDVQQDDBJF\nTSBPViBUTFMgQ0EgLSBHM0MwHhcNMjYxMDIwMDIyMjQyWhcNMjcwNTAzMDIyNzQy\nWjAzMTEwLwYDVQQDDChvdi1nM2MtYWFhLWVtdWRocmEud2UtaGF2ZS1hLXByb2Js\nZW0uY29tMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEb7+5g2YHNYwH2eQERrZc\nTmVuf/RmvzsGGMSwXmZke6vn35Ut5HME7UOOU3Muy+BSfjfpo6fjL5KOs3Ndz67d\nOKOBqzCBqDAMBgNVHRMBAf8EAjAAMA4GA1UdDwEB/wQEAwIHgDATBgNVHSUEDDAK\nBggrBgEFBQcDATAzBgNVHREELDAqgihvdi1nM2MtYWFhLWVtdWRocmEud2UtaGF2\nZS1hLXByb2JsZW0uY29tMB0GA1UdDgQWBBQ7oBzq3jpTuvntbIkzMV12Ps3O0TAf\nBgNVHSMEGDAWgBQ06uoM68kK2vnQxeLtm3jJh54bbDAKBggqhkjOPQQDAgNoADBl\nAjEA/Y7AZ4M2MYbN5Od9tREaUfFjZKoOjczfIKPjKNMjtGxTdL9WUXvAT4QDgktn\n/IJdAjAbORTkAXD5hggyG6K7p0MBz3lFZJb2l/LndZlrttJWBLP8QGrc44hrZZtK\nIq0d1dE=\n-----END CERTIFICATE-----\n",
|
||||
"5f1ded4dd590af74631290cf073b122a2aac6a1fa479778a0bd43579bd832699": "-----BEGIN CERTIFICATE-----\nMIIDzjCCAragAwIBAgIRAOlCI73pL5FOWrmsq/2sNxAwDQYJKoZIhvcNAQELBQAw\nezELMAkGA1UEBhMCR0IxGzAZBgNVBAgMEkdyZWF0ZXIgTWFuY2hlc3RlcjEQMA4G\nA1UEBwwHU2FsZm9yZDEaMBgGA1UECgwRQ29tb2RvIENBIExpbWl0ZWQxITAfBgNV\nBAMMGEFBQSBDZXJ0aWZpY2F0ZSBTZXJ2aWNlczAeFw0yNTA1MTQwMDAwMDBaFw0y\nODEyMzEyMzU5NTlaMD8xCzAJBgNVBAYTAk5MMRMwEQYDVQQKDAplTXVkaHJhIEJW\nMRswGQYDVQQDDBJFTSBPViBUTFMgQ0EgLSBHM0IwdjAQBgcqhkjOPQIBBgUrgQQA\nIgNiAARziXqg6zz9yosSQC/68KvE4SErr43ud4rJ7i1tuD+sKAxeBWOK56Ks1xwy\nKo42HN659pxDArndjXPRzGyqsswK7k4hbOl53VGKSvS2JuVoTN1lgDftDIVOTMEv\nTPhkbQ2jggE1MIIBMTAfBgNVHSMEGDAWgBSgEQojPpbxB+zirynvgqV/0DCktDAd\nBgNVHQ4EFgQUm9hlUa/n5yw3rQjuaRnZvzeErgowDgYDVR0PAQH/BAQDAgEGMBIG\nA1UdEwEB/wQIMAYBAf8CAQAwHQYDVR0lBBYwFAYIKwYBBQUHAwEGCCsGAQUFBwMC\nMDEGA1UdIAQqMCgwCAYGZ4EMAQICMA0GCysGAQQBg44hAQABMA0GCysGAQQBg44h\nAQJuMEMGA1UdHwQ8MDowOKA2oDSGMmh0dHA6Ly9jcmwuY29tb2RvY2EuY29tL0FB\nQUNlcnRpZmljYXRlU2VydmljZXMuY3JsMDQGCCsGAQUFBwEBBCgwJjAkBggrBgEF\nBQcwAYYYaHR0cDovL29jc3AuY29tb2RvY2EuY29tMA0GCSqGSIb3DQEBCwUAA4IB\nAQALRoOSvkzRa+tN5Rje9W5GiXd/qsicd9N6Gnh0SWC7VyWH+HycIqAnFCMYZznj\nCeGusomFYHE42SHWr85JDIbgzy6y3dO4i9n9w0Bx7yrXWBbDzFS8VgA3+EY4Cg7y\nmtoC3RPOJVtaXXPZ+68X804g1Mj+3vCSHj2lNPVgco0OzSIOJ/KMg1R9MLBkiKzb\nbERAGZHejY0HBhYd76IINZN8z6bBNah2UdFYxsOisLynPQUkol6gRLum024/FzVl\nCCDfnUusrkr+mAfPAvD/Vb8bhpA7jYtepuU+2mIdShj5lLlvAwZphn+ypWycuo2x\n0k9mldz2dqI9nhCiY+TTIOOR\n-----END CERTIFICATE-----\n",
|
||||
"62b7f9868263f72f6911fc78ceaf72c1979158b7368bf648683bb2aa39b02e72": "-----BEGIN CERTIFICATE-----\nMIIDzzCCAregAwIBAgIRAJHzR/Glt9OZYrleDuTimdEwDQYJKoZIhvcNAQELBQAw\nezELMAkGA1UEBhMCR0IxGzAZBgNVBAgMEkdyZWF0ZXIgTWFuY2hlc3RlcjEQMA4G\nA1UEBwwHU2FsZm9yZDEaMBgGA1UECgwRQ29tb2RvIENBIExpbWl0ZWQxITAfBgNV\nBAMMGEFBQSBDZXJ0aWZpY2F0ZSBTZXJ2aWNlczAeFw0yNTA1MTQwMDAwMDBaFw0y\nODEyMzEyMzU5NTlaMEAxCzAJBgNVBAYTAlVTMRQwEgYDVQQKDAtlTXVkaHJhIElu\nYzEbMBkGA1UEAwwSRU0gRFYgVExTIENBIC0gRzNDMHYwEAYHKoZIzj0CAQYFK4EE\nACIDYgAEOnp4YsjETTJN3WkDOmeCwO/u/kBV5ooSP6A3G6O9kZRRTuFypDPjYw8C\nvRiAN9nlFJ+bd9uUooHfmHsv6oiLdL3+j9pMEJnubKGVZv4+8OX2sJpVbMyLpQVh\n3xsqRB0Bo4IBNTCCATEwHwYDVR0jBBgwFoAUoBEKIz6W8Qfs4q8p74Klf9AwpLQw\nHQYDVR0OBBYEFK5+EFw8KZbBqEcnBVnx4D3U5QrzMA4GA1UdDwEB/wQEAwIBBjAS\nBgNVHRMBAf8ECDAGAQH/AgEAMB0GA1UdJQQWMBQGCCsGAQUFBwMBBggrBgEFBQcD\nAjAxBgNVHSAEKjAoMAgGBmeBDAECATANBgsrBgEEAYOOIQEAATANBgsrBgEEAYOO\nIQECZDBDBgNVHR8EPDA6MDigNqA0hjJodHRwOi8vY3JsLmNvbW9kb2NhLmNvbS9B\nQUFDZXJ0aWZpY2F0ZVNlcnZpY2VzLmNybDA0BggrBgEFBQcBAQQoMCYwJAYIKwYB\nBQUHMAGGGGh0dHA6Ly9vY3NwLmNvbW9kb2NhLmNvbTANBgkqhkiG9w0BAQsFAAOC\nAQEAr7b6Mx11TIJgVzHt6EH5qGNWeiiqoMrp8E5/k8s+jvupx1n083pZ5XOzUzND\nwEUe0W4C4FIGUHNQ4XDwqOw0yK42xqm2dvCKgsko2UvDlT0haZVtTCeWMQOe5J/J\nziaMfZwsZNfZ3aaI6XPtWDZtwY8AaNfyaeCpbtm5GcGnyNWk4jSNpbtpo6NjfXuP\nCFOm3rTjJ4zytP7Tso6iMm0L01cYFOlTmoPv4515R2r3SPnKDtijoB/D/vgxzojv\nDafgyG9afX8+laO9NbMMz4sqC1vZx3984i/sBNFiugm3Vvdq5OdJRjuGC1QILIs6\ndg/K3i8VQqRpbfaM9kIlMzZ3dQ==\n-----END CERTIFICATE-----\n",
|
||||
"66374ae64962c437bd815a2aae546c76e30c2eafac8335192c8e517c15a246ed": "-----BEGIN CERTIFICATE-----\nMIICNzCCAb6gAwIBAgIUPfthO0PYGAYKs9lQ4gp1IOkOr2MwCgYIKoZIzj0EAwIw\nPzELMAkGA1UEBhMCTkwxEzARBgNVBAoMCmVNdWRocmEgQlYxGzAZBgNVBAMMEkVN\nIEVWIFRMUyBDQSAtIEczQjAeFw0yNjEwMjAwMDU5MzZaFw0yNzA1MDMwMTA0MzZa\nMC8xLTArBgNVBAMMJGV2LWczYi1lbXVkaHJhLndlLWhhdmUtYS1wcm9ibGVtLmNv\nbTBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABPZPwlA3io/AX/pMwu0dNl9WMxrr\nC9j/xMLHgG72cWVzoX62REmvp9zyXQ04kHP9/sW6KnCcm5ZZ5i4YoJbDPASjgacw\ngaQwDAYDVR0TAQH/BAIwADAOBgNVHQ8BAf8EBAMCB4AwEwYDVR0lBAwwCgYIKwYB\nBQUHAwEwLwYDVR0RBCgwJoIkZXYtZzNiLWVtdWRocmEud2UtaGF2ZS1hLXByb2Js\nZW0uY29tMB0GA1UdDgQWBBSvX4QR4ERW5uNUaMq0yYzXCZf0ZjAfBgNVHSMEGDAW\ngBQ+3CDZiUaYh3uHP9iBR7JcFdhHBjAKBggqhkjOPQQDAgNnADBkAjACy/uJR01M\n/O9IEZyRHX1LSU28bmg542K0IX9jDRTrZXUqkDWfiZIL7xfnrRrBpHoCMFbBIaOa\nWwWtFtDg9ZqQvktsWS950u5qfW1sChgWNya/E3IMKFgtoHm2xrZsmfwLSQ==\n-----END CERTIFICATE-----\n",
|
||||
"7485b51c0ed192878fa1d2f948fb529ae1842cc5086c4b67f7be5b2039de9489": "-----BEGIN CERTIFICATE-----\nMIIDzzCCAregAwIBAgIRAI1aSBGTnvRH13ERzdmEmukwDQYJKoZIhvcNAQELBQAw\nezELMAkGA1UEBhMCR0IxGzAZBgNVBAgMEkdyZWF0ZXIgTWFuY2hlc3RlcjEQMA4G\nA1UEBwwHU2FsZm9yZDEaMBgGA1UECgwRQ29tb2RvIENBIExpbWl0ZWQxITAfBgNV\nBAMMGEFBQSBDZXJ0aWZpY2F0ZSBTZXJ2aWNlczAeFw0yNTA1MTQwMDAwMDBaFw0y\nODEyMzEyMzU5NTlaMEAxCzAJBgNVBAYTAlVTMRQwEgYDVQQKDAtlTXVkaHJhIElu\nYzEbMBkGA1UEAwwSRU0gT1YgVExTIENBIC0gRzNDMHYwEAYHKoZIzj0CAQYFK4EE\nACIDYgAEE4JRzVKskpjByKrZdzId65fnCb0LTKCspV3IrVHc/J0ViaFZfjpRIOHv\n1jHGPhg1ysrimGmmLhYx6KKBgatWYW3EXZGKvAnzqw5jz3kqpNztc4e+N7ulaVSf\nHAKycO1no4IBNTCCATEwHwYDVR0jBBgwFoAUoBEKIz6W8Qfs4q8p74Klf9AwpLQw\nHQYDVR0OBBYEFDTq6gzryQra+dDF4u2beMmHnhtsMA4GA1UdDwEB/wQEAwIBBjAS\nBgNVHRMBAf8ECDAGAQH/AgEAMB0GA1UdJQQWMBQGCCsGAQUFBwMBBggrBgEFBQcD\nAjAxBgNVHSAEKjAoMAgGBmeBDAECAjANBgsrBgEEAYOOIQEAATANBgsrBgEEAYOO\nIQECbjBDBgNVHR8EPDA6MDigNqA0hjJodHRwOi8vY3JsLmNvbW9kb2NhLmNvbS9B\nQUFDZXJ0aWZpY2F0ZVNlcnZpY2VzLmNybDA0BggrBgEFBQcBAQQoMCYwJAYIKwYB\nBQUHMAGGGGh0dHA6Ly9vY3NwLmNvbW9kb2NhLmNvbTANBgkqhkiG9w0BAQsFAAOC\nAQEAHlbhZpjhkPEAQVhPHRtBDaHqiisVD20pQ1TUeSx3l62Qe7NQ0b7vWQy7DN0i\nKOu7BGpP5EnIjvXbz9FhpfPJ4FhKEt3tQ51rqXz2oD8vRaryvExWPobDUz3j7HN6\nOzBsVMIe3PD7AAkpky0x8zMSi/ep5R9OMzybmNptbtppyuB5ytgq+h7lQdRIP+CQ\nTaJX3qTgfMqzntYre5GljmEB5hU2/rSn8zoSU2APkJidlZFpghtgbD4I2VRHZvCw\nFFr/SrFM9pwrgbJNZ1MtyTSVgI5NlNb0aMfLR+/r6jJt3ROeY2yh2QQ2dx+Yqgdb\nuL5ChFWOQU+ErSDnUq7yr+pjBQ==\n-----END CERTIFICATE-----\n",
|
||||
"7dd78d5f4f13459a83dff9abbba62edbaf6f2d102bf257fd712f4d9f2746ed8d": "-----BEGIN CERTIFICATE-----\nMIICKTCCAa+gAwIBAgIQDnYGcvFDRZ/I/gqwvAXjlDAKBggqhkjOPQQDAzBWMQsw\nCQYDVQQGEwJJTjElMCMGA1UECgwcZU11ZGhyYSBUZWNobm9sb2dpZXMgTGltaXRl\nZDEgMB4GA1UEAwwXZW1TaWduIFJvb3QgVExTIENBIC0gRzMwHhcNMjQwMjA4MDgx\nOTMyWhcNNDkwMjA3MDgxOTMyWjBWMQswCQYDVQQGEwJJTjElMCMGA1UECgwcZU11\nZGhyYSBUZWNobm9sb2dpZXMgTGltaXRlZDEgMB4GA1UEAwwXZW1TaWduIFJvb3Qg\nVExTIENBIC0gRzMwdjAQBgcqhkjOPQIBBgUrgQQAIgNiAARfD9w/q1cWxL6D9bMG\nH6CQ4u+gAV6GN9s+PwQx9Bl4hC+0iU8QOk8mWO/RwXQy10DCSxT8vSIJClHKp+Ao\n4qJKdYeV0SwZ7LMQziyLfxIUOY6Grja/sQFwLKYf8EdDXAyjQjBAMB0GA1UdDgQW\nBBThOqshfPHjTVSu1HK3/YtZQiCQVDAOBgNVHQ8BAf8EBAMCAQYwDwYDVR0TAQH/\nBAUwAwEB/zAKBggqhkjOPQQDAwNoADBlAjBhCFTkly7Yl7+s4hiw+k6ZJTXT2AiI\nrvmsmfqPr5E7ecwXb6kXA5LTFd6rjwTuV3sCMQC9w6ZSbVq1mveMO6dKFziOzClT\nKCT2RFT6yXtq6nzgRICgBmiXbJAXsmZ8yAwjyWc=\n-----END CERTIFICATE-----\n",
|
||||
"7e02622ab6d745c1edb4b431bb1c551e667305c3dd3eb0c7cc6dfdb523807424": "-----BEGIN CERTIFICATE-----\nMIICQDCCAcagAwIBAgIUU3mgjls/8ZJ8upmukdlSQR1IF/UwCgYIKoZIzj0EAwIw\nPzELMAkGA1UEBhMCTkwxEzARBgNVBAoMCmVNdWRocmEgQlYxGzAZBgNVBAMMEkVN\nIERWIFRMUyBDQSAtIEczQjAeFw0yNjEwMjAwMjIyNDFaFw0yNzA1MDMwMjI3NDFa\nMDMxMTAvBgNVBAMMKGR2LWczYi1hYWEtZW11ZGhyYS53ZS1oYXZlLWEtcHJvYmxl\nbS5jb20wWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAATK3sfcOxxtM2Vdq9Qbw/GR\nNsHlJcnqINEInXO3fkWhw/FYT80hkqGk0jSN/EKib3YZJsr5GyfkYcpEiA2rwMgm\no4GrMIGoMAwGA1UdEwEB/wQCMAAwDgYDVR0PAQH/BAQDAgeAMBMGA1UdJQQMMAoG\nCCsGAQUFBwMBMDMGA1UdEQQsMCqCKGR2LWczYi1hYWEtZW11ZGhyYS53ZS1oYXZl\nLWEtcHJvYmxlbS5jb20wHQYDVR0OBBYEFDggfJFz1eeToKmzFBSYkMCvpAu0MB8G\nA1UdIwQYMBaAFOIeL+ccGTkOVUtiwSAkVzUkiiUUMAoGCCqGSM49BAMCA2gAMGUC\nMDqDDOt7K313QIH0rhn/ZKnzFu8+/9W8OlTuFtDfVCTRDfRmqdyfsCfLsEk7xqHq\nuQIxAJqyLERABmU98U46OCuT5bp5h0ytuSEIGM7orTpKci5S1u1B/AOmwv162U7R\nTcVPHA==\n-----END CERTIFICATE-----\n",
|
||||
"84112972b8e59d14560ae7e7119654474aad119990cf3722bfc1e3b91615e1fd": "-----BEGIN CERTIFICATE-----\nMIIDxTCCAq2gAwIBAgIRANal72E7OQ8y75zfNJ93KEEwDQYJKoZIhvcNAQELBQAw\nezELMAkGA1UEBhMCR0IxGzAZBgNVBAgMEkdyZWF0ZXIgTWFuY2hlc3RlcjEQMA4G\nA1UEBwwHU2FsZm9yZDEaMBgGA1UECgwRQ29tb2RvIENBIExpbWl0ZWQxITAfBgNV\nBAMMGEFBQSBDZXJ0aWZpY2F0ZSBTZXJ2aWNlczAeFw0yNTA1MTQwMDAwMDBaFw0y\nODEyMzEyMzU5NTlaMD8xCzAJBgNVBAYTAlVTMRQwEgYDVQQKDAtlTXVkaHJhIElu\nYzEaMBgGA1UEAwwRRU0gT1YgQ1MgQ0EgLSBHM0MwdjAQBgcqhkjOPQIBBgUrgQQA\nIgNiAAS58OBjRwKmnKaq3UQ8/WLEG5uksgkkaX15dOdpGhJvuWr7PjTC/TvF9IMG\nHyZdwqWENjRzI1EENrKPBPOGI2MGeYDiUD4CUy1lRiNacWWiWaHvSW90G1/e53hy\nCOS4+u+jggEsMIIBKDAfBgNVHSMEGDAWgBSgEQojPpbxB+zirynvgqV/0DCktDAd\nBgNVHQ4EFgQU7m9zKY1vv6ezG8OVNPYVVkR4ZrcwDgYDVR0PAQH/BAQDAgEGMBIG\nA1UdEwEB/wQIMAYBAf8CAQAwEwYDVR0lBAwwCgYIKwYBBQUHAwMwMgYDVR0gBCsw\nKTAIBgZngQwBBAEwDQYLKwYBBAGDjiEBAAEwDgYMKwYBBAGDjiEBAoFIMEMGA1Ud\nHwQ8MDowOKA2oDSGMmh0dHA6Ly9jcmwuY29tb2RvY2EuY29tL0FBQUNlcnRpZmlj\nYXRlU2VydmljZXMuY3JsMDQGCCsGAQUFBwEBBCgwJjAkBggrBgEFBQcwAYYYaHR0\ncDovL29jc3AuY29tb2RvY2EuY29tMA0GCSqGSIb3DQEBCwUAA4IBAQBgGbZ+NDkn\n2Fr5dCVs/51+6cT5iEPP1stglX4TI2UaNh9LmPdltxXw3crV8gK5L8i+d+ScGibF\ny0obtBHNTldXDgLnAHIzY3n+pceyKj7O2ae9CYnyuu5RBifeI3XYKowWE+oXnVGF\nOy0CvCJ/JsdP6aYgoCa9hGnAYicYQQL6MOMQZ5PuhcSM8t4giIzrpgrEkUP93GYE\nemDG5Z8BYwKs80lLeQNBnwg/VHJ3iEF/o84RXYwrlrKqJ+33C094o6LNikMJCqOK\n/WYHZSR8Xyhf8GXFAj5pZuI6Bcpug2V/E7i42j8OBhc25aNYDqK+B0qmb90sjNUB\ng3tJeSIb/599\n-----END CERTIFICATE-----\n",
|
||||
"86a1ecba089c4a8d3bbe2734c612ba341d813e043cf9e8a862cd5c57a36bbe6b": "-----BEGIN CERTIFICATE-----\nMIICTjCCAdOgAwIBAgIKPPYHqWhwDtqLhDAKBggqhkjOPQQDAzBrMQswCQYDVQQG\nEwJJTjETMBEGA1UECxMKZW1TaWduIFBLSTElMCMGA1UEChMcZU11ZGhyYSBUZWNo\nbm9sb2dpZXMgTGltaXRlZDEgMB4GA1UEAxMXZW1TaWduIEVDQyBSb290IENBIC0g\nRzMwHhcNMTgwMjE4MTgzMDAwWhcNNDMwMjE4MTgzMDAwWjBrMQswCQYDVQQGEwJJ\nTjETMBEGA1UECxMKZW1TaWduIFBLSTElMCMGA1UEChMcZU11ZGhyYSBUZWNobm9s\nb2dpZXMgTGltaXRlZDEgMB4GA1UEAxMXZW1TaWduIEVDQyBSb290IENBIC0gRzMw\ndjAQBgcqhkjOPQIBBgUrgQQAIgNiAAQjpQy4LRL1KPOxst3iAhKAnjlfSU2fySU0\nWXTsuwYc58Byr+iuL+FBVIcUqEqy6HyC5ltqtdyzdc6LBtCGI79G1Y4PPwT01xyS\nfvalY8L1X44uT6EYGQIrMgqCZH0Wk9GjQjBAMB0GA1UdDgQWBBR8XQKEE9TMipuB\nzhccLikenEhjQjAOBgNVHQ8BAf8EBAMCAQYwDwYDVR0TAQH/BAUwAwEB/zAKBggq\nhkjOPQQDAwNpADBmAjEAvvNhzwIQHWSVB7gYboiFBS+DCBeQyh+KTOgNG3qxrdWB\nCUfvO6wIBHxcmbHtRwfSAjEAnbpV/KlK6O3t5nYBQnvI+GDZjVGLVTv7jHvrZQnD\n+JbNR6iC8hZVdyR+EhCVBCyj\n-----END CERTIFICATE-----\n",
|
||||
"9200d153c3cac272cc06bf68f9ea163fc24659fc3331e77668236fe00645ae52": "-----BEGIN CERTIFICATE-----\nMIICQjCCAcegAwIBAgIUMH3z62Z6agEULBZqrfhpw9/ZpCAwCgYIKoZIzj0EAwIw\nQDELMAkGA1UEBhMCVVMxFDASBgNVBAoMC2VNdWRocmEgSW5jMRswGQYDVQQDDBJF\nTSBEViBUTFMgQ0EgLSBHM0MwHhcNMjYxMDIwMDIyMjQxWhcNMjcwNTAzMDIyNzQx\nWjAzMTEwLwYDVQQDDChkdi1nM2MtYWFhLWVtdWRocmEud2UtaGF2ZS1hLXByb2Js\nZW0uY29tMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEAMPpHsQqEqIerrj/durO\nEN7gkUVJSVgIPN6+VJ2sEGXDyqXAN1fnNzGuASP/4WEr0RIhht3cYvuUYp2SYofr\nw6OBqzCBqDAMBgNVHRMBAf8EAjAAMA4GA1UdDwEB/wQEAwIHgDATBgNVHSUEDDAK\nBggrBgEFBQcDATAzBgNVHREELDAqgihkdi1nM2MtYWFhLWVtdWRocmEud2UtaGF2\nZS1hLXByb2JsZW0uY29tMB0GA1UdDgQWBBT8CNQz4OLVnIwrQYZBF+++9jTCvzAf\nBgNVHSMEGDAWgBSufhBcPCmWwahHJwVZ8eA91OUK8zAKBggqhkjOPQQDAgNpADBm\nAjEAp3ZLdfBvKcZxgR2p8LZaxWzP6uB7zU/EZ9BA49Ds2P5ByYkRodwK5qcwt4J/\nVL/vAjEAnnxrcrDX7SzoJxuho604GBh6eyBLJLCdmcy+qb+LA3u5oxP/OjAzCA4x\nv1kw6Tm4\n-----END CERTIFICATE-----\n",
|
||||
"98b896625c0c230adeec3eb2cf6aad5001ecf9156a68644e82374ce5833bc25b": "-----BEGIN CERTIFICATE-----\nMIIDZjCCAu2gAwIBAgIPbK2jZ/scEACIEg6iAtVoMAoGCCqGSM49BAMDMFYxCzAJ\nBgNVBAYTAklOMSUwIwYDVQQKDBxlTXVkaHJhIFRlY2hub2xvZ2llcyBMaW1pdGVk\nMSAwHgYDVQQDDBdlbVNpZ24gUm9vdCBUTFMgQ0EgLSBHMzAeFw0yNTA0MzAxNjAw\nMTJaFw00MDA0MjgxODMwMDBaMD8xCzAJBgNVBAYTAk5MMRMwEQYDVQQKDAplTXVk\naHJhIEJWMRswGQYDVQQDDBJFTSBPViBUTFMgQ0EgLSBHM0IwdjAQBgcqhkjOPQIB\nBgUrgQQAIgNiAARziXqg6zz9yosSQC/68KvE4SErr43ud4rJ7i1tuD+sKAxeBWOK\n56Ks1xwyKo42HN659pxDArndjXPRzGyqsswK7k4hbOl53VGKSvS2JuVoTN1lgDft\nDIVOTMEvTPhkbQ2jggGWMIIBkjAfBgNVHSMEGDAWgBThOqshfPHjTVSu1HK3/YtZ\nQiCQVDAdBgNVHQ4EFgQUm9hlUa/n5yw3rQjuaRnZvzeErgowHQYDVR0lBBYwFAYI\nKwYBBQUHAwEGCCsGAQUFBwMCMF0GA1UdIARWMFQwOQYLKwYBBAGDjiEBAAEwKjAo\nBggrBgEFBQcCARYcaHR0cDovL3JlcG9zaXRvcnkuZW1zaWduLmNvbTANBgsrBgEE\nAYOOIQECbjAIBgZngQwBAgIwdQYIKwYBBQUHAQEEaTBnMCQGCCsGAQUFBzABhhho\ndHRwOi8vb2NzcC1iLmVtc2lnbi5jb20wPwYIKwYBBQUHMAKGM2h0dHA6Ly9yZXBv\nc2l0b3J5LmVtc2lnbi5jb20vY2VydHMvRU1PVlRMU0NBRzNCLnA3YzA3BgNVHR8E\nMDAuMCygKqAohiZodHRwOi8vY3JsLmVtc2lnbi5jb20vP3Jvb3R0bHNjYWczLmNy\nbDAOBgNVHQ8BAf8EBAMCAQYwEgYDVR0TAQH/BAgwBgEB/wIBADAKBggqhkjOPQQD\nAwNnADBkAjBAk+5BizdJhrED3wb7F2Gm7bWI60dlNd075nghT2TrR4NWKZtJZcbc\n28xxIY/NONICMF6mPdsjosaqmvEMWqKcBh4vsIvXSKnQj5o8zrFB4oKS+accjYTe\n8Dz3bQjauvy9uw==\n-----END CERTIFICATE-----\n",
|
||||
"9dcb21b1841d75313b3c888f4bb9cc01f8f51379a76fbaa241ca492b668839e7": "-----BEGIN CERTIFICATE-----\nMIICOTCCAb6gAwIBAgIUY1R/PHrtXShV0uhLJ5EyjsHvMi0wCgYIKoZIzj0EAwIw\nPzELMAkGA1UEBhMCTkwxEzARBgNVBAoMCmVNdWRocmEgQlYxGzAZBgNVBAMMEkVN\nIERWIFRMUyBDQSAtIEczQjAeFw0yNjEwMjAwMDU5MzZaFw0yNzA1MDMwMTA0MzZa\nMC8xLTArBgNVBAMMJGR2LWczYi1lbXVkaHJhLndlLWhhdmUtYS1wcm9ibGVtLmNv\nbTBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABFpE24ACBFHQLzpg+7D767thJ3+Q\nNIoJJugUMT/OftEz6G6uDdxlS+9VfXYBDglHjJirjEPFNglOGJoQNQuiz7Gjgacw\ngaQwDAYDVR0TAQH/BAIwADAOBgNVHQ8BAf8EBAMCB4AwEwYDVR0lBAwwCgYIKwYB\nBQUHAwEwLwYDVR0RBCgwJoIkZHYtZzNiLWVtdWRocmEud2UtaGF2ZS1hLXByb2Js\nZW0uY29tMB0GA1UdDgQWBBT4r5v4etYNuG21BqBqs273OkcHiDAfBgNVHSMEGDAW\ngBTiHi/nHBk5DlVLYsEgJFc1JIolFDAKBggqhkjOPQQDAgNpADBmAjEAlxNF+/8l\nAPpgibawACy15yn5bcvTksz2Zw0R9z9nUK9e3MSSk+hXagg7LVb7V4NtAjEA4qyR\nlaFPkaokrnTjqQieq8CgDQ6tIz3FjBvj3D7vkhKXHaml6zD2zT5ih2Y+lD2h\n-----END CERTIFICATE-----\n",
|
||||
"9e39de2881273acc225a149c72ba3e0f51424963669ab633ee1dc7df5db067b4": "-----BEGIN CERTIFICATE-----\nMIIDWzCCAuCgAwIBAgIPeBiTP4+FtGGaKzX7qD0KMAoGCCqGSM49BAMDMFUxCzAJ\nBgNVBAYTAklOMSUwIwYDVQQKDBxlTXVkaHJhIFRlY2hub2xvZ2llcyBMaW1pdGVk\nMR8wHQYDVQQDDBZlbVNpZ24gUm9vdCBDUyBDQSAtIEczMB4XDTI1MDQzMDE2MDgy\nN1oXDTQwMDQyODE4MzAwMFowPjELMAkGA1UEBhMCTkwxEzARBgNVBAoMCmVNdWRo\ncmEgQlYxGjAYBgNVBAMMEUVNIE9WIENTIENBIC0gRzNCMHYwEAYHKoZIzj0CAQYF\nK4EEACIDYgAEqrsPsGZqz88B+tUr/P1MK/LLPDSX3nOVDlgZth/mlQmHQcjEkUOb\nNKBDHK/+W/m0wcjorgAB+fTcitgsWfNjnpbNbD1NsM7+wmw9YlF7N3YTcnQ/jord\nd7EAZPKr5eBQo4IBizCCAYcwHwYDVR0jBBgwFoAUaHQD+IXKjVRQVx0W4MjeRQ2r\nzS0wHQYDVR0OBBYEFHn66OzP2w11jaRY1p+27dx8Sd7mMBMGA1UdJQQMMAoGCCsG\nAQUFBwMDMF4GA1UdIARXMFUwOQYLKwYBBAGDjiEBAAEwKjAoBggrBgEFBQcCARYc\naHR0cDovL3JlcG9zaXRvcnkuZW1zaWduLmNvbTAOBgwrBgEEAYOOIQECgUgwCAYG\nZ4EMAQQBMHQGCCsGAQUFBwEBBGgwZjAkBggrBgEFBQcwAYYYaHR0cDovL29jc3At\nYi5lbXNpZ24uY29tMD4GCCsGAQUFBzAChjJodHRwOi8vcmVwb3NpdG9yeS5lbXNp\nZ24uY29tL2NlcnRzL0VNT1ZDU0NBRzNCLnA3YzA2BgNVHR8ELzAtMCugKaAnhiVo\ndHRwOi8vY3JsLmVtc2lnbi5jb20vP3Jvb3Rjc2NhZzMuY3JsMA4GA1UdDwEB/wQE\nAwIBBjASBgNVHRMBAf8ECDAGAQH/AgEAMAoGCCqGSM49BAMDA2kAMGYCMQC/zr03\nYEELsNKqzUtH2dVFi4BaKFdyrr/6G1dIWp8KAVgsw2/u4lyyboUDaC9jDvICMQC9\nHTxku84tNX9dmHOtH7aC2Nu/JM0jTf6KjZAvovCJzzv1IFWSZpVLZibynMLXOrM=\n-----END CERTIFICATE-----\n",
|
||||
"a496e23ac0cab8155fc9b117b97eae368efcbb9041aec8804d5a308d2d01c2d8": "-----BEGIN CERTIFICATE-----\nMIIDwzCCAqugAwIBAgIQQeMrc6wYXWzg10gLrPhOaTANBgkqhkiG9w0BAQsFADB7\nMQswCQYDVQQGEwJHQjEbMBkGA1UECAwSR3JlYXRlciBNYW5jaGVzdGVyMRAwDgYD\nVQQHDAdTYWxmb3JkMRowGAYDVQQKDBFDb21vZG8gQ0EgTGltaXRlZDEhMB8GA1UE\nAwwYQUFBIENlcnRpZmljYXRlIFNlcnZpY2VzMB4XDTI1MDUxNDAwMDAwMFoXDTI4\nMTIzMTIzNTk1OVowPjELMAkGA1UEBhMCTkwxEzARBgNVBAoMCmVNdWRocmEgQlYx\nGjAYBgNVBAMMEUVNIE9WIENTIENBIC0gRzNCMHYwEAYHKoZIzj0CAQYFK4EEACID\nYgAEqrsPsGZqz88B+tUr/P1MK/LLPDSX3nOVDlgZth/mlQmHQcjEkUObNKBDHK/+\nW/m0wcjorgAB+fTcitgsWfNjnpbNbD1NsM7+wmw9YlF7N3YTcnQ/jordd7EAZPKr\n5eBQo4IBLDCCASgwHwYDVR0jBBgwFoAUoBEKIz6W8Qfs4q8p74Klf9AwpLQwHQYD\nVR0OBBYEFHn66OzP2w11jaRY1p+27dx8Sd7mMA4GA1UdDwEB/wQEAwIBBjASBgNV\nHRMBAf8ECDAGAQH/AgEAMBMGA1UdJQQMMAoGCCsGAQUFBwMDMDIGA1UdIAQrMCkw\nCAYGZ4EMAQQBMA0GCysGAQQBg44hAQABMA4GDCsGAQQBg44hAQKBSDBDBgNVHR8E\nPDA6MDigNqA0hjJodHRwOi8vY3JsLmNvbW9kb2NhLmNvbS9BQUFDZXJ0aWZpY2F0\nZVNlcnZpY2VzLmNybDA0BggrBgEFBQcBAQQoMCYwJAYIKwYBBQUHMAGGGGh0dHA6\nLy9vY3NwLmNvbW9kb2NhLmNvbTANBgkqhkiG9w0BAQsFAAOCAQEAIh0OzWsdBYhY\n7+yxPGyosRL+uUSN6/Mrd30fBuougs5FVWQ3cRgLhImnUUgXADY9buugWhMmfT3c\nyU5T1MFXZXClCILm5mfg+oqm/ikUWJl3Ayf2GvYf2O++6vzqvkn418nI5Lpdt0O7\nLi524YS/3CZHDA+zZQ7rrHpZXgQ9Hmp8AUQ4FneaGjNanFoYxiZxwCyW5z4u7AoK\n0bmOVqKtcDFoVNz5++G9TYDJdhWOXmKKgzqRo1MK0PjazNT7MGHqknP93SqMVJ1g\nmX/qNWxYteAjlvFanQxBNX/+TRxDjuFsEHO/7BvqSpIIck0UEEfizhN8zNpWLbcl\nEqVgl0g39A==\n-----END CERTIFICATE-----\n",
|
||||
"bc88231a840e54cced148da48806169e8d0b1c0a96267a427aeccda9d10e6145": "-----BEGIN CERTIFICATE-----\nMIICQDCCAcagAwIBAgIUUYu3O8bpN1GPvCIHqWpT/FYUNDowCgYIKoZIzj0EAwIw\nPzELMAkGA1UEBhMCTkwxEzARBgNVBAoMCmVNdWRocmEgQlYxGzAZBgNVBAMMEkVN\nIEVWIFRMUyBDQSAtIEczQjAeFw0yNjEwMjAwMjIyNDFaFw0yNzA1MDMwMjI3NDFa\nMDMxMTAvBgNVBAMMKGV2LWczYi1hYWEtZW11ZGhyYS53ZS1oYXZlLWEtcHJvYmxl\nbS5jb20wWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAASjDLpnVdmvXM6R3UB2o42a\n1GnrYfxRXo7UII36mBl9mIS7p9MkAryhPdxitFHkgTrrbypVtzpydNtJIGGGXUwE\no4GrMIGoMAwGA1UdEwEB/wQCMAAwDgYDVR0PAQH/BAQDAgeAMBMGA1UdJQQMMAoG\nCCsGAQUFBwMBMDMGA1UdEQQsMCqCKGV2LWczYi1hYWEtZW11ZGhyYS53ZS1oYXZl\nLWEtcHJvYmxlbS5jb20wHQYDVR0OBBYEFEVBY1ecTN9bLMDl4ifdnN2WZL+WMB8G\nA1UdIwQYMBaAFD7cINmJRpiHe4c/2IFHslwV2EcGMAoGCCqGSM49BAMCA2gAMGUC\nMQCyvr9gktgmtsuTuFleFV+YExNNKLf3GZieuUprQcERrzh798oYKavTqZeYf9Kz\nRhECMCVs1B7KPh7/lgHRly14Z8RJA8ApR7e2cRREW5uPKFOSDlZ/RIezxsfEzsQ4\nlAODeA==\n-----END CERTIFICATE-----\n",
|
||||
"bcd6ab59ea95fd520bfe7709679b1f682abbd79c77367e84e25415b91de37753": "-----BEGIN CERTIFICATE-----\nMIICQDCCAcegAwIBAgIUTMUrGzSU+0BySFdrN3ALZq9Spv8wCgYIKoZIzj0EAwIw\nQDELMAkGA1UEBhMCVVMxFDASBgNVBAoMC2VNdWRocmEgSW5jMRswGQYDVQQDDBJF\nTSBFViBUTFMgQ0EgLSBHM0MwHhcNMjYxMDIwMDIyMjQyWhcNMjcwNTAzMDIyNzQy\nWjAzMTEwLwYDVQQDDChldi1nM2MtYWFhLWVtdWRocmEud2UtaGF2ZS1hLXByb2Js\nZW0uY29tMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEglClDbotLFFeY9SpryT4\nk958tvriY2DDnaPsmjQaecRhB6+L/8hLw6FMt+HT8SLvjIZnFY+AH92cBjedcNST\nAKOBqzCBqDAMBgNVHRMBAf8EAjAAMA4GA1UdDwEB/wQEAwIHgDATBgNVHSUEDDAK\nBggrBgEFBQcDATAzBgNVHREELDAqgihldi1nM2MtYWFhLWVtdWRocmEud2UtaGF2\nZS1hLXByb2JsZW0uY29tMB0GA1UdDgQWBBSVtGbn6xUT1iosUeaGA15Nq640aTAf\nBgNVHSMEGDAWgBSmJV6AqU2385Z8G8eR35K87kvrXzAKBggqhkjOPQQDAgNnADBk\nAjAg7b5bIK9X50QEX0U6G8fQh12BnFQ079Iluzd0Y2TJWCjtVXZi3nOciM8RzNKM\ntZECMCBf0JOyMt8RNS93Wj8kYbWkCtoKoUrqx7G3JTfGReKci+DdTvkF8/3F6doi\n9KB22Q==\n-----END CERTIFICATE-----\n",
|
||||
"cb6e15165ac802e9b3f993f16c25d41f4e81c21ebde71ae1b012ac00bf9c7c3f": "-----BEGIN CERTIFICATE-----\nMIIDaDCCAu6gAwIBAgIPKWkmwiejoVJjkrGUgqUEMAoGCCqGSM49BAMDMFYxCzAJ\nBgNVBAYTAklOMSUwIwYDVQQKDBxlTXVkaHJhIFRlY2hub2xvZ2llcyBMaW1pdGVk\nMSAwHgYDVQQDDBdlbVNpZ24gUm9vdCBUTFMgQ0EgLSBHMzAeFw0yNTA0MzAxNTM3\nNDFaFw00MDA0MjgxODMwMDBaMEAxCzAJBgNVBAYTAlVTMRQwEgYDVQQKDAtlTXVk\naHJhIEluYzEbMBkGA1UEAwwSRU0gT1YgVExTIENBIC0gRzNDMHYwEAYHKoZIzj0C\nAQYFK4EEACIDYgAEE4JRzVKskpjByKrZdzId65fnCb0LTKCspV3IrVHc/J0ViaFZ\nfjpRIOHv1jHGPhg1ysrimGmmLhYx6KKBgatWYW3EXZGKvAnzqw5jz3kqpNztc4e+\nN7ulaVSfHAKycO1no4IBljCCAZIwHwYDVR0jBBgwFoAU4TqrIXzx401UrtRyt/2L\nWUIgkFQwHQYDVR0OBBYEFDTq6gzryQra+dDF4u2beMmHnhtsMB0GA1UdJQQWMBQG\nCCsGAQUFBwMBBggrBgEFBQcDAjBdBgNVHSAEVjBUMDkGCysGAQQBg44hAQABMCow\nKAYIKwYBBQUHAgEWHGh0dHA6Ly9yZXBvc2l0b3J5LmVtc2lnbi5jb20wDQYLKwYB\nBAGDjiEBAm4wCAYGZ4EMAQICMHUGCCsGAQUFBwEBBGkwZzAkBggrBgEFBQcwAYYY\naHR0cDovL29jc3AtYy5lbXNpZ24uY29tMD8GCCsGAQUFBzAChjNodHRwOi8vcmVw\nb3NpdG9yeS5lbXNpZ24uY29tL2NlcnRzL0VNT1ZUTFNDQUczQy5wN2MwNwYDVR0f\nBDAwLjAsoCqgKIYmaHR0cDovL2NybC5lbXNpZ24uY29tLz9yb290dGxzY2FnMy5j\ncmwwDgYDVR0PAQH/BAQDAgEGMBIGA1UdEwEB/wQIMAYBAf8CAQAwCgYIKoZIzj0E\nAwMDaAAwZQIxAMSZt2xsPeumRdeSMSQ3CbzIZeQ/q7GDCWfeE1Pq+mhgA6gRM44c\nNVF3xJ+wsxJk+wIwFJbaDMokD8Ei6eNOiA8yUMYvfzQyngmrhZQMdPCK1SewrR79\n4rSb9vGqxZzelQth\n-----END CERTIFICATE-----\n",
|
||||
"ccd80467f9cac38f115dcb6e4f57e4d74f5efa519814ea934aeb3be53b83a191": "-----BEGIN CERTIFICATE-----\nMIICOTCCAb+gAwIBAgIUOcw5N4XGLiB/3GvB05bJuGoZPR0wCgYIKoZIzj0EAwIw\nQDELMAkGA1UEBhMCVVMxFDASBgNVBAoMC2VNdWRocmEgSW5jMRswGQYDVQQDDBJF\nTSBFViBUTFMgQ0EgLSBHM0MwHhcNMjYxMDIwMDA1OTM3WhcNMjcwNTAzMDEwNDM3\nWjAvMS0wKwYDVQQDDCRldi1nM2MtZW11ZGhyYS53ZS1oYXZlLWEtcHJvYmxlbS5j\nb20wWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAAQo7U02MAUO8EEMomXwIl+kSQzm\nb+nvXKIZ/hC4rZ1GpXchv4SzTrV1SB/Qg+pD6OBEXbBBnPmQNkePNQTlujqRo4Gn\nMIGkMAwGA1UdEwEB/wQCMAAwDgYDVR0PAQH/BAQDAgeAMBMGA1UdJQQMMAoGCCsG\nAQUFBwMBMC8GA1UdEQQoMCaCJGV2LWczYy1lbXVkaHJhLndlLWhhdmUtYS1wcm9i\nbGVtLmNvbTAdBgNVHQ4EFgQUqxe+7qS+MxNyioJZhQsL5MEEw6swHwYDVR0jBBgw\nFoAUpiVegKlNt/OWfBvHkd+SvO5L618wCgYIKoZIzj0EAwIDaAAwZQIwC/CxSIov\nwcar3sV98B8cL+QuR2kkJdLdXsFKzg/HDSJ1MOjqTGm924LB6r9nl0f7AjEAzcwr\nWQOYY9eycqIz+LG2daYS6LOctj+CjoAk/CLugWPxGxp4Aerfau2P1z1h5jAA\n-----END CERTIFICATE-----\n",
|
||||
"d7a7a0fb5d7e2731d771e9484ebcdef71d5f0c3e0a2948782bc83ee0ea699ef4": "-----BEGIN CERTIFICATE-----\nMIIEMjCCAxqgAwIBAgIBATANBgkqhkiG9w0BAQUFADB7MQswCQYDVQQGEwJHQjEb\nMBkGA1UECAwSR3JlYXRlciBNYW5jaGVzdGVyMRAwDgYDVQQHDAdTYWxmb3JkMRow\nGAYDVQQKDBFDb21vZG8gQ0EgTGltaXRlZDEhMB8GA1UEAwwYQUFBIENlcnRpZmlj\nYXRlIFNlcnZpY2VzMB4XDTA0MDEwMTAwMDAwMFoXDTI4MTIzMTIzNTk1OVowezEL\nMAkGA1UEBhMCR0IxGzAZBgNVBAgMEkdyZWF0ZXIgTWFuY2hlc3RlcjEQMA4GA1UE\nBwwHU2FsZm9yZDEaMBgGA1UECgwRQ29tb2RvIENBIExpbWl0ZWQxITAfBgNVBAMM\nGEFBQSBDZXJ0aWZpY2F0ZSBTZXJ2aWNlczCCASIwDQYJKoZIhvcNAQEBBQADggEP\nADCCAQoCggEBAL5AnfRu4ep2hxxNRUSOvkbIgwadwSr+GB+O5AL686tdUIoWMQua\nBtDFcCLNSS1UY8y2bmhGC1Pqy0wkwLxyTurxFa70VJoSCsN6sjNg4tqJVfMiWPPe\n3M/vg4aijJRPn2jymJBGhCfHdr/jzDUsi14HZGWCwEiwqJH5YZ92IFCokcdmtet4\nYgNW8IoaE+oxox6gmf049vYnMlhvB/VruPsUK6+3qszWY19zjNoFmag4qMsXeDZR\nrOme9Hg6jc8P2ULimAyrL58OAd7vn5lJ8S3frHRNG5i1R8XlKdH5kBjHYpy+g8cm\nez6KJcfA3Z3mNWgQIJ2P2N7Sw4ScDV7oL8kCAwEAAaOBwDCBvTAdBgNVHQ4EFgQU\noBEKIz6W8Qfs4q8p74Klf9AwpLQwDgYDVR0PAQH/BAQDAgEGMA8GA1UdEwEB/wQF\nMAMBAf8wewYDVR0fBHQwcjA4oDagNIYyaHR0cDovL2NybC5jb21vZG9jYS5jb20v\nQUFBQ2VydGlmaWNhdGVTZXJ2aWNlcy5jcmwwNqA0oDKGMGh0dHA6Ly9jcmwuY29t\nb2RvLm5ldC9BQUFDZXJ0aWZpY2F0ZVNlcnZpY2VzLmNybDANBgkqhkiG9w0BAQUF\nAAOCAQEACFb8AvCb6P+k+tZ7xkSAzk/ExfYAWMymtrwUSWgEdujm7l3sAg9g1o1Q\nGE8mTgHj5rCl7r+8dFRBv/38ErjHT1r0iWAFf2C3BUrz9vHCv8S5dIa2LX1rzNLz\nRt0vxuBqw8M0Ayx9lt1awg6nCpnBBYurDC/zXDrPbDdVCYfeU0BsWO/8tqtlbgT2\nG9w84FoVxp7Z8VlIMCFlA2zs6SFz7JsDoeA3raAVGI/6ugLOpyypEBMs1OUIJqsi\nl2D4kF501KKaU73yqWjgom7C12yxow+ev+to51byrvLjKzg6CYG1a4XXvi3tPxq3\nsmPi9WIsgtRqAEFQ8TmDn5XpNpaYbg==\n-----END CERTIFICATE-----\n",
|
||||
"e7568fe6e12331f4fd7de61da4c7559d0f8eb192a218d4741a3ba75184333838": "-----BEGIN CERTIFICATE-----\nMIIDaDCCAu2gAwIBAgIQAMfEHv6S2CXwXq6EGkqCwTAKBggqhkjOPQQDAzBWMQsw\nCQYDVQQGEwJJTjElMCMGA1UECgwcZU11ZGhyYSBUZWNobm9sb2dpZXMgTGltaXRl\nZDEgMB4GA1UEAwwXZW1TaWduIFJvb3QgVExTIENBIC0gRzMwHhcNMjUwNDMwMTYw\nMzMxWhcNNDAwNDI4MTgzMDAwWjA/MQswCQYDVQQGEwJOTDETMBEGA1UECgwKZU11\nZGhyYSBCVjEbMBkGA1UEAwwSRU0gRVYgVExTIENBIC0gRzNCMHYwEAYHKoZIzj0C\nAQYFK4EEACIDYgAEJduoWmTTl9oLEFLp6lRuSHvv6pydnWigxerc/u91ppu11vwB\n9Cv7ym60C81kwHZ+Nv/QXZeoE8WqDGyVMOec9fCGZGjxOAZCCqXIUYQLHiUlQOeQ\n5LZUw4Uu1krx6GV9o4IBlTCCAZEwHwYDVR0jBBgwFoAU4TqrIXzx401UrtRyt/2L\nWUIgkFQwHQYDVR0OBBYEFD7cINmJRpiHe4c/2IFHslwV2EcGMB0GA1UdJQQWMBQG\nCCsGAQUFBwMBBggrBgEFBQcDAjBcBgNVHSAEVTBTMDkGCysGAQQBg44hAQABMCow\nKAYIKwYBBQUHAgEWHGh0dHA6Ly9yZXBvc2l0b3J5LmVtc2lnbi5jb20wDQYLKwYB\nBAGDjiEBAngwBwYFZ4EMAQEwdQYIKwYBBQUHAQEEaTBnMCQGCCsGAQUFBzABhhho\ndHRwOi8vb2NzcC1iLmVtc2lnbi5jb20wPwYIKwYBBQUHMAKGM2h0dHA6Ly9yZXBv\nc2l0b3J5LmVtc2lnbi5jb20vY2VydHMvRU1FVlRMU0NBRzNCLnA3YzA3BgNVHR8E\nMDAuMCygKqAohiZodHRwOi8vY3JsLmVtc2lnbi5jb20vP3Jvb3R0bHNjYWczLmNy\nbDAOBgNVHQ8BAf8EBAMCAQYwEgYDVR0TAQH/BAgwBgEB/wIBADAKBggqhkjOPQQD\nAwNpADBmAjEA9Jq999oaOUKhFwopw33iCmt0OHIn3gjT43GfuOHR/YckXdwoNdDA\nlIEhz0NgRO3/AjEAvY/a3OMvktIZLYjn+WLKmp0QhrIMV+e6EpS7j4GVeOKouNp6\nMN/P+ByGeiRMghzB\n-----END CERTIFICATE-----\n",
|
||||
"f2edaaff3d9b701fb4d14c9f285f8c0bad90fc3bcc1fa07d47f4718edaba34a5": "-----BEGIN CERTIFICATE-----\nMIICKDCCAa2gAwIBAgIQAY/OySfBFvWcmX7cDM0uKzAKBggqhkjOPQQDAzBVMQsw\nCQYDVQQGEwJJTjElMCMGA1UECgwcZU11ZGhyYSBUZWNobm9sb2dpZXMgTGltaXRl\nZDEfMB0GA1UEAwwWZW1TaWduIFJvb3QgQ1MgQ0EgLSBHMzAeFw0yNDAyMDgwODMz\nMTRaFw00OTAyMDcwODMzMTRaMFUxCzAJBgNVBAYTAklOMSUwIwYDVQQKDBxlTXVk\naHJhIFRlY2hub2xvZ2llcyBMaW1pdGVkMR8wHQYDVQQDDBZlbVNpZ24gUm9vdCBD\nUyBDQSAtIEczMHYwEAYHKoZIzj0CAQYFK4EEACIDYgAESl9kM5D0VA/Og76dz5Xz\nw1hnGQ4fSR9993/qBkkvI48pXQVKOdyHCgAZThgWejQ8OpFRiTISjpabn4tfRFOY\nuzhApR/XxGbkg9X+2cEtbyghF9CdoF91W22V/R2EVe4xo0IwQDAdBgNVHQ4EFgQU\naHQD+IXKjVRQVx0W4MjeRQ2rzS0wDgYDVR0PAQH/BAQDAgEGMA8GA1UdEwEB/wQF\nMAMBAf8wCgYIKoZIzj0EAwMDaQAwZgIxAK9OlACrFod3UgWY1ai+DF9sGFhahU59\nrv4jJyV01QaxGn0HErZ8gjHkngqe+tcFogIxAPTdW0hBmrGjhxBWdv9mZNdVFiJ4\nlrZ3ahvimsLhTQpnDBgiGKv6Ov1Fx/CE6yjIAg==\n-----END CERTIFICATE-----\n",
|
||||
"f3c3c15bc7edf2b3ebdc922af112f2a0c414eb509901099ef0488c0942e77efe": "-----BEGIN CERTIFICATE-----\nMIICODCCAb6gAwIBAgIUPXjC97k7buQHZ7p5ehDU8QNyrR4wCgYIKoZIzj0EAwIw\nPzELMAkGA1UEBhMCTkwxEzARBgNVBAoMCmVNdWRocmEgQlYxGzAZBgNVBAMMEkVN\nIE9WIFRMUyBDQSAtIEczQjAeFw0yNjEwMjAwMDU5MzdaFw0yNzA1MDMwMTA0Mzda\nMC8xLTArBgNVBAMMJG92LWczYi1lbXVkaHJhLndlLWhhdmUtYS1wcm9ibGVtLmNv\nbTBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABA1haD+95Albk0UN6YtDCPpqevTv\nYUmEhsIVpb/hnhsbBlH0yLthSE2JLH14ywiQJXttoc/pDWC9eoICW6CJXL2jgacw\ngaQwDAYDVR0TAQH/BAIwADAOBgNVHQ8BAf8EBAMCB4AwEwYDVR0lBAwwCgYIKwYB\nBQUHAwEwLwYDVR0RBCgwJoIkb3YtZzNiLWVtdWRocmEud2UtaGF2ZS1hLXByb2Js\nZW0uY29tMB0GA1UdDgQWBBQUhzc4s202Hn62G0srmgguYdY2MTAfBgNVHSMEGDAW\ngBSb2GVRr+fnLDetCO5pGdm/N4SuCjAKBggqhkjOPQQDAgNoADBlAjEAoJ6d4zzA\nxE349yGHHrwKZpVCC/HBFutcOpOfzQoe8ikbmaaBTdtKai5TEllwt3dBAjBeHGiX\nVheDhJfewbei99cw1MfYB5tPwdfuY5pe1MJz0Tg9zJ4JgxBvF6LTT0OOXZ4=\n-----END CERTIFICATE-----\n",
|
||||
"fd9d5c2dbbd4995ab69b8d50c439d9527b00529199c2fd01b2174eb58f07021f": "-----BEGIN CERTIFICATE-----\nMIIDzTCCArWgAwIBAgIQBfB4dS3s56G7hZXTAxZxcTANBgkqhkiG9w0BAQsFADB7\nMQswCQYDVQQGEwJHQjEbMBkGA1UECAwSR3JlYXRlciBNYW5jaGVzdGVyMRAwDgYD\nVQQHDAdTYWxmb3JkMRowGAYDVQQKDBFDb21vZG8gQ0EgTGltaXRlZDEhMB8GA1UE\nAwwYQUFBIENlcnRpZmljYXRlIFNlcnZpY2VzMB4XDTI1MDUxNDAwMDAwMFoXDTI4\nMTIzMTIzNTk1OVowQDELMAkGA1UEBhMCVVMxFDASBgNVBAoMC2VNdWRocmEgSW5j\nMRswGQYDVQQDDBJFTSBFViBUTFMgQ0EgLSBHM0MwdjAQBgcqhkjOPQIBBgUrgQQA\nIgNiAAQtVugppvuCxkpGl9lgr5UekGeukfkm00MG2kioqut4abamfm0nQm4EQqoD\ns24ZiN/2NlFO4c8H9bpLP/KgZkWufTHVhmrTGqPSRusLMXfumct1ZsUWznSAimaP\nkfDEf8qjggE0MIIBMDAfBgNVHSMEGDAWgBSgEQojPpbxB+zirynvgqV/0DCktDAd\nBgNVHQ4EFgQUpiVegKlNt/OWfBvHkd+SvO5L618wDgYDVR0PAQH/BAQDAgEGMBIG\nA1UdEwEB/wQIMAYBAf8CAQAwHQYDVR0lBBYwFAYIKwYBBQUHAwEGCCsGAQUFBwMC\nMDAGA1UdIAQpMCcwBwYFZ4EMAQEwDQYLKwYBBAGDjiEBAAEwDQYLKwYBBAGDjiEB\nAngwQwYDVR0fBDwwOjA4oDagNIYyaHR0cDovL2NybC5jb21vZG9jYS5jb20vQUFB\nQ2VydGlmaWNhdGVTZXJ2aWNlcy5jcmwwNAYIKwYBBQUHAQEEKDAmMCQGCCsGAQUF\nBzABhhhodHRwOi8vb2NzcC5jb21vZG9jYS5jb20wDQYJKoZIhvcNAQELBQADggEB\nAIFfHiaMSjnrdZygRYV4ekRBmG7ybcFk4hGQWMMyMFi0cAEk2+dGfz5bl8IaIbLq\nz+uVJzhOgVEX7WqIfgbWfFsfPYHYjPHBmy7SlZ3BwA86IQ6qF/etpX2/qL9A5yRN\nN3nVowUpaqV6H3aCpkAh3wR2ZNkvAbLDvPz3J0l+sEWjWv+ZISOV4R+hXDTdPXV0\n1lKxnbwqEsdo/sE2QkCDKwFrZh/0uKjWspydJj+jSaDLTrGNx50Dgmn7VEcD1ZI5\nFXjknVUiO25YlmS1dtVZSBbyUKSjlrP/yDwPcZunoAsmCAAtCKsFLviLaymzvsKI\neSSDGxpSySp2VCxompGDk8g=\n-----END CERTIFICATE-----\n"
|
||||
},
|
||||
"chains": {
|
||||
"dv-g3b-aaa-emudhra.we-have-a-problem.com": [
|
||||
"7e02622ab6d745c1edb4b431bb1c551e667305c3dd3eb0c7cc6dfdb523807424",
|
||||
"45a883f349aedfd5f3a649b5498ffb121712ba8b448f3f2a91dbc5a766fcddbe"
|
||||
],
|
||||
"dv-g3b-aaa-noct-emudhra.we-have-a-problem.com": [
|
||||
"5510ecf5764dee8dfe9468ae2d7323da21fcd5530889906540a2f8725c6f74db",
|
||||
"45a883f349aedfd5f3a649b5498ffb121712ba8b448f3f2a91dbc5a766fcddbe"
|
||||
],
|
||||
"dv-g3b-emudhra.we-have-a-problem.com": [
|
||||
"9dcb21b1841d75313b3c888f4bb9cc01f8f51379a76fbaa241ca492b668839e7",
|
||||
"42c4accd12c4f59597925e1a08a7a90102608543809308d50ec427e118ba1493",
|
||||
"3094025792039a68af6e553686410bdab7c0d4a36b9084cba64202e35d6ebbdd"
|
||||
],
|
||||
"dv-g3b-noct-emudhra.we-have-a-problem.com": [
|
||||
"0cbfe22b824385823fd3bf4be4d131e8901342933a7feef1cf8b3110b8e44a70",
|
||||
"42c4accd12c4f59597925e1a08a7a90102608543809308d50ec427e118ba1493",
|
||||
"3094025792039a68af6e553686410bdab7c0d4a36b9084cba64202e35d6ebbdd"
|
||||
],
|
||||
"dv-g3c-aaa-emudhra.we-have-a-problem.com": [
|
||||
"9200d153c3cac272cc06bf68f9ea163fc24659fc3331e77668236fe00645ae52",
|
||||
"62b7f9868263f72f6911fc78ceaf72c1979158b7368bf648683bb2aa39b02e72"
|
||||
],
|
||||
"dv-g3c-emudhra.we-have-a-problem.com": [
|
||||
"346cdd962e97189b59aee5526d1ae8fa714bb1a64094dfa67bef768ee93f36b5",
|
||||
"0164d7ceae5bb909c272af151ff44b1177ae9b47d9424b94f1d9558c30934d19",
|
||||
"3094025792039a68af6e553686410bdab7c0d4a36b9084cba64202e35d6ebbdd"
|
||||
],
|
||||
"ev-g3b-aaa-emudhra.we-have-a-problem.com": [
|
||||
"bc88231a840e54cced148da48806169e8d0b1c0a96267a427aeccda9d10e6145",
|
||||
"579480aea166290bd3c643589e795fc0d0e54e86505d180f1dff99efd3199512"
|
||||
],
|
||||
"ev-g3b-emudhra.we-have-a-problem.com": [
|
||||
"66374ae64962c437bd815a2aae546c76e30c2eafac8335192c8e517c15a246ed",
|
||||
"e7568fe6e12331f4fd7de61da4c7559d0f8eb192a218d4741a3ba75184333838",
|
||||
"3094025792039a68af6e553686410bdab7c0d4a36b9084cba64202e35d6ebbdd"
|
||||
],
|
||||
"ev-g3c-aaa-emudhra.we-have-a-problem.com": [
|
||||
"bcd6ab59ea95fd520bfe7709679b1f682abbd79c77367e84e25415b91de37753",
|
||||
"fd9d5c2dbbd4995ab69b8d50c439d9527b00529199c2fd01b2174eb58f07021f"
|
||||
],
|
||||
"ev-g3c-emudhra.we-have-a-problem.com": [
|
||||
"ccd80467f9cac38f115dcb6e4f57e4d74f5efa519814ea934aeb3be53b83a191",
|
||||
"432ec25f0d28de9006d97bf752f17bbb2cd41f669933fcb072d9a1148d0261f3",
|
||||
"3094025792039a68af6e553686410bdab7c0d4a36b9084cba64202e35d6ebbdd"
|
||||
],
|
||||
"ov-g3b-aaa-emudhra.we-have-a-problem.com": [
|
||||
"21f57f83631a93c98e867050075746b3361e0433c9e0e969462852476784f6f0",
|
||||
"5f1ded4dd590af74631290cf073b122a2aac6a1fa479778a0bd43579bd832699"
|
||||
],
|
||||
"ov-g3b-emudhra.we-have-a-problem.com": [
|
||||
"f3c3c15bc7edf2b3ebdc922af112f2a0c414eb509901099ef0488c0942e77efe",
|
||||
"98b896625c0c230adeec3eb2cf6aad5001ecf9156a68644e82374ce5833bc25b",
|
||||
"3094025792039a68af6e553686410bdab7c0d4a36b9084cba64202e35d6ebbdd"
|
||||
],
|
||||
"ov-g3c-aaa-emudhra.we-have-a-problem.com": [
|
||||
"59cf0386b56bc2414a0c06241ea9e25e01a9c93bac7a596648747f9d0ded4906",
|
||||
"7485b51c0ed192878fa1d2f948fb529ae1842cc5086c4b67f7be5b2039de9489"
|
||||
],
|
||||
"ov-g3c-emudhra.we-have-a-problem.com": [
|
||||
"4314941cf242a4ea4149aa7e9cdf77a15de2771a79984b809fc04443fea013c8",
|
||||
"cb6e15165ac802e9b3f993f16c25d41f4e81c21ebde71ae1b012ac00bf9c7c3f",
|
||||
"3094025792039a68af6e553686410bdab7c0d4a36b9084cba64202e35d6ebbdd"
|
||||
]
|
||||
},
|
||||
"cross_signed_roots": {
|
||||
"3094025792039a68af6e553686410bdab7c0d4a36b9084cba64202e35d6ebbdd": "emSign Root TLS CA - G3 (emSign ECC Root CA - G3 cross-sign)"
|
||||
}
|
||||
}
|
||||
+269
@@ -0,0 +1,269 @@
|
||||
// Immutable N0 oracle copied from F1 commit 1cc56cb9; used only for differential tests.
|
||||
const _ORDINALS = ['1st', '2nd', '3rd', '4th', '5th'];
|
||||
const _DAYS = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
|
||||
function _hasTimeComponent(dateStr) {
|
||||
return typeof dateStr === 'string' && /T\d{2}:\d{2}/.test(dateStr);
|
||||
}
|
||||
|
||||
function _formatDueDate(dateStr) {
|
||||
if (!dateStr) return '';
|
||||
const d = new Date(dateStr);
|
||||
if (isNaN(d)) return '';
|
||||
const now = new Date();
|
||||
const hasTime = _hasTimeComponent(dateStr);
|
||||
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||
const due = new Date(d.getFullYear(), d.getMonth(), d.getDate());
|
||||
const diffDays = Math.round((due - today) / 86400000);
|
||||
const timeStr = hasTime ? d.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' }) : '';
|
||||
if (hasTime && d < now) return 'overdue';
|
||||
if (!hasTime && diffDays < 0) return 'overdue';
|
||||
if (diffDays === 0) return hasTime ? timeStr : 'today';
|
||||
if (diffDays === 1) return hasTime ? `tmrw ${timeStr}` : 'tomorrow';
|
||||
const dateLabel = d.toLocaleDateString([], { month: 'short', day: 'numeric' });
|
||||
return hasTime ? `${dateLabel} ${timeStr}` : dateLabel;
|
||||
}
|
||||
|
||||
function _isDueOverdue(dateStr) {
|
||||
if (!dateStr) return false;
|
||||
const d = new Date(dateStr);
|
||||
if (isNaN(d)) return false;
|
||||
if (_hasTimeComponent(dateStr)) return d < new Date();
|
||||
return d < new Date(new Date().toDateString());
|
||||
}
|
||||
|
||||
function _isDueTodayOrOverdue(dateStr) {
|
||||
if (!dateStr) return false;
|
||||
const d = new Date(dateStr);
|
||||
const now = new Date();
|
||||
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||
const due = new Date(d.getFullYear(), d.getMonth(), d.getDate());
|
||||
return due <= today;
|
||||
}
|
||||
|
||||
function _laterTodayDate() {
|
||||
const now = new Date();
|
||||
const eight = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 18, 0); // 6pm today
|
||||
// If less than 1 hour before 6pm, push to "in 3 hours" instead
|
||||
if (eight - now < 60 * 60 * 1000) return new Date(now.getTime() + 3 * 60 * 60 * 1000);
|
||||
return eight;
|
||||
}
|
||||
|
||||
function _tomorrowDate() {
|
||||
const t = new Date();
|
||||
t.setDate(t.getDate() + 1);
|
||||
t.setHours(8, 0, 0, 0);
|
||||
return t;
|
||||
}
|
||||
|
||||
function _nextWeekDate() {
|
||||
const t = new Date();
|
||||
const daysUntilMon = (8 - t.getDay()) % 7 || 7;
|
||||
t.setDate(t.getDate() + daysUntilMon);
|
||||
t.setHours(8, 0, 0, 0);
|
||||
return t;
|
||||
}
|
||||
|
||||
function _toLocalDatetimeStr(d) {
|
||||
// Format as YYYY-MM-DDTHH:MM (local, no TZ)
|
||||
const pad = n => String(n).padStart(2, '0');
|
||||
return `${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||
}
|
||||
|
||||
function _formatReminderTag(dateStr) {
|
||||
if (!dateStr) return '';
|
||||
const d = new Date(dateStr);
|
||||
if (isNaN(d)) return '';
|
||||
const now = new Date();
|
||||
const sameDay = d.toDateString() === now.toDateString();
|
||||
const tomorrow = new Date(now); tomorrow.setDate(tomorrow.getDate() + 1);
|
||||
const isTomorrow = d.toDateString() === tomorrow.toDateString();
|
||||
const time = d.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' });
|
||||
if (sameDay) return `Today, ${time}`;
|
||||
if (isTomorrow) return `Tomorrow, ${time}`;
|
||||
const dateLabel = d.toLocaleDateString([], { month: 'short', day: 'numeric' });
|
||||
return `${dateLabel}, ${time}`;
|
||||
}
|
||||
|
||||
function _nthWeekdayLabel(d) {
|
||||
const n = Math.ceil(d.getDate() / 7); // 1..5
|
||||
return `${_ORDINALS[n - 1] || `${n}th`} ${_DAYS[d.getDay()]}`;
|
||||
}
|
||||
|
||||
function _isLastWeekdayOfMonth(d) {
|
||||
const test = new Date(d);
|
||||
test.setDate(d.getDate() + 7);
|
||||
return test.getMonth() !== d.getMonth();
|
||||
}
|
||||
|
||||
function _nthWeekdayOfMonth(year, month, weekday, n) {
|
||||
const first = new Date(year, month, 1);
|
||||
const offset = (weekday - first.getDay() + 7) % 7;
|
||||
let day = 1 + offset + (n - 1) * 7;
|
||||
// Last day of month
|
||||
const lastDay = new Date(year, month + 1, 0).getDate();
|
||||
if (day > lastDay) day -= 7;
|
||||
return new Date(year, month, day, 0, 0, 0);
|
||||
}
|
||||
|
||||
function _lastWeekdayOfMonth(year, month, weekday) {
|
||||
const lastDay = new Date(year, month + 1, 0);
|
||||
const back = (lastDay.getDay() - weekday + 7) % 7;
|
||||
return new Date(year, month, lastDay.getDate() - back, 0, 0, 0);
|
||||
}
|
||||
|
||||
function _snapToRepeat(currentDate, normRepeat) {
|
||||
const hh = currentDate.getHours();
|
||||
const mm = currentDate.getMinutes();
|
||||
const now = Date.now();
|
||||
const anchor = currentDate.getTime() > now ? currentDate : new Date();
|
||||
const parts = normRepeat.split(':');
|
||||
const kind = parts[0];
|
||||
if (kind === 'weekly') {
|
||||
const targetWd = parseInt(parts[1], 10);
|
||||
if (isNaN(targetWd)) return null;
|
||||
const d = new Date(anchor.getFullYear(), anchor.getMonth(), anchor.getDate(), hh, mm, 0, 0);
|
||||
const delta = (targetWd - d.getDay() + 7) % 7;
|
||||
d.setDate(d.getDate() + delta);
|
||||
if (d.getTime() <= now) d.setDate(d.getDate() + 7);
|
||||
return d;
|
||||
}
|
||||
if (kind === 'monthly') {
|
||||
const sub = parts[1];
|
||||
let y = anchor.getFullYear();
|
||||
let m = anchor.getMonth();
|
||||
// Walk forward up to 14 months to find the next matching slot.
|
||||
for (let tries = 0; tries < 14; tries++) {
|
||||
let target;
|
||||
if (sub === 'day') {
|
||||
const wantDay = parseInt(parts[2], 10);
|
||||
if (isNaN(wantDay)) return null;
|
||||
const lastDay = new Date(y, m + 1, 0).getDate();
|
||||
target = new Date(y, m, Math.min(wantDay, lastDay));
|
||||
} else if (sub === 'nth') {
|
||||
const n = parseInt(parts[2], 10);
|
||||
const wd = parseInt(parts[3], 10);
|
||||
if (isNaN(n) || isNaN(wd)) return null;
|
||||
target = _nthWeekdayOfMonth(y, m, wd, n);
|
||||
} else if (sub === 'last') {
|
||||
const wd = parseInt(parts[2], 10);
|
||||
if (isNaN(wd)) return null;
|
||||
target = _lastWeekdayOfMonth(y, m, wd);
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
target.setHours(hh, mm, 0, 0);
|
||||
if (target.getTime() > now && target.getTime() >= anchor.getTime()) return target;
|
||||
m++;
|
||||
if (m > 11) { m = 0; y++; }
|
||||
}
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function _formatRepeatLabel(repeat, originalDate) {
|
||||
if (!repeat || repeat === 'none') return '';
|
||||
const norm = _normalizeRepeat(repeat, originalDate);
|
||||
if (norm === 'daily') return 'Daily';
|
||||
if (norm === 'yearly') return 'Yearly';
|
||||
const parts = norm.split(':');
|
||||
if (parts[0] === 'weekly') {
|
||||
const wd = parseInt(parts[1], 10);
|
||||
if (isNaN(wd)) return 'Weekly';
|
||||
return `Weekly on ${_DAYS[wd]}s`;
|
||||
}
|
||||
if (parts[0] === 'monthly') {
|
||||
if (parts[1] === 'day') return `Monthly on day ${parts[2]}`;
|
||||
if (parts[1] === 'nth') {
|
||||
const n = parseInt(parts[2], 10);
|
||||
const wd = parseInt(parts[3], 10);
|
||||
return `Monthly on ${_ORDINALS[n - 1] || `${n}th`} ${_DAYS[wd]}`;
|
||||
}
|
||||
if (parts[1] === 'last') {
|
||||
const wd = parseInt(parts[2], 10);
|
||||
return `Monthly on last ${_DAYS[wd]}`;
|
||||
}
|
||||
}
|
||||
return norm;
|
||||
}
|
||||
|
||||
function _normalizeRepeat(repeat, originalDate) {
|
||||
if (!repeat || repeat === 'none') return 'none';
|
||||
if (repeat === 'daily' || repeat === 'yearly') return repeat;
|
||||
if (/^(weekly|monthly):/.test(repeat)) return repeat;
|
||||
// Legacy bare values - derive params from the original date
|
||||
const wd = originalDate.getDay();
|
||||
const n = Math.ceil(originalDate.getDate() / 7);
|
||||
if (repeat === 'weekly') return `weekly:${wd}`;
|
||||
if (repeat === 'monthly') return `monthly:day:${originalDate.getDate()}`;
|
||||
if (repeat === 'monthly_nth_weekday') return `monthly:nth:${n}:${wd}`;
|
||||
if (repeat === 'monthly_last_weekday') return `monthly:last:${wd}`;
|
||||
return repeat;
|
||||
}
|
||||
|
||||
function _advanceRecurring(dateStr, repeat) {
|
||||
const orig = new Date(dateStr);
|
||||
const hh = orig.getHours();
|
||||
const mm = orig.getMinutes();
|
||||
let d = new Date(orig);
|
||||
const norm = _normalizeRepeat(repeat, orig);
|
||||
if (norm === 'none') return null;
|
||||
|
||||
function step() {
|
||||
if (norm === 'daily') {
|
||||
d.setDate(d.getDate() + 1);
|
||||
return;
|
||||
}
|
||||
if (norm === 'yearly') {
|
||||
d.setFullYear(d.getFullYear() + 1);
|
||||
return;
|
||||
}
|
||||
const parts = norm.split(':');
|
||||
const kind = parts[0];
|
||||
if (kind === 'weekly') {
|
||||
// Snap to the requested weekday in the next 1-7 days
|
||||
const targetWd = parseInt(parts[1], 10);
|
||||
let delta = (targetWd - d.getDay() + 7) % 7;
|
||||
if (delta === 0) delta = 7;
|
||||
d.setDate(d.getDate() + delta);
|
||||
d.setHours(hh, mm, 0, 0);
|
||||
return;
|
||||
}
|
||||
if (kind === 'monthly') {
|
||||
const sub = parts[1];
|
||||
const ny = d.getFullYear() + (d.getMonth() === 11 ? 1 : 0);
|
||||
const nm = (d.getMonth() + 1) % 12;
|
||||
let target;
|
||||
if (sub === 'day') {
|
||||
const wantDay = parseInt(parts[2], 10);
|
||||
const lastDay = new Date(ny, nm + 1, 0).getDate();
|
||||
target = new Date(ny, nm, Math.min(wantDay, lastDay));
|
||||
} else if (sub === 'nth') {
|
||||
const n = parseInt(parts[2], 10);
|
||||
const wd = parseInt(parts[3], 10);
|
||||
target = _nthWeekdayOfMonth(ny, nm, wd, n);
|
||||
} else if (sub === 'last') {
|
||||
const wd = parseInt(parts[2], 10);
|
||||
target = _lastWeekdayOfMonth(ny, nm, wd);
|
||||
} else {
|
||||
d = null; return;
|
||||
}
|
||||
target.setHours(hh, mm, 0, 0);
|
||||
d = target;
|
||||
return;
|
||||
}
|
||||
d = null;
|
||||
}
|
||||
|
||||
step();
|
||||
if (d === null) return null;
|
||||
const now = Date.now();
|
||||
// Cap catch-up to avoid runaway on a malformed/very-old date.
|
||||
let guard = 5000;
|
||||
while (d.getTime() <= now) {
|
||||
if (--guard <= 0) return null;
|
||||
step();
|
||||
if (d === null) return null;
|
||||
}
|
||||
return _toLocalDatetimeStr(d);
|
||||
}
|
||||
+4429
File diff suppressed because it is too large
Load Diff
+1226
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,142 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
import { allowsDependency, imports, checkArchitecture, inventory } from '../scripts/check_frontend.mjs';
|
||||
import * as source from '../frontend/src/shared/actionMenuOrder.js';
|
||||
import * as deployed from '../static/js/actionMenuOrder.js';
|
||||
|
||||
test('stateless helper preserves its public API, ordering, references and inputs', () => {
|
||||
for (const module of [source, deployed]) {
|
||||
assert.deepEqual(Object.keys(module).sort(), ['SELECT_MENU_ICON', 'actionMenuRank', 'orderActionMenuItems']);
|
||||
const callback = () => {};
|
||||
const items = ['Delete', 'Select', 'Copy', 'Rename', 'Archive', 'Open', 'Cancel'].map(label => Object.freeze({ label, action: callback }));
|
||||
Object.freeze(items);
|
||||
const sorted = module.orderActionMenuItems(items);
|
||||
assert.deepEqual(sorted.map(i => i.label), ['Open', 'Rename', 'Copy', 'Select', 'Archive', 'Delete', 'Cancel']);
|
||||
assert.ok(sorted.every(i => items.includes(i)));
|
||||
assert.deepEqual(module.orderActionMenuItems([]), []);
|
||||
assert.equal(module.actionMenuRank({ menuOrder: 12, label: 'Delete' }), 12);
|
||||
assert.equal(module.actionMenuRank({ label: 'Unknown' }), 100);
|
||||
const equalRank = [{ label: 'Open first' }, { label: 'Open second' }];
|
||||
assert.deepEqual(module.orderActionMenuItems(equalRank), equalRank);
|
||||
}
|
||||
assert.equal(source.SELECT_MENU_ICON, deployed.SELECT_MENU_ICON);
|
||||
});
|
||||
|
||||
test('dependency direction separates composition, workflow, adapters and views', () => {
|
||||
const n = name => `features/notes/${name}.js`;
|
||||
const allowed = [
|
||||
['app/bootstrap.js', n('index')],
|
||||
[n('index'), n('infrastructure/api')],
|
||||
[n('application/crud'), n('domain/recurrence')],
|
||||
[n('infrastructure/api'), 'shared/platform/http.js'],
|
||||
[n('ui/panel'), n('application/crud')],
|
||||
[n('application/reminders'), 'features/calendar/index.js'],
|
||||
];
|
||||
for (const [from, to] of allowed) assert.ok(allowsDependency(from, to), `${from} -> ${to}`);
|
||||
const forbidden = [
|
||||
[n('domain/recurrence'), n('infrastructure/api')],
|
||||
[n('application/crud'), n('ui/panel')],
|
||||
[n('application/crud'), n('infrastructure/api')],
|
||||
[n('ui/panel'), 'features/calendar/index.js'],
|
||||
[n('application/crud'), 'features/calendar/application/store.js'],
|
||||
['app/bootstrap.js', n('domain/recurrence')],
|
||||
['shared/platform/http.js', n('index')],
|
||||
[n('domain/recurrence'), 'app/bootstrap.js'],
|
||||
];
|
||||
for (const [from, to] of forbidden) assert.equal(allowsDependency(from, to), false, `${from} -> ${to}`);
|
||||
});
|
||||
|
||||
test('parser sees re-exports, multiline and dynamic imports without comment false positives', () => {
|
||||
const code = `// import './false.js';
|
||||
const text = "import './false.js'";
|
||||
import { a }\n from './a.js';
|
||||
export { b } from './b.js';
|
||||
export * from './c.js';
|
||||
const load = () => import('./d.js');`;
|
||||
assert.deepEqual(imports(code).map(i => i.specifier), ['./a.js', './b.js', './c.js', './d.js']);
|
||||
assert.throws(() => imports('const load = p => import(p);'), /computed import/);
|
||||
assert.throws(() => imports('export const now = () => Date.now();', { domain: true }), /ambient clock/);
|
||||
assert.throws(() => imports('export const load = () => fetch("/api/notes");', { domain: true }), /platform identifier/);
|
||||
assert.doesNotThrow(() => imports('export const day = value => new Date(value);', { domain: true }));
|
||||
assert.doesNotThrow(() => imports('export const copy = value => ({ document: value.document });', { domain: true }));
|
||||
});
|
||||
|
||||
function fixture(t, modules, { cache = true } = {}) {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ody-frontend-gates-'));
|
||||
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
const entries = Object.entries(modules).map(([name, code]) => {
|
||||
const file = path.join(root, 'frontend/src', name);
|
||||
fs.mkdirSync(path.dirname(file), { recursive: true });
|
||||
fs.writeFileSync(file, code);
|
||||
return { source: name, deployment: `js/${name}` };
|
||||
});
|
||||
fs.mkdirSync(path.join(root, 'static'), { recursive: true });
|
||||
fs.writeFileSync(path.join(root, 'frontend/modules.json'), JSON.stringify({ version: 1, modules: entries }));
|
||||
const urls = cache ? entries.map(m => `/static/${m.deployment}`) : [];
|
||||
fs.writeFileSync(path.join(root, 'static/sw.js'), `const PRECACHE = ${JSON.stringify(urls)}; const PANEL_PRECACHE = [];`);
|
||||
return root;
|
||||
}
|
||||
|
||||
test('complete mapped graph and exact precache pass', t => {
|
||||
const root = fixture(t, { 'features/notes/index.js': "export { value } from './domain/value.js';", 'features/notes/domain/value.js': 'export const value = 1;' });
|
||||
assert.equal(checkArchitecture(root), 2);
|
||||
});
|
||||
|
||||
test('feature-private import fails through the full checker', t => {
|
||||
const root = fixture(t, { 'features/notes/application/a.js': "import '../../calendar/domain/b.js';", 'features/calendar/domain/b.js': 'export const value = 1;' });
|
||||
assert.throws(() => checkArchitecture(root), /forbidden dependency/);
|
||||
});
|
||||
|
||||
test('undeclared legacy coupling and alternate identities fail', t => {
|
||||
for (const specifier of ['/static/js/notes.js?v=legacy', './b.js?v=other', '/frontend/src/shared/b.js', 'some-package']) {
|
||||
const root = fixture(t, { 'shared/a.js': `import '${specifier}';`, 'shared/b.js': 'export const value = 1;' });
|
||||
assert.throws(() => checkArchitecture(root), /legacy import|one unversioned|unsupported|canonical static/);
|
||||
}
|
||||
});
|
||||
|
||||
test('a copy mapping cannot silently change relative import resolution', t => {
|
||||
const root = fixture(t, { 'shared/a.js': "import './b.js';", 'shared/b.js': 'export const value = 1;' });
|
||||
const manifest = JSON.parse(fs.readFileSync(path.join(root, 'frontend/modules.json')));
|
||||
manifest.modules[1].deployment = 'js/elsewhere/b.js';
|
||||
fs.writeFileSync(path.join(root, 'frontend/modules.json'), JSON.stringify(manifest));
|
||||
fs.writeFileSync(path.join(root, 'static/sw.js'), "const PRECACHE = ['/static/js/shared/a.js', '/static/js/elsewhere/b.js'];");
|
||||
assert.throws(() => checkArchitecture(root), /resolve differently/);
|
||||
});
|
||||
|
||||
test('new modules must join exact offline manifests and cannot introduce cycles', t => {
|
||||
const missing = fixture(t, { 'shared/a.js': 'export const a = 1;' }, { cache: false });
|
||||
assert.throws(() => checkArchitecture(missing), /absent from explicit precache/);
|
||||
const cyclic = fixture(t, { 'shared/a.js': "import './b.js';", 'shared/b.js': "import './a.js';" });
|
||||
assert.throws(() => checkArchitecture(cyclic), /dependency cycle/);
|
||||
});
|
||||
|
||||
test('legacy adapters require exact, used exceptions and reserved names cannot bypass ownership', t => {
|
||||
const root = fixture(t, { 'features/notes/infrastructure/api.js': "import '/static/js/legacy.js?v=existing';" });
|
||||
fs.mkdirSync(path.join(root, 'static/js'), { recursive: true });
|
||||
fs.writeFileSync(path.join(root, 'static/js/legacy.js'), 'export const legacy = true;');
|
||||
const file = path.join(root, 'frontend/modules.json');
|
||||
const manifest = JSON.parse(fs.readFileSync(file));
|
||||
manifest.modules[0].legacyImports = [{ specifier: '/static/js/legacy.js?v=existing', reason: 'Temporary existing transport adapter; remove in N2.' }];
|
||||
fs.writeFileSync(file, JSON.stringify(manifest));
|
||||
assert.equal(checkArchitecture(root), 1);
|
||||
manifest.modules[0].legacyImports.push({ specifier: '/static/js/unused.js', reason: 'stale' });
|
||||
fs.writeFileSync(file, JSON.stringify(manifest));
|
||||
assert.throws(() => checkArchitecture(root), /unused legacy import exception/);
|
||||
assert.throws(() => allowsDependency('features/shared/index.js', 'app/bootstrap.js'), /reserved feature name/);
|
||||
});
|
||||
|
||||
test('inventory reports executable static cycles and separates dynamic edges', t => {
|
||||
const root = fixture(t, { 'shared/a.js': 'export const a = 1;' });
|
||||
fs.mkdirSync(path.join(root, 'static/js'), { recursive: true });
|
||||
fs.writeFileSync(path.join(root, 'static/js/a.js'), "import './b.js'; const load = () => import('./c.js');");
|
||||
fs.writeFileSync(path.join(root, 'static/js/b.js'), "import './a.js';");
|
||||
fs.writeFileSync(path.join(root, 'static/js/c.js'), 'export const c = 1;');
|
||||
const result = inventory(root);
|
||||
assert.equal(result.staticImports, 2);
|
||||
assert.equal(result.dynamicLiteralImports, 1);
|
||||
assert.deepEqual(result.staticCycles, [['js/a.js', 'js/b.js']]);
|
||||
assert.deepEqual(result.missing, []);
|
||||
});
|
||||
@@ -0,0 +1,10 @@
|
||||
"""Read deployed Notes responsibilities for source guards; runtime tests execute their real modules."""
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
|
||||
|
||||
def notes_source(*modules):
|
||||
base = ROOT / "static/js/features/notes"
|
||||
paths = [base / f"{module}.js" for module in modules] if modules else sorted(base.rglob("*.js"))
|
||||
return "\n".join(path.read_text(encoding="utf-8") for path in paths)
|
||||
@@ -0,0 +1,93 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createNotesState } from '../frontend/src/features/notes/application/state.js';
|
||||
import { createNotesWorkflows } from '../frontend/src/features/notes/application/workflows.js';
|
||||
import { createLayout } from '../frontend/src/features/notes/ui/layout.js';
|
||||
|
||||
test('views cannot mutate Notes state, but captured identities observe application updates', () => {
|
||||
const app = createNotesState();
|
||||
app.replaceNotes([{ id: 'a', items: [{ text: 'Task', done: false }] }]);
|
||||
const note = app.state.notes[0];
|
||||
assert.throws(() => { note.items[0].done = true; }, /state operations/);
|
||||
assert.throws(() => app.state.notes.push({ id: 'other' }), /state operations/);
|
||||
assert.throws(() => app.state.selectedIds.add('a'), /selection operations/);
|
||||
assert.throws(() => Object.defineProperty(note, 'title', { value: 'unowned' }), /state operations/);
|
||||
assert.throws(() => Object.preventExtensions(note), /state operations/);
|
||||
assert.throws(() => Object.getOwnPropertyDescriptor(note, 'items').value.push({}), /state operations/);
|
||||
app.updateItem(note, 0, { done: true });
|
||||
assert.equal(note.items[0].done, true);
|
||||
assert.equal(app.selectId('a'), undefined, 'selection operations do not leak the mutable set');
|
||||
assert.deepEqual([...app.state.selectedIds], ['a']);
|
||||
assert.throws(() => app.state.selectedIds.valueOf().clear(), /selection operations/);
|
||||
app.state.selectedIds.forEach((_value, _key, set) => {
|
||||
assert.equal(set, app.state.selectedIds);
|
||||
assert.throws(() => set.clear(), /selection operations/);
|
||||
});
|
||||
app.mergeNote(note, { title: 'Changed' });
|
||||
assert.equal(note.title, 'Changed');
|
||||
});
|
||||
|
||||
test('fetch preserves archived query, failures clear notes, and successful fallback shapes', async () => {
|
||||
const app = createNotesState(), requests = [];
|
||||
let response = { ok: true, json: async () => [{ id: 'a' }] };
|
||||
const workflows = createNotesWorkflows({ app, api: { listNotes: async archived => { requests.push(archived); return response; } }, effects: {} });
|
||||
app.setShowingArchived(true);
|
||||
await workflows._fetchNotes();
|
||||
assert.deepEqual(requests, [true]);
|
||||
assert.equal(app.state.loading, false);
|
||||
assert.equal(app.state.notes[0].id, 'a');
|
||||
response = { ok: false };
|
||||
await workflows._fetchNotes();
|
||||
assert.equal(app.state.notes.length, 0);
|
||||
response = { ok: true, json: async () => ({ notes: [{ id: 'b' }] }) };
|
||||
await workflows._fetchNotes();
|
||||
assert.equal(app.state.notes[0].id, 'b');
|
||||
});
|
||||
|
||||
test('fetch completions retain the original last-completion ordering', async () => {
|
||||
const app = createNotesState(), releases = [];
|
||||
const workflows = createNotesWorkflows({ app, api: { listNotes: () => new Promise(resolve => releases.push(resolve)) }, effects: {} });
|
||||
const first = workflows._fetchNotes(), second = workflows._fetchNotes();
|
||||
releases[1]({ ok: true, json: async () => ({ notes: [{ id: 'second' }] }) });
|
||||
await second;
|
||||
assert.equal(app.state.notes[0].id, 'second');
|
||||
releases[0]({ ok: true, json: async () => ({ notes: [{ id: 'first' }] }) });
|
||||
await first;
|
||||
assert.equal(app.state.notes[0].id, 'first');
|
||||
});
|
||||
|
||||
test('writes built from views store raw data, not read-only views', () => {
|
||||
const app = createNotesState();
|
||||
const raw = [{ id: 'a', items: [{ id: 'x', done: false }, { id: 'y', done: false }] }];
|
||||
app.replaceNotes(raw);
|
||||
const note = app.state.notes[0];
|
||||
app.mergeNote(note, { items: note.items.filter(item => item.id !== 'x') });
|
||||
app.prependNote({ ...note, id: 'b' });
|
||||
app.spliceItems(app.state.notes[1], 1, 0, note.items[0]);
|
||||
assert.doesNotThrow(() => structuredClone(raw));
|
||||
raw[1].items[0].done = true;
|
||||
assert.equal(app.state.notes[1].items[0].done, true);
|
||||
assert.throws(() => { app.state.notes[1].items[0].done = false; }, /state operations/);
|
||||
});
|
||||
|
||||
test('masonry observer is not re-created once Notes is closed', () => {
|
||||
const created = [];
|
||||
const previous = { window: globalThis.window, ResizeObserver: globalThis.ResizeObserver };
|
||||
class FakeObserver { constructor() { created.push(this); this.live = true; } observe() {} disconnect() { this.live = false; } }
|
||||
globalThis.ResizeObserver = FakeObserver;
|
||||
globalThis.window = { ResizeObserver: FakeObserver, matchMedia: () => ({ matches: false }) };
|
||||
try {
|
||||
let open = true;
|
||||
const layout = createLayout({ isOpen: () => open });
|
||||
const body = { isConnected: true, closest: () => ({ classList: { contains: name => name === 'notes-view-grid' } }), querySelector: () => null, querySelectorAll: () => [] };
|
||||
layout._applyMasonry(body);
|
||||
assert.equal(created.filter(o => o.live).length, 1);
|
||||
layout.dispose();
|
||||
open = false;
|
||||
layout._applyMasonry(body);
|
||||
assert.equal(created.filter(o => o.live).length, 0);
|
||||
} finally {
|
||||
globalThis.window = previous.window;
|
||||
globalThis.ResizeObserver = previous.ResizeObserver;
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,105 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import vm from 'node:vm';
|
||||
import { parse } from 'acorn';
|
||||
import { createNotesApi } from '../frontend/src/features/notes/infrastructure/http.js';
|
||||
import { createNotesState } from '../frontend/src/features/notes/application/state.js';
|
||||
import { createNotesWorkflows } from '../frontend/src/features/notes/application/workflows.js';
|
||||
|
||||
const source = fs.readFileSync(new URL('../static/js/notes.js', import.meta.url), 'utf8');
|
||||
const ast = parse(source, { ecmaVersion: 'latest', sourceType: 'module' });
|
||||
const implementation = fs.readFileSync(new URL('../frontend/src/features/notes/index.js', import.meta.url), 'utf8');
|
||||
const implementationAST = parse(implementation, { ecmaVersion: 'latest', sourceType: 'module' });
|
||||
function functions(names, ports = {}) {
|
||||
const datesSource = fs.readFileSync(new URL('../frontend/src/features/notes/domain/recurrence.js', import.meta.url), 'utf8').replaceAll('export ', '');
|
||||
const context = vm.createContext(ports);
|
||||
const datesContext = vm.createContext({ ...ports, clock: { now: () => (ports.Date || Date).now() } });
|
||||
vm.runInContext(datesSource, datesContext);
|
||||
context.dates = datesContext;
|
||||
context.clock = { now: () => (ports.Date || Date).now() };
|
||||
context.api = createNotesApi({ origin: ports.API_BASE, request: ports.fetch });
|
||||
context._saveNote = context.api.saveNote;
|
||||
context._patchNote = context.api.patchNote;
|
||||
context._deleteNoteApi = context.api.deleteNote;
|
||||
context.workflows = createNotesWorkflows({ app: createNotesState(), api: context.api, effects: {} });
|
||||
for (const statement of implementationAST.body) {
|
||||
const node = statement.declaration || statement;
|
||||
if (node.type === 'FunctionDeclaration' && names.includes(node.id.name)) {
|
||||
vm.runInContext(implementation.slice(node.start, node.end), context);
|
||||
}
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
test('legacy public namespace and default API are intentional', () => {
|
||||
const names = [];
|
||||
for (const node of ast.body) {
|
||||
if (node.type !== 'ExportNamedDeclaration') continue;
|
||||
if (node.declaration) names.push(node.declaration.id.name);
|
||||
for (const spec of node.specifiers) if (spec.exported.name !== 'default') names.push(spec.exported.name);
|
||||
}
|
||||
assert.deepEqual(names.sort(), ['closeNotes', 'closePanel', 'dismissFiredReminderDot', 'isNotesOpen', 'isPanelOpen', 'newNote', 'openNote', 'openNotes', 'openPanel', 'refreshDueBadge', 'togglePanel']);
|
||||
assert.match(source, /window.notesModule = notesModule/);
|
||||
});
|
||||
|
||||
test('CRUD preserves same-origin credentials, payloads and error contracts', async () => {
|
||||
const requests = [];
|
||||
let ok = true;
|
||||
const api = functions(['_saveNote', '_patchNote', '_deleteNoteApi'], {
|
||||
API_BASE: 'https://notes.invalid',
|
||||
fetch: async (url, options) => {
|
||||
requests.push({ url, ...options });
|
||||
return { ok, status: 403, json: async () => ({ id: 'saved' }) };
|
||||
},
|
||||
});
|
||||
await api._saveNote({ title: 'New' });
|
||||
await api._saveNote({ id: 'a', title: '' });
|
||||
await api._patchNote('a', { due_date: null });
|
||||
await api._deleteNoteApi('a');
|
||||
assert.deepEqual(requests.map(r => [r.url, r.method, r.credentials]), [
|
||||
['https://notes.invalid/api/notes', 'POST', 'same-origin'],
|
||||
['https://notes.invalid/api/notes/a', 'PUT', 'same-origin'],
|
||||
['https://notes.invalid/api/notes/a', 'PUT', 'same-origin'],
|
||||
['https://notes.invalid/api/notes/a', 'DELETE', 'same-origin'],
|
||||
]);
|
||||
assert.equal(requests[2].body, '{"due_date":null}');
|
||||
ok = false;
|
||||
await assert.rejects(api._saveNote({}), /Failed to save note/);
|
||||
await assert.rejects(api._patchNote('a', {}), /Failed to update note/);
|
||||
await assert.rejects(api._deleteNoteApi('a'), /HTTP 403/);
|
||||
});
|
||||
|
||||
test('recurrence clock is read after first advancement and never for none', () => {
|
||||
const calls = [];
|
||||
class ClockDate extends Date {
|
||||
static now() { calls.push('clock'); return Date.parse('2024-02-01T12:00'); }
|
||||
setDate(value) { calls.push('step'); return super.setDate(value); }
|
||||
}
|
||||
const domain = functions(['_normalizeRepeat', '_advanceRecurring', '_toLocalDatetimeStr', '_nthWeekdayOfMonth', '_lastWeekdayOfMonth'], { Date: ClockDate });
|
||||
assert.equal(domain.dates._advanceRecurring('2024-02-01T09:00', 'daily', domain.clock), '2024-02-02T09:00');
|
||||
assert.deepEqual(calls, ['step', 'clock']);
|
||||
calls.length = 0;
|
||||
assert.equal(domain.dates._advanceRecurring('2024-02-01T09:00', 'none', domain.clock), null);
|
||||
assert.deepEqual(calls, []);
|
||||
});
|
||||
|
||||
test('archive failure restores position; undo failure rolls back reinsertion', async () => {
|
||||
const notes = [{ id: 'a' }, { id: 'b' }, { id: 'c' }], errors = [];
|
||||
const stateOwner = createNotesState();
|
||||
stateOwner.replaceNotes(notes);
|
||||
const app = createNotesWorkflows({
|
||||
app: stateOwner,
|
||||
api: { patchNote: async () => { throw new Error('refused'); } },
|
||||
effects: { render: () => {}, showError: e => errors.push(e) },
|
||||
});
|
||||
assert.equal(app._archiveNoteById('b'), true);
|
||||
assert.deepEqual(notes.map(n => n.id), ['a', 'c']);
|
||||
await new Promise(setImmediate);
|
||||
assert.deepEqual(notes.map(n => n.id), ['a', 'b', 'c']);
|
||||
notes.splice(1, 1);
|
||||
app._popAndRunUndo();
|
||||
await new Promise(setImmediate);
|
||||
assert.deepEqual(notes.map(n => n.id), ['a', 'c']);
|
||||
assert.deepEqual(errors, ['Failed to archive', 'Undo failed']);
|
||||
});
|
||||
@@ -1,21 +1,28 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import vm from 'node:vm';
|
||||
import { createNotesPersistence } from '../frontend/src/features/notes/infrastructure/storage.js';
|
||||
import { createNotesState } from '../frontend/src/features/notes/application/state.js';
|
||||
import { createNotesDrafts } from '../frontend/src/features/notes/application/drafts.js';
|
||||
import { createDraftEditor } from '../frontend/src/features/notes/ui/draftEditor.js';
|
||||
|
||||
const source = fs.readFileSync(new URL('../static/js/notes.js', import.meta.url), 'utf8');
|
||||
function harness(patch = async () => ({})) {
|
||||
const data = new Map(), timers = new Map(), errors = [];
|
||||
let nextTimer = 0;
|
||||
const context = vm.createContext({
|
||||
const context = {
|
||||
localStorage: { getItem: k => data.get(k) ?? null, setItem: (k,v) => data.set(k,v), removeItem: k => data.delete(k) },
|
||||
setTimeout: f => { timers.set(++nextTimer, f); return nextTimer; },
|
||||
clearTimeout: id => timers.delete(id),
|
||||
_patchNote: patch, _notes: [{id:'note1'}],
|
||||
_collectItems: form => form.items,
|
||||
uiModule: {showError: message => errors.push(message)},
|
||||
});
|
||||
vm.runInContext(source.slice(source.indexOf("const _DRAFT_PREFIX"), source.indexOf('// ---- Create / Edit Form ----')), context);
|
||||
};
|
||||
context.persistence = createNotesPersistence({ storage: context.localStorage });
|
||||
context.app = createNotesState();
|
||||
context.app.replaceNotes(context._notes);
|
||||
context.state = context.app.state;
|
||||
Object.assign(context, context.persistence);
|
||||
const drafts = createNotesDrafts({ app: context.app, api: { patchNote: patch }, persistence: context.persistence, scheduler: context, effects: context.uiModule });
|
||||
Object.assign(context, createDraftEditor({ ...context, ...context.persistence, bindDraft: drafts.bindDraft }));
|
||||
const fields = {'.note-form-title':{value:'Title'}, '.note-form-content':{value:'Original'}};
|
||||
const handlers = {};
|
||||
const form = { dataset:{noteType:'note'}, items:[], querySelector: s => fields[s], addEventListener:(n,f) => handlers[n]=f };
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createNotesApi } from '../frontend/src/features/notes/infrastructure/http.js';
|
||||
import { createNotesPersistence, REMINDER_FIRED_KEY } from '../frontend/src/features/notes/infrastructure/storage.js';
|
||||
|
||||
test('list, reorder, upload, reminder and agent transports retain exact requests', async () => {
|
||||
const calls = [], result = { ok: false, status: 403 };
|
||||
const api = createNotesApi({ origin: 'https://notes.invalid', request: async (...args) => { calls.push(args); return result; } });
|
||||
const body = { opaque: true };
|
||||
assert.equal(await api.listNotes(true), result);
|
||||
await api.listNotes(false); await api.reorder(['b', 'a']); await api.upload(body);
|
||||
await api.fireReminder({ note_id: 'a' }); await api.defaultChat();
|
||||
await api.createSession(body); await api.startChat(body);
|
||||
assert.deepEqual(calls.map(([url, o]) => [url, o.method || 'GET', o.credentials]), [
|
||||
['https://notes.invalid/api/notes?archived=true', 'GET', 'same-origin'],
|
||||
['https://notes.invalid/api/notes', 'GET', 'same-origin'],
|
||||
['https://notes.invalid/api/notes/reorder', 'POST', 'same-origin'],
|
||||
['https://notes.invalid/api/upload', 'POST', 'same-origin'],
|
||||
['/api/notes/fire-reminder', 'POST', 'same-origin'],
|
||||
['https://notes.invalid/api/default-chat', 'GET', 'same-origin'],
|
||||
['https://notes.invalid/api/session', 'POST', 'same-origin'],
|
||||
['https://notes.invalid/api/chat_stream', 'POST', 'same-origin'],
|
||||
]);
|
||||
assert.equal(calls[2][1].body, '{"ids":["b","a"]}');
|
||||
assert.equal(calls[3][1].body, body);
|
||||
assert.equal(api.uploadUrl('file'), 'https://notes.invalid/api/upload/file');
|
||||
});
|
||||
|
||||
test('draft and marker persistence retain keys, formats and malformed fallback', () => {
|
||||
const values = new Map();
|
||||
const storage = { getItem: key => values.get(key) ?? null, setItem: (key, value) => values.set(key, value), removeItem: key => values.delete(key) };
|
||||
const persistence = createNotesPersistence({ storage });
|
||||
assert.equal(persistence._draftKey(null), 'odysseus-note-draft-__new__');
|
||||
persistence._saveFiredReminders(new Set(['a', 'b']));
|
||||
assert.equal(values.get(REMINDER_FIRED_KEY), '["a","b"]');
|
||||
assert.deepEqual([...persistence._loadFiredReminders()], ['a', 'b']);
|
||||
values.set(REMINDER_FIRED_KEY, '{broken');
|
||||
assert.equal(persistence._loadFiredReminders().size, 0);
|
||||
storage.setItem(persistence._draftKey('a'), '{"content":"","_ts":1}');
|
||||
assert.deepEqual(persistence._loadDraft('a'), { content: '', _ts: 1 });
|
||||
persistence._clearDraft('a');
|
||||
assert.equal(persistence._loadDraft('a'), null);
|
||||
const failing = createNotesPersistence({ storage: { getItem() { throw Error('blocked'); }, setItem() { throw Error('full'); } } });
|
||||
assert.equal(failing._loadDraft('a'), null);
|
||||
assert.doesNotThrow(() => failing._saveFiredReminders(new Set(['a'])));
|
||||
assert.throws(() => failing.setItem('odysseus-note-draft-a', '{}'), /full/, 'draft caller must observe backup failure');
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import vm from 'node:vm';
|
||||
import * as rules from '../frontend/src/features/notes/domain/recurrence.js';
|
||||
|
||||
const baseline = fs.readFileSync(new URL('./fixtures/notesRecurrenceBaseline.mjs', import.meta.url), 'utf8');
|
||||
const timed = new Set(['_formatDueDate', '_isDueOverdue', '_isDueTodayOrOverdue', '_laterTodayDate', '_tomorrowDate', '_nextWeekDate', '_formatReminderTag', '_snapToRepeat', '_advanceRecurring']);
|
||||
for (const zone of ['UTC', 'Europe/Lisbon', 'America/New_York', 'Australia/Sydney']) {
|
||||
test(`recurrence differential oracle in ${zone}`, () => {
|
||||
const oldZone = process.env.TZ;
|
||||
process.env.TZ = zone;
|
||||
try {
|
||||
const now = Date.parse('2024-03-10T01:59:00');
|
||||
class FixedDate extends Date { constructor(...args) { super(...(args.length ? args : [now])); } static now() { return now; } }
|
||||
const original = vm.createContext({ Date: FixedDate });
|
||||
vm.runInContext(baseline, original);
|
||||
const compare = (name, args) => {
|
||||
const before = original[name](...args);
|
||||
const after = rules[name](...args, ...(timed.has(name) ? [{ now: () => now }] : []));
|
||||
assert.equal(after instanceof Date ? after.getTime() : after, before instanceof Date ? before.getTime() : before, `${name}(${args})`);
|
||||
};
|
||||
const dates = ['2024-01-31T09:17', '2024-02-29T09:17', '2023-02-28T09:17', '2024-03-09T02:30', '2024-11-02T01:30', '2030-07-31T18:42', '1900-01-01T09:00', 'invalid', '2024-03-10'];
|
||||
const repeats = [null, '', 'none', 'daily', 'weekly', 'monthly', 'yearly', 'monthly_nth_weekday', 'monthly_last_weekday', 'weekly:1', 'weekly:bad', 'monthly:day:31', 'monthly:nth:5:1', 'monthly:last:0', 'monthly:bad', 'unknown'];
|
||||
for (const date of dates) {
|
||||
for (const repeat of repeats) {
|
||||
compare('_advanceRecurring', [date, repeat]);
|
||||
compare('_normalizeRepeat', [repeat, new Date(date)]);
|
||||
compare('_formatRepeatLabel', [repeat, new Date(date)]);
|
||||
}
|
||||
for (const name of ['_hasTimeComponent', '_formatDueDate', '_isDueOverdue', '_isDueTodayOrOverdue', '_formatReminderTag']) compare(name, [date]);
|
||||
for (const repeat of ['weekly:1', 'monthly:day:31', 'monthly:nth:5:1', 'monthly:last:0', 'daily', 'monthly:bad']) compare('_snapToRepeat', [new Date(date), repeat]);
|
||||
}
|
||||
for (const name of ['_laterTodayDate', '_tomorrowDate', '_nextWeekDate']) compare(name, []);
|
||||
for (const year of [2023, 2024]) for (let month = 0; month < 12; month++) for (let wd = 0; wd < 7; wd++) {
|
||||
compare('_nthWeekdayOfMonth', [year, month, wd, 5]);
|
||||
compare('_lastWeekdayOfMonth', [year, month, wd]);
|
||||
}
|
||||
} finally { if (oldZone === undefined) delete process.env.TZ; else process.env.TZ = oldZone; }
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createNotesState } from '../frontend/src/features/notes/application/state.js';
|
||||
import { createNotesReminders } from '../frontend/src/features/notes/application/reminders.js';
|
||||
import { createNotesNotifications } from '../frontend/src/features/notes/infrastructure/notifications.js';
|
||||
import { createNotesPersistence, REMINDER_DISMISSED_AT_KEY } from '../frontend/src/features/notes/infrastructure/storage.js';
|
||||
|
||||
function harness(notes = []) {
|
||||
const now = Date.parse('2024-03-10T09:00'), app = createNotesState();
|
||||
app.replaceNotes(notes);
|
||||
const values = new Map(), timeouts = new Map(), intervals = new Map(), fired = [], shown = [], highlighted = [], patches = [];
|
||||
let nextId = 0, response = Promise.resolve({ ok: true, json: async () => ({ synthesis: 'Synthesis' }) });
|
||||
const scheduler = {
|
||||
setTimeout: (fn, ms) => { const id = ++nextId; timeouts.set(id, { fn, ms }); return id; }, clearTimeout: id => timeouts.delete(id),
|
||||
setInterval: (fn, ms) => { const id = ++nextId; intervals.set(id, { fn, ms }); return id; }, clearInterval: id => intervals.delete(id),
|
||||
};
|
||||
const persistence = createNotesPersistence({ storage: { getItem: key => values.get(key), setItem: (key, value) => values.set(key, value) } });
|
||||
const reminders = createNotesReminders({
|
||||
app, clock: { now: () => now }, scheduler, persistence, dismissedKey: REMINDER_DISMISSED_AT_KEY,
|
||||
notifications: { show: (...args) => shown.push(args) }, fetchNotes: async () => {},
|
||||
hasItems: note => ['todo', 'goal', 'checklist'].includes(note.note_type), isNoteFullyDone: () => false,
|
||||
api: { listNotes: async () => ({ ok: true, json: async () => ({ notes }) }), patchNote: async (...args) => patches.push(args), fireReminder: body => { fired.push(body); return response; } },
|
||||
effects: { badge: () => {}, highlight: id => highlighted.push(id) },
|
||||
});
|
||||
return { now, app, reminders, timeouts, intervals, fired, shown, highlighted, patches, persistence, setResponse: value => { response = value; } };
|
||||
}
|
||||
|
||||
test('startup and loop are idempotent, and panel closure leaves background scheduling alive', async () => {
|
||||
const h = harness();
|
||||
h.reminders.start(); h.reminders.start();
|
||||
assert.deepEqual([...h.timeouts.values()].map(t => t.ms), [3000]);
|
||||
await [...h.timeouts.values()][0].fn();
|
||||
h.reminders._startReminderLoop();
|
||||
assert.deepEqual([...h.intervals.values()].map(t => t.ms), [30000]);
|
||||
h.app.setOpen(false);
|
||||
assert.equal(h.intervals.size, 1);
|
||||
h.reminders.dispose();
|
||||
assert.equal(h.intervals.size, 0);
|
||||
h.reminders._startReminderLoop();
|
||||
assert.equal(h.intervals.size, 0);
|
||||
});
|
||||
|
||||
test('fresh reminders dispatch once, stale reminders mark silently, recurring reminders advance', async () => {
|
||||
const now = Date.parse('2024-03-10T09:00');
|
||||
const local = value => { const d = new Date(value); return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}T${String(d.getHours()).padStart(2,'0')}:${String(d.getMinutes()).padStart(2,'0')}:30`; };
|
||||
const h = harness([
|
||||
{ id: 'fresh', due_date: local(now - 30000), content: 'Body' },
|
||||
{ id: 'old', due_date: local(now - 180000) },
|
||||
{ id: 'recurring', due_date: local(now - 180000), repeat: 'daily' },
|
||||
{ id: 'date', due_date: '2024-03-10' },
|
||||
{ id: 'archived', archived: true, due_date: local(now - 30000) },
|
||||
]);
|
||||
h.reminders._checkReminders(); h.reminders._checkReminders();
|
||||
await new Promise(setImmediate);
|
||||
assert.deepEqual(h.fired.map(n => n.note_id), ['fresh']);
|
||||
assert.deepEqual([...h.persistence._loadFiredReminders()], ['fresh', 'old']);
|
||||
assert.equal(h.patches.length, 1);
|
||||
assert.equal(h.app.state.notes[2].due_date, '2024-03-11T08:57');
|
||||
assert.deepEqual(h.highlighted, ['fresh']);
|
||||
assert.equal(h.shown[0][1], 'Synthesis');
|
||||
});
|
||||
|
||||
test('slow synthesis and rejected dispatch use the local fallback exactly once', async () => {
|
||||
const h = harness();
|
||||
let resolve;
|
||||
h.setResponse(new Promise(r => { resolve = r; }));
|
||||
h.reminders._fireReminder({ id: 'a', title: 'Title', content: 'Raw' });
|
||||
const timer = [...h.timeouts.values()].find(t => t.ms === 1500);
|
||||
timer.fn();
|
||||
resolve({ ok: true, json: async () => ({ synthesis: 'Too late' }) });
|
||||
await new Promise(setImmediate);
|
||||
assert.deepEqual(h.shown, [['Title', 'Raw', 'a']]);
|
||||
h.setResponse(Promise.reject(Error('offline')));
|
||||
h.reminders._fireReminder({ id: 'b', note_type: 'todo', items: [{ text: 'Task', done: false }] });
|
||||
await new Promise(setImmediate);
|
||||
assert.equal(h.shown.length, 2);
|
||||
assert.equal(h.fired[1].body, 'Pending (1):\n- Task');
|
||||
});
|
||||
|
||||
test('notification denial, unsupported browsers and permission errors remain fail-safe', async () => {
|
||||
const toast = [];
|
||||
const absent = createNotesNotifications({ browser: {}, toast: title => toast.push(title) });
|
||||
assert.equal(await absent.ensurePermission(), false);
|
||||
absent.show('Title', 'Body', 'a');
|
||||
assert.deepEqual(toast, ['Title']);
|
||||
const denied = createNotesNotifications({ browser: { Notification: true }, notify: { permission: 'denied' } });
|
||||
assert.equal(await denied.ensurePermission(), false);
|
||||
const failed = createNotesNotifications({ browser: { Notification: true }, notify: { permission: 'default', requestPermission: async () => { throw Error('blocked'); } } });
|
||||
assert.equal(await failed.ensurePermission(), false);
|
||||
});
|
||||
@@ -0,0 +1,147 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createNotesState } from '../frontend/src/features/notes/application/state.js';
|
||||
import { createCardOperations } from '../frontend/src/features/notes/application/cardOperations.js';
|
||||
import { createChecklistOperations } from '../frontend/src/features/notes/application/checklistOperations.js';
|
||||
import { createNotesSubmission } from '../frontend/src/features/notes/application/submission.js';
|
||||
import { createSelectionOperations } from '../frontend/src/features/notes/application/selection.js';
|
||||
import { createNotesPersistence } from '../frontend/src/features/notes/infrastructure/storage.js';
|
||||
import { createNotesLifecycle } from '../frontend/src/features/notes/ui/lifecycle.js';
|
||||
import { createText } from '../frontend/src/features/notes/ui/text.js';
|
||||
import { createDeletion } from '../frontend/src/features/notes/application/deletion.js';
|
||||
|
||||
const drain = () => new Promise(setImmediate);
|
||||
function owner(notes) { const app = createNotesState(); app.replaceNotes(notes); return app; }
|
||||
|
||||
test('reorder retains desktop persistence-only and mobile post-response state ordering', async () => {
|
||||
const app = owner([{ id: 'a', sort_order: 9 }, { id: 'b', sort_order: 8 }]), requests = [];
|
||||
let resolve;
|
||||
const operations = createCardOperations({ app, api: { reorder: ids => { requests.push(ids); return new Promise(done => { resolve = done; }); } } });
|
||||
const desktop = operations.persistOrder(['b', 'a']);
|
||||
resolve({ ok: false }); await desktop;
|
||||
assert.deepEqual(app.state.notes.map(note => note.sort_order), [9, 8]);
|
||||
const mobile = operations.commitMobileOrder(['b', 'a']);
|
||||
assert.deepEqual(app.state.notes.map(note => note.sort_order), [9, 8]);
|
||||
resolve({ ok: false }); await mobile;
|
||||
assert.deepEqual(app.state.notes.map(note => note.sort_order), [1, 0]);
|
||||
assert.deepEqual(requests, [['b', 'a'], ['b', 'a']]);
|
||||
});
|
||||
|
||||
test('editor deletion waits for confirmation and preserves optimistic failure/refetch timing', async () => {
|
||||
const app = owner([{ id: 'a' }]), events = [];
|
||||
app.setEditingId('a');
|
||||
let confirm, fail;
|
||||
const operations = createDeletion({ app, state: app.state,
|
||||
_deleteNoteApi: id => { events.push('delete:' + id); return new Promise((_resolve, reject) => { fail = reject; }); },
|
||||
_fetchNotes: async () => { events.push('fetch'); app.replaceNotes([{ id: 'a' }]); },
|
||||
_renderNotes: () => events.push('render'),
|
||||
effects: { styledConfirm: () => new Promise(resolve => { confirm = resolve; }), showToast: () => events.push('toast'), showError: error => events.push(error) },
|
||||
});
|
||||
const deletion = operations.deleteFromEditor('a');
|
||||
assert.equal(app.state.notes.length, 1);
|
||||
confirm(false); await deletion;
|
||||
assert.deepEqual(events, []);
|
||||
const accepted = operations.deleteFromEditor('a');
|
||||
confirm(true); await accepted;
|
||||
assert.equal(app.state.notes.length, 0);
|
||||
assert.equal(app.state.editingId, null);
|
||||
assert.deepEqual(events, ['render', 'delete:a']);
|
||||
fail(Error('403')); await drain();
|
||||
assert.equal(app.state.notes[0].id, 'a');
|
||||
assert.deepEqual(events, ['render', 'delete:a', 'Failed to delete', 'fetch', 'render']);
|
||||
});
|
||||
|
||||
test('pin and delete failures roll back captured state and order', async () => {
|
||||
const app = owner([{ id: 'a', pinned: false, sort_order: 4 }, { id: 'b', pinned: true, sort_order: -3 }]), errors = [];
|
||||
const operations = createCardOperations({ app, api: { patchNote: async () => { throw Error('403'); }, deleteNote: async () => { throw Error('403'); } }, effects: { render() {}, showError: error => errors.push(error) } });
|
||||
operations.togglePin('a');
|
||||
assert.equal(app.state.notes[0].pinned, true);
|
||||
assert.equal(app.state.notes[0].sort_order, -4);
|
||||
await drain();
|
||||
assert.equal(app.state.notes[0].pinned, false);
|
||||
assert.equal(app.state.notes[0].sort_order, 4);
|
||||
operations.deleteOptimistically('a');
|
||||
assert.deepEqual(app.state.notes.map(n => n.id), ['b']);
|
||||
await drain();
|
||||
assert.deepEqual(app.state.notes.map(n => n.id), ['a', 'b']);
|
||||
assert.deepEqual(errors, ['Failed to pin', 'Failed to delete']);
|
||||
});
|
||||
|
||||
test('item removal, quick add and checkbox errors preserve checklist contents', async () => {
|
||||
const app = owner([{ id: 'a', items: [{ id: 'first', text: 'A', done: false }, { id: 'second', text: 'B', done: true }] }]);
|
||||
const operations = createChecklistOperations({ app, api: { patchNote: async () => { throw Error('offline'); } }, uid: () => 'new', effects: { render() {}, showError() {} } });
|
||||
operations.removeItem('a', 0);
|
||||
assert.equal(app.state.notes[0].items[0].id, 'second');
|
||||
await drain();
|
||||
assert.deepEqual(app.state.notes[0].items.map(it => it.id), ['first', 'second']);
|
||||
const done = [];
|
||||
operations.toggleItem('a', 0, value => done.push(value), () => {});
|
||||
await drain();
|
||||
assert.deepEqual(done, [true, false]);
|
||||
const persist = operations.addItem('a', 'C', () => {});
|
||||
assert.equal(app.state.notes[0].items.length, 3);
|
||||
persist(); await drain();
|
||||
assert.equal(app.state.notes[0].items.length, 2);
|
||||
operations.editItem(app.state.notes[0], 1, 'Changed');
|
||||
await drain();
|
||||
assert.equal(app.state.notes[0].items[1].text, 'B');
|
||||
});
|
||||
|
||||
test('submission preserves top ordering, server ID replacement and newer draft recovery', async () => {
|
||||
const app = owner([{ id: 'existing', sort_order: -2 }]), values = new Map();
|
||||
const persistence = createNotesPersistence({ storage: { getItem: key => values.get(key), setItem: (key, value) => values.set(key, value), removeItem: key => values.delete(key) } });
|
||||
const draftKey = persistence._draftKey('__new__');
|
||||
values.set(draftKey, '{"content":"submitted"}');
|
||||
let resolve, renders = 0;
|
||||
const operations = createNotesSubmission({ app, persistence, clock: { now: () => 0 }, uid: () => 'id', api: { saveNote: () => new Promise(r => { resolve = r; }) }, effects: { render: () => renders++, showError() {} }, fetchNotes: async () => {} });
|
||||
operations.submitNote({ title: 'New' }, null, false);
|
||||
assert.equal(app.state.notes[0].id, 'tmp_id');
|
||||
assert.equal(app.state.notes[0].sort_order, -3);
|
||||
values.set(draftKey, '{"content":"newer"}');
|
||||
resolve({ id: 'server-id' }); await drain();
|
||||
assert.equal(app.state.notes[0].id, 'server-id');
|
||||
assert.equal(values.get(draftKey), '{"content":"newer"}');
|
||||
assert.equal(renders, 2);
|
||||
});
|
||||
|
||||
test('selection can exit only after synchronous acceptance', () => {
|
||||
const app = owner([]), order = [];
|
||||
app.selectId('a');
|
||||
let accepted = false;
|
||||
const operations = createSelectionOperations({ app, sendContext: detail => { order.push('dispatch'); detail.accepted = accepted; }, exitSelection: () => order.push('exit'), effects: { showToast: () => order.push('rejected') } });
|
||||
operations.attachSelected();
|
||||
assert.deepEqual(order, ['dispatch', 'rejected']);
|
||||
accepted = true; operations.attachSelected();
|
||||
assert.deepEqual(order, ['dispatch', 'rejected', 'dispatch', 'exit']);
|
||||
});
|
||||
|
||||
test('listener owners release on replacement and disposal is idempotent', () => {
|
||||
const previous = { document: globalThis.document, MutationObserver: globalThis.MutationObserver };
|
||||
let check, disconnected = 0;
|
||||
globalThis.document = { body: {} };
|
||||
globalThis.MutationObserver = class { constructor(callback) { check = callback; } observe() {} disconnect() { disconnected++; } };
|
||||
try {
|
||||
const lifecycle = createNotesLifecycle(), target = new EventTarget(), canvas = { isConnected: true };
|
||||
let calls = 0;
|
||||
lifecycle.listen(canvas, target, 'mouseup', () => calls++);
|
||||
let released = 0;
|
||||
lifecycle.onDispose(canvas, () => released++);
|
||||
target.dispatchEvent(new Event('mouseup'));
|
||||
canvas.isConnected = false; check();
|
||||
target.dispatchEvent(new Event('mouseup'));
|
||||
lifecycle.dispose(); lifecycle.dispose();
|
||||
assert.equal(calls, 1); assert.equal(disconnected, 1);
|
||||
assert.equal(released, 1);
|
||||
} finally {
|
||||
for (const [name, value] of Object.entries(previous)) if (value === undefined) delete globalThis[name]; else globalThis[name] = value;
|
||||
}
|
||||
});
|
||||
|
||||
test('render helpers reject script-capable media and escape URL attributes', () => {
|
||||
const helpers = createText({ uiModule: { esc: value => value.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>') } });
|
||||
for (const value of ['javascript:alert(1)', 'data:image/svg+xml;base64,abc', 'data:text/html;base64,abc']) assert.equal(helpers._safeImgSrc(value), '');
|
||||
assert.equal(helpers._safeImgSrc('data:image/png;base64,abc'), 'data:image/png;base64,abc');
|
||||
assert.equal(helpers._attrEsc('"\'<>`'), '"'<>`');
|
||||
assert.match(helpers._linkify('<script> https://example.org/test'), /<script>/);
|
||||
assert.doesNotMatch(helpers._linkify('<script> https://example.org/test'), /<script>/i);
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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\)/);
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -20,4 +20,3 @@ def test_terse_timed_calendar_create_promotes_to_calendar():
|
||||
|
||||
assert intent.needs_tools
|
||||
assert intent.category == "calendar"
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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 == []
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"})
|
||||
@@ -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():
|
||||
|
||||
@@ -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.'}
|
||||
|
||||
@@ -281,7 +281,8 @@ async def test_browser_approval_application_binding_is_exact(producer, field, va
|
||||
original = authority()
|
||||
content = '{"action":"session_info"}'
|
||||
approval = approval_for(original, "private_browser", content)
|
||||
changed = replace(original, **{field: value}, browser_sessions=(), browser_pages=())
|
||||
# resource_roots=None re-seals the root for the changed owner.
|
||||
changed = replace(original, **{field: value}, browser_sessions=(), browser_pages=(), resource_roots=None)
|
||||
_, result = await dispatch(changed, "private_browser", content, approval)
|
||||
assert result["exit_code"] == 1 and not approval._claimed
|
||||
|
||||
|
||||
@@ -0,0 +1,387 @@
|
||||
"""Offline regressions for incident roots, unaffected roots, and CA directories."""
|
||||
|
||||
import contextlib
|
||||
import hashlib
|
||||
import importlib.util
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import ssl
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from types import SimpleNamespace
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
SPEC = importlib.util.spec_from_file_location("check_ca_trust", ROOT / "scripts/check_ca_trust.py")
|
||||
checker = importlib.util.module_from_spec(SPEC)
|
||||
SPEC.loader.exec_module(checker)
|
||||
INCIDENT = json.loads((ROOT / "tests/fixtures/ca-trust/incident.json").read_text())
|
||||
|
||||
|
||||
class CATrustTests(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
# Public certificate data is embedded below. Tests create temporary
|
||||
# PEM files from these constants; no fixture contains a private key.
|
||||
cls.temporary = tempfile.TemporaryDirectory()
|
||||
cls.fixtures = Path(cls.temporary.name)
|
||||
for name, pem in PUBLIC_CERTIFICATES.items():
|
||||
(cls.fixtures / name).write_text(pem)
|
||||
for fingerprint, pem in INCIDENT["certificates"].items():
|
||||
if hashlib.sha256(ssl.PEM_cert_to_DER_cert(pem)).hexdigest() != fingerprint:
|
||||
raise ValueError("incident fixture fingerprint mismatch")
|
||||
(cls.fixtures / f"{fingerprint}.pem").write_text(pem)
|
||||
|
||||
@classmethod
|
||||
def tearDownClass(cls):
|
||||
cls.temporary.cleanup()
|
||||
|
||||
def test_unaffected_emsign_root_remains_usable(self):
|
||||
checker.check_bundle(self.fixtures / "emsign-g1.pem")
|
||||
|
||||
def test_verbose_python_check_reports_and_rejects_affected_user_bundle(self):
|
||||
# A fresh interpreter must see persisted user packages, independently
|
||||
# of pytest's imported certifi/pip modules and any active test venv.
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
home = Path(tmp)
|
||||
env = dict(os.environ, HOME=str(home), PYTHONUSERBASE=str(home / ".local"))
|
||||
env.pop("PYTHONNOUSERSITE", None)
|
||||
env.pop("PYTHONPATH", None)
|
||||
python = getattr(sys, "_base_executable", sys.executable)
|
||||
site_result = subprocess.run(
|
||||
[python, "-m", "site", "--user-site"], env=env, capture_output=True, text=True,
|
||||
)
|
||||
self.assertEqual(site_result.returncode, 0, site_result.stderr)
|
||||
user_site = Path(site_result.stdout.strip())
|
||||
for package in ("certifi", "pip", "pip/_vendor", "pip/_vendor/certifi"):
|
||||
directory = user_site / package
|
||||
directory.mkdir(parents=True, exist_ok=True)
|
||||
(directory / "__init__.py").write_text(
|
||||
"from pathlib import Path\ndef where(): return str(Path(__file__).with_name('cacert.pem'))\n"
|
||||
)
|
||||
application_bundle = user_site / "certifi/cacert.pem"
|
||||
pip_bundle = user_site / "pip/_vendor/certifi/cacert.pem"
|
||||
for bundle in (application_bundle, pip_bundle):
|
||||
bundle.write_text(PUBLIC_CERTIFICATES["emsign-g1.pem"])
|
||||
command = [python, str(ROOT / "scripts/check_ca_trust.py"), "--python", "--verbose"]
|
||||
clean = subprocess.run(command, env=env, capture_output=True, text=True)
|
||||
self.assertEqual(clean.returncode, 0, clean.stderr)
|
||||
self.assertIn(str(application_bundle), clean.stdout)
|
||||
self.assertIn(str(pip_bundle), clean.stdout)
|
||||
self.assertIn(str(home), clean.stdout)
|
||||
self.assertIn("Python executable:", clean.stdout)
|
||||
application_bundle.write_text(PUBLIC_CERTIFICATES["emsign-g3.pem"])
|
||||
affected = subprocess.run(command, env=env, capture_output=True, text=True)
|
||||
self.assertEqual(affected.returncode, 1, affected.stderr)
|
||||
self.assertIn(str(application_bundle), affected.stderr)
|
||||
self.assertIn("emSign ECC Root CA - G3", affected.stderr)
|
||||
|
||||
def test_compromised_emsign_path_is_rejected(self):
|
||||
with self.assertRaisesRegex(ValueError, "emSign ECC Root CA - G3"):
|
||||
checker.check_bundle(self.fixtures / "emsign-g3.pem")
|
||||
|
||||
def test_aaa_cross_sign_path_is_rejected(self):
|
||||
with self.assertRaisesRegex(ValueError, "AAA Certificate Services"):
|
||||
checker.check_bundle(self.fixtures / "aaa.pem")
|
||||
|
||||
def test_mixed_bundle_does_not_hide_an_affected_root(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
bundle = Path(tmp) / "mixed.pem"
|
||||
bundle.write_bytes((self.fixtures / "emsign-g1.pem").read_bytes() + (self.fixtures / "aaa.pem").read_bytes())
|
||||
with self.assertRaisesRegex(ValueError, "AAA Certificate Services"):
|
||||
checker.check_bundle(bundle)
|
||||
|
||||
def test_all_published_intermediates_and_cross_signs_are_rejected(self):
|
||||
self.assertEqual(len(INCIDENT["intermediates"]), 16)
|
||||
for fingerprint, name in INCIDENT["intermediates"].items():
|
||||
with self.subTest(certificate=name), self.assertRaisesRegex(ValueError, "affected CA certificates"):
|
||||
checker.check_bundle(self.fixtures / f"{fingerprint}.pem")
|
||||
|
||||
def test_published_roots_and_cross_signed_issuer_are_rejected(self):
|
||||
certificates = INCIDENT["roots"] | INCIDENT["cross_signed_roots"]
|
||||
self.assertEqual(len(certificates), 5)
|
||||
for fingerprint, name in certificates.items():
|
||||
with self.subTest(certificate=name), self.assertRaises(ValueError):
|
||||
checker.check_bundle(self.fixtures / f"{fingerprint}.pem")
|
||||
|
||||
def test_clean_roots_do_not_hide_an_added_compromised_intermediate(self):
|
||||
fingerprint = next(iter(INCIDENT["intermediates"]))
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
bundle = Path(tmp) / "custom.pem"
|
||||
bundle.write_text(PUBLIC_CERTIFICATES["emsign-g1.pem"] + INCIDENT["certificates"][fingerprint])
|
||||
with self.assertRaisesRegex(ValueError, "EM DV TLS CA - G3B"):
|
||||
checker.check_bundle(bundle)
|
||||
|
||||
@unittest.skipUnless(shutil.which("openssl"), "offline chain verification requires OpenSSL")
|
||||
def test_published_chains_and_custom_anchor_with_affected_positive_control(self):
|
||||
# Freeze time inside the public fixtures' validity interval. Otherwise
|
||||
# expiry could make an ineffective trust-store fix appear successful.
|
||||
help_result = subprocess.run(["openssl", "verify", "-help"], capture_output=True, text=True)
|
||||
no_store = ["-no-CAstore"] if "-no-CAstore" in help_result.stdout + help_result.stderr else []
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
directory = Path(tmp)
|
||||
affected = directory / "affected.pem"
|
||||
affected.write_text("".join(INCIDENT["certificates"][key] for key in INCIDENT["roots"]))
|
||||
clean = self.fixtures / "emsign-g1.pem"
|
||||
custom = directory / "custom.pem"
|
||||
fingerprint = next(iter(INCIDENT["intermediates"]))
|
||||
custom.write_text(clean.read_text() + INCIDENT["certificates"][fingerprint])
|
||||
self.assertEqual(len(INCIDENT["chains"]), 14)
|
||||
custom_accepted = []
|
||||
for hostname, chain in INCIDENT["chains"].items():
|
||||
leaf, intermediates = directory / "leaf.pem", directory / "chain.pem"
|
||||
leaf.write_text(INCIDENT["certificates"][chain[0]])
|
||||
intermediates.write_text("".join(INCIDENT["certificates"][key] for key in chain[1:]))
|
||||
|
||||
def verify(bundle):
|
||||
return subprocess.run([
|
||||
"openssl", "verify", "-no-CApath", *no_store,
|
||||
"-attime", str(INCIDENT["verification_time"]),
|
||||
"-partial_chain", "-x509_strict", "-purpose", "sslserver",
|
||||
"-verify_hostname", hostname, "-CAfile", str(bundle),
|
||||
"-untrusted", str(intermediates), str(leaf),
|
||||
], capture_output=True, text=True)
|
||||
|
||||
with self.subTest(hostname=hostname):
|
||||
positive = verify(affected)
|
||||
self.assertEqual(positive.returncode, 0, positive.stderr)
|
||||
negative = verify(clean)
|
||||
self.assertNotEqual(negative.returncode, 0)
|
||||
self.assertIn("unable to get local issuer certificate", negative.stderr)
|
||||
if verify(custom).returncode == 0:
|
||||
custom_accepted.append(hostname)
|
||||
self.assertCountEqual(custom_accepted, [
|
||||
"dv-g3b-aaa-emudhra.we-have-a-problem.com",
|
||||
"dv-g3b-aaa-noct-emudhra.we-have-a-problem.com",
|
||||
"dv-g3b-emudhra.we-have-a-problem.com",
|
||||
"dv-g3b-noct-emudhra.we-have-a-problem.com",
|
||||
])
|
||||
checker.check_bundle(clean)
|
||||
for bundle in (affected, custom):
|
||||
with self.subTest(bundle=bundle.name), self.assertRaises(ValueError):
|
||||
checker.check_bundle(bundle)
|
||||
|
||||
def test_empty_malformed_and_missing_bundles_fail_closed(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
for content in ("", "not a certificate"):
|
||||
path = Path(tmp) / "bad.pem"
|
||||
path.write_text(content)
|
||||
with self.subTest(content=content), self.assertRaises((ssl.SSLError, ValueError)):
|
||||
checker.check_bundle(path)
|
||||
with self.assertRaises(OSError):
|
||||
checker.check_bundle(Path(tmp) / "missing.pem")
|
||||
|
||||
def test_each_configured_override_is_checked(self):
|
||||
fingerprint = next(iter(INCIDENT["intermediates"]))
|
||||
affected = self.fixtures / f"{fingerprint}.pem"
|
||||
clean = self.fixtures / "emsign-g1.pem"
|
||||
for name in ("LLM_CA_BUNDLE", "SSL_CERT_FILE", "REQUESTS_CA_BUNDLE", "CURL_CA_BUNDLE",
|
||||
"PIP_CERT", "NODE_EXTRA_CA_CERTS", "GRPC_DEFAULT_SSL_ROOTS_FILE_PATH"):
|
||||
for bundle, expected in ((clean, 0), (affected, 1)):
|
||||
with self.subTest(variable=name, bundle=bundle.name), patch.dict(
|
||||
os.environ, {name: str(bundle)}, clear=True
|
||||
), contextlib.redirect_stdout(io.StringIO()), contextlib.redirect_stderr(io.StringIO()):
|
||||
self.assertEqual(checker.main(["--overrides", str(clean)]), expected)
|
||||
|
||||
def test_invalid_override_is_not_hidden_by_a_clean_explicit_bundle(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
directory = Path(tmp)
|
||||
for name, content in (("empty.pem", ""), ("malformed.pem", "invalid")):
|
||||
(directory / name).write_text(content)
|
||||
for name in ("missing.pem", "empty.pem", "malformed.pem"):
|
||||
with self.subTest(file=name), patch.dict(
|
||||
os.environ, {"LLM_CA_BUNDLE": str(directory / name)}, clear=True
|
||||
), contextlib.redirect_stderr(io.StringIO()):
|
||||
self.assertEqual(checker.main(["--overrides", str(self.fixtures / "emsign-g1.pem")]), 1)
|
||||
|
||||
def test_no_configured_overrides_does_not_report_validated_stores(self):
|
||||
with patch.dict(os.environ, {"LLM_CA_BUNDLE": " "}, clear=True), contextlib.redirect_stderr(io.StringIO()):
|
||||
self.assertEqual(checker.main(["--overrides"]), 1)
|
||||
|
||||
def test_requests_directory_override_checks_hashed_intermediates(self):
|
||||
fingerprint = next(iter(INCIDENT["intermediates"]))
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
directory = Path(tmp)
|
||||
for name in ("REQUESTS_CA_BUNDLE", "CURL_CA_BUNDLE", "PIP_CERT"):
|
||||
with self.subTest(variable=name), patch.dict(os.environ, {name: str(directory)}, clear=True):
|
||||
for content, expected in ((PUBLIC_CERTIFICATES["emsign-g1.pem"], 0),
|
||||
(INCIDENT["certificates"][fingerprint], 1)):
|
||||
(directory / "12345678.0").write_text(content)
|
||||
with contextlib.redirect_stdout(io.StringIO()), contextlib.redirect_stderr(io.StringIO()):
|
||||
self.assertEqual(checker.main(["--overrides"]), expected)
|
||||
(directory / "12345678.0").unlink()
|
||||
with contextlib.redirect_stderr(io.StringIO()):
|
||||
self.assertEqual(checker.main(["--overrides"]), 1)
|
||||
|
||||
def test_safe_cafile_does_not_hide_stale_hashed_directory(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
directory = Path(tmp)
|
||||
(directory / "14bc7599.0").symlink_to(self.fixtures / "emsign-g3.pem")
|
||||
defaults = ssl.get_default_verify_paths()._replace(
|
||||
cafile=str(self.fixtures / "emsign-g1.pem"), capath=str(directory)
|
||||
)
|
||||
errors = io.StringIO()
|
||||
with patch.object(checker.ssl, "get_default_verify_paths", return_value=defaults), patch.dict(
|
||||
os.environ, {defaults.openssl_capath_env: str(directory)}
|
||||
):
|
||||
with contextlib.redirect_stderr(errors):
|
||||
result = checker.main(["--system"])
|
||||
self.assertEqual(result, 1)
|
||||
self.assertIn("emSign ECC Root CA - G3", errors.getvalue())
|
||||
|
||||
def test_hashed_non_ca_certificate_does_not_reject_clean_store(self):
|
||||
# Ubuntu's OpenSSL directory can also hash ssl-cert-snakeoil.pem.
|
||||
# A valid server certificate has no CA entries in get_ca_certs().
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
directory = Path(tmp)
|
||||
(directory / "ce275665.0").symlink_to(self.fixtures / "localhost-leaf.pem")
|
||||
defaults = ssl.get_default_verify_paths()._replace(
|
||||
cafile=str(self.fixtures / "emsign-g1.pem"), capath=str(directory)
|
||||
)
|
||||
with patch.object(checker.ssl, "get_default_verify_paths", return_value=defaults), patch.dict(
|
||||
os.environ, {defaults.openssl_capath_env: str(directory)}
|
||||
), contextlib.redirect_stdout(io.StringIO()):
|
||||
self.assertEqual(checker.main(["--system"]), 0)
|
||||
|
||||
def test_multiple_ca_directories_are_all_checked(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
clean, stale = Path(tmp) / "clean", Path(tmp) / "stale"
|
||||
clean.mkdir()
|
||||
stale.mkdir()
|
||||
(stale / "ee64a828.0").symlink_to(self.fixtures / "aaa.pem")
|
||||
defaults = ssl.get_default_verify_paths()._replace(
|
||||
cafile=str(self.fixtures / "emsign-g1.pem"), capath=None
|
||||
)
|
||||
errors = io.StringIO()
|
||||
with patch.object(checker.ssl, "get_default_verify_paths", return_value=defaults), patch.dict(
|
||||
os.environ, {defaults.openssl_capath_env: os.pathsep.join(map(str, (clean, stale)))}
|
||||
):
|
||||
with contextlib.redirect_stderr(errors):
|
||||
result = checker.main(["--system"])
|
||||
self.assertEqual(result, 1)
|
||||
self.assertIn("AAA Certificate Services", errors.getvalue())
|
||||
|
||||
def test_grpc_bundled_roots_are_checked_without_an_override(self):
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
package = Path(temporary) / "grpc"
|
||||
bundle = package / "_cython" / "_credentials" / "roots.pem"
|
||||
bundle.parent.mkdir(parents=True)
|
||||
bundle.write_bytes((self.fixtures / "emsign-g3.pem").read_bytes())
|
||||
errors = io.StringIO()
|
||||
with patch.dict(os.environ, {"GRPC_DEFAULT_SSL_ROOTS_FILE_PATH": ""}), patch.object(
|
||||
checker.importlib.util, "find_spec", return_value=SimpleNamespace(origin=str(package / "__init__.py"))
|
||||
), contextlib.redirect_stderr(errors):
|
||||
self.assertEqual(checker.main(["--grpc"]), 1)
|
||||
self.assertIn("emSign ECC Root CA - G3", errors.getvalue())
|
||||
|
||||
def test_grpc_explicit_clean_store_replaces_the_independent_bundle(self):
|
||||
with patch.dict(os.environ, {"GRPC_DEFAULT_SSL_ROOTS_FILE_PATH": str(self.fixtures / "emsign-g1.pem")}), patch.object(
|
||||
checker.importlib.util, "find_spec", side_effect=AssertionError("must use the explicit store")
|
||||
), contextlib.redirect_stdout(io.StringIO()):
|
||||
self.assertEqual(checker.main(["--grpc"]), 0)
|
||||
|
||||
def test_grpc_invalid_override_does_not_fall_back_silently(self):
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
for name, content in (("empty.pem", ""), ("malformed.pem", "not a certificate"), ("affected.pem", PUBLIC_CERTIFICATES["aaa.pem"])):
|
||||
bundle = Path(temporary) / name
|
||||
bundle.write_text(content)
|
||||
with self.subTest(name=name), patch.dict(os.environ, {"GRPC_DEFAULT_SSL_ROOTS_FILE_PATH": str(bundle)}), contextlib.redirect_stderr(io.StringIO()):
|
||||
self.assertEqual(checker.main(["--grpc"]), 1)
|
||||
with patch.dict(os.environ, {"GRPC_DEFAULT_SSL_ROOTS_FILE_PATH": str(Path(temporary) / "missing.pem")}), contextlib.redirect_stderr(io.StringIO()):
|
||||
self.assertEqual(checker.main(["--grpc"]), 1)
|
||||
|
||||
def test_grpc_check_reports_a_missing_installation(self):
|
||||
with patch.dict(os.environ, {"GRPC_DEFAULT_SSL_ROOTS_FILE_PATH": ""}), patch.object(
|
||||
checker.importlib.util, "find_spec", return_value=None
|
||||
), contextlib.redirect_stderr(io.StringIO()):
|
||||
self.assertEqual(checker.main(["--grpc"]), 1)
|
||||
|
||||
|
||||
# Public Mozilla CA certificates; provenance: tests/fixtures/ca-trust/README.md.
|
||||
PUBLIC_CERTIFICATES = {
|
||||
'localhost-leaf.pem': """-----BEGIN CERTIFICATE-----
|
||||
MIIBOTCB7KADAgECAhRcVxnOQNEFWf92K4dm4I8ChyjVMzAFBgMrZXAwFDESMBAG
|
||||
A1UEAwwJbG9jYWxob3N0MB4XDTI2MTAyMTE4NTgwN1oXDTM2MTAxODE4NTgwN1ow
|
||||
FDESMBAGA1UEAwwJbG9jYWxob3N0MCowBQYDK2VwAyEARglphvNgp9qRCESLHnuu
|
||||
W8VC5urdZl+YgS+Ux1BoLryjUDBOMB0GA1UdDgQWBBSaEckyR/76lX3a5Ijh8B42
|
||||
oJwN1DAfBgNVHSMEGDAWgBSaEckyR/76lX3a5Ijh8B42oJwN1DAMBgNVHRMBAf8E
|
||||
AjAAMAUGAytlcANBAJkq9ayy+d4AUZnjhWM31rFke5DT+l9QFcPl34+dpVhqJV/L
|
||||
m4IpPHYagqrBRHn9Dv/C8qYyPBKnAwJRpNptsgw=
|
||||
-----END CERTIFICATE-----
|
||||
""",
|
||||
'aaa.pem': """-----BEGIN CERTIFICATE-----
|
||||
MIIEMjCCAxqgAwIBAgIBATANBgkqhkiG9w0BAQUFADB7MQswCQYDVQQGEwJHQjEb
|
||||
MBkGA1UECAwSR3JlYXRlciBNYW5jaGVzdGVyMRAwDgYDVQQHDAdTYWxmb3JkMRow
|
||||
GAYDVQQKDBFDb21vZG8gQ0EgTGltaXRlZDEhMB8GA1UEAwwYQUFBIENlcnRpZmlj
|
||||
YXRlIFNlcnZpY2VzMB4XDTA0MDEwMTAwMDAwMFoXDTI4MTIzMTIzNTk1OVowezEL
|
||||
MAkGA1UEBhMCR0IxGzAZBgNVBAgMEkdyZWF0ZXIgTWFuY2hlc3RlcjEQMA4GA1UE
|
||||
BwwHU2FsZm9yZDEaMBgGA1UECgwRQ29tb2RvIENBIExpbWl0ZWQxITAfBgNVBAMM
|
||||
GEFBQSBDZXJ0aWZpY2F0ZSBTZXJ2aWNlczCCASIwDQYJKoZIhvcNAQEBBQADggEP
|
||||
ADCCAQoCggEBAL5AnfRu4ep2hxxNRUSOvkbIgwadwSr+GB+O5AL686tdUIoWMQua
|
||||
BtDFcCLNSS1UY8y2bmhGC1Pqy0wkwLxyTurxFa70VJoSCsN6sjNg4tqJVfMiWPPe
|
||||
3M/vg4aijJRPn2jymJBGhCfHdr/jzDUsi14HZGWCwEiwqJH5YZ92IFCokcdmtet4
|
||||
YgNW8IoaE+oxox6gmf049vYnMlhvB/VruPsUK6+3qszWY19zjNoFmag4qMsXeDZR
|
||||
rOme9Hg6jc8P2ULimAyrL58OAd7vn5lJ8S3frHRNG5i1R8XlKdH5kBjHYpy+g8cm
|
||||
ez6KJcfA3Z3mNWgQIJ2P2N7Sw4ScDV7oL8kCAwEAAaOBwDCBvTAdBgNVHQ4EFgQU
|
||||
oBEKIz6W8Qfs4q8p74Klf9AwpLQwDgYDVR0PAQH/BAQDAgEGMA8GA1UdEwEB/wQF
|
||||
MAMBAf8wewYDVR0fBHQwcjA4oDagNIYyaHR0cDovL2NybC5jb21vZG9jYS5jb20v
|
||||
QUFBQ2VydGlmaWNhdGVTZXJ2aWNlcy5jcmwwNqA0oDKGMGh0dHA6Ly9jcmwuY29t
|
||||
b2RvLm5ldC9BQUFDZXJ0aWZpY2F0ZVNlcnZpY2VzLmNybDANBgkqhkiG9w0BAQUF
|
||||
AAOCAQEACFb8AvCb6P+k+tZ7xkSAzk/ExfYAWMymtrwUSWgEdujm7l3sAg9g1o1Q
|
||||
GE8mTgHj5rCl7r+8dFRBv/38ErjHT1r0iWAFf2C3BUrz9vHCv8S5dIa2LX1rzNLz
|
||||
Rt0vxuBqw8M0Ayx9lt1awg6nCpnBBYurDC/zXDrPbDdVCYfeU0BsWO/8tqtlbgT2
|
||||
G9w84FoVxp7Z8VlIMCFlA2zs6SFz7JsDoeA3raAVGI/6ugLOpyypEBMs1OUIJqsi
|
||||
l2D4kF501KKaU73yqWjgom7C12yxow+ev+to51byrvLjKzg6CYG1a4XXvi3tPxq3
|
||||
smPi9WIsgtRqAEFQ8TmDn5XpNpaYbg==
|
||||
-----END CERTIFICATE-----
|
||||
""",
|
||||
'emsign-g1.pem': """-----BEGIN CERTIFICATE-----
|
||||
MIIDlDCCAnygAwIBAgIKMfXkYgxsWO3W2DANBgkqhkiG9w0BAQsFADBnMQswCQYD
|
||||
VQQGEwJJTjETMBEGA1UECxMKZW1TaWduIFBLSTElMCMGA1UEChMcZU11ZGhyYSBU
|
||||
ZWNobm9sb2dpZXMgTGltaXRlZDEcMBoGA1UEAxMTZW1TaWduIFJvb3QgQ0EgLSBH
|
||||
MTAeFw0xODAyMTgxODMwMDBaFw00MzAyMTgxODMwMDBaMGcxCzAJBgNVBAYTAklO
|
||||
MRMwEQYDVQQLEwplbVNpZ24gUEtJMSUwIwYDVQQKExxlTXVkaHJhIFRlY2hub2xv
|
||||
Z2llcyBMaW1pdGVkMRwwGgYDVQQDExNlbVNpZ24gUm9vdCBDQSAtIEcxMIIBIjAN
|
||||
BgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAk0u76WaK7p1b1TST0Bsew+eeuGQz
|
||||
f2N4aLTNLnF115sgxk0pvLZoYIr3IZpWNVrzdr3YzZr/k1ZLpVkGoZM0Kd0WNHVO
|
||||
8oG0x5ZOrRkVUkr+PHB1cM2vK6sVmjM8qrOLqs1D/fXqcP/tzxE7lM5OMhbTI0Aq
|
||||
d7OvPAEsbO2ZLIvZTmmYsvePQbAyeGHWDV/D+qJAkh1cF+ZwPjXnorfCYuKrpDhM
|
||||
tTk1b+oDafo6VGiFbdbyL0NVHpENDtjVaqSW0RM8LHhQ6DqS0hdW5TUaQBw+jSzt
|
||||
Od9C4INBdN+jzcKGYEho42kLVACL5HZpIQ15TjQIXhTCzLG3rdd8cIrHhQIDAQAB
|
||||
o0IwQDAdBgNVHQ4EFgQU++8Nhp6w492pufEhF38+/PB3KxowDgYDVR0PAQH/BAQD
|
||||
AgEGMA8GA1UdEwEB/wQFMAMBAf8wDQYJKoZIhvcNAQELBQADggEBAFn/8oz1h31x
|
||||
PaOfG1vR2vjTnGs2vZupYeveFix0PZ7mddrXuqe8QhfnPZHr5X3dPpzxz5KsbEjM
|
||||
wiI/aTvFthUvozXGaCocV685743QNcMYDHsAVhzNixl03r4PEuDQqqE/AjSxcM6d
|
||||
GNYIAwlG7mDgfrbESQRRfXBgvKqy/3lyeqYdPV8q+Mri/Tm3R7nrft8EI6/6nAYH
|
||||
6ftjk4BAtcZsCjEozgyfz7MjNYBBjWzEN3uBL4ChQEKF6dk4jeihU80Bv2noWgby
|
||||
RQuQ+q7hv53yrlc8pa6yVvSLZUDp/TGBLPQ5Cdjua6e0ph0VpZj3AYHYhX3zUVxx
|
||||
iN66zB+Afko=
|
||||
-----END CERTIFICATE-----
|
||||
""",
|
||||
'emsign-g3.pem': """-----BEGIN CERTIFICATE-----
|
||||
MIICTjCCAdOgAwIBAgIKPPYHqWhwDtqLhDAKBggqhkjOPQQDAzBrMQswCQYDVQQG
|
||||
EwJJTjETMBEGA1UECxMKZW1TaWduIFBLSTElMCMGA1UEChMcZU11ZGhyYSBUZWNo
|
||||
bm9sb2dpZXMgTGltaXRlZDEgMB4GA1UEAxMXZW1TaWduIEVDQyBSb290IENBIC0g
|
||||
RzMwHhcNMTgwMjE4MTgzMDAwWhcNNDMwMjE4MTgzMDAwWjBrMQswCQYDVQQGEwJJ
|
||||
TjETMBEGA1UECxMKZW1TaWduIFBLSTElMCMGA1UEChMcZU11ZGhyYSBUZWNobm9s
|
||||
b2dpZXMgTGltaXRlZDEgMB4GA1UEAxMXZW1TaWduIEVDQyBSb290IENBIC0gRzMw
|
||||
djAQBgcqhkjOPQIBBgUrgQQAIgNiAAQjpQy4LRL1KPOxst3iAhKAnjlfSU2fySU0
|
||||
WXTsuwYc58Byr+iuL+FBVIcUqEqy6HyC5ltqtdyzdc6LBtCGI79G1Y4PPwT01xyS
|
||||
fvalY8L1X44uT6EYGQIrMgqCZH0Wk9GjQjBAMB0GA1UdDgQWBBR8XQKEE9TMipuB
|
||||
zhccLikenEhjQjAOBgNVHQ8BAf8EBAMCAQYwDwYDVR0TAQH/BAUwAwEB/zAKBggq
|
||||
hkjOPQQDAwNpADBmAjEAvvNhzwIQHWSVB7gYboiFBS+DCBeQyh+KTOgNG3qxrdWB
|
||||
CUfvO6wIBHxcmbHtRwfSAjEAnbpV/KlK6O3t5nYBQnvI+GDZjVGLVTv7jHvrZQnD
|
||||
+JbNR6iC8hZVdyR+EhCVBCyj
|
||||
-----END CERTIFICATE-----
|
||||
""",
|
||||
}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
@@ -183,7 +183,10 @@ def test_history_and_chat_routes_support_reasoning_effort():
|
||||
"""Verify history and chat routes support reasoning_effort and thinking_mode persistence."""
|
||||
hist_src = (ROOT / "routes/history/history_routes.py").read_text(encoding="utf-8")
|
||||
assert "reasoning_effort" in hist_src
|
||||
assert 'mode = f"effort:{clean_effort}"' in hist_src
|
||||
assert "def _effort_mode(" in hist_src
|
||||
assert 'return f"effort:{effort}" if effort else "off"' in hist_src
|
||||
assert "mode = _effort_mode(session.model, raw_effort)" in hist_src
|
||||
assert "mode = _effort_mode(session.model, raw_mode[7:])" in hist_src
|
||||
|
||||
chat_src = (ROOT / "routes/chat_routes.py").read_text(encoding="utf-8")
|
||||
assert "validate_reasoning_effort(sess.model, reasoning_effort)" in chat_src
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
"""Regression guard for issue #6132 — the ChromaDB named volume was mounted at a
|
||||
path the image never writes to, so the whole vector store lived in the
|
||||
container's writable layer and was destroyed on every recreate. Silently: the
|
||||
collection survived with a count of 0, and retrieval just stopped returning
|
||||
anything.
|
||||
|
||||
``chromadb/chroma`` runs ``chroma run /config.yaml``, and that config is a single
|
||||
line — ``persist_path: "/data"``. The image declares no ``VOLUME``, so nothing
|
||||
outside a mount survives. ``/chroma/chroma`` is where pre-1.0 images kept the
|
||||
store.
|
||||
|
||||
The documented backup procedure (website/backup-restore.md) archives the
|
||||
``chromadb-data`` volume, so a wrong mount point also makes that tarball empty.
|
||||
|
||||
Pure YAML — no Docker required.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
COMPOSE_FILES = [
|
||||
ROOT / "docker-compose.yml",
|
||||
ROOT / "docker-compose.gpu-nvidia.yml",
|
||||
ROOT / "docker-compose.gpu-amd.yml",
|
||||
]
|
||||
|
||||
# `persist_path` from the image's own /config.yaml.
|
||||
CHROMA_PERSIST_PATH = "/data"
|
||||
LEGACY_PERSIST_PATH = "/chroma/chroma"
|
||||
VOLUME_NAME = "chromadb-data"
|
||||
|
||||
|
||||
def _chromadb_volumes(path: Path) -> list[str]:
|
||||
"""Return a normalised list of ``source:target`` strings for the chromadb service.
|
||||
|
||||
Docker Compose accepts volumes in two syntaxes:
|
||||
|
||||
* Short-form string: ``"chromadb-data:/data"`` or ``"chromadb-data:/data:ro"``
|
||||
* Long-form mapping::
|
||||
|
||||
- source: chromadb-data
|
||||
target: /data
|
||||
type: volume
|
||||
|
||||
Both forms are normalised to ``"<source>:<target>"`` so that callers can
|
||||
use ``split(":")`` to extract either part without worrying about the original
|
||||
syntax or trailing mount-option fields (e.g. ``:z``, ``:ro``).
|
||||
"""
|
||||
compose = yaml.safe_load(path.read_text(encoding="utf-8"))
|
||||
service = compose["services"]["chromadb"]
|
||||
raw = list(service.get("volumes") or [])
|
||||
result: list[str] = []
|
||||
for entry in raw:
|
||||
if isinstance(entry, dict):
|
||||
# Long-form: normalise to "source:target" for uniform handling.
|
||||
source = entry.get("source", "")
|
||||
target = entry.get("target", "")
|
||||
result.append(f"{source}:{target}")
|
||||
else:
|
||||
# Short-form string: keep as-is; split(":")[1] is still the target
|
||||
# even when a third field (mount options like :z, :ro) is present.
|
||||
result.append(str(entry))
|
||||
return result
|
||||
|
||||
|
||||
@pytest.mark.parametrize("path", COMPOSE_FILES, ids=lambda p: p.name)
|
||||
def test_chromadb_volume_is_mounted_at_the_persist_path(path: Path):
|
||||
mounts = _chromadb_volumes(path)
|
||||
targets = {m.split(":")[1] for m in mounts if m.startswith(f"{VOLUME_NAME}:")}
|
||||
assert targets, f"{path.name}: no {VOLUME_NAME} mount on the chromadb service"
|
||||
assert targets == {CHROMA_PERSIST_PATH}, (
|
||||
f"{path.name}: {VOLUME_NAME} is mounted at {sorted(targets)}, but the image "
|
||||
f"persists to {CHROMA_PERSIST_PATH!r}. Anything written outside a mount is "
|
||||
"lost when the container is recreated (issue #6132)."
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("path", COMPOSE_FILES, ids=lambda p: p.name)
|
||||
def test_chromadb_does_not_use_the_pre_1_0_path(path: Path):
|
||||
"""Verify that no mount places chromadb-data at the pre-1.0 store path.
|
||||
|
||||
Uses ``split(":")[1]`` rather than ``endswith()`` so that mount-option
|
||||
suffixes (e.g. ``:z``, ``:ro``) do not mask a wrong target path.
|
||||
"""
|
||||
mounts = _chromadb_volumes(path)
|
||||
legacy = [m for m in mounts if ":" in m and m.split(":")[1] == LEGACY_PERSIST_PATH]
|
||||
assert not legacy, (
|
||||
f"{path.name}: {LEGACY_PERSIST_PATH} is the pre-1.0 store location; "
|
||||
f"current images never write there. Offending entries: {legacy}"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("path", COMPOSE_FILES, ids=lambda p: p.name)
|
||||
def test_chromadb_data_volume_is_declared(path: Path):
|
||||
compose = yaml.safe_load(path.read_text(encoding="utf-8"))
|
||||
assert VOLUME_NAME in (compose.get("volumes") or {}), (
|
||||
f"{path.name}: the {VOLUME_NAME} volume is mounted but not declared"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Unit tests for _chromadb_volumes() — parse-correctness, not Compose state
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class _FakePath:
|
||||
"""Minimal Path stand-in that returns a fixed YAML string."""
|
||||
|
||||
def __init__(self, yaml_text: str):
|
||||
self._text = yaml_text
|
||||
|
||||
def read_text(self, encoding: str = "utf-8") -> str: # noqa: ARG002
|
||||
return self._text
|
||||
|
||||
|
||||
def test_helper_normalises_long_form_volume_dict():
|
||||
"""Long-form dict volume entries must not raise AttributeError."""
|
||||
p = _FakePath(yaml.dump({
|
||||
"services": {"chromadb": {"volumes": [
|
||||
{"source": "chromadb-data", "target": "/data", "type": "volume"},
|
||||
]}},
|
||||
"volumes": {"chromadb-data": None},
|
||||
}))
|
||||
result = _chromadb_volumes(p) # type: ignore[arg-type]
|
||||
assert result == ["chromadb-data:/data"]
|
||||
|
||||
|
||||
def test_helper_passes_short_form_string_unchanged():
|
||||
"""Short-form strings (with or without mount options) are returned as-is."""
|
||||
p = _FakePath(yaml.dump({
|
||||
"services": {"chromadb": {"volumes": ["chromadb-data:/data:z"]}},
|
||||
"volumes": {"chromadb-data": None},
|
||||
}))
|
||||
result = _chromadb_volumes(p) # type: ignore[arg-type]
|
||||
assert result == ["chromadb-data:/data:z"]
|
||||
# split(":")[1] still extracts the correct target regardless of :z suffix
|
||||
assert result[0].split(":")[1] == "/data"
|
||||
|
||||
|
||||
def test_helper_legacy_check_catches_path_with_mount_option_suffix():
|
||||
"""The split-based legacy check catches the old path even with :z appended."""
|
||||
mount = f"{VOLUME_NAME}:{LEGACY_PERSIST_PATH}:z"
|
||||
# Confirm endswith() would miss it (the bug we fixed)
|
||||
assert not mount.endswith(f":{LEGACY_PERSIST_PATH}"), (
|
||||
"endswith() should NOT catch ':z'-suffixed legacy path — that was the bug"
|
||||
)
|
||||
# Confirm split()[1] does catch it
|
||||
assert mount.split(":")[1] == LEGACY_PERSIST_PATH, (
|
||||
"split(':')[1] must still identify the legacy target path with :z suffix"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Regression tests for ChromaDB migration safety invariants (PR #6166 audit)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
MIGRATION_DOC = ROOT / "docs" / "chromadb-volume-migration.md"
|
||||
|
||||
|
||||
def test_migration_guide_requires_quiescence_before_backup():
|
||||
"""Step 2 must stop ChromaDB before running docker cp to prevent torn SQLite pages."""
|
||||
text = MIGRATION_DOC.read_text(encoding="utf-8")
|
||||
assert "docker compose stop chromadb" in text, (
|
||||
"Migration guide must require stopping ChromaDB before database backup"
|
||||
)
|
||||
# Confirm stopping step occurs prior to the cold backup step
|
||||
stop_step = text.find("Step 2: Quiesce Writers and Gracefully Stop ChromaDB")
|
||||
backup_step = text.find("Step 4: Take a Cold File Backup")
|
||||
assert stop_step != -1 and backup_step != -1 and stop_step < backup_step, (
|
||||
"Quiescence/stop step must precede cold backup step in migration sequence"
|
||||
)
|
||||
|
||||
|
||||
def test_migration_guide_mandates_container_preservation_via_commit():
|
||||
"""Guide must preserve stopped container as an immutable image before any recreation."""
|
||||
text = MIGRATION_DOC.read_text(encoding="utf-8")
|
||||
assert "docker commit" in text, (
|
||||
"Migration guide must instruct operators to commit the container to an immutable backup image"
|
||||
)
|
||||
assert "com.docker.compose.project" in text, (
|
||||
"Guide must explain that Compose discovers containers by internal labels, not name alone"
|
||||
)
|
||||
|
||||
|
||||
def test_migration_guide_requires_sqlite_and_hnsw_integrity_verification():
|
||||
"""Guide must mandate SQLite PRAGMA integrity_check and checking HNSW index files."""
|
||||
text = MIGRATION_DOC.read_text(encoding="utf-8")
|
||||
assert "PRAGMA integrity_check;" in text, (
|
||||
"Migration guide must require SQLite PRAGMA integrity_check"
|
||||
)
|
||||
assert "data_level0.bin" in text or "*.bin" in text, (
|
||||
"Migration guide must require verifying HNSW segment index binary files"
|
||||
)
|
||||
|
||||
|
||||
def test_migration_guide_requires_empty_volume_precheck():
|
||||
"""Guide must verify destination volume is strictly empty prior to seeding."""
|
||||
text = MIGRATION_DOC.read_text(encoding="utf-8")
|
||||
assert "ls -A" in text or "empty and ready for seeding" in text, (
|
||||
"Migration guide must check that the destination named volume is empty before seeding"
|
||||
)
|
||||
|
||||
|
||||
def test_migration_guide_includes_staging_validation():
|
||||
"""Guide must validate the seeded volume with a staging container before Compose cutover."""
|
||||
text = MIGRATION_DOC.read_text(encoding="utf-8")
|
||||
assert "chromadb-staging" in text, (
|
||||
"Migration guide must include staging container validation step"
|
||||
)
|
||||
|
||||
|
||||
def test_migration_guide_warns_against_reverting_to_broken_mount():
|
||||
"""Rollback instructions must explicitly prohibit git checkout to pre-#6166 broken mount."""
|
||||
text = MIGRATION_DOC.read_text(encoding="utf-8")
|
||||
assert "Do not `git checkout` the pre-#6166 Compose file" in text or "broken" in text.lower(), (
|
||||
"Rollback instructions must explain why checking out old Compose file breaks volume mount"
|
||||
)
|
||||
@@ -0,0 +1,41 @@
|
||||
"""The managed browser policy must distrust exactly the incident's eight keys."""
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
import unittest
|
||||
|
||||
from cryptography import x509
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
# Public incident inventory: impacted-intermediates.json, 2026-10-21.
|
||||
COMPROMISED_SPKIS = {
|
||||
"43bffa19d6ef651451f6841e019931e96f6f2eaeff28ab4b6f1ce4576de2717b",
|
||||
"49dd353dc2b919a3770645117d0e60c16115e7d1bc259eefe734b00ea434fe5f",
|
||||
"5c0660a2b164910d8ff589c18e14552e9e0ec237dcefe2cb736d41a4811f81a0",
|
||||
"63ecefee431d49523bc1136c4e72d0206a477086caf606cd5cf025686c109cf8",
|
||||
"92c063d67e8f14db71d288e11266ffdaf69cbf115f7bab14f3bc2f68d25e0e30",
|
||||
"e0254c7fcdc265e2f0f8a428dabb75ab4ae039b4872d62f5ee57f0877d5a8a41",
|
||||
"ebed6475c2c2d80fcb199f3fe8dd6e6b3ce60b77eb15d4e42c3dec0c6f5b5105",
|
||||
"ec4ffa1ad0ac88081314b7aaffef6a91f97cd57291bb5ba686c5415f5eb4f6b6",
|
||||
}
|
||||
|
||||
|
||||
class ChromiumCATrustTests(unittest.TestCase):
|
||||
def test_policy_distrusts_each_compromised_key_without_adding_trust(self):
|
||||
policy = json.loads((ROOT / "docker" / "chromium-emudhra.json").read_text())
|
||||
self.assertEqual(set(policy), {"CADistrustedCertificates"})
|
||||
self.assertEqual(len(policy["CADistrustedCertificates"]), 8)
|
||||
keys = set()
|
||||
for encoded in policy["CADistrustedCertificates"]:
|
||||
certificate = x509.load_der_x509_certificate(base64.b64decode(encoded, validate=True))
|
||||
self.assertTrue(certificate.extensions.get_extension_for_class(x509.BasicConstraints).value.ca)
|
||||
self.assertNotEqual(certificate.subject, certificate.issuer)
|
||||
public_key = certificate.public_key().public_bytes(
|
||||
serialization.Encoding.DER, serialization.PublicFormat.SubjectPublicKeyInfo
|
||||
)
|
||||
keys.add(hashlib.sha256(public_key).hexdigest())
|
||||
self.assertEqual(keys, COMPROMISED_SPKIS)
|
||||
@@ -33,3 +33,16 @@ def test_python_tests_are_authoritative():
|
||||
|
||||
assert "python -m pytest -q" in python_tests
|
||||
assert "continue-on-error:" not in python_tests
|
||||
|
||||
|
||||
def test_ci_checks_system_trust_before_project_installation():
|
||||
python_tests = _indented_block(_WORKFLOW.read_text(), "python-tests", 2)
|
||||
system_check = python_tests.index("python scripts/check_ca_trust.py --system\n")
|
||||
system_update = python_tests.index("sudo apt-get install -y --no-install-recommends ffmpeg bubblewrap ca-certificates")
|
||||
bootstrap = python_tests.index("uv pip install --system --require-hashes")
|
||||
python_check = python_tests.index("python scripts/check_ca_trust.py --python\n")
|
||||
application_install = python_tests.index("uv pip install --system -r requirements.txt")
|
||||
|
||||
assert system_update < system_check < bootstrap < python_check < application_install
|
||||
assert system_check < python_tests.index("npm ci")
|
||||
assert system_check < python_tests.index("npx playwright install")
|
||||
|
||||
+185
-354
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"]
|
||||
@@ -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):
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
"""Sidecar images must be pinned to a versioned tag and an immutable digest.
|
||||
|
||||
A bare or ``:latest`` reference changes underneath an existing install on the
|
||||
next ``docker compose pull``/``up``, so two hosts on the same commit can run
|
||||
different ChromaDB or ntfy builds. The ``odysseus`` service is excluded: it is
|
||||
the project's own image, selected through ``ODYSSEUS_IMAGE``. The standalone
|
||||
GPU files are held equal to these services by test_gpu_compose_standalone.py.
|
||||
"""
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
|
||||
COMPOSE = Path(__file__).resolve().parents[1] / "docker-compose.yml"
|
||||
PINNED = re.compile(r"^[\w.\-/]+:(?P<tag>[\w.\-]+)@sha256:[0-9a-f]{64}$")
|
||||
|
||||
|
||||
def _sidecars():
|
||||
services = yaml.safe_load(COMPOSE.read_text(encoding="utf-8"))["services"]
|
||||
return {name: svc["image"] for name, svc in services.items() if name != "odysseus"}
|
||||
|
||||
|
||||
def test_expected_sidecars_present():
|
||||
assert {"chromadb", "searxng", "ntfy"} <= set(_sidecars())
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name", sorted(_sidecars()))
|
||||
def test_sidecar_image_has_version_tag_and_digest(name):
|
||||
image = _sidecars()[name]
|
||||
match = PINNED.match(image)
|
||||
assert match, f"{name}: expected <repo>:<tag>@sha256:<digest>, got {image!r}"
|
||||
assert match.group("tag") != "latest", f"{name}: pin a release tag, not latest"
|
||||
@@ -311,7 +311,14 @@ class TestMaybeCompactFourthMessage:
|
||||
|
||||
cc.get_context_length = lambda url, model: context_length
|
||||
cc.llm_call_async = _fake_summary
|
||||
cc.resolve_endpoint = lambda which, owner=None: (None, None, None)
|
||||
def fake_resolve_endpoint(which, **kwargs):
|
||||
assert which == "utility"
|
||||
assert kwargs["fallback_url"] == "http://local/v1/chat/completions"
|
||||
assert kwargs["fallback_model"] == "local-model"
|
||||
assert kwargs["fallback_headers"] == {}
|
||||
return None, None, None
|
||||
|
||||
cc.resolve_endpoint = fake_resolve_endpoint
|
||||
cc._update_session_history = lambda *a, **k: None
|
||||
try:
|
||||
return asyncio.run(
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
"""Read-only Cookbook tools must be admin-only for the agent.
|
||||
|
||||
Regression: `list_cached_models`, `list_served_models`, `tail_serve_output`,
|
||||
`list_downloads`, `search_hf_models`, `list_serve_presets` and
|
||||
`list_cookbook_servers` sat in the plan-mode read-only allowlist but not in
|
||||
NON_ADMIN_BLOCKED_TOOLS or the dispatcher's _ADMIN_TOOLS. Their
|
||||
implementations call require_admin routes over the internal loopback with the
|
||||
process-wide INTERNAL_TOOL_TOKEN and never look at the owner, so a non-admin
|
||||
user's agent could reach admin capability through them. The worst case is
|
||||
`list_cached_models`: a caller-chosen `host` is forwarded to
|
||||
/api/model/cached, which SSHes to that host with the app user's keys and
|
||||
walks caller-chosen `model_dir`s. THREAT_MODEL.md lists model serving as
|
||||
admin-only, so the read side has to be gated the same way as serve/download.
|
||||
"""
|
||||
|
||||
import sys
|
||||
import types
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
|
||||
from src.tool_security import (
|
||||
NON_ADMIN_BLOCKED_TOOLS,
|
||||
blocked_tools_for_owner,
|
||||
delegated_credential_blocked_tools,
|
||||
)
|
||||
from src.tool_execution import _ADMIN_TOOLS
|
||||
from src.agent_runtime.authority import OperationGrant, RequestAuthority
|
||||
|
||||
READONLY_COOKBOOK_TOOLS = (
|
||||
"list_cached_models",
|
||||
"list_served_models",
|
||||
"tail_serve_output",
|
||||
"list_downloads",
|
||||
"search_hf_models",
|
||||
"list_serve_presets",
|
||||
"list_cookbook_servers",
|
||||
)
|
||||
|
||||
|
||||
def _install_core_auth_stub(monkeypatch, *, is_admin: bool):
|
||||
"""Narrow auth surface: AUTH enabled, configured, owner admin or not."""
|
||||
core_mod = types.ModuleType("core")
|
||||
core_mod.__path__ = []
|
||||
auth_mod = types.ModuleType("core.auth")
|
||||
|
||||
class FakeAuth:
|
||||
is_configured = True
|
||||
|
||||
def is_admin(self, username):
|
||||
return is_admin
|
||||
|
||||
auth_mod.AuthManager = lambda: FakeAuth()
|
||||
core_mod.auth = auth_mod
|
||||
monkeypatch.setitem(sys.modules, "core", core_mod)
|
||||
monkeypatch.setitem(sys.modules, "core.auth", auth_mod)
|
||||
monkeypatch.setattr("src.auth_helpers._auth_disabled", lambda: False)
|
||||
return auth_mod
|
||||
|
||||
|
||||
def test_readonly_cookbook_tools_are_in_every_admin_gate():
|
||||
from src.agent_loop import _ADMIN_TOOLS as prompt_admin_tools
|
||||
|
||||
# Spelled out (not imported from a shared tuple in src/) so dropping one
|
||||
# from either set fails here instead of silently reopening the hole.
|
||||
for tool in READONLY_COOKBOOK_TOOLS:
|
||||
assert tool in NON_ADMIN_BLOCKED_TOOLS, tool
|
||||
assert tool in _ADMIN_TOOLS, tool
|
||||
assert tool in prompt_admin_tools, tool
|
||||
|
||||
|
||||
def test_readonly_cookbook_tools_hidden_from_non_admin_and_bearer_runs(monkeypatch):
|
||||
_install_core_auth_stub(monkeypatch, is_admin=False)
|
||||
hidden = blocked_tools_for_owner("regular-user")
|
||||
for tool in READONLY_COOKBOOK_TOOLS:
|
||||
assert tool in hidden, tool
|
||||
assert tool in delegated_credential_blocked_tools(), tool
|
||||
|
||||
|
||||
def test_admin_owner_keeps_readonly_cookbook_tools(monkeypatch):
|
||||
_install_core_auth_stub(monkeypatch, is_admin=True)
|
||||
assert blocked_tools_for_owner("admin-user") == set()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("tool_name", READONLY_COOKBOOK_TOOLS)
|
||||
async def test_non_admin_agent_cannot_execute_readonly_cookbook_tools(monkeypatch, tool_name):
|
||||
_install_core_auth_stub(monkeypatch, is_admin=False)
|
||||
from src.tool_execution import NO_TOOL_SECURITY_CONTEXT, execute_tool_block
|
||||
|
||||
# The loopback must never be touched: if the gate fails, the tool would
|
||||
# hit a require_admin route with the internal token.
|
||||
import httpx
|
||||
|
||||
def _boom(*args, **kwargs):
|
||||
raise AssertionError(f"{tool_name} reached the internal loopback for a non-admin owner")
|
||||
|
||||
monkeypatch.setattr(httpx, "AsyncClient", _boom)
|
||||
monkeypatch.setattr(httpx, "get", _boom)
|
||||
monkeypatch.setattr(httpx, "post", _boom)
|
||||
|
||||
payload = '{"host": "root@10.0.0.5", "ssh_port": "22", "model_dir": "/root,/etc"}'
|
||||
desc, result = await execute_tool_block(
|
||||
SimpleNamespace(tool_type=tool_name, content=payload),
|
||||
owner="regular-user",
|
||||
security_context=NO_TOOL_SECURITY_CONTEXT,
|
||||
request_authority=RequestAuthority(
|
||||
"cookbook-test", "regular-user", "", "", (OperationGrant(tool_name),)
|
||||
),
|
||||
)
|
||||
assert desc == f"{tool_name}: BLOCKED"
|
||||
assert result["exit_code"] == 1
|
||||
assert "admin" in result["error"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("tool_name", READONLY_COOKBOOK_TOOLS)
|
||||
async def test_admin_agent_can_dispatch_readonly_cookbook_tools(monkeypatch, tool_name):
|
||||
from src import tool_implementations
|
||||
from src.tool_execution import NO_TOOL_SECURITY_CONTEXT, execute_tool_block
|
||||
|
||||
_install_core_auth_stub(monkeypatch, is_admin=True)
|
||||
handler = AsyncMock(return_value={"output": "Cookbook result", "exit_code": 0})
|
||||
monkeypatch.setattr(tool_implementations, f"do_{tool_name}", handler)
|
||||
desc, result = await execute_tool_block(
|
||||
SimpleNamespace(tool_type=tool_name, content="{}"),
|
||||
owner="admin-user",
|
||||
security_context=NO_TOOL_SECURITY_CONTEXT,
|
||||
request_authority=RequestAuthority(
|
||||
"cookbook-test", "admin-user", "", "", (OperationGrant(tool_name),)
|
||||
),
|
||||
)
|
||||
assert desc == tool_name
|
||||
assert result["exit_code"] == 0
|
||||
handler.assert_awaited_once_with("{}", owner="admin-user")
|
||||
@@ -0,0 +1,63 @@
|
||||
"""Execute the publication workflow's image-name setup without contacting GHCR."""
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
|
||||
|
||||
WORKFLOW = (
|
||||
Path(__file__).resolve().parents[1]
|
||||
/ ".github"
|
||||
/ "workflows"
|
||||
/ "docker-publish.yml"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.skipif(shutil.which("bash") is None, reason="Docker publication requires Bash")
|
||||
@pytest.mark.parametrize("job", ["build", "merge"])
|
||||
@pytest.mark.parametrize(
|
||||
("repository", "expected"),
|
||||
[
|
||||
("RaresKeY/odysseus-dev", "rareskey/odysseus-dev"),
|
||||
("ExampleOwner/Example-Repo", "exampleowner/example-repo"),
|
||||
("odysseus-dev/odysseus", "odysseus-dev/odysseus"),
|
||||
],
|
||||
)
|
||||
def test_image_name_is_lowercase_before_docker_steps(tmp_path, job, repository, expected):
|
||||
workflow = yaml.safe_load(WORKFLOW.read_text(encoding="utf-8"))
|
||||
image_name = workflow["env"].get("IMAGE_NAME", "").replace(
|
||||
"${{ github.repository }}", repository
|
||||
)
|
||||
github_env = tmp_path / "github-env"
|
||||
env = {
|
||||
"PATH": os.environ["PATH"],
|
||||
"GITHUB_REPOSITORY": repository,
|
||||
"GITHUB_ENV": str(github_env),
|
||||
"GITHUB_OUTPUT": str(tmp_path / "github-output"),
|
||||
"GITHUB_SHA": "a" * 40,
|
||||
}
|
||||
|
||||
# Run the real setup shell before the job starts using Docker actions.
|
||||
# Checkout, login, builds and registry publication are deliberately excluded.
|
||||
for step in workflow["jobs"][job]["steps"]:
|
||||
if step.get("uses", "").startswith("docker/"):
|
||||
break
|
||||
if "run" not in step:
|
||||
continue
|
||||
subprocess.run(
|
||||
["bash", "--noprofile", "--norc", "-e", "-o", "pipefail", "-c", step["run"]],
|
||||
cwd=WORKFLOW.parents[2],
|
||||
env=env,
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if github_env.exists():
|
||||
env.update(line.split("=", 1) for line in github_env.read_text().splitlines())
|
||||
image_name = env.get("IMAGE_NAME", image_name)
|
||||
|
||||
assert image_name == expected
|
||||
@@ -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"
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -0,0 +1,515 @@
|
||||
"""User-supplied IMAP/SMTP hosts must pass the outbound host guard.
|
||||
|
||||
Regression: POST /api/email/accounts/test opened raw ``imaplib`` / ``smtplib``
|
||||
connections to whatever host:port the caller supplied, and the account save
|
||||
routes persisted arbitrary hosts for the background pollers to contact. Both
|
||||
are reachable by any authenticated user (``require_user`` / ``require_owner``,
|
||||
no admin gate), so a non-admin could port-scan loopback, the LAN and the cloud
|
||||
metadata range through the server and read the first 200 chars of the banner
|
||||
back out of the error message. THREAT_MODEL.md lists email as admin-only.
|
||||
|
||||
Policy (see routes/email_routes.py:_check_mail_host): link-local (metadata),
|
||||
multicast, reserved and unspecified targets are always rejected; loopback and
|
||||
private ranges are rejected for non-admin owners or when
|
||||
EMAIL_BLOCK_PRIVATE_IPS=true, and stay allowed for admins (local-first: a LAN
|
||||
Dovecot is a normal setup). Unresolved names are rejected before persistence;
|
||||
live connections also validate and pin their own resolved addresses.
|
||||
"""
|
||||
|
||||
import socket
|
||||
import unittest.mock as mock
|
||||
|
||||
import pytest
|
||||
|
||||
from src.url_safety import check_outbound_host, connect_outbound_tcp, OutboundAddressBlocked
|
||||
|
||||
|
||||
# ── helper policy table ──────────────────────────────────────────────────────
|
||||
|
||||
@pytest.mark.parametrize("host", [
|
||||
"169.254.169.254",
|
||||
"[::ffff:169.254.169.254]",
|
||||
"::ffff:169.254.169.254",
|
||||
"fe80::1",
|
||||
"0.0.0.0",
|
||||
"224.0.0.1",
|
||||
"100.100.100.200",
|
||||
"[100.100.100.200]",
|
||||
])
|
||||
def test_always_blocked_addresses(host):
|
||||
ok, reason = check_outbound_host(host)
|
||||
assert ok is False, (host, reason)
|
||||
ok, reason = check_outbound_host(host, block_private=False)
|
||||
assert ok is False
|
||||
|
||||
|
||||
@pytest.mark.parametrize("host", [
|
||||
"127.0.0.1",
|
||||
"::1",
|
||||
"[::1]",
|
||||
"10.0.0.5",
|
||||
"192.168.1.10",
|
||||
"100.64.1.1",
|
||||
"localhost",
|
||||
"LOCALHOST.",
|
||||
"mail.localhost",
|
||||
"ip6-localhost",
|
||||
])
|
||||
def test_private_and_loopback_gated_by_block_private(host):
|
||||
ok, _ = check_outbound_host(host, block_private=True)
|
||||
assert ok is False, host
|
||||
ok, _ = check_outbound_host(host, block_private=False)
|
||||
assert ok is True, host
|
||||
|
||||
|
||||
@pytest.mark.parametrize("host", ["host:993", "http://mail.example.com", "user@mail.example.com", "mail.example.com/", "a b", "", " "])
|
||||
def test_non_bare_hosts_rejected(host):
|
||||
ok, _ = check_outbound_host(host, resolver=lambda h: ["93.184.216.34"])
|
||||
assert ok is False, host
|
||||
|
||||
|
||||
def test_nonstring_rejected():
|
||||
assert check_outbound_host(None)[0] is False # type: ignore[arg-type]
|
||||
assert check_outbound_host(123)[0] is False # type: ignore[arg-type]
|
||||
|
||||
|
||||
def test_resolved_name_is_classified():
|
||||
ok, reason = check_outbound_host("metadata.google.internal", resolver=lambda h: ["169.254.169.254"])
|
||||
assert ok is False and "link-local" in reason
|
||||
ok, _ = check_outbound_host("mail.corp", block_private=True, resolver=lambda h: ["10.1.2.3"])
|
||||
assert ok is False
|
||||
ok, _ = check_outbound_host("mail.corp", block_private=False, resolver=lambda h: ["10.1.2.3"])
|
||||
assert ok is True
|
||||
ok, _ = check_outbound_host("imap.example.org", block_private=True, resolver=lambda h: ["93.184.216.34"])
|
||||
assert ok is True
|
||||
|
||||
|
||||
def test_unresolved_policy():
|
||||
def _nx(h):
|
||||
raise OSError("Name or service not known")
|
||||
assert check_outbound_host("nope.invalid", resolver=_nx)[0] is False
|
||||
assert check_outbound_host("nope.invalid", resolver=lambda h: [])[0] is False
|
||||
|
||||
|
||||
# ── route wiring ─────────────────────────────────────────────────────────────
|
||||
|
||||
def _route(router, path, method):
|
||||
for r in router.routes:
|
||||
if r.path == path and method in getattr(r, "methods", set()):
|
||||
return r.endpoint
|
||||
raise AssertionError(f"route not found: {method} {path}")
|
||||
|
||||
|
||||
def _make_orm_db():
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from core.database import Base
|
||||
engine = create_engine("sqlite:///:memory:", connect_args={"check_same_thread": False})
|
||||
Base.metadata.create_all(engine)
|
||||
Factory = sessionmaker(bind=engine)
|
||||
return Factory
|
||||
|
||||
|
||||
def _set_admin(monkeypatch, is_admin: bool):
|
||||
monkeypatch.setattr("src.tool_security.owner_is_admin_or_single_user", lambda owner: is_admin)
|
||||
monkeypatch.delenv("EMAIL_BLOCK_PRIVATE_IPS", raising=False)
|
||||
monkeypatch.delenv("EMAIL_ALLOW_PRIVATE_IPS", raising=False)
|
||||
|
||||
|
||||
class _Req:
|
||||
def __init__(self, body):
|
||||
self._body = body
|
||||
|
||||
async def json(self):
|
||||
return self._body
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_test_connection_rejects_loopback_for_non_admin_before_any_socket(monkeypatch):
|
||||
import routes.email_routes as email_routes
|
||||
_set_admin(monkeypatch, False)
|
||||
router = email_routes.setup_email_routes()
|
||||
test_conn = _route(router, "/api/email/accounts/test", "POST")
|
||||
|
||||
body = {
|
||||
"imap_host": "127.0.0.1", "imap_port": 11434, "imap_user": "x", "imap_password": "x",
|
||||
"imap_starttls": False,
|
||||
"smtp_host": "169.254.169.254", "smtp_port": 80, "smtp_security": "none",
|
||||
}
|
||||
with mock.patch("socket.socket") as create_socket:
|
||||
result = await test_conn(req=_Req(body), owner="regular-user")
|
||||
|
||||
assert result["ok"] is False
|
||||
assert ("Rejected IMAP host" in result["imap"]["error"] or "server address is not allowed" in result["imap"]["error"])
|
||||
assert ("Rejected SMTP host" in result["smtp"]["error"] or "server address is not allowed" in result["smtp"]["error"])
|
||||
create_socket.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_test_connection_metadata_ip_rejected_even_for_admin(monkeypatch):
|
||||
import routes.email_routes as email_routes
|
||||
_set_admin(monkeypatch, True)
|
||||
router = email_routes.setup_email_routes()
|
||||
test_conn = _route(router, "/api/email/accounts/test", "POST")
|
||||
|
||||
body = {"imap_host": "169.254.169.254", "imap_port": 80, "imap_user": "x", "imap_password": "x", "imap_starttls": True}
|
||||
with mock.patch("socket.socket") as create_socket:
|
||||
result = await test_conn(req=_Req(body), owner="admin")
|
||||
assert result["ok"] is False
|
||||
assert ("link-local" in result["imap"]["error"] or "server address is not allowed" in result["imap"]["error"])
|
||||
create_socket.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_test_connection_admin_may_use_loopback(monkeypatch):
|
||||
"""Local-first: an admin's Dovecot on localhost keeps working."""
|
||||
import routes.email_routes as email_routes
|
||||
_set_admin(monkeypatch, True)
|
||||
router = email_routes.setup_email_routes()
|
||||
test_conn = _route(router, "/api/email/accounts/test", "POST")
|
||||
|
||||
body = {"imap_host": "127.0.0.1", "imap_port": 31143, "imap_user": "x", "imap_password": "x", "imap_starttls": False}
|
||||
with mock.patch("routes.email_routes._open_imap_connection", return_value=mock.MagicMock()) as imap_open:
|
||||
result = await test_conn(req=_Req(body), owner="admin")
|
||||
assert result["imap"]["ok"] is True
|
||||
imap_open.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_account_rejects_metadata_host_before_db(monkeypatch):
|
||||
import routes.email_routes as email_routes
|
||||
_set_admin(monkeypatch, True)
|
||||
router = email_routes.setup_email_routes()
|
||||
create = _route(router, "/api/email/accounts", "POST")
|
||||
|
||||
def _no_db():
|
||||
raise AssertionError("DB must not be touched for a rejected host")
|
||||
|
||||
with mock.patch("core.database.SessionLocal", _no_db):
|
||||
result = await create(
|
||||
{"name": "Backup", "imap_host": "169.254.169.254", "imap_user": "u", "imap_password": "p"},
|
||||
owner="admin",
|
||||
)
|
||||
assert result["ok"] is False
|
||||
assert "Rejected IMAP host" in result["error"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_account_rejects_private_host_for_non_admin(monkeypatch):
|
||||
import routes.email_routes as email_routes
|
||||
_set_admin(monkeypatch, False)
|
||||
router = email_routes.setup_email_routes()
|
||||
create = _route(router, "/api/email/accounts", "POST")
|
||||
with mock.patch("core.database.SessionLocal", _make_orm_db()):
|
||||
result = await create(
|
||||
{"name": "Scan", "imap_host": "93.184.216.34", "smtp_host": "10.0.0.5", "imap_user": "u", "imap_password": "p"},
|
||||
owner="regular-user",
|
||||
)
|
||||
assert result["ok"] is False
|
||||
assert "Rejected SMTP host" in result["error"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_account_admin_private_host_allowed_unless_locked_down(monkeypatch):
|
||||
import routes.email_routes as email_routes
|
||||
_set_admin(monkeypatch, True)
|
||||
router = email_routes.setup_email_routes()
|
||||
create = _route(router, "/api/email/accounts", "POST")
|
||||
payload = {"name": "LAN", "imap_host": "192.168.1.10", "imap_user": "u", "imap_password": "p"}
|
||||
|
||||
with mock.patch("core.database.SessionLocal", _make_orm_db()):
|
||||
result = await create(dict(payload), owner="admin")
|
||||
assert result["ok"] is True
|
||||
|
||||
monkeypatch.setenv("EMAIL_BLOCK_PRIVATE_IPS", "true")
|
||||
with mock.patch("core.database.SessionLocal", _make_orm_db()):
|
||||
result = await create(dict(payload), owner="admin")
|
||||
assert result["ok"] is False
|
||||
assert "Rejected IMAP host" in result["error"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_account_rejects_bad_host_and_keeps_row(monkeypatch):
|
||||
import routes.email_routes as email_routes
|
||||
from core.database import EmailAccount
|
||||
_set_admin(monkeypatch, False)
|
||||
Factory = _make_orm_db()
|
||||
db = Factory()
|
||||
db.add(EmailAccount(id="acct-1", owner="regular-user", name="Mine", imap_host="imap.gmail.com", imap_port=993))
|
||||
db.commit()
|
||||
db.close()
|
||||
|
||||
router = email_routes.setup_email_routes()
|
||||
update = _route(router, "/api/email/accounts/{account_id}", "PUT")
|
||||
with mock.patch("core.database.SessionLocal", Factory), \
|
||||
mock.patch("routes.email_routes._assert_owns_account", lambda account_id, owner: None):
|
||||
result = await update("acct-1", {"imap_host": "127.0.0.1", "imap_port": 8000}, owner="regular-user")
|
||||
assert result["ok"] is False
|
||||
assert "Rejected IMAP host" in result["error"]
|
||||
|
||||
db = Factory()
|
||||
try:
|
||||
assert db.get(EmailAccount, "acct-1").imap_host == "imap.gmail.com"
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_config_rejects_bad_host(monkeypatch):
|
||||
import routes.email_routes as email_routes
|
||||
_set_admin(monkeypatch, False)
|
||||
router = email_routes.setup_email_routes()
|
||||
update_cfg = _route(router, "/api/email/config", "PUT")
|
||||
|
||||
def _no_db():
|
||||
raise AssertionError("DB must not be touched for a rejected host")
|
||||
|
||||
with mock.patch("core.database.SessionLocal", _no_db), \
|
||||
mock.patch("routes.email_routes._load_settings", _no_db):
|
||||
result = await update_cfg({"smtp_host": "10.0.0.1", "smtp_port": 6379}, account_id=None, owner="regular-user")
|
||||
assert result["ok"] is False
|
||||
assert "Rejected SMTP host" in result["error"]
|
||||
|
||||
|
||||
# ── adversarial runtime & connection-time tests ─────────────────────────────
|
||||
|
||||
def test_connect_outbound_tcp_blocks_dns_rebinding():
|
||||
with mock.patch("socket.getaddrinfo", return_value=[(socket.AF_INET, socket.SOCK_STREAM, 6, "", ("169.254.169.254", 993))]):
|
||||
with pytest.raises(OutboundAddressBlocked) as exc_info:
|
||||
connect_outbound_tcp("rebind.example.org", 993, block_private=False)
|
||||
assert "link-local" in str(exc_info.value)
|
||||
|
||||
|
||||
def test_connect_outbound_tcp_multiple_ips_fails_closed():
|
||||
mixed_addrs = [
|
||||
(socket.AF_INET, socket.SOCK_STREAM, 6, "", ("93.184.216.34", 993)),
|
||||
(socket.AF_INET, socket.SOCK_STREAM, 6, "", ("10.0.0.1", 993)),
|
||||
]
|
||||
with mock.patch("socket.getaddrinfo", return_value=mixed_addrs):
|
||||
with pytest.raises(OutboundAddressBlocked) as exc_info:
|
||||
connect_outbound_tcp("dual.example.org", 993, block_private=True)
|
||||
assert "private/shared/loopback" in str(exc_info.value)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_test_connection_endpoint_dns_rebinding_blocked(monkeypatch):
|
||||
"""If DNS changes between save-check and connection, connect-time validation stops it."""
|
||||
import routes.email_routes as email_routes
|
||||
_set_admin(monkeypatch, False)
|
||||
router = email_routes.setup_email_routes()
|
||||
test_conn = _route(router, "/api/email/accounts/test", "POST")
|
||||
|
||||
body = {
|
||||
"imap_host": "rebind.example.org", "imap_port": 993, "imap_user": "u", "imap_password": "p",
|
||||
"imap_starttls": False,
|
||||
}
|
||||
rebinding_addrs = [(socket.AF_INET, socket.SOCK_STREAM, 6, "", ("127.0.0.1", 993))]
|
||||
with mock.patch("socket.getaddrinfo", return_value=rebinding_addrs), \
|
||||
mock.patch("socket.socket") as create_socket:
|
||||
result = await test_conn(req=_Req(body), owner="regular-user")
|
||||
|
||||
assert result["ok"] is False
|
||||
assert ("Rejected IMAP host" in result["imap"]["error"] or "server address is not allowed" in result["imap"]["error"])
|
||||
create_socket.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_runtime_imap_connect_blocks_preexisting_db_row(monkeypatch):
|
||||
"""A database row with private host (preexisting, restored, or DB-injected) fails closed at runtime."""
|
||||
import routes.email_helpers as email_helpers
|
||||
from core.database import EmailAccount
|
||||
_set_admin(monkeypatch, False)
|
||||
Factory = _make_orm_db()
|
||||
db = Factory()
|
||||
db.add(EmailAccount(id="acct-malicious", owner="regular-user", name="Evil", imap_host="127.0.0.1", imap_port=993, imap_user="u", imap_password="p"))
|
||||
db.commit()
|
||||
db.close()
|
||||
|
||||
with mock.patch("core.database.SessionLocal", Factory):
|
||||
with pytest.raises(OutboundAddressBlocked) as exc_info:
|
||||
email_helpers._imap_connect("acct-malicious", owner="regular-user")
|
||||
assert "private/shared/loopback" in str(exc_info.value)
|
||||
|
||||
|
||||
def test_runtime_send_smtp_message_blocks_metadata_host(monkeypatch):
|
||||
"""_send_smtp_message fails closed on cloud metadata destination."""
|
||||
import routes.email_helpers as email_helpers
|
||||
_set_admin(monkeypatch, True)
|
||||
cfg = {
|
||||
"smtp_host": "169.254.169.254",
|
||||
"smtp_port": 25,
|
||||
"owner": "admin",
|
||||
}
|
||||
with pytest.raises(OutboundAddressBlocked) as exc_info:
|
||||
email_helpers._send_smtp_message(cfg, "from@example.com", ["to@example.com"], b"data")
|
||||
assert "link-local" in str(exc_info.value)
|
||||
|
||||
|
||||
def test_ipv6_loopback_allowed_for_admin_blocked_for_non_admin():
|
||||
mock_sock = mock.MagicMock()
|
||||
with mock.patch("socket.socket", return_value=mock_sock):
|
||||
sock = connect_outbound_tcp("::1", 993, block_private=False)
|
||||
assert sock is mock_sock
|
||||
mock_sock.connect.assert_called_with(("::1", 993, 0, 0))
|
||||
|
||||
with pytest.raises(OutboundAddressBlocked) as exc_info:
|
||||
connect_outbound_tcp("::1", 993, block_private=True)
|
||||
assert "private/shared/loopback" in str(exc_info.value)
|
||||
|
||||
|
||||
def test_email_block_private_ips_env_overrides_admin(monkeypatch):
|
||||
import routes.email_helpers as email_helpers
|
||||
_set_admin(monkeypatch, True)
|
||||
monkeypatch.setenv("EMAIL_BLOCK_PRIVATE_IPS", "true")
|
||||
cfg = {
|
||||
"smtp_host": "127.0.0.1",
|
||||
"smtp_port": 25,
|
||||
"owner": "admin",
|
||||
}
|
||||
with pytest.raises(OutboundAddressBlocked) as exc_info:
|
||||
email_helpers._send_smtp_message(cfg, "from@example.com", ["to@example.com"], b"data")
|
||||
assert "private/shared/loopback" in str(exc_info.value)
|
||||
|
||||
|
||||
def test_tls_sni_preserved_during_safe_connection():
|
||||
import routes.email_helpers as email_helpers
|
||||
mock_sock = mock.MagicMock()
|
||||
mock_ssl_sock = mock.MagicMock()
|
||||
mock_ctx = mock.MagicMock()
|
||||
mock_ctx.wrap_socket.return_value = mock_ssl_sock
|
||||
|
||||
with mock.patch("routes.email_helpers.connect_outbound_tcp", return_value=mock_sock):
|
||||
imap = email_helpers._PolicyIMAP4_SSL.__new__(email_helpers._PolicyIMAP4_SSL)
|
||||
imap._block_private = False
|
||||
imap.host = "imap.secure.example.com"
|
||||
imap.port = 993
|
||||
imap.ssl_context = mock_ctx
|
||||
res = imap._create_socket(10)
|
||||
mock_ctx.wrap_socket.assert_called_with(mock_sock, server_hostname="imap.secure.example.com")
|
||||
assert res is mock_ssl_sock
|
||||
|
||||
mock_smtp_sock = mock.MagicMock()
|
||||
mock_ctx.wrap_socket.return_value = mock_smtp_sock
|
||||
with mock.patch("routes.email_helpers.connect_outbound_tcp", return_value=mock_sock):
|
||||
smtp = email_helpers._PolicySMTP_SSL.__new__(email_helpers._PolicySMTP_SSL)
|
||||
smtp._block_private = False
|
||||
smtp._host = "smtp.secure.example.com"
|
||||
smtp.context = mock_ctx
|
||||
smtp.source_address = None
|
||||
res = smtp._get_socket("smtp.secure.example.com", 465, 10)
|
||||
mock_ctx.wrap_socket.assert_called_with(mock_sock, server_hostname="smtp.secure.example.com")
|
||||
assert res is mock_smtp_sock
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("route_name", ["create", "update", "config"])
|
||||
@pytest.mark.parametrize("field", ["imap_host", "smtp_host"])
|
||||
@pytest.mark.parametrize("is_admin", [False, True])
|
||||
@pytest.mark.parametrize("answer", ["error", "empty", "garbage", "none"])
|
||||
async def test_save_rejects_unresolved_hosts_before_persistence(monkeypatch, route_name, field, is_admin, answer):
|
||||
import routes.email_routes as routes
|
||||
import src.url_safety as safety
|
||||
|
||||
_set_admin(monkeypatch, is_admin)
|
||||
def resolve(host):
|
||||
if answer == "error":
|
||||
raise RuntimeError("resolver-internal-detail")
|
||||
return {"empty": [], "garbage": ["not-an-ip"], "none": None}[answer]
|
||||
|
||||
def no_persistence(*args, **kwargs):
|
||||
raise AssertionError("Rejected mail host reached persistence")
|
||||
|
||||
monkeypatch.setattr(safety, "_default_resolver", resolve)
|
||||
monkeypatch.setattr("core.database.SessionLocal", no_persistence)
|
||||
monkeypatch.setattr(routes, "_load_settings", no_persistence)
|
||||
monkeypatch.setattr(routes, "_save_settings", no_persistence)
|
||||
monkeypatch.setattr(routes, "_assert_owns_account", lambda *a: None)
|
||||
router = routes.setup_email_routes()
|
||||
payload = {"name": "Pending DNS", field: "pending.example.invalid"}
|
||||
owner = "admin" if is_admin else "regular-user"
|
||||
if route_name == "create":
|
||||
result = await _route(router, "/api/email/accounts", "POST")(payload, owner=owner)
|
||||
elif route_name == "update":
|
||||
result = await _route(router, "/api/email/accounts/{account_id}", "PUT")("acct", payload, owner=owner)
|
||||
else:
|
||||
result = await _route(router, "/api/email/config", "PUT")(payload, account_id=None, owner=owner)
|
||||
assert result["ok"] is False
|
||||
assert "does not resolve" in result["error"]
|
||||
assert "resolver-internal-detail" not in result["error"]
|
||||
assert "not-an-ip" not in result["error"]
|
||||
|
||||
|
||||
def test_connect_pins_the_first_resolution_without_rebinding_lookup():
|
||||
public = [(socket.AF_INET, socket.SOCK_STREAM, 6, "", ("93.184.216.34", 993))]
|
||||
metadata = [(socket.AF_INET, socket.SOCK_STREAM, 6, "", ("169.254.169.254", 993))]
|
||||
resolver = mock.Mock(side_effect=[public, metadata])
|
||||
with mock.patch("socket.socket") as create_socket:
|
||||
sock = connect_outbound_tcp("rebind.example.org", 993, resolver=resolver, block_private=True)
|
||||
resolver.assert_called_once_with("rebind.example.org", 993, 0, socket.SOCK_STREAM)
|
||||
sock.connect.assert_called_once_with(("93.184.216.34", 993))
|
||||
|
||||
|
||||
def test_resolver_type_error_propagates_without_alternate_signature_retry():
|
||||
resolver = mock.Mock(side_effect=TypeError("resolver-internal-bug"))
|
||||
with mock.patch("socket.socket") as create_socket:
|
||||
with pytest.raises(TypeError, match="resolver-internal-bug"):
|
||||
connect_outbound_tcp("mail.example.org", 993, resolver=resolver)
|
||||
resolver.assert_called_once_with("mail.example.org", 993, 0, socket.SOCK_STREAM)
|
||||
create_socket.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("port", [None, 0, -1, 65536, True, 25.5, "not-a-port"])
|
||||
def test_invalid_tcp_port_is_rejected_before_resolution(port):
|
||||
resolver = mock.Mock()
|
||||
with pytest.raises(ValueError):
|
||||
connect_outbound_tcp("mail.example.org", port, resolver=resolver)
|
||||
resolver.assert_not_called()
|
||||
|
||||
|
||||
def test_empty_resolver_iterator_fails_closed():
|
||||
assert check_outbound_host("empty.example.org", resolver=lambda host: iter(()))[0] is False
|
||||
|
||||
|
||||
def test_shared_space_metadata_endpoint_is_identified_correctly():
|
||||
ok, reason = check_outbound_host("100.100.100.200", block_private=False)
|
||||
assert ok is False
|
||||
assert "metadata endpoint blocked" in reason
|
||||
assert "link-local" not in reason
|
||||
|
||||
|
||||
def test_importing_mail_helpers_does_not_replace_stdlib_clients():
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
subprocess.run([sys.executable, "-c", """
|
||||
import imaplib, smtplib
|
||||
original = (imaplib.IMAP4, imaplib.IMAP4_SSL, smtplib.SMTP, smtplib.SMTP_SSL)
|
||||
import routes.email.email_helpers
|
||||
assert original == (imaplib.IMAP4, imaplib.IMAP4_SSL, smtplib.SMTP, smtplib.SMTP_SSL)
|
||||
"""], check=True, capture_output=True, text=True)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("route_name", ["create", "update", "config"])
|
||||
async def test_save_host_resolution_runs_off_the_event_loop(monkeypatch, route_name):
|
||||
import threading
|
||||
import routes.email_routes as routes
|
||||
|
||||
event_loop_thread = threading.get_ident()
|
||||
def guard(*args):
|
||||
assert threading.get_ident() != event_loop_thread
|
||||
return "Host rejected"
|
||||
|
||||
monkeypatch.setattr(routes, "_check_mail_host", guard)
|
||||
monkeypatch.setattr(routes, "_assert_owns_account", lambda *args: None)
|
||||
router = routes.setup_email_routes()
|
||||
payload = {"name": "Pending DNS", "imap_host": "pending.example.invalid"}
|
||||
if route_name == "create":
|
||||
result = await _route(router, "/api/email/accounts", "POST")(payload, owner="admin")
|
||||
elif route_name == "update":
|
||||
result = await _route(router, "/api/email/accounts/{account_id}", "PUT")("acct", payload, owner="admin")
|
||||
else:
|
||||
result = await _route(router, "/api/email/config", "PUT")(payload, account_id=None, owner="admin")
|
||||
assert result == {"ok": False, "error": "Host rejected"}
|
||||
|
||||
|
||||
@@ -0,0 +1,509 @@
|
||||
"""Bulk Delete must retain messages that could not be moved to Trash."""
|
||||
|
||||
from contextlib import contextmanager
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import Mock
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
|
||||
class Mailbox:
|
||||
def __init__(self):
|
||||
self.inbox = {"101", "102", "103"}
|
||||
self.trash = set()
|
||||
self.deleted = set()
|
||||
self.calls = []
|
||||
self.move_status = "NO"
|
||||
self.copy_status = {}
|
||||
self.store_status = {}
|
||||
self.expunge_status = "OK"
|
||||
self.uid_expunge_status = None
|
||||
self.select_status = "OK"
|
||||
|
||||
def select(self, folder, readonly=False):
|
||||
return self.select_status, [b"3"]
|
||||
|
||||
def uid(self, command, raw_uid, *args):
|
||||
uid = raw_uid.decode() if isinstance(raw_uid, bytes) else (str(raw_uid) if raw_uid is not None else "")
|
||||
self.calls.append((command, uid))
|
||||
if command == "FETCH":
|
||||
if uid not in self.inbox:
|
||||
return "OK", []
|
||||
if args and "(FLAGS)" in args:
|
||||
flags = rb"(\Deleted)" if uid in self.deleted else rb"()"
|
||||
return "OK", [(f"1 (UID {uid} FLAGS ".encode() + flags + b")")]
|
||||
return "OK", [f"1 (UID {uid})".encode()]
|
||||
if command == "SEARCH":
|
||||
if args and "DELETED" in args:
|
||||
return "OK", [b" ".join(u.encode() for u in sorted(self.deleted))]
|
||||
searched_uid = args[-1].split()[-1] if args else ""
|
||||
return "OK", [searched_uid.encode() if searched_uid in self.inbox else b""]
|
||||
if command == "MOVE":
|
||||
status = self.move_status.get(uid, "NO") if isinstance(self.move_status, dict) else self.move_status
|
||||
if isinstance(status, Exception):
|
||||
raise status
|
||||
if status == "OK":
|
||||
self.trash.add(uid)
|
||||
self.inbox.remove(uid)
|
||||
return status, []
|
||||
if command == "COPY":
|
||||
status = self.copy_status.get(uid, "OK")
|
||||
if isinstance(status, Exception):
|
||||
raise status
|
||||
if status == "OK":
|
||||
self.trash.add(uid)
|
||||
return status, []
|
||||
if command == "STORE":
|
||||
status = self.store_status.get(uid, "OK")
|
||||
if isinstance(status, Exception):
|
||||
raise status
|
||||
if status == "OK":
|
||||
if args and args[0] == "-FLAGS":
|
||||
self.deleted.discard(uid)
|
||||
else:
|
||||
self.deleted.add(uid)
|
||||
return status, []
|
||||
if command == "EXPUNGE":
|
||||
status = self.uid_expunge_status if self.uid_expunge_status is not None else self.expunge_status
|
||||
if isinstance(status, Exception):
|
||||
raise status
|
||||
if status == "OK":
|
||||
expunged = {u for u in uid.split(",") if u in self.deleted}
|
||||
self.inbox.difference_update(expunged)
|
||||
self.deleted.difference_update(expunged)
|
||||
return status, []
|
||||
raise AssertionError(f"Unexpected IMAP command: {command}")
|
||||
|
||||
def expunge(self):
|
||||
self.calls.append(("EXPUNGE", None))
|
||||
if isinstance(self.expunge_status, Exception):
|
||||
raise self.expunge_status
|
||||
if self.expunge_status == "OK":
|
||||
self.inbox.difference_update(self.deleted)
|
||||
self.deleted.clear()
|
||||
return self.expunge_status, []
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def bulk_delete(monkeypatch):
|
||||
from routes.email import email_routes as routes
|
||||
|
||||
mailbox = Mailbox()
|
||||
index_delete = Mock()
|
||||
invalidate_cache = Mock()
|
||||
|
||||
@contextmanager
|
||||
def imap(account_id=None, owner=""):
|
||||
assert owner == "alice"
|
||||
yield mailbox
|
||||
|
||||
monkeypatch.setattr(routes, "_start_poller", lambda: None)
|
||||
monkeypatch.setattr(routes, "_POOL_HOOKS", {})
|
||||
monkeypatch.setattr(routes, "_imap", imap)
|
||||
monkeypatch.setattr(routes, "_resolve_mail_folder", lambda *args: "Trash")
|
||||
monkeypatch.setattr(routes, "_list_imap_folders", lambda conn: ({}, ["INBOX", "Trash"]))
|
||||
monkeypatch.setattr(routes, "_email_index_delete", index_delete)
|
||||
monkeypatch.setitem(routes._CACHE_HOOKS, "invalidate_list", invalidate_cache)
|
||||
app = FastAPI()
|
||||
app.include_router(routes.setup_email_routes())
|
||||
app.dependency_overrides[routes.require_owner] = lambda: "alice"
|
||||
|
||||
with TestClient(app) as client:
|
||||
def delete(uids):
|
||||
response = client.post("/api/email/delete-bulk", json={"uids": uids})
|
||||
assert response.status_code == 200
|
||||
return response.json()
|
||||
|
||||
yield SimpleNamespace(
|
||||
mailbox=mailbox,
|
||||
delete=delete,
|
||||
index_delete=index_delete,
|
||||
invalidate_cache=invalidate_cache,
|
||||
routes=routes,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("copy_status", ["NO", "BAD"])
|
||||
def test_failed_trash_copy_preserves_original(bulk_delete, copy_status):
|
||||
bulk_delete.mailbox.copy_status["101"] = copy_status
|
||||
|
||||
result = bulk_delete.delete(["101"])
|
||||
|
||||
assert "101" in bulk_delete.mailbox.inbox
|
||||
assert bulk_delete.mailbox.deleted == set()
|
||||
assert result["deleted_uids"] == []
|
||||
assert result["failed_uids"] == ["101"]
|
||||
assert ("STORE", "101") not in bulk_delete.mailbox.calls
|
||||
assert ("EXPUNGE", None) not in bulk_delete.mailbox.calls
|
||||
bulk_delete.index_delete.assert_not_called()
|
||||
|
||||
|
||||
def test_mixed_batch_only_deletes_messages_copied_to_trash(bulk_delete):
|
||||
bulk_delete.mailbox.copy_status["102"] = "NO"
|
||||
|
||||
result = bulk_delete.delete(["101", "102", "103"])
|
||||
|
||||
assert bulk_delete.mailbox.inbox == {"102"}
|
||||
assert bulk_delete.mailbox.trash == {"101", "103"}
|
||||
assert result["deleted_uids"] == ["101", "103"]
|
||||
assert result["failed_uids"] == ["102"]
|
||||
assert [call.args[-1] for call in bulk_delete.index_delete.call_args_list] == ["101", "103"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("expunge_status", ["NO", "BAD", RuntimeError("Disconnected")])
|
||||
def test_failed_expunge_keeps_index_and_reports_failure(bulk_delete, expunge_status):
|
||||
bulk_delete.mailbox.expunge_status = expunge_status
|
||||
|
||||
result = bulk_delete.delete(["101", "102"])
|
||||
|
||||
assert bulk_delete.mailbox.inbox == {"101", "102", "103"}
|
||||
assert bulk_delete.mailbox.trash == {"101", "102"}
|
||||
assert result["deleted_uids"] == []
|
||||
assert result["failed_uids"] == ["101", "102"]
|
||||
bulk_delete.index_delete.assert_not_called()
|
||||
|
||||
|
||||
def test_store_failure_retains_original_and_index(bulk_delete):
|
||||
bulk_delete.mailbox.store_status["101"] = "NO"
|
||||
|
||||
result = bulk_delete.delete(["101"])
|
||||
|
||||
assert "101" in bulk_delete.mailbox.inbox
|
||||
assert bulk_delete.mailbox.trash == {"101"}
|
||||
assert result["failed_uids"] == ["101"]
|
||||
assert result["deleted_uids"] == []
|
||||
assert ("EXPUNGE", None) not in bulk_delete.mailbox.calls
|
||||
bulk_delete.index_delete.assert_not_called()
|
||||
|
||||
|
||||
def test_native_move_needs_no_copy_or_expunge(bulk_delete):
|
||||
bulk_delete.mailbox.move_status = "OK"
|
||||
|
||||
result = bulk_delete.delete(["101", "102"])
|
||||
|
||||
assert result["deleted_uids"] == ["101", "102"]
|
||||
assert result["failed_uids"] == []
|
||||
assert bulk_delete.mailbox.inbox == {"103"}
|
||||
assert bulk_delete.mailbox.trash == {"101", "102"}
|
||||
assert not any(command in {"COPY", "STORE", "EXPUNGE"} for command, _ in bulk_delete.mailbox.calls)
|
||||
|
||||
|
||||
def test_copy_fallback_expunge_runs_once_for_the_batch(bulk_delete):
|
||||
result = bulk_delete.delete(["101", "102"])
|
||||
|
||||
assert result["deleted_uids"] == ["101", "102"]
|
||||
assert result["failed_uids"] == []
|
||||
assert bulk_delete.mailbox.inbox == {"103"}
|
||||
assert bulk_delete.mailbox.trash == {"101", "102"}
|
||||
assert sum(1 for command, _ in bulk_delete.mailbox.calls if command == "EXPUNGE") == 1
|
||||
assert bulk_delete.index_delete.call_count == 2
|
||||
|
||||
|
||||
def test_failed_folder_selection_never_changes_messages(bulk_delete):
|
||||
bulk_delete.mailbox.select_status = "NO"
|
||||
|
||||
result = bulk_delete.delete(["101"])
|
||||
|
||||
assert result["success"] is False
|
||||
assert result["failed_uids"] == ["101"]
|
||||
assert bulk_delete.mailbox.calls == []
|
||||
bulk_delete.index_delete.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("expunge_status", ["NO", RuntimeError("Disconnected")])
|
||||
def test_completed_native_move_is_retained_when_fallback_expunge_fails(bulk_delete, expunge_status):
|
||||
bulk_delete.mailbox.move_status = {"101": "OK"}
|
||||
bulk_delete.mailbox.expunge_status = expunge_status
|
||||
|
||||
result = bulk_delete.delete(["101", "102"])
|
||||
|
||||
assert result["deleted_uids"] == ["101"]
|
||||
assert result["failed_uids"] == ["102"]
|
||||
assert bulk_delete.mailbox.inbox == {"102", "103"}
|
||||
assert bulk_delete.mailbox.trash == {"101", "102"}
|
||||
bulk_delete.index_delete.assert_called_once_with("alice", None, "INBOX", "101")
|
||||
|
||||
|
||||
def test_absent_uid_is_reported_failed_without_deleting_another_message(bulk_delete):
|
||||
result = bulk_delete.delete(["999"])
|
||||
|
||||
assert result["deleted_uids"] == []
|
||||
assert result["failed_uids"] == ["999"]
|
||||
assert bulk_delete.mailbox.inbox == {"101", "102", "103"}
|
||||
assert bulk_delete.mailbox.trash == set()
|
||||
assert not any(command in {"MOVE", "COPY", "STORE", "EXPUNGE"} for command, _ in bulk_delete.mailbox.calls)
|
||||
bulk_delete.index_delete.assert_not_called()
|
||||
|
||||
|
||||
def test_duplicate_and_invalid_uids_do_not_repeat_mail_operations(bulk_delete):
|
||||
result = bulk_delete.delete(["101", "101", "", None, "invalid"])
|
||||
|
||||
assert result["deleted_uids"] == ["101"]
|
||||
assert result["failed_uids"] == []
|
||||
assert bulk_delete.mailbox.calls.count(("COPY", "101")) == 1
|
||||
bulk_delete.index_delete.assert_called_once_with("alice", None, "INBOX", "101")
|
||||
|
||||
|
||||
def test_bulk_delete_invalidates_source_and_trash_caches(bulk_delete):
|
||||
result = bulk_delete.delete(["101"])
|
||||
|
||||
assert result["success"] is True
|
||||
assert result["deleted_uids"] == ["101"]
|
||||
bulk_delete.invalidate_cache.assert_any_call(None, "INBOX")
|
||||
bulk_delete.invalidate_cache.assert_any_call(None, "Trash")
|
||||
|
||||
|
||||
def test_partial_failure_with_exception_invalidates_caches(bulk_delete):
|
||||
bulk_delete.mailbox.move_status = {"101": "OK"}
|
||||
bulk_delete.mailbox.uid_expunge_status = RuntimeError("Socket reset during expunge")
|
||||
bulk_delete.mailbox.expunge_status = RuntimeError("Socket reset during expunge")
|
||||
|
||||
result = bulk_delete.delete(["101", "102"])
|
||||
|
||||
assert result["success"] is True
|
||||
assert result["deleted_uids"] == ["101"]
|
||||
assert result["failed_uids"] == ["102"]
|
||||
assert result["error"] == "Mail operation partially failed"
|
||||
bulk_delete.invalidate_cache.assert_any_call(None, "INBOX")
|
||||
bulk_delete.invalidate_cache.assert_any_call(None, "Trash")
|
||||
|
||||
|
||||
def test_total_failure_returns_success_false(bulk_delete):
|
||||
bulk_delete.mailbox.copy_status["101"] = "NO"
|
||||
|
||||
result = bulk_delete.delete(["101"])
|
||||
|
||||
assert result["success"] is False
|
||||
assert result["error"] == "Failed to delete requested emails"
|
||||
assert result["deleted_uids"] == []
|
||||
assert result["failed_uids"] == ["101"]
|
||||
|
||||
|
||||
def test_uid_expunge_preserves_unrelated_deleted_message(bulk_delete):
|
||||
# Unrelated message 999 is already marked \Deleted before bulk delete
|
||||
bulk_delete.mailbox.inbox.add("999")
|
||||
bulk_delete.mailbox.deleted.add("999")
|
||||
|
||||
result = bulk_delete.delete(["101"])
|
||||
|
||||
assert result["success"] is True
|
||||
assert result["deleted_uids"] == ["101"]
|
||||
# 101 was expunged, 999 remains in inbox and remains marked deleted
|
||||
assert "101" not in bulk_delete.mailbox.inbox
|
||||
assert "999" in bulk_delete.mailbox.inbox
|
||||
assert bulk_delete.mailbox.deleted == {"999"}
|
||||
assert ("EXPUNGE", "101") in bulk_delete.mailbox.calls
|
||||
assert ("EXPUNGE", None) not in bulk_delete.mailbox.calls
|
||||
|
||||
|
||||
def test_server_lacking_uidplus_never_calls_mailbox_expunge(bulk_delete):
|
||||
# Server does not support UIDPLUS (UID EXPUNGE returns BAD)
|
||||
# Even if no other deleted messages exist, mailbox-wide EXPUNGE is strictly forbidden.
|
||||
bulk_delete.mailbox.uid_expunge_status = "BAD"
|
||||
assert bulk_delete.mailbox.deleted == set()
|
||||
|
||||
result = bulk_delete.delete(["101"])
|
||||
|
||||
assert result["success"] is False
|
||||
assert result["deleted_uids"] == []
|
||||
assert result["failed_uids"] == ["101"]
|
||||
assert "101" in bulk_delete.mailbox.inbox
|
||||
assert "101" not in bulk_delete.mailbox.deleted
|
||||
assert ("EXPUNGE", None) not in bulk_delete.mailbox.calls
|
||||
bulk_delete.index_delete.assert_not_called()
|
||||
|
||||
|
||||
def test_concurrent_client_marks_unrelated_deleted_message_during_operation(bulk_delete):
|
||||
# Another client marks unrelated UID 999 \Deleted before/during the operation
|
||||
bulk_delete.mailbox.inbox.add("999")
|
||||
bulk_delete.mailbox.deleted.add("999")
|
||||
|
||||
result = bulk_delete.delete(["101"])
|
||||
|
||||
assert result["success"] is True
|
||||
assert result["deleted_uids"] == ["101"]
|
||||
# 101 was expunged, unrelated 999 is NEVER expunged
|
||||
assert "101" not in bulk_delete.mailbox.inbox
|
||||
assert "999" in bulk_delete.mailbox.inbox
|
||||
assert bulk_delete.mailbox.deleted == {"999"}
|
||||
assert ("EXPUNGE", "101") in bulk_delete.mailbox.calls
|
||||
assert ("EXPUNGE", None) not in bulk_delete.mailbox.calls
|
||||
|
||||
|
||||
def test_preexisting_deleted_flag_preserved_when_expunge_fails(bulk_delete):
|
||||
# Message 101 already had \Deleted flag before the bulk operation
|
||||
bulk_delete.mailbox.deleted.add("101")
|
||||
bulk_delete.mailbox.uid_expunge_status = "NO"
|
||||
|
||||
result = bulk_delete.delete(["101"])
|
||||
|
||||
assert result["success"] is False
|
||||
assert result["deleted_uids"] == []
|
||||
assert result["failed_uids"] == ["101"]
|
||||
assert "101" in bulk_delete.mailbox.inbox
|
||||
# Pre-existing \Deleted flag must NOT be removed
|
||||
assert "101" in bulk_delete.mailbox.deleted
|
||||
assert ("EXPUNGE", None) not in bulk_delete.mailbox.calls
|
||||
bulk_delete.index_delete.assert_not_called()
|
||||
|
||||
|
||||
def test_preexisting_deleted_flag_expunged_on_success(bulk_delete):
|
||||
# Message 101 already had \Deleted flag before the bulk operation
|
||||
bulk_delete.mailbox.deleted.add("101")
|
||||
|
||||
result = bulk_delete.delete(["101"])
|
||||
|
||||
assert result["success"] is True
|
||||
assert result["deleted_uids"] == ["101"]
|
||||
assert "101" not in bulk_delete.mailbox.inbox
|
||||
assert "101" not in bulk_delete.mailbox.deleted
|
||||
assert "101" in bulk_delete.mailbox.trash
|
||||
bulk_delete.index_delete.assert_called_once_with("alice", None, "INBOX", "101")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("status", ["NO", "BAD"])
|
||||
def test_uid_expunge_status_failures(bulk_delete, status):
|
||||
bulk_delete.mailbox.uid_expunge_status = status
|
||||
|
||||
result = bulk_delete.delete(["101", "102"])
|
||||
|
||||
assert result["success"] is False
|
||||
assert result["deleted_uids"] == []
|
||||
assert result["failed_uids"] == ["101", "102"]
|
||||
assert bulk_delete.mailbox.inbox == {"101", "102", "103"}
|
||||
assert ("EXPUNGE", None) not in bulk_delete.mailbox.calls
|
||||
bulk_delete.index_delete.assert_not_called()
|
||||
|
||||
|
||||
def test_uid_expunge_connection_exception_handled_conservatively(bulk_delete):
|
||||
bulk_delete.mailbox.uid_expunge_status = ConnectionResetError("Connection lost during selective expunge")
|
||||
|
||||
result = bulk_delete.delete(["101"])
|
||||
|
||||
assert result["success"] is False
|
||||
assert result["deleted_uids"] == []
|
||||
assert result["failed_uids"] == ["101"]
|
||||
assert "101" in bulk_delete.mailbox.inbox
|
||||
assert ("EXPUNGE", None) not in bulk_delete.mailbox.calls
|
||||
bulk_delete.index_delete.assert_not_called()
|
||||
|
||||
|
||||
def test_disconnect_after_copy_before_store_preserves_message(bulk_delete):
|
||||
# COPY succeeds, but STORE raises a disconnect
|
||||
bulk_delete.mailbox.store_status["101"] = ConnectionResetError("Disconnected")
|
||||
|
||||
result = bulk_delete.delete(["101"])
|
||||
|
||||
assert result["success"] is False
|
||||
assert result["deleted_uids"] == []
|
||||
assert result["failed_uids"] == ["101"]
|
||||
assert "101" in bulk_delete.mailbox.inbox
|
||||
assert "101" in bulk_delete.mailbox.trash
|
||||
assert bulk_delete.mailbox.deleted == set()
|
||||
assert ("EXPUNGE", None) not in bulk_delete.mailbox.calls
|
||||
bulk_delete.index_delete.assert_not_called()
|
||||
|
||||
|
||||
def test_disconnect_after_store_preserves_messages_and_index(bulk_delete):
|
||||
# COPY and STORE succeed, but UID EXPUNGE disconnects
|
||||
bulk_delete.mailbox.uid_expunge_status = ConnectionResetError("Disconnected during expunge")
|
||||
|
||||
result = bulk_delete.delete(["101"])
|
||||
|
||||
assert result["success"] is False
|
||||
assert result["deleted_uids"] == []
|
||||
assert result["failed_uids"] == ["101"]
|
||||
assert "101" in bulk_delete.mailbox.inbox
|
||||
assert "101" in bulk_delete.mailbox.trash
|
||||
assert ("EXPUNGE", None) not in bulk_delete.mailbox.calls
|
||||
bulk_delete.index_delete.assert_not_called()
|
||||
|
||||
|
||||
def test_disconnect_with_partial_move_success(bulk_delete):
|
||||
# 101 succeeds via MOVE, 102 falls back to COPY+STORE and UID EXPUNGE disconnects
|
||||
bulk_delete.mailbox.move_status = {"101": "OK", "102": "NO"}
|
||||
bulk_delete.mailbox.uid_expunge_status = ConnectionResetError("Disconnected during expunge")
|
||||
|
||||
result = bulk_delete.delete(["101", "102"])
|
||||
|
||||
assert result["success"] is True
|
||||
assert result["deleted_uids"] == ["101"]
|
||||
assert result["failed_uids"] == ["102"]
|
||||
assert "101" not in bulk_delete.mailbox.inbox
|
||||
assert "102" in bulk_delete.mailbox.inbox
|
||||
assert ("EXPUNGE", None) not in bulk_delete.mailbox.calls
|
||||
bulk_delete.index_delete.assert_called_once_with("alice", None, "INBOX", "101")
|
||||
bulk_delete.invalidate_cache.assert_any_call(None, "INBOX")
|
||||
bulk_delete.invalidate_cache.assert_any_call(None, "Trash")
|
||||
|
||||
|
||||
def test_lost_acknowledgement_retains_local_index_and_permits_safe_retry(bulk_delete):
|
||||
# Server succeeds in moving, but connection drops during acknowledgement
|
||||
bulk_delete.mailbox.uid_expunge_status = OSError("Connection dropped")
|
||||
bulk_delete.mailbox.expunge_status = OSError("Connection dropped")
|
||||
|
||||
result = bulk_delete.delete(["101"])
|
||||
|
||||
assert result["success"] is False
|
||||
assert result["deleted_uids"] == []
|
||||
assert result["failed_uids"] == ["101"]
|
||||
assert ("EXPUNGE", None) not in bulk_delete.mailbox.calls
|
||||
bulk_delete.index_delete.assert_not_called()
|
||||
|
||||
# Retry when server is reconnected and 101 was already moved (absent from source)
|
||||
bulk_delete.mailbox.inbox.discard("101")
|
||||
bulk_delete.mailbox.uid_expunge_status = "OK"
|
||||
bulk_delete.mailbox.expunge_status = "OK"
|
||||
|
||||
retry_result = bulk_delete.delete(["101"])
|
||||
assert retry_result["deleted_uids"] == []
|
||||
assert retry_result["failed_uids"] == ["101"]
|
||||
bulk_delete.index_delete.assert_not_called()
|
||||
|
||||
|
||||
def test_repeated_requests_after_partial_failure(bulk_delete):
|
||||
# Initial request: 101 moves, 102 fails copy
|
||||
bulk_delete.mailbox.move_status = {"101": "OK", "102": "NO"}
|
||||
bulk_delete.mailbox.copy_status["102"] = "NO"
|
||||
|
||||
res1 = bulk_delete.delete(["101", "102"])
|
||||
assert res1["deleted_uids"] == ["101"]
|
||||
assert res1["failed_uids"] == ["102"]
|
||||
|
||||
# Retry with failed UID 102 now succeeding via copy
|
||||
bulk_delete.mailbox.copy_status["102"] = "OK"
|
||||
res2 = bulk_delete.delete(["102"])
|
||||
assert res2["deleted_uids"] == ["102"]
|
||||
assert res2["failed_uids"] == []
|
||||
|
||||
assert bulk_delete.mailbox.inbox == {"103"}
|
||||
assert bulk_delete.mailbox.trash == {"101", "102"}
|
||||
assert ("EXPUNGE", None) not in bulk_delete.mailbox.calls
|
||||
|
||||
|
||||
def test_unrelated_messages_never_expunged(bulk_delete):
|
||||
# Mailbox contains unrelated 888 (with \Deleted) and 999 (without \Deleted)
|
||||
bulk_delete.mailbox.inbox.update({"888", "999"})
|
||||
bulk_delete.mailbox.deleted.add("888")
|
||||
|
||||
result = bulk_delete.delete(["101"])
|
||||
|
||||
assert result["success"] is True
|
||||
assert result["deleted_uids"] == ["101"]
|
||||
assert "101" not in bulk_delete.mailbox.inbox
|
||||
assert "101" in bulk_delete.mailbox.trash
|
||||
# 888 and 999 are completely untouched in inbox
|
||||
assert "888" in bulk_delete.mailbox.inbox
|
||||
assert "999" in bulk_delete.mailbox.inbox
|
||||
assert "888" in bulk_delete.mailbox.deleted
|
||||
assert "888" not in bulk_delete.mailbox.trash
|
||||
assert "999" not in bulk_delete.mailbox.trash
|
||||
assert ("EXPUNGE", None) not in bulk_delete.mailbox.calls
|
||||
|
||||
|
||||
def test_standard_expunge_never_invoked_by_fallback(bulk_delete):
|
||||
# Perform operations under multiple fallback conditions; standard expunge is never called
|
||||
bulk_delete.delete(["101"])
|
||||
assert ("EXPUNGE", None) not in bulk_delete.mailbox.calls
|
||||
@@ -41,18 +41,18 @@ def test_email_bulk_read_unread_checks_backend_success_before_syncing_cache():
|
||||
assert "_libCacheWriteBack()" in src
|
||||
|
||||
|
||||
def test_email_bulk_export_attachments_is_ui_only_selected_context():
|
||||
def test_email_bulk_exports_messages_and_attachments_from_selected_context():
|
||||
frontend = email_library_source()
|
||||
backend = _EMAIL_ROUTES.read_text(encoding="utf-8")
|
||||
export_src = frontend[
|
||||
frontend.index("async function _exportSelectedAttachments()"):
|
||||
frontend.index("\n}\n\n// URL-suffix helper", frontend.index("async function _exportSelectedAttachments()")) + 3
|
||||
]
|
||||
export_src = _function_source("_exportSelectedBundle")
|
||||
payload_src = _function_source("_selectedEmailExportPayload")
|
||||
|
||||
assert "Export Emails" in frontend
|
||||
assert "Email file (.eml)" in frontend
|
||||
assert "_exportSelectedEmailPdf" in frontend
|
||||
assert "Export Attachments" in frontend
|
||||
assert "All in view" in frontend
|
||||
assert "/api/email/attachments-download-bulk" in export_src
|
||||
assert "${isEmailExport ? 'messages' : 'attachments'}-download-bulk" in export_src
|
||||
assert "method: 'POST'" in export_src
|
||||
assert "res.blob()" in export_src
|
||||
assert "account_id: String(em.account_id || state._libAccountId || '')" in payload_src
|
||||
@@ -63,6 +63,96 @@ def test_email_bulk_export_attachments_is_ui_only_selected_context():
|
||||
assert "_fixture_email_read(uid, folder, owner)" in backend
|
||||
assert "_is_likely_signature_image_attachment(att)" in backend
|
||||
|
||||
assert '@router.post("/messages-download-bulk")' in backend
|
||||
assert '"(UID RFC822)"' in backend
|
||||
assert "msg.as_bytes(policy=email_policy.SMTP)" in backend
|
||||
assert 'media_type="message/rfc822"' in backend
|
||||
assert '"X-Exported-Emails"' in backend
|
||||
|
||||
|
||||
def test_email_print_pdf_export_reads_without_marking_read_and_sanitizes_body():
|
||||
"""Print / save PDF export must use the browser print dialog (SAN-157).
|
||||
|
||||
The removed html2pdf library must not be loaded or referenced. Instead
|
||||
the export uses a sandboxed iframe and window.print(), matching the
|
||||
established pattern in document.js.
|
||||
"""
|
||||
frontend = email_library_source()
|
||||
print_src = _function_source("_printSelectedEmails")
|
||||
menu_src = js_function_source("_showBulkActionsMenu", frontend)
|
||||
|
||||
assert "mark_seen=false&full=true" in print_src
|
||||
assert "_safeRenderEmailBody(data)" in print_src
|
||||
assert "new DOMParser()" in print_src
|
||||
assert "up to 20 emails" in print_src
|
||||
assert "Print / save PDF" in menu_src
|
||||
|
||||
# SAN-157: must NOT reference the removed html2pdf library.
|
||||
assert "html2pdf" not in print_src
|
||||
assert "html2pdf" not in frontend
|
||||
|
||||
# Must use the browser print pattern with a sandboxed iframe.
|
||||
assert "contentWindow.print()" in print_src
|
||||
assert "sandbox" in print_src
|
||||
assert "allow-same-origin" in print_src
|
||||
assert "allow-modals" in print_src
|
||||
assert "afterprint" in print_src
|
||||
assert "email-browser-print-frame" in print_src
|
||||
|
||||
|
||||
def test_email_eml_export_bounds_total_size():
|
||||
"""The /messages-download-bulk endpoint must enforce a total size cap
|
||||
to prevent unbounded memory growth when exporting many large messages."""
|
||||
backend = _EMAIL_ROUTES.read_text(encoding="utf-8")
|
||||
|
||||
assert "max_total_bytes" in backend
|
||||
assert "total_bytes" in backend
|
||||
assert "Export too large" in backend
|
||||
assert "status_code=413" in backend
|
||||
|
||||
|
||||
def test_email_eml_export_validates_selection_count():
|
||||
"""The /messages-download-bulk endpoint must reject oversized selections."""
|
||||
backend = _EMAIL_ROUTES.read_text(encoding="utf-8")
|
||||
|
||||
assert 'len(messages) > 250' in backend
|
||||
assert '"Too many emails selected"' in backend
|
||||
|
||||
|
||||
def test_email_eml_export_rejects_non_numeric_remote_uids():
|
||||
"""Non-numeric UIDs must not be passed to IMAP UID FETCH."""
|
||||
backend = _EMAIL_ROUTES.read_text(encoding="utf-8")
|
||||
|
||||
# After fixture lookup fails, non-numeric UIDs are skipped.
|
||||
assert 're.fullmatch(r"\\d+", uid)' in backend
|
||||
|
||||
|
||||
def test_email_eml_export_handles_partial_batch_failures():
|
||||
"""When an IMAP fetch batch returns partial results, exported/skipped
|
||||
accounting must remain correct."""
|
||||
backend = _EMAIL_ROUTES.read_text(encoding="utf-8")
|
||||
|
||||
# The skipped counter increments for UIDs not found in the batch response.
|
||||
assert "skipped += len(batch) - len(found)" in backend
|
||||
# Whole-connection failures count remaining unprocessed rows as skipped.
|
||||
assert "skipped += remaining_rows" in backend
|
||||
assert '"X-Exported-Emails"' in backend
|
||||
assert '"X-Skipped-Emails"' in backend
|
||||
|
||||
|
||||
def test_email_eml_export_enforces_account_ownership():
|
||||
"""The /messages-download-bulk endpoint must assert account ownership."""
|
||||
backend = _EMAIL_ROUTES.read_text(encoding="utf-8")
|
||||
|
||||
assert "_assert_owns_account(account_id, owner)" in backend
|
||||
|
||||
|
||||
def test_email_eml_export_deduplicates_selection():
|
||||
"""Duplicate items in selection must be skipped to avoid duplicate files."""
|
||||
backend = _EMAIL_ROUTES.read_text(encoding="utf-8")
|
||||
|
||||
assert "seen_items" in backend
|
||||
|
||||
|
||||
def test_fixture_tag_filter_supports_receipt_tag_flow():
|
||||
backend = _EMAIL_ROUTES.read_text(encoding="utf-8")
|
||||
@@ -171,3 +261,808 @@ def test_email_compose_can_attach_gallery_images():
|
||||
assert "GalleryImage" in backend
|
||||
assert "_gallery_image_path(img.filename)" in backend
|
||||
assert "_stage_compose_file" in backend
|
||||
|
||||
|
||||
class _MockImapConn:
|
||||
def __init__(self, messages_by_uid: dict[str, bytes] | None = None, fetch_exc: Exception | None = None, fetch_cb=None):
|
||||
self.messages = messages_by_uid or {}
|
||||
self.fetch_exc = fetch_exc
|
||||
self.fetch_cb = fetch_cb
|
||||
self.fetch_calls: list = []
|
||||
|
||||
def select(self, folder, readonly=True):
|
||||
return "OK", [b"1"]
|
||||
|
||||
def uid(self, command, *args):
|
||||
if command == "FETCH":
|
||||
self.fetch_calls.append(args)
|
||||
if self.fetch_exc is not None:
|
||||
raise self.fetch_exc
|
||||
if self.fetch_cb:
|
||||
return self.fetch_cb(args)
|
||||
uid_set_b = args[0]
|
||||
uid_set = uid_set_b.decode() if isinstance(uid_set_b, bytes) else str(uid_set_b)
|
||||
uids = [u.strip() for u in uid_set.split(",") if u.strip()]
|
||||
fetch_cmd = str(args[1]) if len(args) > 1 else ""
|
||||
res = []
|
||||
if "RFC822.SIZE" in fetch_cmd:
|
||||
for u in uids:
|
||||
if u in self.messages:
|
||||
meta = f"1 (UID {u} RFC822.SIZE {len(self.messages[u])})".encode()
|
||||
res.append(meta)
|
||||
return "OK", res
|
||||
for u in uids:
|
||||
if u in self.messages:
|
||||
meta = f"1 (UID {u} RFC822 {{{len(self.messages[u])}}}".encode()
|
||||
res.append((meta, self.messages[u]))
|
||||
res.append(b")")
|
||||
return "OK", res
|
||||
return "NO", []
|
||||
|
||||
|
||||
def _create_test_app(monkeypatch, mailbox: _MockImapConn | None = None, assert_owns_fn=None):
|
||||
from contextlib import contextmanager
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
from routes.email import email_routes as routes
|
||||
|
||||
conn = mailbox or _MockImapConn()
|
||||
|
||||
@contextmanager
|
||||
def mock_imap(account_id=None, owner=""):
|
||||
yield conn
|
||||
|
||||
monkeypatch.setattr(routes, "_start_poller", lambda: None)
|
||||
monkeypatch.setattr(routes, "_POOL_HOOKS", {})
|
||||
monkeypatch.setattr(routes, "_imap", mock_imap)
|
||||
if assert_owns_fn:
|
||||
monkeypatch.setattr(routes, "_assert_owns_account", assert_owns_fn)
|
||||
|
||||
app = FastAPI()
|
||||
app.include_router(routes.setup_email_routes())
|
||||
app.dependency_overrides[routes.require_owner] = lambda: "test-user"
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
def test_messages_download_bulk_validates_empty_and_oversized_payload(monkeypatch):
|
||||
client = _create_test_app(monkeypatch)
|
||||
|
||||
res_empty = client.post("/api/email/messages-download-bulk", json={"messages": []})
|
||||
assert res_empty.status_code == 400
|
||||
assert "No emails selected" in res_empty.text
|
||||
|
||||
oversized = [{"uid": str(i)} for i in range(251)]
|
||||
res_over = client.post("/api/email/messages-download-bulk", json={"messages": oversized})
|
||||
assert res_over.status_code == 400
|
||||
assert "Too many emails selected" in res_over.text
|
||||
|
||||
|
||||
def test_messages_download_bulk_single_message_eml_export(monkeypatch):
|
||||
raw_rfc822 = b"From: sender@example.com\r\nTo: test-user\r\nSubject: Report\r\n\r\nReport content"
|
||||
mailbox = _MockImapConn({"101": raw_rfc822})
|
||||
client = _create_test_app(monkeypatch, mailbox=mailbox)
|
||||
|
||||
res = client.post("/api/email/messages-download-bulk", json={
|
||||
"messages": [{"uid": "101", "subject": "Quarterly Report", "date": "2026-10-20"}]
|
||||
})
|
||||
|
||||
assert res.status_code == 200
|
||||
assert res.headers["content-type"].startswith("message/rfc822")
|
||||
assert res.headers["x-exported-emails"] == "1"
|
||||
assert res.headers["x-skipped-emails"] == "0"
|
||||
assert "email.eml" in res.headers["content-disposition"]
|
||||
assert res.content == raw_rfc822
|
||||
|
||||
|
||||
def test_messages_download_bulk_multi_message_zip_export(monkeypatch):
|
||||
import io
|
||||
import zipfile
|
||||
|
||||
msg1 = b"From: a@example.com\r\nSubject: First\r\n\r\nOne"
|
||||
msg2 = b"From: b@example.com\r\nSubject: Second\r\n\r\nTwo"
|
||||
mailbox = _MockImapConn({"101": msg1, "102": msg2})
|
||||
client = _create_test_app(monkeypatch, mailbox=mailbox)
|
||||
|
||||
res = client.post("/api/email/messages-download-bulk", json={
|
||||
"messages": [
|
||||
{"uid": "101", "subject": "First Msg", "date": "2026-10-18"},
|
||||
{"uid": "102", "subject": "Second Msg", "date": "2026-10-19"},
|
||||
]
|
||||
})
|
||||
|
||||
assert res.status_code == 200
|
||||
assert res.headers["content-type"] == "application/zip"
|
||||
assert res.headers["x-exported-emails"] == "2"
|
||||
assert res.headers["x-skipped-emails"] == "0"
|
||||
|
||||
zf = zipfile.ZipFile(io.BytesIO(res.content))
|
||||
namelist = zf.namelist()
|
||||
assert len(namelist) == 2
|
||||
assert any("101.eml" in name for name in namelist)
|
||||
assert any("102.eml" in name for name in namelist)
|
||||
|
||||
|
||||
def test_messages_download_bulk_partial_failure_and_duplicate_dedup(monkeypatch):
|
||||
import io
|
||||
import zipfile
|
||||
|
||||
msg1 = b"From: a@example.com\r\nSubject: Exists\r\n\r\nExists body"
|
||||
# UID 101 exists, UID 999 does not exist in mailbox
|
||||
mailbox = _MockImapConn({"101": msg1})
|
||||
client = _create_test_app(monkeypatch, mailbox=mailbox)
|
||||
|
||||
res = client.post("/api/email/messages-download-bulk", json={
|
||||
"messages": [
|
||||
{"uid": "101", "subject": "Exists"},
|
||||
{"uid": "999", "subject": "Missing"},
|
||||
{"uid": "101", "subject": "Duplicate"}, # duplicate must be skipped
|
||||
]
|
||||
})
|
||||
|
||||
assert res.status_code == 200
|
||||
assert res.headers["content-type"] == "application/zip"
|
||||
assert res.headers["x-exported-emails"] == "1"
|
||||
assert res.headers["x-skipped-emails"] == "2"
|
||||
|
||||
zf = zipfile.ZipFile(io.BytesIO(res.content))
|
||||
assert len(zf.namelist()) == 1
|
||||
|
||||
|
||||
def test_messages_download_bulk_enforces_account_ownership_guard(monkeypatch):
|
||||
from fastapi import HTTPException
|
||||
|
||||
def mock_assert_owns(account_id, owner):
|
||||
if account_id == "other-tenant-acct":
|
||||
raise HTTPException(status_code=404, detail="Account not found")
|
||||
|
||||
client = _create_test_app(monkeypatch, assert_owns_fn=mock_assert_owns)
|
||||
|
||||
res = client.post("/api/email/messages-download-bulk", json={
|
||||
"messages": [{"uid": "101", "account_id": "other-tenant-acct"}]
|
||||
})
|
||||
|
||||
assert res.status_code == 404
|
||||
assert "Account not found" in res.text
|
||||
|
||||
|
||||
def test_messages_download_bulk_mid_batch_failure_accounting(monkeypatch):
|
||||
"""When an exception occurs mid-batch after partial progress, already-exported
|
||||
records must not be double-counted as skipped."""
|
||||
class MidBatchFailImap:
|
||||
def select(self, folder, readonly=True):
|
||||
return "OK", [b"1"]
|
||||
def uid(self, command, *args):
|
||||
fetch_cmd = str(args[1]) if len(args) > 1 else ""
|
||||
if "RFC822.SIZE" in fetch_cmd:
|
||||
return "OK", [f"{i} (UID {i} RFC822.SIZE 100)".encode() for i in range(1, 11)]
|
||||
records = []
|
||||
for i in range(1, 5):
|
||||
records.append((f"{i} (UID {i} RFC822 {{{10}}}".encode(), f"Subject: Msg {i}\r\n\r\nBody {i}".encode()))
|
||||
class BadRecord:
|
||||
def __bytes__(self):
|
||||
raise RuntimeError("Corrupt memory buffer")
|
||||
records.append((b"5 (UID 5 RFC822 {10}", BadRecord()))
|
||||
return "OK", records
|
||||
|
||||
from contextlib import contextmanager
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
from routes.email import email_routes as routes
|
||||
|
||||
@contextmanager
|
||||
def mock_imap(account_id=None, owner=""):
|
||||
yield MidBatchFailImap()
|
||||
|
||||
monkeypatch.setattr(routes, "_start_poller", lambda: None)
|
||||
monkeypatch.setattr(routes, "_POOL_HOOKS", {})
|
||||
monkeypatch.setattr(routes, "_imap", mock_imap)
|
||||
|
||||
app = FastAPI()
|
||||
app.include_router(routes.setup_email_routes())
|
||||
app.dependency_overrides[routes.require_owner] = lambda: "test-user"
|
||||
client = TestClient(app)
|
||||
|
||||
rows = [{"uid": str(i)} for i in range(1, 11)]
|
||||
res = client.post("/api/email/messages-download-bulk", json={"messages": rows})
|
||||
assert res.status_code == 200
|
||||
exp = int(res.headers.get("x-exported-emails"))
|
||||
skp = int(res.headers.get("x-skipped-emails"))
|
||||
assert exp == 4
|
||||
assert skp == 6
|
||||
assert exp + skp == 10
|
||||
|
||||
|
||||
def test_messages_download_bulk_size_cap_413_boundary(monkeypatch):
|
||||
"""Cumulative export size exceeding max_total_bytes returns HTTP 413."""
|
||||
class LargeMessageImap:
|
||||
def select(self, folder, readonly=True):
|
||||
return "OK", [b"1"]
|
||||
def uid(self, command, *args):
|
||||
raw_uids = args[0] if isinstance(args[0], str) else args[0].decode()
|
||||
if "(UID RFC822.SIZE)" in str(args):
|
||||
records = []
|
||||
for u in raw_uids.split(","):
|
||||
records.append(f"{u} (UID {u} RFC822.SIZE 10485760)".encode())
|
||||
return "OK", records
|
||||
records = []
|
||||
big_chunk = b"X" * (10 * 1024 * 1024)
|
||||
for u in raw_uids.split(","):
|
||||
records.append((f"{u} (UID {u} RFC822".encode(), big_chunk))
|
||||
return "OK", records
|
||||
|
||||
from contextlib import contextmanager
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
from routes.email import email_routes as routes
|
||||
|
||||
@contextmanager
|
||||
def mock_imap(account_id=None, owner=""):
|
||||
yield LargeMessageImap()
|
||||
|
||||
monkeypatch.setattr(routes, "_start_poller", lambda: None)
|
||||
monkeypatch.setattr(routes, "_POOL_HOOKS", {})
|
||||
monkeypatch.setattr(routes, "_imap", mock_imap)
|
||||
|
||||
app = FastAPI()
|
||||
app.include_router(routes.setup_email_routes())
|
||||
app.dependency_overrides[routes.require_owner] = lambda: "test-user"
|
||||
client = TestClient(app)
|
||||
|
||||
# 52 messages of 10 MB = 520 MB > 512 MiB limit
|
||||
payload_52 = [{"uid": str(i + 1), "subject": f"Big {i}"} for i in range(52)]
|
||||
res = client.post("/api/email/messages-download-bulk", json={"messages": payload_52})
|
||||
assert res.status_code == 413
|
||||
assert res.json()["detail"] == "Export too large"
|
||||
|
||||
|
||||
def test_messages_download_bulk_per_message_limit_413(monkeypatch):
|
||||
"""A single oversized message (>64 MiB) triggers HTTP 413."""
|
||||
class HugeSingleMsgImap:
|
||||
def select(self, folder, readonly=True):
|
||||
return "OK", [b"1"]
|
||||
def uid(self, command, *args):
|
||||
return "OK", [b"1 (UID 101 RFC822.SIZE 73400320)"]
|
||||
|
||||
from contextlib import contextmanager
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
from routes.email import email_routes as routes
|
||||
|
||||
@contextmanager
|
||||
def mock_imap(account_id=None, owner=""):
|
||||
yield HugeSingleMsgImap()
|
||||
|
||||
monkeypatch.setattr(routes, "_start_poller", lambda: None)
|
||||
monkeypatch.setattr(routes, "_POOL_HOOKS", {})
|
||||
monkeypatch.setattr(routes, "_imap", mock_imap)
|
||||
|
||||
app = FastAPI()
|
||||
app.include_router(routes.setup_email_routes())
|
||||
app.dependency_overrides[routes.require_owner] = lambda: "test-user"
|
||||
client = TestClient(app)
|
||||
|
||||
res = client.post("/api/email/messages-download-bulk", json={
|
||||
"messages": [{"uid": "101", "subject": "Huge Email"}]
|
||||
})
|
||||
assert res.status_code == 413
|
||||
assert res.json()["detail"] == "Export too large"
|
||||
|
||||
|
||||
def test_messages_download_bulk_account_ownership_memoization(monkeypatch):
|
||||
"""Account ownership validation is memoized per distinct account_id to eliminate N+1 DB queries."""
|
||||
owns_calls = []
|
||||
def counting_assert_owns(account_id, owner):
|
||||
owns_calls.append(account_id)
|
||||
|
||||
raw_rfc822 = b"From: sender@example.com\r\nSubject: Test\r\n\r\nBody"
|
||||
msgs = {str(i): raw_rfc822 for i in range(1, 26)}
|
||||
mailbox = _MockImapConn(msgs)
|
||||
client = _create_test_app(monkeypatch, mailbox=mailbox, assert_owns_fn=counting_assert_owns)
|
||||
|
||||
# 25 messages all referencing the same account_id
|
||||
payload = [{"uid": str(i), "account_id": "acc-shared"} for i in range(1, 26)]
|
||||
res = client.post("/api/email/messages-download-bulk", json={"messages": payload})
|
||||
assert res.status_code == 200
|
||||
assert len(owns_calls) == 1
|
||||
|
||||
|
||||
def test_messages_download_bulk_corrupt_zip_fails_closed(monkeypatch):
|
||||
"""If ZIP construction fails (e.g. disk error), fail closed with 500 instead of returning corrupt archive."""
|
||||
import zipfile
|
||||
raw_rfc822 = b"From: sender@example.com\r\nSubject: Test\r\n\r\nBody"
|
||||
mailbox = _MockImapConn({"101": raw_rfc822, "102": raw_rfc822})
|
||||
|
||||
orig_writestr = zipfile.ZipFile.writestr
|
||||
def failing_writestr(self, zinfo_or_arcname, data=None, *args, **kwargs):
|
||||
raise OSError("Disk write failed: No space left on device")
|
||||
|
||||
monkeypatch.setattr(zipfile.ZipFile, "writestr", failing_writestr)
|
||||
client = _create_test_app(monkeypatch, mailbox=mailbox)
|
||||
|
||||
res = client.post("/api/email/messages-download-bulk", json={
|
||||
"messages": [{"uid": "101"}, {"uid": "102"}]
|
||||
})
|
||||
assert res.status_code == 500
|
||||
assert "Failed to create export archive" in res.json()["detail"]
|
||||
|
||||
|
||||
def test_messages_download_bulk_preflight_no_fails_safe_no_body_fetch(monkeypatch):
|
||||
"""When preflight returns NO, size safety is unknown: fail safely (502) and do not fetch bodies."""
|
||||
mailbox = _MockImapConn({"101": b"dummy", "102": b"dummy"})
|
||||
mailbox.fetch_cb = lambda args: ("NO", []) if "RFC822.SIZE" in str(args) else ("OK", [])
|
||||
client = _create_test_app(monkeypatch, mailbox=mailbox)
|
||||
|
||||
res = client.post("/api/email/messages-download-bulk", json={"messages": [{"uid": "101"}, {"uid": "102"}]})
|
||||
assert res.status_code == 502
|
||||
assert "Unable to verify email size safety" in res.json()["detail"]
|
||||
assert not any("(UID RFC822)" in str(call) for call in mailbox.fetch_calls)
|
||||
|
||||
|
||||
def test_messages_download_bulk_preflight_empty_missing_mail(monkeypatch):
|
||||
"""When preflight returns empty data, all requested messages are missing on server: returns 404 with no body fetch."""
|
||||
mailbox = _MockImapConn() # no messages
|
||||
client = _create_test_app(monkeypatch, mailbox=mailbox)
|
||||
|
||||
res = client.post("/api/email/messages-download-bulk", json={"messages": [{"uid": "101"}, {"uid": "102"}]})
|
||||
assert res.status_code == 404
|
||||
assert "No selected emails could be exported" in res.json()["detail"]
|
||||
assert not any("(UID RFC822)" in str(call) for call in mailbox.fetch_calls)
|
||||
|
||||
|
||||
def test_messages_download_bulk_preflight_malformed_fails_safe_no_body_fetch(monkeypatch):
|
||||
"""When preflight returns records without RFC822.SIZE, fail safely (502) and do not fetch bodies."""
|
||||
mailbox = _MockImapConn({"101": b"dummy"})
|
||||
mailbox.fetch_cb = lambda args: ("OK", [b"1 (UID 101 FLAGS (\\Seen))"]) if "RFC822.SIZE" in str(args) else ("OK", [])
|
||||
client = _create_test_app(monkeypatch, mailbox=mailbox)
|
||||
|
||||
res = client.post("/api/email/messages-download-bulk", json={"messages": [{"uid": "101"}]})
|
||||
assert res.status_code == 502
|
||||
assert "Invalid email size metadata from server" in res.json()["detail"]
|
||||
assert not any("(UID RFC822)" in str(call) for call in mailbox.fetch_calls)
|
||||
|
||||
|
||||
def test_messages_download_bulk_preflight_inconsistent_fails_safe_no_body_fetch(monkeypatch):
|
||||
"""When preflight returns negative or corrupted size values, fail safely (502) and do not fetch bodies."""
|
||||
mailbox = _MockImapConn({"101": b"dummy"})
|
||||
mailbox.fetch_cb = lambda args: ("OK", [b"1 (UID 101 RFC822.SIZE -50)"]) if "RFC822.SIZE" in str(args) else ("OK", [])
|
||||
client = _create_test_app(monkeypatch, mailbox=mailbox)
|
||||
|
||||
res = client.post("/api/email/messages-download-bulk", json={"messages": [{"uid": "101"}]})
|
||||
assert res.status_code == 502
|
||||
assert "Invalid email size metadata from server" in res.json()["detail"]
|
||||
assert not any("(UID RFC822)" in str(call) for call in mailbox.fetch_calls)
|
||||
|
||||
|
||||
def test_messages_download_bulk_preflight_partial_missing_omitted_from_body_fetch(monkeypatch):
|
||||
"""When some requested UIDs do not exist on the server, omit them from body fetch and export verified items."""
|
||||
msg1 = b"From: a@example.com\r\nSubject: Only One\r\n\r\nBody"
|
||||
mailbox = _MockImapConn({"101": msg1}) # 102 does not exist on server
|
||||
client = _create_test_app(monkeypatch, mailbox=mailbox)
|
||||
|
||||
res = client.post("/api/email/messages-download-bulk", json={
|
||||
"messages": [{"uid": "101"}, {"uid": "102"}]
|
||||
})
|
||||
assert res.status_code == 200
|
||||
assert res.headers["x-exported-emails"] == "1"
|
||||
assert res.headers["x-skipped-emails"] == "1"
|
||||
|
||||
# Verify body fetch was only requested for 101, NOT 102
|
||||
body_fetches = [call for call in mailbox.fetch_calls if "(UID RFC822)" in str(call)]
|
||||
assert len(body_fetches) == 1
|
||||
requested_uids = body_fetches[0][0].decode() if isinstance(body_fetches[0][0], bytes) else str(body_fetches[0][0])
|
||||
assert requested_uids == "101"
|
||||
assert "102" not in requested_uids
|
||||
|
||||
|
||||
def test_messages_download_bulk_duplicate_imap_records_rejected(monkeypatch):
|
||||
"""Duplicate IMAP FETCH response records for the same UID must be ignored.
|
||||
They must not produce duplicate archive entries or corrupt accounting."""
|
||||
import io
|
||||
import zipfile
|
||||
|
||||
msg1 = b"From: a@example.com\r\nSubject: Unique\r\n\r\nBody 1"
|
||||
mailbox = _MockImapConn({"101": msg1})
|
||||
|
||||
def custom_fetch(args):
|
||||
fetch_cmd = str(args[1]) if len(args) > 1 else ""
|
||||
if "RFC822.SIZE" in fetch_cmd:
|
||||
return "OK", [b"1 (UID 101 RFC822.SIZE 100)", b"2 (UID 102 RFC822.SIZE 100)"]
|
||||
# In body fetch, server sends duplicate records for 101, and nothing for 102
|
||||
records = [
|
||||
(b"1 (UID 101 RFC822 {50}", msg1),
|
||||
b")",
|
||||
(b"1 (UID 101 RFC822 {50}", msg1), # DUPLICATE RECORD
|
||||
b")",
|
||||
]
|
||||
return "OK", records
|
||||
|
||||
mailbox.fetch_cb = custom_fetch
|
||||
client = _create_test_app(monkeypatch, mailbox=mailbox)
|
||||
|
||||
res = client.post("/api/email/messages-download-bulk", json={
|
||||
"messages": [{"uid": "101"}, {"uid": "102"}]
|
||||
})
|
||||
assert res.status_code == 200
|
||||
assert res.headers["x-exported-emails"] == "1"
|
||||
assert res.headers["x-skipped-emails"] == "1"
|
||||
|
||||
zf = zipfile.ZipFile(io.BytesIO(res.content))
|
||||
assert len(zf.namelist()) == 1
|
||||
assert "101.eml" in zf.namelist()[0]
|
||||
|
||||
|
||||
def test_messages_download_bulk_single_message_preflight_failure_no_body_fetch(monkeypatch):
|
||||
"""In single message mode, bad preflight or missing mail fails safely without body fetch."""
|
||||
mailbox = _MockImapConn({"101": b"dummy"})
|
||||
|
||||
# Case A: preflight NO -> 502
|
||||
mailbox.fetch_cb = lambda args: ("NO", []) if "RFC822.SIZE" in str(args) else ("OK", [])
|
||||
client = _create_test_app(monkeypatch, mailbox=mailbox)
|
||||
res_no = client.post("/api/email/messages-download-bulk", json={"messages": [{"uid": "101"}]})
|
||||
assert res_no.status_code == 502
|
||||
assert not any("(UID RFC822)" in str(call) for call in mailbox.fetch_calls)
|
||||
|
||||
# Case B: missing mail -> 404
|
||||
mailbox.fetch_calls.clear()
|
||||
mailbox.fetch_cb = lambda args: ("OK", []) if "RFC822.SIZE" in str(args) else ("OK", [])
|
||||
res_miss = client.post("/api/email/messages-download-bulk", json={"messages": [{"uid": "101"}]})
|
||||
assert res_miss.status_code == 404
|
||||
assert not any("(UID RFC822)" in str(call) for call in mailbox.fetch_calls)
|
||||
|
||||
# Case C: malformed metadata -> 502
|
||||
mailbox.fetch_calls.clear()
|
||||
mailbox.fetch_cb = lambda args: ("OK", [b"1 (UID 101 NO_SIZE)"]) if "RFC822.SIZE" in str(args) else ("OK", [])
|
||||
res_mal = client.post("/api/email/messages-download-bulk", json={"messages": [{"uid": "101"}]})
|
||||
assert res_mal.status_code == 502
|
||||
assert not any("(UID RFC822)" in str(call) for call in mailbox.fetch_calls)
|
||||
|
||||
|
||||
def test_messages_download_bulk_spooled_stream_lifecycle():
|
||||
"""Verify that _iter_spooled closes the underlying SpooledTemporaryFile on completion and GeneratorExit."""
|
||||
import tempfile
|
||||
from routes.email.email_routes import setup_email_routes
|
||||
|
||||
# Test normal completion
|
||||
f = tempfile.SpooledTemporaryFile(max_size=1024, mode="w+b")
|
||||
f.write(b"Hello world payload")
|
||||
f.seek(0)
|
||||
# Re-create generator helper pattern
|
||||
def _iter(file_obj, chunk_size=64):
|
||||
try:
|
||||
while True:
|
||||
chunk = file_obj.read(chunk_size)
|
||||
if not chunk:
|
||||
break
|
||||
yield chunk
|
||||
finally:
|
||||
file_obj.close()
|
||||
|
||||
gen = _iter(f)
|
||||
chunks = list(gen)
|
||||
assert b"".join(chunks) == b"Hello world payload"
|
||||
assert f.closed
|
||||
|
||||
# Test early disconnect / cancellation (GeneratorExit)
|
||||
f2 = tempfile.SpooledTemporaryFile(max_size=1024, mode="w+b")
|
||||
f2.write(b"Chunk1Chunk2Chunk3")
|
||||
f2.seek(0)
|
||||
gen2 = _iter(f2, chunk_size=6)
|
||||
next(gen2)
|
||||
assert not f2.closed
|
||||
gen2.close() # Simulates client disconnect / generator cancellation
|
||||
assert f2.closed
|
||||
|
||||
|
||||
def test_uid_and_size_from_fetch_meta_case_insensitivity_and_validation():
|
||||
"""Unit test: _uid_from_fetch_meta and _size_from_fetch_meta case insensitivity and boundary validation."""
|
||||
from routes.email.email_routes import _uid_from_fetch_meta, _size_from_fetch_meta
|
||||
|
||||
# 1. Standard uppercase UID and RFC822.SIZE
|
||||
meta_upper = b"1 (UID 101 RFC822.SIZE 1234)"
|
||||
assert _uid_from_fetch_meta(meta_upper) == "101"
|
||||
assert _size_from_fetch_meta(meta_upper) == 1234
|
||||
|
||||
# 2. Lowercase uid and rfc822.size
|
||||
meta_lower = b"1 (uid 202 rfc822.size 5678)"
|
||||
assert _uid_from_fetch_meta(meta_lower) == "202"
|
||||
assert _size_from_fetch_meta(meta_lower) == 5678
|
||||
|
||||
# 3. Mixed-case combinations
|
||||
combos = [
|
||||
(b"1 (Uid 303 Rfc822.Size 1000)", "303", 1000),
|
||||
(b"1 (uID 404 RFC822.size 2000)", "404", 2000),
|
||||
(b"1 (UID 505 rfc822.size 3000)", "505", 3000),
|
||||
(b"1 (uid 606 RFC822.SIZE 4000)", "606", 4000),
|
||||
(b"1 (uId 707 Rfc822.SIZE 5000)", "707", 5000),
|
||||
(b"1 (FLAGS (\\Seen) uid 808 rfc822.Size 6000)", "808", 6000),
|
||||
]
|
||||
for meta_bytes, exp_uid, exp_sz in combos:
|
||||
assert _uid_from_fetch_meta(meta_bytes) == exp_uid
|
||||
assert _size_from_fetch_meta(meta_bytes) == exp_sz
|
||||
|
||||
# 4. Malformed metadata cases
|
||||
assert _uid_from_fetch_meta(b"1 (rfc822.size 1234)") == ""
|
||||
assert _size_from_fetch_meta(b"1 (uid 101 flags (\\seen))") is None
|
||||
assert _uid_from_fetch_meta(b"1 (uid abc rfc822.size 1234)") == ""
|
||||
assert _size_from_fetch_meta(b"1 (uid 101 rfc822.size invalid)") is None
|
||||
assert _size_from_fetch_meta(b"1 (uid 101 rfc822.size -50)") is None
|
||||
|
||||
# 5. Boundary integrity (must not match substrings like UUID, GUID, NOT_RFC822.SIZE)
|
||||
assert _uid_from_fetch_meta(b"1 (UUID 101 GUID 202 NOT_RFC822.SIZE 1234)") == ""
|
||||
assert _size_from_fetch_meta(b"1 (UUID 101 GUID 202 NOT_RFC822.SIZE 1234)") is None
|
||||
assert _uid_from_fetch_meta(b"1 (uid101 rfc822.size1234)") == ""
|
||||
assert _size_from_fetch_meta(b"1 (uid101 rfc822.size1234)") is None
|
||||
assert _uid_from_fetch_meta(b"") == ""
|
||||
assert _size_from_fetch_meta(b"") is None
|
||||
|
||||
|
||||
def test_messages_download_bulk_case_insensitive_imap_atoms(monkeypatch):
|
||||
"""End-to-end: Bulk and single-message export with lowercase and mixed-case IMAP atoms."""
|
||||
import io
|
||||
import zipfile
|
||||
|
||||
msg101 = b"From: a@example.com\r\nSubject: Item 101\r\n\r\nBody 1"
|
||||
msg102 = b"From: b@example.com\r\nSubject: Item 102\r\n\r\nBody 2"
|
||||
mailbox = _MockImapConn({"101": msg101, "102": msg102})
|
||||
|
||||
def custom_fetch(args):
|
||||
fetch_cmd = str(args[1]) if len(args) > 1 else ""
|
||||
if "RFC822.SIZE" in fetch_cmd or "rfc822.size" in fetch_cmd.lower():
|
||||
# Server returns lowercase/mixed-case atoms
|
||||
return "OK", [
|
||||
b"1 (uid 101 rfc822.size " + str(len(msg101)).encode() + b")",
|
||||
b"2 (Uid 102 Rfc822.Size " + str(len(msg102)).encode() + b")",
|
||||
]
|
||||
# In body fetch, server returns mixed-case atoms
|
||||
return "OK", [
|
||||
(b"1 (uid 101 rfc822 {" + str(len(msg101)).encode(), msg101),
|
||||
b")",
|
||||
(b"2 (Uid 102 RFC822 {" + str(len(msg102)).encode(), msg102),
|
||||
b")",
|
||||
]
|
||||
|
||||
mailbox.fetch_cb = custom_fetch
|
||||
client = _create_test_app(monkeypatch, mailbox=mailbox)
|
||||
|
||||
# 1. Bulk download (2 items) with lowercase / mixed-case IMAP responses
|
||||
res_bulk = client.post("/api/email/messages-download-bulk", json={
|
||||
"messages": [{"uid": "101"}, {"uid": "102"}]
|
||||
})
|
||||
assert res_bulk.status_code == 200
|
||||
assert res_bulk.headers["x-exported-emails"] == "2"
|
||||
assert res_bulk.headers["x-skipped-emails"] == "0"
|
||||
zf = zipfile.ZipFile(io.BytesIO(res_bulk.content))
|
||||
assert len(zf.namelist()) == 2
|
||||
|
||||
# 2. Single message download fast path with lowercase IMAP response
|
||||
mailbox.fetch_calls.clear()
|
||||
res_single = client.post("/api/email/messages-download-bulk", json={
|
||||
"messages": [{"uid": "101"}]
|
||||
})
|
||||
assert res_single.status_code == 200
|
||||
assert res_single.headers["content-type"].startswith("message/rfc822")
|
||||
assert res_single.content == msg101
|
||||
|
||||
# 3. Lowercase malformed preflight fails safely with 502 and NO body fetch
|
||||
mailbox.fetch_calls.clear()
|
||||
mailbox.fetch_cb = lambda args: (
|
||||
("OK", [b"1 (uid 101 rfc822.size NaN)"])
|
||||
if "RFC822.SIZE" in str(args) or "rfc822.size" in str(args).lower()
|
||||
else ("OK", [])
|
||||
)
|
||||
res_malformed = client.post("/api/email/messages-download-bulk", json={
|
||||
"messages": [{"uid": "101"}]
|
||||
})
|
||||
assert res_malformed.status_code == 502
|
||||
assert "Invalid email size metadata from server" in res_malformed.json()["detail"]
|
||||
assert not any("(UID RFC822)" in str(call) for call in mailbox.fetch_calls)
|
||||
|
||||
|
||||
def test_messages_download_bulk_multi_group_preflight_failure_propagates_502(monkeypatch):
|
||||
"""A preflight safety failure in any group produces HTTP 502 for the entire request,
|
||||
even when another group has already exported messages into the temporary ZIP.
|
||||
Confirms temporary-file cleanup, zero body FETCH for the failed group, and both group orderings."""
|
||||
import tempfile
|
||||
|
||||
msg101 = b"From: a@example.com\r\nSubject: Good\r\n\r\nBody 1"
|
||||
|
||||
spooled_instances: list[tempfile.SpooledTemporaryFile] = []
|
||||
orig_spooled = tempfile.SpooledTemporaryFile
|
||||
|
||||
def tracking_spooled(*args, **kwargs):
|
||||
inst = orig_spooled(*args, **kwargs)
|
||||
spooled_instances.append(inst)
|
||||
return inst
|
||||
|
||||
monkeypatch.setattr(tempfile, "SpooledTemporaryFile", tracking_spooled)
|
||||
|
||||
class MultiMailboxConn:
|
||||
def __init__(self):
|
||||
self.fetch_calls: list[tuple[str, tuple]] = []
|
||||
self.current_folder = "INBOX"
|
||||
|
||||
def select(self, folder, readonly=True):
|
||||
self.current_folder = str(folder)
|
||||
return "OK", [b"1"]
|
||||
|
||||
def uid(self, command, *args):
|
||||
self.fetch_calls.append((self.current_folder, args))
|
||||
fetch_cmd = str(args[1]) if len(args) > 1 else ""
|
||||
uid_set = args[0].decode() if isinstance(args[0], bytes) else str(args[0])
|
||||
|
||||
if "RFC822.SIZE" in fetch_cmd:
|
||||
if "folder-fail" in self.current_folder:
|
||||
return "NO", [] # Preflight failure for folder-fail
|
||||
return "OK", [f"1 (UID {uid_set} RFC822.SIZE 100)".encode()]
|
||||
|
||||
# Body fetch
|
||||
if "folder-fail" in self.current_folder:
|
||||
return "NO", []
|
||||
return "OK", [(f"1 (UID {uid_set} RFC822 {{100}}".encode(), msg101), b")"]
|
||||
|
||||
# 1. Successful group followed by failed preflight
|
||||
conn1 = MultiMailboxConn()
|
||||
client1 = _create_test_app(monkeypatch, mailbox=conn1)
|
||||
spooled_instances.clear()
|
||||
|
||||
res1 = client1.post("/api/email/messages-download-bulk", json={
|
||||
"messages": [
|
||||
{"uid": "101", "folder": "folder-good"},
|
||||
{"uid": "202", "folder": "folder-fail"},
|
||||
]
|
||||
})
|
||||
assert res1.status_code == 502
|
||||
assert "Unable to verify email size safety" in res1.json()["detail"]
|
||||
|
||||
# No RFC822 body FETCH for the failed group
|
||||
failed_body_fetches1 = [
|
||||
call for folder, call in conn1.fetch_calls
|
||||
if "folder-fail" in folder and "(UID RFC822)" in str(call)
|
||||
]
|
||||
assert len(failed_body_fetches1) == 0
|
||||
|
||||
# Temporary file cleanup confirmed
|
||||
assert len(spooled_instances) == 1
|
||||
assert spooled_instances[0].closed
|
||||
|
||||
# 2. Failed preflight followed by successful group
|
||||
conn2 = MultiMailboxConn()
|
||||
client2 = _create_test_app(monkeypatch, mailbox=conn2)
|
||||
spooled_instances.clear()
|
||||
|
||||
res2 = client2.post("/api/email/messages-download-bulk", json={
|
||||
"messages": [
|
||||
{"uid": "202", "folder": "folder-fail"},
|
||||
{"uid": "101", "folder": "folder-good"},
|
||||
]
|
||||
})
|
||||
assert res2.status_code == 502
|
||||
assert "Unable to verify email size safety" in res2.json()["detail"]
|
||||
|
||||
# No RFC822 body FETCH for the failed group
|
||||
failed_body_fetches2 = [
|
||||
call for folder, call in conn2.fetch_calls
|
||||
if "folder-fail" in folder and "(UID RFC822)" in str(call)
|
||||
]
|
||||
assert len(failed_body_fetches2) == 0
|
||||
|
||||
# Temporary file cleanup confirmed
|
||||
assert len(spooled_instances) == 1
|
||||
assert spooled_instances[0].closed
|
||||
|
||||
|
||||
def test_messages_download_bulk_multi_group_both_successful(monkeypatch):
|
||||
"""When both groups succeed preflight and body fetch, return 200 with all messages in ZIP."""
|
||||
import io
|
||||
import zipfile
|
||||
|
||||
msg101 = b"From: a@example.com\r\nSubject: Item 1\r\n\r\nBody 1"
|
||||
msg202 = b"From: b@example.com\r\nSubject: Item 2\r\n\r\nBody 2"
|
||||
|
||||
class MultiMailboxConn:
|
||||
def __init__(self):
|
||||
self.current_folder = "INBOX"
|
||||
|
||||
def select(self, folder, readonly=True):
|
||||
self.current_folder = str(folder)
|
||||
return "OK", [b"1"]
|
||||
|
||||
def uid(self, command, *args):
|
||||
fetch_cmd = str(args[1]) if len(args) > 1 else ""
|
||||
uid_set = args[0].decode() if isinstance(args[0], bytes) else str(args[0])
|
||||
msg = msg101 if "101" in uid_set else msg202
|
||||
if "RFC822.SIZE" in fetch_cmd:
|
||||
return "OK", [f"1 (UID {uid_set} RFC822.SIZE {len(msg)})".encode()]
|
||||
return "OK", [(f"1 (UID {uid_set} RFC822 {{{len(msg)}}}".encode(), msg), b")"]
|
||||
|
||||
conn = MultiMailboxConn()
|
||||
client = _create_test_app(monkeypatch, mailbox=conn)
|
||||
|
||||
res = client.post("/api/email/messages-download-bulk", json={
|
||||
"messages": [
|
||||
{"uid": "101", "folder": "folder-1"},
|
||||
{"uid": "202", "folder": "folder-2"},
|
||||
]
|
||||
})
|
||||
assert res.status_code == 200
|
||||
assert res.headers["x-exported-emails"] == "2"
|
||||
assert res.headers["x-skipped-emails"] == "0"
|
||||
zf = zipfile.ZipFile(io.BytesIO(res.content))
|
||||
assert len(zf.namelist()) == 2
|
||||
|
||||
|
||||
def test_messages_download_bulk_multi_group_genuinely_missing_message(monkeypatch):
|
||||
"""Genuinely missing messages (unreturned by preflight) do not cause 502;
|
||||
they are skipped while verified messages in any group are successfully exported."""
|
||||
import io
|
||||
import zipfile
|
||||
|
||||
msg101 = b"From: a@example.com\r\nSubject: Exists\r\n\r\nBody"
|
||||
|
||||
class MissingMailboxConn:
|
||||
def __init__(self):
|
||||
self.fetch_calls: list = []
|
||||
self.current_folder = "INBOX"
|
||||
|
||||
def select(self, folder, readonly=True):
|
||||
self.current_folder = str(folder)
|
||||
return "OK", [b"1"]
|
||||
|
||||
def uid(self, command, *args):
|
||||
self.fetch_calls.append((self.current_folder, args))
|
||||
fetch_cmd = str(args[1]) if len(args) > 1 else ""
|
||||
uid_set = args[0].decode() if isinstance(args[0], bytes) else str(args[0])
|
||||
if "RFC822.SIZE" in fetch_cmd:
|
||||
# 101 exists on server; 999 is genuinely missing (unreported in size metadata)
|
||||
res = []
|
||||
if "101" in uid_set:
|
||||
res.append(f"1 (UID 101 RFC822.SIZE {len(msg101)})".encode())
|
||||
return "OK", res
|
||||
# Body fetch only returns 101
|
||||
return "OK", [(f"1 (UID 101 RFC822 {{{len(msg101)}}}".encode(), msg101), b")"]
|
||||
|
||||
conn = MissingMailboxConn()
|
||||
client = _create_test_app(monkeypatch, mailbox=conn)
|
||||
|
||||
res = client.post("/api/email/messages-download-bulk", json={
|
||||
"messages": [
|
||||
{"uid": "101", "folder": "folder-1"},
|
||||
{"uid": "999", "folder": "folder-1"},
|
||||
]
|
||||
})
|
||||
assert res.status_code == 200
|
||||
assert res.headers["x-exported-emails"] == "1"
|
||||
assert res.headers["x-skipped-emails"] == "1"
|
||||
|
||||
zf = zipfile.ZipFile(io.BytesIO(res.content))
|
||||
assert len(zf.namelist()) == 1
|
||||
|
||||
# Verify 999 was never requested in body fetch
|
||||
body_fetches = [
|
||||
call for folder, call in conn.fetch_calls
|
||||
if "(UID RFC822)" in str(call)
|
||||
]
|
||||
assert len(body_fetches) == 1
|
||||
assert "999" not in str(body_fetches[0])
|
||||
|
||||
|
||||
def test_email_print_browser_behaviors():
|
||||
"""Real Chromium DOM: placeholder stripping, button removal, and partial failure."""
|
||||
import shutil
|
||||
import subprocess
|
||||
if not shutil.which('node'):
|
||||
import pytest
|
||||
pytest.skip('Node is required')
|
||||
probe = subprocess.run(['node', '-e', "require.resolve('playwright')"], cwd=_REPO, capture_output=True)
|
||||
if probe.returncode:
|
||||
import pytest
|
||||
pytest.skip('Playwright is required')
|
||||
result = subprocess.run(
|
||||
['node', str(_REPO / 'tests/test_email_library_print_browser.cjs')],
|
||||
cwd=_REPO,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=60,
|
||||
)
|
||||
assert result.returncode == 0, result.stdout + result.stderr
|
||||
assert "ALL_BROWSER_TESTS_PASSED" in result.stdout
|
||||
|
||||
@@ -0,0 +1,438 @@
|
||||
/**
|
||||
* Browser print regression tests for Email Library print and PDF export.
|
||||
*
|
||||
* Requirements:
|
||||
* - Node.js (v20+)
|
||||
* - Playwright (`npm ci` or `npm install -D @playwright/test`)
|
||||
* - Chromium browser binary:
|
||||
* - Standard Playwright install: `npx playwright install chromium`
|
||||
* - Or custom executable via env vars:
|
||||
* - `PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH=/path/to/chrome`
|
||||
* - `CHROME_PATH=/path/to/chrome`
|
||||
* - Or custom browsers cache dir via:
|
||||
* - `PLAYWRIGHT_BROWSERS_PATH=/path/to/cache`
|
||||
*/
|
||||
|
||||
const http = require('http');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
|
||||
let chromium;
|
||||
try {
|
||||
({ chromium } = require('playwright'));
|
||||
} catch (err) {
|
||||
if (err && err.code === 'MODULE_NOT_FOUND') {
|
||||
console.error(
|
||||
'Error: Playwright module not found.\n' +
|
||||
'Please install dependencies by running:\n' +
|
||||
' npm ci\n' +
|
||||
'or ensure NODE_PATH contains the playwright package.'
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
const ROOT = path.resolve(__dirname, '..');
|
||||
|
||||
// Discover available Chromium executable using standard Playwright resolution,
|
||||
// custom environment variables, standard cache paths, or system binaries.
|
||||
function findChromiumPath() {
|
||||
// 1. Explicit environment variable overrides
|
||||
if (process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH && fs.existsSync(process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH)) {
|
||||
return process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH;
|
||||
}
|
||||
if (process.env.CHROME_PATH && fs.existsSync(process.env.CHROME_PATH)) {
|
||||
return process.env.CHROME_PATH;
|
||||
}
|
||||
|
||||
// 2. Playwright's configured/default executable path
|
||||
try {
|
||||
const defaultPath = chromium.executablePath();
|
||||
if (defaultPath && fs.existsSync(defaultPath)) {
|
||||
return defaultPath;
|
||||
}
|
||||
} catch (e) {
|
||||
// chromium.executablePath() may throw if no default browser is installed
|
||||
}
|
||||
|
||||
// 3. Inspect standard Playwright browser cache directories
|
||||
const cacheDirs = [];
|
||||
if (process.env.PLAYWRIGHT_BROWSERS_PATH) {
|
||||
cacheDirs.push(process.env.PLAYWRIGHT_BROWSERS_PATH);
|
||||
}
|
||||
if (process.platform === 'win32') {
|
||||
if (process.env.LOCALAPPDATA) {
|
||||
cacheDirs.push(path.join(process.env.LOCALAPPDATA, 'ms-playwright'));
|
||||
}
|
||||
cacheDirs.push(path.join(os.homedir(), 'AppData', 'Local', 'ms-playwright'));
|
||||
} else if (process.platform === 'darwin') {
|
||||
cacheDirs.push(path.join(os.homedir(), 'Library', 'Caches', 'ms-playwright'));
|
||||
} else {
|
||||
cacheDirs.push(path.join(os.homedir(), '.cache', 'ms-playwright'));
|
||||
}
|
||||
|
||||
for (const cacheDir of cacheDirs) {
|
||||
if (!fs.existsSync(cacheDir)) continue;
|
||||
try {
|
||||
const entries = fs.readdirSync(cacheDir).sort().reverse();
|
||||
for (const entry of entries) {
|
||||
const entryPath = path.join(cacheDir, entry);
|
||||
const candidates = [
|
||||
path.join(entryPath, 'chrome-headless-shell-linux64', 'chrome-headless-shell'),
|
||||
path.join(entryPath, 'chrome-linux64', 'chrome'),
|
||||
path.join(entryPath, 'chrome-linux', 'chrome'),
|
||||
path.join(entryPath, 'chrome-win64', 'chrome.exe'),
|
||||
path.join(entryPath, 'chrome-win', 'chrome.exe'),
|
||||
path.join(entryPath, 'chrome-mac', 'Chromium.app', 'Contents', 'MacOS', 'Chromium'),
|
||||
];
|
||||
for (const candidate of candidates) {
|
||||
if (fs.existsSync(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
// 4. System browser fallbacks
|
||||
const systemCandidates = process.platform === 'win32' ? [
|
||||
path.join(process.env['PROGRAMFILES'] || 'C:\\Program Files', 'Google', 'Chrome', 'Application', 'chrome.exe'),
|
||||
path.join(process.env['PROGRAMFILES(X86)'] || 'C:\\Program Files (x86)', 'Google', 'Chrome', 'Application', 'chrome.exe'),
|
||||
] : [
|
||||
'/usr/bin/google-chrome',
|
||||
'/usr/bin/chromium',
|
||||
'/usr/bin/chromium-browser',
|
||||
'/snap/bin/chromium',
|
||||
];
|
||||
for (const sc of systemCandidates) {
|
||||
if (fs.existsSync(sc)) return sc;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async function run() {
|
||||
const server = http.createServer((req, res) => {
|
||||
const url = new URL(req.url, 'http://127.0.0.1');
|
||||
if (url.pathname.startsWith('/api/email/read/')) {
|
||||
const uid = url.pathname.replace('/api/email/read/', '');
|
||||
if (uid === 'fail_uid' || uid === 'net_fail') {
|
||||
res.writeHead(500, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: 'Mailbox disconnected' }));
|
||||
return;
|
||||
}
|
||||
if (uid === 'crash_network') {
|
||||
req.destroy();
|
||||
return;
|
||||
}
|
||||
|
||||
const markSeen = url.searchParams.get('mark_seen');
|
||||
if (markSeen !== 'false') {
|
||||
res.writeHead(400, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: 'mark_seen must be false' }));
|
||||
return;
|
||||
}
|
||||
|
||||
let body_html = '';
|
||||
if (uid === 'img_test') {
|
||||
body_html = [
|
||||
'<div>',
|
||||
'<p>Email with diverse images</p>',
|
||||
'<!-- Inline image with alt text -->',
|
||||
'<img src="cid:logo123" alt="Company Brand Logo">',
|
||||
'<!-- Remote image with title -->',
|
||||
'<img src="https://example.com/tracker.png" title="Tracking Pixel">',
|
||||
'<!-- Image without alt or title -->',
|
||||
'<img src="https://example.com/no-alt.jpg">',
|
||||
'<!-- Interactive control that shouldn\'t be in print -->',
|
||||
'<button type="button" class="btn-action">Interactive Action</button>',
|
||||
'<!-- XSS payload -->',
|
||||
'<script>window.__pwned = true;</script>',
|
||||
'</div>'
|
||||
].join('\n');
|
||||
} else if (uid === 'long_msg') {
|
||||
body_html = `<div><p>${'A'.repeat(5000)}</p></div>`;
|
||||
} else {
|
||||
body_html = `<div><p>Standard message body for ${uid}</p></div>`;
|
||||
}
|
||||
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({
|
||||
uid: uid,
|
||||
subject: `Subject ${uid}`,
|
||||
from_name: 'Sender',
|
||||
from_address: 'sender@example.com',
|
||||
to: 'user@example.com',
|
||||
date: '2026-10-20',
|
||||
date_display: 'Oct 20, 2026',
|
||||
body: `Plain text for ${uid}`,
|
||||
body_html: body_html,
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
let filePath = path.join(ROOT, url.pathname);
|
||||
if (fs.existsSync(filePath) && fs.statSync(filePath).isFile()) {
|
||||
const ext = path.extname(filePath);
|
||||
const mime = ext === '.js' ? 'application/javascript' : ext === '.html' ? 'text/html' : ext === '.css' ? 'text/css' : 'text/plain';
|
||||
res.writeHead(200, { 'Content-Type': mime });
|
||||
res.end(fs.readFileSync(filePath));
|
||||
return;
|
||||
}
|
||||
res.writeHead(404);
|
||||
res.end('Not found');
|
||||
});
|
||||
|
||||
await new Promise(r => server.listen(0, '127.0.0.1', r));
|
||||
const port = server.address().port;
|
||||
const origin = `http://127.0.0.1:${port}`;
|
||||
|
||||
const launchOpts = { headless: true };
|
||||
const chromePath = findChromiumPath();
|
||||
if (chromePath) launchOpts.executablePath = chromePath;
|
||||
|
||||
let browser;
|
||||
try {
|
||||
browser = await chromium.launch(launchOpts);
|
||||
} catch (launchErr) {
|
||||
throw new Error(
|
||||
`Failed to launch Chromium browser: ${launchErr.message}\n` +
|
||||
'Missing or unexecutable Chromium binary.\n' +
|
||||
'To install Playwright Chromium, run:\n' +
|
||||
' npx playwright install chromium\n' +
|
||||
'Or set PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH to a valid Chromium executable.'
|
||||
);
|
||||
}
|
||||
const context = await browser.newContext();
|
||||
const page = await context.newPage();
|
||||
|
||||
const results = {};
|
||||
|
||||
try {
|
||||
await page.goto(`${origin}/static/index.html`);
|
||||
await page.evaluate(() => {
|
||||
if (!document.getElementById('email-lib-bulk-actions')) {
|
||||
const d = document.createElement('div'); d.id = 'email-lib-bulk-actions'; document.body.appendChild(d);
|
||||
}
|
||||
if (!document.getElementById('email-lib-selected-count')) {
|
||||
const d = document.createElement('div'); d.id = 'email-lib-selected-count'; document.body.appendChild(d);
|
||||
}
|
||||
});
|
||||
|
||||
// TEST 1: Full Print Image Sanitization and Replacement
|
||||
const printImageResult = await page.evaluate(async (base) => {
|
||||
window.API_BASE = base;
|
||||
const mod = await import(`${base}/static/js/emailLibrary/index.js`);
|
||||
const stateMod = await import(`${base}/static/js/emailLibrary/state.js`);
|
||||
|
||||
stateMod.state._selectedUids = new Set(['img_test']);
|
||||
stateMod.state._libEmails = [{ uid: 'img_test', subject: 'Image Test', folder: 'INBOX' }];
|
||||
|
||||
let iframePrintInvoked = false;
|
||||
let printDocHtml = '';
|
||||
let afterPrintTriggered = false;
|
||||
let hasPlaceholderElement = false;
|
||||
|
||||
const observer = new MutationObserver(() => {
|
||||
const frame = document.getElementById('email-browser-print-frame');
|
||||
if (frame && frame.contentWindow && !frame._patched) {
|
||||
frame._patched = true;
|
||||
frame.contentWindow.print = function() {
|
||||
iframePrintInvoked = true;
|
||||
printDocHtml = frame.contentDocument ? frame.contentDocument.documentElement.outerHTML : '';
|
||||
const body = frame.contentDocument ? frame.contentDocument.body : null;
|
||||
hasPlaceholderElement = !!body?.querySelector('.email-inline-image-placeholder');
|
||||
frame.contentWindow.dispatchEvent(new Event('afterprint'));
|
||||
afterPrintTriggered = true;
|
||||
};
|
||||
}
|
||||
});
|
||||
observer.observe(document.body, { childList: true });
|
||||
|
||||
await mod._exportSelectedEmailPdf();
|
||||
observer.disconnect();
|
||||
|
||||
const frameAfter = document.getElementById('email-browser-print-frame');
|
||||
|
||||
return {
|
||||
iframePrintInvoked,
|
||||
afterPrintTriggered,
|
||||
frameRemovedAfterPrint: frameAfter === null,
|
||||
hasLogoNotice: printDocHtml.includes('[Image: Company Brand Logo]'),
|
||||
hasTrackerNotice: printDocHtml.includes('[Image: Tracking Pixel]'),
|
||||
hasNoAltNotice: printDocHtml.includes('[Image omitted]'),
|
||||
hasPlaceholderElement,
|
||||
hasRemoteImageBlockedText: printDocHtml.includes('Remote image blocked'),
|
||||
hasDownloadButton: printDocHtml.includes('email-inline-image-download-btn'),
|
||||
hasInteractiveActionBtn: printDocHtml.includes('btn-action'),
|
||||
hasXSS: printDocHtml.includes('<script>') || printDocHtml.includes('window.__pwned'),
|
||||
};
|
||||
}, origin);
|
||||
|
||||
results.printImageSanitization = printImageResult;
|
||||
|
||||
// TEST 2: Partial Failure - 1 failure out of 3 emails
|
||||
const partialFailureResult = await page.evaluate(async (base) => {
|
||||
window.API_BASE = base;
|
||||
const mod = await import(`${base}/static/js/emailLibrary/index.js`);
|
||||
const stateMod = await import(`${base}/static/js/emailLibrary/state.js`);
|
||||
|
||||
const messages = [
|
||||
{ uid: 'msg1', subject: 'Email One', folder: 'INBOX' },
|
||||
{ uid: 'fail_uid', subject: 'Failing Email', folder: 'INBOX' },
|
||||
{ uid: 'msg2', subject: 'Email Two', folder: 'INBOX' },
|
||||
];
|
||||
stateMod.state._selectedUids = new Set(messages.map(m => m.uid));
|
||||
stateMod.state._libEmails = messages;
|
||||
|
||||
let printInvoked = false;
|
||||
let printDocHtml = '';
|
||||
|
||||
const observer = new MutationObserver(() => {
|
||||
const frame = document.getElementById('email-browser-print-frame');
|
||||
if (frame && frame.contentWindow && !frame._patched) {
|
||||
frame._patched = true;
|
||||
frame.contentWindow.print = function() {
|
||||
printInvoked = true;
|
||||
printDocHtml = frame.contentDocument ? frame.contentDocument.documentElement.outerHTML : '';
|
||||
frame.contentWindow.dispatchEvent(new Event('afterprint'));
|
||||
};
|
||||
}
|
||||
});
|
||||
observer.observe(document.body, { childList: true });
|
||||
|
||||
await mod._exportSelectedEmailPdf();
|
||||
observer.disconnect();
|
||||
|
||||
const toast = document.getElementById('toast')?.textContent || '';
|
||||
|
||||
return {
|
||||
printInvoked,
|
||||
toast,
|
||||
hasMsg1: printDocHtml.includes('Subject msg1'),
|
||||
hasMsg2: printDocHtml.includes('Subject msg2'),
|
||||
hasOmittedNotice: printDocHtml.includes('1 selected email was unavailable and omitted from this print'),
|
||||
};
|
||||
}, origin);
|
||||
|
||||
results.partialFailureTest = partialFailureResult;
|
||||
|
||||
// TEST 3: All emails fail
|
||||
const allFailedResult = await page.evaluate(async (base) => {
|
||||
window.API_BASE = base;
|
||||
const mod = await import(`${base}/static/js/emailLibrary/index.js`);
|
||||
const stateMod = await import(`${base}/static/js/emailLibrary/state.js`);
|
||||
|
||||
const messages = [
|
||||
{ uid: 'fail_uid', subject: 'Fail 1', folder: 'INBOX' },
|
||||
{ uid: 'net_fail', subject: 'Fail 2', folder: 'INBOX' },
|
||||
];
|
||||
stateMod.state._selectedUids = new Set(messages.map(m => m.uid));
|
||||
stateMod.state._libEmails = messages;
|
||||
|
||||
let printInvoked = false;
|
||||
const observer = new MutationObserver(() => {
|
||||
const frame = document.getElementById('email-browser-print-frame');
|
||||
if (frame && frame.contentWindow && !frame._patched) {
|
||||
frame._patched = true;
|
||||
frame.contentWindow.print = function() {
|
||||
printInvoked = true;
|
||||
};
|
||||
}
|
||||
});
|
||||
observer.observe(document.body, { childList: true });
|
||||
|
||||
let thrownError = null;
|
||||
try {
|
||||
await mod._exportSelectedEmailPdf();
|
||||
} catch (err) {
|
||||
thrownError = err.message || String(err);
|
||||
}
|
||||
observer.disconnect();
|
||||
|
||||
const frame = document.getElementById('email-browser-print-frame');
|
||||
const toast = document.getElementById('toast')?.textContent || '';
|
||||
|
||||
return {
|
||||
printInvoked,
|
||||
frameOpened: frame !== null,
|
||||
thrownError,
|
||||
toast,
|
||||
};
|
||||
}, origin);
|
||||
|
||||
results.allFailedTest = allFailedResult;
|
||||
|
||||
// TEST 4: Idempotent Iframe Cleanup & Window Afterprint
|
||||
const cleanupResult = await page.evaluate(async (base) => {
|
||||
window.API_BASE = base;
|
||||
const mod = await import(`${base}/static/js/emailLibrary/index.js`);
|
||||
const stateMod = await import(`${base}/static/js/emailLibrary/state.js`);
|
||||
|
||||
stateMod.state._selectedUids = new Set(['msg1']);
|
||||
stateMod.state._libEmails = [{ uid: 'msg1', subject: 'Cleanup Test', folder: 'INBOX' }];
|
||||
|
||||
const observer = new MutationObserver(() => {
|
||||
const frame = document.getElementById('email-browser-print-frame');
|
||||
if (frame && frame.contentWindow && !frame._patched) {
|
||||
frame._patched = true;
|
||||
frame.contentWindow.print = function() {
|
||||
window.dispatchEvent(new Event('afterprint'));
|
||||
};
|
||||
}
|
||||
});
|
||||
observer.observe(document.body, { childList: true });
|
||||
|
||||
await mod._exportSelectedEmailPdf();
|
||||
observer.disconnect();
|
||||
|
||||
const frameAfter = document.getElementById('email-browser-print-frame');
|
||||
return {
|
||||
frameRemovedViaWindowAfterprint: frameAfter === null,
|
||||
};
|
||||
}, origin);
|
||||
|
||||
results.cleanupTest = cleanupResult;
|
||||
|
||||
} finally {
|
||||
await browser.close();
|
||||
server.close();
|
||||
}
|
||||
|
||||
// Assertions
|
||||
const t1 = results.printImageSanitization;
|
||||
if (!t1.iframePrintInvoked) throw new Error('Assertion failed: print not invoked');
|
||||
if (!t1.frameRemovedAfterPrint) throw new Error('Assertion failed: frame not removed');
|
||||
if (!t1.hasLogoNotice) throw new Error('Assertion failed: [Image: Company Brand Logo] missing');
|
||||
if (!t1.hasTrackerNotice) throw new Error('Assertion failed: [Image: Tracking Pixel] missing');
|
||||
if (!t1.hasNoAltNotice) throw new Error('Assertion failed: [Image omitted] missing');
|
||||
if (t1.hasPlaceholderElement) throw new Error('Assertion failed: .email-inline-image-placeholder found in print doc');
|
||||
if (t1.hasRemoteImageBlockedText) throw new Error('Assertion failed: Remote image blocked leaked into print doc');
|
||||
if (t1.hasDownloadButton) throw new Error('Assertion failed: download button leaked into print doc');
|
||||
if (t1.hasInteractiveActionBtn) throw new Error('Assertion failed: interactive button leaked into print doc');
|
||||
if (t1.hasXSS) throw new Error('Assertion failed: script tag found');
|
||||
|
||||
const t2 = results.partialFailureTest;
|
||||
if (!t2.printInvoked) throw new Error('Assertion failed: partial print was not invoked');
|
||||
if (!t2.hasMsg1 || !t2.hasMsg2) throw new Error('Assertion failed: successful messages missing from print');
|
||||
if (!t2.hasOmittedNotice) throw new Error('Assertion failed: omitted notice banner missing');
|
||||
if (!t2.toast.includes('Prepared 2 emails for printing; 1 unavailable')) {
|
||||
throw new Error('Assertion failed: toast text mismatch: ' + t2.toast);
|
||||
}
|
||||
|
||||
const t3 = results.allFailedTest;
|
||||
if (t3.printInvoked) throw new Error('Assertion failed: print should NOT be invoked when all fail');
|
||||
if (t3.frameOpened) throw new Error('Assertion failed: print frame should NOT remain in DOM');
|
||||
|
||||
const t4 = results.cleanupTest;
|
||||
if (!t4.frameRemovedViaWindowAfterprint) throw new Error('Assertion failed: top window afterprint did not clean up frame');
|
||||
|
||||
console.log('ALL_BROWSER_TESTS_PASSED');
|
||||
}
|
||||
|
||||
run().catch(err => {
|
||||
console.error('Browser test failed:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,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 == ()
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
@@ -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())
|
||||
|
||||
|
||||
@@ -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"]
|
||||
@@ -76,3 +76,158 @@ def test_mixed_endpoint_requires_configured_image_model(monkeypatch):
|
||||
assert lookup(Query(), endpoint.base_url, 'alice', 'text-model') is None
|
||||
assert lookup(Query(), endpoint.base_url, 'alice', 'openai/gpt-image-1') is None
|
||||
assert lookup(Query(), 'https://unregistered.test/v1', 'alice', 'openai/gpt-5-image') is None
|
||||
|
||||
|
||||
def _form_fields(content, content_type):
|
||||
"""Parse simple multipart form fields (text parts only) from a request body."""
|
||||
boundary = content_type.split('boundary=')[1].strip().strip('"')
|
||||
fields = {}
|
||||
for part in content.split(('--' + boundary).encode()):
|
||||
if b'name="' not in part:
|
||||
continue
|
||||
head, _, body = part.partition(b'\r\n\r\n')
|
||||
import re as _re
|
||||
match = _re.search(rb'name="([^"]+)"', head)
|
||||
if not match:
|
||||
continue
|
||||
fields[match.group(1).decode()] = body.rstrip(b'\r\n-').decode('latin1')
|
||||
return fields
|
||||
|
||||
|
||||
def _self_hosted_inpaint(monkeypatch, image, mask, body_extra, respond, include_mask=True):
|
||||
base_url = 'http://100.69.120.65:8188/v1'
|
||||
endpoint = SimpleNamespace(base_url=base_url, api_key='local', owner='alice')
|
||||
monkeypatch.setattr(gallery, 'SessionLocal', lambda: SimpleNamespace(close=lambda: None))
|
||||
monkeypatch.setattr(gallery, 'require_privilege', lambda *a: 'alice')
|
||||
monkeypatch.setattr(gallery, '_visible_image_endpoint_for_base', lambda *a: endpoint)
|
||||
monkeypatch.setattr('src.url_safety.check_outbound_url', lambda *a, **k: (True, ''))
|
||||
_real_client = httpx.AsyncClient
|
||||
monkeypatch.setattr(httpx, 'AsyncClient',
|
||||
lambda **kw: _real_client(transport=httpx.MockTransport(respond), **kw))
|
||||
|
||||
class Request:
|
||||
async def json(self):
|
||||
payload = {'_endpoint': base_url, 'image': png(image), 'prompt': 'do a thing'}
|
||||
if include_mask:
|
||||
payload['mask'] = png(mask)
|
||||
payload.update(body_extra)
|
||||
return payload
|
||||
|
||||
route = next(r for r in gallery.setup_gallery_routes().routes if r.path == '/api/image/inpaint')
|
||||
return asyncio.run(route.endpoint(Request()))
|
||||
|
||||
|
||||
def test_self_hosted_inpaint_caps_oversize_and_restores_dimensions(monkeypatch):
|
||||
seen = {}
|
||||
|
||||
def respond(request):
|
||||
assert str(request.url) == 'http://100.69.120.65:8188/v1/images/edits'
|
||||
seen.update(_form_fields(request.content, request.headers['content-type']))
|
||||
return httpx.Response(200, json={'data': [{'b64_json': png(Image.new('RGB', (768, 576), 'blue'))}]})
|
||||
|
||||
out = _self_hosted_inpaint(monkeypatch, Image.new('RGB', (1600, 1200), 'red'),
|
||||
Image.new('L', (1600, 1200), 255),
|
||||
{'steps': 8}, respond)
|
||||
# Working size is capped to a model-friendly size (<=768 long side, /16).
|
||||
assert seen['size'] == '768x576'
|
||||
# A requested step budget passes through up to the cap (default 8).
|
||||
assert seen['steps'] == '8'
|
||||
# The returned image keeps the original dimensions.
|
||||
assert Image.open(io.BytesIO(base64.b64decode(out['image']))).size == (1600, 1200)
|
||||
|
||||
|
||||
def test_self_hosted_inpaint_clamps_steps_to_the_cap(monkeypatch):
|
||||
seen = {}
|
||||
|
||||
def respond(request):
|
||||
seen.update(_form_fields(request.content, request.headers['content-type']))
|
||||
return httpx.Response(200, json={'data': [{'b64_json': png(Image.new('RGB', (640, 480), 'blue'))}]})
|
||||
|
||||
_self_hosted_inpaint(monkeypatch, Image.new('RGB', (640, 480), 'red'),
|
||||
Image.new('L', (640, 480), 255), {'steps': 20}, respond)
|
||||
# Over-cap requests are clamped to the configured maximum (default 8).
|
||||
assert seen['steps'] == '8'
|
||||
|
||||
|
||||
def test_self_hosted_inpaint_leaves_small_images_unchanged(monkeypatch):
|
||||
seen = {}
|
||||
|
||||
def respond(request):
|
||||
seen.update(_form_fields(request.content, request.headers['content-type']))
|
||||
return httpx.Response(200, json={'data': [{'b64_json': png(Image.new('RGB', (640, 480), 'green'))}]})
|
||||
|
||||
out = _self_hosted_inpaint(monkeypatch, Image.new('RGB', (640, 480), 'red'),
|
||||
Image.new('L', (640, 480), 255), {}, respond)
|
||||
# Under the cap: dimensions pass through untouched; with no requested count
|
||||
# sent the moderate default (4) is used.
|
||||
assert seen['size'] == '640x480'
|
||||
assert seen['steps'] == '4'
|
||||
assert Image.open(io.BytesIO(base64.b64decode(out['image']))).size == (640, 480)
|
||||
|
||||
|
||||
def test_self_hosted_inpaint_without_mask_uses_a_full_white_mask(monkeypatch):
|
||||
"""A whole-image instruction edit may omit the mask; it must not raise
|
||||
KeyError -> 400 (the health-audit regression)."""
|
||||
seen = {}
|
||||
mask_bytes = {}
|
||||
|
||||
def respond(request):
|
||||
seen.update(_form_fields(request.content, request.headers['content-type']))
|
||||
# The provider must receive a (full-white) mask, not a missing one.
|
||||
import re as _re
|
||||
parts = request.content.split(b'--')
|
||||
for part in parts:
|
||||
if b'name="mask"' in part:
|
||||
head, _, body = part.partition(b'\r\n\r\n')
|
||||
mask_bytes['png'] = body.rstrip(b'\r\n-')
|
||||
return httpx.Response(200, json={'data': [{'b64_json': png(Image.new('RGB', (32, 32), 'blue'))}]})
|
||||
|
||||
out = _self_hosted_inpaint(monkeypatch, Image.new('RGB', (32, 32), 'red'),
|
||||
None, {}, respond, include_mask=False)
|
||||
assert 'image' in out, out
|
||||
assert seen.get('size') == '32x32'
|
||||
# The synthesized mask is image-sized and all-white (whole image editable).
|
||||
mask_img = Image.open(io.BytesIO(mask_bytes['png'])).convert('L')
|
||||
assert mask_img.size == (32, 32)
|
||||
assert mask_img.getextrema() == (255, 255)
|
||||
|
||||
|
||||
def test_openai_inpaint_without_mask_is_a_clean_400(monkeypatch):
|
||||
"""OpenAI edits need a mask; a missing one must be a clean 400, not a
|
||||
KeyError traceback."""
|
||||
from fastapi import HTTPException
|
||||
|
||||
endpoint = SimpleNamespace(base_url='https://api.openai.com/v1', api_key='sk-x', owner='alice')
|
||||
monkeypatch.setattr(gallery, 'SessionLocal', lambda: SimpleNamespace(close=lambda: None))
|
||||
monkeypatch.setattr(gallery, 'require_privilege', lambda *a: 'alice')
|
||||
monkeypatch.setattr(gallery, '_visible_image_endpoint_for_base', lambda *a: endpoint)
|
||||
monkeypatch.setattr('src.url_safety.check_outbound_url', lambda *a, **k: (True, ''))
|
||||
|
||||
class Request:
|
||||
async def json(self):
|
||||
return {'_endpoint': 'https://api.openai.com/v1',
|
||||
'image': png(Image.new('RGB', (32, 32), 'red')), 'prompt': 'x'}
|
||||
|
||||
route = next(r for r in gallery.setup_gallery_routes().routes if r.path == '/api/image/inpaint')
|
||||
try:
|
||||
asyncio.run(route.endpoint(Request()))
|
||||
raise AssertionError('expected a 400 for a missing mask')
|
||||
except HTTPException as exc:
|
||||
assert exc.status_code == 400
|
||||
assert 'mask' in str(exc.detail).lower()
|
||||
|
||||
|
||||
def test_self_hosted_inpaint_forwards_request_id(monkeypatch):
|
||||
"""A client-supplied request_id is forwarded to the provider so the client
|
||||
can poll GET /api/image/edit-progress for step progress."""
|
||||
seen = {}
|
||||
|
||||
def respond(request):
|
||||
seen.update(_form_fields(request.content, request.headers.get('content-type', '')))
|
||||
return httpx.Response(200, json={'model': 'qwen-image-edit-2511',
|
||||
'data': [{'b64_json': png(Image.new('RGBA', (32, 32), 'red'))}]})
|
||||
|
||||
out = _self_hosted_inpaint(monkeypatch, Image.new('RGB', (32, 32), 'red'),
|
||||
Image.new('L', (32, 32), 255), {'request_id': 'job-777'}, respond)
|
||||
assert 'image' in out, out
|
||||
assert seen.get('request_id') == 'job-777'
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
"""Tests for the image-op progress contract (HARNESS-owned).
|
||||
|
||||
The 7011 side accepts an optional client-supplied ``request_id`` on heavy image
|
||||
ops and exposes ``GET /api/image/edit-progress`` which proxies the provider's
|
||||
``/v1/images/progress/{request_id}`` route. When the provider has no such route
|
||||
the endpoint returns ``{"available": false}`` so the client keeps an
|
||||
indeterminate bar.
|
||||
"""
|
||||
import asyncio
|
||||
from types import SimpleNamespace
|
||||
|
||||
import httpx
|
||||
|
||||
import routes.gallery_routes as gallery
|
||||
|
||||
|
||||
def _route(path):
|
||||
return next(r for r in gallery.setup_gallery_routes().routes if r.path == path)
|
||||
|
||||
|
||||
class _Req:
|
||||
def __init__(self, query):
|
||||
self.query_params = query
|
||||
|
||||
|
||||
def _fake_db():
|
||||
class Db:
|
||||
def close(self):
|
||||
pass
|
||||
|
||||
def query(self, *args, **kwargs):
|
||||
return self
|
||||
|
||||
def filter(self, *args, **kwargs):
|
||||
return self
|
||||
|
||||
def first(self):
|
||||
return None
|
||||
|
||||
return Db()
|
||||
|
||||
|
||||
def test_edit_progress_requires_request_id(monkeypatch):
|
||||
monkeypatch.setattr(gallery, "require_privilege", lambda *a: "alice")
|
||||
route = _route("/api/image/edit-progress")
|
||||
try:
|
||||
asyncio.run(route.endpoint(_Req({})))
|
||||
except Exception as exc: # FastAPI HTTPException
|
||||
assert getattr(exc, "status_code", None) == 400
|
||||
else:
|
||||
raise AssertionError("expected a 400 for a missing request_id")
|
||||
|
||||
|
||||
def test_edit_progress_rejects_an_unsafe_request_id(monkeypatch):
|
||||
monkeypatch.setattr(gallery, "require_privilege", lambda *a: "alice")
|
||||
monkeypatch.setattr(gallery, "SessionLocal", lambda: _fake_db())
|
||||
route = _route("/api/image/edit-progress")
|
||||
try:
|
||||
asyncio.run(route.endpoint(_Req({"request_id": "../secret"})))
|
||||
except Exception as exc:
|
||||
assert getattr(exc, "status_code", None) == 400
|
||||
else:
|
||||
raise AssertionError("expected a 400 for an unsafe request_id")
|
||||
|
||||
|
||||
def test_edit_progress_returns_provider_payload(monkeypatch):
|
||||
endpoint = SimpleNamespace(base_url="http://100.69.120.65:8188/v1", api_key="k")
|
||||
monkeypatch.setattr(gallery, "SessionLocal", lambda: _fake_db())
|
||||
monkeypatch.setattr(gallery, "require_privilege", lambda *a: "alice")
|
||||
monkeypatch.setattr(gallery, "_first_visible_image_endpoint", lambda *a: endpoint)
|
||||
seen = {}
|
||||
|
||||
def respond(request):
|
||||
seen["url"] = str(request.url)
|
||||
# Live seneca contract stages: loading | editing | compositing | done | error.
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={"stage": "editing", "step": 3, "steps": 8, "eta_s": 42.0, "progress": 0.375},
|
||||
)
|
||||
|
||||
client = httpx.AsyncClient
|
||||
monkeypatch.setattr(httpx, "AsyncClient", lambda **k: client(transport=httpx.MockTransport(respond), **k))
|
||||
out = asyncio.run(_route("/api/image/edit-progress").endpoint(_Req({"request_id": "abc-123"})))
|
||||
assert out["available"] is True
|
||||
assert out["stage"] == "editing" # passed through unchanged
|
||||
assert out["step"] == 3 and out["steps"] == 8
|
||||
assert seen["url"].endswith("/images/progress/abc-123")
|
||||
|
||||
|
||||
def test_edit_progress_available_false_when_provider_has_no_route(monkeypatch):
|
||||
endpoint = SimpleNamespace(base_url="http://100.69.120.65:8188/v1", api_key="k")
|
||||
monkeypatch.setattr(gallery, "SessionLocal", lambda: _fake_db())
|
||||
monkeypatch.setattr(gallery, "require_privilege", lambda *a: "alice")
|
||||
monkeypatch.setattr(gallery, "_first_visible_image_endpoint", lambda *a: endpoint)
|
||||
|
||||
client = httpx.AsyncClient
|
||||
monkeypatch.setattr(
|
||||
httpx, "AsyncClient",
|
||||
lambda **k: client(transport=httpx.MockTransport(lambda req: httpx.Response(404)), **k),
|
||||
)
|
||||
out = asyncio.run(_route("/api/image/edit-progress").endpoint(_Req({"request_id": "abc"})))
|
||||
assert out["available"] is False
|
||||
|
||||
|
||||
def test_edit_progress_available_false_when_unreachable(monkeypatch):
|
||||
endpoint = SimpleNamespace(base_url="http://100.69.120.65:8188/v1", api_key="k")
|
||||
monkeypatch.setattr(gallery, "SessionLocal", lambda: _fake_db())
|
||||
monkeypatch.setattr(gallery, "require_privilege", lambda *a: "alice")
|
||||
monkeypatch.setattr(gallery, "_first_visible_image_endpoint", lambda *a: endpoint)
|
||||
|
||||
class Boom:
|
||||
async def __aenter__(self):
|
||||
raise httpx.ConnectError("nope")
|
||||
|
||||
async def __aexit__(self, *a):
|
||||
return False
|
||||
|
||||
monkeypatch.setattr(httpx, "AsyncClient", lambda **k: Boom())
|
||||
out = asyncio.run(_route("/api/image/edit-progress").endpoint(_Req({"request_id": "abc"})))
|
||||
assert out["available"] is False
|
||||
@@ -0,0 +1,201 @@
|
||||
"""Heavy in-process image ops must not load GB-scale models by default.
|
||||
|
||||
remove-bg / SAM mask / Real-ESRGAN upscale+denoise used to load rembg / torch
|
||||
models inside the web process and OOM-killed the host. They now default to a
|
||||
graceful 503, proxy to the seneca remote ops service when
|
||||
``ODYSSEUS_IMAGE_OPS_BASE`` is set (translate to the /v1/images/* contract), and
|
||||
only run in-process when explicitly enabled via ODYSSEUS_ALLOW_LOCAL_IMAGE_MODELS.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from routes.gallery import gallery_routes as gr
|
||||
|
||||
|
||||
HEAVY_ROUTES = [
|
||||
("/api/image/remove-bg", {"image": "x"}, "Background removal"),
|
||||
("/api/image/mask", {"image": "x"}, "Smart mask"),
|
||||
("/api/image/upscale-local", {"image": "x"}, "AI upscale"),
|
||||
("/api/image/denoise", {"image": "x"}, "AI denoise"),
|
||||
]
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clean_env(monkeypatch):
|
||||
monkeypatch.setattr(gr, "require_privilege", lambda *a, **k: None)
|
||||
monkeypatch.delenv("ODYSSEUS_IMAGE_OPS_BASE", raising=False)
|
||||
monkeypatch.delenv("ODYSSEUS_ALLOW_LOCAL_IMAGE_MODELS", raising=False)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def client():
|
||||
app = FastAPI()
|
||||
app.include_router(gr.setup_gallery_routes())
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
def test_local_image_models_disabled_by_default():
|
||||
assert gr._local_image_models_enabled() is False
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value", ["1", "true", "Yes", "ON"])
|
||||
def test_local_image_models_enabled_env(monkeypatch, value):
|
||||
monkeypatch.setenv("ODYSSEUS_ALLOW_LOCAL_IMAGE_MODELS", value)
|
||||
assert gr._local_image_models_enabled() is True
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value", ["", "0", "no", "off", "maybe"])
|
||||
def test_local_image_models_disabled_env(monkeypatch, value):
|
||||
monkeypatch.setenv("ODYSSEUS_ALLOW_LOCAL_IMAGE_MODELS", value)
|
||||
assert gr._local_image_models_enabled() is False
|
||||
|
||||
|
||||
def test_rss_mb_is_positive():
|
||||
assert gr._rss_mb() > 0
|
||||
|
||||
|
||||
def test_route_heavy_image_op_returns_none_when_enabled(monkeypatch):
|
||||
monkeypatch.setenv("ODYSSEUS_ALLOW_LOCAL_IMAGE_MODELS", "1")
|
||||
assert asyncio.run(gr._route_heavy_image_op("/api/image/denoise", {}, label="x")) is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize("path,body,label", HEAVY_ROUTES)
|
||||
def test_heavy_ops_503_when_disabled(client, path, body, label):
|
||||
resp = client.post(path, json=body)
|
||||
assert resp.status_code == 503
|
||||
detail = resp.json().get("detail", "")
|
||||
assert "temporarily unavailable" in detail
|
||||
assert label in detail
|
||||
|
||||
|
||||
def test_remove_bg_disabled_does_not_import_rembg(monkeypatch):
|
||||
import sys
|
||||
|
||||
monkeypatch.setitem(sys.modules, "rembg", None)
|
||||
|
||||
app = FastAPI()
|
||||
app.include_router(gr.setup_gallery_routes())
|
||||
client = TestClient(app)
|
||||
resp = client.post("/api/image/remove-bg", json={"image": "x"})
|
||||
assert resp.status_code == 503
|
||||
|
||||
|
||||
class _FakeResponse:
|
||||
def __init__(self, payload, status_code=200):
|
||||
self._payload = payload
|
||||
self.status_code = status_code
|
||||
|
||||
def json(self):
|
||||
return self._payload
|
||||
|
||||
|
||||
class _FakeAsyncClient:
|
||||
calls = []
|
||||
response = _FakeResponse({})
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
pass
|
||||
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *args):
|
||||
return False
|
||||
|
||||
async def post(self, url, json=None, files=None, data=None):
|
||||
_FakeAsyncClient.calls.append((url, {"json": json, "files": files, "data": data}))
|
||||
return _FakeAsyncClient.response
|
||||
|
||||
|
||||
def _client():
|
||||
app = FastAPI()
|
||||
app.include_router(gr.setup_gallery_routes())
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
def test_remove_bg_proxies_to_remote_contract(monkeypatch):
|
||||
monkeypatch.setenv("ODYSSEUS_IMAGE_OPS_BASE", "http://seneca.test:8188")
|
||||
monkeypatch.setattr(httpx, "AsyncClient", _FakeAsyncClient)
|
||||
_FakeAsyncClient.calls = []
|
||||
_FakeAsyncClient.response = _FakeResponse(
|
||||
{"data": [{"b64_json": "RB", "mask_b64": "M", "bbox": [0, 0, 1, 1]}], "model": "birefnet"}
|
||||
)
|
||||
|
||||
resp = _client().post("/api/image/remove-bg", json={"image": "AAAA"})
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["image"] == "RB"
|
||||
url, kw = _FakeAsyncClient.calls[-1]
|
||||
assert url == "http://seneca.test:8188/v1/images/remove-background"
|
||||
assert "image" in kw["files"]
|
||||
|
||||
|
||||
def test_mask_proxies_to_remote_contract(monkeypatch):
|
||||
monkeypatch.setenv("ODYSSEUS_IMAGE_OPS_BASE", "http://seneca.test:8188")
|
||||
monkeypatch.setattr(httpx, "AsyncClient", _FakeAsyncClient)
|
||||
_FakeAsyncClient.calls = []
|
||||
_FakeAsyncClient.response = _FakeResponse(
|
||||
{"masks": [{"b64_json": "MASK", "bbox": [10, 20, 30, 40], "score": 0.9, "label": "person"}],
|
||||
"model": "sam+grounding"}
|
||||
)
|
||||
|
||||
resp = _client().post("/api/image/mask", json={"image": "AAAA", "text": "person"})
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["mask"] == "MASK"
|
||||
# remote [x,y,w,h] -> app [x1,y1,x2,y2]
|
||||
assert body["bbox"] == [10, 20, 40, 60]
|
||||
url, kw = _FakeAsyncClient.calls[-1]
|
||||
assert url == "http://seneca.test:8188/v1/images/mask"
|
||||
assert kw["data"]["prompt"] == "person"
|
||||
|
||||
|
||||
def test_mask_picks_top_score_not_first(monkeypatch):
|
||||
monkeypatch.setenv("ODYSSEUS_IMAGE_OPS_BASE", "http://seneca.test:8188")
|
||||
monkeypatch.setattr(httpx, "AsyncClient", _FakeAsyncClient)
|
||||
_FakeAsyncClient.response = _FakeResponse(
|
||||
{"masks": [
|
||||
{"b64_json": "LOW", "bbox": [0, 0, 1, 1], "score": 0.2},
|
||||
{"b64_json": "HIGH", "bbox": [0, 0, 2, 2], "score": 0.95},
|
||||
{"b64_json": "MID", "bbox": [0, 0, 3, 3], "score": 0.5},
|
||||
], "model": "sam+grounding"}
|
||||
)
|
||||
resp = _client().post("/api/image/mask", json={"image": "AAAA", "text": "person"})
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["mask"] == "HIGH"
|
||||
|
||||
|
||||
def test_mask_box_is_sent_as_xywh(monkeypatch):
|
||||
monkeypatch.setenv("ODYSSEUS_IMAGE_OPS_BASE", "http://seneca.test:8188")
|
||||
monkeypatch.setattr(httpx, "AsyncClient", _FakeAsyncClient)
|
||||
_FakeAsyncClient.calls = []
|
||||
_FakeAsyncClient.response = _FakeResponse({"masks": [{"b64_json": "M", "bbox": [0, 0, 1, 1], "score": 1.0}]})
|
||||
resp = _client().post("/api/image/mask", json={"image": "AAAA", "box": [10, 20, 40, 60]})
|
||||
assert resp.status_code == 200
|
||||
_, kw = _FakeAsyncClient.calls[-1]
|
||||
assert kw["data"]["box"] == "10,20,30,40"
|
||||
|
||||
|
||||
def test_mask_remote_empty_masks_is_200_nothing_selected(monkeypatch):
|
||||
monkeypatch.setenv("ODYSSEUS_IMAGE_OPS_BASE", "http://seneca.test:8188")
|
||||
monkeypatch.setattr(httpx, "AsyncClient", _FakeAsyncClient)
|
||||
_FakeAsyncClient.response = _FakeResponse({"masks": [], "model": "sam+grounding"})
|
||||
resp = _client().post("/api/image/mask", json={"image": "AAAA", "text": "person"})
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["mask"] is None
|
||||
assert body["masks"] == []
|
||||
assert "Nothing selected" in body["error"]
|
||||
|
||||
|
||||
def test_remote_proxy_surfaces_error_status(monkeypatch):
|
||||
monkeypatch.setenv("ODYSSEUS_IMAGE_OPS_BASE", "http://seneca.test:8188")
|
||||
monkeypatch.setattr(httpx, "AsyncClient", _FakeAsyncClient)
|
||||
_FakeAsyncClient.response = _FakeResponse({}, status_code=500)
|
||||
resp = _client().post("/api/image/remove-bg", json={"image": "AAAA"})
|
||||
assert resp.status_code == 503
|
||||
assert "temporarily unavailable" in resp.json().get("detail", "")
|
||||
@@ -0,0 +1,124 @@
|
||||
"""Image endpoint fallback: image models on mixed (non image-only) endpoints.
|
||||
|
||||
Chat image generation resolves its model by name across every enabled endpoint,
|
||||
so a ``qwen-image-edit`` / ``gpt-image-1`` model registered on a shared
|
||||
OpenAI-compatible gateway works there. The editor's generative ops used a
|
||||
stricter image-only lookup and reported "No image generation endpoint
|
||||
configured" on those same setups. These tests pin the fallback that makes the
|
||||
editor find the same endpoint.
|
||||
"""
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
import routes.gallery_routes as gallery_routes
|
||||
|
||||
|
||||
class _Predicate:
|
||||
def __init__(self, check):
|
||||
self._check = check
|
||||
|
||||
def __call__(self, row):
|
||||
return self._check(row)
|
||||
|
||||
def __or__(self, other):
|
||||
return _Predicate(lambda row: self(row) or other(row))
|
||||
|
||||
|
||||
class _Column:
|
||||
def __init__(self, name):
|
||||
self.name = name
|
||||
|
||||
def __eq__(self, value):
|
||||
return _Predicate(lambda row: getattr(row, self.name) == value)
|
||||
|
||||
|
||||
class _ModelEndpoint:
|
||||
base_url = _Column("base_url")
|
||||
model_type = _Column("model_type")
|
||||
is_enabled = _Column("is_enabled")
|
||||
owner = _Column("owner")
|
||||
|
||||
|
||||
class _Query:
|
||||
def __init__(self, rows):
|
||||
self._rows = list(rows)
|
||||
|
||||
def filter(self, *predicates):
|
||||
self._rows = [row for row in self._rows if all(pred(row) for pred in predicates)]
|
||||
return self
|
||||
|
||||
def all(self):
|
||||
return list(self._rows)
|
||||
|
||||
|
||||
class _DB:
|
||||
def __init__(self, rows):
|
||||
self._rows = rows
|
||||
|
||||
def query(self, model):
|
||||
assert model is _ModelEndpoint
|
||||
return _Query(self._rows)
|
||||
|
||||
|
||||
def _ep(base_url, owner=None, *, enabled=True, model_type="chat", models=None):
|
||||
return SimpleNamespace(
|
||||
base_url=base_url,
|
||||
owner=owner,
|
||||
is_enabled=enabled,
|
||||
model_type=model_type,
|
||||
api_key="sk-secret",
|
||||
cached_models=models or [],
|
||||
pinned_models=[],
|
||||
)
|
||||
|
||||
|
||||
def _patch_model(monkeypatch):
|
||||
monkeypatch.setattr(gallery_routes, "ModelEndpoint", _ModelEndpoint)
|
||||
|
||||
|
||||
def test_mixed_endpoint_with_image_model_is_found(monkeypatch):
|
||||
_patch_model(monkeypatch)
|
||||
rows = [_ep("https://gateway.example.com/v1", models=["qwen-image-edit", "llama-3"])]
|
||||
|
||||
ep = gallery_routes._first_visible_image_endpoint(_DB(rows), None)
|
||||
|
||||
assert ep is not None
|
||||
assert ep.base_url == "https://gateway.example.com/v1"
|
||||
|
||||
|
||||
def test_mixed_endpoint_without_image_model_is_ignored(monkeypatch):
|
||||
_patch_model(monkeypatch)
|
||||
rows = [_ep("https://gateway.example.com/v1", models=["llama-3", "mistral"])]
|
||||
|
||||
assert gallery_routes._first_visible_image_endpoint(_DB(rows), None) is None
|
||||
|
||||
|
||||
def test_image_only_endpoint_wins_over_mixed(monkeypatch):
|
||||
_patch_model(monkeypatch)
|
||||
rows = [
|
||||
_ep("https://gateway.example.com/v1", models=["gpt-image-1"]),
|
||||
_ep("https://images.example.com/v1", model_type="image"),
|
||||
]
|
||||
|
||||
ep = gallery_routes._first_visible_image_endpoint(_DB(rows), None)
|
||||
|
||||
assert ep is not None
|
||||
assert ep.model_type == "image"
|
||||
|
||||
|
||||
def test_unreachable_endpoint_reports_502():
|
||||
"""The inpaint proxy must distinguish an unreachable endpoint from a provider error.
|
||||
|
||||
502 (gateway) is reserved for "the image server could not be reached"; 503 is
|
||||
reserved for "generation is switched off on the server", which the editor maps
|
||||
to its own fixed message.
|
||||
"""
|
||||
from pathlib import Path
|
||||
|
||||
src = (
|
||||
Path(__file__).resolve().parents[1] / "routes" / "gallery" / "gallery_routes.py"
|
||||
).read_text()
|
||||
assert "Image server unreachable" in src
|
||||
assert "except httpx.TransportError as exc:" in src
|
||||
assert "provider HTTP" in src
|
||||
assert 'raise HTTPException(503, f"Image endpoint unreachable' not in src
|
||||
@@ -0,0 +1,39 @@
|
||||
"""Source checks for the gallery Settings-tab image-model picker.
|
||||
|
||||
The Quick Edit model picker lives in the editor; HARNESS mirrors it into the
|
||||
gallery Settings tab, fed by GET /api/editor/image-models and sharing the
|
||||
same localStorage key (`ge-ai-command-model`) so the two stay in sync.
|
||||
"""
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
GALLERY_JS = (ROOT / "static/js/gallery.js").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_settings_tab_has_image_model_picker():
|
||||
assert 'id="gallery-image-model"' in GALLERY_JS
|
||||
assert "/api/editor/image-models" in GALLERY_JS
|
||||
assert "ge-ai-command-model" in GALLERY_JS
|
||||
|
||||
|
||||
def test_image_model_picker_lives_in_the_settings_container():
|
||||
settings_idx = GALLERY_JS.index('id="gallery-settings-container"')
|
||||
picker_idx = GALLERY_JS.index('id="gallery-image-model"')
|
||||
assert picker_idx > settings_idx
|
||||
|
||||
|
||||
def test_picker_persists_with_the_shared_localstorage_key():
|
||||
assert "localStorage.setItem(_GE_MODEL_KEY" in GALLERY_JS
|
||||
assert "localStorage.getItem(_GE_MODEL_KEY)" in GALLERY_JS
|
||||
|
||||
|
||||
def test_cloud_image_models_are_listed_alongside_the_local_ones():
|
||||
"""Gateway endpoints (OpenRouter) register as llm but carry image models.
|
||||
|
||||
The picker must surface those ids too — otherwise the only selectable
|
||||
edit model is the local Qwen server.
|
||||
"""
|
||||
routes = (ROOT / "routes/editor_agent_routes.py").read_text(encoding="utf-8")
|
||||
assert "from src.image_model_ids import looks_like_image_generation_model" in routes
|
||||
assert 'image_endpoint = getattr(ep, "model_type", None) == "image"' in routes
|
||||
assert "if not image_endpoint and not looks_like_image_generation_model(name):" in routes
|
||||
@@ -0,0 +1,25 @@
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
GALLERY = ROOT / "static/js/gallery.js"
|
||||
|
||||
|
||||
def test_editor_landing_has_an_import_button_next_to_new_canvas():
|
||||
"""The editor landing must offer a direct "Import" action.
|
||||
|
||||
It sits in the same action row as "New canvas..." / "Browse gallery" and,
|
||||
on file pick, opens the editor on the chosen raster image (``openEditor``
|
||||
takes an image data URL as its first argument).
|
||||
"""
|
||||
src = GALLERY.read_text(encoding="utf-8")
|
||||
assert 'id="gallery-editor-import"' in src, "landing Import button missing"
|
||||
# Button order: New canvas → Import → Browse gallery.
|
||||
new_at = src.index('id="gallery-editor-new"')
|
||||
import_at = src.index('id="gallery-editor-import"')
|
||||
pick_at = src.index('id="gallery-editor-pick"')
|
||||
assert new_at < import_at < pick_at, "Import must sit next to New canvas"
|
||||
# Wiring opens the editor on the selected file's data URL.
|
||||
assert "getElementById('gallery-editor-import')?.addEventListener('click'" in src
|
||||
assert "readAsDataURL(file)" in src
|
||||
assert "await openEditor(reader.result, null, null, name)" in src
|
||||
@@ -0,0 +1,187 @@
|
||||
"""Tests for the seneca single-image op proxies (HARNESS-owned).
|
||||
|
||||
POST /api/image/replace-background -> provider /v1/images/replace-background
|
||||
POST /api/image/outpaint -> provider /v1/images/outpaint (alias /extend)
|
||||
|
||||
Both forward a client-supplied JPEG/PNG as multipart with the prompt/params plus
|
||||
``request_id`` (so GET /api/image/edit-progress lights the progress bar) and
|
||||
return the provider image as ``{image}``.
|
||||
"""
|
||||
import asyncio
|
||||
import base64
|
||||
from types import SimpleNamespace
|
||||
|
||||
import httpx
|
||||
|
||||
import routes.gallery_routes as gallery
|
||||
|
||||
_IMG_B64 = base64.b64encode(b"abc-not-a-real-png").decode()
|
||||
|
||||
|
||||
def _route(path):
|
||||
return next(r for r in gallery.setup_gallery_routes().routes if r.path == path)
|
||||
|
||||
|
||||
class _Req:
|
||||
def __init__(self, body):
|
||||
self._body = body
|
||||
|
||||
async def json(self):
|
||||
return self._body
|
||||
|
||||
|
||||
def _fake_db():
|
||||
class Db:
|
||||
def close(self):
|
||||
pass
|
||||
|
||||
def query(self, *args, **kwargs):
|
||||
return self
|
||||
|
||||
def filter(self, *args, **kwargs):
|
||||
return self
|
||||
|
||||
def first(self):
|
||||
return None
|
||||
|
||||
return Db()
|
||||
|
||||
|
||||
def _setup(monkeypatch, respond):
|
||||
endpoint = SimpleNamespace(base_url="http://100.69.120.65:8188/v1", api_key="k")
|
||||
monkeypatch.setattr(gallery, "SessionLocal", lambda: _fake_db())
|
||||
monkeypatch.setattr(gallery, "require_privilege", lambda *a: "alice")
|
||||
monkeypatch.setattr(gallery, "_first_visible_image_endpoint", lambda *a: endpoint)
|
||||
seen = {}
|
||||
|
||||
def _respond(request):
|
||||
seen["url"] = str(request.url)
|
||||
seen["body"] = bytes(request.content)
|
||||
return respond(request)
|
||||
|
||||
client = httpx.AsyncClient
|
||||
monkeypatch.setattr(
|
||||
httpx, "AsyncClient",
|
||||
lambda **k: client(transport=httpx.MockTransport(_respond), **k),
|
||||
)
|
||||
return seen
|
||||
|
||||
|
||||
def _ok(_req):
|
||||
return httpx.Response(200, json={"data": [{"b64_json": "AAAA"}]})
|
||||
|
||||
|
||||
def test_replace_background_forwards_multipart_and_returns_image(monkeypatch):
|
||||
seen = _setup(monkeypatch, _ok)
|
||||
out = asyncio.run(_route("/api/image/replace-background").endpoint(_Req({
|
||||
"image": _IMG_B64, "prompt": "put her on a beach", "steps": 8,
|
||||
"harmonise": True, "shadow": False, "request_id": "rb-123",
|
||||
})))
|
||||
assert out["image"] == "AAAA"
|
||||
assert seen["url"].endswith("/images/replace-background")
|
||||
body = seen["body"]
|
||||
assert b"put her on a beach" in body
|
||||
assert b"rb-123" in body # request_id forwarded for progress
|
||||
assert b"harmonise" in body and b"shadow" in body
|
||||
assert b"8" in body # steps
|
||||
|
||||
|
||||
def test_outpaint_forwards_ratio_size_context_and_returns_image(monkeypatch):
|
||||
seen = _setup(monkeypatch, _ok)
|
||||
out = asyncio.run(_route("/api/image/outpaint").endpoint(_Req({
|
||||
"image": _IMG_B64, "prompt": "extend the sky", "ratio": "16:9",
|
||||
"size": "1920x1080", "steps": 4, "context": "calm blue sky",
|
||||
"request_id": "op-9",
|
||||
})))
|
||||
assert out["image"] == "AAAA"
|
||||
assert seen["url"].endswith("/images/outpaint")
|
||||
body = seen["body"]
|
||||
for token in (b"16:9", b"1920x1080", b"calm blue sky", b"op-9"):
|
||||
assert token in body, token
|
||||
|
||||
|
||||
def test_outpaint_extend_alias_uses_the_same_provider_route(monkeypatch):
|
||||
seen = _setup(monkeypatch, _ok)
|
||||
out = asyncio.run(_route("/api/image/extend").endpoint(_Req({
|
||||
"image": _IMG_B64, "prompt": "extend", "ratio": "9:16",
|
||||
})))
|
||||
assert out["image"] == "AAAA"
|
||||
assert seen["url"].endswith("/images/outpaint")
|
||||
|
||||
|
||||
def test_outpaint_reads_openai_style_url_result(monkeypatch):
|
||||
def _respond(_req):
|
||||
return httpx.Response(200, json={"data": [{"url": "http://100.69.120.65:8188/v1/images/x.png"}]})
|
||||
|
||||
_setup(monkeypatch, _respond)
|
||||
monkeypatch.setattr(gallery, "_fetch_result_image_b64", lambda url: _async_return("ZZZZ"))
|
||||
out = asyncio.run(_route("/api/image/outpaint").endpoint(_Req({"image": _IMG_B64, "prompt": "x"})))
|
||||
assert out["image"] == "ZZZZ"
|
||||
|
||||
|
||||
def test_replace_background_502_when_provider_returns_no_image(monkeypatch):
|
||||
_setup(monkeypatch, lambda _req: httpx.Response(200, json={"data": []}))
|
||||
try:
|
||||
asyncio.run(_route("/api/image/replace-background").endpoint(_Req({"image": _IMG_B64})))
|
||||
except Exception as exc:
|
||||
assert getattr(exc, "status_code", None) == 502
|
||||
else:
|
||||
raise AssertionError("expected a 502 when the provider returns no image")
|
||||
|
||||
|
||||
async def _async_return(value):
|
||||
return value
|
||||
|
||||
|
||||
def _capture_forward(monkeypatch):
|
||||
"""Replace _forward_seneca_image_op with a fake that records kwargs."""
|
||||
captured = {}
|
||||
|
||||
async def _fake_forward(base, api_key, provider_path, image_bytes, fields, *, label, timeout=300.0):
|
||||
captured["label"] = label
|
||||
captured["provider_path"] = provider_path
|
||||
captured["timeout"] = timeout
|
||||
return {"image": "AAAA"}
|
||||
|
||||
monkeypatch.setattr(gallery, "_forward_seneca_image_op", _fake_forward)
|
||||
monkeypatch.setattr(gallery, "SessionLocal", lambda: _fake_db())
|
||||
monkeypatch.setattr(gallery, "require_privilege", lambda *a: "alice")
|
||||
monkeypatch.setattr(
|
||||
gallery, "_first_visible_image_endpoint",
|
||||
lambda *a: SimpleNamespace(base_url="http://100.69.120.65:8188/v1", api_key="k"),
|
||||
)
|
||||
return captured
|
||||
|
||||
|
||||
def test_outpaint_proxy_uses_a_long_timeout(monkeypatch):
|
||||
captured = _capture_forward(monkeypatch)
|
||||
out = asyncio.run(_route("/api/image/outpaint").endpoint(_Req({"image": _IMG_B64, "prompt": "extend to 9:16"})))
|
||||
assert out["image"] == "AAAA"
|
||||
assert captured["provider_path"] == "/images/outpaint"
|
||||
assert captured["timeout"] >= 900, captured
|
||||
|
||||
|
||||
def test_outpaint_extend_alias_also_uses_the_long_timeout(monkeypatch):
|
||||
captured = _capture_forward(monkeypatch)
|
||||
asyncio.run(_route("/api/image/extend").endpoint(_Req({"image": _IMG_B64, "prompt": "extend"})))
|
||||
assert captured["timeout"] >= 900, captured
|
||||
|
||||
|
||||
def test_replace_background_keeps_the_short_default_timeout(monkeypatch):
|
||||
captured = _capture_forward(monkeypatch)
|
||||
asyncio.run(_route("/api/image/replace-background").endpoint(_Req({"image": _IMG_B64, "prompt": "beach"})))
|
||||
assert captured["timeout"] == 300.0, captured
|
||||
|
||||
|
||||
def test_outpaint_timeout_env_override(monkeypatch):
|
||||
monkeypatch.setenv("ODYSSEUS_IMAGE_OUTPAINT_TIMEOUT", "720")
|
||||
assert gallery._outpaint_timeout() == 720.0
|
||||
monkeypatch.delenv("ODYSSEUS_IMAGE_OUTPAINT_TIMEOUT", raising=False)
|
||||
assert gallery._outpaint_timeout() == 900.0
|
||||
|
||||
|
||||
def test_graceful_shutdown_window_covers_a_long_outpaint():
|
||||
from pathlib import Path
|
||||
|
||||
src = (Path(__file__).resolve().parent.parent / "app.py").read_text()
|
||||
assert '"ODYSSEUS_GRACEFUL_SHUTDOWN_TIMEOUT", "900"' in src
|
||||
@@ -60,7 +60,7 @@ def test_generated_image_headers_include_nosniff():
|
||||
assert generated_images.GENERATED_IMAGE_HEADERS["X-Content-Type-Options"] == "nosniff"
|
||||
assert (
|
||||
generated_images.GENERATED_IMAGE_HEADERS["Cache-Control"]
|
||||
== "public, max-age=31536000, immutable"
|
||||
== "private, no-cache, must-revalidate"
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,493 @@
|
||||
import os
|
||||
from unittest.mock import MagicMock, patch
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
from starlette.requests import Request
|
||||
|
||||
from app import serve_generated_image
|
||||
from src.generated_images import (
|
||||
GENERATED_IMAGE_HEADERS,
|
||||
register_generated_image,
|
||||
)
|
||||
|
||||
|
||||
class DummyState:
|
||||
def __init__(self, current_user=None, api_token=False, api_token_owner=None):
|
||||
self.current_user = current_user
|
||||
self.api_token = api_token
|
||||
self.api_token_owner = api_token_owner
|
||||
|
||||
|
||||
def _make_request(current_user=None, api_token=False, api_token_owner=None):
|
||||
req = MagicMock(spec=Request)
|
||||
req.state = DummyState(current_user, api_token, api_token_owner)
|
||||
return req
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_image(tmp_path, monkeypatch):
|
||||
from src import generated_images
|
||||
image_dir = tmp_path / "generated_images"
|
||||
image_dir.mkdir(parents=True, exist_ok=True)
|
||||
filename = "0123456789ab.png"
|
||||
img_file = image_dir / filename
|
||||
img_file.write_bytes(b"\x89PNG\r\n\x1a\nfake-image-bytes")
|
||||
monkeypatch.setattr(generated_images, "GENERATED_IMAGE_DIR", image_dir)
|
||||
return filename
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_authorized_cookie_session_owner(sample_image):
|
||||
"""Cookie session owner receives 200 for their owned image."""
|
||||
req = _make_request(current_user="alice")
|
||||
mock_row = MagicMock()
|
||||
mock_row.owner = "alice"
|
||||
mock_row.filename = sample_image
|
||||
|
||||
with patch("src.auth_helpers.auth_disabled", return_value=False), \
|
||||
patch("core.database.SessionLocal") as mock_sl:
|
||||
mock_db = MagicMock()
|
||||
mock_db.query.return_value.filter.return_value.first.return_value = mock_row
|
||||
mock_sl.return_value = mock_db
|
||||
|
||||
resp = await serve_generated_image(sample_image, req)
|
||||
assert resp.status_code == 200
|
||||
assert resp.headers["Cache-Control"] == "private, no-cache, must-revalidate"
|
||||
assert resp.headers["X-Content-Type-Options"] == "nosniff"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_authorized_api_token_owner(sample_image):
|
||||
"""API token request matching image owner receives 200."""
|
||||
req = _make_request(current_user="api", api_token=True, api_token_owner="alice")
|
||||
mock_row = MagicMock()
|
||||
mock_row.owner = "alice"
|
||||
mock_row.filename = sample_image
|
||||
|
||||
with patch("src.auth_helpers.auth_disabled", return_value=False), \
|
||||
patch("core.database.SessionLocal") as mock_sl:
|
||||
mock_db = MagicMock()
|
||||
mock_db.query.return_value.filter.return_value.first.return_value = mock_row
|
||||
mock_sl.return_value = mock_db
|
||||
|
||||
resp = await serve_generated_image(sample_image, req)
|
||||
assert resp.status_code == 200
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cross_user_denial_returns_404(sample_image):
|
||||
"""Bob attempting to access Alice's image receives 404 (preventing existence leak)."""
|
||||
req = _make_request(current_user="bob")
|
||||
mock_row = MagicMock()
|
||||
mock_row.owner = "alice"
|
||||
mock_row.filename = sample_image
|
||||
|
||||
with patch("src.auth_helpers.auth_disabled", return_value=False), \
|
||||
patch("core.database.SessionLocal") as mock_sl:
|
||||
mock_db = MagicMock()
|
||||
mock_db.query.return_value.filter.return_value.first.return_value = mock_row
|
||||
mock_sl.return_value = mock_db
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await serve_generated_image(sample_image, req)
|
||||
assert exc_info.value.status_code == 404
|
||||
assert exc_info.value.detail == "Image not found"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cross_user_api_token_denial_returns_404(sample_image):
|
||||
"""Bob's API token attempting to access Alice's image receives 404."""
|
||||
req = _make_request(current_user="api", api_token=True, api_token_owner="bob")
|
||||
mock_row = MagicMock()
|
||||
mock_row.owner = "alice"
|
||||
mock_row.filename = sample_image
|
||||
|
||||
with patch("src.auth_helpers.auth_disabled", return_value=False), \
|
||||
patch("core.database.SessionLocal") as mock_sl:
|
||||
mock_db = MagicMock()
|
||||
mock_db.query.return_value.filter.return_value.first.return_value = mock_row
|
||||
mock_sl.return_value = mock_db
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await serve_generated_image(sample_image, req)
|
||||
assert exc_info.value.status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unauthenticated_request_rejected(sample_image):
|
||||
"""Unauthenticated caller (no user identity) is rejected with 401 when auth is enabled."""
|
||||
req = _make_request(current_user=None)
|
||||
|
||||
with patch("src.auth_helpers.auth_disabled", return_value=False):
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await serve_generated_image(sample_image, req)
|
||||
assert exc_info.value.status_code == 401
|
||||
assert exc_info.value.detail == "Not authenticated"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_missing_gallery_row_fails_closed_404(sample_image):
|
||||
"""Image on disk with no gallery row, no registry, and no chat reference returns 404."""
|
||||
req = _make_request(current_user="bob")
|
||||
|
||||
with patch("src.auth_helpers.auth_disabled", return_value=False), \
|
||||
patch("core.database.SessionLocal") as mock_sl:
|
||||
mock_db = MagicMock()
|
||||
# No gallery row
|
||||
mock_db.query.return_value.filter.return_value.first.return_value = None
|
||||
# No chat message reference
|
||||
mock_db.query.return_value.join.return_value.filter.return_value.first.return_value = None
|
||||
mock_sl.return_value = mock_db
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await serve_generated_image(sample_image, req)
|
||||
assert exc_info.value.status_code == 404
|
||||
assert exc_info.value.detail == "Image not found"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_null_owner_metadata_fails_closed_404(sample_image):
|
||||
"""Gallery row with owner=None and no session link returns 404."""
|
||||
req = _make_request(current_user="bob")
|
||||
mock_row = MagicMock()
|
||||
mock_row.owner = None
|
||||
mock_row.session_id = None
|
||||
mock_row.filename = sample_image
|
||||
|
||||
with patch("src.auth_helpers.auth_disabled", return_value=False), \
|
||||
patch("core.database.SessionLocal") as mock_sl:
|
||||
mock_db = MagicMock()
|
||||
mock_db.query.return_value.filter.return_value.first.return_value = mock_row
|
||||
mock_sl.return_value = mock_db
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await serve_generated_image(sample_image, req)
|
||||
assert exc_info.value.status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_session_linked_null_owner_authorized(sample_image):
|
||||
"""Gallery row with owner=None resolves owner through session_id."""
|
||||
req_alice = _make_request(current_user="alice")
|
||||
req_bob = _make_request(current_user="bob")
|
||||
|
||||
mock_row = MagicMock()
|
||||
mock_row.owner = None
|
||||
mock_row.session_id = "sess-123"
|
||||
mock_row.filename = sample_image
|
||||
|
||||
mock_sess = MagicMock()
|
||||
mock_sess.owner = "alice"
|
||||
|
||||
with patch("src.auth_helpers.auth_disabled", return_value=False), \
|
||||
patch("core.database.SessionLocal") as mock_sl:
|
||||
mock_db = MagicMock()
|
||||
mock_db.query.return_value.filter.return_value.first.side_effect = [
|
||||
mock_row, mock_sess, # for Alice
|
||||
mock_row, mock_sess, # for Bob
|
||||
]
|
||||
mock_sl.return_value = mock_db
|
||||
|
||||
# Alice is the session owner -> 200
|
||||
resp = await serve_generated_image(sample_image, req_alice)
|
||||
assert resp.status_code == 200
|
||||
|
||||
# Bob is not the session owner -> 404
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await serve_generated_image(sample_image, req_bob)
|
||||
assert exc_info.value.status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_chat_referenced_rowless_image_authorized(sample_image):
|
||||
"""Rowless image referenced in a chat session owned by user is authorized."""
|
||||
req_alice = _make_request(current_user="alice")
|
||||
req_bob = _make_request(current_user="bob")
|
||||
|
||||
with patch("src.auth_helpers.auth_disabled", return_value=False), \
|
||||
patch("core.database.SessionLocal") as mock_sl:
|
||||
mock_db = MagicMock()
|
||||
# Row is None
|
||||
mock_db.query.return_value.filter.return_value.first.return_value = None
|
||||
# Alice's chat query returns a match, Bob's does not
|
||||
mock_db.query.return_value.join.return_value.filter.return_value.first.side_effect = [
|
||||
MagicMock(), # Alice has a match
|
||||
None, # Bob has no match
|
||||
]
|
||||
mock_sl.return_value = mock_db
|
||||
|
||||
resp = await serve_generated_image(sample_image, req_alice)
|
||||
assert resp.status_code == 200
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await serve_generated_image(sample_image, req_bob)
|
||||
assert exc_info.value.status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_freshly_generated_registry_preserves_access(sample_image):
|
||||
"""Freshly generated image registered to Alice is accessible before DB commit; Bob is denied."""
|
||||
req_alice = _make_request(current_user="alice")
|
||||
req_bob = _make_request(current_user="bob")
|
||||
|
||||
fresh_filename = "fedcba987654.png"
|
||||
from src import generated_images
|
||||
img_file = generated_images.GENERATED_IMAGE_DIR / fresh_filename
|
||||
img_file.write_bytes(b"\x89PNG\r\n\x1a\nfake-image-bytes")
|
||||
|
||||
register_generated_image(fresh_filename, "alice")
|
||||
|
||||
with patch("src.auth_helpers.auth_disabled", return_value=False), \
|
||||
patch("core.database.SessionLocal") as mock_sl:
|
||||
mock_sl.side_effect = AssertionError("DB should not be reached for registered owner")
|
||||
|
||||
# Alice retrieves without hitting DB
|
||||
resp = await serve_generated_image(fresh_filename, req_alice)
|
||||
assert resp.status_code == 200
|
||||
|
||||
# Bob is denied with 404
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await serve_generated_image(fresh_filename, req_bob)
|
||||
assert exc_info.value.status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_database_failure_fails_closed_503(sample_image):
|
||||
"""Database exception during ownership check raises HTTP 503."""
|
||||
req = _make_request(current_user="alice")
|
||||
|
||||
with patch("src.auth_helpers.auth_disabled", return_value=False), \
|
||||
patch("core.database.SessionLocal") as mock_sl:
|
||||
mock_db = MagicMock()
|
||||
mock_db.query.side_effect = RuntimeError("SQLite database locked or corrupted")
|
||||
mock_sl.return_value = mock_db
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await serve_generated_image(sample_image, req)
|
||||
assert exc_info.value.status_code == 503
|
||||
assert exc_info.value.detail == "Ownership verification unavailable"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_login_compatibility_auth_disabled(sample_image):
|
||||
"""When AUTH_ENABLED=false, existing images are served with 200, missing images return 404."""
|
||||
req = _make_request(current_user=None)
|
||||
|
||||
with patch("src.auth_helpers.auth_disabled", return_value=True):
|
||||
resp = await serve_generated_image(sample_image, req)
|
||||
assert resp.status_code == 200
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await serve_generated_image("aaaaaaaaaaaa.png", req)
|
||||
assert exc_info.value.status_code == 404
|
||||
|
||||
|
||||
def test_cache_headers_private_no_cache():
|
||||
"""Verify generated image response headers use private, no-cache directives."""
|
||||
assert GENERATED_IMAGE_HEADERS["Cache-Control"] == "private, no-cache, must-revalidate"
|
||||
assert GENERATED_IMAGE_HEADERS["X-Content-Type-Options"] == "nosniff"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_existence_oracle_eliminated_during_outage(sample_image):
|
||||
"""During a DB outage, both existing and non-existent files return 503 without revealing existence."""
|
||||
req = _make_request(current_user="alice")
|
||||
|
||||
with patch("src.auth_helpers.auth_disabled", return_value=False), \
|
||||
patch("core.database.SessionLocal") as mock_sl:
|
||||
mock_db = MagicMock()
|
||||
mock_db.query.side_effect = RuntimeError("DB down")
|
||||
mock_sl.return_value = mock_db
|
||||
|
||||
# Existing file -> 503
|
||||
with pytest.raises(HTTPException) as exc_existing:
|
||||
await serve_generated_image(sample_image, req)
|
||||
assert exc_existing.value.status_code == 503
|
||||
|
||||
# Non-existent file -> 503 (NOT 404)
|
||||
with pytest.raises(HTTPException) as exc_missing:
|
||||
await serve_generated_image("111122223333.png", req)
|
||||
assert exc_missing.value.status_code == 503
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mcp_image_generation_passes_owner_arg():
|
||||
"""_call_mcp_tool passes _odysseus_owner to MCP arguments for generate_image."""
|
||||
from src import tool_execution
|
||||
|
||||
mock_mcp = MagicMock()
|
||||
mock_mcp.call_tool = MagicMock()
|
||||
|
||||
async def fake_call(qualified, args):
|
||||
return {"stdout": "Generated image for: cat\nhttps://host/api/generated-image/1234567890ab.png", "exit_code": 0}
|
||||
|
||||
mock_mcp.call_tool.side_effect = fake_call
|
||||
|
||||
with patch.object(tool_execution, "get_mcp_manager", return_value=mock_mcp), \
|
||||
patch.object(tool_execution, "active_backend_operation", return_value=None):
|
||||
res = await tool_execution._call_mcp_tool("generate_image", "cat", owner="alice")
|
||||
assert res.get("image_url") == "https://host/api/generated-image/1234567890ab.png"
|
||||
call_args = mock_mcp.call_tool.call_args[0]
|
||||
assert call_args[1].get("_odysseus_owner") == "alice"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mcp_fallback_propagates_owner():
|
||||
"""When MCP manager is unavailable, _direct_fallback receives owner."""
|
||||
from src import tool_execution
|
||||
|
||||
with patch.object(tool_execution, "get_mcp_manager", return_value=None), \
|
||||
patch.object(tool_execution, "active_backend_operation", return_value=None), \
|
||||
patch.object(tool_execution, "_direct_fallback", return_value={"output": "ok", "exit_code": 0}) as mock_fallback:
|
||||
res = await tool_execution._call_mcp_tool("web_search", "query", owner="alice")
|
||||
assert res == {"output": "ok", "exit_code": 0}
|
||||
mock_fallback.assert_called_once_with("web_search", "query", progress_cb=None, owner="alice")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_trusted_owner_overrides_forged_owner_in_tool_args():
|
||||
"""Trusted owner overrides any forged _odysseus_owner in tool block content."""
|
||||
import json
|
||||
from src import tool_execution
|
||||
|
||||
mock_mcp = MagicMock()
|
||||
mock_mcp.call_tool = MagicMock()
|
||||
|
||||
async def fake_call(qualified, args):
|
||||
return {"stdout": "Generated image for: cat\n/api/generated-image/1234567890ab.png", "exit_code": 0}
|
||||
|
||||
mock_mcp.call_tool.side_effect = fake_call
|
||||
forged_content = json.dumps({"prompt": "cat", "_odysseus_owner": "bob"})
|
||||
|
||||
with patch.object(tool_execution, "get_mcp_manager", return_value=mock_mcp), \
|
||||
patch.object(tool_execution, "active_backend_operation", return_value=None):
|
||||
await tool_execution._call_mcp_tool("generate_image", forged_content, owner="alice")
|
||||
call_args = mock_mcp.call_tool.call_args[0]
|
||||
assert call_args[1].get("_odysseus_owner") == "alice"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_missing_or_empty_owner_removes_forged_owner_claim():
|
||||
"""When caller has no trusted owner (None or empty), forged _odysseus_owner is completely removed."""
|
||||
import json
|
||||
from src import tool_execution
|
||||
|
||||
mock_mcp = MagicMock()
|
||||
mock_mcp.call_tool = MagicMock()
|
||||
|
||||
async def fake_call(qualified, args):
|
||||
return {"stdout": "Generated image for: cat\n/api/generated-image/1234567890ab.png", "exit_code": 0}
|
||||
|
||||
mock_mcp.call_tool.side_effect = fake_call
|
||||
forged_content = json.dumps({"prompt": "cat", "_odysseus_owner": "bob"})
|
||||
|
||||
with patch.object(tool_execution, "get_mcp_manager", return_value=mock_mcp), \
|
||||
patch.object(tool_execution, "active_backend_operation", return_value=None):
|
||||
# Case 1: owner=None
|
||||
await tool_execution._call_mcp_tool("generate_image", forged_content, owner=None)
|
||||
call_args = mock_mcp.call_tool.call_args[0]
|
||||
assert "_odysseus_owner" not in call_args[1]
|
||||
|
||||
# Case 2: owner=""
|
||||
await tool_execution._call_mcp_tool("generate_image", forged_content, owner="")
|
||||
call_args = mock_mcp.call_tool.call_args[0]
|
||||
assert "_odysseus_owner" not in call_args[1]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_qualified_mcp_image_gen_sanitizes_forged_owner():
|
||||
"""Qualified mcp__image_gen__generate_image tool dispatches with sanitized owner."""
|
||||
import json
|
||||
from unittest.mock import AsyncMock
|
||||
from src import tool_execution
|
||||
from src.tool_types import ToolBlock
|
||||
|
||||
mock_mcp = MagicMock()
|
||||
mock_mcp.call_tool = AsyncMock(return_value={"stdout": "ok", "exit_code": 0})
|
||||
forged_content = json.dumps({"prompt": "cat", "_odysseus_owner": "bob"})
|
||||
|
||||
with patch.object(tool_execution, "get_mcp_manager", return_value=mock_mcp), \
|
||||
patch.object(tool_execution, "_owner_is_admin", return_value=True):
|
||||
# With trusted owner 'alice' -> sets 'alice'
|
||||
await tool_execution._execute_tool_block_impl(
|
||||
ToolBlock("mcp__image_gen__generate_image", forged_content),
|
||||
owner="alice",
|
||||
)
|
||||
call_args = mock_mcp.call_tool.call_args[0]
|
||||
assert call_args[1].get("_odysseus_owner") == "alice"
|
||||
|
||||
# Without owner -> removes forged owner
|
||||
await tool_execution._execute_tool_block_impl(
|
||||
ToolBlock("mcp__image_gen__generate_image", forged_content),
|
||||
owner=None,
|
||||
)
|
||||
call_args = mock_mcp.call_tool.call_args[0]
|
||||
assert "_odysseus_owner" not in call_args[1]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mcp_disconnected_fallback_does_not_resurrect_forged_owner():
|
||||
"""When MCP is disconnected, direct fallback receives verified owner context, not forged arguments."""
|
||||
import json
|
||||
from src import tool_execution
|
||||
|
||||
mock_mcp = MagicMock()
|
||||
mock_mcp.call_tool = MagicMock()
|
||||
|
||||
async def fake_call(qualified, args):
|
||||
return {"error": "Server not connected", "exit_code": 1}
|
||||
|
||||
mock_mcp.call_tool.side_effect = fake_call
|
||||
forged_content = json.dumps({"query": "python", "_odysseus_owner": "bob"})
|
||||
|
||||
with patch.object(tool_execution, "get_mcp_manager", return_value=mock_mcp), \
|
||||
patch.object(tool_execution, "active_backend_operation", return_value=None), \
|
||||
patch.object(tool_execution, "_direct_fallback", return_value={"output": "ok", "exit_code": 0}) as mock_fallback:
|
||||
# Trusted owner=None: mock_fallback receives owner=None
|
||||
await tool_execution._call_mcp_tool("web_search", forged_content, owner=None)
|
||||
mock_fallback.assert_called_with("web_search", forged_content, progress_cb=None, owner=None)
|
||||
|
||||
# Trusted owner="alice": mock_fallback receives owner="alice"
|
||||
await tool_execution._call_mcp_tool("web_search", forged_content, owner="alice")
|
||||
mock_fallback.assert_called_with("web_search", forged_content, progress_cb=None, owner="alice")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_image_gen_server_owner_sanitization():
|
||||
"""mcp_servers.image_gen_server sanitizes whitespace, empty, and None owner claims."""
|
||||
from unittest.mock import AsyncMock
|
||||
from mcp_servers import image_gen_server
|
||||
|
||||
with patch("src.database.SessionLocal") as mock_sl, \
|
||||
patch("httpx.AsyncClient") as mock_http, \
|
||||
patch("src.ai_interaction._resolve_model", return_value=("https://host/v1/chat/completions", "gpt-image-1", {})), \
|
||||
patch("src.settings.get_setting", return_value="dummy_key"), \
|
||||
patch("src.settings.load_settings", return_value={"image_model": "gpt-image-1"}), \
|
||||
patch("pathlib.Path.write_bytes"):
|
||||
mock_db = MagicMock()
|
||||
mock_sl.return_value = mock_db
|
||||
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.status_code = 200
|
||||
mock_resp.json.return_value = {
|
||||
"data": [{"b64_json": "aGVsbG8="}]
|
||||
}
|
||||
mock_client = MagicMock()
|
||||
mock_client.post = AsyncMock(return_value=mock_resp)
|
||||
mock_http.return_value.__aenter__.return_value = mock_client
|
||||
|
||||
# Call with whitespace owner: " " -> owner becomes None
|
||||
await image_gen_server.call_tool(
|
||||
"generate_image",
|
||||
{"prompt": "cat", "_odysseus_owner": " "},
|
||||
)
|
||||
added = mock_db.add.call_args[0][0]
|
||||
assert added.owner is None
|
||||
|
||||
# Call with valid owner: "alice" -> owner becomes "alice"
|
||||
await image_gen_server.call_tool(
|
||||
"generate_image",
|
||||
{"prompt": "cat", "_odysseus_owner": "alice"},
|
||||
)
|
||||
added = mock_db.add.call_args[0][0]
|
||||
assert added.owner == "alice"
|
||||
@@ -108,8 +108,12 @@ def _compact_prompt_for(monkeypatch, history):
|
||||
import src.model_context as model_context
|
||||
|
||||
monkeypatch.setattr(agent_runs, "is_active", lambda session_id: False)
|
||||
def fake_resolve_endpoint(kind, owner=None):
|
||||
def fake_resolve_endpoint(kind, owner=None, **kwargs):
|
||||
captured.setdefault("resolve_calls", []).append((kind, owner))
|
||||
if "fallback_url" in kwargs:
|
||||
captured.setdefault("fallback_calls", []).append(
|
||||
(kind, owner, kwargs)
|
||||
)
|
||||
return None, None, {}
|
||||
|
||||
monkeypatch.setattr(endpoint_resolver, "resolve_endpoint", fake_resolve_endpoint)
|
||||
@@ -127,6 +131,13 @@ def _compact_prompt_for(monkeypatch, history):
|
||||
assert response.status_code == 200
|
||||
assert response.json()["status"] == "ok"
|
||||
assert manager.saved is True
|
||||
assert any(
|
||||
kind == "utility"
|
||||
and kwargs.get("fallback_url") == session.endpoint_url
|
||||
and kwargs.get("fallback_model") == session.model
|
||||
and kwargs.get("fallback_headers") == session.headers
|
||||
for kind, _owner, kwargs in captured.get("fallback_calls", [])
|
||||
)
|
||||
return captured["messages"][1]["content"]
|
||||
|
||||
|
||||
@@ -151,8 +162,12 @@ def _registered_compact_response(monkeypatch, history, active_run=False):
|
||||
import src.llm_core as llm_core
|
||||
|
||||
monkeypatch.setattr(agent_runs, "is_active", lambda session_id: active_run)
|
||||
def fake_resolve_endpoint(kind, owner=None):
|
||||
def fake_resolve_endpoint(kind, owner=None, **kwargs):
|
||||
captured.setdefault("resolve_calls", []).append((kind, owner))
|
||||
if "fallback_url" in kwargs:
|
||||
captured.setdefault("fallback_calls", []).append(
|
||||
(kind, owner, kwargs)
|
||||
)
|
||||
return None, None, {}
|
||||
|
||||
monkeypatch.setattr(endpoint_resolver, "resolve_endpoint", fake_resolve_endpoint)
|
||||
@@ -237,6 +252,14 @@ def test_registered_manual_compact_route_uses_session_owner(monkeypatch):
|
||||
assert response.status_code == 200
|
||||
assert manager.replaced_messages is not None
|
||||
assert ("utility", "session-owner") in captured["resolve_calls"]
|
||||
assert any(
|
||||
kind == "utility"
|
||||
and owner == "session-owner"
|
||||
and kwargs.get("fallback_url") == manager.session.endpoint_url
|
||||
and kwargs.get("fallback_model") == manager.session.model
|
||||
and kwargs.get("fallback_headers") == manager.session.headers
|
||||
for kind, owner, kwargs in captured.get("fallback_calls", [])
|
||||
)
|
||||
|
||||
|
||||
def test_registered_manual_compact_route_rejects_active_agent_run(monkeypatch):
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
"""Invalid truncation requests must leave saved chat history intact."""
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from tests.helpers.database import isolated_session_database
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def history_client(tmp_path, monkeypatch):
|
||||
from core.models import ChatMessage
|
||||
from routes.history import history_routes
|
||||
from routes import session_routes
|
||||
|
||||
with isolated_session_database(tmp_path) as (manager, database):
|
||||
monkeypatch.setattr(history_routes, "SessionLocal", database.SessionLocal)
|
||||
monkeypatch.setattr(session_routes, "SessionLocal", database.SessionLocal)
|
||||
monkeypatch.setenv("AUTH_ENABLED", "true")
|
||||
manager.create_session("truncate-test", "Test", "http://example.test", "test", owner="alice")
|
||||
for text in ("first", "second", "third"):
|
||||
manager.add_message("truncate-test", ChatMessage("user", text))
|
||||
app = FastAPI()
|
||||
app.state.auth_manager = SimpleNamespace(is_configured=True)
|
||||
|
||||
@app.middleware("http")
|
||||
async def owner(request, call_next):
|
||||
request.state.current_user = request.headers.get("X-Test-Owner", "alice")
|
||||
return await call_next(request)
|
||||
|
||||
app.include_router(history_routes.setup_history_routes(manager))
|
||||
with TestClient(app, raise_server_exceptions=False) as client:
|
||||
yield client, manager, database
|
||||
|
||||
|
||||
def saved_messages(database):
|
||||
with database.SessionLocal() as db:
|
||||
return [row.content for row in db.query(database.ChatMessage).filter(
|
||||
database.ChatMessage.session_id == "truncate-test"
|
||||
).order_by(database.ChatMessage.timestamp).all()]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("payload", [
|
||||
{}, {"unrelated": 1}, {"before_msg_id": ""},
|
||||
{"keep_count": None}, {"keep_count": False}, {"keep_count": True},
|
||||
{"keep_count": 1.9}, {"keep_count": -1}, {"keep_count": "bad"},
|
||||
{"keep_count": []}, {"keep_count": {}},
|
||||
{"before_msg_id": True}, {"before_msg_id": ["message"]},
|
||||
{"before_msg_id": False}, {"before_msg_id": 0}, {"message_id": 0},
|
||||
[], None, "two", 2, False,
|
||||
])
|
||||
def test_invalid_target_preserves_history(history_client, payload):
|
||||
client, manager, database = history_client
|
||||
import json
|
||||
response = client.post("/api/session/truncate-test/truncate", content=json.dumps(payload), headers={"Content-Type": "application/json"})
|
||||
assert response.status_code == 400, response.text
|
||||
assert saved_messages(database) == ["first", "second", "third"]
|
||||
assert [m.content for m in manager.get_session("truncate-test").history] == ["first", "second", "third"]
|
||||
|
||||
|
||||
def test_malformed_json_preserves_history(history_client):
|
||||
client, _, database = history_client
|
||||
response = client.post("/api/session/truncate-test/truncate", content="{", headers={"Content-Type": "application/json"})
|
||||
assert response.status_code == 400, response.text
|
||||
assert saved_messages(database) == ["first", "second", "third"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("count,expected", [(0, []), (2, ["first", "second"]), (9, ["first", "second", "third"]), ("2", ["first", "second"])])
|
||||
def test_explicit_count_remains_supported(history_client, count, expected):
|
||||
client, _, database = history_client
|
||||
response = client.post("/api/session/truncate-test/truncate", json={"keep_count": count})
|
||||
assert response.status_code == 200, response.text
|
||||
assert saved_messages(database) == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize("field", ["before_msg_id", "message_id"])
|
||||
def test_message_id_boundary_remains_supported(history_client, field):
|
||||
client, _, database = history_client
|
||||
with database.SessionLocal() as db:
|
||||
message_id = db.query(database.ChatMessage).filter(database.ChatMessage.content == "second").one().id
|
||||
response = client.post("/api/session/truncate-test/truncate", json={field: message_id})
|
||||
assert response.status_code == 200, response.text
|
||||
assert saved_messages(database) == ["first"]
|
||||
|
||||
|
||||
def test_missing_message_id_does_not_clear_history(history_client):
|
||||
client, _, database = history_client
|
||||
response = client.post("/api/session/truncate-test/truncate", json={"before_msg_id": "missing"})
|
||||
assert response.status_code == 404
|
||||
assert saved_messages(database) == ["first", "second", "third"]
|
||||
|
||||
|
||||
def test_owner_gate_still_precedes_validation(history_client):
|
||||
client, _, database = history_client
|
||||
response = client.post("/api/session/truncate-test/truncate", json={}, headers={"X-Test-Owner": "bob"})
|
||||
assert response.status_code == 404
|
||||
assert saved_messages(database) == ["first", "second", "third"]
|
||||
@@ -120,8 +120,9 @@ def test_disabled_generation_is_not_restored():
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize('incognito', [False, True])
|
||||
@pytest.mark.parametrize('exit_code', [None, 0])
|
||||
async def test_generation_dispatch_uses_owner_aware_backend(monkeypatch, exit_code):
|
||||
async def test_generation_dispatch_uses_owner_aware_backend(monkeypatch, exit_code, incognito):
|
||||
from types import SimpleNamespace
|
||||
from src import ai_interaction, tool_execution
|
||||
from src.agent_runtime.journal import ActionJournal, bind_journal
|
||||
@@ -145,6 +146,7 @@ async def test_generation_dispatch_uses_owner_aware_backend(monkeypatch, exit_co
|
||||
_, denied = await execute(block, owner='pewds', session_id='fixture',
|
||||
disabled_tools={'generate_image'}, security_context=tool_execution.NO_TOOL_SECURITY_CONTEXT)
|
||||
_, result = await execute(block, owner='pewds', session_id='fixture',
|
||||
client_runtime_context={'web_ui_state': {'incognito': incognito}},
|
||||
security_context=tool_execution.NO_TOOL_SECURITY_CONTEXT)
|
||||
assert denied['exit_code'] != 0
|
||||
assert journal.actions[0].execution_id is None
|
||||
@@ -152,7 +154,7 @@ async def test_generation_dispatch_uses_owner_aware_backend(monkeypatch, exit_co
|
||||
assert journal.actions[1].execution_id is not None
|
||||
assert journal.actions[1].outcome['authoritative'] is (exit_code == 0)
|
||||
assert result['image_id'] == 'test'
|
||||
assert calls == [(block.content, {'owner': 'pewds', 'session_id': 'fixture'})]
|
||||
assert calls == [(block.content, {'owner': 'pewds', 'session_id': 'fixture', **({'incognito': True} if incognito else {})})]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
"""The heavy image-edit CPU work must run off the event loop, and the app must
|
||||
expose in-flight heavy-op counts + a graceful shutdown so a deploy restart does
|
||||
not drop a 60-150 s edit."""
|
||||
import base64
|
||||
import io
|
||||
from pathlib import Path
|
||||
|
||||
from PIL import Image
|
||||
|
||||
from routes.gallery import gallery_routes as gr
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def _png_b64(w, h, color=(120, 160, 210)):
|
||||
buf = io.BytesIO()
|
||||
Image.new("RGBA", (w, h), color).save(buf, format="PNG")
|
||||
return base64.b64encode(buf.getvalue()).decode("ascii")
|
||||
|
||||
|
||||
def test_prepare_local_edit_caps_large_input():
|
||||
src, mask, mw, mh, orig = gr._prepare_local_edit(_png_b64(4000, 3000), None, 768)
|
||||
assert orig == (4000, 3000)
|
||||
assert max(mw, mh) <= 768 and mw % 16 == 0 and mh % 16 == 0
|
||||
assert mask # a full-white mask is synthesised when none is supplied
|
||||
assert Image.open(io.BytesIO(src)).size == (mw, mh)
|
||||
|
||||
|
||||
def test_prepare_local_edit_leaves_small_input_unchanged():
|
||||
_src, _mask, mw, mh, orig = gr._prepare_local_edit(_png_b64(96, 96), _png_b64(96, 96, (255, 255, 255)), 768)
|
||||
assert orig == (96, 96) and (mw, mh) == (96, 96)
|
||||
|
||||
|
||||
def test_restore_local_b64_resizes_back_to_original():
|
||||
raw = _png_b64(768, 576)
|
||||
out = gr._restore_local_b64(raw, (4000, 3000), (768, 576))
|
||||
assert Image.open(io.BytesIO(base64.b64decode(out))).size == (4000, 3000)
|
||||
# a same-size result is returned untouched
|
||||
assert gr._restore_local_b64(raw, (768, 576), (768, 576)) == raw
|
||||
|
||||
|
||||
def test_inpaint_offloads_cpu_work_to_a_thread():
|
||||
src = (ROOT / "routes/gallery/gallery_routes.py").read_text()
|
||||
assert "await asyncio.to_thread(\n _prepare_local_edit" in src
|
||||
assert "asyncio.to_thread(\n _restore_local_b64" in src
|
||||
|
||||
|
||||
def test_app_exposes_busy_count_and_graceful_shutdown():
|
||||
src = (ROOT / "app.py").read_text()
|
||||
assert "busy_image_ops" in src
|
||||
assert '@app.get("/api/busy")' in src
|
||||
assert "_HEAVY_IMAGE_OP_PREFIXES" in src
|
||||
assert "timeout_graceful_shutdown" in src
|
||||
assert "activity.begin_image_op" in src and "activity.end_image_op" in src
|
||||
@@ -33,6 +33,38 @@ def test_native_generation_respects_admin_disable(monkeypatch):
|
||||
assert 'disabled' in result['error']
|
||||
|
||||
|
||||
@pytest.mark.parametrize('editing', [False, True])
|
||||
def test_incognito_images_never_write_gallery_or_files(image_transport, editing):
|
||||
configure, source, requests, rows = image_transport
|
||||
configure('https://openrouter.ai/api/v1')
|
||||
if editing:
|
||||
result = asyncio.run(ai_interaction.do_edit_image(
|
||||
'Private edit', str(source), model_spec='openai/gpt-5-image',
|
||||
owner='alice', session_id='private-chat', incognito=True))
|
||||
else:
|
||||
result = asyncio.run(ai_interaction.do_generate_image(
|
||||
'Private image\nopenai/gpt-5-image', owner='alice',
|
||||
session_id='private-chat', incognito=True))
|
||||
assert 'error' not in result
|
||||
assert result['image_url'].startswith('data:image/png;base64,')
|
||||
assert base64.b64decode(result['image_url'].split(',', 1)[1]) == source.read_bytes()
|
||||
assert not result['image_id']
|
||||
assert not rows
|
||||
assert not (source.parent / 'generated').exists()
|
||||
|
||||
|
||||
def test_incognito_url_image_is_not_persisted(image_transport, monkeypatch):
|
||||
configure, source, requests, rows = image_transport
|
||||
configure('https://api.openai.com/v1', body={'data': [{'url': 'https://example.com/output.png'}]})
|
||||
monkeypatch.setattr(httpx, 'get', lambda *args, **kwargs: httpx.Response(200, content=source.read_bytes()))
|
||||
monkeypatch.setattr('src.url_safety.check_outbound_url', lambda *args, **kwargs: (True, ''))
|
||||
result = asyncio.run(ai_interaction.do_generate_image(
|
||||
'Private image\nopenai/gpt-5-image', owner='alice', incognito=True))
|
||||
assert result['image_url'].startswith('data:image/png;base64,')
|
||||
assert not rows
|
||||
assert not (source.parent / 'generated').exists()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def image_transport(monkeypatch, tmp_path):
|
||||
rows = []
|
||||
|
||||
@@ -0,0 +1,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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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):
|
||||
|
||||
@@ -264,3 +264,231 @@ def test_launchers_export_the_port_they_serve_on():
|
||||
text = (_repo_root() / name).read_text(encoding="utf-8")
|
||||
assert uvicorn_flag in text, f"{name}: launcher no longer passes {uvicorn_flag}"
|
||||
assert export in text, f"{name}: serves on a port the app cannot read back"
|
||||
|
||||
|
||||
# ── Callback & Exchange Expired State Handling ───────────────────
|
||||
|
||||
def test_oauth_callback_expired_state_returns_dedicated_expired_page(monkeypatch):
|
||||
"""An unknown or expired generic MCP OAuth state in the callback must return
|
||||
the dedicated 'Authorization Expired' page instead of a bare 403 'Admin only'
|
||||
or a 404 'Server not found'."""
|
||||
from unittest.mock import MagicMock
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from fastapi.testclient import TestClient
|
||||
from routes.mcp import mcp_routes
|
||||
|
||||
# Simulate unauthenticated request that would fail admin check if invoked
|
||||
def _deny_admin(request):
|
||||
raise HTTPException(403, "Admin only")
|
||||
|
||||
monkeypatch.setattr(mcp_routes, "require_admin", _deny_admin)
|
||||
|
||||
# Empty DB / no matching server
|
||||
class FakeQuery:
|
||||
def filter(self, *a):
|
||||
return self
|
||||
|
||||
def first(self):
|
||||
return None
|
||||
|
||||
class FakeSession:
|
||||
def query(self, *a):
|
||||
return FakeQuery()
|
||||
|
||||
def close(self):
|
||||
pass
|
||||
|
||||
monkeypatch.setattr(mcp_routes, "SessionLocal", lambda: FakeSession())
|
||||
|
||||
app = FastAPI()
|
||||
manager = MagicMock()
|
||||
router = mcp_routes.setup_mcp_routes(manager)
|
||||
app.include_router(router)
|
||||
client = TestClient(app)
|
||||
|
||||
# Both code and state are provided, but state is not in _pending (expired/unknown)
|
||||
resp = client.get("/api/mcp/oauth/callback?code=some-code&state=expired-state-123")
|
||||
assert resp.status_code == 400
|
||||
assert "Authorization Expired" in resp.text
|
||||
assert (
|
||||
"This authorization round has expired. Press Reconnect in Odysseus and try again."
|
||||
in resp.text
|
||||
)
|
||||
# Ensure it did not return the bare 403 "Admin only"
|
||||
assert "Admin only" not in resp.text
|
||||
|
||||
|
||||
def test_oauth_callback_active_pending_state_succeeds_without_admin(monkeypatch):
|
||||
"""An active pending generic MCP OAuth state resolves without requiring admin auth."""
|
||||
from unittest.mock import MagicMock
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from fastapi.testclient import TestClient
|
||||
from routes.mcp import mcp_routes
|
||||
|
||||
# Admin check fails if invoked
|
||||
def _deny_admin(request):
|
||||
raise HTTPException(403, "Admin only")
|
||||
|
||||
monkeypatch.setattr(mcp_routes, "require_admin", _deny_admin)
|
||||
|
||||
app = FastAPI()
|
||||
manager = MagicMock()
|
||||
router = mcp_routes.setup_mcp_routes(manager)
|
||||
app.include_router(router)
|
||||
client = TestClient(app)
|
||||
|
||||
async def go():
|
||||
fut = mcp_oauth.register_pending("pending-round-1")
|
||||
resp = client.get("/api/mcp/oauth/callback?code=auth-code-xyz&state=pending-round-1")
|
||||
assert resp.status_code == 200
|
||||
assert "Authorization Successful" in resp.text
|
||||
code, state = await asyncio.wait_for(fut, timeout=1)
|
||||
assert code == "auth-code-xyz"
|
||||
assert state == "pending-round-1"
|
||||
|
||||
asyncio.run(go())
|
||||
|
||||
|
||||
def test_oauth_callback_legacy_google_server_requires_admin(monkeypatch):
|
||||
"""Legacy Google OAuth callbacks (where state is server_id of a server with
|
||||
oauth_config) still enforce require_admin."""
|
||||
import json
|
||||
from unittest.mock import MagicMock
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from fastapi.testclient import TestClient
|
||||
from routes.mcp import mcp_routes
|
||||
from core.database import McpServer
|
||||
|
||||
# Server exists in DB with Google oauth_config
|
||||
google_srv = McpServer(
|
||||
id="google-srv-1",
|
||||
name="Google Workspace",
|
||||
oauth_config=json.dumps({"provider": "google", "keys_file": "k.json", "token_file": "t.json"}),
|
||||
)
|
||||
|
||||
class FakeQuery:
|
||||
def filter(self, *a):
|
||||
return self
|
||||
|
||||
def first(self):
|
||||
return google_srv
|
||||
|
||||
class FakeSession:
|
||||
def query(self, *a):
|
||||
return FakeQuery()
|
||||
|
||||
def close(self):
|
||||
pass
|
||||
|
||||
monkeypatch.setattr(mcp_routes, "SessionLocal", lambda: FakeSession())
|
||||
|
||||
# When require_admin denies, 403 is raised for the legacy Google path
|
||||
def _deny_admin(request):
|
||||
raise HTTPException(403, "Admin only")
|
||||
|
||||
monkeypatch.setattr(mcp_routes, "require_admin", _deny_admin)
|
||||
|
||||
app = FastAPI()
|
||||
manager = MagicMock()
|
||||
router = mcp_routes.setup_mcp_routes(manager)
|
||||
app.include_router(router)
|
||||
client = TestClient(app)
|
||||
|
||||
resp = client.get("/api/mcp/oauth/callback?code=code-123&state=google-srv-1")
|
||||
assert resp.status_code == 403
|
||||
assert "Admin only" in resp.text
|
||||
|
||||
|
||||
def test_oauth_exchange_expired_generic_state_returns_expired_page(monkeypatch):
|
||||
"""Remote paste-back flow for a generic MCP server returns the dedicated expired
|
||||
page when the state is no longer pending, instead of 'No OAuth config.'."""
|
||||
from unittest.mock import MagicMock
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
from routes.mcp import mcp_routes
|
||||
from core.database import McpServer
|
||||
|
||||
monkeypatch.setattr(mcp_routes, "require_admin", lambda r: None)
|
||||
|
||||
# Server exists in DB without oauth_config (generic MCP server)
|
||||
generic_srv = McpServer(
|
||||
id="http-srv-1",
|
||||
name="Remote HTTP MCP",
|
||||
oauth_config=None,
|
||||
)
|
||||
|
||||
class FakeQuery:
|
||||
def filter(self, *a):
|
||||
return self
|
||||
|
||||
def first(self):
|
||||
return generic_srv
|
||||
|
||||
class FakeSession:
|
||||
def query(self, *a):
|
||||
return FakeQuery()
|
||||
|
||||
def close(self):
|
||||
pass
|
||||
|
||||
monkeypatch.setattr(mcp_routes, "SessionLocal", lambda: FakeSession())
|
||||
|
||||
app = FastAPI()
|
||||
manager = MagicMock()
|
||||
router = mcp_routes.setup_mcp_routes(manager)
|
||||
app.include_router(router)
|
||||
client = TestClient(app)
|
||||
|
||||
resp = client.post(
|
||||
"/api/mcp/oauth/exchange/http-srv-1",
|
||||
data={"callback_url": "http://localhost:7000/api/mcp/oauth/callback?code=code-1&state=expired-state"},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
assert "Authorization Expired" in resp.text
|
||||
assert (
|
||||
"This authorization round has expired. Press Reconnect in Odysseus and try again."
|
||||
in resp.text
|
||||
)
|
||||
assert "No OAuth config." not in resp.text
|
||||
|
||||
|
||||
def test_oauth_callback_missing_state_returns_malformed_error_not_expired():
|
||||
"""Missing state parameter in callback must return a bad-request error and must
|
||||
not be treated as an expired authorization round."""
|
||||
from unittest.mock import MagicMock
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
from routes.mcp import mcp_routes
|
||||
|
||||
app = FastAPI()
|
||||
manager = MagicMock()
|
||||
router = mcp_routes.setup_mcp_routes(manager)
|
||||
app.include_router(router)
|
||||
client = TestClient(app)
|
||||
|
||||
resp = client.get("/api/mcp/oauth/callback?code=some-code")
|
||||
assert resp.status_code == 400
|
||||
assert "No OAuth state parameter found in the callback URL." in resp.text
|
||||
assert "Authorization Expired" not in resp.text
|
||||
assert "This authorization round has expired" not in resp.text
|
||||
|
||||
|
||||
def test_oauth_callback_missing_code_returns_malformed_error_not_expired():
|
||||
"""Missing code parameter in callback must return a bad-request error and must
|
||||
not be treated as an expired authorization round."""
|
||||
from unittest.mock import MagicMock
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
from routes.mcp import mcp_routes
|
||||
|
||||
app = FastAPI()
|
||||
manager = MagicMock()
|
||||
router = mcp_routes.setup_mcp_routes(manager)
|
||||
app.include_router(router)
|
||||
client = TestClient(app)
|
||||
|
||||
resp = client.get("/api/mcp/oauth/callback?state=some-state")
|
||||
assert resp.status_code == 400
|
||||
assert "No authorization code found in the URL." in resp.text
|
||||
assert "Authorization Expired" not in resp.text
|
||||
assert "This authorization round has expired" not in resp.text
|
||||
|
||||
@@ -0,0 +1,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)
|
||||
|
||||
@@ -0,0 +1,296 @@
|
||||
"""Regression tests for SSRF hardening on model endpoint routes.
|
||||
|
||||
Verifies that POST /model-endpoints/test, POST /model-endpoints, and
|
||||
PATCH /model-endpoints/{ep_id} reject user-supplied URLs that resolve to
|
||||
link-local (cloud metadata), non-HTTP schemes, or (when
|
||||
MODELENDPOINT_BLOCK_PRIVATE_IPS=true) private/loopback addresses — matching
|
||||
the existing SSRF guard pattern in embedding_routes.py.
|
||||
|
||||
A stub resolver is injected so the loopback tests never touch real DNS.
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
# ── Resolver stub (same pattern as tests/test_url_safety.py) ────────────────
|
||||
def _resolver(mapping):
|
||||
"""Create a stub resolver for deterministic DNS in tests."""
|
||||
def resolve(host):
|
||||
if host in mapping:
|
||||
return mapping[host]
|
||||
raise OSError(f"unresolvable: {host}")
|
||||
return resolve
|
||||
|
||||
|
||||
LOCALHOST_V4 = _resolver({"localhost": ["127.0.0.1"]})
|
||||
LOCALHOST_V6 = _resolver({"localhost": ["::1"]})
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(monkeypatch):
|
||||
"""Build a TestClient with auth disabled and minimal DB stubs."""
|
||||
monkeypatch.setenv("AUTH_ENABLED", "false")
|
||||
monkeypatch.setenv("DATABASE_URL", "sqlite:///:memory:")
|
||||
|
||||
# Stub heavy deps that model_routes imports at module level.
|
||||
# Use monkeypatch so entries are removed after the test.
|
||||
for mod in [
|
||||
"src.tls_overrides",
|
||||
"src.llm_core",
|
||||
"src.settings",
|
||||
"src.constants",
|
||||
"core.log_safety",
|
||||
]:
|
||||
if mod not in sys.modules:
|
||||
monkeypatch.setitem(sys.modules, mod, MagicMock())
|
||||
|
||||
# Provide real endpoint_resolver so the route logic works
|
||||
if "src.endpoint_resolver" not in sys.modules or isinstance(
|
||||
sys.modules["src.endpoint_resolver"], MagicMock
|
||||
):
|
||||
er = MagicMock()
|
||||
er.normalize_base = lambda url: url
|
||||
er.resolve_url = lambda url: url
|
||||
er.build_chat_url = lambda base, **kw: base
|
||||
er.build_models_url = lambda base, **kw: base + "/v1/models"
|
||||
er.build_headers = lambda base, **kw: {}
|
||||
monkeypatch.setitem(sys.modules, "src.endpoint_resolver", er)
|
||||
|
||||
# Clear cached model_routes module so it picks up our stubs
|
||||
monkeypatch.delitem(sys.modules, "routes.model_routes", raising=False)
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
import routes.model_routes as mr
|
||||
from fastapi import FastAPI
|
||||
|
||||
app = FastAPI()
|
||||
router = mr.setup_model_routes(model_discovery=None)
|
||||
app.include_router(router)
|
||||
app.state.auth_enabled = False
|
||||
return TestClient(app, raise_server_exceptions=False)
|
||||
|
||||
|
||||
class TestModelEndpointSSRF:
|
||||
"""SSRF protection for POST /model-endpoints/test."""
|
||||
|
||||
def test_cloud_metadata_blocked(self, client):
|
||||
"""Cloud metadata IP (169.254.169.254) must be rejected."""
|
||||
resp = client.post(
|
||||
"/api/model-endpoints/test",
|
||||
data={"base_url": "http://169.254.169.254/latest/meta-data/"},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
assert "Rejected" in resp.json()["detail"]
|
||||
|
||||
def test_non_http_scheme_blocked(self, client):
|
||||
"""Non-HTTP schemes (file://, ftp://) must be rejected."""
|
||||
resp = client.post(
|
||||
"/api/model-endpoints/test",
|
||||
data={"base_url": "file:///etc/passwd"},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
assert "Rejected" in resp.json()["detail"]
|
||||
|
||||
def test_loopback_accepted_by_default(self, client):
|
||||
"""Local-first: loopback is accepted when block_private=False."""
|
||||
with patch("src.url_safety._default_resolver", LOCALHOST_V4):
|
||||
resp = client.post(
|
||||
"/api/model-endpoints/test",
|
||||
data={"base_url": "http://localhost:11434/v1"},
|
||||
)
|
||||
# Guard passes; probe fails (no server) but that is not SSRF.
|
||||
assert "Rejected endpoint URL" not in str(resp.json())
|
||||
|
||||
def test_loopback_rejected_in_strict_mode(self, client):
|
||||
"""Strict mode: loopback is rejected when block_private=True."""
|
||||
os.environ["MODELENDPOINT_BLOCK_PRIVATE_IPS"] = "true"
|
||||
try:
|
||||
with patch("src.url_safety._default_resolver", LOCALHOST_V4):
|
||||
resp = client.post(
|
||||
"/api/model-endpoints/test",
|
||||
data={"base_url": "http://localhost:11434/v1"},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
assert "Rejected" in resp.json()["detail"]
|
||||
finally:
|
||||
os.environ.pop("MODELENDPOINT_BLOCK_PRIVATE_IPS", None)
|
||||
|
||||
def test_strict_mode_blocks_direct_loopback_ip(self, client):
|
||||
"""Strict mode rejects 127.0.0.1 passed directly (no DNS needed)."""
|
||||
os.environ["MODELENDPOINT_BLOCK_PRIVATE_IPS"] = "true"
|
||||
try:
|
||||
resp = client.post(
|
||||
"/api/model-endpoints/test",
|
||||
data={"base_url": "http://127.0.0.1:11434/v1"},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
assert "Rejected" in resp.json()["detail"]
|
||||
finally:
|
||||
os.environ.pop("MODELENDPOINT_BLOCK_PRIVATE_IPS", None)
|
||||
|
||||
def test_ipv6_loopback_literal_accepted_by_default(self, client):
|
||||
"""::1 is loopback, not a disallowed 'reserved' address (dfd1f7e7)."""
|
||||
resp = client.post(
|
||||
"/api/model-endpoints/test",
|
||||
data={"base_url": "http://[::1]:11434/v1"},
|
||||
)
|
||||
assert "Rejected endpoint URL" not in str(resp.json())
|
||||
|
||||
def test_ipv6_loopback_localhost_accepted_by_default(self, client):
|
||||
"""Dual-stack hosts whose resolver answers ::1 first must work."""
|
||||
with patch("src.url_safety._default_resolver", LOCALHOST_V6):
|
||||
resp = client.post(
|
||||
"/api/model-endpoints/test",
|
||||
data={"base_url": "http://localhost:11434/v1"},
|
||||
)
|
||||
assert "Rejected endpoint URL" not in str(resp.json())
|
||||
|
||||
def test_ipv6_loopback_rejected_in_strict_mode(self, client):
|
||||
"""Strict mode still rejects ::1."""
|
||||
os.environ["MODELENDPOINT_BLOCK_PRIVATE_IPS"] = "true"
|
||||
try:
|
||||
resp = client.post(
|
||||
"/api/model-endpoints/test",
|
||||
data={"base_url": "http://[::1]:11434/v1"},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
assert "Rejected" in resp.json()["detail"]
|
||||
finally:
|
||||
os.environ.pop("MODELENDPOINT_BLOCK_PRIVATE_IPS", None)
|
||||
|
||||
|
||||
class TestModelEndpointCreateSSRF:
|
||||
"""SSRF protection for POST /model-endpoints."""
|
||||
|
||||
def test_cloud_metadata_blocked(self, client):
|
||||
"""Cloud metadata IP must be rejected on endpoint creation."""
|
||||
resp = client.post(
|
||||
"/api/model-endpoints",
|
||||
data={"base_url": "http://169.254.169.254/latest/meta-data/"},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
assert "Rejected" in resp.json()["detail"]
|
||||
|
||||
def test_non_http_scheme_blocked(self, client):
|
||||
"""Non-HTTP schemes must be rejected on endpoint creation."""
|
||||
resp = client.post(
|
||||
"/api/model-endpoints",
|
||||
data={"base_url": "gopher://evil:6379/secret"},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
assert "Rejected" in resp.json()["detail"]
|
||||
|
||||
def test_loopback_accepted_by_default(self, client):
|
||||
"""Local-first: loopback is accepted when block_private=False."""
|
||||
with patch("src.url_safety._default_resolver", LOCALHOST_V4):
|
||||
resp = client.post(
|
||||
"/api/model-endpoints",
|
||||
data={"base_url": "http://localhost:11434/v1"},
|
||||
)
|
||||
# Guard passes; route may 500 on DB/probe but never 400 SSRF.
|
||||
assert resp.status_code != 400
|
||||
body = resp.text if resp.status_code == 500 else str(resp.json())
|
||||
assert "Rejected endpoint URL" not in body
|
||||
|
||||
def test_loopback_rejected_in_strict_mode(self, client):
|
||||
"""Strict mode: loopback is rejected when block_private=True."""
|
||||
os.environ["MODELENDPOINT_BLOCK_PRIVATE_IPS"] = "true"
|
||||
try:
|
||||
with patch("src.url_safety._default_resolver", LOCALHOST_V4):
|
||||
resp = client.post(
|
||||
"/api/model-endpoints",
|
||||
data={"base_url": "http://localhost:11434/v1"},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
assert "Rejected" in resp.json()["detail"]
|
||||
finally:
|
||||
os.environ.pop("MODELENDPOINT_BLOCK_PRIVATE_IPS", None)
|
||||
|
||||
|
||||
class TestModelEndpointPatchSSRF:
|
||||
"""SSRF protection for PATCH /api/model-endpoints/{ep_id} (base_url).
|
||||
|
||||
The update path rewrites the stored base_url, which later fetches probe,
|
||||
list models and serve chat through — it must pass the same guard as the
|
||||
POST routes instead of bypassing it.
|
||||
"""
|
||||
|
||||
def _install_fake_endpoint(self, monkeypatch, base_url="http://localhost:9999/v1"):
|
||||
import routes.model_routes as mr
|
||||
|
||||
ep = SimpleNamespace(
|
||||
id="ep1",
|
||||
name="EP",
|
||||
base_url=base_url,
|
||||
api_key=None,
|
||||
is_enabled=True,
|
||||
owner=None,
|
||||
hidden_models=None,
|
||||
cached_models=None,
|
||||
pinned_models=None,
|
||||
model_type="llm",
|
||||
supports_tools=None,
|
||||
endpoint_kind="auto",
|
||||
model_refresh_mode="auto",
|
||||
model_refresh_interval=None,
|
||||
model_refresh_timeout=None,
|
||||
created_at=None,
|
||||
updated_at=None,
|
||||
)
|
||||
|
||||
class _FakeDb:
|
||||
def query(self, _model):
|
||||
return self
|
||||
|
||||
def filter(self, *_args, **_kwargs):
|
||||
return self
|
||||
|
||||
def first(self):
|
||||
return ep
|
||||
|
||||
def commit(self):
|
||||
pass
|
||||
|
||||
def close(self):
|
||||
pass
|
||||
|
||||
monkeypatch.setattr(mr, "SessionLocal", lambda: _FakeDb())
|
||||
monkeypatch.setattr(mr, "require_admin", lambda request: None)
|
||||
monkeypatch.setattr(mr, "_chatgpt_endpoint_visible", lambda ep_, request: True)
|
||||
return ep
|
||||
|
||||
def test_patch_cloud_metadata_blocked(self, client, monkeypatch):
|
||||
"""PATCH must reject the cloud metadata URL, not store it."""
|
||||
ep = self._install_fake_endpoint(monkeypatch)
|
||||
resp = client.patch(
|
||||
"/api/model-endpoints/ep1",
|
||||
json={"base_url": "http://169.254.169.254/latest/meta-data"},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
assert "Rejected endpoint URL" in resp.json()["detail"]
|
||||
assert ep.base_url == "http://localhost:9999/v1"
|
||||
|
||||
def test_patch_non_http_scheme_blocked(self, client, monkeypatch):
|
||||
"""PATCH must reject non-HTTP schemes."""
|
||||
ep = self._install_fake_endpoint(monkeypatch)
|
||||
resp = client.patch(
|
||||
"/api/model-endpoints/ep1",
|
||||
json={"base_url": "file:///etc/passwd"},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
assert "Rejected endpoint URL" in resp.json()["detail"]
|
||||
assert ep.base_url == "http://localhost:9999/v1"
|
||||
|
||||
def test_patch_valid_url_accepted(self, client, monkeypatch):
|
||||
"""Control: a legitimate local endpoint URL still updates fine."""
|
||||
ep = self._install_fake_endpoint(monkeypatch)
|
||||
resp = client.patch(
|
||||
"/api/model-endpoints/ep1",
|
||||
json={"base_url": "http://127.0.0.1:11434/v1"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert ep.base_url == "http://127.0.0.1:11434/v1"
|
||||
@@ -36,7 +36,26 @@ def test_task_parse_resolves_with_owner_scope():
|
||||
def test_history_compact_resolves_with_owner_scope():
|
||||
body = _function_source("routes/history/history_routes.py", "compact_session")
|
||||
assert "owner = effective_user(request)" in body
|
||||
assert 'resolve_endpoint("utility", owner=owner or None)' in body
|
||||
tree = ast.parse(body)
|
||||
calls = [
|
||||
node for node in ast.walk(tree)
|
||||
if isinstance(node, ast.Call)
|
||||
and isinstance(node.func, ast.Name)
|
||||
and node.func.id == "resolve_endpoint"
|
||||
and node.args
|
||||
and isinstance(node.args[0], ast.Constant)
|
||||
and node.args[0].value == "utility"
|
||||
]
|
||||
assert len(calls) == 1
|
||||
|
||||
kwargs = {
|
||||
kw.arg: ast.unparse(kw.value)
|
||||
for kw in calls[0].keywords
|
||||
}
|
||||
assert kwargs["owner"] == "owner or None"
|
||||
assert kwargs["fallback_url"] == "getattr(session, 'endpoint_url', None)"
|
||||
assert kwargs["fallback_model"] == "getattr(session, 'model', None)"
|
||||
assert kwargs["fallback_headers"] == "getattr(session, 'headers', None)"
|
||||
|
||||
|
||||
def test_note_reminder_synthesis_resolves_with_owner_scope():
|
||||
|
||||
@@ -1281,6 +1281,7 @@ def _patch_create_deps(monkeypatch, db, settings=None):
|
||||
monkeypatch.setattr(model_routes, "_save_settings", lambda s: settings.update(s))
|
||||
monkeypatch.setattr(endpoint_resolver, "resolve_url", lambda u: u)
|
||||
monkeypatch.setattr(auth_helpers, "get_current_user", lambda req: None)
|
||||
monkeypatch.setattr(model_routes, "check_outbound_url", lambda *a, **k: (True, "ok"))
|
||||
return settings
|
||||
|
||||
|
||||
@@ -2000,6 +2001,7 @@ def test_explicit_proxy_test_fetches_models_with_long_timeout(monkeypatch):
|
||||
|
||||
monkeypatch.setattr(model_routes, "require_admin", lambda request: None)
|
||||
monkeypatch.setattr(model_routes, "_ping_endpoint", lambda *a, **k: (_ for _ in ()).throw(AssertionError("ping should not run when model listing succeeds")))
|
||||
monkeypatch.setattr(model_routes, "check_outbound_url", lambda *a, **k: (True, "ok"))
|
||||
|
||||
calls = []
|
||||
returned = ["NVIDIA NIM/openai/gpt-oss-120b", "mistral/mistral-small-2603"]
|
||||
@@ -2038,6 +2040,7 @@ def test_explicit_proxy_add_fetches_and_caches_models_with_long_timeout(monkeypa
|
||||
monkeypatch.setattr(model_routes, "_save_settings", lambda settings: None)
|
||||
monkeypatch.setattr("src.auth_helpers.get_current_user", lambda request: None)
|
||||
monkeypatch.setattr(model_routes, "_ping_endpoint", lambda *a, **k: (_ for _ in ()).throw(AssertionError("ping should not run when model listing succeeds")))
|
||||
monkeypatch.setattr(model_routes, "check_outbound_url", lambda *a, **k: (True, "ok"))
|
||||
|
||||
calls = []
|
||||
returned = ["NVIDIA NIM/openai/gpt-oss-120b", "mistral/mistral-small-2603"]
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
from tests.helpers.notes_source import notes_source
|
||||
from pathlib import Path
|
||||
from tests.helpers.document_source import document_source
|
||||
|
||||
@@ -6,7 +7,7 @@ ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def test_opening_notes_minimizes_open_document():
|
||||
script = (ROOT / "static/js/notes.js").read_text()
|
||||
script = notes_source("ui/panel")
|
||||
|
||||
assert "window.documentModule?.isPanelOpen?.()" in script
|
||||
assert "window.documentModule.closePanel('down')" in script
|
||||
|
||||
@@ -2,12 +2,14 @@
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from tests.helpers.notes_source import notes_source
|
||||
|
||||
|
||||
_REPO = Path(__file__).resolve().parent.parent
|
||||
|
||||
|
||||
def test_notes_image_src_guard_rejects_script_capable_data_images():
|
||||
src = (_REPO / "static" / "js" / "notes.js").read_text(encoding="utf-8")
|
||||
src = notes_source("ui/text", "ui/editor", "ui/drawing")
|
||||
|
||||
assert "function _safeImgSrc(s)" in src
|
||||
assert r"^data:image\/(?:png|jpe?g|gif|webp);base64," in src
|
||||
@@ -15,7 +17,7 @@ def test_notes_image_src_guard_rejects_script_capable_data_images():
|
||||
|
||||
|
||||
def test_notes_linkify_escapes_href_attribute():
|
||||
src = (_REPO / "static" / "js" / "notes.js").read_text(encoding="utf-8")
|
||||
src = notes_source("ui/text", "ui/editor", "ui/drawing")
|
||||
|
||||
assert "function _attrEsc(s)" in src
|
||||
assert 'href="${_attrEsc(href)}"' in src
|
||||
@@ -23,7 +25,7 @@ def test_notes_linkify_escapes_href_attribute():
|
||||
|
||||
|
||||
def test_notes_edit_form_uses_safe_image_src_guard():
|
||||
src = (_REPO / "static" / "js" / "notes.js").read_text(encoding="utf-8")
|
||||
src = notes_source("ui/text", "ui/editor", "ui/drawing")
|
||||
|
||||
assert "let currentImageUrl = _safeImgSrc(note?.image_url || '');" in src
|
||||
assert "let _stashedDrawUrl = (type === 'draw') ? (_safeImgSrc(note?.image_url) || null) : null;" in src
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
from tests.helpers.notes_source import notes_source
|
||||
from pathlib import Path
|
||||
import re
|
||||
|
||||
@@ -10,23 +11,23 @@ ROOT = Path(__file__).resolve().parent.parent
|
||||
|
||||
def test_mobile_notes_checklist_rows_remain_tappable():
|
||||
css = app_css()
|
||||
notes = (ROOT / "static" / "js" / "notes.js").read_text(encoding="utf-8")
|
||||
notes = notes_source("ui/cardChecklist", "ui/checklist", "ui/editor", "ui/mobileEditor", "ui/mobileReorder", "ui/selection", "ui/drawing")
|
||||
|
||||
assert "body.notes-mobile-mode .note-card .note-checkbox {" in css
|
||||
assert "pointer-events: auto;" in css
|
||||
assert "touch-action: manipulation;" in css
|
||||
assert "body.notes-mobile-mode .note-card .note-checkbox,\nbody.notes-mobile-mode .note-card .note-checkbox-rm" not in css
|
||||
assert "if (_selectMode) return; // let card-level handler take over\n e.preventDefault();" in notes
|
||||
assert re.search(r"if \(state.selectMode\) return; // let card-level handler take over\n\s+e.preventDefault\(\);", notes)
|
||||
|
||||
|
||||
def test_mobile_edit_checklist_toggle_marks_form_dirty():
|
||||
notes = (ROOT / "static" / "js" / "notes.js").read_text(encoding="utf-8")
|
||||
notes = notes_source("ui/cardChecklist", "ui/checklist", "ui/editor", "ui/mobileEditor", "ui/mobileReorder", "ui/selection", "ui/drawing")
|
||||
|
||||
assert "container.dispatchEvent(new Event('input', { bubbles: true }));" in notes
|
||||
|
||||
|
||||
def test_completed_mobile_todo_uses_finish_action():
|
||||
notes = (ROOT / "static" / "js" / "notes.js").read_text(encoding="utf-8")
|
||||
notes = notes_source("ui/cardChecklist", "ui/checklist", "ui/editor", "ui/mobileEditor", "ui/mobileReorder", "ui/selection", "ui/drawing")
|
||||
|
||||
assert "const _isFinishedTodo = () =>" in notes
|
||||
assert "const label = _isFinishedTodo() ? 'Finish' : 'Archive';" in notes
|
||||
@@ -34,10 +35,10 @@ def test_completed_mobile_todo_uses_finish_action():
|
||||
|
||||
|
||||
def test_mobile_long_press_enters_select_mode_for_that_note():
|
||||
notes = (ROOT / "static" / "js" / "notes.js").read_text(encoding="utf-8")
|
||||
notes = notes_source("ui/cardChecklist", "ui/checklist", "ui/editor", "ui/mobileEditor", "ui/mobileReorder", "ui/selection", "ui/drawing")
|
||||
|
||||
assert "function _enterSelectMode(initialId = null)" in notes
|
||||
assert "_suppressNextNoteTapId = card.dataset.noteId;" in notes
|
||||
assert "app.setSuppressNextNoteTapId(card.dataset.noteId);" in notes
|
||||
assert "_enterSelectMode(card.dataset.noteId);" in notes
|
||||
|
||||
|
||||
@@ -60,7 +61,7 @@ def test_notes_mobile_checklist_asset_versions_are_bumped():
|
||||
|
||||
|
||||
def test_drawing_edits_mark_notes_dirty_and_keep_one_gallery_image():
|
||||
notes = (ROOT / "static" / "js" / "notes.js").read_text(encoding="utf-8")
|
||||
notes = notes_source("ui/cardChecklist", "ui/checklist", "ui/editor", "ui/mobileEditor", "ui/mobileReorder", "ui/selection", "ui/drawing")
|
||||
upload = (ROOT / "routes" / "upload_routes.py").read_text(encoding="utf-8")
|
||||
note_routes = (ROOT / "routes" / "note" / "note_routes.py").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user