Skip to content

Commit 775f3fc

Browse files
committed
ci: add test pipeline and API contract guard
- ci.yml: ruff + pytest on push/PR, installing from the hashed lockfile with --require-hashes, plus a lockfile-vs-requirements consistency check (dependabot cannot regenerate the lockfile; drift was silent). - test_api_contract.py: deep-scans public config, dashboard, admin file list and share metadata responses for camelCase keys - the regression net for the D7 public-config leak found during review.
1 parent 13f572a commit 775f3fc

2 files changed

Lines changed: 140 additions & 0 deletions

File tree

.github/workflows/ci.yml

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
name: CI
2+
3+
on:
4+
push:
5+
branches:
6+
- master
7+
- dev
8+
pull_request:
9+
10+
concurrency:
11+
group: ci-${{ github.ref }}
12+
cancel-in-progress: true
13+
14+
permissions:
15+
contents: read
16+
17+
jobs:
18+
test:
19+
name: Lint and test
20+
runs-on: ubuntu-latest
21+
timeout-minutes: 15
22+
steps:
23+
- name: Checkout
24+
uses: actions/checkout@v4
25+
26+
- name: Setup Python
27+
uses: actions/setup-python@v5
28+
with:
29+
python-version: '3.12'
30+
31+
- name: Install dependencies
32+
run: |
33+
python -m pip install --upgrade pip
34+
# The hashed lockfile is the deploy artifact; install from it with
35+
# --require-hashes so CI fails on lockfile drift, exactly like the
36+
# Docker build does.
37+
pip install --require-hashes -r requirements.lock.txt
38+
pip install pytest pytest-asyncio httpx
39+
40+
- name: Ruff
41+
run: pipx run ruff==0.16.6 check .
42+
43+
- name: Verify lockfile matches requirements.txt
44+
# Dependabot bumps requirements.txt but cannot regenerate the hashed
45+
# lockfile; without this check a stale lockfile would silently keep
46+
# the Docker build on old versions.
47+
run: |
48+
python - <<'PY'
49+
import re, sys
50+
pins = dict(re.findall(r'^([\w-]+)==([\w.]+)$', open('requirements.txt').read(), re.M))
51+
lock = open('requirements.lock.txt').read()
52+
stale = []
53+
for name, ver in pins.items():
54+
m = re.search(rf'(?mi)^{re.escape(name)}==([\w.]+)\b', lock)
55+
if m is None or m.group(1) != ver:
56+
stale.append((name, ver, m.group(1) if m else 'ABSENT'))
57+
for name, req_ver, lock_ver in stale:
58+
print(f'STALE LOCKFILE: {name} requirements.txt={req_ver} lock={lock_ver}')
59+
sys.exit(1 if stale else 0)
60+
PY
61+
62+
- name: Run tests
63+
run: pytest -q

tests/test_api_contract.py

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
"""API contract guard: responses must carry snake_case keys only.
2+
3+
Regression net for the D7 lesson: a camel+snake dual-field contract crept
4+
back through build_public_config/build_public_meta (file-based scoping hid
5+
that they serve a live endpoint). Any key matching [a-z]+[A-Z] camel shape
6+
anywhere in these documented response payloads now fails the suite.
7+
"""
8+
import re
9+
10+
import httpx
11+
import pytest
12+
13+
CAMEL_KEY = re.compile(r"^[a-z0-9]+(?:[A-Z][a-zA-Z0-9]*)+$")
14+
15+
16+
def _assert_no_camel_keys(node, path, violations):
17+
if isinstance(node, dict):
18+
for key, value in node.items():
19+
key_path = f"{path}.{key}"
20+
if isinstance(key, str) and CAMEL_KEY.match(key):
21+
violations.append(key_path)
22+
_assert_no_camel_keys(value, key_path, violations)
23+
elif isinstance(node, list):
24+
for index, item in enumerate(node):
25+
_assert_no_camel_keys(item, f"{path}[{index}]", violations)
26+
27+
28+
async def _login(client: httpx.AsyncClient) -> str:
29+
from tests.conftest import TEST_ADMIN_PASSWORD
30+
31+
response = await client.post(
32+
"/admin/login", json={"password": TEST_ADMIN_PASSWORD}
33+
)
34+
assert response.status_code == 200, response.text
35+
return response.json()["detail"]["token"]
36+
37+
38+
def _check_contract(payload: dict, url: str) -> None:
39+
violations = []
40+
_assert_no_camel_keys(payload, url, violations)
41+
assert not violations, f"camelCase keys leaked into {url}: {violations}"
42+
43+
44+
@pytest.mark.asyncio
45+
class TestApiContractSnakeCase:
46+
async def test_public_config(self, initialized_client):
47+
response = await initialized_client.get("/api/v1/config")
48+
assert response.status_code == 200
49+
_check_contract(response.json(), "/api/v1/config")
50+
51+
async def test_dashboard(self, initialized_client):
52+
token = await _login(initialized_client)
53+
response = await initialized_client.get(
54+
"/admin/dashboard", headers={"Authorization": f"Bearer {token}"}
55+
)
56+
assert response.status_code == 200
57+
_check_contract(response.json(), "/admin/dashboard")
58+
59+
async def test_admin_file_list(self, initialized_client):
60+
token = await _login(initialized_client)
61+
response = await initialized_client.get(
62+
"/admin/file/list", headers={"Authorization": f"Bearer {token}"}
63+
)
64+
assert response.status_code == 200
65+
_check_contract(response.json(), "/admin/file/list")
66+
67+
async def test_share_metadata(self, initialized_client):
68+
share = await initialized_client.post(
69+
"/share/text/", data={"text": "contract guard", "expire_value": 1, "expire_style": "day"}
70+
)
71+
assert share.status_code == 200, share.text
72+
code = share.json()["detail"]["code"]
73+
response = await initialized_client.get(
74+
"/share/metadata/", params={"code": code}
75+
)
76+
assert response.status_code == 200
77+
_check_contract(response.json(), "/share/metadata/")

0 commit comments

Comments
 (0)