style: repo-wide lint pass — make the lint gate green again

Push builds have been RED on the lint step since ~build 209 from accumulated
formatting drift. This is the mechanical cleanup: ruff format + ruff --fix
(UP038 isinstance unions, SIM105 contextlib.suppress, UP031 f-strings, SIM115
tempfile context manager), shfmt -i 2 -ci, nixpkgs-fmt/statix/deadnix (merged
attrsets, dropped unused lib args), yamllint, and shell quoting fixes in
tests/lasuite-docs/setup_custom_tests.sh. No behaviour changes intended;
lint: PASS, unit tests: 138 passed.
This commit is contained in:
2026-06-09 21:56:15 +00:00
parent e76d4005ab
commit 9a7772563a
115 changed files with 952 additions and 660 deletions

View File

@ -15,7 +15,8 @@ import shlex
import sys
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "runner"))
from harness import http as harness_http, lifecycle # noqa: E402
from harness import http as harness_http # noqa: E402
from harness import lifecycle
PDS_HOST_LOCAL = "http://localhost:3000"
_PW = "ccci-P4-marker-pw-2026"

View File

@ -27,6 +27,7 @@ CRUD). A wedged PDS subsystem fails AT its layer.
from __future__ import annotations
import contextlib
import os
import re
import secrets
@ -35,7 +36,8 @@ import sys
import uuid
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "..", "runner"))
from harness import http as harness_http, lifecycle # noqa: E402
from harness import http as harness_http # noqa: E402
from harness import lifecycle
PDS_HOST_LOCAL = "http://localhost:3000"
@ -58,14 +60,18 @@ def _goat_admin(domain: str, args: str) -> str:
return _in_container(domain, cmd)
def _xrpc_post(domain: str, nsid: str, data: dict, token: str | None = None) -> tuple[int, dict | None]:
def _xrpc_post(
domain: str, nsid: str, data: dict, token: str | None = None
) -> tuple[int, dict | None]:
headers = {}
if token:
headers["Authorization"] = f"Bearer {token}"
return harness_http.http_post(f"https://{domain}/xrpc/{nsid}", data=data, headers=headers)
def _xrpc_get(domain: str, nsid: str, query: str, token: str | None = None) -> tuple[int, dict | None]:
def _xrpc_get(
domain: str, nsid: str, query: str, token: str | None = None
) -> tuple[int, dict | None]:
headers = {}
if token:
headers["Authorization"] = f"Bearer {token}"
@ -82,9 +88,9 @@ def test_account_lifecycle_and_post_roundtrip(live_app):
# Step 1: PDS describe via goat — recipe self-identifies as did:web:<domain>
out = _in_container(domain, f"goat pds describe {PDS_HOST_LOCAL} 2>&1")
assert f"did:web:{domain}" in out, (
f"goat pds describe did not contain expected DID 'did:web:{domain}'. Output:\n{out[:500]!r}"
)
assert (
f"did:web:{domain}" in out
), f"goat pds describe did not contain expected DID 'did:web:{domain}'. Output:\n{out[:500]!r}"
# Step 2: Create account (UUID-suffixed handle = no run-to-run collision)
out = _goat_admin(
@ -127,9 +133,9 @@ def test_account_lifecycle_and_post_roundtrip(live_app):
assert s == 200, f"createRecord HTTP {s}: {body!r}"
record_uri = (body or {}).get("uri", "")
# URI format: at://<did>/app.bsky.feed.post/<rkey>
assert record_uri.startswith(f"at://{new_did}/app.bsky.feed.post/"), (
f"unexpected record uri: {record_uri!r}"
)
assert record_uri.startswith(
f"at://{new_did}/app.bsky.feed.post/"
), f"unexpected record uri: {record_uri!r}"
rkey = record_uri.rsplit("/", 1)[-1]
assert rkey, f"no rkey in uri: {record_uri!r}"
@ -142,15 +148,13 @@ def test_account_lifecycle_and_post_roundtrip(live_app):
)
assert s == 200, f"getRecord HTTP {s}: {body!r}"
record_value = (body or {}).get("value", {})
assert record_value.get("text") == marker, (
f"post text did not round-trip: created={marker!r}, fetched={record_value.get('text')!r}"
)
assert (
record_value.get("text") == marker
), f"post text did not round-trip: created={marker!r}, fetched={record_value.get('text')!r}"
assert record_value.get("$type") == "app.bsky.feed.post"
finally:
# Step 6: Best-effort cleanup. (The per-run domain teardown will discard the volume
# too, but we exercise the delete-account path because it's part of §4.3.)
if cleanup_did:
try:
with contextlib.suppress(Exception):
_goat_admin(domain, f"account delete {cleanup_did}")
except Exception: # noqa: BLE001
pass

View File

@ -26,6 +26,6 @@ def test_describe_server_returns_atproto_envelope(live_app):
# At least one of these atproto-spec fields must be present
expected_any = ("availableUserDomains", "inviteCodeRequired", "links", "did")
present = [k for k in expected_any if k in body]
assert present, (
f"describe-server missing all of {expected_any}; got keys: {sorted(body.keys())[:20]}"
)
assert (
present
), f"describe-server missing all of {expected_any}; got keys: {sorted(body.keys())[:20]}"

View File

@ -17,6 +17,6 @@ def test_pds_health_returns_version(live_app):
url = f"https://{live_app}/xrpc/_health"
status, body = harness_http.retry_http_get(url, expect_status=200, max_wait=60, interval=3)
assert status == 200, f"GET {url} HTTP {status} (expected 200)"
assert isinstance(body, dict) and isinstance(body.get("version"), str) and body["version"], (
f"GET {url} response is not the expected health envelope: {body!r}"
)
assert (
isinstance(body, dict) and isinstance(body.get("version"), str) and body["version"]
), f"GET {url} response is not the expected health envelope: {body!r}"

View File

@ -30,6 +30,6 @@ def test_get_session_requires_auth(live_app):
f"body: {body!r}"
)
# The XRPC error envelope is JSON with an `error` field per the atproto spec.
assert isinstance(body, dict) and body.get("error"), (
f"expected XRPC JSON error envelope; got: {body!r}"
)
assert isinstance(body, dict) and body.get(
"error"
), f"expected XRPC JSON error envelope; got: {body!r}"

View File

@ -22,12 +22,12 @@ echo " bluesky-pds install_steps: generating secp256k1 PLC rotation key..."
# same shape the PDS expects (32-byte hex). Equivalent for atproto PDS bootstrap.
KEY_HEX=$(cc-ci-run -c 'import secrets; print(secrets.token_bytes(32).hex())')
if [ -z "${KEY_HEX}" ] || [ "${#KEY_HEX}" != "64" ]; then
echo " install_steps: failed to generate PLC rotation key (KEY_HEX length=${#KEY_HEX})" >&2
exit 1
echo " install_steps: failed to generate PLC rotation key (KEY_HEX length=${#KEY_HEX})" >&2
exit 1
fi
# Insert via abra under TTY-wrap (`abra app secret insert` requires a TTY on this version).
# We DON'T log the key value — abra also doesn't print it.
script -qec "abra app secret insert ${CCCI_APP_DOMAIN} pds_plc_rotation_key v1 ${KEY_HEX} --no-input" /dev/null \
>/dev/null 2>&1
>/dev/null 2>&1
echo " bluesky-pds install_steps: PLC rotation key inserted (v1)."

View File

@ -11,6 +11,6 @@ import _p4 # noqa: E402
def test_restore_returns_state(live_app):
assert _p4.account_exists(live_app), (
"restore did not bring back the seeded marker account (PDS data did not survive restore)"
)
assert _p4.account_exists(
live_app
), "restore did not bring back the seeded marker account (PDS data did not survive restore)"

View File

@ -13,7 +13,8 @@ import sys
import pytest
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "runner"))
from harness import deps as deps_mod, lifecycle, naming # noqa: E402
from harness import deps as deps_mod # noqa: E402
from harness import lifecycle, naming
def _short(s: str, n: int = 8) -> str:

View File

