mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-25 14:53:34 +00:00
* fix: restore owner-scoped gateway startup * fix: split gateway owner and sender scope * fix: keep multi-user gateway sender identity * test: cover gateway sender scope regression * test: harden e2e startup teardown race * fix: align gateway owner scope across auth modes
834 lines
31 KiB
Rust
834 lines
31 KiB
Rust
//! 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<Arc<dyn crate::db::Database>>,
|
|
prompt_queue: Option<PromptQueue>,
|
|
) -> Arc<GatewayState> {
|
|
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<dyn crate::db::Database>, 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<dyn crate::db::Database>, 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<GatewayState>, 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<GatewayState>, 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);
|
|
}
|
|
}
|