Compare commits

..
Author SHA1 Message Date
autonomic-bot a1a6790c9b test(lasuite-docs): update stale OIDC tests for impress v5.4.0 Bearer-auth removal
continuous-integration/drone/push Build was killed
test_oidc_login_via_keycloak and test_create_doc_and_read_back authenticated with
'Authorization: Bearer <password-grant JWT>'; impress v5.4.0 removed Bearer/JWT
auth on the API (SessionAuthentication only), so both went RED with 401 on the
v5.4.1 upgrade.

Updated to the successor auth path: a new recipe-local _oidc_session.py drives the
real OIDC authorization-code flow (app -> keycloak login form -> callback ->
Django session cookie, with CSRF headers on unsafe methods).
- test_oidc_login: still asserts the unauth challenge redirect; NOW also asserts a
  raw Bearer JWT is REJECTED (401/403 - the v5.4.0 hardening, asserted as the new
  correct behavior); then asserts the session-authenticated whoami returns the
  provisioned user. No assertion weakened - the auth proof is stronger than before.
- test_create_doc: same create+read-back round-trip assertions, now over the
  session-authenticated API.

Stale-test fix for recipe PR
recipe-maintainers/lasuite-docs#7
(carry-over from /upgrade-all 2026-07-24).
2026-08-03 20:43:07 +00:00
4 changed files with 82 additions and 98 deletions
@@ -1,19 +1,17 @@
"""Recipe-local OIDC *session* login helper (authorization-code flow + session cookie). """Recipe-local OIDC *session* login helper (authorization-code flow + session cookie).
meet v1.22.0 hardened API auth — raw OIDC user access tokens are rejected as Bearer impress v5.4.0 removed Bearer-token (JWT) authentication on the API — the app now accepts only
credentials; the app accepts only its own session cookie, established through the standard OIDC its own session cookie, established through the standard OIDC authorization-code browser flow
authorization-code browser flow (app login URL → keycloak login form → callback → Django (app login URL → keycloak login form → callback → Django session). This helper drives that flow
session). This helper drives that flow with urllib + a CookieJar so the custom tests can with urllib + a CookieJar so the custom tests can exercise the API the way a real client does.
exercise the API the way a real client does.
Kept recipe-local (cf. tests/ghost/custom/_ghost.py precedent; same helper as Kept recipe-local (cf. tests/ghost/custom/_ghost.py precedent) rather than in runner/harness —
tests/lasuite-docs/custom/_oidc_session.py) rather than in runner/harness — promote it there promote it there if a third recipe needs it.
if a third recipe needs it.
Usage: Usage:
sess = OidcSession(f"https://{live_app}") sess = OidcSession(f"https://{live_app}")
me = sess.login(kc["user"], kc["password"]) # asserts whoami 200; returns the user dict me = sess.login(kc["user"], kc["password"]) # asserts whoami 200; returns the user dict
status, body = sess.post("/api/v1.0/rooms/", {"name": "x"}) # CSRF handled status, body = sess.post("/api/v1.0/documents/", {"title": "x"}) # CSRF handled
""" """
from __future__ import annotations from __future__ import annotations
+19 -32
View File
@@ -3,12 +3,13 @@
Plan §4.3 explicitly names this test for lasuite-docs: "create a doc, edit via the API, confirm Plan §4.3 explicitly names this test for lasuite-docs: "create a doc, edit via the API, confirm
persistence". This is the canonical create-an-object + read-it-back for lasuite-docs. persistence". This is the canonical create-an-object + read-it-back for lasuite-docs.
Flow (uses an OIDC token from the dep keycloak): Flow (updated for impress v5.4.0, which removed Bearer/JWT auth on the API — the doc CRUD now
1. Obtain a JWT via OIDC password grant against the dep keycloak (the test user is provisioned runs on the app's session cookie from the real OIDC authorization-code login):
by the orchestrator's dep-provisioning step). 1. Log in via the OIDC authorization-code flow against the dep keycloak (the test user is
2. POST `/api/v1.0/documents/` with `Authorization: Bearer <jwt>` to create a new doc with a provisioned by the orchestrator's dep-provisioning step) → session cookie.
2. POST `/api/v1.0/documents/` with the session (+ CSRF header) to create a new doc with a
unique title; capture the returned `id`. unique title; capture the returned `id`.
3. GET `/api/v1.0/documents/<id>/` with the same Bearer token; assert the returned title and 3. GET `/api/v1.0/documents/<id>/` with the same session; assert the returned title and
id match. id match.
Non-vacuous: a misconfigured OIDC, broken backend, or missing endpoint fails at the layer it's Non-vacuous: a misconfigured OIDC, broken backend, or missing endpoint fails at the layer it's
@@ -26,9 +27,9 @@ import uuid
import pytest import pytest
sys.path.insert(0, os.path.dirname(__file__))
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "..", "runner")) sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "..", "runner"))
from harness import http as harness_http # noqa: E402 from _oidc_session import OidcSession # noqa: E402 (recipe-local helper, same dir)
from harness import sso
@pytest.mark.requires_deps @pytest.mark.requires_deps
@@ -36,43 +37,29 @@ def test_create_doc_and_read_back(live_app, deps):
"""Create a doc via the authenticated API; fetch it back; assert round-trip.""" """Create a doc via the authenticated API; fetch it back; assert round-trip."""
kc = deps["keycloak"] kc = deps["keycloak"]
# Obtain a JWT via OIDC password grant # Session login via the OIDC authorization-code flow (impress v5.4.0+ rejects Bearer JWTs)
access_token = sso.oidc_password_grant( sess = OidcSession(f"https://{live_app}")
{ sess.login(kc["user"], kc["password"])
"client_id": kc["client_id"],
"client_secret": kc["client_secret"],
"user": kc["user"],
"password": kc["password"],
"token_url": kc["token_url"],
}
)
auth = {"Authorization": f"Bearer {access_token}"}
# Create a doc with a unique title # Create a doc with a unique title
title = f"ccci-doc-{uuid.uuid4().hex[:8]}" title = f"ccci-doc-{uuid.uuid4().hex[:8]}"
s, body = harness_http.http_post( s, body = sess.post("/api/v1.0/documents/", {"title": title})
f"https://{live_app}/api/v1.0/documents/",
data={"title": title},
headers=auth,
)
assert s in (200, 201), f"POST /api/v1.0/documents/ HTTP {s}: {body!r}" assert s in (200, 201), f"POST /api/v1.0/documents/ HTTP {s}: {body!r}"
assert isinstance(body, dict), f"unexpected response shape: {body!r}" assert isinstance(body, dict), f"unexpected response shape: {body!r}"
doc_id = body.get("id") doc_id = body.get("id")
assert doc_id, f"created doc has no id: {body!r}" assert doc_id, f"created doc has no id: {body!r}"
assert ( assert body.get("title") == title, (
body.get("title") == title f"created doc title mismatch: created={title!r}, response={body.get('title')!r}"
), f"created doc title mismatch: created={title!r}, response={body.get('title')!r}" )
# Fetch it back via the dedicated GET endpoint # Fetch it back via the dedicated GET endpoint
s, fetched = harness_http.http_get( s, fetched = sess.get(f"/api/v1.0/documents/{doc_id}/")
f"https://{live_app}/api/v1.0/documents/{doc_id}/", headers=auth
)
assert s == 200, f"GET /api/v1.0/documents/{doc_id}/ HTTP {s}: {fetched!r}" assert s == 200, f"GET /api/v1.0/documents/{doc_id}/ HTTP {s}: {fetched!r}"
assert isinstance(fetched, dict), f"unexpected GET response: {fetched!r}" assert isinstance(fetched, dict), f"unexpected GET response: {fetched!r}"
assert fetched.get("id") in ( assert fetched.get("id") in (
doc_id, doc_id,
str(doc_id), str(doc_id),
), f"fetched id mismatch: created={doc_id!r}, fetched={fetched.get('id')!r}" ), f"fetched id mismatch: created={doc_id!r}, fetched={fetched.get('id')!r}"
assert ( assert fetched.get("title") == title, (
fetched.get("title") == title f"fetched title mismatch: created={title!r}, fetched={fetched.get('title')!r}"
), f"fetched title mismatch: created={title!r}, fetched={fetched.get('title')!r}" )
+28 -16
View File
@@ -2,13 +2,16 @@
SOURCE: references/recipe-maintainer/recipe-info/lasuite-docs/tests/oidc_login.py SOURCE: references/recipe-maintainer/recipe-info/lasuite-docs/tests/oidc_login.py
End-to-end flow: End-to-end flow (updated for impress v5.4.0, which REMOVED Bearer/JWT auth on the API —
the app now only accepts its own session cookie from the OIDC authorization-code flow):
1. GET `/api/v1.0/users/me/` without auth → asserts the response REDIRECTS to the dep 1. GET `/api/v1.0/users/me/` without auth → asserts the response REDIRECTS to the dep
keycloak's realm auth endpoint (the recipe is correctly configured to challenge keycloak's realm auth endpoint (the recipe is correctly configured to challenge
unauthenticated callers — wired via install_steps.sh). unauthenticated callers — wired via install_steps.sh).
2. Obtain an OIDC token from the dep keycloak via password grant 2. Obtain an OIDC token from the dep keycloak via password grant, and assert the API
(the test user provisioned by the orchestrator's realm setup). now REJECTS it as a Bearer credential (the v5.4.0 auth hardening — a 200 here would
3. Call `/api/v1.0/users/me/` with `Authorization: Bearer <jwt>` → asserts 200 and the mean the hardening regressed).
3. Log in via the real OIDC authorization-code flow (app → keycloak form → callback →
session cookie) and call `/api/v1.0/users/me/` with the session → asserts 200 and the
returned user's email matches the provisioned test user. returned user's email matches the provisioned test user.
Marked @pytest.mark.requires_deps — skips with `deps-not-ready` if dep provisioning failed. Marked @pytest.mark.requires_deps — skips with `deps-not-ready` if dep provisioning failed.
@@ -24,9 +27,11 @@ import urllib.request
import pytest import pytest
sys.path.insert(0, os.path.dirname(__file__))
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "..", "runner")) sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "..", "runner"))
from _oidc_session import OidcSession # noqa: E402 (recipe-local helper, same dir)
from harness import http as harness_http # noqa: E402 from harness import http as harness_http # noqa: E402
from harness import sso from harness import sso # noqa: E402
_CTX = ssl.create_default_context() _CTX = ssl.create_default_context()
_CTX.check_hostname = False _CTX.check_hostname = False
@@ -62,16 +67,18 @@ def test_oidc_login_via_keycloak(live_app, deps):
# 302 redirect. Both are valid "auth-required" indicators — accept either, but if a # 302 redirect. Both are valid "auth-required" indicators — accept either, but if a
# redirect is returned it must point at the dep keycloak realm. # redirect is returned it must point at the dep keycloak realm.
if status in (301, 302, 303, 307, 308): if status in (301, 302, 303, 307, 308):
assert expected_prefix in ( assert expected_prefix in (redirect or ""), (
redirect or "" f"Docs redirected to {redirect!r}, expected to start with {expected_prefix!r}"
), f"Docs redirected to {redirect!r}, expected to start with {expected_prefix!r}" )
else: else:
assert status in (401, 403), ( assert status in (401, 403), (
f"GET /api/v1.0/users/me/ unauth: HTTP {status}; expected redirect to keycloak " f"GET /api/v1.0/users/me/ unauth: HTTP {status}; expected redirect to keycloak "
f"OR 401/403. (200 would be an auth leak.)" f"OR 401/403. (200 would be an auth leak.)"
) )
# Step 2: obtain an OIDC token via password grant against the dep keycloak # Step 2: obtain an OIDC token via password grant against the dep keycloak, and assert
# the API REJECTS it as Bearer — impress v5.4.0 removed Bearer/JWT auth (SessionAuthentication
# only); a 200 here would mean the auth hardening regressed.
creds = { creds = {
"client_id": kc["client_id"], "client_id": kc["client_id"],
"client_secret": kc["client_secret"], "client_secret": kc["client_secret"],
@@ -81,14 +88,19 @@ def test_oidc_login_via_keycloak(live_app, deps):
} }
access_token = sso.oidc_password_grant(creds) access_token = sso.oidc_password_grant(creds)
assert isinstance(access_token, str) and access_token.count(".") == 2, "expected JWT" assert isinstance(access_token, str) and access_token.count(".") == 2, "expected JWT"
# Step 3: call the protected API with the Bearer token; assert 200 + user email
status, body = harness_http.http_get( status, body = harness_http.http_get(
f"https://{live_app}/api/v1.0/users/me/", f"https://{live_app}/api/v1.0/users/me/",
headers={"Authorization": f"Bearer {access_token}"}, headers={"Authorization": f"Bearer {access_token}"},
) )
assert status == 200, f"GET /api/v1.0/users/me/ with token HTTP {status}: {body!r}" assert status in (401, 403), (
assert isinstance(body, dict), f"unexpected response: {body!r}" f"GET /api/v1.0/users/me/ with a Bearer JWT returned HTTP {status} — impress >= v5.4.0 "
assert ( f"must reject raw Bearer tokens (got body {body!r})"
body.get("email") == kc["email"] )
), f"unexpected user email: got {body.get('email')!r}, expected {kc['email']!r}"
# Step 3: the successor auth path — real OIDC authorization-code login (session cookie);
# the session-authenticated whoami must return the provisioned user.
sess = OidcSession(f"https://{live_app}")
me = sess.login(kc["user"], kc["password"])
assert me.get("email") == kc["email"], (
f"unexpected user email: got {me.get('email')!r}, expected {kc['email']!r}"
)
+28 -41
View File
@@ -5,14 +5,8 @@ SOURCE: references/recipe-maintainer/recipe-info/lasuite-meet/tests/meeting_flow
Meet's characteristic behavior is real-time meetings: a user creates a room and receives a LiveKit Meet's characteristic behavior is real-time meetings: a user creates a room and receives a LiveKit
(SFU) join token for WebSocket signaling. This is the §4.3 create-an-object + read-it-back, plus the (SFU) join token for WebSocket signaling. This is the §4.3 create-an-object + read-it-back, plus the
distinctive WebRTC-signaling feature (LiveKit token issuance) — not a health/200 stand-in. distinctive WebRTC-signaling feature (LiveKit token issuance) — not a health/200 stand-in. Flow:
1. OIDC password grant (the per-run keycloak user) → a Meet API bearer token.
Updated for meet v1.22.0+ API auth hardening: the API now REJECTS raw OIDC user access tokens
sent as Bearer credentials; authenticated calls run on the app's session cookie from the real
OIDC authorization-code login (see _oidc_session.py). Flow:
1. OIDC password grant (the per-run keycloak user) → assert the API rejects it as Bearer
(the v1.22.0 hardening — a 2xx here would mean the hardening regressed); then log in via
the OIDC authorization-code flow → session cookie.
2. POST /api/v1.0/rooms/ {name, access_level:public} → 201 with id/slug AND a LiveKit room+token. 2. POST /api/v1.0/rooms/ {name, access_level:public} → 201 with id/slug AND a LiveKit room+token.
3. GET /api/v1.0/rooms/{id}/ (read-it-back) → 200, again with a LiveKit token for the same room. 3. GET /api/v1.0/rooms/{id}/ (read-it-back) → 200, again with a LiveKit token for the same room.
4. Assert the LiveKit token is a real JWT carrying a video grant for that room (token issuance — 4. Assert the LiveKit token is a real JWT carrying a video grant for that room (token issuance —
@@ -33,11 +27,9 @@ import sys
import pytest import pytest
sys.path.insert(0, os.path.dirname(__file__))
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "..", "runner")) sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "..", "runner"))
from _oidc_session import OidcSession # noqa: E402 (recipe-local helper, same dir)
from harness import http as harness_http # noqa: E402 from harness import http as harness_http # noqa: E402
from harness import sso # noqa: E402 from harness import sso
def _b64url(seg: str) -> bytes: def _b64url(seg: str) -> bytes:
@@ -65,28 +57,17 @@ def _creds(deps: dict) -> dict:
@pytest.mark.requires_deps @pytest.mark.requires_deps
def test_create_room_get_livekit_token_and_read_back(live_app, deps): def test_create_room_get_livekit_token_and_read_back(live_app, deps):
assert "keycloak" in deps, f"keycloak creds missing; got {list(deps.keys())}" assert "keycloak" in deps, f"keycloak creds missing; got {list(deps.keys())}"
kc = deps["keycloak"]
base = f"https://{live_app}" base = f"https://{live_app}"
# meet v1.22.0+ hardening: a raw OIDC user access token must be REJECTED as Bearer.
token = sso.oidc_password_grant(_creds(deps)) token = sso.oidc_password_grant(_creds(deps))
assert isinstance(token, str) and token.count(".") == 2, "OIDC access token is not a JWT" assert isinstance(token, str) and token.count(".") == 2, "OIDC access token is not a JWT"
auth = {"Authorization": f"Bearer {token}"}
# --- create a room (the object) ---
status, body = harness_http.http_post( status, body = harness_http.http_post(
f"{base}/api/v1.0/rooms/", f"{base}/api/v1.0/rooms/",
data={"name": "ccci-meeting", "access_level": "public"}, data={"name": "ccci-meeting", "access_level": "public"},
headers={"Authorization": f"Bearer {token}"}, headers=auth,
) )
assert status in (401, 403), (
f"POST /api/v1.0/rooms/ with a raw OIDC Bearer token returned HTTP {status} — meet >= "
f"v1.22.0 must reject user access tokens on the API (body {body!r})"
)
# The successor auth path: session cookie via the real OIDC authorization-code login.
sess = OidcSession(base)
sess.login(kc["user"], kc["password"])
# --- create a room (the object) ---
status, body = sess.post("/api/v1.0/rooms/", {"name": "ccci-meeting", "access_level": "public"})
assert status == 201, f"room create returned HTTP {status} (expected 201); body={body!r}" assert status == 201, f"room create returned HTTP {status} (expected 201); body={body!r}"
assert isinstance(body, dict), f"room create body not JSON: {body!r}" assert isinstance(body, dict), f"room create body not JSON: {body!r}"
room_id = body.get("id") room_id = body.get("id")
@@ -94,32 +75,36 @@ def test_create_room_get_livekit_token_and_read_back(live_app, deps):
lk_room = livekit.get("room") lk_room = livekit.get("room")
lk_token = livekit.get("token") lk_token = livekit.get("token")
assert room_id, f"room created but no id: {body!r}" assert room_id, f"room created but no id: {body!r}"
assert lk_token and isinstance(lk_token, str) and lk_token.count(".") == 2, ( assert (
f"room created but no LiveKit JWT token: {livekit!r}" lk_token and isinstance(lk_token, str) and lk_token.count(".") == 2
) ), f"room created but no LiveKit JWT token: {livekit!r}"
try: try:
# --- read it back (a fresh authenticated GET of the created room) --- # --- read it back (a fresh authenticated GET of the created room) ---
status, got = sess.get(f"/api/v1.0/rooms/{room_id}/") status, got = harness_http.http_request(
assert status == 200, f"room read-back returned HTTP {status} (expected 200); body={got!r}" "GET", f"{base}/api/v1.0/rooms/{room_id}/", headers=auth
assert isinstance(got, dict) and got.get("id") == room_id, (
f"read-back room id mismatch: {got!r}"
) )
assert status == 200, f"room read-back returned HTTP {status} (expected 200); body={got!r}"
assert (
isinstance(got, dict) and got.get("id") == room_id
), f"read-back room id mismatch: {got!r}"
got_lk = got.get("livekit") or {} got_lk = got.get("livekit") or {}
assert got_lk.get("token"), f"read-back room missing LiveKit token: {got!r}" assert got_lk.get("token"), f"read-back room missing LiveKit token: {got!r}"
assert got_lk.get("room") == lk_room, ( assert (
f"read-back LiveKit room {got_lk.get('room')!r} != create-time {lk_room!r}" got_lk.get("room") == lk_room
) ), f"read-back LiveKit room {got_lk.get('room')!r} != create-time {lk_room!r}"
# --- the LiveKit token is a real signaling grant for this room (WebRTC subset) --- # --- the LiveKit token is a real signaling grant for this room (WebRTC subset) ---
payload = json.loads(_b64url(lk_token.split(".")[1])) payload = json.loads(_b64url(lk_token.split(".")[1]))
video = payload.get("video") or {} video = payload.get("video") or {}
assert video.get("room") == lk_room or payload.get("room") == lk_room, ( assert (
f"LiveKit JWT does not grant the created room {lk_room!r}: {payload!r}" video.get("room") == lk_room or payload.get("room") == lk_room
) ), f"LiveKit JWT does not grant the created room {lk_room!r}: {payload!r}"
finally: finally:
# --- delete the room (cleanup + a real DELETE mutation) --- # --- delete the room (cleanup + a real DELETE mutation) ---
del_status, _ = sess.delete(f"/api/v1.0/rooms/{room_id}/") del_status, _ = harness_http.http_request(
"DELETE", f"{base}/api/v1.0/rooms/{room_id}/", headers=auth
)
assert del_status in ( assert del_status in (
204, 204,
200, 200,
@@ -135,7 +120,9 @@ def test_create_room_get_livekit_token_and_read_back(live_app, deps):
gone = False gone = False
for _ in range(5): for _ in range(5):
status, _ = sess.get(f"/api/v1.0/rooms/{room_id}/") status, _ = harness_http.http_request(
"GET", f"{base}/api/v1.0/rooms/{room_id}/", headers=auth
)
if status == 404: if status == 404:
gone = True gone = True
break break