@ -26,6 +26,7 @@ Transient `net::ERR_NETWORK_CHANGED` is handled by the shared `goto_with_retry`
from __future__ import annotations
import contextlib
import os
import sys
import uuid
@ -39,7 +40,11 @@ def _open_pad(ctx, url):
bar once CryptPad has created/loaded the fragment-keyed pad (`#/2/pad/edit/<key>/`)."""
page = ctx.new_page()
harness_browser.goto_with_retry(
page, url, accept_statuses=(200,), goto_timeout_ms=60_000, wait_until="load",
page,
url,
accept_statuses=(200,),
goto_timeout_ms=60_000,
wait_until="load",
deadline_seconds=150,
)
pad_url = url
@ -53,13 +58,15 @@ def _open_pad(ctx, url):
pad_url = page.url
break
if i == 40:
try:
with contextlib.suppress(Exception): # best-effort unstick
harness_browser.goto_with_retry(
page, url, accept_statuses=(200,), goto_timeout_ms=60_000,
wait_until="load", deadline_seconds=120,
page,
url,
accept_statuses=(200,),
goto_timeout_ms=60_000,
wait_until="load",
deadline_seconds=120,
)
except Exception: # noqa: BLE001 — best-effort unstick
pass
return page, pad_url
@ -74,18 +81,22 @@ def _ckeditor_frame(page, deadline_polls=90, reload_at=22, reload_url=None):
if "ckeditor-inner" in f.url:
return f
if i == reload_at and reload_url is not None:
try:
with contextlib.suppress(Exception): # reload is a best-effort unstick
harness_browser.goto_with_retry(
page, reload_url, accept_statuses=(200,), goto_timeout_ms=60_000,
wait_until="load", deadline_seconds=120,
page,
reload_url,
accept_statuses=(200,),
goto_timeout_ms=60_000,
wait_until="load",
deadline_seconds=120,
)
except Exception: # noqa: BLE001 — reload is a best-effort unstick
pass
page.wait_for_timeout(2000)
return None
def _poll_any_frame_for_text(page, needle, deadline_polls=120, reload_at=(20, 45, 75, 100), reload_url=None):
def _poll_any_frame_for_text(
page, needle, deadline_polls=120, reload_at=(20, 45, 75, 100), reload_url=None
):
"""Robust read-back (F2-13): poll EVERY frame's body text for `needle`, returning True as soon as
it appears. The fresh cold-cache read-back context's deeply-nested CKEditor frame is slow/flaky to
*attach* by URL (the prior `_ckeditor_frame` wait timed out on the Adversary's cold run), but the
@ -101,13 +112,15 @@ def _poll_any_frame_for_text(page, needle, deadline_polls=120, reload_at=(20, 45
except Exception: # noqa: BLE001 — frame not ready / detached; keep polling
pass
if reload_url and i in reload_at:
try:
with contextlib.suppress(Exception): # best-effort unstick
harness_browser.goto_with_retry(
page, reload_url, accept_statuses=(200,), goto_timeout_ms=60_000,
wait_until="load", deadline_seconds=120,
page,
reload_url,
accept_statuses=(200,),
goto_timeout_ms=60_000,
wait_until="load",
deadline_seconds=120,
)
except Exception: # noqa: BLE001 — best-effort unstick
pass
page.wait_for_timeout(2000)
return False
@ -137,9 +150,9 @@ def test_cryptpad_pad_content_survives_fresh_session(live_app):
# --- session 1: create the pad + write the marker ---
ctx1 = browser.new_context(ignore_https_errors=True)
page, pad_url = _open_pad(ctx1, f"https://{live_app}/pad/")
assert "#/2/pad/edit/" in pad_url, (
f"CryptPad did not create a fragment-keyed pad URL; got {pad_url!r}"
)
assert (
"#/2/pad/edit/" in pad_url
), f"CryptPad did not create a fragment-keyed pad URL; got {pad_url!r}"
ck = _ckeditor_frame(page, reload_url=pad_url)
assert ck is not None, "CKEditor content frame never attached (pad editor not ready)"
_dismiss_store_modal(page)
@ -148,9 +161,9 @@ def test_cryptpad_pad_content_survives_fresh_session(live_app):
page.wait_for_timeout(1000)
body.type(marker, delay=40)
page.wait_for_timeout(12000) # let CryptPad encrypt + sync the update to the server
assert marker in ck.locator("body").inner_text(), (
"marker not present in the editor after typing — type did not land"
)
assert (
marker in ck.locator("body").inner_text()
), "marker not present in the editor after typing — type did not land"
ctx1.close()
# --- session 2: FRESH context (no shared storage/localStorage) reads the pad back by URL.

View File

@ -51,9 +51,9 @@ def test_cryptpad_spa_renders_with_no_console_errors(live_app):
title = (page.title() or "").lower()
body = page.content()
blower = body.lower()
assert "cryptpad" in title or "cryptpad" in blower, (
f"CryptPad SPA does not carry brand. title={title!r}, body excerpt: {body[:200]!r}"
)
assert (
"cryptpad" in title or "cryptpad" in blower
), f"CryptPad SPA does not carry brand. title={title!r}, body excerpt: {body[:200]!r}"
# Canonical CryptPad asset references in the rendered DOM
canonical = ("/customize/", "/components/", "main.js", "/api/broadcast")

View File

@ -8,7 +8,8 @@ import os
import sys
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "runner"))
from harness import browser as harness_browser, generic, lifecycle # noqa: E402
from harness import browser as harness_browser # noqa: E402
from harness import generic, lifecycle
def test_serving_and_content(live_app, meta):

View File

@ -20,7 +20,9 @@ def test_backup_captures_state(live_app):
Since custom-html-bkp-bad has no ops.py::pre_backup to seed the marker, this file does NOT
exist at backup time — exec_in_app returns empty or raises → assertion fails → backup tier RED.
This models a recipe that declares backup capability but omits the data-seeding hook."""
result = lifecycle.exec_in_app(live_app, ["sh", "-c", f"cat {MARKER_PATH} 2>/dev/null || echo MISSING"]).strip()
result = lifecycle.exec_in_app(
live_app, ["sh", "-c", f"cat {MARKER_PATH} 2>/dev/null || echo MISSING"]
).strip()
assert result == "original", (
f"backup did not capture the expected marker at {MARKER_PATH}: got {result!r}. "
"Expected 'original' (seeded by pre_backup). If the marker is 'MISSING', the pre_backup "

View File

@ -79,9 +79,9 @@ def test_static_file_roundtrip_and_404(live_app):
# A random non-existent path must 404 — proves real static-file semantics, distinguishing a
# working server from a 200-everything stub or a mis-routed Traefik fallback.
miss_status, _ = _get(f"https://{live_app}/ccci-missing-{uuid.uuid4().hex}.txt")
assert miss_status == 404, (
f"missing path returned {miss_status} (expected 404 — generic 200-returner / mis-route?)"
)
assert (
miss_status == 404
), f"missing path returned {miss_status} (expected 404 — generic 200-returner / mis-route?)"
finally:
with contextlib.suppress(OSError):
os.remove(path)

View File

@ -15,7 +15,8 @@ import sys
import uuid
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "..", "runner"))
from harness import http as harness_http, lifecycle # noqa: E402
from harness import http as harness_http # noqa: E402
from harness import lifecycle
def test_content_roundtrip(live_app):

View File

@ -53,9 +53,9 @@ def test_content_type_html_and_txt(live_app):
ct_txt = h_txt.get("content-type", "")
# nginx default: "text/html" for .html and "text/plain" for .txt (may include "; charset=utf-8")
assert ct_html.startswith("text/html"), (
f"{html_name} Content-Type={ct_html!r}, expected text/html (nginx MIME config broken?)"
)
assert ct_txt.startswith("text/plain"), (
f"{txt_name} Content-Type={ct_txt!r}, expected text/plain (nginx MIME config broken?)"
)
assert ct_html.startswith(
"text/html"
), f"{html_name} Content-Type={ct_html!r}, expected text/html (nginx MIME config broken?)"
assert ct_txt.startswith(
"text/plain"
), f"{txt_name} Content-Type={ct_txt!r}, expected text/plain (nginx MIME config broken?)"

View File

@ -9,7 +9,8 @@ import os
import sys
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "runner"))
from harness import browser as harness_browser, generic # noqa: E402
from harness import browser as harness_browser # noqa: E402
from harness import generic
def test_serving_and_content(live_app, meta):

View File

@ -53,7 +53,7 @@ def mint_admin(domain: str) -> tuple[str, str]:
cmd = (
"cd /opt/bitnami/discourse && "
"RUBY=$(command -v ruby || echo /opt/bitnami/ruby/bin/ruby) && "
f"RAILS_ENV=production \"$RUBY\" bin/rails runner \"{_BOOTSTRAP_RB}\""
f'RAILS_ENV=production "$RUBY" bin/rails runner "{_BOOTSTRAP_RB}"'
)
out = lifecycle.exec_in_app(domain, ["bash", "-c", cmd], service="app", timeout=240)
key = user = None
@ -63,9 +63,9 @@ def mint_admin(domain: str) -> tuple[str, str]:
key = line.split("=", 1)[1].strip()
elif line.startswith("CCCI_API_USER="):
user = line.split("=", 1)[1].strip()
assert key and user, (
f"could not bootstrap discourse admin/API key; rails output tail:\n{out[-1000:]}"
)
assert (
key and user
), f"could not bootstrap discourse admin/API key; rails output tail:\n{out[-1000:]}"
return key, user

View File

@ -48,21 +48,23 @@ def test_create_topic_roundtrip(live_app):
headers=hdrs,
timeout=60,
)
assert status in (200, 201) and isinstance(body, dict), (
f"create topic failed: HTTP {status}, body={body!r}"
)
assert status in (200, 201) and isinstance(
body, dict
), f"create topic failed: HTTP {status}, body={body!r}"
topic_id = body.get("topic_id")
assert topic_id, f"create topic returned no topic_id: {body!r}"
# 4) Read the topic back and assert title + first-post body round-trip.
status, got = harness_http.http_get(f"{base}/t/{topic_id}.json", headers=hdrs, timeout=30)
assert status == 200 and isinstance(got, dict), f"read topic failed: HTTP {status}, body={got!r}"
assert got.get("title") == title, (
f"topic title did not round-trip: sent {title!r}, got {got.get('title')!r}"
)
assert status == 200 and isinstance(
got, dict
), f"read topic failed: HTTP {status}, body={got!r}"
assert (
got.get("title") == title
), f"topic title did not round-trip: sent {title!r}, got {got.get('title')!r}"
posts = (got.get("post_stream") or {}).get("posts") or []
assert posts, f"topic has no posts on read-back: {got!r}"
first_cooked = posts[0].get("cooked", "")
assert marker in first_cooked, (
f"topic body did not round-trip: marker {marker!r} not in first post {first_cooked!r}"
)
assert (
marker in first_cooked
), f"topic body did not round-trip: marker {marker!r} not in first post {first_cooked!r}"

View File

@ -20,12 +20,12 @@ def test_site_json_has_discourse_config(live_app):
status, body = harness_http.retry_http_get(
f"https://{live_app}/site.json", expect_status=200, max_wait=120, interval=5
)
assert status == 200 and isinstance(body, dict), (
f"GET /site.json failed: HTTP {status}, body type={type(body).__name__}"
)
assert status == 200 and isinstance(
body, dict
), f"GET /site.json failed: HTTP {status}, body type={type(body).__name__}"
# /site.json carries Discourse-specific structure — `categories` (a list) and `groups` are always
# present in a booted Discourse. A non-Discourse 200 (placeholder page) would not parse to this.
assert "categories" in body, f"/site.json missing 'categories' key: keys={list(body)[:20]}"
assert isinstance(body["categories"], list), (
f"/site.json 'categories' not a list: {type(body['categories']).__name__}"
)
assert isinstance(
body["categories"], list
), f"/site.json 'categories' not a list: {type(body['categories']).__name__}"

View File

@ -15,8 +15,7 @@ from harness import lifecycle # noqa: E402
def _psql(domain, sql):
cmd = (
'PGPASSWORD=$(cat /run/secrets/db_password) '
f'psql -U discourse -d discourse -tAc "{sql}"'
"PGPASSWORD=$(cat /run/secrets/db_password) " f'psql -U discourse -d discourse -tAc "{sql}"'
)
return lifecycle.exec_in_app(domain, ["sh", "-c", cmd], service="db").strip()
@ -42,6 +41,7 @@ def pre_backup(domain, meta):
def pre_restore(domain, meta):
# diverge from the backup so a successful restore is observable
_psql(domain, "DROP TABLE IF EXISTS ci_marker;")
assert _psql(domain, "SELECT to_regclass('public.ci_marker');") in ("", "NULL"), (
"drop did not take"
)
assert _psql(domain, "SELECT to_regclass('public.ci_marker');") in (
"",
"NULL",
), "drop did not take"

View File

@ -6,7 +6,9 @@
# app is actually serving (the canonical "is discourse up" signal — NOT "/", which may redirect to setup).
HEALTH_PATH = "/srv/status"
HEALTH_OK = (200,)
DEPLOY_TIMEOUT = 3600 # slow Rails cold boot (15-25min) on the 7-GiB single node; bumped 2400→3600 for
DEPLOY_TIMEOUT = (
3600 # slow Rails cold boot (15-25min) on the 7-GiB single node; bumped 2400→3600 for
)
# headroom after full4's base deploy timed out at 2400s (RAM/CPU-constrained boot + image re-pull).
HTTP_TIMEOUT = 1200
@ -59,7 +61,11 @@ def BACKUP_VERIFY(domain):
try:
out = lifecycle.exec_in_app(
domain,
["sh", "-c", "gzip -t /var/lib/postgresql/data/backup.sql && wc -c < /var/lib/postgresql/data/backup.sql"],
[
"sh",
"-c",
"gzip -t /var/lib/postgresql/data/backup.sql && wc -c < /var/lib/postgresql/data/backup.sql",
],
service="db",
timeout=60,
).strip()

View File

@ -14,13 +14,12 @@ from harness import lifecycle # noqa: E402
def _psql(domain, sql):
cmd = (
'PGPASSWORD=$(cat /run/secrets/db_password) '
f'psql -U discourse -d discourse -tAc "{sql}"'
"PGPASSWORD=$(cat /run/secrets/db_password) " f'psql -U discourse -d discourse -tAc "{sql}"'
)
return lifecycle.exec_in_app(domain, ["sh", "-c", cmd], service="db").strip()
def test_backup_captures_state(live_app):
assert _psql(live_app, "SELECT v FROM ci_marker;") == "original", (
"the seeded discourse postgres state was not present at backup time"
)
assert (
_psql(live_app, "SELECT v FROM ci_marker;") == "original"
), "the seeded discourse postgres state was not present at backup time"

View File

@ -14,13 +14,12 @@ from harness import lifecycle # noqa: E402
def _psql(domain, sql):
cmd = (
'PGPASSWORD=$(cat /run/secrets/db_password) '
f'psql -U discourse -d discourse -tAc "{sql}"'
"PGPASSWORD=$(cat /run/secrets/db_password) " f'psql -U discourse -d discourse -tAc "{sql}"'
)
return lifecycle.exec_in_app(domain, ["sh", "-c", cmd], service="db").strip()
def test_restore_returns_state(live_app):
assert _psql(live_app, "SELECT v FROM ci_marker;") == "original", (
"restore did not return the pre-mutation discourse postgres state (data-integrity failure)"
)
assert (
_psql(live_app, "SELECT v FROM ci_marker;") == "original"
), "restore did not return the pre-mutation discourse postgres state (data-integrity failure)"

View File

@ -93,9 +93,10 @@ class GhostAdmin:
status, body = self.req(
"POST", "/session/", {"username": ADMIN_EMAIL, "password": ADMIN_PW}
)
assert status in (200, 201), (
f"ghost admin session login failed: HTTP {status}, body={body!r}"
)
assert status in (
200,
201,
), f"ghost admin session login failed: HTTP {status}, body={body!r}"
def create_post(self, title: str, html: str) -> dict:
status, body = self.req(

View File

@ -53,13 +53,15 @@ def test_ghost_admin_route_is_wired(live_app):
return None
status_body = harness_http.assert_converges(
_ready, f"GET {url} returns Ghost admin (200) or setup redirect (302)",
max_wait=60, interval=3,
_ready,
f"GET {url} returns Ghost admin (200) or setup redirect (302)",
max_wait=60,
interval=3,
)
status, body = status_body
assert status in (200, 302), f"unexpected status: {status}"
if status == 200:
# The admin SPA references /ghost-assets/ or contains "ghost" in title/body
assert "ghost" in body.lower(), (
f"GET {url} 200 but body has no Ghost markers: {body[:200]!r}"
)
assert (
"ghost" in body.lower()
), f"GET {url} 200 but body has no Ghost markers: {body[:200]!r}"

View File

@ -35,10 +35,10 @@ def test_content_api_settings_endpoint(live_app):
assert body is not None, f"GET {url} returned non-JSON body"
# On success: {"settings": {...}}. On error: {"errors": [...]}. Either shape is valid.
if status == 200:
assert isinstance(body, dict) and "settings" in body, (
f"200 response missing 'settings' envelope: {body!r}"
)
assert (
isinstance(body, dict) and "settings" in body
), f"200 response missing 'settings' envelope: {body!r}"
else:
assert isinstance(body, dict) and ("errors" in body or "message" in body or body), (
f"error response not a proper Ghost error envelope: {body!r}"
)
assert isinstance(body, dict) and (
"errors" in body or "message" in body or body
), f"error response not a proper Ghost error envelope: {body!r}"

View File

@ -43,17 +43,17 @@ def test_create_post_roundtrip(live_app):
title = f"ccci-marker-{uniq}"
marker = f"ccci-body-marker-{uniq}-roundtrip"
created = admin.create_post(title, f"<p>{marker}</p>")
assert created.get("title") == title, (
f"created post title mismatch: sent {title!r}, got {created.get('title')!r}"
)
assert (
created.get("title") == title
), f"created post title mismatch: sent {title!r}, got {created.get('title')!r}"
# 4) Read it back by id and assert the post survived the round-trip (title always returned;
# html returned because we requested ?formats=html).
got = admin.get_post(created["id"])
assert got.get("title") == title, (
f"post title did not round-trip: sent {title!r}, got {got.get('title')!r}"
)
assert (
got.get("title") == title
), f"post title did not round-trip: sent {title!r}, got {got.get('title')!r}"
html = got.get("html") or ""
assert marker in html, (
f"post body did not round-trip: marker {marker!r} not in read-back html {html!r}"
)
assert (
marker in html
), f"post body did not round-trip: marker {marker!r} not in read-back html {html!r}"

View File

@ -22,10 +22,7 @@ from harness import lifecycle # noqa: E402
def _mysql(domain, sql):
cmd = (
'MYSQL_PWD="$(cat /run/secrets/db_password)" '
f'mysql -u root -N -s ghost -e "{sql}"'
)
cmd = 'MYSQL_PWD="$(cat /run/secrets/db_password)" ' f'mysql -u root -N -s ghost -e "{sql}"'
return lifecycle.exec_in_app(domain, ["sh", "-c", cmd], service="db").strip()

View File

@ -63,7 +63,11 @@ def BACKUP_VERIFY(domain):
try:
out = lifecycle.exec_in_app(
domain,
["sh", "-c", "gzip -t /var/lib/mysql/backup.sql.gz && wc -c < /var/lib/mysql/backup.sql.gz"],
[
"sh",
"-c",
"gzip -t /var/lib/mysql/backup.sql.gz && wc -c < /var/lib/mysql/backup.sql.gz",
],
service="db",
timeout=60,
).strip()

View File

@ -15,14 +15,11 @@ from harness import lifecycle # noqa: E402
def _mysql(domain, sql):
cmd = (
'MYSQL_PWD="$(cat /run/secrets/db_password)" '
f'mysql -u root -N -s ghost -e "{sql}"'
)
cmd = 'MYSQL_PWD="$(cat /run/secrets/db_password)" ' f'mysql -u root -N -s ghost -e "{sql}"'
return lifecycle.exec_in_app(domain, ["sh", "-c", cmd], service="db").strip()
def test_backup_captures_state(live_app):
assert _mysql(live_app, "SELECT v FROM ci_marker;") == "original", (
"the seeded ghost MySQL marker was not present at backup time"
)
assert (
_mysql(live_app, "SELECT v FROM ci_marker;") == "original"
), "the seeded ghost MySQL marker was not present at backup time"

View File

@ -22,10 +22,7 @@ from harness import lifecycle # noqa: E402
def _mysql(domain, sql):
cmd = (
'MYSQL_PWD="$(cat /run/secrets/db_password)" '
f'mysql -u root -N -s ghost -e "{sql}"'
)
cmd = 'MYSQL_PWD="$(cat /run/secrets/db_password)" ' f'mysql -u root -N -s ghost -e "{sql}"'
return lifecycle.exec_in_app(domain, ["sh", "-c", cmd], service="db").strip()

View File

@ -14,14 +14,11 @@ from harness import lifecycle # noqa: E402
def _mysql(domain, sql):
cmd = (
'MYSQL_PWD="$(cat /run/secrets/db_password)" '
f'mysql -u root -N -s ghost -e "{sql}"'
)
cmd = 'MYSQL_PWD="$(cat /run/secrets/db_password)" ' f'mysql -u root -N -s ghost -e "{sql}"'
return lifecycle.exec_in_app(domain, ["sh", "-c", cmd], service="db").strip()
def test_upgrade_preserves_state(live_app):
assert _mysql(live_app, "SELECT v FROM ci_marker;") == "upgrade-survives", (
"the seeded ghost MySQL marker did not survive the upgrade redeploy (data loss on upgrade)"
)
assert (
_mysql(live_app, "SELECT v FROM ci_marker;") == "upgrade-survives"
), "the seeded ghost MySQL marker did not survive the upgrade redeploy (data loss on upgrade)"

View File

@ -14,7 +14,6 @@ import urllib.request
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "..", "runner"))
from harness import http as harness_http # noqa: E402
_CTX = ssl.create_default_context()
_CTX.check_hostname = False
_CTX.verify_mode = ssl.CERT_NONE

View File

@ -15,7 +15,5 @@ from harness import http as harness_http # noqa: E402
def test_hedgedoc_root_serves(live_app):
"""GET / → 200 or 302 (login/new redirect)."""
url = f"https://{live_app}/"
status, _ = harness_http.retry_http_get(
url, expect_status=(200, 302), max_wait=90, interval=5
)
status, _ = harness_http.retry_http_get(url, expect_status=(200, 302), max_wait=90, interval=5)
assert status in (200, 302), f"GET {url} HTTP {status} (expected 200 or 302)"

View File

@ -111,13 +111,13 @@ def test_immich_processes_uploaded_asset_metadata_and_statistics(live_app):
if exif and exif.get("exifImageWidth"):
break
time.sleep(5)
assert exif and exif.get("exifImageWidth") == 1 and exif.get("exifImageHeight") == 1, (
f"immich metadata-extraction did not populate the 1x1 PNG dimensions in exifInfo: {exif!r}"
)
assert (
exif and exif.get("exifImageWidth") == 1 and exif.get("exifImageHeight") == 1
), f"immich metadata-extraction did not populate the 1x1 PNG dimensions in exifInfo: {exif!r}"
# the asset is catalogued into the owner's library statistics (list-back in aggregate)
sst, stats = harness_http.http_request("GET", f"{base}/api/assets/statistics", headers=auth)
assert sst == 200 and isinstance(stats, dict), f"statistics HTTP {sst}: {stats!r}"
assert stats.get("images", 0) >= 1 and stats.get("total", 0) >= 1, (
f"uploaded asset not reflected in library statistics: {stats!r}"
)
assert (
stats.get("images", 0) >= 1 and stats.get("total", 0) >= 1
), f"uploaded asset not reflected in library statistics: {stats!r}"

View File

@ -121,6 +121,6 @@ def test_immich_upload_asset_readback_and_thumbnail(live_app):
if thumb == 200:
break
time.sleep(5)
assert thumb == 200, (
f"immich did not generate a thumbnail/derivative for the uploaded asset (last HTTP {thumb})"
)
assert (
thumb == 200
), f"immich did not generate a thumbnail/derivative for the uploaded asset (last HTTP {thumb})"

View File

@ -16,5 +16,11 @@ from harness import http as harness_http # noqa: E402
def test_immich_returns_200(live_app):
url = f"https://{live_app}/"
status, _ = harness_http.retry_http_get(url, expect_status=(200, 301, 302), max_wait=60, interval=3)
assert status in (200, 301, 302), f"immich at {url} returned HTTP {status} (expected 200/301/302)"
status, _ = harness_http.retry_http_get(
url, expect_status=(200, 301, 302), max_wait=60, interval=3
)
assert status in (
200,
301,
302,
), f"immich at {url} returned HTTP {status} (expected 200/301/302)"

View File

@ -35,4 +35,7 @@ def pre_backup(domain, meta):
def pre_restore(domain, meta):
_psql(domain, "DROP TABLE ci_marker;")
assert _psql(domain, "SELECT to_regclass('public.ci_marker');") in ("", "NULL"), "drop did not take"
assert _psql(domain, "SELECT to_regclass('public.ci_marker');") in (
"",
"NULL",
), "drop did not take"

View File

@ -14,4 +14,6 @@ def _psql(domain, sql):
def test_backup_captures_state(live_app):
assert _psql(live_app, "SELECT v FROM ci_marker;") == "original", "seeded postgres state not present at backup time"
assert (
_psql(live_app, "SELECT v FROM ci_marker;") == "original"
), "seeded postgres state not present at backup time"

View File

@ -7,7 +7,8 @@ import os
import sys
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "runner"))
from harness import browser as harness_browser, generic, lifecycle # noqa: E402
from harness import browser as harness_browser # noqa: E402
from harness import generic, lifecycle
def test_serving_and_frontend(live_app, meta):
@ -25,7 +26,11 @@ def test_serving_and_frontend(live_app, meta):
resp = harness_browser.goto_with_retry(
page, url, accept_statuses=(200, 301, 302), goto_timeout_ms=60_000
)
assert resp is not None and resp.status in (200, 301, 302), f"page status {resp and resp.status}"
assert resp is not None and resp.status in (
200,
301,
302,
), f"page status {resp and resp.status}"
assert "<html" in page.content().lower(), "no HTML served by the immich frontend"
finally:
browser.close()

View File

@ -14,4 +14,6 @@ def _psql(domain, sql):
def test_restore_returns_state(live_app):
assert _psql(live_app, "SELECT v FROM ci_marker;") == "original", "restore did not return the pre-mutation postgres state"
assert (
_psql(live_app, "SELECT v FROM ci_marker;") == "original"
), "restore did not return the pre-mutation postgres state"

View File

@ -14,4 +14,6 @@ def _psql(domain, sql):
def test_upgrade_preserves_data(live_app):
assert _psql(live_app, "SELECT v FROM ci_marker;") == "upgrade-survives", "postgres data did not survive the upgrade"
assert (
_psql(live_app, "SELECT v FROM ci_marker;") == "upgrade-survives"
), "postgres data did not survive the upgrade"

View File

@ -120,9 +120,9 @@ def test_create_confidential_client_and_obtain_token(live_app):
"clientId": client_id,
"enabled": True,
"secret": client_secret,
"publicClient": False, # confidential client
"serviceAccountsEnabled": True, # required for client_credentials grant
"standardFlowEnabled": False, # not needed for service-account-only client
"publicClient": False, # confidential client
"serviceAccountsEnabled": True, # required for client_credentials grant
"standardFlowEnabled": False, # not needed for service-account-only client
"directAccessGrantsEnabled": False,
"protocol": "openid-connect",
}
@ -144,25 +144,25 @@ def test_create_confidential_client_and_obtain_token(live_app):
# Use the client to obtain its own token (client_credentials grant)
tok_status, tok_resp = _client_credentials_token(live_app, client_id, client_secret)
assert tok_status == 200, (
f"client_credentials token returned HTTP {tok_status}: {tok_resp!r}"
)
assert (
tok_status == 200
), f"client_credentials token returned HTTP {tok_status}: {tok_resp!r}"
access_token = tok_resp.get("access_token") if isinstance(tok_resp, dict) else None
assert isinstance(access_token, str) and access_token.count(".") == 2, (
f"client_credentials access_token not a JWT: {access_token!r}"
)
assert (
isinstance(access_token, str) and access_token.count(".") == 2
), f"client_credentials access_token not a JWT: {access_token!r}"
# Decode the JWT payload; assert azp matches the new client
payload = json.loads(_b64url_decode(access_token.split(".")[1]))
assert payload.get("azp") == client_id, (
f"client_credentials JWT azp={payload.get('azp')!r} != client_id={client_id!r}"
)
assert (
payload.get("azp") == client_id
), f"client_credentials JWT azp={payload.get('azp')!r} != client_id={client_id!r}"
# Service-account token does NOT carry a session-scoped user (azp + clientId differ from
# admin-cli token). The presence of azp + iss == per-run-domain proves the issuance flow.
expected_iss = f"https://{live_app}/realms/master"
assert payload.get("iss") == expected_iss, (
f"JWT iss={payload.get('iss')!r} != {expected_iss!r}"
)
assert (
payload.get("iss") == expected_iss
), f"JWT iss={payload.get('iss')!r} != {expected_iss!r}"
finally:
# Idempotent cleanup
if cleanup_id:

View File

@ -43,22 +43,20 @@ def test_password_grant_issues_valid_jwt(live_app):
token = kc_admin.admin_token(live_app, password)
# Shape: a JWT is exactly 3 base64url segments
assert isinstance(token, str) and token.count(".") == 2, (
f"access_token does not look like a JWT (no 3 segments): len={len(token) if token else 0}"
)
assert (
isinstance(token, str) and token.count(".") == 2
), f"access_token does not look like a JWT (no 3 segments): len={len(token) if token else 0}"
payload = _decode_jwt_payload(token)
# iss = the issuer URL, must be the per-run domain's /realms/master endpoint
expected_iss = f"https://{live_app}/realms/master"
assert payload.get("iss") == expected_iss, (
f"JWT iss claim {payload.get('iss')!r} != {expected_iss!r}"
)
assert (
payload.get("iss") == expected_iss
), f"JWT iss claim {payload.get('iss')!r} != {expected_iss!r}"
# azp = authorized party (which client requested this token)
assert payload.get("azp") == "admin-cli", (
f"JWT azp claim {payload.get('azp')!r} != 'admin-cli'"
)
assert payload.get("azp") == "admin-cli", f"JWT azp claim {payload.get('azp')!r} != 'admin-cli'"
# typ = token type
assert payload.get("typ") == "Bearer", f"JWT typ claim {payload.get('typ')!r} != 'Bearer'"
@ -70,6 +68,6 @@ def test_password_grant_issues_valid_jwt(live_app):
# iat (issued at) is also a standard claim
iat = payload.get("iat")
assert isinstance(iat, int) and iat <= time.time() + 60, (
f"JWT iat {iat!r} not a reasonable past timestamp"
)
assert (
isinstance(iat, int) and iat <= time.time() + 60
), f"JWT iat {iat!r} not a reasonable past timestamp"

View File

@ -2,5 +2,7 @@
# conftest — enrolling this recipe needs NO change to runner/harness code (D5).
HEALTH_PATH = "/realms/master" # 200 JSON once keycloak is up (not "/", which redirects)
HEALTH_OK = (200,)
DEPLOY_TIMEOUT = 900 # JVM + DB migration are slow on a 2-vCPU VM; observed 502 fallback up to ~10min
DEPLOY_TIMEOUT = (
900 # JVM + DB migration are slow on a 2-vCPU VM; observed 502 fallback up to ~10min
)
HTTP_TIMEOUT = 900

View File

@ -8,7 +8,8 @@ import os
import sys
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "runner"))
from harness import browser as harness_browser, generic, lifecycle # noqa: E402
from harness import browser as harness_browser # noqa: E402
from harness import generic, lifecycle
def test_serving_and_admin_console(live_app, meta):

View File

@ -28,9 +28,7 @@ def test_users_me_requires_auth(live_app):
url = f"https://{live_app}/api/v1.0/users/me/"
# Retry with broad acceptance: any 4xx (or specific 401) indicates the route exists + auth is
# required. Reject 200 (anonymous access) and 5xx (broken backend).
status, _ = harness_http.retry_http_get(
url, expect_status=(401, 403), max_wait=60, interval=3
)
status, _ = harness_http.retry_http_get(url, expect_status=(401, 403), max_wait=60, interval=3)
assert status in (401, 403), (
f"GET {url} returned {status}, expected 401 (auth required). "
f"200 = anonymous access leaked; 404 = route missing; 5xx = backend broken."

View File

@ -27,7 +27,8 @@ import uuid
import pytest
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "..", "runner"))
from harness import http as harness_http, sso # noqa: E402
from harness import http as harness_http # noqa: E402
from harness import sso
@pytest.mark.requires_deps
@ -36,13 +37,15 @@ def test_create_doc_and_read_back(live_app, deps_creds):
kc = deps_creds["keycloak"]
# Obtain a JWT via OIDC password grant
access_token = sso.oidc_password_grant({
"client_id": kc["client_id"],
"client_secret": kc["client_secret"],
"user": kc["user"],
"password": kc["password"],
"token_url": kc["token_url"],
})
access_token = sso.oidc_password_grant(
{
"client_id": kc["client_id"],
"client_secret": kc["client_secret"],
"user": kc["user"],
"password": kc["password"],
"token_url": kc["token_url"],
}
)
auth = {"Authorization": f"Bearer {access_token}"}
# Create a doc with a unique title
@ -56,9 +59,9 @@ def test_create_doc_and_read_back(live_app, deps_creds):
assert isinstance(body, dict), f"unexpected response shape: {body!r}"
doc_id = body.get("id")
assert doc_id, f"created doc has no id: {body!r}"
assert body.get("title") == title, (
f"created doc title mismatch: created={title!r}, response={body.get('title')!r}"
)
assert (
body.get("title") == title
), f"created doc title mismatch: created={title!r}, response={body.get('title')!r}"
# Fetch it back via the dedicated GET endpoint
s, fetched = harness_http.http_get(
@ -66,9 +69,10 @@ def test_create_doc_and_read_back(live_app, deps_creds):
)
assert s == 200, f"GET /api/v1.0/documents/{doc_id}/ HTTP {s}: {fetched!r}"
assert isinstance(fetched, dict), f"unexpected GET response: {fetched!r}"
assert fetched.get("id") in (doc_id, str(doc_id)), (
f"fetched id mismatch: created={doc_id!r}, fetched={fetched.get('id')!r}"
)
assert fetched.get("title") == title, (
f"fetched title mismatch: created={title!r}, fetched={fetched.get('title')!r}"
)
assert fetched.get("id") in (
doc_id,
str(doc_id),
), f"fetched id mismatch: created={doc_id!r}, fetched={fetched.get('id')!r}"
assert (
fetched.get("title") == title
), f"fetched title mismatch: created={title!r}, fetched={fetched.get('title')!r}"

View File

@ -22,7 +22,11 @@ def test_lasuite_docs_returns_200(live_app):
url = f"https://{live_app}/"
# accept 200 (frontend SPA shell) — lasuite-docs serves the SPA at root unauthenticated;
# the SPA itself bootstraps via /api/v1.0/users/me/ which requires OIDC (separate test).
status, _ = harness_http.retry_http_get(url, expect_status=(200, 301, 302), max_wait=60, interval=3)
assert status in (200, 301, 302), (
f"lasuite-docs at {url} returned HTTP {status} (expected 200/301/302)"
status, _ = harness_http.retry_http_get(
url, expect_status=(200, 301, 302), max_wait=60, interval=3
)
assert status in (
200,
301,
302,
), f"lasuite-docs at {url} returned HTTP {status} (expected 200/301/302)"

View File

@ -25,7 +25,8 @@ import urllib.request
import pytest
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "..", "runner"))
from harness import http as harness_http, sso # noqa: E402
from harness import http as harness_http # noqa: E402
from harness import sso
_CTX = ssl.create_default_context()
_CTX.check_hostname = False
@ -61,9 +62,9 @@ def test_oidc_login_via_keycloak(live_app, deps_creds):
# 302 redirect. Both are valid "auth-required" indicators — accept either, but if a
# redirect is returned it must point at the dep keycloak realm.
if status in (301, 302, 303, 307, 308):
assert expected_prefix in (redirect or ""), (
f"Docs redirected to {redirect!r}, expected to start with {expected_prefix!r}"
)
assert expected_prefix in (
redirect or ""
), f"Docs redirected to {redirect!r}, expected to start with {expected_prefix!r}"
else:
assert status in (401, 403), (
f"GET /api/v1.0/users/me/ unauth: HTTP {status}; expected redirect to keycloak "
@ -88,6 +89,6 @@ def test_oidc_login_via_keycloak(live_app, deps_creds):
)
assert status == 200, f"GET /api/v1.0/users/me/ with token HTTP {status}: {body!r}"
assert isinstance(body, dict), f"unexpected response: {body!r}"
assert body.get("email") == kc["email"], (
f"unexpected user email: got {body.get('email')!r}, expected {kc['email']!r}"
)
assert (
body.get("email") == kc["email"]
), f"unexpected user email: got {body.get('email')!r}, expected {kc['email']!r}"

View File

@ -42,9 +42,9 @@ def test_oidc_password_grant_against_dep_keycloak(live_app, deps_creds):
# Sanity-check the creds shape — orchestrator-written
assert kc["domain"]
# WC1: realm is per-run namespaced "<parent>-<6hex>" so concurrent dependents never collide.
assert re.fullmatch(r"lasuite-docs-[0-9a-f]{6}", kc["realm"]), (
f"realm {kc['realm']!r} not the per-run namespaced form lasuite-docs-<6hex>"
)
assert re.fullmatch(
r"lasuite-docs-[0-9a-f]{6}", kc["realm"]
), f"realm {kc['realm']!r} not the per-run namespaced form lasuite-docs-<6hex>"
assert kc["client_id"] == "lasuite-docs"
assert isinstance(kc["client_secret"], str) and len(kc["client_secret"]) >= 16
assert isinstance(kc["password"], str) and len(kc["password"]) >= 16
@ -74,16 +74,14 @@ def test_oidc_password_grant_against_dep_keycloak(live_app, deps_creds):
# Password grant → real JWT
token = sso.oidc_password_grant(creds)
assert isinstance(token, str) and token.count(".") == 2, (
f"access_token is not a JWT: {token!r}"
)
assert isinstance(token, str) and token.count(".") == 2, f"access_token is not a JWT: {token!r}"
payload = json.loads(_b64url_decode(token.split(".")[1]))
assert payload.get("iss") == expected_iss, f"JWT iss={payload.get('iss')!r} != {expected_iss!r}"
assert payload.get("azp") == kc["client_id"], (
f"JWT azp={payload.get('azp')!r} != {kc['client_id']!r}"
)
assert (
payload.get("azp") == kc["client_id"]
), f"JWT azp={payload.get('azp')!r} != {kc['client_id']!r}"
assert payload.get("typ") == "Bearer", f"JWT typ={payload.get('typ')!r} != 'Bearer'"
exp = payload.get("exp")
assert isinstance(exp, int) and exp > time.time(), (
f"JWT exp={exp!r} not a future timestamp (now={time.time():.0f})"
)
assert (
isinstance(exp, int) and exp > time.time()
), f"JWT exp={exp!r} not a future timestamp (now={time.time():.0f})"

View File

@ -21,15 +21,24 @@ set -euo pipefail
: "${CCCI_APP_DOMAIN:?missing}"
: "${CCCI_DEPS_FILE:?missing}"
test -s "$CCCI_DEPS_FILE" || { echo " setup_custom_tests: deps file empty"; exit 1; }
test -s "$CCCI_DEPS_FILE" || {
echo " setup_custom_tests: deps file empty"
exit 1
}
# Read keycloak dep info via jq
KC_DOMAIN=$(jq -r '.keycloak.domain' "$CCCI_DEPS_FILE")
KC_REALM=$( jq -r '.keycloak.realm' "$CCCI_DEPS_FILE")
KC_CLIENT=$(jq -r '.keycloak.client_id' "$CCCI_DEPS_FILE")
KC_SECRET=$(jq -r '.keycloak.client_secret' "$CCCI_DEPS_FILE")
[ -n "$KC_DOMAIN" ] && [ "$KC_DOMAIN" != "null" ] || { echo " setup_custom_tests: no keycloak.domain in deps"; exit 1; }
[ -n "$KC_SECRET" ] && [ "$KC_SECRET" != "null" ] || { echo " setup_custom_tests: no keycloak.client_secret"; exit 1; }
KC_DOMAIN=$(jq -r '.keycloak.domain' "$CCCI_DEPS_FILE")
KC_REALM=$(jq -r '.keycloak.realm' "$CCCI_DEPS_FILE")
KC_CLIENT=$(jq -r '.keycloak.client_id' "$CCCI_DEPS_FILE")
KC_SECRET=$(jq -r '.keycloak.client_secret' "$CCCI_DEPS_FILE")
if [ -z "$KC_DOMAIN" ] || [ "$KC_DOMAIN" = "null" ]; then
echo " setup_custom_tests: no keycloak.domain in deps"
exit 1
fi
if [ -z "$KC_SECRET" ] || [ "$KC_SECRET" = "null" ]; then
echo " setup_custom_tests: no keycloak.client_secret"
exit 1
fi
echo " lasuite-docs setup_custom_tests: wiring OIDC against keycloak dep ${KC_DOMAIN}"
@ -39,12 +48,15 @@ echo " lasuite-docs setup_custom_tests: wiring OIDC against keycloak dep ${KC_D
# update SECRET_OIDC_RPCS_VERSION in the .env to point at the new one.
ENV_PATH="$HOME/.abra/servers/default/${CCCI_APP_DOMAIN}.env"
CUR_VER=$(grep -E '^\s*SECRET_OIDC_RPCS_VERSION=' "$ENV_PATH" | tail -1 | cut -d= -f2 | tr -d '"\r' || echo "v1")
NEW_NUM=$(( ${CUR_VER#v} + 1 ))
NEW_NUM=$((${CUR_VER#v} + 1))
NEW_VER="v${NEW_NUM}"
INSERT_LOG=$(abra app secret insert $CCCI_APP_DOMAIN oidc_rpcs $NEW_VER $KC_SECRET --no-input -C -o 2>&1) \
|| INSERT_LOG=$(script -qec "abra app secret insert $CCCI_APP_DOMAIN oidc_rpcs $NEW_VER $KC_SECRET --no-input -C -o" /dev/null 2>&1) \
|| { echo " setup_custom_tests: abra app secret insert oidc_rpcs@$NEW_VER failed: $INSERT_LOG"; exit 1; }
INSERT_LOG=$(abra app secret insert "$CCCI_APP_DOMAIN" oidc_rpcs "$NEW_VER" "$KC_SECRET" --no-input -C -o 2>&1) ||
INSERT_LOG=$(script -qec "abra app secret insert $CCCI_APP_DOMAIN oidc_rpcs $NEW_VER $KC_SECRET --no-input -C -o" /dev/null 2>&1) ||
{
echo " setup_custom_tests: abra app secret insert oidc_rpcs@$NEW_VER failed: $INSERT_LOG"
exit 1
}
# Repoint the env var to the new version
sed -i "s|^\s*SECRET_OIDC_RPCS_VERSION=.*|SECRET_OIDC_RPCS_VERSION=$NEW_VER|" "$ENV_PATH"
echo " setup_custom_tests: oidc_rpcs secret inserted at $NEW_VER (was $CUR_VER)"
@ -52,25 +64,25 @@ echo " setup_custom_tests: oidc_rpcs secret inserted at $NEW_VER (was $CUR_VER)
# 2) Write OIDC env vars to the app's .env (names per lasuite-docs's .env.sample).
# Ensure the file ends with a newline FIRST so our appends don't concatenate onto the last line
# (we saw `TIMEOUT=900OIDC_REALM=...` malformed by a missing-trailing-newline file).
[ -z "$(tail -c1 "$ENV_PATH" 2>/dev/null)" ] || printf '\n' >> "$ENV_PATH"
write_env () {
[ -z "$(tail -c1 "$ENV_PATH" 2>/dev/null)" ] || printf '\n' >>"$ENV_PATH"
write_env() {
local key="$1" val="$2"
# remove any existing key (commented or live) then append the live key=val
sed -i "/^\s*#\?\s*${key}=/d" "$ENV_PATH"
# Re-ensure trailing newline after each delete (sed may leave the file without one)
[ -z "$(tail -c1 "$ENV_PATH" 2>/dev/null)" ] || printf '\n' >> "$ENV_PATH"
printf '%s=%s\n' "$key" "$val" >> "$ENV_PATH"
[ -z "$(tail -c1 "$ENV_PATH" 2>/dev/null)" ] || printf '\n' >>"$ENV_PATH"
printf '%s=%s\n' "$key" "$val" >>"$ENV_PATH"
}
write_env OIDC_REALM "$KC_REALM"
write_env OIDC_OP_DISCOVERY_ENDPOINT "https://${KC_DOMAIN}/realms/${KC_REALM}/.well-known/openid-configuration"
write_env OIDC_OP_AUTHORIZATION_ENDPOINT "https://${KC_DOMAIN}/realms/${KC_REALM}/protocol/openid-connect/auth"
write_env OIDC_OP_TOKEN_ENDPOINT "https://${KC_DOMAIN}/realms/${KC_REALM}/protocol/openid-connect/token"
write_env OIDC_OP_USER_ENDPOINT "https://${KC_DOMAIN}/realms/${KC_REALM}/protocol/openid-connect/userinfo"
write_env OIDC_OP_LOGOUT_ENDPOINT "https://${KC_DOMAIN}/realms/${KC_REALM}/protocol/openid-connect/logout"
write_env OIDC_OP_JWKS_ENDPOINT "https://${KC_DOMAIN}/realms/${KC_REALM}/protocol/openid-connect/certs"
write_env OIDC_RP_CLIENT_ID "$KC_CLIENT"
write_env OIDC_RP_SIGN_ALGO "RS256"
write_env OIDC_RP_SCOPES "openid email profile"
write_env OIDC_REALM "$KC_REALM"
write_env OIDC_OP_DISCOVERY_ENDPOINT "https://${KC_DOMAIN}/realms/${KC_REALM}/.well-known/openid-configuration"
write_env OIDC_OP_AUTHORIZATION_ENDPOINT "https://${KC_DOMAIN}/realms/${KC_REALM}/protocol/openid-connect/auth"
write_env OIDC_OP_TOKEN_ENDPOINT "https://${KC_DOMAIN}/realms/${KC_REALM}/protocol/openid-connect/token"
write_env OIDC_OP_USER_ENDPOINT "https://${KC_DOMAIN}/realms/${KC_REALM}/protocol/openid-connect/userinfo"
write_env OIDC_OP_LOGOUT_ENDPOINT "https://${KC_DOMAIN}/realms/${KC_REALM}/protocol/openid-connect/logout"
write_env OIDC_OP_JWKS_ENDPOINT "https://${KC_DOMAIN}/realms/${KC_REALM}/protocol/openid-connect/certs"
write_env OIDC_RP_CLIENT_ID "$KC_CLIENT"
write_env OIDC_RP_SIGN_ALGO "RS256"
write_env OIDC_RP_SCOPES "openid email profile"
# 3) Trigger an in-place redeploy so the env update takes effect. --force re-deploys even when
# the recipe hasn't changed; --chaos avoids the chaos prompt; --no-input non-interactive.

View File

@ -10,7 +10,8 @@ import os
import sys
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "runner"))
from harness import browser as harness_browser, generic, lifecycle # noqa: E402
from harness import browser as harness_browser # noqa: E402
from harness import generic, lifecycle
def test_serving_and_frontend(live_app, meta):

View File

@ -25,6 +25,8 @@ def test_lasuite_drive_returns_200(live_app):
status, _ = harness_http.retry_http_get(
url, expect_status=(200, 301, 302), max_wait=60, interval=3
)
assert status in (200, 301, 302), (
f"lasuite-drive at {url} returned HTTP {status} (expected 200/301/302)"
)
assert status in (
200,
301,
302,
), f"lasuite-drive at {url} returned HTTP {status} (expected 200/301/302)"

View File

@ -29,8 +29,8 @@ BUCKET = "drive-media-storage"
def _mc(domain: str, script: str) -> str:
"""Run an `mc` shell script inside the minio container (root creds from /run/secrets)."""
prelude = (
'set -e; '
'U=$(cat /run/secrets/minio_ru); P=$(cat /run/secrets/minio_rp); '
"set -e; "
"U=$(cat /run/secrets/minio_ru); P=$(cat /run/secrets/minio_rp); "
'mc alias set ccci http://localhost:9000 "$U" "$P" >/dev/null 2>&1; '
)
return lifecycle.exec_in_app(domain, ["sh", "-c", prelude + script], service="minio")
@ -49,13 +49,13 @@ def test_minio_bucket_present_and_object_roundtrip(live_app):
domain,
# upload via stdin; list the object; read it back (tagged); then delete.
f'printf %s "{marker}" | mc pipe ccci/{BUCKET}/{key} >/dev/null 2>&1; '
f'mc ls ccci/{BUCKET}/{key}; '
f"mc ls ccci/{BUCKET}/{key}; "
f'echo "READBACK:$(mc cat ccci/{BUCKET}/{key})"; '
f'mc rm ccci/{BUCKET}/{key} >/dev/null 2>&1',
f"mc rm ccci/{BUCKET}/{key} >/dev/null 2>&1",
)
# The object was listed (its key appears) and its content round-tripped intact.
assert f"{marker}.txt" in out, f"uploaded object not listed in bucket: {out!r}"
assert f"READBACK:{marker}" in out, (
f"object content did not round-trip through MinIO; got: {out!r}"
)
assert (
f"READBACK:{marker}" in out
), f"object content did not round-trip through MinIO; got: {out!r}"

View File

@ -46,9 +46,9 @@ def test_oidc_password_grant_against_dep_keycloak(live_app, deps_creds):
# Creds shape. WC1: realm is per-run namespaced "<parent>-<6hex>"; client_id stays the parent.
assert kc["domain"]
assert re.fullmatch(r"lasuite-drive-[0-9a-f]{6}", kc["realm"]), (
f"realm {kc['realm']!r} not the per-run namespaced form lasuite-drive-<6hex>"
)
assert re.fullmatch(
r"lasuite-drive-[0-9a-f]{6}", kc["realm"]
), f"realm {kc['realm']!r} not the per-run namespaced form lasuite-drive-<6hex>"
assert kc["client_id"] == "lasuite-drive"
assert isinstance(kc["client_secret"], str) and len(kc["client_secret"]) >= 16
assert isinstance(kc["password"], str) and len(kc["password"]) >= 16
@ -77,16 +77,14 @@ def test_oidc_password_grant_against_dep_keycloak(live_app, deps_creds):
# Password grant → real JWT
token = sso.oidc_password_grant(creds)
assert isinstance(token, str) and token.count(".") == 2, (
f"access_token is not a JWT: {token!r}"
)
assert isinstance(token, str) and token.count(".") == 2, f"access_token is not a JWT: {token!r}"
payload = json.loads(_b64url_decode(token.split(".")[1]))
assert payload.get("iss") == expected_iss, f"JWT iss={payload.get('iss')!r} != {expected_iss!r}"
assert payload.get("azp") == kc["client_id"], (
f"JWT azp={payload.get('azp')!r} != {kc['client_id']!r}"
)
assert (
payload.get("azp") == kc["client_id"]
), f"JWT azp={payload.get('azp')!r} != {kc['client_id']!r}"
assert payload.get("typ") == "Bearer", f"JWT typ={payload.get('typ')!r} != 'Bearer'"
exp = payload.get("exp")
assert isinstance(exp, int) and exp > time.time(), (
f"JWT exp={exp!r} not a future timestamp (now={time.time():.0f})"
)
assert (
isinstance(exp, int) and exp > time.time()
), f"JWT exp={exp!r} not a future timestamp (now={time.time():.0f})"

View File

@ -28,7 +28,7 @@ if [ -z "${CCCI_DEPS_FILE:-}" ] || [ ! -s "${CCCI_DEPS_FILE}" ]; then
exit 0
fi
KC_DOMAIN=$(jq -r '.keycloak.domain // empty' "$CCCI_DEPS_FILE")
KC_REALM=$( jq -r '.keycloak.realm // empty' "$CCCI_DEPS_FILE")
KC_REALM=$(jq -r '.keycloak.realm // empty' "$CCCI_DEPS_FILE")
KC_CLIENT=$(jq -r '.keycloak.client_id // empty' "$CCCI_DEPS_FILE")
KC_SECRET=$(jq -r '.keycloak.client_secret // empty' "$CCCI_DEPS_FILE")
if [ -z "$KC_DOMAIN" ] || [ -z "$KC_SECRET" ]; then
@ -43,35 +43,38 @@ echo " lasuite-drive install_steps: wiring OIDC at install against keycloak ${K
# point SECRET_OIDC_RPCS_VERSION at it. (The app is not deployed yet — a swarm secret can be created
# independently of a running stack — so the single deploy below picks up v2.)
CUR_VER=$(grep -E '^\s*SECRET_OIDC_RPCS_VERSION=' "$ENV_PATH" | tail -1 | cut -d= -f2 | tr -d '"\r' || echo "v1")
NEW_NUM=$(( ${CUR_VER#v} + 1 ))
NEW_NUM=$((${CUR_VER#v} + 1))
NEW_VER="v${NEW_NUM}"
INSERT_LOG=$(abra app secret insert "$CCCI_APP_DOMAIN" oidc_rpcs "$NEW_VER" "$KC_SECRET" --no-input -C -o 2>&1) \
|| INSERT_LOG=$(script -qec "abra app secret insert $CCCI_APP_DOMAIN oidc_rpcs $NEW_VER $KC_SECRET --no-input -C -o" /dev/null 2>&1) \
|| { echo " install_steps: abra app secret insert oidc_rpcs@$NEW_VER failed: $INSERT_LOG"; exit 1; }
INSERT_LOG=$(abra app secret insert "$CCCI_APP_DOMAIN" oidc_rpcs "$NEW_VER" "$KC_SECRET" --no-input -C -o 2>&1) ||
INSERT_LOG=$(script -qec "abra app secret insert $CCCI_APP_DOMAIN oidc_rpcs $NEW_VER $KC_SECRET --no-input -C -o" /dev/null 2>&1) ||
{
echo " install_steps: abra app secret insert oidc_rpcs@$NEW_VER failed: $INSERT_LOG"
exit 1
}
sed -i "s|^\s*SECRET_OIDC_RPCS_VERSION=.*|SECRET_OIDC_RPCS_VERSION=$NEW_VER|" "$ENV_PATH"
echo " install_steps: oidc_rpcs secret inserted at $NEW_VER (was $CUR_VER)"
# 2) Write the OIDC env vars (explicit endpoints — deterministic, no reliance on ${AUTH_DOMAIN}
# expansion). Mirrors the recipe-maintainer impress/La Suite OIDC env contract.
write_env () {
write_env() {
local key="$1" val="$2"
sed -i "/^\s*#\?\s*${key}=/d" "$ENV_PATH"
[ -z "$(tail -c1 "$ENV_PATH" 2>/dev/null)" ] || printf '\n' >> "$ENV_PATH"
printf '%s=%s\n' "$key" "$val" >> "$ENV_PATH"
[ -z "$(tail -c1 "$ENV_PATH" 2>/dev/null)" ] || printf '\n' >>"$ENV_PATH"
printf '%s=%s\n' "$key" "$val" >>"$ENV_PATH"
}
write_env AUTH_DOMAIN "$KC_DOMAIN"
write_env OIDC_REALM "$KC_REALM"
write_env OIDC_OP_JWKS_ENDPOINT "https://${KC_DOMAIN}/realms/${KC_REALM}/protocol/openid-connect/certs"
write_env OIDC_OP_AUTHORIZATION_ENDPOINT "https://${KC_DOMAIN}/realms/${KC_REALM}/protocol/openid-connect/auth"
write_env OIDC_OP_TOKEN_ENDPOINT "https://${KC_DOMAIN}/realms/${KC_REALM}/protocol/openid-connect/token"
write_env OIDC_OP_USER_ENDPOINT "https://${KC_DOMAIN}/realms/${KC_REALM}/protocol/openid-connect/userinfo"
write_env OIDC_OP_LOGOUT_ENDPOINT "https://${KC_DOMAIN}/realms/${KC_REALM}/protocol/openid-connect/logout"
write_env OIDC_RP_CLIENT_ID "$KC_CLIENT"
write_env OIDC_RP_SIGN_ALGO "RS256"
write_env OIDC_RP_SCOPES "openid email profile"
write_env OIDC_REDIRECT_ALLOWED_HOSTS "[\"https://${KC_DOMAIN}\", \"https://${CCCI_APP_DOMAIN}\"]"
write_env AUTH_DOMAIN "$KC_DOMAIN"
write_env OIDC_REALM "$KC_REALM"
write_env OIDC_OP_JWKS_ENDPOINT "https://${KC_DOMAIN}/realms/${KC_REALM}/protocol/openid-connect/certs"
write_env OIDC_OP_AUTHORIZATION_ENDPOINT "https://${KC_DOMAIN}/realms/${KC_REALM}/protocol/openid-connect/auth"
write_env OIDC_OP_TOKEN_ENDPOINT "https://${KC_DOMAIN}/realms/${KC_REALM}/protocol/openid-connect/token"
write_env OIDC_OP_USER_ENDPOINT "https://${KC_DOMAIN}/realms/${KC_REALM}/protocol/openid-connect/userinfo"
write_env OIDC_OP_LOGOUT_ENDPOINT "https://${KC_DOMAIN}/realms/${KC_REALM}/protocol/openid-connect/logout"
write_env OIDC_RP_CLIENT_ID "$KC_CLIENT"
write_env OIDC_RP_SIGN_ALGO "RS256"
write_env OIDC_RP_SCOPES "openid email profile"
write_env OIDC_REDIRECT_ALLOWED_HOSTS "[\"https://${KC_DOMAIN}\", \"https://${CCCI_APP_DOMAIN}\"]"
# The recipe default acr_values=eidas1 is FranceConnect-specific; keycloak can't satisfy it and it
# would break the interactive auth flow. Clear it so the keycloak OIDC client works.
write_env OIDC_AUTH_REQUEST_EXTRA_PARAMS "{}"
write_env OIDC_AUTH_REQUEST_EXTRA_PARAMS "{}"
echo " lasuite-drive install_steps: OIDC env wired into .env (deploy will pick it up, no reconverge)"

View File

@ -29,7 +29,7 @@ docker service scale --detach "${STACK}_minio-createbuckets=1" >/dev/null 2>&1 |
for i in $(seq 1 30); do
MC_CID=$(docker ps -q -f "name=${STACK}_minio.1" | head -1)
if [ -n "$MC_CID" ] && docker exec "$MC_CID" sh -c \
'mc alias set _c http://localhost:9000 "$(cat /run/secrets/minio_ru)" "$(cat /run/secrets/minio_rp)" >/dev/null 2>&1 && mc ls _c/drive-media-storage >/dev/null 2>&1'; then
'mc alias set _c http://localhost:9000 "$(cat /run/secrets/minio_ru)" "$(cat /run/secrets/minio_rp)" >/dev/null 2>&1 && mc ls _c/drive-media-storage >/dev/null 2>&1'; then
echo " setup: bucket drive-media-storage present after ${i} poll(s)"
break
fi

View File

@ -10,7 +10,8 @@ import os
import sys
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "runner"))
from harness import browser as harness_browser, generic, lifecycle # noqa: E402
from harness import browser as harness_browser # noqa: E402
from harness import generic, lifecycle
def test_serving_and_frontend(live_app, meta):

View File

@ -21,6 +21,8 @@ def test_lasuite_meet_returns_200(live_app):
status, _ = harness_http.retry_http_get(
url, expect_status=(200, 301, 302), max_wait=60, interval=3
)
assert status in (200, 301, 302), (
f"lasuite-meet at {url} returned HTTP {status} (expected 200/301/302)"
)
assert status in (
200,
301,
302,
), f"lasuite-meet at {url} returned HTTP {status} (expected 200/301/302)"

View File

@ -28,7 +28,8 @@ import sys
import pytest
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "..", "runner"))
from harness import http as harness_http, sso # noqa: E402
from harness import http as harness_http # noqa: E402
from harness import sso
def _b64url(seg: str) -> bytes:
@ -74,33 +75,40 @@ def test_create_room_get_livekit_token_and_read_back(live_app, deps_creds):
lk_room = livekit.get("room")
lk_token = livekit.get("token")
assert room_id, f"room created but no id: {body!r}"
assert lk_token and isinstance(lk_token, str) and lk_token.count(".") == 2, (
f"room created but no LiveKit JWT token: {livekit!r}"
)
assert (
lk_token and isinstance(lk_token, str) and lk_token.count(".") == 2
), f"room created but no LiveKit JWT token: {livekit!r}"
try:
# --- read it back (a fresh authenticated GET of the created room) ---
status, got = harness_http.http_request("GET", f"{base}/api/v1.0/rooms/{room_id}/", headers=auth)
status, got = harness_http.http_request(
"GET", f"{base}/api/v1.0/rooms/{room_id}/", headers=auth
)
assert status == 200, f"room read-back returned HTTP {status} (expected 200); body={got!r}"
assert isinstance(got, dict) and got.get("id") == room_id, (
f"read-back room id mismatch: {got!r}"
)
got_lk = (got.get("livekit") or {})
assert (
isinstance(got, dict) and got.get("id") == room_id
), f"read-back room id mismatch: {got!r}"
got_lk = got.get("livekit") or {}
assert got_lk.get("token"), f"read-back room missing LiveKit token: {got!r}"
assert got_lk.get("room") == lk_room, (
f"read-back LiveKit room {got_lk.get('room')!r} != create-time {lk_room!r}"
)
assert (
got_lk.get("room") == lk_room
), f"read-back LiveKit room {got_lk.get('room')!r} != create-time {lk_room!r}"
# --- the LiveKit token is a real signaling grant for this room (WebRTC subset) ---
payload = json.loads(_b64url(lk_token.split(".")[1]))
video = payload.get("video") or {}
assert video.get("room") == lk_room or payload.get("room") == lk_room, (
f"LiveKit JWT does not grant the created room {lk_room!r}: {payload!r}"
)
assert (
video.get("room") == lk_room or payload.get("room") == lk_room
), f"LiveKit JWT does not grant the created room {lk_room!r}: {payload!r}"
finally:
# --- delete the room (cleanup + a real DELETE mutation) ---
del_status, _ = harness_http.http_request("DELETE", f"{base}/api/v1.0/rooms/{room_id}/", headers=auth)
assert del_status in (204, 200), f"room delete returned HTTP {del_status} (expected 204/200)"
del_status, _ = harness_http.http_request(
"DELETE", f"{base}/api/v1.0/rooms/{room_id}/", headers=auth
)
assert del_status in (
204,
200,
), f"room delete returned HTTP {del_status} (expected 204/200)"
# --- best-effort: confirm the delete took (404 on re-GET). The §4.3 floor (create-an-object +
# read-it-back + LiveKit-token issuance) is already proven by the hard assertions above; this
@ -112,7 +120,9 @@ def test_create_room_get_livekit_token_and_read_back(live_app, deps_creds):
gone = False
for _ in range(5):
status, _ = harness_http.http_request("GET", f"{base}/api/v1.0/rooms/{room_id}/", headers=auth)
status, _ = harness_http.http_request(
"GET", f"{base}/api/v1.0/rooms/{room_id}/", headers=auth
)
if status == 404:
gone = True
break

View File

@ -46,9 +46,9 @@ def test_oidc_password_grant_against_dep_keycloak(live_app, deps_creds):
# Creds shape. WC1: realm is per-run namespaced "<parent>-<6hex>"; client_id stays the parent.
assert kc["domain"]
assert re.fullmatch(r"lasuite-meet-[0-9a-f]{6}", kc["realm"]), (
f"realm {kc['realm']!r} not the per-run namespaced form lasuite-meet-<6hex>"
)
assert re.fullmatch(
r"lasuite-meet-[0-9a-f]{6}", kc["realm"]
), f"realm {kc['realm']!r} not the per-run namespaced form lasuite-meet-<6hex>"
assert kc["client_id"] == "lasuite-meet"
assert isinstance(kc["client_secret"], str) and len(kc["client_secret"]) >= 16
assert isinstance(kc["password"], str) and len(kc["password"]) >= 16
@ -77,16 +77,14 @@ def test_oidc_password_grant_against_dep_keycloak(live_app, deps_creds):
# Password grant → real JWT
token = sso.oidc_password_grant(creds)
assert isinstance(token, str) and token.count(".") == 2, (
f"access_token is not a JWT: {token!r}"
)
assert isinstance(token, str) and token.count(".") == 2, f"access_token is not a JWT: {token!r}"
payload = json.loads(_b64url_decode(token.split(".")[1]))
assert payload.get("iss") == expected_iss, f"JWT iss={payload.get('iss')!r} != {expected_iss!r}"
assert payload.get("azp") == kc["client_id"], (
f"JWT azp={payload.get('azp')!r} != {kc['client_id']!r}"
)
assert (
payload.get("azp") == kc["client_id"]
), f"JWT azp={payload.get('azp')!r} != {kc['client_id']!r}"
assert payload.get("typ") == "Bearer", f"JWT typ={payload.get('typ')!r} != 'Bearer'"
exp = payload.get("exp")
assert isinstance(exp, int) and exp > time.time(), (
f"JWT exp={exp!r} not a future timestamp (now={time.time():.0f})"
)
assert (
isinstance(exp, int) and exp > time.time()
), f"JWT exp={exp!r} not a future timestamp (now={time.time():.0f})"

View File

@ -26,7 +26,7 @@ if [ -z "${CCCI_DEPS_FILE:-}" ] || [ ! -s "${CCCI_DEPS_FILE}" ]; then
exit 0
fi
KC_DOMAIN=$(jq -r '.keycloak.domain // empty' "$CCCI_DEPS_FILE")
KC_REALM=$( jq -r '.keycloak.realm // empty' "$CCCI_DEPS_FILE")
KC_REALM=$(jq -r '.keycloak.realm // empty' "$CCCI_DEPS_FILE")
KC_CLIENT=$(jq -r '.keycloak.client_id // empty' "$CCCI_DEPS_FILE")
KC_SECRET=$(jq -r '.keycloak.client_secret // empty' "$CCCI_DEPS_FILE")
if [ -z "$KC_DOMAIN" ] || [ -z "$KC_SECRET" ]; then
@ -40,31 +40,34 @@ echo " lasuite-meet install_steps: wiring OIDC at install against keycloak ${KC
# forbids overwriting a secret at the same version). The app is not deployed yet — a swarm secret can
# be created independently — so the single deploy below picks up v2.
CUR_VER=$(grep -E '^\s*SECRET_OIDC_RPCS_VERSION=' "$ENV_PATH" | tail -1 | cut -d= -f2 | tr -d '"\r' || echo "v1")
NEW_NUM=$(( ${CUR_VER#v} + 1 ))
NEW_NUM=$((${CUR_VER#v} + 1))
NEW_VER="v${NEW_NUM}"
INSERT_LOG=$(abra app secret insert "$CCCI_APP_DOMAIN" oidc_rpcs "$NEW_VER" "$KC_SECRET" --no-input -C -o 2>&1) \
|| INSERT_LOG=$(script -qec "abra app secret insert $CCCI_APP_DOMAIN oidc_rpcs $NEW_VER $KC_SECRET --no-input -C -o" /dev/null 2>&1) \
|| { echo " install_steps: abra app secret insert oidc_rpcs@$NEW_VER failed: $INSERT_LOG"; exit 1; }
INSERT_LOG=$(abra app secret insert "$CCCI_APP_DOMAIN" oidc_rpcs "$NEW_VER" "$KC_SECRET" --no-input -C -o 2>&1) ||
INSERT_LOG=$(script -qec "abra app secret insert $CCCI_APP_DOMAIN oidc_rpcs $NEW_VER $KC_SECRET --no-input -C -o" /dev/null 2>&1) ||
{
echo " install_steps: abra app secret insert oidc_rpcs@$NEW_VER failed: $INSERT_LOG"
exit 1
}
sed -i "s|^\s*SECRET_OIDC_RPCS_VERSION=.*|SECRET_OIDC_RPCS_VERSION=$NEW_VER|" "$ENV_PATH"
echo " install_steps: oidc_rpcs secret inserted at $NEW_VER (was $CUR_VER)"
# 2) Write the OIDC env vars (explicit endpoints — deterministic). Meet's .env.sample templates the
# endpoints off ${AUTH_DOMAIN}; set AUTH_DOMAIN + override each endpoint with the concrete realm URL.
write_env () {
write_env() {
local key="$1" val="$2"
sed -i "/^\s*#\?\s*${key}=/d" "$ENV_PATH"
[ -z "$(tail -c1 "$ENV_PATH" 2>/dev/null)" ] || printf '\n' >> "$ENV_PATH"
printf '%s=%s\n' "$key" "$val" >> "$ENV_PATH"
[ -z "$(tail -c1 "$ENV_PATH" 2>/dev/null)" ] || printf '\n' >>"$ENV_PATH"
printf '%s=%s\n' "$key" "$val" >>"$ENV_PATH"
}
write_env AUTH_DOMAIN "$KC_DOMAIN"
write_env OIDC_REALM "$KC_REALM"
write_env OIDC_OP_JWKS_ENDPOINT "https://${KC_DOMAIN}/realms/${KC_REALM}/protocol/openid-connect/certs"
write_env OIDC_OP_AUTHORIZATION_ENDPOINT "https://${KC_DOMAIN}/realms/${KC_REALM}/protocol/openid-connect/auth"
write_env OIDC_OP_TOKEN_ENDPOINT "https://${KC_DOMAIN}/realms/${KC_REALM}/protocol/openid-connect/token"
write_env OIDC_OP_USER_ENDPOINT "https://${KC_DOMAIN}/realms/${KC_REALM}/protocol/openid-connect/userinfo"
write_env OIDC_OP_LOGOUT_ENDPOINT "https://${KC_DOMAIN}/realms/${KC_REALM}/protocol/openid-connect/logout"
write_env OIDC_RP_CLIENT_ID "$KC_CLIENT"
write_env OIDC_RP_SIGN_ALGO "RS256"
write_env OIDC_RP_SCOPES "openid email"
write_env AUTH_DOMAIN "$KC_DOMAIN"
write_env OIDC_REALM "$KC_REALM"
write_env OIDC_OP_JWKS_ENDPOINT "https://${KC_DOMAIN}/realms/${KC_REALM}/protocol/openid-connect/certs"
write_env OIDC_OP_AUTHORIZATION_ENDPOINT "https://${KC_DOMAIN}/realms/${KC_REALM}/protocol/openid-connect/auth"
write_env OIDC_OP_TOKEN_ENDPOINT "https://${KC_DOMAIN}/realms/${KC_REALM}/protocol/openid-connect/token"
write_env OIDC_OP_USER_ENDPOINT "https://${KC_DOMAIN}/realms/${KC_REALM}/protocol/openid-connect/userinfo"
write_env OIDC_OP_LOGOUT_ENDPOINT "https://${KC_DOMAIN}/realms/${KC_REALM}/protocol/openid-connect/logout"
write_env OIDC_RP_CLIENT_ID "$KC_CLIENT"
write_env OIDC_RP_SIGN_ALGO "RS256"
write_env OIDC_RP_SCOPES "openid email"
echo " lasuite-meet install_steps: OIDC env wired into .env (deploy will pick it up, no reconverge)"

View File

@ -10,7 +10,8 @@ import os
import sys
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "runner"))
from harness import browser as harness_browser, generic, lifecycle # noqa: E402
from harness import browser as harness_browser # noqa: E402
from harness import generic, lifecycle
def test_serving_and_frontend(live_app, meta):
@ -33,9 +34,11 @@ def test_serving_and_frontend(live_app, meta):
resp = harness_browser.goto_with_retry(
page, url, accept_statuses=(200, 301, 302), goto_timeout_ms=60_000
)
assert resp is not None and resp.status in (200, 301, 302), (
f"page status {resp and resp.status}"
)
assert resp is not None and resp.status in (
200,
301,
302,
), f"page status {resp and resp.status}"
assert "<html" in page.content().lower(), "no HTML served by the frontend"
finally:
browser.close()

View File

@ -43,10 +43,7 @@ def test_send_and_receive_mail(live_app):
deadline = time.time() + 150
while time.time() < deadline:
for box in ("INBOX", "Junk"):
query = (
f"doveadm search -u '{email_addr}' mailbox {box} "
f"header subject '{marker}'"
)
query = f"doveadm search -u '{email_addr}' mailbox {box} " f"header subject '{marker}'"
out = lifecycle.exec_in_app(live_app, ["sh", "-c", query], service="imap")
if out.strip(): # a non-empty result = "<mailbox-guid> <uid>" → message stored
return

View File

@ -24,6 +24,6 @@ def test_create_mailbox_and_read_back(live_app):
cfg = _mailu.config_export(live_app)
emails = _mailu.user_emails(cfg)
assert email in emails, (
f"created mailbox {email} not present in mailu config-export users {emails}"
)
assert (
email in emails
), f"created mailbox {email} not present in mailu config-export users {emails}"

View File

@ -34,12 +34,12 @@ def test_federation_version_endpoint(live_app):
assert status == 200, f"GET {url} HTTP {status} (expected 200)"
assert isinstance(body, dict), f"federation version returned non-dict: {type(body).__name__}"
server = body.get("server")
assert isinstance(server, dict), (
f"federation version response missing 'server' envelope: {body!r}"
)
assert isinstance(
server, dict
), f"federation version response missing 'server' envelope: {body!r}"
name = server.get("name")
assert name == "Synapse", f"server.name={name!r}, expected 'Synapse'"
version = server.get("version")
assert isinstance(version, str) and len(version) > 0, (
f"server.version is not a non-empty string: {version!r}"
)
assert (
isinstance(version, str) and len(version) > 0
), f"server.version is not a non-empty string: {version!r}"

View File

@ -11,7 +11,6 @@ Runs in the custom tier against the shared post-install live deployment.
from __future__ import annotations
import json
import os
import sys
@ -24,6 +23,6 @@ def test_synapse_client_versions_returns_json(live_app):
url = f"https://{live_app}/_matrix/client/versions"
status, body = harness_http.retry_http_get(url, expect_status=200, max_wait=60, interval=3)
assert status == 200, f"GET {url} HTTP {status} (expected 200)"
assert isinstance(body, dict) and isinstance(body.get("versions"), list) and body["versions"], (
f"GET {url} did not return Matrix client-versions document: {body!r}"
)
assert (
isinstance(body, dict) and isinstance(body.get("versions"), list) and body["versions"]
), f"GET {url} did not return Matrix client-versions document: {body!r}"

View File

@ -42,7 +42,8 @@ import sys
import uuid
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "..", "runner"))
from harness import http as harness_http, lifecycle # noqa: E402
from harness import http as harness_http # noqa: E402
from harness import lifecycle
def _registration_secret(domain: str) -> str:
@ -101,8 +102,11 @@ def _admin_register(domain: str, secret: str, username: str, password: str, admi
r = _container_curl(domain, "GET", "/_synapse/admin/v1/register")
if r["status"] in (500, 502, 503, 504, 0):
last = r
print(f" [register] {username}: nonce GET transient {r['status']} "
f"(attempt {attempt}, synapse recovering) — retrying", flush=True)
print(
f" [register] {username}: nonce GET transient {r['status']} "
f"(attempt {attempt}, synapse recovering) — retrying",
flush=True,
)
time.sleep(5)
continue
assert r["status"] == 200, f"nonce GET failed: status={r['status']} raw={r['raw'][:200]!r}"
@ -122,13 +126,19 @@ def _admin_register(domain: str, secret: str, username: str, password: str, admi
r = _container_curl(domain, "POST", "/_synapse/admin/v1/register", body=payload)
if r["status"] == 200:
if attempt > 1:
print(f" [register] {username}: succeeded on attempt {attempt} "
f"(synapse recovered)", flush=True)
print(
f" [register] {username}: succeeded on attempt {attempt} "
f"(synapse recovered)",
flush=True,
)
return r["body"] or {}
if r["status"] in (500, 502, 503, 504, 0):
last = r
print(f" [register] {username}: POST transient {r['status']} "
f"(attempt {attempt}, synapse recovering) — retrying", flush=True)
print(
f" [register] {username}: POST transient {r['status']} "
f"(attempt {attempt}, synapse recovering) — retrying",
flush=True,
)
time.sleep(5)
continue
# a 4xx is a real rejection — fail fast, do not retry
@ -167,9 +177,9 @@ def test_register_two_users_send_receive_message(live_app):
create + invite + join a room; send and read a message."""
domain = live_app
secret = _registration_secret(domain)
assert secret and len(secret) >= 16, (
f"registration shared secret missing/short: len={len(secret) if secret else 0}"
)
assert (
secret and len(secret) >= 16
), f"registration shared secret missing/short: len={len(secret) if secret else 0}"
suffix = uuid.uuid4().hex[:8]
user_a = f"alice{suffix}"

View File

@ -41,9 +41,9 @@ def test_create_message_roundtrip(live_app):
headers=auth,
timeout=30,
)
assert status in (200, 201) and isinstance(team, dict) and team.get("id"), (
f"team creation failed: HTTP {status}, body={team!r}"
)
assert (
status in (200, 201) and isinstance(team, dict) and team.get("id")
), f"team creation failed: HTTP {status}, body={team!r}"
status, chan = harness_http.http_post(
f"{base}/channels",
data={
@ -55,9 +55,9 @@ def test_create_message_roundtrip(live_app):
headers=auth,
timeout=30,
)
assert status in (200, 201) and isinstance(chan, dict) and chan.get("id"), (
f"channel creation failed: HTTP {status}, body={chan!r}"
)
assert (
status in (200, 201) and isinstance(chan, dict) and chan.get("id")
), f"channel creation failed: HTTP {status}, body={chan!r}"
# 4) POST a unique marker message.
marker = f"ccci-marker-{uniq}-roundtrip"
@ -67,13 +67,13 @@ def test_create_message_roundtrip(live_app):
headers=auth,
timeout=30,
)
assert status in (200, 201) and isinstance(post, dict) and post.get("id"), (
f"post creation failed: HTTP {status}, body={post!r}"
)
assert (
status in (200, 201) and isinstance(post, dict) and post.get("id")
), f"post creation failed: HTTP {status}, body={post!r}"
# 5) Read it back by id and assert the message survived the round-trip.
status, got = harness_http.http_get(f"{base}/posts/{post['id']}", headers=auth, timeout=30)
assert status == 200 and isinstance(got, dict), f"read-back failed: HTTP {status}, body={got!r}"
assert got.get("message") == marker, (
f"message did not round-trip: sent {marker!r}, got {got.get('message')!r}"
)
assert (
got.get("message") == marker
), f"message did not round-trip: sent {marker!r}, got {got.get('message')!r}"

