From 1a04dab031eae6c01dadbf67da4f175b02330b58 Mon Sep 17 00:00:00 2001 From: Shane Maynard Date: Sat, 3 Oct 2026 08:58:36 -0400 Subject: [PATCH] test: guard durable chat deployment packaging --- .gitea/workflows/rebuild.yml | 2 +- tests/unit/test_chat_packaging.py | 77 +++++++++++++++++++++++++++++++ 2 files changed, 78 insertions(+), 1 deletion(-) create mode 100644 tests/unit/test_chat_packaging.py diff --git a/.gitea/workflows/rebuild.yml b/.gitea/workflows/rebuild.yml index 5418b0f..a27ce9b 100644 --- a/.gitea/workflows/rebuild.yml +++ b/.gitea/workflows/rebuild.yml @@ -27,7 +27,7 @@ jobs: tests/unit/test_chat_store.py tests/unit/test_chat_runs.py \ tests/unit/test_chat_api.py tests/unit/test_chat_exports.py \ tests/unit/test_chat_features.py tests/unit/test_chat_security_review.py \ - tests/unit/test_chat_cross_client.py -q + tests/unit/test_chat_cross_client.py tests/unit/test_chat_packaging.py -q ' - name: Build images run: | diff --git a/tests/unit/test_chat_packaging.py b/tests/unit/test_chat_packaging.py new file mode 100644 index 0000000..6475542 --- /dev/null +++ b/tests/unit/test_chat_packaging.py @@ -0,0 +1,77 @@ +"""Offline source packaging guards; no image build, service or Docker calls.""" +from html.parser import HTMLParser +from pathlib import Path +import re + +ROOT = Path(__file__).resolve().parents[2] + + +class AssetReferences(HTMLParser): + def __init__(self): + super().__init__() + self.paths = [] + + def handle_starttag(self, tag, attrs): + attrs = dict(attrs) + path = attrs.get('src') if tag == 'script' else attrs.get('href') if tag == 'link' and attrs.get('rel') == 'stylesheet' else None + if path and not path.startswith(('http:', 'https:', '//', 'data:')): + self.paths.append(path.split('?')[0]) + + +def test_speech_image_packages_coordinator_and_all_entrypoint_assets(): + dockerfile = (ROOT/'Dockerfile').read_text() + for source, destination in ( + ('agent/agent.py', '/opt/voice-agent/agent.py'), + ('agent/hermes_llm.py', '/opt/voice-agent/hermes_llm.py'), + ('agent/conversation_llm.py', '/opt/voice-agent/conversation_llm.py'), + ): + assert (ROOT/source).is_file() + assert re.search(r'^COPY\s+'+re.escape(source)+r'\s+'+re.escape(destination)+r'\s*$', dockerfile, re.M), source + references = AssetReferences() + references.feed((ROOT/'web/index.html').read_text()) + assert {'chat.js', 'features.js', 'hope-themes.css'} <= set(references.paths) + for path in references.paths: + assert (ROOT/'web'/path).is_file(), path + assert re.search(r'^COPY\s+web/'+re.escape(path)+r'\s+/var/www/voice/\s*$', dockerfile, re.M), path + assert 'chmod -R a+rX /var/www/voice' in dockerfile + assert 'chmod 644 /opt/voice-agent/*.py /opt/voice/*.py' in dockerfile + assert '.venv-tests' in (ROOT/'.dockerignore').read_text().splitlines() + + +def test_control_packages_chat_imports_pdf_and_mounted_private_roots(): + dockerfile = (ROOT/'control.Dockerfile').read_text() + assert re.search(r'^COPY\s+control\s+/app/control\s*$', dockerfile, re.M) + assert re.search(r'\bpypdf==6\.\d+\.\d+', dockerfile) + for module in ('chat_api', 'chat_store', 'chat_runs', 'chat_features', 'chat_exports'): + assert (ROOT/'control'/(module+'.py')).is_file() + deploy = (ROOT/'scripts/deploy-unraid.sh').read_text() + assert '-e CONTROL_DB_PATH=/var/lib/hope/accounts.sqlite3' in deploy + assert '-e HOPE_HERMES_DATA_DIR="$ROOT/hermes"' in deploy + assert '-v "$ROOT/accounts:/var/lib/hope"' in deploy + assert '-v "$ROOT/hermes:$ROOT/hermes"' in deploy + app = (ROOT/'control/app.py').read_text() + assert "Path(settings.db_path).absolute().parent / 'chat-features'" in app + + +def test_nginx_preserves_strict_auth_and_allows_declared_advanced_bodies(): + nginx = (ROOT/'nginx.conf').read_text() + assert re.search(r'client_max_body_size\s+16k;', nginx) + for route, limit in ((r'/api/attachments', '20m'), (r'~ ^/api/(artifacts|memory)(/|$)', '1m'), (r'/api/tasks', '64k')): + block = re.search(r'location\s+'+re.escape(route)+r'\s*\{([^}]+)\}', nginx) + assert block is not None, route + assert f'client_max_body_size {limit};' in block[1] + assert 'proxy_pass http://127.0.0.1:8092;' in block[1] + assert re.search(r'location\s+/internal/\s*\{\s*return 404;', nginx) + api = re.search(r'location\s+/api/\s*\{([^}]+)\}', nginx)[1] + assert 'proxy_buffering off;' in api and 'proxy_read_timeout 700s;' in api + + +def test_ci_qualification_gates_images_and_deployment_without_live_conftest(): + workflow = (ROOT/'.gitea/workflows/rebuild.yml').read_text() + assert workflow.index('Qualify offline') < workflow.index('Build images') < workflow.index('Deploy using') + assert '--noconftest -p no:cacheprovider' in workflow + for name in ('test_chat_packaging.py', 'test_chat_exports.py', 'test_chat_cross_client.py', 'test_chat_security_review.py', 'test_deploy_rollback.py'): + assert 'tests/unit/'+name in workflow + assert 'pytest-asyncio -r control/requirements.txt' in workflow + assert 'scripts/generate_hope_themes.py --check' in workflow + assert '-v "$PWD:/src:ro"' in workflow