Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 8 additions & 3 deletions .github/workflows/coverage.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
17 changes: 12 additions & 5 deletions .github/workflows/reborn-e2e.yml
Original file line number Diff line number Diff line change
Expand Up @@ -200,10 +200,12 @@ jobs:
save-if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }}

- 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
Expand All @@ -228,7 +230,12 @@ jobs:
playwright install --with-deps chromium

- 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:
Expand Down
7 changes: 7 additions & 0 deletions crates/ironclaw_reborn_cli/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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" }
Expand Down
221 changes: 218 additions & 3 deletions crates/ironclaw_reborn_cli/src/commands/serve_sso.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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") {
Expand All @@ -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
Expand Down Expand Up @@ -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",
Expand All @@ -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() {
Expand Down
2 changes: 2 additions & 0 deletions tests/e2e/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down Expand Up @@ -170,6 +171,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. |
Expand Down
1 change: 1 addition & 0 deletions tests/e2e/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
26 changes: 26 additions & 0 deletions tests/e2e/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Comment thread
coderabbitai[bot] marked this conversation as resolved.


@pytest.fixture(scope="session")
def ironclaw_reborn_openai_compat_binary():
"""Ensure Reborn `ironclaw` is built for the OpenAI-compatible scenarios.
Expand Down
Loading
Loading