Spaces:
Paused
Paused
File size: 10,457 Bytes
d9d9e44 90694b4 d9d9e44 478c16c 90694b4 d9d9e44 90694b4 d9d9e44 90694b4 d9d9e44 90694b4 d9d9e44 90694b4 d9d9e44 90694b4 d9d9e44 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 | """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"]))
|