Compare commits

..
Author SHA1 Message Date
autonomic-bot 972f5ec4ad test(lasuite-meet): update stale meeting-flow test for meet v1.22.0+ API auth hardening
continuous-integration/drone/push Build is failing
test_create_room_get_livekit_token_and_read_back authenticated with a raw OIDC user
access token as 'Authorization: Bearer'; meet v1.22.0 hardened API auth to reject
user access tokens (release notes: 'reject user access tokens on the API'), so the
test went RED with 401 on the v1.24.0 upgrade (drone build #1137; same at v1.23.0
in build #1122).

Updated to the successor auth path: recipe-local _oidc_session.py (same helper as
tests/lasuite-docs) drives the real OIDC authorization-code flow (app -> keycloak
login form -> callback -> Django session cookie, CSRF on unsafe methods).
- NEW assertion: a raw OIDC Bearer token is REJECTED (401/403) - the v1.22.0
  hardening asserted as the new correct behavior.
- The full meeting flow (create 201 + LiveKit JWT grant, read-back, DELETE) is
  unchanged, now over the session-authenticated API. No assertion weakened.

Stale-test fix for recipe PR
recipe-maintainers/lasuite-meet#8
(carry-over from /upgrade-all 2026-07-24).
2026-08-03 20:45:37 +00:00
4 changed files with 98 additions and 82 deletions
+32 -19
View File
@@ -3,13 +3,12 @@
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 (updated for impress v5.4.0, which removed Bearer/JWT auth on the API — the doc CRUD now Flow (uses an OIDC token from the dep keycloak):
runs on the app's session cookie from the real OIDC authorization-code login): 1. Obtain a JWT via OIDC password grant against the dep keycloak (the test user is provisioned
1. Log in via the OIDC authorization-code flow against the dep keycloak (the test user is by the orchestrator's dep-provisioning step).
provisioned by the orchestrator's dep-provisioning step) → session cookie. 2. POST `/api/v1.0/documents/` with `Authorization: Bearer <jwt>` to create a new doc with a
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 session; assert the returned title and 3. GET `/api/v1.0/documents/<id>/` with the same Bearer token; 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
@@ -27,9 +26,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 _oidc_session import OidcSession # noqa: E402 (recipe-local helper, same dir) from harness import http as harness_http # noqa: E402
from harness import sso
@pytest.mark.requires_deps @pytest.mark.requires_deps
@@ -37,29 +36,43 @@ 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"]
# Session login via the OIDC authorization-code flow (impress v5.4.0+ rejects Bearer JWTs) # Obtain a JWT via OIDC password grant
sess = OidcSession(f"https://{live_app}") access_token = sso.oidc_password_grant(
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 = sess.post("/api/v1.0/documents/", {"title": title}) s, body = harness_http.http_post(
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 body.get("title") == title, ( assert (
f"created doc title mismatch: created={title!r}, response={body.get('title')!r}" body.get("title") == title
) ), 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 = sess.get(f"/api/v1.0/documents/{doc_id}/") s, fetched = harness_http.http_get(
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 fetched.get("title") == title, ( assert (
f"fetched title mismatch: created={title!r}, fetched={fetched.get('title')!r}" fetched.get("title") == title
) ), f"fetched title mismatch: created={title!r}, fetched={fetched.get('title')!r}"
+16 -28
View File
@@ -2,16 +2,13 @@
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 (updated for impress v5.4.0, which REMOVED Bearer/JWT auth on the API — End-to-end flow:
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, and assert the API 2. Obtain an OIDC token from the dep keycloak via password grant
now REJECTS it as a Bearer credential (the v5.4.0 auth hardening — a 200 here would (the test user provisioned by the orchestrator's realm setup).
mean the hardening regressed). 3. Call `/api/v1.0/users/me/` with `Authorization: Bearer <jwt>` → asserts 200 and the
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.
@@ -27,11 +24,9 @@ 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 # noqa: E402 from harness import sso
_CTX = ssl.create_default_context() _CTX = ssl.create_default_context()
_CTX.check_hostname = False _CTX.check_hostname = False
@@ -67,18 +62,16 @@ 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 (redirect or ""), ( assert expected_prefix in (
f"Docs redirected to {redirect!r}, expected to start with {expected_prefix!r}" redirect or ""
) ), 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, and assert # Step 2: obtain an OIDC token via password grant against the dep keycloak
# 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"],
@@ -88,19 +81,14 @@ 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 in (401, 403), ( assert status == 200, f"GET /api/v1.0/users/me/ with token HTTP {status}: {body!r}"
f"GET /api/v1.0/users/me/ with a Bearer JWT returned HTTP {status} — impress >= v5.4.0 " assert isinstance(body, dict), f"unexpected response: {body!r}"
f"must reject raw Bearer tokens (got body {body!r})" assert (
) 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}"
)
@@ -1,17 +1,19 @@
"""Recipe-local OIDC *session* login helper (authorization-code flow + session cookie). """Recipe-local OIDC *session* login helper (authorization-code flow + session cookie).
impress v5.4.0 removed Bearer-token (JWT) authentication on the API — the app now accepts only meet v1.22.0 hardened API auth — raw OIDC user access tokens are rejected as Bearer
its own session cookie, established through the standard OIDC authorization-code browser flow credentials; the app accepts only its own session cookie, established through the standard OIDC
(app login URL → keycloak login form → callback → Django session). This helper drives that flow authorization-code browser flow (app login URL → keycloak login form → callback → Django
with urllib + a CookieJar so the custom tests can exercise the API the way a real client does. session). This helper drives that flow with urllib + a CookieJar so the custom tests can
exercise the API the way a real client does.
Kept recipe-local (cf. tests/ghost/custom/_ghost.py precedent) rather than in runner/harness — Kept recipe-local (cf. tests/ghost/custom/_ghost.py precedent; same helper as
promote it there if a third recipe needs it. tests/lasuite-docs/custom/_oidc_session.py) rather than in runner/harness — promote it there
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/documents/", {"title": "x"}) # CSRF handled status, body = sess.post("/api/v1.0/rooms/", {"name": "x"}) # CSRF handled
""" """
from __future__ import annotations from __future__ import annotations
+41 -28
View File
@@ -5,8 +5,14 @@ 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. Flow: distinctive WebRTC-signaling feature (LiveKit token issuance) — not a health/200 stand-in.
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 —
@@ -27,9 +33,11 @@ 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 from harness import sso # noqa: E402
def _b64url(seg: str) -> bytes: def _b64url(seg: str) -> bytes:
@@ -57,17 +65,28 @@ 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=auth, headers={"Authorization": f"Bearer {token}"},
) )
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")
@@ -75,36 +94,32 @@ 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 ( assert lk_token and isinstance(lk_token, str) and lk_token.count(".") == 2, (
lk_token and isinstance(lk_token, str) and lk_token.count(".") == 2 f"room created but no LiveKit JWT token: {livekit!r}"
), 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 = harness_http.http_request( status, got = sess.get(f"/api/v1.0/rooms/{room_id}/")
"GET", f"{base}/api/v1.0/rooms/{room_id}/", headers=auth
)
assert status == 200, f"room read-back returned HTTP {status} (expected 200); body={got!r}" assert status == 200, f"room read-back returned HTTP {status} (expected 200); body={got!r}"
assert ( assert isinstance(got, dict) and got.get("id") == room_id, (
isinstance(got, dict) and got.get("id") == room_id f"read-back room id mismatch: {got!r}"
), 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 ( assert got_lk.get("room") == lk_room, (
got_lk.get("room") == lk_room f"read-back LiveKit room {got_lk.get('room')!r} != create-time {lk_room!r}"
), 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 ( assert video.get("room") == lk_room or payload.get("room") == lk_room, (
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}"
), 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, _ = harness_http.http_request( del_status, _ = sess.delete(f"/api/v1.0/rooms/{room_id}/")
"DELETE", f"{base}/api/v1.0/rooms/{room_id}/", headers=auth
)
assert del_status in ( assert del_status in (
204, 204,
200, 200,
@@ -120,9 +135,7 @@ 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, _ = harness_http.http_request( status, _ = sess.get(f"/api/v1.0/rooms/{room_id}/")
"GET", f"{base}/api/v1.0/rooms/{room_id}/", headers=auth
)
if status == 404: if status == 404:
gone = True gone = True
break break