"""SPEC-3 Docker 镜像守卫的非容器化集成测试。 覆盖以下 SPEC §9 场景中可在宿主机执行的部分: - 9.1 步骤 3:`/api/version` 端点契约(JSON 字段、降级语义、免鉴权) - 9.3 步骤 1-2:ruff F821 拦截 typo - self-check 白名单 import 在宿主机环境也能跑通(等同于容器启动期检查) 宿主机不可达的部分(docker compose up / crash-loop / OCI label)走 shell SOP, 见 `docs/docker.md` 的"代码更新后的 rebuild SOP"章节。 """ from __future__ import annotations import shutil import subprocess import sys from pathlib import Path import pytest from fastapi.testclient import TestClient REPO_ROOT = Path(__file__).resolve().parents[2] BASH_EXE = Path("C:/Program Files/Git/bin/bash.exe") if not BASH_EXE.exists(): BASH_EXE = Path("C:/Program Files/Git/usr/bin/bash.exe") if not BASH_EXE.exists(): detected_bash = shutil.which("bash") BASH_EXE = Path(detected_bash) if detected_bash else Path("bash") # --------------------------------------------------------------------------- # §9.1 /api/version 端点契约 # --------------------------------------------------------------------------- @pytest.fixture def client() -> TestClient: from autoteam.api import app return TestClient(app) def test_api_version_returns_two_fields(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None: """SPEC AC3: /api/version 必须返回 git_sha + build_time 双字段。""" monkeypatch.setenv("AUTOTEAM_GIT_SHA", "cf2f7d3") monkeypatch.setenv("AUTOTEAM_BUILD_TIME", "2026-04-26T03:00:00Z") resp = client.get("/api/version") assert resp.status_code == 200 body = resp.json() assert set(body.keys()) == {"git_sha", "build_time"} assert body["git_sha"] == "cf2f7d3" assert body["build_time"] == "2026-04-26T03:00:00Z" def test_api_version_falls_back_to_unknown(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None: """SPEC AC7: 不传 build-arg(env 未设)应降级返回 unknown,不报 500。""" monkeypatch.delenv("AUTOTEAM_GIT_SHA", raising=False) monkeypatch.delenv("AUTOTEAM_BUILD_TIME", raising=False) resp = client.get("/api/version") assert resp.status_code == 200 body = resp.json() assert body == {"git_sha": "unknown", "build_time": "unknown"} def test_api_version_is_unauthenticated(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None: """SPEC AC9: 即便配了 API_KEY,/api/version 也免鉴权直接返回 200。""" # 模拟"已配 API_KEY 但请求不带 Bearer" import autoteam.api as api_module monkeypatch.setattr(api_module, "API_KEY", "fake-secret-do-not-leak") resp = client.get("/api/version") # 故意不带 Authorization header assert resp.status_code == 200, "version 端点应在 _AUTH_SKIP_PATHS 白名单中" def test_api_version_in_auth_skip_paths() -> None: """额外契约:_AUTH_SKIP_PATHS 必须显式包含 /api/version。""" from autoteam.api import _AUTH_SKIP_PATHS assert "/api/version" in _AUTH_SKIP_PATHS # --------------------------------------------------------------------------- # §9.3 lint 守卫:ruff F821 拦截 typo(同 entrypoint self-check 等价语义) # --------------------------------------------------------------------------- @pytest.mark.skipif( shutil.which("ruff") is None, reason="本机 PATH 中未找到 ruff 二进制,跳过 lint 集成验证(CI 应直接装 ruff)", ) def test_ruff_catches_undefined_import(tmp_path: Path) -> None: """SPEC AC5: 制造 typo 的 import,ruff F821 必须返回非零 exit code。 用 PATH 上已存在的 ruff 直接跑,避开 `uv run` 在 Windows 上可能的虚拟环境锁。 CI 与 pre-commit hook 走 ruff-pre-commit,语义等价。 """ canary = tmp_path / "canary.py" canary.write_text( "from autoteam.accounts import list_accounts # typo: 实际应为 load_accounts\n", encoding="utf-8", ) cmd = ["ruff", "check", "--select", "F401,F811,F821", str(canary)] proc = subprocess.run(cmd, capture_output=True, text=True, cwd=REPO_ROOT) assert proc.returncode != 0, ( f"ruff 应拦截 typo,但返回 exit=0。stdout={proc.stdout!r}, stderr={proc.stderr!r}" ) # F401(unused) 或 F821(undefined) 任一命中即可 — typo 引入的 import 通常两者都中 combined = proc.stdout + proc.stderr assert ("F401" in combined) or ("F821" in combined), ( f"输出中缺少 F401/F821 标记: {combined!r}" ) # --------------------------------------------------------------------------- # self-check 白名单契约:entrypoint 列出的 11 个符号必须全部 importable # --------------------------------------------------------------------------- def test_self_check_whitelist_imports() -> None: """SPEC AC1: docker-entrypoint.sh self-check 列出的所有符号必须可导入。 若 PR 改名/删除这些符号,本测试先红 → entrypoint 才不会在容器启动期红。 """ from autoteam.accounts import ( # noqa: F401 STATUS_ACTIVE, STATUS_AUTH_INVALID, STATUS_EXHAUSTED, STATUS_ORPHAN, STATUS_PENDING, STATUS_PERSONAL, STATUS_STANDBY, load_accounts, save_accounts, ) from autoteam.api import app # noqa: F401 from autoteam.chatgpt_transport import build_chatgpt_transport # noqa: F401 from autoteam.manager import sync_account_states # noqa: F401 from autoteam.playwright_lifecycle import close_playwright_objects # noqa: F401 from autoteam.playwright_probe import main as playwright_probe_main # noqa: F401 from autoteam.runtime_resources import collect_runtime_resource_snapshot # noqa: F401 # 简单的契约校验 — 函数应可调用、状态常量应是字符串 assert callable(load_accounts) assert callable(save_accounts) assert callable(sync_account_states) assert callable(collect_runtime_resource_snapshot) assert callable(close_playwright_objects) assert callable(playwright_probe_main) assert callable(build_chatgpt_transport) for const in ( STATUS_ACTIVE, STATUS_EXHAUSTED, STATUS_STANDBY, STATUS_PENDING, STATUS_PERSONAL, STATUS_AUTH_INVALID, STATUS_ORPHAN, ): assert isinstance(const, str) and const, f"状态常量必须是非空字符串: {const!r}" # --------------------------------------------------------------------------- # Dockerfile / compose / entrypoint 静态契约(避免 SPEC §1-§4 被悄悄回退) # --------------------------------------------------------------------------- def test_dockerfile_declares_git_sha_arg() -> None: """SPEC AC4: Dockerfile 必须声明 ARG GIT_SHA + ENV AUTOTEAM_GIT_SHA。""" dockerfile = (REPO_ROOT / "Dockerfile").read_text(encoding="utf-8") assert "ARG GIT_SHA" in dockerfile assert "ARG BUILD_TIME" in dockerfile assert "ENV AUTOTEAM_GIT_SHA" in dockerfile assert "ENV AUTOTEAM_BUILD_TIME" in dockerfile assert "org.opencontainers.image.revision" in dockerfile def test_compose_passes_build_args() -> None: """compose 必须把 GIT_SHA / BUILD_TIME 作为 build-arg 透传(降级 unknown)。""" compose = (REPO_ROOT / "docker-compose.yml").read_text(encoding="utf-8") assert "GIT_SHA: ${GIT_SHA:-unknown}" in compose assert "BUILD_TIME: ${BUILD_TIME:-unknown}" in compose def test_compose_declares_runtime_safety_bounds() -> None: """0515 hardening: compose must keep browser/container resource boundaries.""" compose = (REPO_ROOT / "docker-compose.yml").read_text(encoding="utf-8") assert "init: true" in compose assert 'shm_size: "1gb"' in compose assert 'mem_limit: "2g"' in compose assert 'memswap_limit: "2g"' in compose assert "pids_limit: 768" in compose assert 'AUTOTEAM_MEMORY_WARN_RATIO: "0.85"' in compose assert 'AUTOTEAM_ZOMBIE_WARN_THRESHOLD: "5"' in compose assert "healthcheck:" in compose assert "curl -fsS http://127.0.0.1:8787/api/version" in compose def test_dockerfile_fast_preserves_runtime_contracts() -> None: """Dockerfile.fast is allowed only if it preserves fingerprint and self-check paths.""" dockerfile = (REPO_ROOT / "Dockerfile.fast").read_text(encoding="utf-8") assert "FROM autoteam:latest" in dockerfile assert "ARG GIT_SHA" in dockerfile assert "ARG BUILD_TIME" in dockerfile assert "ENV AUTOTEAM_GIT_SHA" in dockerfile assert "ENV AUTOTEAM_BUILD_TIME" in dockerfile assert "org.opencontainers.image.revision" in dockerfile assert "COPY docker-entrypoint.sh /docker-entrypoint.sh" in dockerfile assert "sed -i 's/\\r$//'" in dockerfile assert 'ENTRYPOINT ["/docker-entrypoint.sh"]' in dockerfile def test_entrypoint_contains_self_check() -> None: """entrypoint 必须包含 self-check + crash-loop 兜底。""" entrypoint = (REPO_ROOT / "docker-entrypoint.sh").read_text(encoding="utf-8") assert "[self-check]" in entrypoint assert "from autoteam.api import app" in entrypoint assert "from autoteam.accounts import" in entrypoint assert "from autoteam.runtime_resources import collect_runtime_resource_snapshot" in entrypoint assert "from autoteam.playwright_lifecycle import close_playwright_objects" in entrypoint assert "from autoteam.playwright_probe import main as playwright_probe_main" in entrypoint assert "from autoteam.chatgpt_transport import build_chatgpt_transport" in entrypoint assert "exit 1" in entrypoint, "self-check 失败必须 exit 1 触发 crash-loop" def test_entrypoint_shell_syntax_is_valid() -> None: """The self-check heredoc must remain valid after Dockerfile CRLF normalization.""" proc = subprocess.run( [str(BASH_EXE), "-lc", "tr -d '\\r' < docker-entrypoint.sh | bash -n"], capture_output=True, text=True, encoding="utf-8", errors="replace", cwd=REPO_ROOT, ) assert proc.returncode == 0, proc.stderr def test_pyproject_has_ruff_config() -> None: """pyproject.toml 必须配 [tool.ruff.lint] 三条规则。""" pyproject = (REPO_ROOT / "pyproject.toml").read_text(encoding="utf-8") assert "[tool.ruff.lint]" in pyproject assert "F401" in pyproject assert "F811" in pyproject assert "F821" in pyproject if __name__ == "__main__": sys.exit(pytest.main([__file__, "-v"]))