View File

@ -18,9 +18,7 @@ from harness import http as harness_http # noqa: E402
def test_root_serves(live_app):
"""GET / → 200 or 302 (mattermost web app shell / login redirect)."""
url = f"https://{live_app}/"
status, _ = harness_http.retry_http_get(
url, expect_status=(200, 302), max_wait=60, interval=3
)
status, _ = harness_http.retry_http_get(url, expect_status=(200, 302), max_wait=60, interval=3)
assert status in (200, 302), f"GET {url} HTTP {status} (expected 200/302)"
@ -28,10 +26,8 @@ def test_system_ping_ok(live_app):
"""GET /api/v4/system/ping → 200 with JSON {"status":"OK"} — the mattermost server's own
liveness endpoint (distinguishes a live mattermost API from a Traefik fallback / dead backend)."""
url = f"https://{live_app}/api/v4/system/ping"
status, body = harness_http.retry_http_get(
url, expect_status=200, max_wait=120, interval=3
)
status, body = harness_http.retry_http_get(url, expect_status=200, max_wait=120, interval=3)
assert status == 200, f"GET {url} HTTP {status} (expected 200)"
assert isinstance(body, dict) and body.get("status") == "OK", (
f"/api/v4/system/ping did not report status=OK; got {body!r}"
)
assert (
isinstance(body, dict) and body.get("status") == "OK"
), f"/api/v4/system/ping did not report status=OK; got {body!r}"

