mirror of
https://github.com/nearai/ironclaw.git
synced 2026-09-03 08:06:01 +08:00
Finishes the feature-slice migration started in stage 4a. After this: - `src/channels/web/server.rs` no longer exists. - Every caller of `crate::channels::web::server::*` now points at `platform::router::start_server` or `platform::state::*` directly. - All ~60 caller-level tests that used to live in `server.rs::tests` now live inside the feature slice they actually exercise, next to the handler they test. ## What moved where Classification driven by the handler each test drives: | Slice | Tests | |---|---| | `features/chat/mod.rs::tests` | 3 × history, 4 × auth-token/cancel + gate-resolve, 1 × approval, 3 × pending-gate-extension-name, 1 × test_auth_manager helper | | `features/pairing/mod.rs::tests` | 1 × list, 5 × approve (claim / no-followup / with-thread / external-callback / blank-code), `make_pairing_test_state` helper | | `features/extensions/mod.rs::tests` | 2 × activation classifier, 2 × path-traversal guards, 1 × setup-submit-not-activated, 2 × list-inactive-wasm-channel, 1 × phase-precedence, 1 × readiness handler, 2 × apply_extension_readiness | | `features/oauth/mod.rs::tests` | 13 × oauth callback (missing params / unknown state / expired × 2 / no-ext-mgr / strip-prefix / versioned × 2 / happy × 3 / exchange-fail), 5 × relay oauth callback, + `TestOauthProxy`, `EnvVarGuard`, `set_env_var`, `fresh_pending_oauth_flow`, `expired_flow_created_at`, `test_oauth_router`, `test_relay_oauth_router` helpers | | `platform/static_files.rs::tests` | 3 × CSP header / base / nonce, 2 × css etag, 1 × css handler, 2 × css multi-tenant, 4 × stamp nonce + build frontend HTML, 1 × test_build_frontend_html_returns_none_in_multi_tenant_mode | | `platform/state.rs::tests` | 1 × workspace_pool_resolve_seeds_new_user_workspace | | `handlers/llm.rs::tests` | 3 × llm admin-role guards | | `handlers/users.rs::tests` | 1 × delete_user_evicts_auth_and_pairing_caches | ## Cross-slice test fixtures Four helpers that multiple slices share (`insert_test_user`, `test_secrets_store`, `test_ext_mgr`, `test_ext_mgr_with_db`) moved into `src/channels/web/test_helpers.rs` as `#[cfg(test)] pub(crate)` free functions, following the pattern from stage 6a (#2704) for `test_gateway_state*`. All four keep the exact signatures they had in `server.rs::tests`, so the move was mechanical. Rust expect suppressions on the five `.expect(...)` lines inside these fixtures carry `// safety: cfg(test) fixture` comments — the pre-commit safety check is diff-line based and doesn't look up whether the containing function is already `cfg(test)`-gated. ## Mechanical renames (25 files) `channels::web::server::<item>` call sites now import from: - `platform::router::start_server` - `platform::state::{GatewayState, RateLimiter, PerUserRateLimiter, WorkspacePool, FrontendCacheKey, FrontendHtmlCache, ActiveConfigSnapshot, PromptQueue, RoutineEngineSlot, rate_limit_key_from_headers}` Covers `src/main.rs`, `src/app.rs`, `src/tools/builtin/{job,memory}.rs`, all 13 handlers in `handlers/*.rs`, the four integration tests (`ws_gateway_integration`, `openai_compat_integration`, `multi_tenant_integration`, `oauth_greeting_integration`), plus `tests/support/gateway_workflow_harness.rs` and `src/channels/web/tests/multi_tenant.rs`. No behavior change. ## Boundary checker retained `scripts/check_gateway_boundaries.py` still rejects any `crate::channels::web::server::` path as a defense-in-depth guard against accidental re-introduction (literal new `server.rs`, stray imports, etc.). The explanatory comment and the regression test's docstring now reflect "shim is gone; this guard prevents re-creation" instead of "shim exists; don't route through it." ## Documentation updates - `src/channels/web/CLAUDE.md`: deleted the `server.rs` File Map row, updated the `test_helpers.rs` row to list all seven `pub(crate)` fixtures (stages 6a + 6 together), fixed all prose references that pointed at `server.rs`, and updated the "Adding a New API Endpoint" recipe to point at `features/<slice>/` and `platform/router.rs`. - `src/channels/web/platform/state.rs`: module docstring now says "shim was removed" instead of "shim exists pending migration." - `src/bridge/CLAUDE.md`: `pending_gate_extension_name` reference now points at `features/chat/mod.rs`. ## Quality gate - [x] `cargo fmt --all` - [x] `cargo clippy --all --benches --tests --examples --all-features` — zero warnings - [x] `cargo check -p ironclaw --no-default-features --features libsql --tests` — clean - [x] `cargo test -p ironclaw --lib channels::web` — 434 passed (up from 431 — three tests that were incorrectly filtered under `channels::web::server::tests` now surface under their proper slice's module path) - [x] `cargo test -p ironclaw --test multi_tenant_integration` — 40 passed - [x] `cargo test -p ironclaw --test openai_compat_integration` — 16 passed - [x] `cargo test -p ironclaw --test ws_gateway_integration` — 11 passed - [x] `python3 scripts/check_gateway_boundaries.py` — clean - [x] `python3 scripts/check_gateway_boundaries.py test` — 16/16 - [x] `bash scripts/pre-commit-safety.sh` — clean ## Regression coverage Pure relocation + mechanical rename; no behavior change. The existing ~60 tests from `server.rs::tests` continue to pass unmodified, which is the regression evidence. A "test that would have caught this" would necessarily duplicate the existing tests — no new test adds coverage. [skip-regression-check] Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
366 lines
14 KiB
Rust
366 lines
14 KiB
Rust
//! Integration tests for assistant-thread bootstrap and cookie-based auth.
|
|
//!
|
|
//! Verifies:
|
|
//! - Newly provisioned users start with one persisted assistant greeting
|
|
//! - Listing /api/chat/threads does not duplicate that greeting
|
|
//! - Concurrent requests don't create duplicate assistant greetings
|
|
//! - Multiple users each get their own assistant thread and greeting
|
|
//! - Cookie-based session auth works for protected endpoints
|
|
//! - Pre-existing conversations are not overwritten
|
|
|
|
#[cfg(feature = "libsql")]
|
|
mod tests {
|
|
use std::sync::Arc;
|
|
|
|
use ironclaw::agent::SessionManager;
|
|
use ironclaw::channels::web::auth::{MultiAuthState, UserIdentity};
|
|
use ironclaw::channels::web::platform::router::start_server;
|
|
use ironclaw::channels::web::platform::state::{GatewayState, PerUserRateLimiter, RateLimiter};
|
|
use ironclaw::channels::web::sse::SseManager;
|
|
use ironclaw::channels::web::ws::WsConnectionTracker;
|
|
use ironclaw::db::Database;
|
|
use ironclaw::workspace::GREETING_SEED;
|
|
|
|
const ALICE_TOKEN: &str = "tok-alice-greeting-test";
|
|
const BOB_TOKEN: &str = "tok-bob-greeting-test";
|
|
|
|
async fn create_test_db() -> (Arc<dyn Database>, tempfile::TempDir) {
|
|
use ironclaw::db::libsql::LibSqlBackend;
|
|
|
|
let temp_dir = tempfile::tempdir().expect("tempdir");
|
|
let db_path = temp_dir.path().join("greeting_test.db");
|
|
let backend = LibSqlBackend::new_local(&db_path)
|
|
.await
|
|
.expect("LibSqlBackend");
|
|
backend.run_migrations().await.expect("migrations");
|
|
(Arc::new(backend) as Arc<dyn Database>, temp_dir)
|
|
}
|
|
|
|
fn auth_state(tokens: Vec<(&str, &str)>) -> MultiAuthState {
|
|
let mut map = std::collections::HashMap::new();
|
|
for (token, user_id) in tokens {
|
|
map.insert(
|
|
token.to_string(),
|
|
UserIdentity {
|
|
user_id: user_id.to_string(),
|
|
role: "admin".to_string(),
|
|
workspace_read_scopes: Vec::new(),
|
|
},
|
|
);
|
|
}
|
|
MultiAuthState::multi(map)
|
|
}
|
|
|
|
async fn start_test_server(
|
|
db: Arc<dyn Database>,
|
|
auth: MultiAuthState,
|
|
) -> std::net::SocketAddr {
|
|
let (agent_tx, _agent_rx) = tokio::sync::mpsc::channel(64);
|
|
let session_manager = Arc::new(SessionManager::new());
|
|
|
|
let state = Arc::new(GatewayState {
|
|
msg_tx: tokio::sync::RwLock::new(Some(agent_tx)),
|
|
sse: Arc::new(SseManager::new()),
|
|
workspace: None,
|
|
workspace_pool: None,
|
|
session_manager: Some(session_manager),
|
|
log_broadcaster: None,
|
|
log_level_handle: None,
|
|
extension_manager: None,
|
|
tool_registry: None,
|
|
store: Some(db),
|
|
settings_cache: None,
|
|
job_manager: None,
|
|
prompt_queue: None,
|
|
scheduler: None,
|
|
owner_id: "test-owner".to_string(),
|
|
shutdown_tx: tokio::sync::RwLock::new(None),
|
|
ws_tracker: Some(Arc::new(WsConnectionTracker::new())),
|
|
llm_provider: None,
|
|
llm_reload: None,
|
|
llm_session_manager: None,
|
|
config_toml_path: None,
|
|
skill_registry: None,
|
|
skill_catalog: None,
|
|
auth_manager: None,
|
|
chat_rate_limiter: PerUserRateLimiter::new(30, 60),
|
|
oauth_rate_limiter: PerUserRateLimiter::new(20, 60),
|
|
webhook_rate_limiter: RateLimiter::new(10, 60),
|
|
registry_entries: Vec::new(),
|
|
cost_guard: None,
|
|
routine_engine: Arc::new(tokio::sync::RwLock::new(None)),
|
|
startup_time: std::time::Instant::now(),
|
|
active_config: Arc::new(tokio::sync::RwLock::new(Default::default())),
|
|
secrets_store: None,
|
|
db_auth: None,
|
|
pairing_store: None,
|
|
oauth_providers: None,
|
|
oauth_state_store: None,
|
|
oauth_base_url: None,
|
|
oauth_allowed_domains: Vec::new(),
|
|
near_nonce_store: None,
|
|
near_rpc_url: None,
|
|
near_network: None,
|
|
oauth_sweep_shutdown: None,
|
|
frontend_html_cache: std::sync::Arc::new(tokio::sync::RwLock::new(None)),
|
|
tool_dispatcher: None,
|
|
});
|
|
|
|
let addr: std::net::SocketAddr = "127.0.0.1:0".parse().unwrap();
|
|
start_server(addr, state, auth.into())
|
|
.await
|
|
.expect("start server")
|
|
}
|
|
|
|
fn client() -> reqwest::Client {
|
|
reqwest::Client::builder()
|
|
.timeout(std::time::Duration::from_secs(5))
|
|
.build()
|
|
.unwrap()
|
|
}
|
|
|
|
async fn create_user(db: &Arc<dyn Database>, user_id: &str) {
|
|
let now = chrono::Utc::now();
|
|
db.create_user(&ironclaw::db::UserRecord {
|
|
id: user_id.to_string(),
|
|
email: Some(format!("{user_id}@example.com")),
|
|
display_name: user_id.to_string(),
|
|
status: "active".to_string(),
|
|
role: "member".to_string(),
|
|
created_at: now,
|
|
updated_at: now,
|
|
last_login_at: None,
|
|
created_by: None,
|
|
metadata: serde_json::json!({}),
|
|
})
|
|
.await
|
|
.expect("create user");
|
|
}
|
|
|
|
/// Helper: call /api/chat/threads and return the JSON response.
|
|
async fn get_threads(
|
|
client: &reqwest::Client,
|
|
addr: std::net::SocketAddr,
|
|
token: &str,
|
|
) -> serde_json::Value {
|
|
let resp = client
|
|
.get(format!("http://{addr}/api/chat/threads"))
|
|
.bearer_auth(token)
|
|
.send()
|
|
.await
|
|
.expect("threads request");
|
|
assert_eq!(resp.status(), 200);
|
|
resp.json().await.expect("parse threads JSON")
|
|
}
|
|
|
|
/// Helper: get messages for a conversation via /api/chat/history.
|
|
async fn get_history(
|
|
client: &reqwest::Client,
|
|
addr: std::net::SocketAddr,
|
|
token: &str,
|
|
thread_id: &str,
|
|
) -> serde_json::Value {
|
|
let resp = client
|
|
.get(format!(
|
|
"http://{addr}/api/chat/history?thread_id={thread_id}"
|
|
))
|
|
.bearer_auth(token)
|
|
.send()
|
|
.await
|
|
.expect("history request");
|
|
assert_eq!(resp.status(), 200);
|
|
resp.json().await.expect("parse history JSON")
|
|
}
|
|
|
|
// ── Tests ────────────────────────────────────────────────────────────
|
|
|
|
#[tokio::test]
|
|
async fn test_fresh_user_gets_single_initial_assistant_greeting() {
|
|
let (db, _dir) = create_test_db().await;
|
|
create_user(&db, "alice").await;
|
|
let auth = auth_state(vec![(ALICE_TOKEN, "alice")]);
|
|
let addr = start_test_server(db, auth).await;
|
|
let c = client();
|
|
|
|
// First call should load the already-provisioned assistant thread.
|
|
let threads1 = get_threads(&c, addr, ALICE_TOKEN).await;
|
|
let assistant1 = threads1["assistant_thread"]
|
|
.as_object()
|
|
.expect("assistant thread");
|
|
let thread_id = assistant1["id"].as_str().expect("thread id");
|
|
|
|
// A fresh provisioned user should have exactly one greeting turn.
|
|
let history = get_history(&c, addr, ALICE_TOKEN, thread_id).await;
|
|
let turns = history["turns"].as_array().expect("turns array");
|
|
assert_eq!(
|
|
turns.len(),
|
|
1,
|
|
"fresh assistant thread should have one greeting"
|
|
);
|
|
assert_eq!(turns[0]["response"].as_str(), Some(GREETING_SEED));
|
|
|
|
// Second call should remain a pure read.
|
|
let _threads2 = get_threads(&c, addr, ALICE_TOKEN).await;
|
|
let history2 = get_history(&c, addr, ALICE_TOKEN, thread_id).await;
|
|
let turns2 = history2["turns"].as_array().expect("turns array");
|
|
assert_eq!(
|
|
turns2.len(),
|
|
1,
|
|
"second call should not duplicate the greeting"
|
|
);
|
|
assert_eq!(turns2[0]["response"].as_str(), Some(GREETING_SEED));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_threads_listing_does_not_duplicate_greeting_on_rapid_calls() {
|
|
let (db, _dir) = create_test_db().await;
|
|
create_user(&db, "alice-rapid").await;
|
|
let auth = auth_state(vec![(ALICE_TOKEN, "alice-rapid")]);
|
|
let addr = start_test_server(db, auth).await;
|
|
let c = client();
|
|
|
|
// Fire 5 concurrent requests.
|
|
let mut handles = Vec::new();
|
|
for _ in 0..5 {
|
|
let c2 = c.clone();
|
|
let addr2 = addr;
|
|
handles.push(tokio::spawn(async move {
|
|
get_threads(&c2, addr2, ALICE_TOKEN).await
|
|
}));
|
|
}
|
|
for h in handles {
|
|
h.await.expect("join");
|
|
}
|
|
|
|
// Check that the assistant thread still has exactly the original greeting.
|
|
let threads = get_threads(&c, addr, ALICE_TOKEN).await;
|
|
let thread_id = threads["assistant_thread"]["id"]
|
|
.as_str()
|
|
.expect("thread id");
|
|
let history = get_history(&c, addr, ALICE_TOKEN, thread_id).await;
|
|
let turns = history["turns"].as_array().expect("turns");
|
|
assert_eq!(
|
|
turns.len(),
|
|
1,
|
|
"concurrent calls should not duplicate the assistant greeting"
|
|
);
|
|
assert_eq!(turns[0]["response"].as_str(), Some(GREETING_SEED));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_each_user_gets_own_single_assistant_greeting() {
|
|
let (db, _dir) = create_test_db().await;
|
|
create_user(&db, "alice-multi").await;
|
|
create_user(&db, "bob-multi").await;
|
|
let auth = auth_state(vec![(ALICE_TOKEN, "alice-multi"), (BOB_TOKEN, "bob-multi")]);
|
|
let addr = start_test_server(db, auth).await;
|
|
let c = client();
|
|
|
|
// Alice's first request.
|
|
let alice_threads = get_threads(&c, addr, ALICE_TOKEN).await;
|
|
let alice_id = alice_threads["assistant_thread"]["id"]
|
|
.as_str()
|
|
.expect("alice thread id");
|
|
|
|
// Bob's first request.
|
|
let bob_threads = get_threads(&c, addr, BOB_TOKEN).await;
|
|
let bob_id = bob_threads["assistant_thread"]["id"]
|
|
.as_str()
|
|
.expect("bob thread id");
|
|
|
|
// Different thread IDs.
|
|
assert_ne!(
|
|
alice_id, bob_id,
|
|
"each user should have their own assistant thread"
|
|
);
|
|
|
|
// Both threads have the single greeting created at provisioning time.
|
|
let alice_history = get_history(&c, addr, ALICE_TOKEN, alice_id).await;
|
|
let bob_history = get_history(&c, addr, BOB_TOKEN, bob_id).await;
|
|
|
|
assert_eq!(
|
|
alice_history["turns"].as_array().unwrap().len(),
|
|
1,
|
|
"alice should start with exactly one assistant greeting"
|
|
);
|
|
assert_eq!(
|
|
bob_history["turns"].as_array().unwrap().len(),
|
|
1,
|
|
"bob should start with exactly one assistant greeting"
|
|
);
|
|
assert_eq!(
|
|
alice_history["turns"][0]["response"].as_str(),
|
|
Some(GREETING_SEED)
|
|
);
|
|
assert_eq!(
|
|
bob_history["turns"][0]["response"].as_str(),
|
|
Some(GREETING_SEED)
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_cookie_auth_works_for_threads() {
|
|
let (db, _dir) = create_test_db().await;
|
|
create_user(&db, "alice-cookie").await;
|
|
let auth = auth_state(vec![(ALICE_TOKEN, "alice-cookie")]);
|
|
let addr = start_test_server(db, auth).await;
|
|
|
|
// Use a cookie instead of Bearer token.
|
|
let c = reqwest::Client::builder()
|
|
.timeout(std::time::Duration::from_secs(5))
|
|
.build()
|
|
.unwrap();
|
|
|
|
let resp = c
|
|
.get(format!("http://{addr}/api/chat/threads"))
|
|
.header("Cookie", format!("ironclaw_session={ALICE_TOKEN}"))
|
|
.send()
|
|
.await
|
|
.expect("cookie auth request");
|
|
|
|
assert_eq!(resp.status(), 200);
|
|
let body: serde_json::Value = resp.json().await.expect("parse");
|
|
assert!(
|
|
body["assistant_thread"].is_object(),
|
|
"should have assistant thread via cookie auth"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_existing_conversation_is_preserved() {
|
|
let (db, _dir) = create_test_db().await;
|
|
create_user(&db, "alice-existing").await;
|
|
let auth = auth_state(vec![(ALICE_TOKEN, "alice-existing")]);
|
|
let addr = start_test_server(Arc::clone(&db), auth).await;
|
|
let c = client();
|
|
|
|
// Pre-populate the assistant conversation with a user message after the
|
|
// initial greeting was provisioned.
|
|
let conv_id = db
|
|
.get_or_create_assistant_conversation("alice-existing", "gateway")
|
|
.await
|
|
.expect("create conv");
|
|
db.add_conversation_message(conv_id, "user", "Hello!")
|
|
.await
|
|
.expect("add message");
|
|
|
|
// Now call /api/chat/threads — should leave the existing conversation untouched.
|
|
let threads = get_threads(&c, addr, ALICE_TOKEN).await;
|
|
let thread_id = threads["assistant_thread"]["id"]
|
|
.as_str()
|
|
.expect("thread id");
|
|
|
|
let history = get_history(&c, addr, ALICE_TOKEN, thread_id).await;
|
|
let turns = history["turns"].as_array().expect("turns");
|
|
assert_eq!(
|
|
turns.len(),
|
|
2,
|
|
"should preserve the greeting and the pre-existing message"
|
|
);
|
|
assert_eq!(turns[0]["response"].as_str(), Some(GREETING_SEED));
|
|
// A standalone user message with no assistant response shows as user_input.
|
|
let user_input = turns[1]["user_input"].as_str().unwrap_or("");
|
|
assert_eq!(user_input, "Hello!", "should be the original message");
|
|
}
|
|
}
|