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:
26
tests/lasuite-meet/functional/test_health_check.py
Normal file
26
tests/lasuite-meet/functional/test_health_check.py
Normal 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)"
|
||||
)
|
||||
92
tests/lasuite-meet/functional/test_oidc_with_keycloak.py
Normal file
92
tests/lasuite-meet/functional/test_oidc_with_keycloak.py
Normal 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})"
|
||||
)
|
||||
70
tests/lasuite-meet/install_steps.sh
Executable file
70
tests/lasuite-meet/install_steps.sh
Executable 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
44
tests/lasuite-meet/ops.py
Normal 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"
|
||||
22
tests/lasuite-meet/test_backup.py
Normal file
22
tests/lasuite-meet/test_backup.py
Normal 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"
|
||||
41
tests/lasuite-meet/test_install.py
Normal file
41
tests/lasuite-meet/test_install.py
Normal 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()
|
||||
22
tests/lasuite-meet/test_restore.py
Normal file
22
tests/lasuite-meet/test_restore.py
Normal 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"
|
||||
22
tests/lasuite-meet/test_upgrade.py
Normal file
22
tests/lasuite-meet/test_upgrade.py
Normal 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"
|
||||
Reference in New Issue
Block a user