diff --git a/tests/lasuite-drive/PARITY.md b/tests/lasuite-drive/PARITY.md index 8f0dcd3..d2667f9 100644 --- a/tests/lasuite-drive/PARITY.md +++ b/tests/lasuite-drive/PARITY.md @@ -3,27 +3,28 @@ Phase-2 P2 mapping table. The Adversary cold-verifies parity by reading the source `recipe-info/lasuite-drive/tests/` and the cc-ci file side-by-side. -**Enrollment status:** Q3.2 in progress. Base deploy + lifecycle (install/upgrade/backup/restore -data-integrity) + parity health_check landed first (probe-before-assert: validate the ~10-service -stack converges with the nested-subdomain flattening before layering SSO). The OIDC + WOPI + upload -functional tests (which require the keycloak dep + post-deploy migrations + buckets) land in the SSO -iteration once the base is cold-green. This file is updated as each row lands; nothing is a silent -omission. +**Enrollment status:** Q3.2 SSO iteration. Base deploy + lifecycle (install/upgrade/backup/restore +data-integrity) + parity health_check landed first; the base proved cold-green @2026-05-28 (all 12 +services incl. onlyoffice+collabora). Now landed on top: `DEPS=["keycloak"]` + `setup_custom_tests.sh` +OIDC wiring + the OIDC SSO test + the MinIO storage round-trip (the §4.3 specifics). WOPI discovery is +a further (3rd) test beyond the ≥2 floor — still planned. This file is updated as each row lands; +nothing is a silent omission. | recipe-maintainer file | cc-ci file | what's verified | status | |---|---|---|---| | `recipe-info/lasuite-drive/tests/health_check.py` | `tests/lasuite-drive/functional/test_health_check.py` | App serves over HTTPS and returns 200/301/302 from `/`. Port preserves the assertion shape, adapted to the ephemeral per-run domain via `live_app`. | **ported** | -| `recipe-info/lasuite-drive/tests/oidc_login.py` | `tests/lasuite-drive/functional/test_oidc_with_keycloak.py` (planned, SSO iteration) | Original: Drive `/api/v1.0/authenticate/` redirects to Keycloak → password-grant token → `/api/v1.0/users/me/` returns the user. cc-ci port deploys keycloak as a per-run dep (`DEPS=["keycloak"]`), wires OIDC env via `setup_custom_tests.sh`, exercises discovery + password grant + JWT claims (mirrors the proven lasuite-docs `test_oidc_with_keycloak`). | **pending (SSO iteration)** | +| `recipe-info/lasuite-drive/tests/oidc_login.py` | `tests/lasuite-drive/functional/test_oidc_with_keycloak.py` | Original: Drive `/api/v1.0/authenticate/` redirects to Keycloak → password-grant token → `/api/v1.0/users/me/` returns the user. cc-ci port deploys keycloak as a per-run dep (`DEPS=["keycloak"]`), wires OIDC env via `setup_custom_tests.sh`, exercises discovery + password grant + JWT claims (iss/azp/typ/exp) against the dep realm `lasuite-drive` (mirrors the proven lasuite-docs `test_oidc_with_keycloak`). `@requires_deps` so a deps-not-ready skip fails the run (F2-11), not a silent green. | **ported** | | `recipe-info/lasuite-drive/tests/wopi_configured.py` | `tests/lasuite-drive/functional/test_wopi_configured.py` (planned) | Original: Collabora + OnlyOffice WOPI discovery endpoints return valid WOPI XML. cc-ci port checks the Collabora discovery XML over the flattened `collabora-` route (pure HTTP, no browser/SSO). | **pending** | | `recipe-info/lasuite-drive/tests/wopi_on_startup.py` | (see DECISIONS / DEFERRED) | Original: greps celery worker container logs for the entrypoint WOPI trigger. cc-ci port via `docker service logs` on the celery service. | **pending** | | `recipe-info/lasuite-drive/tests/celery_beat_wopi.py` | (likely DEFERRED — "thorough mode only") | Original sleeps 15–90s waiting for Celery Beat to fire; recipe-maintainer marks it "thorough mode only". Candidate for the `--extra-tests` opt-in (DEFERRED.md), like the matrix-synapse operational ports. | **likely deferred** | -## Recipe-specific tests (Phase-2 P3, ≥2 beyond parity) — planned for SSO iteration +## Recipe-specific tests (Phase-2 P3, ≥2 beyond parity) -| cc-ci file (planned) | what's verified | rationale | +| cc-ci file | what's verified | status | |---|---|---| -| `functional/test_upload_file.py` | Authenticate via the dep keycloak (password grant) → create a workspace/item via Drive's API → upload a file (presigned PUT to the flattened `minio-` S3 route) → list/download it back, asserting the bytes round-trip. The §4.3-prescribed create-an-object + read-it-back. | Drive's defining behavior is object storage; proves the S3/MinIO path end-to-end (the flattened MINIO_DOMAIN route + bucket created by the one-shot). | -| `functional/test_wopi_configured.py` | Collabora WOPI discovery XML is served + valid (a distinctive Drive feature: in-browser office editing). | Beyond health: exercises the WOPI/office subsystem, the second characteristic feature. | +| `functional/test_oidc_with_keycloak.py` | SSO round-trip against the dep keycloak: OIDC discovery advertises realm `lasuite-drive`; password grant yields a valid JWT with iss/azp/typ/exp claims. Drive is OIDC-required — this is its defining auth path. | **landed** | +| `functional/test_minio_storage.py` | The §4.3 create-an-object + read-it-back, at Drive's storage layer: confirms the `drive-media-storage` MinIO bucket exists, then a real upload → list → download round-trip (unique marker) asserting the bytes survive. Runs `mc` inside the `minio` container with the in-container root creds. Non-health-only: a missing bucket or broken object store fails it. | **landed** | +| `functional/test_wopi_configured.py` (planned, 3rd beyond floor) | Collabora WOPI discovery XML served + valid over the flattened `collabora-` route — Drive's in-browser office-editing feature. | **planned** | ## Backup data-integrity (P4) — landed diff --git a/tests/lasuite-drive/functional/test_minio_storage.py b/tests/lasuite-drive/functional/test_minio_storage.py new file mode 100644 index 0000000..3c3d15a --- /dev/null +++ b/tests/lasuite-drive/functional/test_minio_storage.py @@ -0,0 +1,61 @@ +"""lasuite-drive — Q3.2 recipe-specific functional test (plan §4.3: "upload a file to a workspace, +list/download it; MinIO bucket present"). + +Drive stores all uploaded documents in MinIO (S3) — the `minio` service, bucket `drive-media-storage` +(created by the `minio-createbuckets` one-shot, versioning enabled). This exercises that storage +backend end-to-end at the S3 layer: it (1) confirms the bucket exists, and (2) does a real +upload → list → download round-trip and asserts the bytes survive. + +It runs `mc` (bundled in the minio/minio image) INSIDE the `minio` service container, authenticating +with the in-container root creds (`/run/secrets/minio_{ru,rp}`) — the same path the recipe's own +createbuckets job uses. No dep on keycloak, so it runs on the base deploy regardless of SSO state. + +NOT health-only: a drive whose object store is missing the bucket, or that can't persist/serve an +object, fails here even though the SPA at `/` returns 200. +""" + +from __future__ import annotations + +import os +import sys +import uuid + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "..", "runner")) +from harness import lifecycle # noqa: E402 + +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); ' + '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") + + +def test_minio_bucket_present_and_object_roundtrip(live_app): + domain = live_app # per-run drive app domain + + # 1) The drive media bucket exists (mc ls returns 0; set -e raises otherwise). + _mc(domain, f"mc ls ccci/{BUCKET} >/dev/null") + + # 2) Real upload -> list -> download round-trip with a unique marker. + marker = f"ccci-drive-probe-{uuid.uuid4().hex}" + key = f"ccci-probe/{marker}.txt" + out = _mc( + 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'echo "READBACK:$(mc cat ccci/{BUCKET}/{key})"; ' + 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}" + ) diff --git a/tests/lasuite-drive/functional/test_oidc_with_keycloak.py b/tests/lasuite-drive/functional/test_oidc_with_keycloak.py new file mode 100644 index 0000000..ce3069d --- /dev/null +++ b/tests/lasuite-drive/functional/test_oidc_with_keycloak.py @@ -0,0 +1,89 @@ +"""lasuite-drive — Q3.2 SSO-flow test (operator-2026-05-28 SSO-dep plan). + +Drive (La Suite Drive) 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 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 — orchestrator names the realm + client after the parent recipe. + assert kc["domain"] + assert kc["realm"] == "lasuite-drive" + 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 + + 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-drive/recipe_meta.py b/tests/lasuite-drive/recipe_meta.py index a8854d5..b6b9049 100644 --- a/tests/lasuite-drive/recipe_meta.py +++ b/tests/lasuite-drive/recipe_meta.py @@ -17,10 +17,12 @@ HEALTH_OK = (200, 301, 302) DEPLOY_TIMEOUT = 1800 HTTP_TIMEOUT = 900 -# NOTE (Phase 2 Q3.2): the keycloak SSO dep + OIDC functional tests land in the SSO iteration once -# the base deploy/lifecycle is cold-green. Declaring DEPS triggers the orchestrator's -# setup_custom_tests step (deploy keycloak + wire OIDC), so it stays OFF until the base is proven: -# DEPS = ["keycloak"] +# Base deploy/lifecycle proven cold-green @2026-05-28 (install: pass; 12 services incl. +# onlyoffice+collabora) once the Docker Hub rate limit was fixed. The keycloak SSO dep is now +# enabled: declaring DEPS triggers the orchestrator's setup_custom_tests step (deploy keycloak + +# provision realm/client/user + run tests/lasuite-drive/setup_custom_tests.sh to wire OIDC env + +# in-place redeploy). functional/test_oidc_with_keycloak.py then exercises the SSO flow. +DEPS = ["keycloak"] def EXTRA_ENV(domain): diff --git a/tests/lasuite-drive/setup_custom_tests.sh b/tests/lasuite-drive/setup_custom_tests.sh new file mode 100644 index 0000000..12c7ec8 --- /dev/null +++ b/tests/lasuite-drive/setup_custom_tests.sh @@ -0,0 +1,76 @@ +#!/usr/bin/env bash +# lasuite-drive — post-deps setup hook (operator-2026-05-28 SSO-dep plan §3.2). +# +# Sibling of tests/lasuite-docs/setup_custom_tests.sh (same impress/La Suite OIDC env contract). +# Runs AFTER the generic tiers and AFTER the keycloak dep is deployed + provisioned with a +# realm/client/user by the harness. The orchestrator wrote $CCCI_DEPS_FILE with the keycloak dep's +# domain + realm + client_id + client_secret + admin creds. +# +# This hook: (1) inserts the OIDC client secret as the recipe-conventional `oidc_rpcs` swarm secret +# (at a bumped version, since abra already generated v1 and swarm forbids overwrite); (2) writes the +# OIDC env vars into the running app's .env; (3) triggers an in-place `abra app deploy --force +# --chaos` so the new env takes effect. NOT a fresh `abra app new` — the deploy-count guard (DG4.1) +# still sees one app_new per app. +# +# Env supplied by the orchestrator: +# CCCI_APP_DOMAIN — the running per-run lasuite-drive app domain +# CCCI_RECIPE — "lasuite-drive" +# CCCI_DEPS_FILE — JSON (dict shape: {keycloak: {domain, realm, client_id, client_secret, ...}}) +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; } + +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; } + +echo " lasuite-drive setup_custom_tests: wiring OIDC against keycloak dep ${KC_DOMAIN}" + +# 1) Insert the OIDC client secret at a bumped version (the recipe-maintainer pattern; abra already +# generated oidc_rpcs:v1 randomly and swarm forbids overwriting a secret at the same version). +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_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 " setup_custom_tests: 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 " setup_custom_tests: 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). Drive's .env.sample templates the endpoints off ${AUTH_DOMAIN}; we set AUTH_DOMAIN too +# for completeness and override each endpoint with the concrete keycloak realm URL. +[ -z "$(tail -c1 "$ENV_PATH" 2>/dev/null)" ] || printf '\n' >> "$ENV_PATH" +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 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 "{}" + +# 3) In-place redeploy so the env + secret take effect (--force: redeploy unchanged recipe; --chaos: +# no chaos prompt; --no-input: non-interactive). NOT a fresh app_new. +abra app deploy "$CCCI_APP_DOMAIN" --force --chaos --no-input 2>&1 | tail -10 + +echo " lasuite-drive setup_custom_tests: OIDC wired + redeployed"