Spaces:
Paused
Paused
| """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 端点契约 | |
| # --------------------------------------------------------------------------- | |
| 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 等价语义) | |
| # --------------------------------------------------------------------------- | |
| 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"])) | |