View File

@ -51,7 +51,12 @@ def test_second_user_reads_first_users_message(live_app):
assert status in (200, 201) and team.get("id"), f"team create HTTP {status}: {team!r}"
status, chan = harness_http.http_post(
f"{base}/channels",
data={"team_id": team["id"], "name": f"c{uniq}", "display_name": f"chan {uniq}", "type": "O"},
data={
"team_id": team["id"],
"name": f"c{uniq}",
"display_name": f"chan {uniq}",
"type": "O",
},
headers=auth_a,
timeout=30,
)
@ -60,7 +65,10 @@ def test_second_user_reads_first_users_message(live_app):
# 2) user_a posts a unique marker
marker = f"ccci-multiuser-{uniq}"
status, post = harness_http.http_post(
f"{base}/posts", data={"channel_id": chan["id"], "message": marker}, headers=auth_a, timeout=30
f"{base}/posts",
data={"channel_id": chan["id"], "message": marker},
headers=auth_a,
timeout=30,
)
assert status in (200, 201) and post.get("id"), f"post create HTTP {status}: {post!r}"
@ -97,6 +105,6 @@ def test_second_user_reads_first_users_message(live_app):
# 5) user_b sees user_a's marker (cross-user delivery, not a self read-back)
messages = [p.get("message") for p in (posts.get("posts") or {}).values()]
assert marker in messages, (
f"user_b did not see user_a's message {marker!r} in the channel; saw {messages!r}"
)
assert (
marker in messages
), f"user_b did not see user_a's message {marker!r} in the channel; saw {messages!r}"

