test(webui): add standalone SSO session and multi-user isolation coverage (#6849)

* test(webui): add guarded SSO provider E2E seam

* test(e2e): cover WebUI SSO multi-user isolation

* fix(test): address SSO E2E review feedback
This commit is contained in:
jinxin
2026-07-30 20:22:15 +08:00
committed by GitHub
parent a44a26319f
commit e35ba16434
11 changed files with 619 additions and 21 deletions

View File

@@ -237,10 +237,15 @@ jobs:
# Pre-build the reborn binary under the same llvm-cov env so the E2E
# fixtures find it cached instead of doing a cold instrumented build
# inside a pytest timeout. The WebChat v2 and OpenAI-compatible route
# surfaces are both unconditional, so one binary covers every scenario
# selected below.
# surfaces are both unconditional. The test-support feature adds only
# the guarded loopback SSO-provider seam used by the selected SSO E2E.
- name: Build instrumented ironclaw-reborn
run: cargo build -p ironclaw --bin ironclaw
run: |
set -euo pipefail
cargo build -p ironclaw --bin ironclaw
cargo build -p ironclaw --bin ironclaw \
--features test-support \
--target-dir "${CARGO_TARGET_DIR:-target}/e2e-sso"
- name: Mark OpenAI-compatible binary build
run: touch target/debug/.ironclaw-reborn-openai-compat.stamp

View File

@@ -212,10 +212,12 @@ jobs:
redis-password: ${{ secrets.SCCACHE_REDIS_PASSWORD }}
- name: Build ironclaw-reborn with WebChat v2 surface
run: >-
cargo build
-p ironclaw
--bin ironclaw
run: |
set -euo pipefail
cargo build -p ironclaw --bin ironclaw
cargo build -p ironclaw --bin ironclaw \
--features test-support \
--target-dir "${CARGO_TARGET_DIR:-target}/e2e-sso"
- name: Mark OpenAI-compatible binary build
run: touch target/debug/.ironclaw-reborn-openai-compat.stamp
@@ -272,7 +274,12 @@ jobs:
retention-days: 14
- name: Run Reborn WebUI v2 smoke
run: pytest tests/e2e/scenarios/test_reborn_webui_v2_smoke.py -v --timeout=120
run: >-
pytest
tests/e2e/scenarios/test_reborn_webui_v2_smoke.py
tests/e2e/scenarios/test_reborn_webui_v2_sso.py
-v
--timeout=120
- name: Run harvested QA replay and provider contracts with Emulate
env:

View File

@@ -32,6 +32,13 @@ eula = false
memory-mem0 = [
"ironclaw_reborn_composition/memory-mem0",
]
# Feature bar 4 (dev-only seam): compile the loopback-only OAuth provider
# endpoint constructor used by hermetic standalone E2E tests. Activation also
# requires a debug build plus paired endpoint env vars; release builds fail
# closed if the vars are present.
test-support = [
"ironclaw_webui/test-support",
]
[dependencies]
anyhow = "1"
async-trait = { version = "0.1" }

View File

@@ -26,6 +26,8 @@ use ironclaw_webui::{
use secrecy::SecretString;
const WEBUI_BASE_URL_ENV: &str = "IRONCLAW_REBORN_WEBUI_BASE_URL";
const TEST_GOOGLE_AUTH_ENDPOINT_ENV: &str = "IRONCLAW_REBORN_TEST_WEBUI_GOOGLE_AUTH_ENDPOINT";
const TEST_GOOGLE_TOKEN_ENDPOINT_ENV: &str = "IRONCLAW_REBORN_TEST_WEBUI_GOOGLE_TOKEN_ENDPOINT";
/// Resolved SSO startup config: the providers to mount plus the public
/// base URL their callback URLs are built from. Constructed by
@@ -195,6 +197,7 @@ pub(crate) fn is_cleartext_http_scheme(base_url: &str) -> bool {
/// different registered redirect URIs.)
fn oauth_providers_from_env() -> anyhow::Result<Vec<Arc<dyn OAuthProvider>>> {
let mut providers: Vec<Arc<dyn OAuthProvider>> = Vec::new();
let google_test_endpoints = google_test_endpoints_from_env()?;
// Optional operator override for the provider HTTP timeout, applied to
// every configured provider. Useful on a slow / cross-border path to
// the provider (e.g. `github.com`) where the default times out.
@@ -234,14 +237,33 @@ fn oauth_providers_from_env() -> anyhow::Result<Vec<Arc<dyn OAuthProvider>>> {
// authorization succeeds almost always means the secret does not
// match this client id.
log_provider_config("google", &client_id, client_secret.len());
let provider = GoogleProvider::new(GoogleOAuthConfig {
let config = GoogleOAuthConfig {
client_id,
client_secret: SecretString::from(client_secret),
allowed_hd,
http_timeout,
})
.context("failed to build Google OAuth provider")?;
};
#[cfg(feature = "test-support")]
let test_config = config.clone();
let provider = GoogleProvider::new(config);
#[cfg(feature = "test-support")]
let provider = if let Some((auth_endpoint, token_endpoint)) = google_test_endpoints.as_ref()
{
GoogleProvider::with_endpoints(
test_config,
auth_endpoint.clone(),
token_endpoint.clone(),
)
} else {
provider
};
let provider = provider.context("failed to build Google OAuth provider")?;
providers.push(Arc::new(provider));
} else if google_test_endpoints.is_some() {
anyhow::bail!(
"{TEST_GOOGLE_AUTH_ENDPOINT_ENV} and {TEST_GOOGLE_TOKEN_ENDPOINT_ENV} require \
IRONCLAW_REBORN_WEBUI_GOOGLE_CLIENT_ID"
);
}
if let Some(client_id) = non_empty_env("IRONCLAW_REBORN_WEBUI_GITHUB_CLIENT_ID") {
@@ -265,6 +287,73 @@ fn oauth_providers_from_env() -> anyhow::Result<Vec<Arc<dyn OAuthProvider>>> {
Ok(providers)
}
/// Resolve the paired, loopback-only Google endpoint override used by the
/// standalone-binary E2E harness.
///
/// This is deliberately stricter than an ordinary provider URL setting:
/// either both endpoints are absent (the production default), or both must be
/// present in a `test-support` debug build and point at literal loopback
/// IP addresses over HTTP. A partial or production activation fails startup
/// rather than falling through to a real provider mid-test.
fn google_test_endpoints_from_env() -> anyhow::Result<Option<(String, String)>> {
let auth_endpoint = non_empty_env(TEST_GOOGLE_AUTH_ENDPOINT_ENV);
let token_endpoint = non_empty_env(TEST_GOOGLE_TOKEN_ENDPOINT_ENV);
let (auth_endpoint, token_endpoint) = match (auth_endpoint, token_endpoint) {
(None, None) => return Ok(None),
(Some(auth_endpoint), Some(token_endpoint)) => (auth_endpoint, token_endpoint),
_ => {
anyhow::bail!(
"{TEST_GOOGLE_AUTH_ENDPOINT_ENV} and {TEST_GOOGLE_TOKEN_ENDPOINT_ENV} \
must be set together"
)
}
};
if !cfg!(feature = "test-support") {
anyhow::bail!(
"{TEST_GOOGLE_AUTH_ENDPOINT_ENV} is test-only and requires the \
`test-support` feature"
);
}
if !cfg!(debug_assertions) {
anyhow::bail!(
"{TEST_GOOGLE_AUTH_ENDPOINT_ENV} is test-only and unavailable in release builds"
);
}
validate_test_google_endpoint(TEST_GOOGLE_AUTH_ENDPOINT_ENV, &auth_endpoint)?;
validate_test_google_endpoint(TEST_GOOGLE_TOKEN_ENDPOINT_ENV, &token_endpoint)?;
tracing::warn!(
auth_endpoint = %auth_endpoint,
token_endpoint = %token_endpoint,
"test-only WebUI Google OAuth endpoints are ACTIVE"
);
Ok(Some((auth_endpoint, token_endpoint)))
}
fn validate_test_google_endpoint(name: &str, raw: &str) -> anyhow::Result<()> {
let endpoint =
reqwest::Url::parse(raw).with_context(|| format!("{name} must be a valid URL"))?;
if endpoint.scheme() != "http" {
anyhow::bail!("{name} must use http:// for the local E2E provider");
}
if !endpoint.username().is_empty() || endpoint.password().is_some() {
anyhow::bail!("{name} must not contain URL credentials");
}
let host = endpoint
.host_str()
.ok_or_else(|| anyhow!("{name} must include a loopback IP host"))?;
let ip = host
.parse::<std::net::IpAddr>()
.with_context(|| format!("{name} host must be a loopback IP literal"))?;
if !ip.is_loopback() {
anyhow::bail!("{name} host must be a loopback IP literal");
}
Ok(())
}
/// Log a redacted view of a configured OAuth provider at startup. The
/// secret value is never logged — only its length — so a misconfigured
/// (empty / truncated / wrong) secret is diagnosable from boot logs
@@ -430,6 +519,130 @@ mod tests {
assert!(require_admission_allowlist(&["example.com".to_string()]).is_ok());
}
#[test]
fn test_google_endpoint_overrides_must_be_paired() {
let _guard = crate::runtime::test_env::lock_runtime_env();
clear_sso_env();
// SAFETY: the shared process-env lock serializes this mutation.
unsafe {
std::env::set_var(
TEST_GOOGLE_AUTH_ENDPOINT_ENV,
"http://127.0.0.1:1234/authorize",
)
};
let error = google_test_endpoints_from_env()
.expect_err("a partial endpoint override must fail closed");
assert!(
error.to_string().contains("must be set together"),
"unexpected error: {error}"
);
clear_sso_env();
}
#[test]
fn test_google_endpoints_require_loopback_ip_literals() {
let cases = [
("https://127.0.0.1:1234/authorize", "must use http://"),
("http://localhost:1234/authorize", "loopback IP literal"),
("http://192.0.2.1:1234/authorize", "loopback IP literal"),
("http://user@127.0.0.1:1234/authorize", "URL credentials"),
];
for (raw, expected) in cases {
let error = validate_test_google_endpoint(TEST_GOOGLE_AUTH_ENDPOINT_ENV, raw)
.expect_err("unsafe test endpoint must be rejected");
assert!(
error.to_string().contains(expected),
"{raw}: expected `{expected}` in `{error}`"
);
}
validate_test_google_endpoint(
TEST_GOOGLE_AUTH_ENDPOINT_ENV,
"http://127.0.0.1:1234/authorize",
)
.expect("loopback HTTP endpoint");
}
#[cfg(not(feature = "test-support"))]
#[test]
fn test_google_endpoints_require_explicit_cargo_feature() {
let _guard = crate::runtime::test_env::lock_runtime_env();
clear_sso_env();
// SAFETY: the shared process-env lock serializes these mutations.
unsafe {
std::env::set_var(
TEST_GOOGLE_AUTH_ENDPOINT_ENV,
"http://127.0.0.1:1234/authorize",
);
std::env::set_var(
TEST_GOOGLE_TOKEN_ENDPOINT_ENV,
"http://127.0.0.1:1234/token",
);
}
let error =
google_test_endpoints_from_env().expect_err("default builds must reject the test seam");
assert!(
error.to_string().contains("test-support"),
"unexpected error: {error}"
);
clear_sso_env();
}
#[cfg(feature = "test-support")]
#[test]
fn test_google_endpoints_resolve_in_feature_enabled_debug_build() {
let _guard = crate::runtime::test_env::lock_runtime_env();
clear_sso_env();
// SAFETY: the shared process-env lock serializes these mutations.
unsafe {
std::env::set_var(
TEST_GOOGLE_AUTH_ENDPOINT_ENV,
"http://127.0.0.1:1234/authorize",
);
std::env::set_var(
TEST_GOOGLE_TOKEN_ENDPOINT_ENV,
"http://127.0.0.1:1234/token",
);
}
let endpoints = google_test_endpoints_from_env()
.expect("feature-enabled debug build accepts loopback endpoints")
.expect("paired endpoints");
assert_eq!(endpoints.0, "http://127.0.0.1:1234/authorize");
assert_eq!(endpoints.1, "http://127.0.0.1:1234/token");
clear_sso_env();
}
#[cfg(feature = "test-support")]
#[test]
fn test_google_endpoints_without_client_id_fail_through_startup_caller() {
let _guard = crate::runtime::test_env::lock_runtime_env();
clear_sso_env();
// SAFETY: the shared process-env lock serializes these mutations.
unsafe {
std::env::set_var(
TEST_GOOGLE_AUTH_ENDPOINT_ENV,
"http://127.0.0.1:1234/authorize",
);
std::env::set_var(
TEST_GOOGLE_TOKEN_ENDPOINT_ENV,
"http://127.0.0.1:1234/token",
);
}
let Err(error) = sso_startup_config_from_env(addr("127.0.0.1:3000")) else {
panic!("test endpoints without a Google client id must abort startup");
};
assert!(
error
.to_string()
.contains("IRONCLAW_REBORN_WEBUI_GOOGLE_CLIENT_ID"),
"unexpected error: {error}"
);
clear_sso_env();
}
const SSO_ENV_VARS: &[&str] = &[
"IRONCLAW_REBORN_WEBUI_GOOGLE_CLIENT_ID",
"IRONCLAW_REBORN_WEBUI_GOOGLE_CLIENT_SECRET",
@@ -438,6 +651,8 @@ mod tests {
"IRONCLAW_REBORN_WEBUI_GITHUB_CLIENT_SECRET",
WEBUI_BASE_URL_ENV,
"IRONCLAW_REBORN_WEBUI_ALLOWED_EMAIL_DOMAINS",
TEST_GOOGLE_AUTH_ENDPOINT_ENV,
TEST_GOOGLE_TOKEN_ENDPOINT_ENV,
];
fn clear_sso_env() {

View File

@@ -115,6 +115,7 @@ from `tests/e2e/` for the full, current set.
| File | What it tests |
|------|--------------|
| `test_reborn_webui_v2_smoke.py` | Canonical v2 smoke: serve boots, SPA renders authed shell, bearer auth + `?token=` shim scope, text turn persists/streams, thread list/delete, timeline pagination, composer-while-running, approval-gate send block, **new-chat-while-a-run-is-active (the #5256 `submitBusyRef` deadlock regression)** |
| `test_reborn_webui_v2_sso.py` | Google-shaped SSO login through a local mock OIDC provider, one-time ticket exchange, two-user thread/timeline isolation, and logout revocation against the standalone `ironclaw serve` binary |
| `test_reborn_webui_v2_tool_gates.py` | Served capability smoke: tool-result persistence and final reply, in-flight cancellation, approval approve/decline outcomes, and manual-token auth-gate resume with SSE/artifact redaction |
| `test_reborn_gateway_smoke.py` | Legacy `ironclaw` web channel (`/api/chat/*`) under `ENGINE_V2` — NOT the reborn binary |
| `test_reborn_v2_file_download.py` | Agent-produced workspace files are downloadable from the v2 UI |
@@ -171,6 +172,7 @@ All fixtures are defined in `tests/e2e/conftest.py`. Running `pytest scenarios/`
| `ironclaw_binary` | Legacy gateway binary. Checks `target/debug/ironclaw`; if absent, runs `cargo build -p ironclaw` (timeout 600s). |
| `ironclaw_reborn_binary` | Reborn v2 binary. Builds `target/debug/ironclaw` with default features when stale/missing. Used by the v2 SPA and full-path fixture scenarios. |
| `reborn_v2_server` | Starts `ironclaw serve` (v2 SPA at `/`, `local-dev` profile) against `mock_llm_server`; config written via `_write_config_toml` (selects the `openai` provider pointed at the mock). Waits for `/api/health`; SIGINT teardown. (Module-scoped, defined in `test_reborn_webui_v2_smoke.py`.) |
| `reborn_v2_sso_server` | Starts the same standalone binary with the guarded debug-only Google endpoint seam pointed at `mock_oauth_idp`; queues Alice and Bob OIDC profiles for full SSO and scope-isolation coverage. (Module-scoped, defined in `reborn_webui_harness.py`.) |
| `reborn_v2_browser` | Chromium instance for the v2 scenarios, independent of the legacy `browser` fixture (generous launch timeout + retry). |
| `mock_llm_server` | Starts `mock_llm.py --port 0`, reads the assigned port from stdout, waits for `/v1/models` to return 200. Yields the base URL. Serves canned responses including delayed ones (e.g. `"editable composer slow response"` → ~5s) so tests can act while a run is in flight. |
| `emulate_google_server` | Starts the Emulate CLI selected by `IRONCLAW_EMULATE_CLI`, or the `emulate@0.7.0` fallback, with `fixtures/emulate/google_gmail.yaml`; waits for the Gmail messages endpoint; and yields the base URL for HTTP rewrite maps. The pinned CI fork covers Gmail, Calendar, Drive, Docs, Sheets, and Slides. Local runs skip if neither the selected CLI nor `npx` is available; CI fails. |

View File

@@ -59,6 +59,7 @@ Then Playwright drives a headless Chromium browser against the gateway, making D
| `test_html_injection.py` | HTML injection security |
| `test_extensions.py` | Extensions tab: install, remove, configure, OAuth, auth card, activate |
| `test_oauth_refresh.py` | Hosted Gmail/MCP OAuth refresh; the Gmail path refreshes through the proxy and reads seeded Gmail data from Emulate |
| `test_reborn_webui_v2_sso.py` | Standalone Reborn SSO login, ticket exchange, logout revocation, and two-user thread/timeline isolation through a local mock OIDC provider |
| `test_emulate_reborn_provider_contracts.py` | Emulate provider contracts for Reborn-backed Google Gmail/Calendar/Drive reads, writes, missing resources, and account isolation; Slack QA 9/10 channel/thread/DM routing, strict-scope failures, profiles, mentions, and identity shapes; and GitHub identity, negative-result, repo/issue/PR/search/branch/git-object/release/fork/action-route surfaces |
| `test_provider_fault_proxy.py` | Self-tests the transparent provider fault proxy, reusable status/transport/response profiles, safe request ledger, reset behavior, and commit-then-disconnect semantics |
| `test_reborn_emulate_full_path.py` | Full-path IronClaw + Emulate coverage: install/auth extensions, drive scripted Gmail/Calendar/Drive/GitHub/Slack calls, assert provider-side state, and exercise GitHub→Slack, Calendar+Drive→Slack, Gmail→Slack, and Slack→Drive→Slack dispatch |

View File

@@ -500,6 +500,32 @@ def ironclaw_reborn_binary():
return str(binary)
@pytest.fixture(scope="session")
def ironclaw_reborn_sso_binary():
"""Build the debug-only Reborn binary variant used by the SSO mock."""
target_dir = _cargo_target_dir() / "e2e-sso"
binary = target_dir / "debug" / "ironclaw"
if _binary_needs_rebuild(binary):
print("Building Reborn ironclaw with test support (this may take a while)...")
subprocess.run(
[
"cargo", "build",
"-p", "ironclaw",
"--bin", "ironclaw",
"--features", "test-support",
"--target-dir", str(target_dir),
],
cwd=ROOT,
check=True,
timeout=600,
)
assert binary.exists(), (
f"Binary not found at {binary}. "
f"Cargo target dir resolved to: {target_dir}"
)
return str(binary)
@pytest.fixture(scope="session")
def ironclaw_reborn_openai_compat_binary():
"""Ensure Reborn `ironclaw` is built for the OpenAI-compatible scenarios.

View File

@@ -1,8 +1,10 @@
"""Reusable mock OAuth 2.0 authorization server with PKCE support.
"""Reusable mock OAuth 2.0 / OIDC authorization server with PKCE support.
Implements the minimum surface needed for Reborn product-auth E2E tests:
- GET /authorize — redirects to callback URL with ?code=&state=
- POST /token — issues a fake access_token + refresh_token
- optional queued OIDC profiles — adds a Google-shaped ``id_token`` so
WebUI SSO tests can log in distinct users through the real provider path
Security assertions this fixture supports:
- PKCE S256 challenge round-trip (can be toggled off for negative tests)
@@ -33,7 +35,10 @@ from __future__ import annotations
import hashlib
import base64
import json
import secrets
import time
from collections import deque
from dataclasses import dataclass, field
from typing import AsyncIterator
from urllib.parse import parse_qs, urlparse
@@ -62,6 +67,16 @@ class MockOAuthCodeGrant:
state: str
@dataclass(frozen=True)
class MockOidcProfile:
"""One identity returned by the next authorization-code exchange."""
subject: str
email: str
display_name: str
hosted_domain: str | None = None
@dataclass
class MockOAuthIdpHandle:
base_url: str
@@ -70,6 +85,8 @@ class MockOAuthIdpHandle:
# Maps refresh_token → client_id for RFC 6749 §10.4 binding validation.
issued_refresh_tokens: dict[str, str] = field(default_factory=dict)
_pending_codes: dict[str, dict] = field(default_factory=dict)
_initial_oidc_profiles: tuple[MockOidcProfile, ...] = field(default_factory=tuple)
_oidc_profiles: deque[MockOidcProfile] = field(default_factory=deque)
@property
def authorize_url(self) -> str:
@@ -84,6 +101,7 @@ class MockOAuthIdpHandle:
self.issued_tokens.clear()
self.issued_refresh_tokens.clear()
self._pending_codes.clear()
self._oidc_profiles = deque(self._initial_oidc_profiles)
def make_authorization_url(
self,
@@ -110,6 +128,36 @@ class MockOAuthIdpHandle:
return f"{self.authorize_url}?{urlencode(params)}"
def _google_id_token(profile: MockOidcProfile, client_id: str) -> str:
"""Build a non-secret JWT-shaped ID token for insecure test decoding.
The production Google provider validates the claims and algorithm after
receiving the token over the configured token endpoint, but deliberately
does not verify this response token's signature. The mock still includes a
non-empty signature segment so the fixture has a valid JWT wire shape.
"""
def encode_json(value: dict[str, object]) -> str:
raw = json.dumps(value, separators=(",", ":"), sort_keys=True).encode()
return base64.urlsafe_b64encode(raw).rstrip(b"=").decode()
claims: dict[str, object] = {
"sub": profile.subject,
"aud": client_id,
"iss": "https://accounts.google.com",
"email": profile.email,
"email_verified": True,
"name": profile.display_name,
"exp": int(time.time()) + 3600,
}
if profile.hosted_domain:
claims["hd"] = profile.hosted_domain
header = encode_json({"alg": "RS256", "typ": "JWT"})
payload = encode_json(claims)
signature = base64.urlsafe_b64encode(b"mock-oidc-signature").rstrip(b"=").decode()
return f"{header}.{payload}.{signature}"
async def issue_oauth_code(
handle: MockOAuthIdpHandle,
*,
@@ -145,9 +193,17 @@ async def issue_oauth_code(
)
async def start_mock_oauth_idp(*, port: int = 0) -> AsyncIterator[MockOAuthIdpHandle]:
async def start_mock_oauth_idp(
*,
port: int = 0,
oidc_profiles: tuple[MockOidcProfile, ...] = (),
) -> AsyncIterator[MockOAuthIdpHandle]:
"""Context manager that starts the mock IDP and yields a handle."""
handle = MockOAuthIdpHandle(base_url="") # filled after bind
handle = MockOAuthIdpHandle(
base_url="",
_initial_oidc_profiles=oidc_profiles,
_oidc_profiles=deque(oidc_profiles),
) # base_url filled after bind
async def authorize(request: web.Request) -> web.Response:
"""Simulate the IdP authorization endpoint.
@@ -160,15 +216,27 @@ async def start_mock_oauth_idp(*, port: int = 0) -> AsyncIterator[MockOAuthIdpHa
state = qs.get("state", "")
code_challenge = qs.get("code_challenge")
code_challenge_method = qs.get("code_challenge_method", "S256")
client_id = qs.get("client_id", "")
if not redirect_uri or not state:
return web.Response(status=400, text="missing redirect_uri or state")
if not redirect_uri or not state or not client_id:
return web.Response(
status=400,
text="missing client_id, redirect_uri, or state",
)
oidc_profile = None
if handle._initial_oidc_profiles:
if not handle._oidc_profiles:
return web.Response(status=409, text="no mock OIDC profiles remain")
oidc_profile = handle._oidc_profiles.popleft()
code = f"fake_code_{secrets.token_urlsafe(12)}"
handle._pending_codes[code] = {
"client_id": client_id,
"redirect_uri": redirect_uri,
"code_challenge": code_challenge,
"code_challenge_method": code_challenge_method,
"oidc_profile": oidc_profile,
}
from urllib.parse import urlencode
@@ -194,6 +262,16 @@ async def start_mock_oauth_idp(*, port: int = 0) -> AsyncIterator[MockOAuthIdpHa
{"error": "invalid_grant", "error_description": "redirect_uri mismatch"},
status=400,
)
submitted_client_id = body.get("client_id", "")
if submitted_client_id and submitted_client_id != pending["client_id"]:
return web.json_response(
{"error": "invalid_grant", "error_description": "client_id mismatch"},
status=400,
)
# Some product-auth fixtures model a public client and omit
# client_id at exchange. Retain the authorization request's
# binding in that case; reject only an explicitly different id.
client_id = submitted_client_id or pending["client_id"]
# PKCE S256: verifier required when challenge was registered.
expected_challenge = pending.get("code_challenge")
@@ -213,16 +291,19 @@ async def start_mock_oauth_idp(*, port: int = 0) -> AsyncIterator[MockOAuthIdpHa
handle.received_codes.append(code)
access_token = f"fake_access_{secrets.token_urlsafe(16)}"
refresh_token = f"fake_refresh_{secrets.token_urlsafe(16)}"
client_id = body.get("client_id", "")
handle.issued_tokens.append(access_token)
handle.issued_refresh_tokens[refresh_token] = client_id
return web.json_response({
response = {
"access_token": access_token,
"refresh_token": refresh_token,
"token_type": "Bearer",
"expires_in": 3600,
"scope": "openid email",
})
}
oidc_profile = pending.get("oidc_profile")
if oidc_profile is not None:
response["id_token"] = _google_id_token(oidc_profile, client_id)
return web.json_response(response)
if grant_type == "refresh_token":
refresh_token = body.get("refresh_token", "")

View File

@@ -14,6 +14,7 @@
# provider-fixture or model-tool-choice coverage that should stay in the normal
# E2E suite but not in this Reborn binary coverage gate.
tests/e2e/scenarios/test_reborn_webui_v2_smoke.py
tests/e2e/scenarios/test_reborn_webui_v2_sso.py
tests/e2e/scenarios/test_reborn_webui_v2_tool_gates.py
tests/e2e/scenarios/test_reborn_webui_v2_streaming_run_control_api.py::test_reborn_v2_sse_reconnect_resumes_without_gap_or_duplicate_served
tests/e2e/scenarios/test_reborn_webui_v2_streaming_run_control_api.py::test_reborn_v2_websocket_origin_projection_and_shared_capacity_served

View File

@@ -21,6 +21,7 @@ import httpx
import pytest
from playwright.async_api import Error as PlaywrightError
from fixtures.mock_oauth_idp import MockOidcProfile, start_mock_oauth_idp
from helpers import REBORN_V2_AUTH_TOKEN, SEL_V2, wait_for_ready
USER_ID = "reborn-v2-e2e-user"
@@ -29,6 +30,7 @@ YOLO_PROFILE = "local-dev-yolo"
DEFAULT_MODEL = "mock-model"
VISION_MODEL = "gpt-4o"
ACCEPTED_SEND_OUTCOMES = {"submitted", "already_submitted"}
SSO_GOOGLE_CLIENT_ID = "reborn-v2-e2e-google-client"
DEFAULT_ARTIFACT_MAX_BYTES = 256 * 1024 * 1024
MAX_SERVER_LOG_BYTES = 16 * 1024 * 1024
_ARTIFACT_PENDING_SENTINEL = ".pytest-outcome-pending"
@@ -440,6 +442,7 @@ async def start_reborn_webui_v2_server(
model: str = DEFAULT_MODEL,
log_prefix: str = "reborn-v2",
extra_env: dict[str, str] | None = None,
use_listener_as_webui_base_url: bool = False,
) -> tuple[object, str]:
"""Start ``ironclaw serve`` and return ``(process, base_url)``."""
configured_artifact_root = os.environ.get(
@@ -472,6 +475,7 @@ async def start_reborn_webui_v2_server(
for attempt in range(1, 4):
port = find_free_port()
last_port = port
base_url = f"http://127.0.0.1:{port}"
if artifact_root:
log_dir = artifact_root / "server-logs" / home_dir.name
log_dir.mkdir(parents=True, exist_ok=True)
@@ -495,6 +499,8 @@ async def start_reborn_webui_v2_server(
}
if extra_env:
env.update(extra_env)
if use_listener_as_webui_base_url:
env["IRONCLAW_REBORN_WEBUI_BASE_URL"] = base_url
forward_coverage_env(env)
args = [
@@ -550,8 +556,6 @@ async def start_reborn_webui_v2_server(
env=env,
cwd=workspace_dir,
)
base_url = f"http://127.0.0.1:{port}"
try:
await wait_for_ready(f"{base_url}/api/health", timeout=60)
return proc, base_url
@@ -618,6 +622,52 @@ async def reborn_v2_server(ironclaw_reborn_binary, mock_llm_server, tmp_path_fac
await close_reborn_server(proc)
@pytest.fixture(scope="module")
async def reborn_v2_sso_server(
ironclaw_reborn_sso_binary, mock_llm_server, tmp_path_factory
):
"""Start ``ironclaw serve`` with Google SSO backed by a local mock IDP."""
profiles = (
MockOidcProfile(
subject="alice-subject",
email="alice@example.com",
display_name="Alice E2E",
),
MockOidcProfile(
subject="bob-subject",
email="bob@example.com",
display_name="Bob E2E",
),
)
async for provider in start_mock_oauth_idp(oidc_profiles=profiles):
home_dir = tmp_path_factory.mktemp("ironclaw-reborn-v2-sso-home")
proc, base_url = await start_reborn_webui_v2_server(
ironclaw_reborn_binary=ironclaw_reborn_sso_binary,
mock_llm_server=mock_llm_server,
home_dir=home_dir,
profile=DEFAULT_PROFILE,
log_prefix="reborn-v2-sso",
use_listener_as_webui_base_url=True,
extra_env={
"IRONCLAW_REBORN_WEBUI_GOOGLE_CLIENT_ID": SSO_GOOGLE_CLIENT_ID,
"IRONCLAW_REBORN_WEBUI_GOOGLE_CLIENT_SECRET": "mock-google-secret",
"IRONCLAW_REBORN_WEBUI_ALLOWED_EMAIL_DOMAINS": "example.com",
# Defeat ambient repo/user .env values so this fixture never
# advertises or contacts a provider it did not start itself.
"IRONCLAW_REBORN_WEBUI_GITHUB_CLIENT_ID": "",
"IRONCLAW_REBORN_WEBUI_GITHUB_CLIENT_SECRET": "",
"IRONCLAW_REBORN_TEST_WEBUI_GOOGLE_AUTH_ENDPOINT": (
provider.authorize_url
),
"IRONCLAW_REBORN_TEST_WEBUI_GOOGLE_TOKEN_ENDPOINT": provider.token_url,
},
)
try:
yield {"base_url": base_url, "provider": provider}
finally:
await close_reborn_server(proc)
@pytest.fixture(scope="module")
async def reborn_v2_yolo_server(ironclaw_reborn_binary, mock_llm_server, tmp_path_factory):
"""Start ``ironclaw serve`` with auto-approval local-dev-yolo profile."""

View File

@@ -0,0 +1,203 @@
"""Standalone Reborn WebUI v2 SSO and multi-user isolation smoke (#4636).
This scenario starts the shipping ``ironclaw serve`` process in session-auth
mode, drives the full Google-shaped OAuth redirect/callback/ticket exchange
against a local mock provider, and proves two admitted users retain distinct
thread and timeline scopes in one tenant.
"""
import json
from urllib.parse import parse_qs, urljoin, urlparse
import httpx
from helpers import sse_stream, wait_for_sse_line
from reborn_webui_harness import create_thread, reborn_bearer_headers
pytest_plugins = ["reborn_webui_harness"]
async def _login_with_next_mock_user(client: httpx.AsyncClient, base_url: str) -> str:
login = await client.get(
f"{base_url}/auth/login/google",
params={"redirect_after": "/"},
timeout=15,
)
assert login.status_code == 307, login.text
authorize = await client.get(
urljoin(base_url, login.headers["location"]),
timeout=15,
)
assert authorize.status_code == 302, authorize.text
callback = await client.get(
urljoin(base_url, authorize.headers["location"]),
timeout=15,
)
assert callback.status_code == 303, callback.text
landing = urljoin(base_url, callback.headers["location"])
ticket = parse_qs(urlparse(landing).query).get("login_ticket", [""])[0]
assert ticket, callback.headers["location"]
exchange = await client.post(
f"{base_url}/auth/session/exchange",
json={"ticket": ticket},
timeout=15,
)
exchange.raise_for_status()
token = exchange.json()["token"]
assert token
return token
async def _session(base_url: str, token: str) -> httpx.Response:
async with httpx.AsyncClient(
headers=reborn_bearer_headers(token),
trust_env=False,
) as client:
return await client.get(
f"{base_url}/api/webchat/v2/session",
timeout=15,
)
async def _assert_cross_user_stream_denied(
base_url: str,
thread_id: str,
token: str,
) -> None:
# EventSource requires a successful HTTP open. Authorization failures are
# therefore delivered as one redacted stream_error frame before close.
async with sse_stream(
base_url,
path=f"/api/webchat/v2/threads/{thread_id}/events",
token=token,
timeout=15,
) as response:
assert response.status == 200
assert response.headers["content-type"].startswith("text/event-stream")
event_line = await wait_for_sse_line(
response,
predicate=lambda line: line.startswith("event:"),
timeout=10,
)
data_line = await wait_for_sse_line(
response,
predicate=lambda line: line.startswith("data:"),
timeout=10,
)
assert event_line.partition(":")[2].strip() == "stream_error"
assert json.loads(data_line.partition(":")[2].strip()) == {
"error": "not_found",
"kind": "not_found",
"retryable": False,
}
async def test_reborn_v2_sso_login_logout_and_multi_user_scope_isolation(
reborn_v2_sso_server,
):
base_url = reborn_v2_sso_server["base_url"]
provider = reborn_v2_sso_server["provider"]
async with httpx.AsyncClient(follow_redirects=False, trust_env=False) as public:
providers = await public.get(f"{base_url}/auth/providers", timeout=15)
providers.raise_for_status()
assert providers.json() == {"providers": ["google"]}
alice_token = await _login_with_next_mock_user(public, base_url)
bob_token = await _login_with_next_mock_user(public, base_url)
assert alice_token != bob_token
assert len(provider.received_codes) == 2
alice_session = await _session(base_url, alice_token)
bob_session = await _session(base_url, bob_token)
alice_session.raise_for_status()
bob_session.raise_for_status()
alice_identity = alice_session.json()
bob_identity = bob_session.json()
assert alice_identity["tenant_id"] == bob_identity["tenant_id"] == "reborn-v2-e2e"
assert alice_identity["user_id"] != bob_identity["user_id"]
assert alice_identity["capabilities"]["operator_webui_config"] is False
assert bob_identity["capabilities"]["operator_webui_config"] is False
async with (
httpx.AsyncClient(
headers=reborn_bearer_headers(alice_token),
trust_env=False,
) as alice,
httpx.AsyncClient(
headers=reborn_bearer_headers(bob_token),
trust_env=False,
) as bob,
):
alice_thread = await create_thread(alice, base_url)
bob_thread = await create_thread(bob, base_url)
assert alice_thread != bob_thread
alice_threads = await alice.get(
f"{base_url}/api/webchat/v2/threads",
timeout=15,
)
bob_threads = await bob.get(
f"{base_url}/api/webchat/v2/threads",
timeout=15,
)
alice_threads.raise_for_status()
bob_threads.raise_for_status()
alice_thread_ids = {
thread["thread_id"] for thread in alice_threads.json()["threads"]
}
bob_thread_ids = {
thread["thread_id"] for thread in bob_threads.json()["threads"]
}
assert alice_thread in alice_thread_ids
assert bob_thread not in alice_thread_ids
assert bob_thread in bob_thread_ids
assert alice_thread not in bob_thread_ids
alice_own_timeline = await alice.get(
f"{base_url}/api/webchat/v2/threads/{alice_thread}/timeline",
timeout=15,
)
bob_own_timeline = await bob.get(
f"{base_url}/api/webchat/v2/threads/{bob_thread}/timeline",
timeout=15,
)
alice_own_timeline.raise_for_status()
bob_own_timeline.raise_for_status()
assert alice_own_timeline.json()["messages"] == []
assert bob_own_timeline.json()["messages"] == []
alice_reads_bob = await alice.get(
f"{base_url}/api/webchat/v2/threads/{bob_thread}/timeline",
timeout=15,
)
bob_reads_alice = await bob.get(
f"{base_url}/api/webchat/v2/threads/{alice_thread}/timeline",
timeout=15,
)
assert alice_reads_bob.status_code == 404
assert bob_reads_alice.status_code == 404
await _assert_cross_user_stream_denied(
base_url,
bob_thread,
alice_token,
)
await _assert_cross_user_stream_denied(
base_url,
alice_thread,
bob_token,
)
logout = await alice.post(f"{base_url}/auth/logout", timeout=15)
assert logout.status_code == 204
assert (await _session(base_url, alice_token)).status_code == 401
bob_still_authenticated = await _session(base_url, bob_token)
bob_still_authenticated.raise_for_status()
assert bob_still_authenticated.json()["user_id"] == bob_identity["user_id"]