//! Multi-tenant isolation tests for the web gateway. //! //! Tests cover workspace pool scoping, job handler isolation, and auth //! enforcement on protected endpoints. Uses `LibSqlBackend::new_local()` //! with a temporary directory for a real (but ephemeral) database. use std::collections::HashMap; use std::sync::Arc; use std::time::Duration; use axum::Router; use axum::body::Body; use axum::http::{Method, Request, StatusCode}; use axum::middleware; use axum::routing::{delete, get, post}; use tower::ServiceExt; use uuid::Uuid; use crate::channels::web::GatewayChannel; use crate::channels::web::auth::{ AuthenticatedUser, MultiAuthState, UserIdentity, auth_middleware, }; use crate::channels::web::server::{ ActiveConfigSnapshot, GatewayState, PerUserRateLimiter, PromptQueue, RateLimiter, WorkspacePool, }; use crate::channels::web::sse::SseManager; use crate::config::GatewayConfig; // ── Helpers ──────────────────────────────────────────────────────────── /// Create a two-user `MultiAuthState` for alice and bob. fn two_user_auth() -> MultiAuthState { let mut tokens = HashMap::new(); tokens.insert( "tok-alice".to_string(), UserIdentity { user_id: "alice".to_string(), workspace_read_scopes: vec!["shared".to_string()], }, ); tokens.insert( "tok-bob".to_string(), UserIdentity { user_id: "bob".to_string(), workspace_read_scopes: vec!["shared".to_string(), "alice".to_string()], }, ); MultiAuthState::multi(tokens) } /// Build a `GatewayState` with configurable store and prompt queue. fn build_state( store: Option>, prompt_queue: Option, ) -> Arc { Arc::new(GatewayState { msg_tx: tokio::sync::RwLock::new(None), sse: Arc::new(SseManager::new()), workspace: None, workspace_pool: None, session_manager: None, log_broadcaster: None, log_level_handle: None, extension_manager: None, tool_registry: None, store, job_manager: None, prompt_queue, owner_id: "test".to_string(), default_sender_id: "test".to_string(), shutdown_tx: tokio::sync::RwLock::new(None), ws_tracker: None, llm_provider: None, skill_registry: None, skill_catalog: None, scheduler: None, chat_rate_limiter: PerUserRateLimiter::new(30, 60), oauth_rate_limiter: RateLimiter::new(10, 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: ActiveConfigSnapshot::default(), }) } fn gateway_config() -> GatewayConfig { GatewayConfig { host: "127.0.0.1".to_string(), port: 3000, auth_token: Some("gateway-auth".to_string()), user_id: "gateway-sender".to_string(), workspace_read_scopes: Vec::new(), memory_layers: Vec::new(), user_tokens: None, } } #[test] fn with_owner_scope_updates_gateway_owner_scope_in_multi_user_mode() { let mut gateway = GatewayChannel::new(gateway_config()); gateway.auth = two_user_auth(); gateway.config.user_tokens = Some(HashMap::new()); let gateway = gateway.with_owner_scope("owner-scope"); assert_eq!(gateway.state.owner_id, "owner-scope"); assert_eq!(gateway.state.default_sender_id, "gateway-sender"); let alice = gateway .auth .authenticate("tok-alice") .expect("alice token should remain valid"); let bob = gateway .auth .authenticate("tok-bob") .expect("bob token should remain valid"); assert_eq!(alice.user_id, "alice"); assert_eq!(bob.user_id, "bob"); } /// Create a libSQL-backed test database in a temporary directory. /// /// Returns the database and a `TempDir` guard — the database file is /// deleted when the guard is dropped. #[cfg(feature = "libsql")] async fn test_db() -> (Arc, tempfile::TempDir) { use crate::db::Database; let dir = tempfile::tempdir().expect("failed to create temp dir"); // safety: test-only let path = dir.path().join("test.db"); let backend = crate::db::libsql::LibSqlBackend::new_local(&path) .await .expect("failed to create test LibSqlBackend"); // safety: test-only backend .run_migrations() .await .expect("failed to run migrations"); // safety: test-only (Arc::new(backend) as Arc, dir) } /// Build a minimal Routine for testing. fn make_routine(user_id: &str, name: &str) -> crate::agent::routine::Routine { let now = chrono::Utc::now(); crate::agent::routine::Routine { id: Uuid::new_v4(), name: name.to_string(), description: format!("Test routine: {name}"), user_id: user_id.to_string(), enabled: true, trigger: crate::agent::routine::Trigger::Cron { schedule: "0 9 * * *".to_string(), timezone: None, }, action: crate::agent::routine::RoutineAction::Lightweight { prompt: "hello".to_string(), context_paths: vec![], max_tokens: 1024, use_tools: false, max_tool_rounds: 3, }, guardrails: crate::agent::routine::RoutineGuardrails { cooldown: Duration::from_secs(60), max_concurrent: 1, dedup_window: None, }, notify: crate::agent::routine::NotifyConfig { channel: None, user: None, on_success: false, on_failure: true, on_attention: true, }, last_run_at: None, next_fire_at: None, run_count: 0, consecutive_failures: 0, state: serde_json::json!({}), created_at: now, updated_at: now, } } /// Build a minimal SandboxJobRecord for testing. fn make_sandbox_job(user_id: &str, task: &str) -> crate::history::SandboxJobRecord { let now = chrono::Utc::now(); crate::history::SandboxJobRecord { id: Uuid::new_v4(), task: task.to_string(), status: "completed".to_string(), user_id: user_id.to_string(), project_dir: format!("/tmp/test-{}", Uuid::new_v4()), success: Some(true), failure_reason: None, created_at: now, started_at: Some(now), completed_at: Some(now), credential_grants_json: "[]".to_string(), } } // ═══════════════════════════════════════════════════════════════════════ // WorkspacePool Tests // ═══════════════════════════════════════════════════════════════════════ #[cfg(feature = "libsql")] mod workspace_pool { use super::*; use crate::config::{WorkspaceConfig, WorkspaceSearchConfig}; use crate::workspace::EmbeddingCacheConfig; use crate::workspace::layer::MemoryLayer; #[tokio::test] async fn test_workspace_pool_applies_search_config() { let (db, _dir) = test_db().await; let search_config = WorkspaceSearchConfig { rrf_k: 42, ..Default::default() }; let pool = WorkspacePool::new( db, None, EmbeddingCacheConfig::default(), search_config, WorkspaceConfig::default(), ); let identity = UserIdentity { user_id: "alice".to_string(), workspace_read_scopes: vec![], }; let ws = pool.get_or_create(&identity).await; assert_eq!(ws.user_id(), "alice"); } #[tokio::test] async fn test_workspace_pool_applies_memory_layers() { let (db, _dir) = test_db().await; let layers = vec![MemoryLayer { name: "shared-layer".to_string(), scope: "shared".to_string(), writable: false, sensitivity: Default::default(), }]; let ws_config = WorkspaceConfig { memory_layers: layers, read_scopes: vec![], }; let pool = WorkspacePool::new( db, None, EmbeddingCacheConfig::default(), WorkspaceSearchConfig::default(), ws_config, ); let identity = UserIdentity { user_id: "alice".to_string(), workspace_read_scopes: vec![], }; let ws = pool.get_or_create(&identity).await; // Memory layer scope "shared" should appear in read_user_ids. assert!( ws.read_user_ids().contains(&"shared".to_string()), "expected 'shared' in read_user_ids, got {:?}", ws.read_user_ids() ); } #[tokio::test] async fn test_workspace_pool_applies_identity_read_scopes() { let (db, _dir) = test_db().await; let pool = WorkspacePool::new( db, None, EmbeddingCacheConfig::default(), WorkspaceSearchConfig::default(), WorkspaceConfig::default(), ); let identity = UserIdentity { user_id: "bob".to_string(), workspace_read_scopes: vec!["alice".to_string(), "shared".to_string()], }; let ws = pool.get_or_create(&identity).await; assert_eq!(ws.user_id(), "bob"); assert!( ws.read_user_ids().contains(&"alice".to_string()), "expected 'alice' in read_user_ids from identity scopes" ); assert!( ws.read_user_ids().contains(&"shared".to_string()), "expected 'shared' in read_user_ids from identity scopes" ); } #[tokio::test] async fn test_workspace_pool_caches_per_user() { let (db, _dir) = test_db().await; let pool = WorkspacePool::new( db, None, EmbeddingCacheConfig::default(), WorkspaceSearchConfig::default(), WorkspaceConfig::default(), ); let alice_id = UserIdentity { user_id: "alice".to_string(), workspace_read_scopes: vec![], }; let bob_id = UserIdentity { user_id: "bob".to_string(), workspace_read_scopes: vec![], }; let alice_ws1 = pool.get_or_create(&alice_id).await; let alice_ws2 = pool.get_or_create(&alice_id).await; let bob_ws = pool.get_or_create(&bob_id).await; // Same user gets the same Arc. assert!(Arc::ptr_eq(&alice_ws1, &alice_ws2)); // Different users get different instances. assert!(!Arc::ptr_eq(&alice_ws1, &bob_ws)); assert_eq!(alice_ws1.user_id(), "alice"); assert_eq!(bob_ws.user_id(), "bob"); } #[tokio::test] async fn test_workspace_pool_combines_global_and_identity_scopes() { let (db, _dir) = test_db().await; let ws_config = WorkspaceConfig { memory_layers: vec![], read_scopes: vec!["global-shared".to_string()], }; let pool = WorkspacePool::new( db, None, EmbeddingCacheConfig::default(), WorkspaceSearchConfig::default(), ws_config, ); let identity = UserIdentity { user_id: "alice".to_string(), workspace_read_scopes: vec!["token-scope".to_string()], }; let ws = pool.get_or_create(&identity).await; let scopes = ws.read_user_ids(); // Primary scope assert!(scopes.contains(&"alice".to_string())); // Global config scope assert!( scopes.contains(&"global-shared".to_string()), "expected global scope 'global-shared', got {:?}", scopes ); // Token identity scope assert!( scopes.contains(&"token-scope".to_string()), "expected token scope 'token-scope', got {:?}", scopes ); } } // ═══════════════════════════════════════════════════════════════════════ // Jobs Handler Isolation Tests // ═══════════════════════════════════════════════════════════════════════ #[cfg(feature = "libsql")] mod jobs_isolation { use super::*; use crate::channels::web::handlers::jobs::{ jobs_cancel_handler, jobs_prompt_handler, jobs_restart_handler, jobs_summary_handler, }; // SandboxStore methods are accessed through the Database supertrait. /// Build a router with job endpoints behind multi-user auth. fn jobs_router(state: Arc, auth: MultiAuthState) -> Router { Router::new() .route("/api/jobs/summary", get(jobs_summary_handler)) .route("/api/jobs/{id}/cancel", post(jobs_cancel_handler)) .route("/api/jobs/{id}/restart", post(jobs_restart_handler)) .route("/api/jobs/{id}/prompt", post(jobs_prompt_handler)) .layer(middleware::from_fn_with_state(auth, auth_middleware)) .with_state(state) } #[tokio::test] async fn test_jobs_summary_scoped_to_user() { let (db, _dir) = test_db().await; // Insert sandbox jobs for alice and bob. let alice_job = make_sandbox_job("alice", "alice task"); let bob_job = make_sandbox_job("bob", "bob task"); db.save_sandbox_job(&alice_job).await.unwrap(); db.save_sandbox_job(&bob_job).await.unwrap(); let state = build_state(Some(db), None); let auth = two_user_auth(); let app = jobs_router(state, auth); // Alice should see 1 job. let req = Request::builder() .uri("/api/jobs/summary") .header("Authorization", "Bearer tok-alice") .body(Body::empty()) .unwrap(); let resp = app.clone().oneshot(req).await.unwrap(); assert_eq!(resp.status(), StatusCode::OK); let body: serde_json::Value = serde_json::from_slice(&axum::body::to_bytes(resp.into_body(), 4096).await.unwrap()) .unwrap(); assert_eq!(body["total"], 1, "alice should see only her own jobs"); // Bob should see 1 job. let req = Request::builder() .uri("/api/jobs/summary") .header("Authorization", "Bearer tok-bob") .body(Body::empty()) .unwrap(); let resp = app.oneshot(req).await.unwrap(); assert_eq!(resp.status(), StatusCode::OK); let body: serde_json::Value = serde_json::from_slice(&axum::body::to_bytes(resp.into_body(), 4096).await.unwrap()) .unwrap(); assert_eq!(body["total"], 1, "bob should see only his own jobs"); } #[tokio::test] async fn test_jobs_restart_rejects_other_user() { let (db, _dir) = test_db().await; // Insert a failed sandbox job owned by alice. let mut alice_job = make_sandbox_job("alice", "alice task"); alice_job.status = "failed".to_string(); alice_job.success = Some(false); db.save_sandbox_job(&alice_job).await.unwrap(); let state = build_state(Some(db), None); let auth = two_user_auth(); let app = jobs_router(state, auth); // Bob tries to restart alice's job. let req = Request::builder() .method(Method::POST) .uri(format!("/api/jobs/{}/restart", alice_job.id)) .header("Authorization", "Bearer tok-bob") .body(Body::empty()) .unwrap(); let resp = app.oneshot(req).await.unwrap(); assert_eq!( resp.status(), StatusCode::NOT_FOUND, "bob should not be able to restart alice's job" ); } #[tokio::test] async fn test_jobs_prompt_works_for_agent_jobs() { let (db, _dir) = test_db().await; // Insert a running sandbox job owned by alice in claude_code mode. let mut alice_job = make_sandbox_job("alice", "prompt test"); alice_job.status = "running".to_string(); alice_job.success = None; alice_job.completed_at = None; db.save_sandbox_job(&alice_job).await.unwrap(); db.update_sandbox_job_mode(alice_job.id, "claude_code") .await .unwrap(); let prompt_queue: PromptQueue = Arc::new(tokio::sync::Mutex::new(std::collections::HashMap::new())); let state = build_state(Some(db), Some(prompt_queue.clone())); let auth = two_user_auth(); let app = jobs_router(state, auth); // Alice prompts her own job. let req = Request::builder() .method(Method::POST) .uri(format!("/api/jobs/{}/prompt", alice_job.id)) .header("Authorization", "Bearer tok-alice") .header("Content-Type", "application/json") .body(Body::from( serde_json::to_string(&serde_json::json!({"content": "hello"})).unwrap(), )) .unwrap(); let resp = app.oneshot(req).await.unwrap(); assert_eq!( resp.status(), StatusCode::OK, "alice should be able to prompt her own job" ); // Verify prompt was enqueued. let queue = prompt_queue.lock().await; assert!( queue.contains_key(&alice_job.id), "prompt queue should contain alice's job" ); } #[tokio::test] async fn test_jobs_prompt_rejects_other_user() { let (db, _dir) = test_db().await; let mut alice_job = make_sandbox_job("alice", "alice task"); alice_job.status = "running".to_string(); alice_job.success = None; alice_job.completed_at = None; db.save_sandbox_job(&alice_job).await.unwrap(); db.update_sandbox_job_mode(alice_job.id, "claude_code") .await .unwrap(); let prompt_queue: PromptQueue = Arc::new(tokio::sync::Mutex::new(std::collections::HashMap::new())); let state = build_state(Some(db), Some(prompt_queue)); let auth = two_user_auth(); let app = jobs_router(state, auth); // Bob tries to prompt alice's job. let req = Request::builder() .method(Method::POST) .uri(format!("/api/jobs/{}/prompt", alice_job.id)) .header("Authorization", "Bearer tok-bob") .header("Content-Type", "application/json") .body(Body::from( serde_json::to_string(&serde_json::json!({"content": "sneaky"})).unwrap(), )) .unwrap(); let resp = app.oneshot(req).await.unwrap(); assert_eq!( resp.status(), StatusCode::NOT_FOUND, "bob should not be able to prompt alice's job" ); } #[tokio::test] async fn test_jobs_cancel_rejects_other_user() { let (db, _dir) = test_db().await; let mut alice_job = make_sandbox_job("alice", "alice running"); alice_job.status = "running".to_string(); alice_job.success = None; alice_job.completed_at = None; db.save_sandbox_job(&alice_job).await.unwrap(); let state = build_state(Some(db), None); let auth = two_user_auth(); let app = jobs_router(state, auth); // Bob tries to cancel alice's job. let req = Request::builder() .method(Method::POST) .uri(format!("/api/jobs/{}/cancel", alice_job.id)) .header("Authorization", "Bearer tok-bob") .body(Body::empty()) .unwrap(); let resp = app.oneshot(req).await.unwrap(); assert_eq!( resp.status(), StatusCode::NOT_FOUND, "bob should not be able to cancel alice's job" ); } } // ═══════════════════════════════════════════════════════════════════════ // Routines Isolation Tests // ═══════════════════════════════════════════════════════════════════════ #[cfg(feature = "libsql")] mod routines_isolation { use super::*; use crate::channels::web::handlers::routines::{ routines_delete_handler, routines_detail_handler, routines_list_handler, routines_summary_handler, routines_toggle_handler, }; // RoutineStore methods are accessed through the Database supertrait. fn routines_router(state: Arc, auth: MultiAuthState) -> Router { Router::new() .route("/api/routines", get(routines_list_handler)) .route("/api/routines/summary", get(routines_summary_handler)) .route("/api/routines/{id}", get(routines_detail_handler)) .route("/api/routines/{id}/toggle", post(routines_toggle_handler)) .route("/api/routines/{id}", delete(routines_delete_handler)) .layer(middleware::from_fn_with_state(auth, auth_middleware)) .with_state(state) } #[tokio::test] async fn test_routines_isolation() { let (db, _dir) = test_db().await; // Create routines for alice and bob. let alice_routine = make_routine("alice", "alice-daily"); let bob_routine = make_routine("bob", "bob-daily"); db.create_routine(&alice_routine).await.unwrap(); db.create_routine(&bob_routine).await.unwrap(); let state = build_state(Some(db), None); let auth = two_user_auth(); let app = routines_router(state, auth); // Alice sees only her routine in the list. let req = Request::builder() .uri("/api/routines") .header("Authorization", "Bearer tok-alice") .body(Body::empty()) .unwrap(); let resp = app.clone().oneshot(req).await.unwrap(); assert_eq!(resp.status(), StatusCode::OK); let body: serde_json::Value = serde_json::from_slice(&axum::body::to_bytes(resp.into_body(), 8192).await.unwrap()) .unwrap(); let routines = body["routines"].as_array().unwrap(); assert_eq!(routines.len(), 1, "alice should see only her routines"); assert_eq!(routines[0]["name"], "alice-daily"); // Bob sees only his routine. let req = Request::builder() .uri("/api/routines") .header("Authorization", "Bearer tok-bob") .body(Body::empty()) .unwrap(); let resp = app.clone().oneshot(req).await.unwrap(); assert_eq!(resp.status(), StatusCode::OK); let body: serde_json::Value = serde_json::from_slice(&axum::body::to_bytes(resp.into_body(), 8192).await.unwrap()) .unwrap(); let routines = body["routines"].as_array().unwrap(); assert_eq!(routines.len(), 1, "bob should see only his routines"); assert_eq!(routines[0]["name"], "bob-daily"); // Bob cannot view alice's routine detail. let req = Request::builder() .uri(format!("/api/routines/{}", alice_routine.id)) .header("Authorization", "Bearer tok-bob") .body(Body::empty()) .unwrap(); let resp = app.clone().oneshot(req).await.unwrap(); assert_eq!( resp.status(), StatusCode::NOT_FOUND, "bob should not see alice's routine detail" ); // Bob cannot toggle alice's routine. let req = Request::builder() .method(Method::POST) .uri(format!("/api/routines/{}/toggle", alice_routine.id)) .header("Authorization", "Bearer tok-bob") .body(Body::empty()) .unwrap(); let resp = app.clone().oneshot(req).await.unwrap(); assert_eq!( resp.status(), StatusCode::NOT_FOUND, "bob should not toggle alice's routine" ); // Bob cannot delete alice's routine. let req = Request::builder() .method(Method::DELETE) .uri(format!("/api/routines/{}", alice_routine.id)) .header("Authorization", "Bearer tok-bob") .body(Body::empty()) .unwrap(); let resp = app.oneshot(req).await.unwrap(); assert_eq!( resp.status(), StatusCode::NOT_FOUND, "bob should not delete alice's routine" ); } } // ═══════════════════════════════════════════════════════════════════════ // Handler Auth Enforcement Tests // ═══════════════════════════════════════════════════════════════════════ mod auth_enforcement { use super::*; /// Dummy handler that extracts `AuthenticatedUser` — if the auth middleware /// rejects the request, this handler is never reached. async fn authed_handler(AuthenticatedUser(_user): AuthenticatedUser) -> &'static str { "ok" } /// Build a router with the real auth middleware and dummy handlers at all /// the paths we want to verify require authentication. fn auth_test_router(auth: MultiAuthState) -> Router { let state = build_state(None, None); Router::new() // Routines .route("/api/routines", get(authed_handler)) .route("/api/routines/summary", get(authed_handler)) .route("/api/routines/{id}", get(authed_handler)) .route("/api/routines/{id}/toggle", post(authed_handler)) .route("/api/routines/{id}", delete(authed_handler)) // Skills .route("/api/skills", get(authed_handler)) .route("/api/skills/search", post(authed_handler)) .route("/api/skills/install", post(authed_handler)) .route("/api/skills/{name}", delete(authed_handler)) // Logs .route("/api/logs/events", get(authed_handler)) .route("/api/logs/level", get(authed_handler).put(authed_handler)) // Gateway status .route("/api/gateway/status", get(authed_handler)) .layer(middleware::from_fn_with_state(auth, auth_middleware)) .with_state(state) } /// Send a request without auth and assert it returns UNAUTHORIZED. async fn assert_requires_auth(app: &Router, method: Method, uri: &str) { let req = Request::builder() .method(method.clone()) .uri(uri) .body(Body::empty()) .unwrap(); let resp = app.clone().oneshot(req).await.unwrap(); assert_eq!( resp.status(), StatusCode::UNAUTHORIZED, "{} {} should require auth", method, uri ); } /// Send a request with a valid token and assert it succeeds. async fn assert_passes_with_token(app: &Router, method: Method, uri: &str, token: &str) { let req = Request::builder() .method(method.clone()) .uri(uri) .header("Authorization", format!("Bearer {token}")) .body(Body::empty()) .unwrap(); let resp = app.clone().oneshot(req).await.unwrap(); assert_eq!( resp.status(), StatusCode::OK, "{} {} should pass with valid token", method, uri ); } #[tokio::test] async fn test_routines_handlers_require_auth() { let auth = MultiAuthState::single("secret-tok".to_string(), "user".to_string()); let app = auth_test_router(auth); let id = Uuid::new_v4(); assert_requires_auth(&app, Method::GET, "/api/routines").await; assert_requires_auth(&app, Method::GET, "/api/routines/summary").await; assert_requires_auth(&app, Method::GET, &format!("/api/routines/{id}")).await; assert_requires_auth(&app, Method::POST, &format!("/api/routines/{id}/toggle")).await; assert_requires_auth(&app, Method::DELETE, &format!("/api/routines/{id}")).await; } #[tokio::test] async fn test_skills_handlers_require_auth() { let auth = MultiAuthState::single("secret-tok".to_string(), "user".to_string()); let app = auth_test_router(auth); assert_requires_auth(&app, Method::GET, "/api/skills").await; assert_requires_auth(&app, Method::POST, "/api/skills/search").await; assert_requires_auth(&app, Method::POST, "/api/skills/install").await; assert_requires_auth(&app, Method::DELETE, "/api/skills/test-skill").await; } #[tokio::test] async fn test_logs_handlers_require_auth() { let auth = MultiAuthState::single("secret-tok".to_string(), "user".to_string()); let app = auth_test_router(auth); assert_requires_auth(&app, Method::GET, "/api/logs/events").await; assert_requires_auth(&app, Method::GET, "/api/logs/level").await; assert_requires_auth(&app, Method::PUT, "/api/logs/level").await; } #[tokio::test] async fn test_gateway_status_requires_auth() { let auth = MultiAuthState::single("secret-tok".to_string(), "user".to_string()); let app = auth_test_router(auth); assert_requires_auth(&app, Method::GET, "/api/gateway/status").await; } #[tokio::test] async fn test_valid_token_passes_all_endpoints() { let auth = MultiAuthState::single("secret-tok".to_string(), "user".to_string()); let app = auth_test_router(auth); let id = Uuid::new_v4(); assert_passes_with_token(&app, Method::GET, "/api/routines", "secret-tok").await; assert_passes_with_token(&app, Method::GET, "/api/skills", "secret-tok").await; assert_passes_with_token(&app, Method::GET, "/api/logs/events", "secret-tok").await; assert_passes_with_token(&app, Method::GET, "/api/gateway/status", "secret-tok").await; assert_passes_with_token( &app, Method::GET, &format!("/api/routines/{id}"), "secret-tok", ) .await; } #[tokio::test] async fn test_wrong_token_rejected_on_all_endpoints() { let auth = MultiAuthState::single("secret-tok".to_string(), "user".to_string()); let app = auth_test_router(auth); // Wrong token should be rejected. let req = Request::builder() .uri("/api/routines") .header("Authorization", "Bearer wrong-tok") .body(Body::empty()) .unwrap(); let resp = app.clone().oneshot(req).await.unwrap(); assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); let req = Request::builder() .uri("/api/gateway/status") .header("Authorization", "Bearer wrong-tok") .body(Body::empty()) .unwrap(); let resp = app.oneshot(req).await.unwrap(); assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); } }