Readur/tests/integration_oidc_tests.rs

450 lines
17 KiB
Rust

mod tests {
use readur::models::{AuthProvider, CreateUser, UserRole};
use axum::http::StatusCode;
use serde_json::json;
use tower::util::ServiceExt;
use wiremock::{matchers::{method, path, query_param, header}, Mock, MockServer, ResponseTemplate};
use std::sync::Arc;
use readur::{AppState, oidc::OidcClient};
use uuid;
async fn create_test_app_simple() -> (axum::Router, ()) {
// Use TEST_DATABASE_URL directly, no containers
let database_url = std::env::var("TEST_DATABASE_URL")
.or_else(|_| std::env::var("DATABASE_URL"))
.unwrap_or_else(|_| "postgresql://readur:readur@localhost:5432/readur".to_string());
let config = readur::config::Config {
database_url: database_url.clone(),
server_address: "127.0.0.1:0".to_string(),
jwt_secret: "test-secret".to_string(),
upload_path: "./test-uploads".to_string(),
watch_folder: "./test-watch".to_string(),
user_watch_base_dir: "./user_watch".to_string(),
enable_per_user_watch: false,
allowed_file_types: vec!["pdf".to_string()],
watch_interval_seconds: Some(30),
file_stability_check_ms: Some(500),
max_file_age_hours: None,
ocr_language: "eng".to_string(),
concurrent_ocr_jobs: 2,
ocr_timeout_seconds: 60,
max_file_size_mb: 10,
memory_limit_mb: 256,
cpu_priority: "normal".to_string(),
oidc_enabled: false,
oidc_client_id: None,
oidc_client_secret: None,
oidc_issuer_url: None,
oidc_redirect_uri: None,
s3_enabled: false,
s3_config: None,
};
let db = readur::db::Database::new(&config.database_url).await.unwrap();
// Retry migration up to 3 times to handle concurrent test execution
for attempt in 1..=3 {
match db.migrate().await {
Ok(_) => break,
Err(e) if attempt < 3 && e.to_string().contains("tuple concurrently updated") => {
// Wait a bit and retry
tokio::time::sleep(tokio::time::Duration::from_millis(100 * attempt)).await;
continue;
}
Err(e) => panic!("Migration failed after {} attempts: {}", attempt, e),
}
}
// Create file service
let storage_config = readur::storage::StorageConfig::Local { upload_path: config.upload_path.clone() };
let storage_backend = readur::storage::factory::create_storage_backend(storage_config).await.unwrap();
let file_service = Arc::new(readur::services::file_service::FileService::with_storage(config.upload_path.clone(), storage_backend));
let app = axum::Router::new()
.nest("/api/auth", readur::routes::auth::router())
.with_state(Arc::new(AppState {
db: db.clone(),
config,
file_service: file_service.clone(),
webdav_scheduler: None,
source_scheduler: None,
queue_service: Arc::new(readur::ocr::queue::OcrQueueService::new(
db.clone(),
db.pool.clone(),
2,
file_service.clone()
)),
oidc_client: None,
sync_progress_tracker: std::sync::Arc::new(readur::services::sync_progress_tracker::SyncProgressTracker::new()),
user_watch_service: None,
webdav_metrics_collector: None,
}));
(app, ())
}
async fn create_test_app_with_oidc() -> (axum::Router, MockServer) {
let mock_server = MockServer::start().await;
// Mock OIDC discovery endpoint
let discovery_response = json!({
"issuer": mock_server.uri(),
"authorization_endpoint": format!("{}/auth", mock_server.uri()),
"token_endpoint": format!("{}/token", mock_server.uri()),
"userinfo_endpoint": format!("{}/userinfo", mock_server.uri())
});
Mock::given(method("GET"))
.and(path("/.well-known/openid-configuration"))
.respond_with(ResponseTemplate::new(200).set_body_json(discovery_response))
.mount(&mock_server)
.await;
// Use TEST_DATABASE_URL directly, no containers
let database_url = std::env::var("TEST_DATABASE_URL")
.or_else(|_| std::env::var("DATABASE_URL"))
.unwrap_or_else(|_| "postgresql://readur:readur@localhost:5432/readur".to_string());
// Update the app state to include OIDC client
let config = readur::config::Config {
database_url: database_url.clone(),
server_address: "127.0.0.1:0".to_string(),
jwt_secret: "test-secret".to_string(),
upload_path: "./test-uploads".to_string(),
watch_folder: "./test-watch".to_string(),
user_watch_base_dir: "./user_watch".to_string(),
enable_per_user_watch: false,
allowed_file_types: vec!["pdf".to_string()],
watch_interval_seconds: Some(30),
file_stability_check_ms: Some(500),
max_file_age_hours: None,
ocr_language: "eng".to_string(),
concurrent_ocr_jobs: 2,
ocr_timeout_seconds: 60,
max_file_size_mb: 10,
memory_limit_mb: 256,
cpu_priority: "normal".to_string(),
oidc_enabled: true,
oidc_client_id: Some("test-client-id".to_string()),
oidc_client_secret: Some("test-client-secret".to_string()),
oidc_issuer_url: Some(mock_server.uri()),
oidc_redirect_uri: Some("http://localhost:8000/auth/oidc/callback".to_string()),
s3_enabled: false,
s3_config: None,
};
let oidc_client = match OidcClient::new(&config).await {
Ok(client) => Some(Arc::new(client)),
Err(e) => {
panic!("OIDC client creation failed: {}", e);
}
};
// Connect to the database and run migrations with retry logic for concurrency
let db = readur::db::Database::new(&config.database_url).await.unwrap();
// Retry migration up to 3 times to handle concurrent test execution
for attempt in 1..=3 {
match db.migrate().await {
Ok(_) => break,
Err(e) if attempt < 3 && e.to_string().contains("tuple concurrently updated") => {
// Wait a bit and retry
tokio::time::sleep(tokio::time::Duration::from_millis(100 * attempt)).await;
continue;
}
Err(e) => panic!("Migration failed after {} attempts: {}", attempt, e),
}
}
// Create file service for OIDC app
let storage_config = readur::storage::StorageConfig::Local { upload_path: config.upload_path.clone() };
let storage_backend = readur::storage::factory::create_storage_backend(storage_config).await.unwrap();
let file_service = Arc::new(readur::services::file_service::FileService::with_storage(config.upload_path.clone(), storage_backend));
// Create app with OIDC configuration
let app = axum::Router::new()
.nest("/api/auth", readur::routes::auth::router())
.with_state(Arc::new(AppState {
db: db.clone(),
config,
file_service: file_service.clone(),
webdav_scheduler: None,
source_scheduler: None,
queue_service: Arc::new(readur::ocr::queue::OcrQueueService::new(
db.clone(),
db.pool.clone(),
2,
file_service.clone()
)),
oidc_client,
sync_progress_tracker: std::sync::Arc::new(readur::services::sync_progress_tracker::SyncProgressTracker::new()),
user_watch_service: None,
webdav_metrics_collector: None,
}));
(app, mock_server)
}
#[tokio::test]
async fn test_oidc_login_redirect() {
let (app, _mock_server) = create_test_app_with_oidc().await;
let response = app
.oneshot(
axum::http::Request::builder()
.method("GET")
.uri("/api/auth/oidc/login")
.body(axum::body::Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::SEE_OTHER);
let location = response.headers().get("location").unwrap().to_str().unwrap();
assert!(location.contains("/auth"));
assert!(location.contains("client_id=test-client-id"));
assert!(location.contains("scope=openid"));
}
#[tokio::test]
async fn test_oidc_login_disabled() {
let (app, _container) = create_test_app_simple().await; // Regular app without OIDC
let response = app
.oneshot(
axum::http::Request::builder()
.method("GET")
.uri("/api/auth/oidc/login")
.body(axum::body::Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn test_oidc_callback_missing_code() {
let (app, _mock_server) = create_test_app_with_oidc().await;
let response = app
.oneshot(
axum::http::Request::builder()
.method("GET")
.uri("/api/auth/oidc/callback")
.body(axum::body::Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn test_oidc_callback_with_error() {
let (app, _mock_server) = create_test_app_with_oidc().await;
let response = app
.oneshot(
axum::http::Request::builder()
.method("GET")
.uri("/api/auth/oidc/callback?error=access_denied")
.body(axum::body::Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn test_oidc_callback_success_new_user() {
let (app, mock_server) = create_test_app_with_oidc().await;
// Generate random identifiers to avoid test interference
let test_id = uuid::Uuid::new_v4().to_string()[..8].to_string();
let test_username = format!("oidcuser_{}", test_id);
let test_email = format!("oidc_{}@example.com", test_id);
let test_subject = format!("oidc-user-{}", test_id);
// Clean up any existing test user to ensure test isolation
let database_url = std::env::var("TEST_DATABASE_URL")
.or_else(|_| std::env::var("DATABASE_URL"))
.unwrap_or_else(|_| "postgresql://readur:readur@localhost:5432/readur".to_string());
let db = readur::db::Database::new(&database_url).await.unwrap();
// Delete any existing user with the test username or OIDC subject
let _ = sqlx::query("DELETE FROM users WHERE username = $1 OR oidc_subject = $2")
.bind(&test_username)
.bind(&test_subject)
.execute(&db.pool)
.await;
// Mock token exchange
let token_response = json!({
"access_token": "test-access-token",
"token_type": "Bearer",
"expires_in": 3600
});
Mock::given(method("POST"))
.and(path("/token"))
.and(header("content-type", "application/x-www-form-urlencoded"))
.respond_with(ResponseTemplate::new(200)
.set_body_json(token_response)
.insert_header("content-type", "application/json"))
.mount(&mock_server)
.await;
// Mock user info
let user_info_response = json!({
"sub": test_subject,
"email": test_email,
"name": "OIDC User",
"preferred_username": test_username
});
Mock::given(method("GET"))
.and(path("/userinfo"))
.respond_with(ResponseTemplate::new(200)
.set_body_json(user_info_response)
.insert_header("content-type", "application/json"))
.mount(&mock_server)
.await;
// Add a small delay to make sure everything is set up
tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
let response = app
.oneshot(
axum::http::Request::builder()
.method("GET")
.uri("/api/auth/oidc/callback?code=test-auth-code&state=test-state")
.body(axum::body::Body::empty())
.unwrap(),
)
.await
.unwrap();
let status = response.status();
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
if status != StatusCode::OK {
let error_text = String::from_utf8_lossy(&body);
eprintln!("Response status: {}", status);
eprintln!("Response body: {}", error_text);
// Also check if we made the expected API calls to the mock server
eprintln!("Mock server received calls:");
let received_requests = mock_server.received_requests().await.unwrap();
for req in received_requests {
eprintln!(" {} {} - {}", req.method, req.url.path(), String::from_utf8_lossy(&req.body));
}
// Try to parse as JSON to see if there's a more detailed error message
if let Ok(error_json) = serde_json::from_slice::<serde_json::Value>(&body) {
eprintln!("Error JSON: {:#}", error_json);
}
}
assert_eq!(status, StatusCode::OK);
let login_response: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert!(login_response["token"].is_string());
assert_eq!(login_response["user"]["username"], test_username);
assert_eq!(login_response["user"]["email"], test_email);
}
#[tokio::test]
async fn test_oidc_callback_invalid_token() {
let (app, mock_server) = create_test_app_with_oidc().await;
// Mock failed token exchange
Mock::given(method("POST"))
.and(path("/token"))
.respond_with(ResponseTemplate::new(400).set_body_json(json!({
"error": "invalid_grant"
})))
.mount(&mock_server)
.await;
let response = app
.oneshot(
axum::http::Request::builder()
.method("GET")
.uri("/api/auth/oidc/callback?code=invalid-auth-code")
.body(axum::body::Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn test_oidc_callback_invalid_user_info() {
let (app, mock_server) = create_test_app_with_oidc().await;
// Generate random identifiers to avoid test interference
let test_id = uuid::Uuid::new_v4().to_string()[..8].to_string();
let test_username = format!("oidcuser_{}", test_id);
let test_subject = format!("oidc-user-{}", test_id);
// Clean up any existing test user to ensure test isolation
let database_url = std::env::var("TEST_DATABASE_URL")
.or_else(|_| std::env::var("DATABASE_URL"))
.unwrap_or_else(|_| "postgresql://readur:readur@localhost:5432/readur".to_string());
let db = readur::db::Database::new(&database_url).await.unwrap();
// Delete any existing user that might conflict
let _ = sqlx::query("DELETE FROM users WHERE username = $1 OR oidc_subject = $2")
.bind(&test_username)
.bind(&test_subject)
.execute(&db.pool)
.await;
// Mock successful token exchange
let token_response = json!({
"access_token": "test-access-token",
"token_type": "Bearer",
"expires_in": 3600
});
Mock::given(method("POST"))
.and(path("/token"))
.respond_with(ResponseTemplate::new(200).set_body_json(token_response))
.mount(&mock_server)
.await;
// Mock failed user info
Mock::given(method("GET"))
.and(path("/userinfo"))
.respond_with(ResponseTemplate::new(401))
.mount(&mock_server)
.await;
let response = app
.oneshot(
axum::http::Request::builder()
.method("GET")
.uri("/api/auth/oidc/callback?code=test-auth-code")
.body(axum::body::Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
}
}