View File

@ -15,6 +15,4 @@ def test_serving_and_api(live_app, meta):
generic.assert_serving(live_app, meta)
# ... then the recipe-specific assertion: the mattermost REST liveness endpoint answers 200.
status = lifecycle.http_get(live_app, "/api/v4/system/ping")
assert status == 200, (
f"expected 200 from {live_app}/api/v4/system/ping, got {status}"
)
assert status == 200, f"expected 200 from {live_app}/api/v4/system/ping, got {status}"

View File

@ -12,6 +12,7 @@ cc-ci host (`mode: host`); tests run on-host via cc-ci-run, so they connect to 1
from __future__ import annotations
import contextlib
import socket
import ssl
import struct
@ -29,8 +30,14 @@ MSG_USERSTATE = 9
MSG_SERVERCONFIG = 24
REJECT_TYPES = {
0: "None", 1: "WrongVersion", 2: "InvalidUsername", 3: "WrongUserPW",
4: "WrongServerPW", 5: "UsernameInUse", 6: "ServerFull", 7: "NoCertificate",
0: "None",
1: "WrongVersion",
2: "InvalidUsername",
3: "WrongUserPW",
4: "WrongServerPW",
5: "UsernameInUse",
6: "ServerFull",
7: "NoCertificate",
8: "AuthenticatorFail",
}
@ -81,7 +88,7 @@ def _dec_fields(data: bytes) -> dict:
off += 8
elif wire == 2:
length, off = _dec_varint(data, off)
raw = data[off:off + length]
raw = data[off : off + length]
off += length
try:
value = raw.decode("utf-8")
@ -120,9 +127,11 @@ def _recv(sock, timeout: float) -> tuple[int, bytes]:
def _build_version() -> bytes:
v = (1 << 16) | (5 << 8) | 0 # pretend client 1.5.0
return (_enc_field_varint(1, v)
+ _enc_field_string(2, "cc-ci mumble probe 1.0")
+ _enc_field_string(3, "Linux"))
return (
_enc_field_varint(1, v)
+ _enc_field_string(2, "cc-ci mumble probe 1.0")
+ _enc_field_string(3, "Linux")
)
def _build_authenticate(username: str, password: str = "") -> bytes:
@ -133,18 +142,29 @@ def _build_authenticate(username: str, password: str = "") -> bytes:
return payload
def handshake(host: str = "127.0.0.1", port: int = PORT, username: str = "cc-ci-probe",
password: str = "", timeout: float = 20.0) -> dict:
def handshake(
host: str = "127.0.0.1",
port: int = PORT,
username: str = "cc-ci-probe",
password: str = "",
timeout: float = 20.0,
) -> dict:
"""Full Mumble control-channel handshake. Returns a result dict:
tls_connect (bool), server_version (dict|None), auth_accepted (bool), channels (list[str]),
users (list[str]), server_sync (bool), welcome_text (str|None), server_config (dict),
error (str|None).
tls_connect (bool), server_version (dict|None), auth_accepted (bool), channels (list[str]),
users (list[str]), server_sync (bool), welcome_text (str|None), server_config (dict),
error (str|None).
"""
result = {
"tls_connect": False, "server_version": None, "auth_accepted": False,
"channels": [], "users": [], "server_sync": False, "welcome_text": None,
"server_config": {}, "error": None,
"tls_connect": False,
"server_version": None,
"auth_accepted": False,
"channels": [],
"users": [],
"server_sync": False,
"welcome_text": None,
"server_config": {},
"error": None,
}
raw = tls = None
try:
@ -181,19 +201,21 @@ def handshake(host: str = "127.0.0.1", port: int = PORT, username: str = "cc-ci-
break
try:
msg_type, payload = _recv(tls, timeout=remaining)
except (socket.timeout, ConnectionError):
except (TimeoutError, ConnectionError):
break
if msg_type == MSG_VERSION:
f = _dec_fields(payload)
v1 = f.get(1, 0)
result["server_version"] = {
"string": f"{(v1 >> 16) & 0xFF}.{(v1 >> 8) & 0xFF}.{v1 & 0xFF}",
"release": f.get(2, ""), "os": f.get(3, ""),
"release": f.get(2, ""),
"os": f.get(3, ""),
}
elif msg_type == MSG_REJECT:
f = _dec_fields(payload)
result["error"] = (f"Rejected: {REJECT_TYPES.get(f.get(1, 0), 'Unknown')} "
f"{f.get(2, '')}")
result["error"] = (
f"Rejected: {REJECT_TYPES.get(f.get(1, 0), 'Unknown')} " f"{f.get(2, '')}"
)
return result
elif msg_type == MSG_CHANNELSTATE:
f = _dec_fields(payload)
@ -209,9 +231,12 @@ def handshake(host: str = "127.0.0.1", port: int = PORT, username: str = "cc-ci-
# ServerConfig fields: 1 max_bandwidth, 2 welcome_text, 3 allow_html,
# 4 message_length, 5 image_message_length, 6 max_users
result["server_config"] = {
"max_bandwidth": f.get(1), "welcome_text": f.get(2),
"allow_html": f.get(3), "message_length": f.get(4),
"image_message_length": f.get(5), "max_users": f.get(6),
"max_bandwidth": f.get(1),
"welcome_text": f.get(2),
"allow_html": f.get(3),
"message_length": f.get(4),
"image_message_length": f.get(5),
"max_users": f.get(6),
}
elif msg_type == MSG_SERVERSYNC:
f = _dec_fields(payload)
@ -230,10 +255,8 @@ def handshake(host: str = "127.0.0.1", port: int = PORT, username: str = "cc-ci-
result["error"] = f"{type(e).__name__}: {e}"
finally:
if tls is not None:
try:
with contextlib.suppress(OSError):
tls.shutdown(socket.SHUT_RDWR)
except OSError:
pass
tls.close()
elif raw is not None:
raw.close()

View File

@ -25,7 +25,7 @@ def test_handshake_completes_with_channel_presence(live_app):
assert r["server_version"] is not None, "server did not send a Version message"
assert r["auth_accepted"], f"authentication not accepted — {r.get('error')}"
# Channel presence: the server must expose at least the root channel (beyond a bare TCP open).
assert len(r["channels"]) >= 1, (
f"server reported no channels (expected >=1 root channel) — {r!r}"
)
assert (
len(r["channels"]) >= 1
), f"server reported no channels (expected >=1 root channel) — {r!r}"
assert r["server_sync"], f"ServerSync handshake did not complete — {r.get('error')}"

View File

@ -32,6 +32,7 @@ def test_configured_max_users_surfaces_in_serverconfig(live_app):
)
# allow_html defaults true in the recipe; assert it is present/boolean to prove the field set
# is the real ServerConfig (not an empty/garbled decode).
assert cfg.get("allow_html") in (0, 1), (
f"ServerConfig.allow_html unexpected: {cfg.get('allow_html')!r}"
)
assert cfg.get("allow_html") in (
0,
1,
), f"ServerConfig.allow_html unexpected: {cfg.get('allow_html')!r}"

View File

@ -25,10 +25,10 @@
# WELCOME_TEXT -> MUMBLE_CONFIG_WELCOMETEXT, surfaced in the ServerSync welcome_text.
# USERS -> MUMBLE_CONFIG_USERS (max users), surfaced in the ServerConfig.max_users.
HEALTH_PATH = "/" # mumble-web client UI (present on both 0.2.0 base and 1.0.0 latest)
HEALTH_PATH = "/" # mumble-web client UI (present on both 0.2.0 base and 1.0.0 latest)
HEALTH_OK = (200,)
DEPLOY_TIMEOUT = 900 # two images to pull (mumble-server + mumble-web) on a cold node
DEPLOY_TIMEOUT = 900 # two images to pull (mumble-server + mumble-web) on a cold node
HTTP_TIMEOUT = 300
# A unique, stable welcome-text marker the round-trip test asserts surfaces over the protocol.

View File

@ -23,6 +23,6 @@ def _sqlite(domain, sql):
def test_backup_captures_state(live_app):
assert _sqlite(live_app, "SELECT v FROM ci_marker;") == "original", (
"the seeded mumble sqlite marker was not present at backup time"
)
assert (
_sqlite(live_app, "SELECT v FROM ci_marker;") == "original"
), "the seeded mumble sqlite marker was not present at backup time"

View File

@ -25,6 +25,6 @@ def _sqlite(domain, sql):
def test_restore_returns_state(live_app):
assert _sqlite(live_app, "SELECT v FROM ci_marker;") == "original", (
"restore did not return the pre-mutation mumble sqlite marker (data-integrity failure)"
)
assert (
_sqlite(live_app, "SELECT v FROM ci_marker;") == "original"
), "restore did not return the pre-mutation mumble sqlite marker (data-integrity failure)"

View File

@ -91,6 +91,6 @@ def test_login_endpoint_returns_json(live_app):
assert body is not None, f"/rest/login returned no parseable JSON: state={state}"
# If it's a dict, it's the expected envelope; if it's a list, n8n shouldn't do that on this
# endpoint, but accept either; only reject obvious non-shapes.
assert isinstance(body, (dict, list)), (
f"/rest/login returned unexpected JSON type {type(body).__name__}: {body!r}"
)
assert isinstance(
body, dict | list
), f"/rest/login returned unexpected JSON type {type(body).__name__}: {body!r}"

View File

@ -72,9 +72,9 @@ def test_rest_settings_returns_json_with_known_keys(live_app):
# (e.g. version 3.2.0+2.20.6).
assert isinstance(body, dict), f"/rest/settings returned non-dict JSON: {type(body).__name__}"
data = body.get("data") if "data" in body else body
assert isinstance(data, dict), (
f"/rest/settings response missing 'data' envelope: keys={list(body.keys())[:10]}"
)
assert isinstance(
data, dict
), f"/rest/settings response missing 'data' envelope: keys={list(body.keys())[:10]}"
# Bootstrap keys the editor SPA relies on across versions:
# - `userManagement`: the auth-mode dict (whether owner-setup is needed, smtp/email mode).
# - `defaultLocale`: i18n bootstrap; present on every n8n install.

View File

@ -136,7 +136,9 @@ def _get_workflow(domain: str, cookie_header: str, workflow_id) -> dict:
return json.loads(resp.read())
except urllib.error.HTTPError as e:
body = e.read().decode(errors="replace")
raise AssertionError(f"GET /rest/workflows/{workflow_id} HTTP {e.code}: {body[:200]}") from e
raise AssertionError(
f"GET /rest/workflows/{workflow_id} HTTP {e.code}: {body[:200]}"
) from e
def test_workflow_create_and_read_back(live_app):
@ -172,18 +174,21 @@ def test_workflow_create_and_read_back(live_app):
# Read it back and prove the round-trip
fetched = _get_workflow(domain, cookie, workflow_id)
fpayload = fetched.get("data") if isinstance(fetched.get("data"), dict) else fetched
assert fpayload.get("id") in (workflow_id, str(workflow_id)), (
f"GET workflow id={fpayload.get('id')!r} != created id={workflow_id!r}"
)
assert fpayload.get("name") == name, (
f"workflow name didn't round-trip: created={name!r}, fetched={fpayload.get('name')!r}"
)
assert fpayload.get("id") in (
workflow_id,
str(workflow_id),
), f"GET workflow id={fpayload.get('id')!r} != created id={workflow_id!r}"
assert (
fpayload.get("name") == name
), f"workflow name didn't round-trip: created={name!r}, fetched={fpayload.get('name')!r}"
nodes = fpayload.get("nodes") or []
assert isinstance(nodes, list) and len(nodes) == 1, (
f"workflow nodes didn't round-trip: expected 1 node, got {len(nodes)}"
)
assert (
isinstance(nodes, list) and len(nodes) == 1
), f"workflow nodes didn't round-trip: expected 1 node, got {len(nodes)}"
node = nodes[0]
assert node.get("type") == "n8n-nodes-base.manualTrigger", (
f"node type didn't round-trip: {node.get('type')!r}"
)
assert node.get("name") == "Manual Trigger", f"node name didn't round-trip: {node.get('name')!r}"
assert (
node.get("type") == "n8n-nodes-base.manualTrigger"
), f"node type didn't round-trip: {node.get('type')!r}"
assert (
node.get("name") == "Manual Trigger"
), f"node name didn't round-trip: {node.get('name')!r}"

View File

@ -8,7 +8,8 @@ import os
import sys
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "runner"))
from harness import browser as harness_browser, generic, lifecycle # noqa: E402
from harness import browser as harness_browser # noqa: E402
from harness import generic, lifecycle
def test_serving_and_editor(live_app, meta):
@ -33,9 +34,10 @@ def test_serving_and_editor(live_app, meta):
ctx = browser.new_context(ignore_https_errors=True)
page = ctx.new_page()
resp = harness_browser.goto_with_retry(page, url, accept_statuses=(200, 304))
assert resp is not None and resp.status in (200, 304), (
f"page status {resp and resp.status}"
)
assert resp is not None and resp.status in (
200,
304,
), f"page status {resp and resp.status}"
body = page.content().lower()
assert "n8n" in body or "<html" in body, "no n8n content served"
finally:

