From 31bda3995da674d7de60d1c50d12f0d558c2f0e4 Mon Sep 17 00:00:00 2001 From: autonomic-bot Date: Fri, 29 May 2026 13:22:30 +0100 Subject: [PATCH] =?UTF-8?q?feat(2):=20Q3.3=20lasuite-meet=20=E2=80=94=20in?= =?UTF-8?q?stall=5Fsteps=20(OIDC-at-install)=20+=20lifecycle=20overlays=20?= =?UTF-8?q?+=20health/OIDC=20parity=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirrors lasuite-drive machinery (sibling La Suite recipe): install_steps.sh wires OIDC at install (client_id from deps, scopes 'openid email'); ops.py + test_{install,upgrade,backup,restore}.py lifecycle overlays (postgres meet/meet ci_marker data-integrity); functional/test_health_check.py (parity) + test_oidc_with_keycloak.py (password-grant JWT vs dep keycloak, realm lasuite-meet-<6hex>). §4.3 meeting_flow + webrtc specifics next (after install+OIDC validated). No setup_custom_tests.sh (no post-deploy step — OIDC at install, no minio/collabora). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../functional/test_health_check.py | 26 ++++++ .../functional/test_oidc_with_keycloak.py | 92 +++++++++++++++++++ tests/lasuite-meet/install_steps.sh | 70 ++++++++++++++ tests/lasuite-meet/ops.py | 44 +++++++++ tests/lasuite-meet/test_backup.py | 22 +++++ tests/lasuite-meet/test_install.py | 41 +++++++++ tests/lasuite-meet/test_restore.py | 22 +++++ tests/lasuite-meet/test_upgrade.py | 22 +++++ 8 files changed, 339 insertions(+) create mode 100644 tests/lasuite-meet/functional/test_health_check.py create mode 100644 tests/lasuite-meet/functional/test_oidc_with_keycloak.py create mode 100755 tests/lasuite-meet/install_steps.sh create mode 100644 tests/lasuite-meet/ops.py create mode 100644 tests/lasuite-meet/test_backup.py create mode 100644 tests/lasuite-meet/test_install.py create mode 100644 tests/lasuite-meet/test_restore.py create mode 100644 tests/lasuite-meet/test_upgrade.py diff --git a/tests/lasuite-meet/functional/test_health_check.py b/tests/lasuite-meet/functional/test_health_check.py new file mode 100644 index 0000000..b854916 --- /dev/null +++ b/tests/lasuite-meet/functional/test_health_check.py @@ -0,0 +1,26 @@ +"""lasuite-meet — parity port of recipe-maintainer's health_check.py (Phase 2 P2). + +SOURCE: references/recipe-maintainer/recipe-info/lasuite-meet/tests/health_check.py + +The original asserted HTTP 200 from `https://lasuite-meet.`. The cc-ci port preserves +the assertion shape — non-error HTTP from the served root — adapted to the ephemeral per-run domain +via the `live_app` fixture. Runs in the custom tier against the shared post-install deployment.""" + +from __future__ import annotations + +import os +import sys + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "..", "runner")) +from harness import http as harness_http # noqa: E402 + + +def test_lasuite_meet_returns_200(live_app): + """Parity with recipe-info/lasuite-meet/tests/health_check.py: HTTP 200 from `/`.""" + 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"lasuite-meet at {url} returned HTTP {status} (expected 200/301/302)" + ) diff --git a/tests/lasuite-meet/functional/test_oidc_with_keycloak.py b/tests/lasuite-meet/functional/test_oidc_with_keycloak.py new file mode 100644 index 0000000..ef59d54 --- /dev/null +++ b/tests/lasuite-meet/functional/test_oidc_with_keycloak.py @@ -0,0 +1,92 @@ +"""lasuite-meet — Q3.3 SSO-flow test (operator-2026-05-28 SSO-dep plan). + +Meet (La Suite Meet) is OIDC-required: login is gated by an external OpenID Connect provider. +Mirrors the proven lasuite-docs SSO model: +- The orchestrator deploys a per-run keycloak dep AFTER the generic tiers and provisions a fresh + realm/client/user via `harness.sso.setup_keycloak_realm`; `setup_custom_tests.sh` then wires the + OIDC env + client secret into the running drive app and redeploys. Creds land in `$CCCI_DEPS_FILE` + (read here via the `deps_creds` fixture). +- This test consumes those creds and exercises the real OIDC flow against the dep keycloak: discovery + endpoint advertises the realm, and a password grant yields a valid JWT with the expected claims. +- Marked `@pytest.mark.requires_deps` so if setup_custom_tests failed the test SKIPs with a clear + `deps-not-ready` reason — and (per F2-11) the orchestrator then fails the run rather than going + green on a skipped SSO test. + +SOURCE: adapted from tests/lasuite-docs/functional/test_oidc_with_keycloak.py (Q2.4 acceptance). +""" + +from __future__ import annotations + +import base64 +import json +import os +import re +import sys +import time + +import pytest + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "..", "runner")) +from harness import sso # noqa: E402 + + +def _b64url_decode(seg: str) -> bytes: + pad = "=" * ((4 - len(seg) % 4) % 4) + return base64.urlsafe_b64decode(seg + pad) + + +@pytest.mark.requires_deps +def test_oidc_password_grant_against_dep_keycloak(live_app, deps_creds): + """The dep keycloak issues a JWT for the pre-provisioned test user via OIDC password grant.""" + assert "keycloak" in deps_creds, ( + f"keycloak creds not in deps_creds; got {list(deps_creds.keys())}. " + "setup_custom_tests should have populated this." + ) + kc = deps_creds["keycloak"] + + # Creds shape. WC1: realm is per-run namespaced "-<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 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 + + creds = { + "provider": "keycloak", + "provider_domain": kc["domain"], + "realm": kc["realm"], + "client_id": kc["client_id"], + "client_secret": kc["client_secret"], + "user": kc["user"], + "password": kc["password"], + "email": kc["email"], + "discovery_url": kc["discovery_url"], + "token_url": kc["token_url"], + "auth_url": kc["auth_url"], + "userinfo_url": kc["userinfo_url"], + } + + # OIDC discovery endpoint advertises the realm + discovery = sso.assert_discovery_endpoint(creds) + expected_iss = f"https://{kc['domain']}/realms/{kc['realm']}" + assert discovery.get("issuer") == expected_iss + assert discovery.get("token_endpoint", "").startswith(expected_iss + "/") + assert discovery.get("authorization_endpoint", "").startswith(expected_iss + "/") + + # 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}" + ) + 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("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})" + ) diff --git a/tests/lasuite-meet/install_steps.sh b/tests/lasuite-meet/install_steps.sh new file mode 100755 index 0000000..67d6d45 --- /dev/null +++ b/tests/lasuite-meet/install_steps.sh @@ -0,0 +1,70 @@ +#!/usr/bin/env bash +# lasuite-meet — INSTALL-TIME OIDC wiring hook (Phase 2 Q3.3; sibling of lasuite-drive's). +# +# Runs during the install tier AFTER `abra app new` + EXTRA_ENV + `abra app secret generate`, and +# BEFORE the single `abra app deploy` (lifecycle.py::_run_install_steps). Writing OIDC env + the real +# client secret HERE means the recipe deploys ONCE with OIDC already wired — no post-deploy reconverge +# (OIDC_AT_INSTALL). The orchestrator provisions the per-run realm/client on the live-warm keycloak +# BEFORE this hook and writes $CCCI_DEPS_FILE (the recipe→creds dict). +# +# Meet's OIDC is REQUIRED (recipe README). Same La Suite/impress env contract as drive, with meet's +# client (OIDC_RP_CLIENT_ID = the per-run keycloak client = "lasuite-meet") and scopes "openid email". +# +# Env supplied by the harness: +# CCCI_APP_DOMAIN — the per-run lasuite-meet app domain +# CCCI_APP_ENV — path to the app's .env (the one `abra app deploy` reads) +# CCCI_DEPS_FILE — JSON {keycloak: {domain, realm, client_id, client_secret, ...}} (may be empty) +set -euo pipefail + +: "${CCCI_APP_DOMAIN:?missing}" +ENV_PATH="${CCCI_APP_ENV:?missing}" + +# No deps file / no keycloak entry → install-time provisioning failed or was skipped. NO-OP so the +# recipe still boots; the @requires_deps OIDC custom test then SKIPs and F2-11 flips the run RED. +if [ -z "${CCCI_DEPS_FILE:-}" ] || [ ! -s "${CCCI_DEPS_FILE}" ]; then + echo " install_steps: no deps file — skipping OIDC wiring (recipe boots without OIDC)" + exit 0 +fi +KC_DOMAIN=$(jq -r '.keycloak.domain // 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 + echo " install_steps: deps file has no keycloak domain/secret — skipping OIDC wiring" + exit 0 +fi + +echo " lasuite-meet install_steps: wiring OIDC at install against keycloak ${KC_DOMAIN}" + +# 1) Insert the OIDC client secret at a bumped version (abra already generated oidc_rpcs:v1; swarm +# 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_VER="v${NEW_NUM}" +INSERT_LOG=$(abra app secret insert "$CCCI_APP_DOMAIN" oidc_rpcs "$NEW_VER" "$KC_SECRET" --no-input 2>&1) \ + || INSERT_LOG=$(script -qec "abra app secret insert $CCCI_APP_DOMAIN oidc_rpcs $NEW_VER $KC_SECRET --no-input" /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 () { + 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" +} +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)" diff --git a/tests/lasuite-meet/ops.py b/tests/lasuite-meet/ops.py new file mode 100644 index 0000000..d5e5627 --- /dev/null +++ b/tests/lasuite-meet/ops.py @@ -0,0 +1,44 @@ +"""lasuite-meet — pre-op seed hooks (Phase 1e HC3). Sibling of tests/lasuite-drive/ops.py. + +The orchestrator runs these BEFORE the op; the matching test_.py asserts post-op. The marker is +a dedicated `ci_marker` row in postgres (independent of the app's Django migrations — CREATE TABLE IF +NOT EXISTS), written via psql in the `db` service (POSTGRES_DB=meet, POSTGRES_USER=meet, password at +/run/secrets/postgres_p). The backup path exercises the recipe's pg_backup.sh DB-dump hook (postgres +is backupbot-labelled).""" + +import os +import sys + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "runner")) +from harness import lifecycle # noqa: E402 + + +def _psql(domain, sql): + cmd = f'PGPASSWORD=$(cat /run/secrets/postgres_p) psql -U meet -d meet -tAc "{sql}"' + return lifecycle.exec_in_app(domain, ["sh", "-c", cmd], service="db").strip() + + +def _seed(domain, value): + _psql( + domain, + "CREATE TABLE IF NOT EXISTS ci_marker(v text); DELETE FROM ci_marker; " + f"INSERT INTO ci_marker VALUES('{value}');", + ) + assert _psql(domain, "SELECT v FROM ci_marker;") == value + + +def pre_upgrade(domain, meta): + _seed(domain, "upgrade-survives") + + +def pre_backup(domain, meta): + _seed(domain, "original") + + +def pre_restore(domain, meta): + # drop the marker table (diverge from the backup) so a successful restore is observable + _psql(domain, "DROP TABLE ci_marker;") + assert _psql(domain, "SELECT to_regclass('public.ci_marker');") in ( + "", + "NULL", + ), "drop did not take" diff --git a/tests/lasuite-meet/test_backup.py b/tests/lasuite-meet/test_backup.py new file mode 100644 index 0000000..cf480fb --- /dev/null +++ b/tests/lasuite-meet/test_backup.py @@ -0,0 +1,22 @@ +"""lasuite-meet — BACKUP overlay (Phase 1e HC3): assertion-only + additive. + +ops.pre_backup wrote "original" into postgres before the backup op (pg_backup.sh dumps the DB); the +orchestrator performed the backup once (generic tier asserted a snapshot artifact). This overlay +ADDS: the seeded row is intact at backup time.""" + +import os +import sys + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "runner")) +from harness import lifecycle # noqa: E402 + + +def _psql(domain, sql): + cmd = f'PGPASSWORD=$(cat /run/secrets/postgres_p) psql -U meet -d meet -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 postgres state was not present at backup time" diff --git a/tests/lasuite-meet/test_install.py b/tests/lasuite-meet/test_install.py new file mode 100644 index 0000000..d2e4d40 --- /dev/null +++ b/tests/lasuite-meet/test_install.py @@ -0,0 +1,41 @@ +"""lasuite-meet — INSTALL overlay (Phase 1d, DG4): override + extend-by-composition. + +Reuses the generic "really serving" assertion, then ADDS recipe-specific checks: the stack serves +over real HTTPS through the gateway, and a real browser loads the live Meet frontend (the SPA shell). +Login is OIDC-gated (the SSO flow is exercised by the functional tests), so the install assertion is +that the frontend SPA is served (unauthenticated landing), not an authenticated flow. Assertion-only +on the shared deployment.""" + +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 + + +def test_serving_and_frontend(live_app, meta): + # extend-by-composition: reuse the generic "really serving" assertion first ... + generic.assert_serving(live_app, meta) + + # ... then the recipe-specific assertions. + status = lifecycle.http_get(live_app, "/") + assert status in (200, 301, 302), f"expected 2xx/3xx from {live_app}, got {status}" + + # A real browser loads the live Meet frontend (the SPA shell) over HTTPS. + from playwright.sync_api import sync_playwright + + url = f"https://{live_app}/" + with sync_playwright() as p: + browser = p.chromium.launch(args=["--no-sandbox"]) + try: + ctx = browser.new_context(ignore_https_errors=True) + page = ctx.new_page() + 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 "