feat(2): Q3.2 lasuite-drive SSO iteration — keycloak dep + OIDC test + MinIO storage round-trip

- recipe_meta: DEPS=[keycloak] enabled (base proven cold-green).
- setup_custom_tests.sh: wire OIDC env (explicit keycloak realm endpoints) + insert oidc_rpcs
  secret at bumped version + clear FranceConnect eidas1 acr + in-place redeploy (adapted from
  the proven lasuite-docs hook).
- functional/test_oidc_with_keycloak.py: SSO discovery + password grant + JWT claims vs dep
  keycloak realm 'lasuite-drive' (@requires_deps; F2-11 fails run on skip).
- functional/test_minio_storage.py: §4.3 specific — drive-media-storage bucket present + real
  upload->list->download round-trip via mc inside the minio container.
- PARITY.md: OIDC + MinIO rows landed; backup data-integrity (ci_marker) already real.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-28 22:28:35 +01:00
parent 5f1ce47593
commit 6557197858
5 changed files with 244 additions and 15 deletions

View File

@ -3,27 +3,28 @@
Phase-2 P2 mapping table. The Adversary cold-verifies parity by reading the source
`recipe-info/lasuite-drive/tests/<file>` 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-<domain>` 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 1590s 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-<domain>` 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-<domain>` route — Drive's in-browser office-editing feature. | **planned** |
## Backup data-integrity (P4) — landed

View File

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

View File

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

View File

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

View File

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