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"]))