View File

@ -88,14 +88,14 @@ def _ingest_and_count(
Returns the events_v2 row count for (pathname, name). Raises if nothing lands within max_wait —
a genuinely-broken ingestion path therefore FAILS (this is not a vacuous check)."""
_register_site(base_domain, site)
count_sql = (
f"SELECT count() FROM events_v2 WHERE pathname = '{pathname}' AND name = '{name}'"
)
count_sql = f"SELECT count() FROM events_v2 WHERE pathname = '{pathname}' AND name = '{name}'"
deadline = time.time() + max_wait
last_status = None
while True:
last_status = _post_event(base_domain, site, name, pathname)
assert last_status == 202, f"POST /api/event for {name!r} → HTTP {last_status} (expected 202)"
assert (
last_status == 202
), f"POST /api/event for {name!r} → HTTP {last_status} (expected 202)"
time.sleep(interval)
raw = _ch(base_domain, count_sql)
count = int(raw) if raw.isdigit() else 0
@ -143,4 +143,6 @@ def test_custom_event_roundtrip(live_app):
live_app,
f"SELECT name FROM events_v2 WHERE pathname = '{pathname}' LIMIT 1",
)
assert stored_name == event_name, f"custom event stored as {stored_name!r}, expected {event_name!r}"
assert (
stored_name == event_name
), f"custom event stored as {stored_name!r}, expected {event_name!r}"

View File

@ -16,7 +16,5 @@ def test_plausible_root_serves(live_app):
reliable health probe; the dedicated /api/health endpoint is.
"""
url = f"https://{live_app}/api/health"
status, _ = harness_http.retry_http_get(
url, expect_status=(200,), max_wait=60, interval=3
)
status, _ = harness_http.retry_http_get(url, expect_status=(200,), max_wait=60, interval=3)
assert status == 200, f"GET {url} HTTP {status}"

View File

@ -34,4 +34,7 @@ def pre_backup(domain, meta):
def pre_restore(domain, meta):
_psql(domain, "DROP TABLE ci_marker;")
assert _psql(domain, "SELECT to_regclass('public.ci_marker');") in ("", "NULL"), "drop did not take"
assert _psql(domain, "SELECT to_regclass('public.ci_marker');") in (
"",
"NULL",
), "drop did not take"

View File

@ -14,4 +14,6 @@ def _psql(domain, sql):
def test_backup_captures_state(live_app):
assert _psql(live_app, "SELECT v FROM ci_marker;") == "original", "seeded postgres state not present at backup time"
assert (
_psql(live_app, "SELECT v FROM ci_marker;") == "original"
), "seeded postgres state not present at backup time"

View File

@ -14,4 +14,6 @@ def _psql(domain, sql):
def test_restore_returns_state(live_app):
assert _psql(live_app, "SELECT v FROM ci_marker;") == "original", "restore did not return the pre-mutation postgres state"
assert (
_psql(live_app, "SELECT v FROM ci_marker;") == "original"
), "restore did not return the pre-mutation postgres state"

View File

@ -14,4 +14,6 @@ def _psql(domain, sql):
def test_upgrade_preserves_data(live_app):
assert _psql(live_app, "SELECT v FROM ci_marker;") == "upgrade-survives", "postgres data did not survive the upgrade"
assert (
_psql(live_app, "SELECT v FROM ci_marker;") == "upgrade-survives"
), "postgres data did not survive the upgrade"

View File

@ -29,9 +29,9 @@ def test_quick_form():
def test_non_trigger_forms_rejected():
for body in (
"!testmexyz", # the Adversary's classic negative
"!testmexyz", # the Adversary's classic negative
"!testme xyz",
"!testme--quick", # no space → not the quick form
"!testme--quick", # no space → not the quick form
"!testme --quick", # double space → not an exact match (conservative)
"please !testme",
"testme",
@ -43,10 +43,11 @@ def test_non_trigger_forms_rejected():
# --- Phase 3 U3: YunoHost-style PR comment builders (R2) -----------------------------------------
def test_start_comment_is_yunohost_shaped():
b = bridge.start_comment_body("uptime-kuma", "dfed87a39f8a", "https://drone.x/cc-ci/42")
assert bridge.COMMENT_MARKER in b # re-!testme updates the same comment
assert "🌻" in b and "" in b # marker + in-progress
assert bridge.COMMENT_MARKER in b # re-!testme updates the same comment
assert "🌻" in b and "" in b # marker + in-progress
assert "uptime-kuma" in b and "dfed87a3" in b
assert "https://drone.x/cc-ci/42" in b
@ -54,30 +55,38 @@ def test_start_comment_is_yunohost_shaped():
def test_result_comment_image_forward_when_card_available(monkeypatch):
monkeypatch.setattr(bridge, "artifact_available", lambda url: True)
monkeypatch.setattr(bridge, "DASH_URL", "https://ci.example")
b = bridge.result_comment_body("uptime-kuma", "dfed87a39f8a", "42", "https://drone.x/cc-ci/42", "success")
b = bridge.result_comment_body(
"uptime-kuma", "dfed87a39f8a", "42", "https://drone.x/cc-ci/42", "success"
)
assert bridge.COMMENT_MARKER in b
assert "" in b and "passed" in b
# the card + badge are embedded as linked images at the stable /runs/<num>/ URLs
assert "![cc-ci result card](https://ci.example/runs/42/summary.png)" in b
assert "https://ci.example/runs/42/badge.svg" in b
assert "(https://drone.x/cc-ci/42)" in b # links to the run
assert "(https://drone.x/cc-ci/42)" in b # links to the run
def test_result_comment_text_fallback_when_card_missing(monkeypatch):
# Render failed / not served → MUST degrade to text, never a broken image (R7).
monkeypatch.setattr(bridge, "artifact_available", lambda url: False)
b = bridge.result_comment_body("hedgedoc", "abc1234def", "9", "https://drone.x/cc-ci/9", "failure")
assert "summary.png" not in b # no image embed
assert "![" not in b # no markdown image at all
b = bridge.result_comment_body(
"hedgedoc", "abc1234def", "9", "https://drone.x/cc-ci/9", "failure"
)
assert "summary.png" not in b # no image embed
assert "![" not in b # no markdown image at all
assert "" in b and "failure" in b
assert "https://drone.x/cc-ci/9" in b
def test_find_existing_comment_matches_marker(monkeypatch):
monkeypatch.setattr(bridge, "list_comments", lambda fn, n: [
{"id": 1, "body": "just a normal comment"},
{"id": 2, "body": bridge.COMMENT_MARKER + "\n🌻 old run"},
])
monkeypatch.setattr(
bridge,
"list_comments",
lambda fn, n: [
{"id": 1, "body": "just a normal comment"},
{"id": 2, "body": bridge.COMMENT_MARKER + "\n🌻 old run"},
],
)
assert bridge.find_existing_comment("org/repo", 5) == 2

View File

@ -48,7 +48,9 @@ def test_is_enrolled_reads_flag(tmp_path, monkeypatch):
def test_registry_roundtrip(tmp_path, monkeypatch):
monkeypatch.setenv("CCCI_WARM_ROOT", str(tmp_path))
assert canonical.read_registry("custom-html") is None
rec = canonical.write_registry("custom-html", version="1.10.0+x", commit="abc123", status="idle")
rec = canonical.write_registry(
"custom-html", version="1.10.0+x", commit="abc123", status="idle"
)
assert rec["domain"] == "warm-custom-html.ci.commoninternet.net"
assert rec["version"] == "1.10.0+x" and rec["commit"] == "abc123" and rec["status"] == "idle"
back = canonical.read_registry("custom-html")
@ -66,9 +68,11 @@ def test_enrolled_recipes_scans_meta(tmp_path, monkeypatch):
fake_harness = tmp_path / "runner" / "harness"
fake_harness.mkdir(parents=True)
monkeypatch.setattr(canonical, "__file__", str(fake_harness / "canonical.py"))
for name, body in (("aaa", "WARM_CANONICAL = True\n"),
("bbb", "DEPS=['x']\n"),
("ccc", "WARM_CANONICAL = True\n")):
for name, body in (
("aaa", "WARM_CANONICAL = True\n"),
("bbb", "DEPS=['x']\n"),
("ccc", "WARM_CANONICAL = True\n"),
):
d = tmp_path / "tests" / name
d.mkdir(parents=True)
(d / "recipe_meta.py").write_text(body)
@ -85,12 +89,13 @@ def test_prune_stale_drops_deenrolled_only(tmp_path, monkeypatch):
# enrolled canonical (keep), de-enrolled canonical (prune), reconciler dir (keep), alerts (keep)
for name in ("keepme", "gone"):
(tmp_path / name).mkdir()
(tmp_path / name / "canonical.json").write_text('{"recipe":"%s"}' % name)
(tmp_path / "keycloak").mkdir(); (tmp_path / "keycloak" / "last_good").write_text("v1") # reconciler
(tmp_path / name / "canonical.json").write_text(f'{{"recipe":"{name}"}}')
(tmp_path / "keycloak").mkdir()
(tmp_path / "keycloak" / "last_good").write_text("v1") # reconciler
(tmp_path / "alerts").mkdir()
pruned = canonical.prune_stale()
assert pruned == ["gone"]
assert not (tmp_path / "gone").exists()
assert (tmp_path / "keepme").exists()
assert (tmp_path / "keycloak").exists() # no canonical.json → not a canonical → kept
assert (tmp_path / "keycloak").exists() # no canonical.json → not a canonical → kept
assert (tmp_path / "alerts").exists()

View File

@ -12,9 +12,8 @@ import os
import sys
import tempfile
_tok = tempfile.NamedTemporaryFile("w", delete=False, suffix=".tok")
_tok.write("test-token")
_tok.close()
with tempfile.NamedTemporaryFile("w", delete=False, suffix=".tok") as _tok:
_tok.write("test-token")
os.environ["DRONE_TOKEN_FILE"] = _tok.name
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "dashboard"))
@ -23,10 +22,17 @@ import dashboard # noqa: E402
def _row(**kw):
base = {
"recipe": "custom-html", "status": "success", "number": 4, "ref": "db9a9502",
"version": "db9a95024e9d", "level": 4, "level_cap_reason": "",
"has_screenshot": True, "flags": {"clean_teardown": True, "no_secret_leak": True},
"finished": 0, "url": "https://drone.x/cc-ci/4",
"recipe": "custom-html",
"status": "success",
"number": 4,
"ref": "db9a9502",
"version": "db9a95024e9d",
"level": 4,
"level_cap_reason": "",
"has_screenshot": True,
"flags": {"clean_teardown": True, "no_secret_leak": True},
"finished": 0,
"url": "https://drone.x/cc-ci/4",
}
base.update(kw)
return base
@ -43,24 +49,33 @@ def test_level_color_ramp_and_fallback():
def test_overview_grid_mirrors_results():
out = dashboard.render_overview([_row()])
assert "custom-html" in out
assert "level 4" in out # the corner level pill
assert dashboard.level_color(4) in out # coloured by level
assert "db9a95024e9d" in out # version from results.json
assert "/runs/4/screenshot.png" in out # thumbnail
assert "/runs/4/summary.png" in out # links to full card
assert "/recipe/custom-html" in out # history link
assert "level 4" in out # the corner level pill
assert dashboard.level_color(4) in out # coloured by level
assert "db9a95024e9d" in out # version from results.json
assert "/runs/4/screenshot.png" in out # thumbnail
assert "/runs/4/summary.png" in out # links to full card
assert "/recipe/custom-html" in out # history link
assert "✔ teardown" in out and "✔ no-leak" in out
def test_overview_never_greener_than_data():
# A failed run at level 0 must show level 0 + the failure pill — never a green/high level.
out = dashboard.render_overview([_row(status="failure", level=0, has_screenshot=False,
flags={}, level_cap_reason="L1 install FAILED")])
out = dashboard.render_overview(
[
_row(
status="failure",
level=0,
has_screenshot=False,
flags={},
level_cap_reason="L1 install FAILED",
)
]
)
assert "level 0" in out
assert dashboard.level_color(0) in out # red
assert dashboard.level_color(0) in out # red
assert dashboard._COLORS["failure"] in out
assert "level 4" not in out and "level 5" not in out and "level 6" not in out
assert "no screenshot" in out # placeholder, no broken image
assert "no screenshot" in out # placeholder, no broken image
def test_level_pill_unknown_when_no_results():
@ -74,7 +89,7 @@ def test_history_table_lists_runs():
assert "#4" in out and "#3" in out
assert "L4" in out and "L2" in out
assert "← all recipes" in out
assert "/runs/4/summary.png" in out # per-run card link
assert "/runs/4/summary.png" in out # per-run card link
def test_history_empty():
@ -83,12 +98,24 @@ def test_history_empty():
def test_build_row_projects_results(monkeypatch):
monkeypatch.setattr(dashboard, "_results_for", lambda n: {
"version": "1.2.3", "level": 2, "level_cap_reason": "cap",
"screenshot": "screenshot.png", "flags": {"clean_teardown": True},
})
b = {"number": 7, "status": "success", "event": "custom",
"params": {"RECIPE": "n8n", "REF": "abcdef1234567890"}, "finished": 10}
monkeypatch.setattr(
dashboard,
"_results_for",
lambda n: {
"version": "1.2.3",
"level": 2,
"level_cap_reason": "cap",
"screenshot": "screenshot.png",
"flags": {"clean_teardown": True},
},
)
b = {
"number": 7,
"status": "success",
"event": "custom",
"params": {"RECIPE": "n8n", "REF": "abcdef1234567890"},
"finished": 10,
}
r = dashboard._build_row(b)
assert r["recipe"] == "n8n" and r["number"] == 7
assert r["level"] == 2 and r["version"] == "1.2.3"
@ -99,11 +126,16 @@ def test_build_row_projects_results(monkeypatch):
def test_build_row_degrades_without_results(monkeypatch):
# No results.json (e.g. an old run): grid still renders from Drone fields, level absent.
monkeypatch.setattr(dashboard, "_results_for", lambda n: {})
b = {"number": 9, "status": "running", "event": "custom",
"params": {"RECIPE": "ghost", "REF": "deadbeefcafe1234567890"}, "finished": 0}
b = {
"number": 9,
"status": "running",
"event": "custom",
"params": {"RECIPE": "ghost", "REF": "deadbeefcafe1234567890"},
"finished": 0,
}
r = dashboard._build_row(b)
assert r["level"] is None and r["has_screenshot"] is False
assert r["version"] == "deadbeefcafe" # ref[:12] fallback
assert r["version"] == "deadbeefcafe" # ref[:12] fallback
# render must not crash or claim a level
assert "level —" in dashboard.render_overview([r])
@ -111,7 +143,7 @@ def test_build_row_degrades_without_results(monkeypatch):
def test_level_badge_shows_level_coloured(monkeypatch):
svg = dashboard.render_level_badge("custom-html", 4)
assert "custom-html" in svg and "level 4" in svg
assert dashboard.level_color(4) in svg # coloured by level
assert dashboard.level_color(4) in svg # coloured by level
assert svg.startswith("<svg") and "image" not in svg # plain SVG
# A higher displayed level than earned would be inflation — badge shows exactly the given level.
assert "level 5" not in svg and "level 6" not in svg
@ -133,8 +165,8 @@ def test_results_for_traversal_guarded():
dashboard.CCCI_RUNS_DIR = d
try:
assert dashboard._results_for("5") == {"level": 3}
assert dashboard._results_for("../../etc") == {} # traversal rejected
assert dashboard._results_for("nonexist") == {} # missing → {}
assert dashboard._results_for("../../etc") == {} # traversal rejected
assert dashboard._results_for("nonexist") == {} # missing → {}
assert dashboard._results_for("") == {}
finally:
dashboard.CCCI_RUNS_DIR = orig

