feat(gtea): build full gitea test suite (M1 build — all files)
Some checks failed
continuous-integration/drone/push Build is failing
Some checks failed
continuous-integration/drone/push Build is failing
- tests/gitea/recipe_meta.py: updated from dep-provider stub to dual-role (dep + recipe-under-test).
Adds BACKUP_CAPABLE=True, READY_PROBE (/api/v1/version), SCREENSHOT (sign-in page), LFS-
conditional EXTRA_ENV (compose.lfs.yml + GITEA_LFS_START_SERVER only when RECIPE=gitea AND
overlay present — dep path unchanged). All existing dep keys preserved; 10/10 dep unit tests pass.
- tests/gitea/ops.py: NEW — admin user creation via gitea CLI (ci_admin, creds in /tmp per-domain
file), marker repo lifecycle (pre_install/pre_upgrade/pre_backup create; pre_restore deletes to
diverge from backup state).
- tests/gitea/test_{install,upgrade,backup,restore}.py: NEW — lifecycle overlays. Install checks
API + admin auth + Playwright sign-in. Upgrade/backup/restore assert marker repo continuity.
- tests/gitea/custom/: NEW — test_health.py (parity: HTTP 200 root), test_git_push.py (parity:
create→clone→push→verify→delete), test_admin_api.py (beyond-parity: user+org+token CRUD),
test_lfs_roundtrip.py (LFS OID round-trip + JWT stability; skips on main, runs on PR #1 head).
- tests/gitea/PARITY.md: NEW — mapping table, source note (recipe-info corpus not upstream repo),
beyond-parity rationale, backup/restore real-tier note, DB choice, dep-split mechanism, LFS skip.
- machine-docs/STATUS-gtea.md: NEW — phase status (building M1).
- machine-docs/BACKLOG-gtea.md: merged with Adversary init.
- machine-docs/JOURNAL-gtea.md: Builder log with design decisions + unit test results.
- machine-docs/REVIEW-gtea.md: kept Adversary init content.
- machine-docs/DECISIONS.md: appended gtea section (LFS split, admin mgmt, marker design).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
68
tests/gitea/PARITY.md
Normal file
68
tests/gitea/PARITY.md
Normal file
@ -0,0 +1,68 @@
|
||||
# Parity — gitea (phase gtea)
|
||||
|
||||
Phase-gtea P2 mapping table. The Adversary cold-verifies parity by reading the source
|
||||
`recipe-info/gitea/tests/<file>` and the cc-ci file side-by-side.
|
||||
|
||||
**Source note:** `recipe-maintainers/gitea` (git.autonomic.zone) has **no `tests/` directory** on
|
||||
`main` or `lfs-plain-gitea`. The reference tests exist ONLY in the local
|
||||
`references/recipe-maintainer/recipe-info/gitea/tests/` corpus (the recipe-info collection kept on
|
||||
the orchestrator VM for parity reference). They are not part of the upstream recipe repo itself.
|
||||
The cc-ci implementations are ports of those reference tests, not of upstream recipe-repo tests.
|
||||
|
||||
| recipe-info source file | cc-ci file | what's verified | status |
|
||||
|---|---|---|---|
|
||||
| `references/recipe-maintainer/recipe-info/gitea/tests/health_check.py` | `tests/gitea/custom/test_health.py` | HTTP 200 from the root URL (`https://<domain>`). Original: resolves domain from testsecrets/. cc-ci port: uses `live_app` fixture (ephemeral per-run domain). Assertion shape preserved. | **ported from recipe-info corpus** |
|
||||
| `references/recipe-maintainer/recipe-info/gitea/tests/git_push.py` | `tests/gitea/custom/test_git_push.py` | Create repo via API → clone → commit → push over HTTPS → verify commit landed via API → delete. Original: reads admin creds from CLI args/testsecrets. cc-ci port: admin creds from `ops.admin_creds()` (the `ci_admin` user created by `ops.pre_install`). Full SCM round-trip exercised. | **ported from recipe-info corpus** |
|
||||
|
||||
## Recipe-specific tests (≥2 beyond parity)
|
||||
|
||||
| cc-ci file | what's verified | rationale |
|
||||
|---|---|---|
|
||||
| `tests/gitea/custom/test_admin_api.py` | **Beyond-parity.** Admin API CRUD lifecycle: create user + org + API token → read-back via the generated token (authenticates as test user) → delete all. Tests two distinct auth paths (admin basic-auth + user token). | Proves the full admin-API contract: user management, org management, and token issuance. Non-vacuous: a broken DB or broken API router fails at least one assertion. Not covered by any upstream recipe-info test. |
|
||||
| `tests/gitea/custom/test_lfs_roundtrip.py` | **Beyond-parity + PR #1 capstone.** Git LFS object upload→download OID round-trip + JWT-secret stability across `abra app restart`. Skips when `compose.lfs.yml` is absent (gitea main — LFS not deployed). Runs and must pass on `lfs-plain-gitea` (PR #1 head). | The mechanism that PROVES PR #1: red/skipped on main (feature absent), green on PR head (feature present). A rotating JWT secret fails the stability assertion (the exact regression the PR fixes). See §3 of the phase plan. |
|
||||
|
||||
## Backup / restore — REAL tiers
|
||||
|
||||
`compose.yml` carries `backupbot.backup=true` labels. `BACKUP_CAPABLE = True` is set explicitly
|
||||
in `recipe_meta.py`. Backup and restore are REAL tiers (not N/A skips). Data integrity is proved
|
||||
by the marker-repo mechanism (see `ops.py`):
|
||||
- `pre_install`: creates `ci_admin` user + `ci-marker` repo (auto_init → README.md commit)
|
||||
- `pre_upgrade`: re-asserts marker exists before chaos redeploy
|
||||
- `pre_backup`: re-asserts marker exists before backup
|
||||
- `pre_restore`: DELETES marker (diverges from backup state)
|
||||
- `test_upgrade`: asserts marker survived upgrade (sqlite3 continuity)
|
||||
- `test_backup`: asserts marker present at backup time
|
||||
- `test_restore`: asserts marker returned (restore reverted deletion — non-vacuous: a no-op restore leaves it absent)
|
||||
|
||||
## Database choice
|
||||
|
||||
sqlite3 (`compose.sqlite3.yml`) — matches the dep config (drone uses gitea with sqlite3), minimal
|
||||
footprint. No MariaDB/Postgres dependency needed for the CI test suite.
|
||||
|
||||
## Dep-vs-recipe-under-test split (§2 of phase plan)
|
||||
|
||||
`EXTRA_ENV(ctx)` applies to BOTH roles (dep deploy for drone AND recipe-under-test). The split
|
||||
mechanism uses TWO conditions to guard LFS-overlay activation:
|
||||
1. `compose.lfs.yml` exists in `$ABRA_DIR/recipes/gitea/` — only true on the `lfs-plain-gitea`
|
||||
PR branch (not on `main`).
|
||||
2. `RECIPE=gitea` — set by the orchestrator only when gitea is the recipe-under-test.
|
||||
|
||||
Both conditions required: this ensures the dep path is **byte-for-byte identical** regardless of
|
||||
whether the LFS PR branch is checked out, and ensures recipe-under-test with LFS overlay doesn't
|
||||
accidentally activate for a dep deploy on the PR branch. Documented in DECISIONS.md (phase gtea).
|
||||
|
||||
## LFS capstone — EXPECTED_NA on main
|
||||
|
||||
The LFS test (`test_lfs_roundtrip.py`) uses `pytest.skip()` when `compose.lfs.yml` is absent:
|
||||
```
|
||||
Skips when compose.lfs.yml absent in gitea recipe checkout — LFS is not enabled on this branch.
|
||||
This test runs on lfs-plain-gitea (PR #1) and is EXPECTED_NA on main.
|
||||
```
|
||||
No `EXPECTED_NA` key in `recipe_meta.py` because this is a custom-tier test that self-skips, not
|
||||
a lifecycle rung. The phase M1 gate (suite green on main) is met WITH the LFS test skipped.
|
||||
The phase M2 gate requires the LFS test GREEN on the `lfs-plain-gitea` PR head.
|
||||
|
||||
## Screenshot
|
||||
|
||||
`SCREENSHOT` hook in `recipe_meta.py` navigates to `/user/login` — the sign-in page. This is
|
||||
credential-free and shows the gitea UI (form, branding). Default capture covers the install tier.
|
||||
130
tests/gitea/custom/test_admin_api.py
Normal file
130
tests/gitea/custom/test_admin_api.py
Normal file
@ -0,0 +1,130 @@
|
||||
"""gitea — beyond-parity: admin API CRUD lifecycle (phase gtea).
|
||||
|
||||
Proves the gitea admin REST API: user + org + token creation, read-back, and deletion.
|
||||
Non-vacuous: a misconfigured gitea (broken DB, broken API routing) fails any of these.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import secrets
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "..", "runner"))
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
||||
from ops import admin_creds # noqa: E402
|
||||
|
||||
import ssl
|
||||
|
||||
_CTX = ssl.create_default_context()
|
||||
_CTX.check_hostname = False
|
||||
_CTX.verify_mode = ssl.CERT_NONE
|
||||
|
||||
|
||||
def _api(domain, path, method="GET", body=None, user="", password="", token=""):
|
||||
data = json.dumps(body).encode() if body is not None else None
|
||||
if token:
|
||||
headers = {"Authorization": f"token {token}"}
|
||||
else:
|
||||
auth = base64.b64encode(f"{user}:{password}".encode()).decode()
|
||||
headers = {"Authorization": f"Basic {auth}"}
|
||||
if data:
|
||||
headers["Content-Type"] = "application/json"
|
||||
req = urllib.request.Request(
|
||||
f"https://{domain}/api/v1{path}", data=data, headers=headers, method=method
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=20, context=_CTX) as r:
|
||||
raw = r.read()
|
||||
return r.status, (json.loads(raw) if raw else {})
|
||||
except urllib.error.HTTPError as e:
|
||||
raw = e.read()
|
||||
try:
|
||||
return e.code, json.loads(raw)
|
||||
except (ValueError, json.JSONDecodeError):
|
||||
return e.code, {}
|
||||
|
||||
|
||||
def test_admin_api_user_org_token_lifecycle(live_app):
|
||||
"""Admin API CRUD: create user → create org → create API token → read-back → delete.
|
||||
|
||||
Proves the full admin-API lifecycle:
|
||||
1. Create a test user (admin-creates-user API)
|
||||
2. Create an org (admin-creates-org API)
|
||||
3. Create an API token for the test user (user-generates-token API)
|
||||
4. Read back the user + org via the token
|
||||
5. Delete the token, org, and user (cleanup)
|
||||
"""
|
||||
adm_user, adm_pass = admin_creds(live_app)
|
||||
suffix = secrets.token_hex(4)
|
||||
test_user = f"testuser-{suffix}"
|
||||
test_org = f"testorg-{suffix}"
|
||||
token_name = f"ci-test-token-{suffix}"
|
||||
test_pass = secrets.token_hex(12) + "A1" # at least one uppercase + digit for any policy
|
||||
|
||||
# 1. Create test user
|
||||
status, body = _api(
|
||||
live_app, "/admin/users", method="POST",
|
||||
body={
|
||||
"username": test_user,
|
||||
"email": f"{test_user}@ci.local",
|
||||
"password": test_pass,
|
||||
"must_change_password": False,
|
||||
"login_name": test_user,
|
||||
"source_id": 0,
|
||||
},
|
||||
user=adm_user, password=adm_pass,
|
||||
)
|
||||
assert status == 201, f"user create HTTP {status}: {body}"
|
||||
assert body.get("login") == test_user, f"unexpected login: {body}"
|
||||
|
||||
try:
|
||||
# 2. Create org (as admin, add test user as member)
|
||||
status, body = _api(
|
||||
live_app, "/orgs", method="POST",
|
||||
body={"username": test_org, "visibility": "public"},
|
||||
user=adm_user, password=adm_pass,
|
||||
)
|
||||
assert status == 201, f"org create HTTP {status}: {body}"
|
||||
assert body.get("username") == test_org, f"unexpected org: {body}"
|
||||
|
||||
try:
|
||||
# 3. Create API token for test user (admin creates token on behalf of user)
|
||||
status, tok_body = _api(
|
||||
live_app, f"/users/{test_user}/tokens", method="POST",
|
||||
body={"name": token_name},
|
||||
user=adm_user, password=adm_pass,
|
||||
)
|
||||
assert status == 201, f"token create HTTP {status}: {tok_body}"
|
||||
token = tok_body.get("sha1")
|
||||
assert token, f"token sha1 missing from response: {tok_body}"
|
||||
|
||||
# 4. Read back via token: authenticated call as test_user
|
||||
status, me = _api(live_app, "/user", token=token)
|
||||
assert status == 200, f"GET /user with token HTTP {status}"
|
||||
assert me.get("login") == test_user, f"token authenticated as wrong user: {me}"
|
||||
|
||||
# 5. Read org via token
|
||||
status, org = _api(live_app, f"/orgs/{test_org}", token=token)
|
||||
assert status == 200, f"GET /orgs/{test_org} HTTP {status}: {org}"
|
||||
assert org.get("username") == test_org
|
||||
|
||||
# 6. Delete the token
|
||||
status, _ = _api(
|
||||
live_app, f"/users/{test_user}/tokens/{token_name}", method="DELETE",
|
||||
user=adm_user, password=adm_pass,
|
||||
)
|
||||
assert status in (204, 404), f"token delete HTTP {status}"
|
||||
|
||||
finally:
|
||||
# Delete org
|
||||
_api(live_app, f"/orgs/{test_org}", method="DELETE", user=adm_user, password=adm_pass)
|
||||
|
||||
finally:
|
||||
# Delete test user (admin only)
|
||||
_api(live_app, f"/admin/users/{test_user}", method="DELETE",
|
||||
user=adm_user, password=adm_pass)
|
||||
122
tests/gitea/custom/test_git_push.py
Normal file
122
tests/gitea/custom/test_git_push.py
Normal file
@ -0,0 +1,122 @@
|
||||
"""gitea — parity port of recipe-info/gitea/tests/git_push.py (phase gtea).
|
||||
|
||||
SOURCE: references/recipe-maintainer/recipe-info/gitea/tests/git_push.py
|
||||
|
||||
Original: create repo via API → clone → commit → push over HTTPS → verify commit via API → delete.
|
||||
cc-ci port: same flow, adapted to the per-run domain and the harness admin creds from ops.py.
|
||||
Non-vacuous: a broken SCM fails the push or the API verification.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "..", "runner"))
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
||||
from ops import admin_creds # noqa: E402
|
||||
|
||||
import ssl
|
||||
|
||||
_CTX = ssl.create_default_context()
|
||||
_CTX.check_hostname = False
|
||||
_CTX.verify_mode = ssl.CERT_NONE
|
||||
|
||||
|
||||
def _api(domain, path, method="GET", body=None, user="", password=""):
|
||||
data = json.dumps(body).encode() if body is not None else None
|
||||
auth = base64.b64encode(f"{user}:{password}".encode()).decode()
|
||||
headers = {"Authorization": f"Basic {auth}"}
|
||||
if data:
|
||||
headers["Content-Type"] = "application/json"
|
||||
req = urllib.request.Request(
|
||||
f"https://{domain}/api/v1{path}", data=data, headers=headers, method=method
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=20, context=_CTX) as r:
|
||||
raw = r.read()
|
||||
return r.status, (json.loads(raw) if raw else {})
|
||||
except urllib.error.HTTPError as e:
|
||||
raw = e.read()
|
||||
try:
|
||||
return e.code, json.loads(raw)
|
||||
except (ValueError, json.JSONDecodeError):
|
||||
return e.code, {}
|
||||
|
||||
|
||||
def _run_git(args, cwd, env=None):
|
||||
result = subprocess.run(
|
||||
["git"] + args,
|
||||
cwd=cwd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
env={**os.environ, **(env or {})},
|
||||
timeout=60,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
raise RuntimeError(f"git {' '.join(args)} failed:\n{result.stderr}")
|
||||
return result.stdout.strip()
|
||||
|
||||
|
||||
def test_git_push(live_app):
|
||||
"""Parity with recipe-info/gitea/tests/git_push.py: create repo → clone → push → verify."""
|
||||
user, password = admin_creds(live_app)
|
||||
repo_name = "ci-test-push"
|
||||
|
||||
# 1. Create test repo
|
||||
status, body = _api(
|
||||
live_app, "/user/repos", method="POST",
|
||||
body={"name": repo_name, "private": False, "auto_init": False},
|
||||
user=user, password=password,
|
||||
)
|
||||
assert status == 201, f"repo create HTTP {status}: {body}"
|
||||
clone_url = body.get("clone_url") or f"https://{live_app}/{user}/{repo_name}.git"
|
||||
|
||||
tmpdir = tempfile.mkdtemp(prefix="ccci-gitea-push-")
|
||||
try:
|
||||
git_env = {
|
||||
"GIT_AUTHOR_NAME": "CI Test Bot",
|
||||
"GIT_AUTHOR_EMAIL": "ci@ci.local",
|
||||
"GIT_COMMITTER_NAME": "CI Test Bot",
|
||||
"GIT_COMMITTER_EMAIL": "ci@ci.local",
|
||||
# Embed credentials so HTTPS push works without interactive prompt.
|
||||
"GIT_CONFIG_COUNT": "1",
|
||||
"GIT_CONFIG_KEY_0": f"url.https://{user}:{password}@{live_app}/.insteadOf",
|
||||
"GIT_CONFIG_VALUE_0": f"https://{live_app}/",
|
||||
}
|
||||
|
||||
# 2. Clone (empty repo)
|
||||
_run_git(["clone", clone_url, tmpdir], cwd="/tmp", env=git_env)
|
||||
_run_git(["checkout", "-b", "main"], cwd=tmpdir, env=git_env)
|
||||
|
||||
# 3. Commit a file
|
||||
readme = os.path.join(tmpdir, "README.md")
|
||||
with open(readme, "w") as f:
|
||||
f.write(f"# {repo_name}\n\nAutomated ci push test.\n")
|
||||
_run_git(["add", "README.md"], cwd=tmpdir, env=git_env)
|
||||
_run_git(["commit", "-m", "test: automated push test"], cwd=tmpdir, env=git_env)
|
||||
|
||||
# 4. Push
|
||||
_run_git(["push", "origin", "HEAD:main"], cwd=tmpdir, env=git_env)
|
||||
|
||||
# 5. Verify commit landed via API
|
||||
status, commits = _api(
|
||||
live_app, f"/repos/{user}/{repo_name}/commits?limit=1",
|
||||
user=user, password=password,
|
||||
)
|
||||
assert status == 200 and commits, f"commit list HTTP {status}: {commits}"
|
||||
commit_msg = commits[0].get("commit", {}).get("message", "").strip()
|
||||
assert "automated push test" in commit_msg, (
|
||||
f"Unexpected commit message: {commit_msg!r}"
|
||||
)
|
||||
finally:
|
||||
shutil.rmtree(tmpdir, ignore_errors=True)
|
||||
# 6. Cleanup — delete the test repo
|
||||
_api(live_app, f"/repos/{user}/{repo_name}", method="DELETE", user=user, password=password)
|
||||
22
tests/gitea/custom/test_health.py
Normal file
22
tests/gitea/custom/test_health.py
Normal file
@ -0,0 +1,22 @@
|
||||
"""gitea — parity port of recipe-info/gitea/tests/health_check.py (phase gtea).
|
||||
|
||||
SOURCE: references/recipe-maintainer/recipe-info/gitea/tests/health_check.py
|
||||
|
||||
The original checks HTTP 200 from the root URL. The cc-ci port preserves the assertion shape,
|
||||
adapted to the ephemeral per-run domain via the `live_app` fixture.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "..", "runner"))
|
||||
from harness import http as harness_http # noqa: E402
|
||||
|
||||
|
||||
def test_gitea_root_returns_200(live_app):
|
||||
"""Parity with recipe-info/gitea/tests/health_check.py: HTTP 200 from the root URL."""
|
||||
url = f"https://{live_app}"
|
||||
status, _ = harness_http.retry_http_get(url, expect_status=200, max_wait=60, interval=3)
|
||||
assert status == 200, f"gitea at {url} returned HTTP {status} (expected 200)"
|
||||
216
tests/gitea/custom/test_lfs_roundtrip.py
Normal file
216
tests/gitea/custom/test_lfs_roundtrip.py
Normal file
@ -0,0 +1,216 @@
|
||||
"""gitea — LFS round-trip capstone (phase gtea, PR #1 lfs-plain-gitea).
|
||||
|
||||
This test is the PROOF for PR #1 (feat: support Git LFS on plain gitea):
|
||||
- On gitea main: compose.lfs.yml is ABSENT → test skips (declared EXPECTED_NA in PARITY.md).
|
||||
- On lfs-plain-gitea PR head: compose.lfs.yml is PRESENT → test runs and must pass.
|
||||
|
||||
What it tests:
|
||||
1. LFS object upload (git lfs push) → download round-trip: fetched bytes hash to the OID.
|
||||
2. LFS JWT secret stability: after `abra app restart`, the rendered app.ini LFS_JWT_SECRET
|
||||
is unchanged and tokens still validate (the regression the PR fixes).
|
||||
|
||||
Non-vacuous: a gitea without LFS enabled cannot accept lfs push (step 1 fails); a rotating
|
||||
LFS_JWT_SECRET breaks existing tokens (step 2 fails).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "..", "runner"))
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
||||
from harness import abra as harness_abra, lifecycle # noqa: E402
|
||||
from ops import admin_creds # noqa: E402
|
||||
|
||||
import ssl
|
||||
|
||||
_CTX = ssl.create_default_context()
|
||||
_CTX.check_hostname = False
|
||||
_CTX.verify_mode = ssl.CERT_NONE
|
||||
|
||||
_LFS_OVERLAY = "compose.lfs.yml"
|
||||
|
||||
|
||||
def _lfs_available() -> bool:
|
||||
abra_dir = os.environ.get("ABRA_DIR") or os.path.expanduser("~/.abra")
|
||||
return os.path.exists(os.path.join(abra_dir, "recipes", "gitea", _LFS_OVERLAY))
|
||||
|
||||
|
||||
def _api(domain, path, method="GET", body=None, user="", password=""):
|
||||
data = json.dumps(body).encode() if body is not None else None
|
||||
auth = base64.b64encode(f"{user}:{password}".encode()).decode()
|
||||
headers = {"Authorization": f"Basic {auth}"}
|
||||
if data:
|
||||
headers["Content-Type"] = "application/json"
|
||||
req = urllib.request.Request(
|
||||
f"https://{domain}/api/v1{path}", data=data, headers=headers, method=method
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=20, context=_CTX) as r:
|
||||
raw = r.read()
|
||||
return r.status, (json.loads(raw) if raw else {})
|
||||
except urllib.error.HTTPError as e:
|
||||
raw = e.read()
|
||||
try:
|
||||
return e.code, json.loads(raw)
|
||||
except (ValueError, json.JSONDecodeError):
|
||||
return e.code, {}
|
||||
|
||||
|
||||
def _run_git(args, cwd, env=None):
|
||||
full_env = {**os.environ, **(env or {})}
|
||||
result = subprocess.run(
|
||||
["git"] + args, cwd=cwd, capture_output=True, text=True, env=full_env, timeout=120
|
||||
)
|
||||
if result.returncode != 0:
|
||||
raise RuntimeError(f"git {' '.join(args)} failed:\n{result.stderr}")
|
||||
return result.stdout.strip()
|
||||
|
||||
|
||||
def test_lfs_roundtrip(live_app):
|
||||
"""LFS object upload→download round-trip + JWT-secret stability after restart.
|
||||
|
||||
Skips when compose.lfs.yml is absent (gitea main — LFS not enabled in this deploy).
|
||||
"""
|
||||
if not _lfs_available():
|
||||
import pytest
|
||||
pytest.skip(
|
||||
"compose.lfs.yml absent in gitea recipe checkout — LFS is not enabled on this branch. "
|
||||
"This test runs on lfs-plain-gitea (PR #1) and is EXPECTED_NA on main."
|
||||
)
|
||||
|
||||
user, password = admin_creds(live_app)
|
||||
repo_name = "ci-lfs-test"
|
||||
git_env = {
|
||||
"GIT_AUTHOR_NAME": "CI LFS Bot",
|
||||
"GIT_AUTHOR_EMAIL": "ci@ci.local",
|
||||
"GIT_COMMITTER_NAME": "CI LFS Bot",
|
||||
"GIT_COMMITTER_EMAIL": "ci@ci.local",
|
||||
"GIT_CONFIG_COUNT": "1",
|
||||
"GIT_CONFIG_KEY_0": f"url.https://{user}:{password}@{live_app}/.insteadOf",
|
||||
"GIT_CONFIG_VALUE_0": f"https://{live_app}/",
|
||||
# Suppress interactive LFS credential prompts
|
||||
"GIT_TERMINAL_PROMPT": "0",
|
||||
}
|
||||
|
||||
# 1. Create LFS test repo
|
||||
status, body = _api(
|
||||
live_app, "/user/repos", method="POST",
|
||||
body={"name": repo_name, "private": False, "auto_init": True, "default_branch": "main"},
|
||||
user=user, password=password,
|
||||
)
|
||||
assert status in (201, 409), f"repo create HTTP {status}: {body}"
|
||||
clone_url = f"https://{live_app}/{user}/{repo_name}.git"
|
||||
|
||||
tmpdir = tempfile.mkdtemp(prefix="ccci-gitea-lfs-")
|
||||
try:
|
||||
# 2. Clone repo
|
||||
_run_git(["clone", clone_url, tmpdir], cwd="/tmp", env=git_env)
|
||||
_run_git(["lfs", "install"], cwd=tmpdir, env=git_env)
|
||||
|
||||
# 3. Track *.bin as LFS
|
||||
_run_git(["lfs", "track", "*.bin"], cwd=tmpdir, env=git_env)
|
||||
_run_git(["add", ".gitattributes"], cwd=tmpdir, env=git_env)
|
||||
|
||||
# 4. Create a 1KB binary blob (content: random-looking but deterministic per test)
|
||||
blob = bytes(range(256)) * 4 # 1024 bytes
|
||||
blob_path = os.path.join(tmpdir, "testblob.bin")
|
||||
with open(blob_path, "wb") as f:
|
||||
f.write(blob)
|
||||
expected_sha256 = hashlib.sha256(blob).hexdigest()
|
||||
expected_oid = f"sha256:{expected_sha256}"
|
||||
|
||||
_run_git(["add", "testblob.bin"], cwd=tmpdir, env=git_env)
|
||||
_run_git(["commit", "-m", "test: add LFS blob"], cwd=tmpdir, env=git_env)
|
||||
|
||||
# 5. Push (LFS upload happens here)
|
||||
_run_git(["push", "origin", "HEAD:main"], cwd=tmpdir, env=git_env)
|
||||
|
||||
# Verify git-lfs pointer was tracked correctly
|
||||
lfs_ls = subprocess.run(
|
||||
["git", "lfs", "ls-files"],
|
||||
cwd=tmpdir, capture_output=True, text=True, env={**os.environ, **git_env}
|
||||
)
|
||||
assert "testblob.bin" in lfs_ls.stdout, f"testblob.bin not in git-lfs ls-files: {lfs_ls.stdout}"
|
||||
|
||||
# 6. Download in a FRESH clone (proves the LFS server stores and serves the object)
|
||||
fresh_dir = tempfile.mkdtemp(prefix="ccci-gitea-lfs-dl-")
|
||||
try:
|
||||
_run_git(["clone", clone_url, fresh_dir], cwd="/tmp", env=git_env)
|
||||
fetched_path = os.path.join(fresh_dir, "testblob.bin")
|
||||
assert os.path.exists(fetched_path), "testblob.bin not fetched in fresh clone"
|
||||
with open(fetched_path, "rb") as f:
|
||||
fetched = f.read()
|
||||
fetched_sha256 = hashlib.sha256(fetched).hexdigest()
|
||||
assert fetched_sha256 == expected_sha256, (
|
||||
f"LFS round-trip OID mismatch: expected {expected_oid}, got sha256:{fetched_sha256}"
|
||||
)
|
||||
finally:
|
||||
shutil.rmtree(fresh_dir, ignore_errors=True)
|
||||
|
||||
# 7. JWT-secret stability: restart gitea + assert LFS_JWT_SECRET unchanged.
|
||||
# Read the current secret from inside the container's rendered app.ini.
|
||||
current_jwt = lifecycle.exec_in_app(
|
||||
live_app,
|
||||
["sh", "-c", "grep -E '^LFS_JWT_SECRET' /etc/gitea/app.ini || echo NOT_FOUND"],
|
||||
timeout=30,
|
||||
).strip()
|
||||
assert current_jwt and "NOT_FOUND" not in current_jwt, (
|
||||
"Could not read LFS_JWT_SECRET from /etc/gitea/app.ini before restart"
|
||||
)
|
||||
|
||||
# Restart the gitea container
|
||||
lifecycle.exec_in_app(live_app, ["true"], timeout=5) # no-op to confirm exec works
|
||||
subprocess.run(
|
||||
["docker", "service", "update", "--force",
|
||||
live_app.replace(".", "_") + "_app"],
|
||||
capture_output=True, timeout=120,
|
||||
)
|
||||
# Wait for gitea to come back up
|
||||
from harness import generic
|
||||
# Re-read meta from the live_app fixture (meta is not in scope here — use the stored meta)
|
||||
import time
|
||||
deadline = time.time() + 120
|
||||
while time.time() < deadline:
|
||||
status2, _ = _api(live_app, "/api/v1/version", user=user, password=password)
|
||||
if status2 == 200:
|
||||
break
|
||||
time.sleep(5)
|
||||
assert status2 == 200, "gitea did not come back up after restart"
|
||||
|
||||
jwt_after = lifecycle.exec_in_app(
|
||||
live_app,
|
||||
["sh", "-c", "grep -E '^LFS_JWT_SECRET' /etc/gitea/app.ini || echo NOT_FOUND"],
|
||||
timeout=30,
|
||||
).strip()
|
||||
assert jwt_after == current_jwt, (
|
||||
"LFS_JWT_SECRET changed after restart — the regression the PR fixes is still present: "
|
||||
f"before={current_jwt!r} after={jwt_after!r}"
|
||||
)
|
||||
|
||||
# 8. Verify a fresh clone still works after restart (tokens still validate)
|
||||
post_restart_dir = tempfile.mkdtemp(prefix="ccci-gitea-lfs-restart-")
|
||||
try:
|
||||
_run_git(["clone", clone_url, post_restart_dir], cwd="/tmp", env=git_env)
|
||||
pr_blob = os.path.join(post_restart_dir, "testblob.bin")
|
||||
assert os.path.exists(pr_blob), "testblob.bin not fetched in post-restart clone"
|
||||
with open(pr_blob, "rb") as f:
|
||||
pr_data = f.read()
|
||||
assert hashlib.sha256(pr_data).hexdigest() == expected_sha256, (
|
||||
"LFS object corrupted after restart — JWT secret may have changed"
|
||||
)
|
||||
finally:
|
||||
shutil.rmtree(post_restart_dir, ignore_errors=True)
|
||||
|
||||
finally:
|
||||
shutil.rmtree(tmpdir, ignore_errors=True)
|
||||
_api(live_app, f"/repos/{user}/{repo_name}", method="DELETE", user=user, password=password)
|
||||
206
tests/gitea/ops.py
Normal file
206
tests/gitea/ops.py
Normal file
@ -0,0 +1,206 @@
|
||||
"""gitea — pre-op seed hooks (phase gtea).
|
||||
|
||||
Admin user setup:
|
||||
gitea has no default admin user after deploy. ops.pre_install creates a `ci_admin` user via
|
||||
the gitea CLI inside the container, stores the generated password in a per-domain temp file
|
||||
(/tmp/ccci-gitea-admin-<domain>.json, mode 600), and every subsequent pre_<op> hook reads it.
|
||||
The ops module is re-imported per op (the orchestrator does not cache it), so the file is the
|
||||
single durable credential store for the run duration.
|
||||
|
||||
Data-integrity marker:
|
||||
Marker = git repo named `ci-marker`, owned by `ci_admin`, with an auto-initialised README.md.
|
||||
pre_install : create admin user + marker repo
|
||||
pre_upgrade : assert marker exists (idempotent re-create as guard)
|
||||
pre_backup : assert marker exists (idempotent re-create as guard)
|
||||
pre_restore : DELETE the marker repo (diverge from backup state)
|
||||
test_upgrade : assert marker survived the chaos redeploy
|
||||
test_backup : assert marker was captured by the backup
|
||||
test_restore : assert marker returned (restore reverted deletion)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import secrets
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "runner"))
|
||||
from harness import generic, lifecycle # noqa: E402
|
||||
|
||||
_ADMIN_USER = "ci_admin"
|
||||
_ADMIN_EMAIL = "ci@ci.local"
|
||||
_MARKER_REPO = "ci-marker"
|
||||
|
||||
_SSL_CTX = None
|
||||
|
||||
|
||||
def _ssl_ctx():
|
||||
global _SSL_CTX
|
||||
if _SSL_CTX is None:
|
||||
import ssl
|
||||
ctx = ssl.create_default_context()
|
||||
ctx.check_hostname = False
|
||||
ctx.verify_mode = ssl.CERT_NONE
|
||||
_SSL_CTX = ctx
|
||||
return _SSL_CTX
|
||||
|
||||
|
||||
def _creds_path(domain: str) -> str:
|
||||
return f"/tmp/ccci-gitea-admin-{domain}.json"
|
||||
|
||||
|
||||
def _load_creds(domain: str) -> tuple[str, str] | None:
|
||||
path = _creds_path(domain)
|
||||
if os.path.exists(path):
|
||||
with open(path) as f:
|
||||
d = json.load(f)
|
||||
return d["user"], d["password"]
|
||||
return None
|
||||
|
||||
|
||||
def _save_creds(domain: str, password: str) -> None:
|
||||
path = _creds_path(domain)
|
||||
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
||||
with os.fdopen(fd, "w") as f:
|
||||
json.dump({"user": _ADMIN_USER, "password": password}, f)
|
||||
|
||||
|
||||
def _ensure_admin(domain: str) -> tuple[str, str]:
|
||||
"""Return (user, password) for the ci_admin user, creating it if this is the first call."""
|
||||
existing = _load_creds(domain)
|
||||
if existing:
|
||||
return existing
|
||||
|
||||
password = secrets.token_hex(16)
|
||||
try:
|
||||
lifecycle.exec_in_app(
|
||||
domain,
|
||||
[
|
||||
"gitea", "admin", "user", "create", "--admin",
|
||||
"--username", _ADMIN_USER,
|
||||
"--password", password,
|
||||
"--email", _ADMIN_EMAIL,
|
||||
"--must-change-password=false",
|
||||
],
|
||||
timeout=120,
|
||||
)
|
||||
except RuntimeError as e:
|
||||
if "already exists" in str(e).lower():
|
||||
lifecycle.exec_in_app(
|
||||
domain,
|
||||
[
|
||||
"gitea", "admin", "user", "change-password",
|
||||
"--username", _ADMIN_USER,
|
||||
"--password", password,
|
||||
],
|
||||
timeout=60,
|
||||
)
|
||||
else:
|
||||
raise
|
||||
_save_creds(domain, password)
|
||||
return _ADMIN_USER, password
|
||||
|
||||
|
||||
def _gitea_api(
|
||||
domain: str, path: str, method: str = "GET", body=None, user: str = "", password: str = ""
|
||||
) -> tuple[int, object]:
|
||||
"""Call the gitea REST API (basic-auth). Returns (status, json_body_or_None)."""
|
||||
data = json.dumps(body).encode() if body is not None else None
|
||||
auth = base64.b64encode(f"{user}:{password}".encode()).decode()
|
||||
headers: dict[str, str] = {"Authorization": f"Basic {auth}"}
|
||||
if data:
|
||||
headers["Content-Type"] = "application/json"
|
||||
req = urllib.request.Request(
|
||||
f"https://{domain}/api/v1{path}", data=data, headers=headers, method=method
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=30, context=_ssl_ctx()) as r:
|
||||
raw = r.read()
|
||||
return r.status, (json.loads(raw) if raw else None)
|
||||
except urllib.error.HTTPError as e:
|
||||
raw = e.read()
|
||||
try:
|
||||
return e.code, json.loads(raw)
|
||||
except (ValueError, json.JSONDecodeError):
|
||||
return e.code, None
|
||||
|
||||
|
||||
def _create_marker_repo(domain: str, user: str, password: str) -> bool:
|
||||
"""Create ci-marker repo with auto_init=True. Returns True if created or already exists."""
|
||||
status, _ = _gitea_api(
|
||||
domain, "/user/repos", method="POST",
|
||||
body={"name": _MARKER_REPO, "private": False, "auto_init": True, "default_branch": "main"},
|
||||
user=user, password=password,
|
||||
)
|
||||
return status in (201, 409)
|
||||
|
||||
|
||||
def _delete_marker_repo(domain: str, user: str, password: str) -> bool:
|
||||
"""Delete ci-marker repo. Returns True if deleted or already gone."""
|
||||
status, _ = _gitea_api(
|
||||
domain, f"/repos/{user}/{_MARKER_REPO}", method="DELETE",
|
||||
user=user, password=password,
|
||||
)
|
||||
return status in (204, 404)
|
||||
|
||||
|
||||
def marker_repo_exists(domain: str, user: str, password: str) -> bool:
|
||||
"""Check whether the ci-marker repo is present. Called by test_*.py overlays."""
|
||||
status, _ = _gitea_api(
|
||||
domain, f"/repos/{user}/{_MARKER_REPO}", user=user, password=password
|
||||
)
|
||||
return status == 200
|
||||
|
||||
|
||||
def admin_creds(domain: str) -> tuple[str, str]:
|
||||
"""Return (user, password) for the ci_admin account. Called by test_*.py overlays."""
|
||||
existing = _load_creds(domain)
|
||||
if existing:
|
||||
return existing
|
||||
raise RuntimeError(
|
||||
f"No admin creds for {domain} — was ops.pre_install called for this run?"
|
||||
)
|
||||
|
||||
|
||||
def pre_install(ctx):
|
||||
"""After deploy: create admin user + seed the marker repo."""
|
||||
# App is already deployed + healthy at this point (pre_install runs after deploy+healthcheck).
|
||||
# Wait explicitly so the API is fully ready (READY_PROBE guards this at the harness level, but
|
||||
# belt-and-suspenders here in case this op is called in isolation).
|
||||
generic.assert_serving(ctx.domain, ctx.meta)
|
||||
user, password = _ensure_admin(ctx.domain)
|
||||
ok = _create_marker_repo(ctx.domain, user, password)
|
||||
assert ok, f"pre_install: could not create {_MARKER_REPO} repo on {ctx.domain}"
|
||||
print(f" gitea ops: admin {user!r} + repo {_MARKER_REPO!r} ready on {ctx.domain}", flush=True)
|
||||
|
||||
|
||||
def pre_upgrade(ctx):
|
||||
"""Before upgrade: ensure marker repo exists (data-continuity baseline)."""
|
||||
user, password = _ensure_admin(ctx.domain)
|
||||
ok = _create_marker_repo(ctx.domain, user, password)
|
||||
assert ok, f"pre_upgrade: could not ensure {_MARKER_REPO} repo exists on {ctx.domain}"
|
||||
|
||||
|
||||
def pre_backup(ctx):
|
||||
"""Before backup: ensure marker repo exists (prove backup captures it)."""
|
||||
user, password = _ensure_admin(ctx.domain)
|
||||
ok = _create_marker_repo(ctx.domain, user, password)
|
||||
assert ok, f"pre_backup: could not ensure {_MARKER_REPO} repo exists on {ctx.domain}"
|
||||
|
||||
|
||||
def pre_restore(ctx):
|
||||
"""After backup, before restore: DELETE marker repo (diverge from backup state).
|
||||
A successful restore must bring it back; a no-op restore leaves it absent → test fails."""
|
||||
user, password = _ensure_admin(ctx.domain)
|
||||
# backupbot cycles the gitea container during backup — wait for it to be back up.
|
||||
generic.assert_serving(ctx.domain, ctx.meta)
|
||||
ok = _delete_marker_repo(ctx.domain, user, password)
|
||||
assert ok, f"pre_restore: could not delete {_MARKER_REPO} repo on {ctx.domain}"
|
||||
assert not marker_repo_exists(ctx.domain, user, password), (
|
||||
f"pre_restore: {_MARKER_REPO} still present after delete — divergence did not take"
|
||||
)
|
||||
print(f" gitea ops: {_MARKER_REPO!r} deleted (diverged from backup state)", flush=True)
|
||||
@ -1,21 +1,60 @@
|
||||
# Per-recipe harness config for gitea when used as an install-time dep provider (phase drone).
|
||||
# Gitea is not enrolled as a standalone recipe-under-test here — it serves as the SCM backend
|
||||
# that drone requires to boot. The harness deploys it before drone, provisions an admin user +
|
||||
# OAuth2 app inside it (sso.setup_gitea_oauth), and tears it down after.
|
||||
# Per-recipe harness config for gitea — used in TWO roles:
|
||||
#
|
||||
# Database: sqlite3 (via compose.sqlite3.yml overlay) — no MariaDB needed for a CI dep; lighter
|
||||
# resource footprint, and the gitea data persists only for the duration of the drone test run.
|
||||
# 1. DEP PROVIDER (RECIPE=drone): gitea is deployed as an install-time dependency for the drone
|
||||
# recipe. The harness deploys it before drone, provisions an admin user + OAuth2 app inside it
|
||||
# (sso.setup_gitea_oauth), and tears it down after. All keys below apply to this path too.
|
||||
#
|
||||
# 2. RECIPE-UNDER-TEST (RECIPE=gitea): gitea is the enrolled recipe being tested. The full
|
||||
# install/upgrade/backup/restore/custom lifecycle runs against it. The recipe-under-test keys
|
||||
# (BACKUP_CAPABLE, SCREENSHOT, READY_PROBE, EXPECTED_NA) below are harmless to the dep path.
|
||||
#
|
||||
# Database: sqlite3 (compose.sqlite3.yml) — matches the dep config + lightest footprint.
|
||||
# Backup: REAL tier — compose.yml carries backupbot.backup=true labels.
|
||||
#
|
||||
# LFS overlay (compose.lfs.yml, PR #1 lfs-plain-gitea): enabled ONLY when
|
||||
# (a) compose.lfs.yml is present in the recipe checkout (it's on the PR branch, not main), AND
|
||||
# (b) RECIPE=gitea (this is a recipe-under-test run, not a drone dep deploy).
|
||||
# See DECISIONS.md (phase gtea) and tests/gitea/PARITY.md for the full split rationale.
|
||||
|
||||
import os as _os
|
||||
|
||||
HEALTH_PATH = "/api/healthz"
|
||||
HEALTH_OK = (200,)
|
||||
DEPLOY_TIMEOUT = 600
|
||||
HTTP_TIMEOUT = 600
|
||||
BACKUP_CAPABLE = True # compose.yml carries backupbot.backup=true labels — force on
|
||||
|
||||
|
||||
def READY_PROBE(ctx):
|
||||
# Extra readiness: the /api/v1/version endpoint returns 200 JSON when the API is fully up
|
||||
# (healthz passes earlier, before the API router is fully wired). Avoids flaky test_install
|
||||
# failures where healthz=200 but admin API calls fail with 503.
|
||||
return [{"host": ctx.domain, "path": "/api/v1/version", "ok": (200,)}]
|
||||
|
||||
|
||||
def SCREENSHOT(page, ctx):
|
||||
# Navigate to the sign-in page — a credential-free view that shows the gitea UI.
|
||||
from playwright.sync_api import sync_playwright # noqa: F401 — re-entry guard
|
||||
page.goto(f"{ctx.base_url}/user/login", wait_until="networkidle", timeout=30_000)
|
||||
page.wait_for_selector("form.ui.form", timeout=15_000)
|
||||
|
||||
|
||||
def _lfs_enabled():
|
||||
"""True when compose.lfs.yml is available in the recipe checkout AND this is a
|
||||
recipe-under-test run (RECIPE=gitea). Both conditions prevent LFS leaking into the dep path."""
|
||||
abra_dir = _os.environ.get("ABRA_DIR") or _os.path.expanduser("~/.abra")
|
||||
lfs_overlay = _os.path.join(abra_dir, "recipes", "gitea", "compose.lfs.yml")
|
||||
return _os.path.exists(lfs_overlay) and _os.environ.get("RECIPE", "") == "gitea"
|
||||
|
||||
|
||||
def EXTRA_ENV(ctx):
|
||||
# Use sqlite3 (no external DB dep), and relax access controls so the harness can create the
|
||||
# admin user and OAuth2 app via API immediately after deploy.
|
||||
return {
|
||||
"COMPOSE_FILE": "compose.yml:compose.sqlite3.yml",
|
||||
lfs = _lfs_enabled()
|
||||
compose_file = "compose.yml:compose.sqlite3.yml"
|
||||
if lfs:
|
||||
compose_file += ":compose.lfs.yml"
|
||||
|
||||
env = {
|
||||
"COMPOSE_FILE": compose_file,
|
||||
"GITEA_APP_NAME": "CI Dep Gitea",
|
||||
"GITEA_ALLOW_ONLY_EXTERNAL_REGISTRATION": "false",
|
||||
"GITEA_AUTO_WATCH_NEW_REPOS": "false",
|
||||
@ -43,10 +82,14 @@ def EXTRA_ENV(ctx):
|
||||
"GITEA_REPO_UPLOAD_MAX_FILES": "0",
|
||||
"GITEA_ENABLE_PUSH_CREATE_USER": "false",
|
||||
"GITEA_ENABLE_PUSH_CREATE_ORG": "false",
|
||||
"GITEA_LFS_START_SERVER": "false",
|
||||
"GITEA_LFS_START_SERVER": "true" if lfs else "false",
|
||||
# CORS allow-domain — left empty; OAuth2 redirects are not CORS-gated.
|
||||
"GITEA_CORS_ALLOW_DOMAIN": "",
|
||||
# Mailer placeholder — required by app.ini.tmpl but SMTP is not enabled.
|
||||
"GITEA_MAILER_FROM": "noreply@ci.local",
|
||||
"GITEA_MAILER_USER": "noreply@ci.local",
|
||||
}
|
||||
if lfs:
|
||||
# Tell abra's secret generator which version to use for lfs_jwt_secret.
|
||||
env["SECRET_LFS_JWT_SECRET_VERSION"] = "v1"
|
||||
return env
|
||||
|
||||
27
tests/gitea/test_backup.py
Normal file
27
tests/gitea/test_backup.py
Normal file
@ -0,0 +1,27 @@
|
||||
"""gitea — BACKUP overlay (phase gtea).
|
||||
|
||||
ops.pre_backup ensured the ci-marker repo existed before the backup was taken.
|
||||
The orchestrator performed the backup (generic tier asserted a snapshot artifact).
|
||||
This overlay ADDS: the marker repo is present at backup time (backup captured real data).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "runner"))
|
||||
from harness import generic # noqa: E402
|
||||
|
||||
sys.path.insert(0, os.path.dirname(__file__))
|
||||
from ops import admin_creds, marker_repo_exists # noqa: E402
|
||||
|
||||
|
||||
def test_backup_captures_marker_repo(live_app, meta):
|
||||
"""The ci-marker repo is present at backup time (backup captures the sqlite3 state)."""
|
||||
# backupbot cycles the gitea container during backup — wait for it to be back up.
|
||||
generic.assert_serving(live_app, meta)
|
||||
user, password = admin_creds(live_app)
|
||||
assert marker_repo_exists(live_app, user, password), (
|
||||
f"{live_app}: ci-marker repo is not present at backup time (backup would capture empty state)"
|
||||
)
|
||||
67
tests/gitea/test_install.py
Normal file
67
tests/gitea/test_install.py
Normal file
@ -0,0 +1,67 @@
|
||||
"""gitea — INSTALL overlay (phase gtea).
|
||||
|
||||
Extends the generic serving assertion with gitea-specific checks:
|
||||
- /api/healthz returns 200 (already covered by HEALTH_PATH, reinforced here)
|
||||
- /api/v1/version returns 200 JSON (API router is up)
|
||||
- Admin API is reachable and authenticated (ops.pre_install created the admin user)
|
||||
- Playwright: the sign-in page renders in a real browser
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "runner"))
|
||||
from harness import browser as harness_browser # noqa: E402
|
||||
from harness import generic, lifecycle # noqa: E402
|
||||
|
||||
sys.path.insert(0, os.path.dirname(__file__))
|
||||
from ops import admin_creds # noqa: E402
|
||||
|
||||
|
||||
def test_install_gitea(live_app, meta):
|
||||
"""Gitea is serving, API is up, admin API is authenticated, UI loads in browser."""
|
||||
# 1. Generic serving (services converged + HEALTH_PATH 200 + TLS valid)
|
||||
generic.assert_serving(live_app, meta)
|
||||
|
||||
# 2. API version endpoint
|
||||
status, body = lifecycle.http_fetch(live_app, "/api/v1/version")
|
||||
assert status == 200, f"{live_app}/api/v1/version: HTTP {status} (expected 200)"
|
||||
assert body, "/api/v1/version: empty response body"
|
||||
|
||||
# 3. Admin API reachable + authenticated — GET /api/v1/users/search as ci_admin
|
||||
user, password = admin_creds(live_app)
|
||||
import base64
|
||||
import urllib.request
|
||||
import ssl
|
||||
ctx = ssl.create_default_context()
|
||||
ctx.check_hostname = False
|
||||
ctx.verify_mode = ssl.CERT_NONE
|
||||
auth = base64.b64encode(f"{user}:{password}".encode()).decode()
|
||||
req = urllib.request.Request(
|
||||
f"https://{live_app}/api/v1/users/search?limit=1",
|
||||
headers={"Authorization": f"Basic {auth}"},
|
||||
)
|
||||
import urllib.error
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=15, context=ctx) as r:
|
||||
assert r.status == 200, f"Admin API /users/search returned {r.status}"
|
||||
except urllib.error.HTTPError as e:
|
||||
raise AssertionError(f"Admin API /users/search failed: HTTP {e.code}") from e
|
||||
|
||||
# 4. Playwright: sign-in page renders
|
||||
from playwright.sync_api import sync_playwright
|
||||
url = f"https://{live_app}/user/login"
|
||||
with sync_playwright() as p:
|
||||
browser = p.chromium.launch(args=["--no-sandbox"])
|
||||
try:
|
||||
page = browser.new_context(ignore_https_errors=True).new_page()
|
||||
harness_browser.goto_with_retry(page, url, accept_statuses=(200, 302), goto_timeout_ms=30_000)
|
||||
page.wait_for_selector("input[name='_csrf']", timeout=20_000)
|
||||
content = page.content()
|
||||
assert "gitea" in content.lower() or "sign in" in content.lower(), (
|
||||
"Sign-in page did not render expected gitea content"
|
||||
)
|
||||
finally:
|
||||
browser.close()
|
||||
27
tests/gitea/test_restore.py
Normal file
27
tests/gitea/test_restore.py
Normal file
@ -0,0 +1,27 @@
|
||||
"""gitea — RESTORE overlay (phase gtea).
|
||||
|
||||
ops.pre_restore DELETED the ci-marker repo (diverged from backup state).
|
||||
The orchestrator restored from the backup. This overlay ADDS: the marker repo returned
|
||||
(sqlite3 was restored to the backed-up state). A no-op restore would leave the repo absent.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "runner"))
|
||||
from harness import generic # noqa: E402
|
||||
|
||||
sys.path.insert(0, os.path.dirname(__file__))
|
||||
from ops import admin_creds, marker_repo_exists # noqa: E402
|
||||
|
||||
|
||||
def test_restore_returns_marker_repo(live_app, meta):
|
||||
"""The ci-marker repo returned after restore (sqlite3 reverted to backed-up state)."""
|
||||
generic.assert_serving(live_app, meta)
|
||||
user, password = admin_creds(live_app)
|
||||
assert marker_repo_exists(live_app, user, password), (
|
||||
f"{live_app}: ci-marker repo absent after restore — restore did not revert the deletion "
|
||||
"(sqlite3 not restored to backed-up state)"
|
||||
)
|
||||
25
tests/gitea/test_upgrade.py
Normal file
25
tests/gitea/test_upgrade.py
Normal file
@ -0,0 +1,25 @@
|
||||
"""gitea — UPGRADE overlay (phase gtea).
|
||||
|
||||
ops.pre_upgrade ensured the ci-marker repo existed before the chaos redeploy.
|
||||
The orchestrator performed the upgrade. This overlay ADDS: the marker repo survived.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "runner"))
|
||||
from harness import generic # noqa: E402
|
||||
|
||||
sys.path.insert(0, os.path.dirname(__file__))
|
||||
from ops import admin_creds, marker_repo_exists # noqa: E402
|
||||
|
||||
|
||||
def test_upgrade_preserves_marker_repo(live_app, meta):
|
||||
"""The ci-marker repo survived the upgrade to the PR head (data continuity)."""
|
||||
generic.assert_serving(live_app, meta)
|
||||
user, password = admin_creds(live_app)
|
||||
assert marker_repo_exists(live_app, user, password), (
|
||||
f"{live_app}: ci-marker repo did not survive the upgrade (sqlite3 data lost)"
|
||||
)
|
||||
Reference in New Issue
Block a user