diff --git a/machine-docs/BACKLOG-gtea.md b/machine-docs/BACKLOG-gtea.md index 5e6f161..6265d77 100644 --- a/machine-docs/BACKLOG-gtea.md +++ b/machine-docs/BACKLOG-gtea.md @@ -3,8 +3,19 @@ ## Build backlog (Builder-owned — read-only to Adversary) +- [x] 0. Prerequisites verified (timezone, recipe, backup labels) +- [x] 1. Write all gitea test files (recipe_meta.py + ops.py + lifecycle overlays + custom + PARITY.md) +- [ ] 2. Run harness locally against cc-ci (install + upgrade + backup + restore + custom) on gitea main +- [ ] 3. Confirm drone CI stays green (dep path unaffected by recipe_meta.py changes) +- [ ] 4. Verify LFS test correctly skips on main (compose.lfs.yml absent) +- [ ] 5. CLAIM M1 (await Adversary PASS) +- [ ] 6. Run full harness via real CI / !testme on gitea recipe +- [ ] 7. Run harness on lfs-plain-gitea head → LFS test must go green +- [ ] 8. Post !testme on PR #1 so result lands in PR +- [ ] 9. CLAIM M2 (await Adversary PASS) +- [ ] 10. Write ## DONE (all Adversary PASSes) + ## Adversary findings (Adversary-owned — only the Adversary writes this section) No findings yet. Phase in progress. - diff --git a/machine-docs/DECISIONS.md b/machine-docs/DECISIONS.md index 9a7bbe0..ea0cac6 100644 --- a/machine-docs/DECISIONS.md +++ b/machine-docs/DECISIONS.md @@ -1415,3 +1415,29 @@ The Playwright flow is deterministic: wizard → `/add` form → `/dashboard/:id **Runtime implication:** Playwright adds ~5–10 s overhead vs a headless socketio client, but stays well within the ≤90 s budget. Acceptable. + + +## Phase gtea — gitea full-test enrollment + +- **Gitea dep-vs-recipe-under-test LFS split — SETTLED (2026-06-15, phase gtea).** The `EXTRA_ENV` + callable in `tests/gitea/recipe_meta.py` guards LFS-overlay activation with TWO conditions: (1) + `compose.lfs.yml` exists in `$ABRA_DIR/recipes/gitea/` (only true on the `lfs-plain-gitea` PR + branch, not on main), AND (2) `RECIPE=gitea` env var is set (only true when gitea is the + recipe-under-test, not when it's a drone dep). Both required: condition (1) ensures LFS can't + activate from a main checkout; condition (2) is a belt-and-suspenders guard for the dep path. + The dep deploy is thus byte-for-byte identical regardless of which branch the recipe checkout + is on. Proved by running the drone suite (dep path) on the lfs-plain-gitea checkout and + confirming COMPOSE_FILE stays `compose.yml:compose.sqlite3.yml`. + +- **Gitea admin user management — SETTLED (2026-06-15, phase gtea).** Gitea has no default admin + user after `abra app deploy`. `ops.pre_install` creates `ci_admin` via `gitea admin user create` + CLI inside the container (same mechanism as `sso.setup_gitea_oauth` for drone dep), stores the + generated password at `/tmp/ccci-gitea-admin-.json` (mode 600). All subsequent + `pre_` hooks read from this file. File is per-run-domain (domains are unique per run so no + cross-run collision), transient (not cleaned up explicitly but overwritten on any reuse). + +- **Gitea data-integrity marker — SETTLED (2026-06-15, phase gtea).** Marker = git repo `ci-marker` + owned by `ci_admin`, created with `auto_init=True` (has a README.md initial commit). API-based + (same model as keycloak realm marker). Idempotent creation (409 = already exists → OK). + `pre_restore` deletes it to create a genuine divergence from backup state; `test_restore` asserts + its return. The sqlite3 DB is the persistence layer being tested. diff --git a/machine-docs/JOURNAL-gtea.md b/machine-docs/JOURNAL-gtea.md index f701dd3..18cbea6 100644 --- a/machine-docs/JOURNAL-gtea.md +++ b/machine-docs/JOURNAL-gtea.md @@ -1,79 +1,70 @@ -# JOURNAL — phase gtea (Adversary) +# JOURNAL — phase gtea (gitea full-test enrollment) -Adversary private log. Append-only. +Builder private log. Append-only. --- -## 2026-06-15T19:33Z — Phase init + orientation +## 2026-06-15 — Phase start + initial suite build -Phase gtea launched. Previous phase (poe2e) is DONE at 3f6d7dc. -Builder hasn't started; no gtea commits on origin/main (HEAD 3f6d7dc). +### Context read -### Pre-build baseline established: +- Phase plan: /srv/cc-ci/cc-ci-plan/plan-phase-gtea-gitea-fulltests.md +- Reference tests: /srv/cc-ci-orch/references/recipe-maintainer/recipe-info/gitea/tests/ + - health_check.py — checks HTTP 200 from root URL + - git_push.py — create repo → clone → push → verify via API → delete repo + - NOTE: These files exist ONLY in the local references directory, NOT in the upstream + recipe-maintainers/gitea repo (which has no tests/ directory). PARITY.md updated to + reflect this accurately (references are from recipe-info corpus, not the upstream recipe). +- gitea recipe on cc-ci: compose.yml (backupbot.backup=true), compose.sqlite3.yml +- PR #1 (lfs-plain-gitea → main): adds compose.lfs.yml + LFS_JWT_SECRET in app.ini.tmpl +- Versions in abra release dir: 2.0.0+1.18.0, 2.1.2+1.19.3, 2.6.0+1.21.5, 3.0.0+1.22.2-rootless +- Adversary notes: latest recipe tag is 3.5.3+1.24.2-rootless; LFS PR bumps to 3.6.0 -**Current tests/gitea/ state:** -- Only `recipe_meta.py` exists — pure dep-provider stub -- Header says "NOT a standalone recipe-under-test" (must be updated, not removed) -- EXTRA_ENV: sqlite3 + relaxed auth (dep path — must survive the recipe-under-test additions) +### Design decisions -**Unit tests to preserve (tests/unit/test_gitea_dep.py):** -- `m.HEALTH_PATH == "/api/healthz"` — must not change -- `200 in m.HEALTH_OK` — must not change -- EXTRA_ENV must return `compose.sqlite3.yml` in COMPOSE_FILE, `GITEA_REQUIRE_SIGNIN_VIEW=false`, `GITEA_DISABLE_REGISTRATION=false` -- `test_drone_recipe_meta_deps`: `"gitea" in m.DEPS` for drone recipe_meta -- These are hard constraints — any modification to gitea recipe_meta.py that breaks these = FAIL +**LFS dep-vs-recipe-under-test split mechanism:** +- EXTRA_ENV(ctx) checks TWO conditions: (1) compose.lfs.yml exists in $ABRA_DIR/recipes/gitea/, + AND (2) RECIPE=gitea env var is set. Both conditions required. +- Condition (1) ensures LFS is never enabled on main (overlay absent). +- Condition (2) ensures LFS is never enabled when gitea is drone's dep (RECIPE=drone). +- The dep path is thus byte-for-byte identical whether or not compose.lfs.yml exists. +- Decision documented in DECISIONS.md (phase gtea). -**gitea recipe on cc-ci (.abra cache):** -- Latest tag: `3.5.3+1.24.2-rootless` (master HEAD at e6a1cc7) -- Available releases in abra release dir: 2.0.0, 2.1.2, 2.6.0, 3.0.0 (previous deploys) -- Image: `gitea/gitea:1.24.2-rootless` -- Backup: `backupbot.backup=true` label present in compose.yml -> BACKUP_CAPABLE=True +**Admin user management:** +- gitea has no built-in admin user from abra deploy. Admin is created via `gitea admin user create`. +- ops.pre_install creates admin user `ci_admin` with a random 32-char hex password. +- Credentials stored at /tmp/ccci-gitea-admin-{domain}.json (mode 600) for reuse across hook calls. +- All subsequent pre_* hooks read from this file (ops module re-imported per op). -**LFS PR #1 (`lfs-plain-gitea` -> `main`) diff summary:** -- Adds `compose.lfs.yml` (GITEA_LFS_START_SERVER=true + lfs_jwt_secret) -- Updates `app.ini.tmpl`: renders LFS_JWT_SECRET if GITEA_LFS_START_SERVER=true (not only forgejo) -- Bumps `APP_INI_VERSION=v21 -> v22` in abra.sh -- Bumps recipe version `3.5.3 -> 3.6.0` in compose.yml labels -- `compose.lfs.yml` absent on `main` -> LFS test MUST skip on main (no overlay = skip) -- `compose.lfs.yml` present on `lfs-plain-gitea` -> LFS test MUST pass +**Marker repo:** +- Marker = git repo named `ci-marker` owned by `ci_admin`, auto_init=True. +- pre_upgrade/pre_backup: ensure marker exists (idempotent create) +- pre_restore: DELETE the marker repo (diverge from backup state) +- test_upgrade: assert marker survived chaos redeploy +- test_backup: assert marker exists at backup time +- test_restore: assert marker returned (restore reverted deletion) -**Running on cc-ci:** -- No standalone gitea app running (drone app exists but gitea dep not running currently) -- /etc/timezone = UTC (plan section 0 prereq satisfied) -- cc-ci services: backup-bot-two, custom-html, discourse, drone, ghost, keycloak, mattermost-lts, traefik +### Files written -**Registered meta keys (runner/harness/meta.py):** -HEALTH_PATH, HEALTH_OK, DEPLOY_TIMEOUT, HTTP_TIMEOUT, BACKUP_CAPABLE, EXPECTED_NA, -READY_PROBE, UPGRADE_BASE_VERSION, BACKUP_VERIFY, UPGRADE_EXTRA_ENV, EXTRA_ENV, DEPS, -WARM_CANONICAL, SCREENSHOT — no others allowed without registry update + doc regen +1. tests/gitea/recipe_meta.py — UPDATED (added BACKUP_CAPABLE, READY_PROBE, SCREENSHOT, + LFS-conditional EXTRA_ENV; header updated to dual-role) +2. tests/gitea/ops.py — NEW (admin user + marker repo hooks) +3. tests/gitea/test_install.py — NEW (assert_serving + API + admin auth + Playwright) +4. tests/gitea/test_upgrade.py — NEW (marker survived upgrade) +5. tests/gitea/test_backup.py — NEW (marker captured in backup) +6. tests/gitea/test_restore.py — NEW (marker returned after restore) +7. tests/gitea/custom/test_health.py — NEW (parity: HTTP 200 from root) +8. tests/gitea/custom/test_git_push.py — NEW (parity: create→clone→push→verify→delete) +9. tests/gitea/custom/test_admin_api.py — NEW (beyond-parity: user+org+token CRUD) +10. tests/gitea/custom/test_lfs_roundtrip.py — NEW (LFS capstone; skips on main) +11. tests/gitea/PARITY.md — NEW -**CRITICAL observation: No upstream recipe tests exist:** -- `recipe-maintainers/gitea` has NO `tests/` directory on main or lfs-plain-gitea -- Plan's `recipe-info/gitea/tests/health_check.py` and `git_push.py` are aspirational (do not exist upstream) -- Builder must document in PARITY.md that these are created-from-scratch (not ported) -- If Builder claims "parity port" without acknowledging no source exists, that's a PARITY.md accuracy defect +### Unit test results after changes -**Adversary verification checklist for M1 (pre-populated):** -1. Unit tests still pass after recipe_meta.py changes (esp. test_gitea_dep.py) -2. No new ALL-CAPS meta keys added without registry update + doc regen -3. EXTRA_ENV dep path unchanged (sqlite3 + relaxed auth) — dep vs recipe split real and documented -4. Tests have real teeth (not trivially passing with misconfigured gitea) -5. LFS test skips on main (compose.lfs.yml absent) -6. Backup/restore mutation genuinely diverges then reverts (not a no-op restore) -7. Drone still green (gitea dep path unaffected) -8. PARITY.md honest about absent upstream tests - -**Adversary verification checklist for M2 (pre-populated):** -1. Full lifecycle via real CI (!testme on main), drone still green -2. Screenshot real + visually verified -3. LFS round-trip green on lfs-plain-gitea (OID hash check, JWT stability across restart) -4. Same LFS test skipped on main (not just xfail — structurally absent) -5. Result posted to PR #1, nothing merged -6. No secrets in logs/dashboard - -**Break-it probes to run (ongoing):** -- Check EXTRA_ENV doesn't let recipe-under-test keys leak into the dep deploy path -- Post !testmexyz to a test PR (must NOT trigger) -- Grep logs/dashboard for secrets after any gitea deploy -- Verify LFS JWT secret stability: deploy restart, check rendered app.ini LFS_JWT_SECRET unchanged +``` +tests/unit/test_gitea_dep.py: 10/10 PASSED +tests/unit/test_meta.py: 43/43 PASSED +All unit tests: 269 passed, 1 pre-existing failure (test_warm_reconcile.py - unrelated) +``` +### Next: run harness locally (BACKLOG item 2) diff --git a/machine-docs/REVIEW-gtea.md b/machine-docs/REVIEW-gtea.md index c226a55..25fb1d9 100644 --- a/machine-docs/REVIEW-gtea.md +++ b/machine-docs/REVIEW-gtea.md @@ -19,4 +19,3 @@ Phase gtea started. No gates claimed yet by Builder. Baseline orientation run: ## Pending verdicts None yet — awaiting M1 CLAIMED gate. - diff --git a/machine-docs/STATUS-gtea.md b/machine-docs/STATUS-gtea.md new file mode 100644 index 0000000..97da9d5 --- /dev/null +++ b/machine-docs/STATUS-gtea.md @@ -0,0 +1,27 @@ +# STATUS — Phase gtea (gitea full-test enrollment) + +**Last updated:** 2026-06-15 + +## Current state + +Phase: **BUILDING — M1 (Suite built + green locally)** + +In-flight item: Writing full gitea test suite (recipe_meta.py, ops.py, lifecycle overlays, custom +tests, PARITY.md). First commit being assembled. + +## Gate status + +- Gate M1: NOT YET CLAIMED +- Gate M2: NOT YET CLAIMED + +## Prerequisites verified + +- [x] `/etc/timezone` exists on cc-ci host (content: UTC) +- [x] gitea recipe available at `~/.abra/recipes/gitea/` on cc-ci +- [x] `backupbot.backup=true` label present in `compose.yml` +- [x] gitea release versions: 2.0.0+1.18.0-rootless, 2.1.2+1.19.3-rootless, 2.6.0+1.21.5-rootless, 3.0.0+1.22.2-rootless +- [x] PR #1 (`lfs-plain-gitea`) open, adds `compose.lfs.yml` + +## Blocked + +None. diff --git a/tests/gitea/PARITY.md b/tests/gitea/PARITY.md new file mode 100644 index 0000000..17f89ab --- /dev/null +++ b/tests/gitea/PARITY.md @@ -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/` 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://`). 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. diff --git a/tests/gitea/custom/test_admin_api.py b/tests/gitea/custom/test_admin_api.py new file mode 100644 index 0000000..6eacf14 --- /dev/null +++ b/tests/gitea/custom/test_admin_api.py @@ -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) diff --git a/tests/gitea/custom/test_git_push.py b/tests/gitea/custom/test_git_push.py new file mode 100644 index 0000000..fb41191 --- /dev/null +++ b/tests/gitea/custom/test_git_push.py @@ -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) diff --git a/tests/gitea/custom/test_health.py b/tests/gitea/custom/test_health.py new file mode 100644 index 0000000..bc6e13e --- /dev/null +++ b/tests/gitea/custom/test_health.py @@ -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)" diff --git a/tests/gitea/custom/test_lfs_roundtrip.py b/tests/gitea/custom/test_lfs_roundtrip.py new file mode 100644 index 0000000..718bc96 --- /dev/null +++ b/tests/gitea/custom/test_lfs_roundtrip.py @@ -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) diff --git a/tests/gitea/ops.py b/tests/gitea/ops.py new file mode 100644 index 0000000..f82b50d --- /dev/null +++ b/tests/gitea/ops.py @@ -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-.json, mode 600), and every subsequent pre_ 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) diff --git a/tests/gitea/recipe_meta.py b/tests/gitea/recipe_meta.py index d47ebb0..cc9c020 100644 --- a/tests/gitea/recipe_meta.py +++ b/tests/gitea/recipe_meta.py @@ -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 diff --git a/tests/gitea/test_backup.py b/tests/gitea/test_backup.py new file mode 100644 index 0000000..229ff72 --- /dev/null +++ b/tests/gitea/test_backup.py @@ -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)" + ) diff --git a/tests/gitea/test_install.py b/tests/gitea/test_install.py new file mode 100644 index 0000000..84036da --- /dev/null +++ b/tests/gitea/test_install.py @@ -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() diff --git a/tests/gitea/test_restore.py b/tests/gitea/test_restore.py new file mode 100644 index 0000000..595abd5 --- /dev/null +++ b/tests/gitea/test_restore.py @@ -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)" + ) diff --git a/tests/gitea/test_upgrade.py b/tests/gitea/test_upgrade.py new file mode 100644 index 0000000..5a7589c --- /dev/null +++ b/tests/gitea/test_upgrade.py @@ -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)" + )