feat(2): Q3.3 lasuite-meet — install_steps (OIDC-at-install) + lifecycle overlays + health/OIDC parity tests

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) <noreply@anthropic.com>
This commit is contained in:
2026-05-29 13:22:30 +01:00
parent 32a743f501
commit 31bda3995d
8 changed files with 339 additions and 0 deletions

View File

@ -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.<DOMAIN_SUFFIX>`. 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)"
)

View File

@ -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 "<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 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})"
)

View File

@ -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)"

44
tests/lasuite-meet/ops.py Normal file
View File

@ -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_<op>.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"

View File

@ -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"

View File

@ -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 "<html" in page.content().lower(), "no HTML served by the frontend"
finally:
browser.close()

View File

@ -0,0 +1,22 @@
"""lasuite-meet — RESTORE overlay (Phase 1e HC3): data-integrity, assertion-only + additive.
ops.pre_restore dropped the marker table (diverge); the orchestrator restored once (generic tier
asserted healthy/serving; the recipe's restore reloads the dump). This overlay ADDS: the restored DB
matches the pre-mutation "original"."""
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_restore_returns_state(live_app):
assert (
_psql(live_app, "SELECT v FROM ci_marker;") == "original"
), "restore did not return the pre-mutation postgres state"

View File

@ -0,0 +1,22 @@
"""lasuite-meet — UPGRADE overlay (Phase 1e HC3): data-continuity, assertion-only + additive.
ops.pre_upgrade wrote a postgres marker row before the upgrade; the orchestrator performed the
upgrade once (generic tier asserted reconverge/serving/moved). This overlay ADDS: the postgres data
survived. Read via psql in the `db` service (meet/meet)."""
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_upgrade_preserves_data(live_app):
assert (
_psql(live_app, "SELECT v FROM ci_marker;") == "upgrade-survives"
), "postgres data did not survive the upgrade"