View File

@ -10,7 +10,6 @@ from __future__ import annotations
import os
import sys
import tempfile
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "runner"))
from harness import deps # noqa: E402
@ -30,9 +29,7 @@ def test_declared_deps_reads_DEPS_list(tmp_path, monkeypatch):
# Build a fake repo layout under tmp_path
recipe_dir = tmp_path / "tests" / fake_recipe
recipe_dir.mkdir(parents=True)
(recipe_dir / "recipe_meta.py").write_text(
'HEALTH_PATH = "/"\nDEPS = ["keycloak", "redis"]\n'
)
(recipe_dir / "recipe_meta.py").write_text('HEALTH_PATH = "/"\nDEPS = ["keycloak", "redis"]\n')
# Patch the deps module's idea of "where the repo is" by monkey-patching __file__ for the
# function indirectly: declared_deps uses `os.path.dirname(__file__), "..", "..", "tests"` —
# which resolves to the real repo's `tests/`. So instead, override that with a symlink/dir

View File

@ -20,9 +20,9 @@ import sys
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "runner"))
import run_recipe_ci # noqa: E402
# ---- 1. the pure gate predicate ----
def test_sso_dep_unverified_true_when_declared_notready_and_skipped():
"""declares DEPS + deps not ready + ≥1 requires_deps test skipped → run must FAIL (F2-11)."""
assert run_recipe_ci.sso_dep_unverified(["keycloak"], deps_ready=False, requires_deps_skipped=1)
@ -31,7 +31,9 @@ def test_sso_dep_unverified_true_when_declared_notready_and_skipped():
def test_sso_dep_unverified_false_when_deps_ready():
"""deps ready (setup_custom_tests succeeded) → SSO tests actually ran → not a failure."""
assert not run_recipe_ci.sso_dep_unverified(["keycloak"], deps_ready=True, requires_deps_skipped=0)
assert not run_recipe_ci.sso_dep_unverified(
["keycloak"], deps_ready=True, requires_deps_skipped=0
)
def test_sso_dep_unverified_false_when_no_deps_declared():
@ -43,11 +45,14 @@ def test_sso_dep_unverified_false_when_no_deps_declared():
def test_sso_dep_unverified_false_when_nothing_skipped():
"""Deps declared + not ready but ZERO requires_deps tests skipped → don't false-fail
(the recipe has no SSO-marked tests to have been masked)."""
assert not run_recipe_ci.sso_dep_unverified(["keycloak"], deps_ready=False, requires_deps_skipped=0)
assert not run_recipe_ci.sso_dep_unverified(
["keycloak"], deps_ready=False, requires_deps_skipped=0
)
# ---- 2. conftest skip + record behavior ----
def _load_conftest():
"""Load tests/conftest.py under a private module name (avoid clashing with pytest's own
loaded `conftest`), so we can call pytest_collection_modifyitems directly with fakes."""
@ -95,7 +100,9 @@ def test_conftest_appends_across_invocations(tmp_path, monkeypatch):
monkeypatch.setenv("CCCI_DEPS_SKIP_REPORT", str(report))
conftest.pytest_collection_modifyitems(None, [_FakeItem(["requires_deps"])])
conftest.pytest_collection_modifyitems(None, [_FakeItem(["requires_deps"]), _FakeItem(["requires_deps"])])
conftest.pytest_collection_modifyitems(
None, [_FakeItem(["requires_deps"]), _FakeItem(["requires_deps"])]
)
total = sum(int(x) for x in report.read_text().split())
assert total == 3

View File

@ -32,7 +32,9 @@ def _fake_clock(monkeypatch):
_DRIVE_META = {
"READY_PROBE": lambda d: [{"host": f"collabora-{d}", "path": "/hosting/discovery", "ok": (200,)}]
"READY_PROBE": lambda d: [
{"host": f"collabora-{d}", "path": "/hosting/discovery", "ok": (200,)}
]
}
@ -73,4 +75,6 @@ def test_wait_healthy_raises_when_converged_but_never_serves(monkeypatch):
monkeypatch.setattr(lc, "services_converged", lambda domain: True)
monkeypatch.setattr(lc, "http_get", lambda *a, **k: 502)
with pytest.raises(TimeoutError):
lc.wait_healthy("x.ci.commoninternet.net", ok_codes=(200,), deploy_timeout=60, http_timeout=60)
lc.wait_healthy(
"x.ci.commoninternet.net", ok_codes=(200,), deploy_timeout=60, http_timeout=60
)

View File

@ -126,9 +126,7 @@ def test_http_get_transport_failure_returns_status_zero():
def test_http_get_sends_headers(server):
status, body = harness_http.http_get(
f"{server}/echo-headers", headers={"X-Auth": "token-123"}
)
status, body = harness_http.http_get(f"{server}/echo-headers", headers={"X-Auth": "token-123"})
assert status == 200
assert body["X-Auth"] == "token-123"

View File

@ -275,7 +275,9 @@ def test_build_results_threads_expected_na(tmp_path):
assert data["level_cap_rung"] == "backup_restore"
assert data["rungs"]["functional"] == "pass"
assert data["skips"]["intentional"]["backup_restore"] == "stateless static file server"
assert data["skips"]["unintentional"] == [] # backup_restore declared; functional passed → clean
assert (
data["skips"]["unintentional"] == []
) # backup_restore declared; functional passed → clean
def test_write_results_roundtrip(tmp_path):

View File

@ -15,7 +15,5 @@ from harness import http as harness_http # noqa: E402
def test_uptime_kuma_root_serves(live_app):
"""GET / → 200 or 302 (setup wizard redirect)."""
url = f"https://{live_app}/"
status, _ = harness_http.retry_http_get(
url, expect_status=(200, 302), max_wait=60, interval=3
)
status, _ = harness_http.retry_http_get(url, expect_status=(200, 302), max_wait=60, interval=3)
assert status in (200, 302), f"GET {url} HTTP {status} (expected 200 or 302)"

View File

@ -42,9 +42,9 @@ def test_uptime_kuma_spa_has_branding(live_app):
body = harness_http.assert_converges(_ready, f"GET {url}", max_wait=60, interval=3)
lower = body.lower()
assert "uptime kuma" in lower or "kuma" in lower, (
f"Page body has no 'kuma' brand. Excerpt: {body[:200]!r}"
)
assert (
"uptime kuma" in lower or "kuma" in lower
), f"Page body has no 'kuma' brand. Excerpt: {body[:200]!r}"
# SPA-bundle markers: at least one of these reference paths should be present
bundle_markers = ("/assets/", "/icon.svg", "favicon", "main.")
present = [m for m in bundle_markers if m in body]