diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/media_files/upload/common_utils/read_upload_session.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/media_files/upload/common_utils/read_upload_session.rs index 3fef773573..e66b939ce7 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/media_files/upload/common_utils/read_upload_session.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/media_files/upload/common_utils/read_upload_session.rs @@ -107,3 +107,206 @@ pub async fn read_upload_session( maybe_cookie_session, }) } + +#[cfg(test)] +mod tests { + //! DATABASE TESTS: these connect to the guarded MySQL test database (see the `mysql_testing` + //! crate — never production, never the local dev database) and drive the real helper with + //! dummy Actix HTTP requests against a test `ServerState`. They RUN BY DEFAULT under + //! `cargo test`; on machines without a local MySQL, skip them with: + //! + //! ```bash + //! SQLX_OFFLINE=true cargo test -p storyteller-web --features skip_database_tests + //! ``` + //! + //! They run in PARALLEL: every test creates its own users, API keys, and MCP sessions. + + use std::marker::PhantomData; + + use actix_web::cookie::Cookie; + use actix_web::test::TestRequest; + use actix_web::HttpRequest; + + use mysql_queries::queries::mcp_sessions::revoke_mcp_session::{ + revoke_mcp_session, RevokeMcpSessionArgs, + }; + use mysql_testing::fixtures::users::{create_test_user, TestUser}; + + use crate::http_server::endpoints::omni_gen::generate::video::tests::support::TestHarness; + use crate::http_server::user_lookup::api_or_web_session::require_any_session_or_key::AnySessionType; + + use super::*; + + mod anonymous_and_cookie_lookups { + use super::*; + + #[tokio::test] + #[cfg_attr(feature = "skip_database_tests", ignore)] + async fn no_credentials_is_an_anonymous_upload() { + let harness = TestHarness::create().await; + + let request = TestRequest::default().to_http_request(); + let session_auth = read(&harness, &request).await.expect("anonymous should be allowed"); + + assert!(!session_auth.is_logged_in()); + assert!(session_auth.maybe_user_token().is_none()); + assert!(session_auth.maybe_header_session.is_none()); + assert!(session_auth.maybe_cookie_session.is_none()); + assert!(session_auth.maybe_preferred_visibility().is_none()); + } + + #[tokio::test] + #[cfg_attr(feature = "skip_database_tests", ignore)] + async fn web_session_cookie_identifies_the_user() { + let harness = TestHarness::create().await; + let user = create_user(&harness).await; + + let request = request_with_session_cookie(&harness, &user); + let session_auth = read(&harness, &request).await.expect("cookie session should be read"); + + assert!(session_auth.is_logged_in()); + assert_eq!(session_auth.maybe_user_token(), Some(&user.user_token)); + assert!(session_auth.maybe_header_session.is_none()); + // Cookie sessions carry the user's preferences. + assert!(session_auth.maybe_preferred_visibility().is_some()); + } + } + + mod header_credential_lookups { + use super::*; + + #[tokio::test] + #[cfg_attr(feature = "skip_database_tests", ignore)] + async fn api_key_identifies_the_user() { + let harness = TestHarness::create().await; + let user = create_user(&harness).await; + let api_key = harness.create_api_key(&user).await; + + let request = request_with_bearer(api_key.as_str_be_careful()); + let session_auth = read(&harness, &request).await.expect("API key should authenticate"); + + assert!(session_auth.is_logged_in()); + assert_eq!(session_auth.maybe_user_token(), Some(&user.user_token)); + let header_session = session_auth.maybe_header_session.as_ref().expect("header session"); + assert_eq!(header_session.session_type, AnySessionType::Api); + assert!(header_session.maybe_api_key_token.is_some()); + // Header lookups don't carry user preferences. + assert!(session_auth.maybe_preferred_visibility().is_none()); + } + + #[tokio::test] + #[cfg_attr(feature = "skip_database_tests", ignore)] + async fn mcp_session_identifies_the_user() { + let harness = TestHarness::create().await; + let user = create_user(&harness).await; + let credential = harness.create_mcp_session(&user).await; + + let request = request_with_bearer(credential.as_str()); + let session_auth = read(&harness, &request).await.expect("MCP session should authenticate"); + + assert!(session_auth.is_logged_in()); + assert_eq!(session_auth.maybe_user_token(), Some(&user.user_token)); + let header_session = session_auth.maybe_header_session.as_ref().expect("header session"); + assert_eq!(header_session.session_type, AnySessionType::McpSession); + assert!(header_session.maybe_mcp_session_token.is_some()); + assert!(session_auth.maybe_preferred_visibility().is_none()); + } + + #[tokio::test] + #[cfg_attr(feature = "skip_database_tests", ignore)] + async fn header_credential_wins_over_a_session_cookie() { + let harness = TestHarness::create().await; + let cookie_user = create_user(&harness).await; + let api_key_user = create_user(&harness).await; + let api_key = harness.create_api_key(&api_key_user).await; + + let cookie = session_cookie(&harness, &cookie_user); + let request = TestRequest::default() + .insert_header(("Authorization", format!("Bearer {}", api_key.as_str_be_careful()))) + .cookie(cookie) + .to_http_request(); + let session_auth = read(&harness, &request).await.expect("header credential should win"); + + assert_eq!(session_auth.maybe_user_token(), Some(&api_key_user.user_token)); + assert!(session_auth.maybe_cookie_session.is_none()); + } + + #[tokio::test] + #[cfg_attr(feature = "skip_database_tests", ignore)] + async fn unknown_header_credentials_are_rejected_not_anonymous() { + let harness = TestHarness::create().await; + + for credential in [ + "artcraft_api_no_such_key_0000000000000000", + "mcp_session_no_such_session_00000000000000000000000000000000", + ] { + let request = request_with_bearer(credential); + let result = read(&harness, &request).await; + assert!( + matches!(result, Err(MediaFileUploadError::NotAuthorized)), + "bad credential {credential:?} must be a 401, not an anonymous fallthrough", + ); + } + } + + #[tokio::test] + #[cfg_attr(feature = "skip_database_tests", ignore)] + async fn revoked_mcp_session_is_rejected() { + let harness = TestHarness::create().await; + let user = create_user(&harness).await; + let credential = harness.create_mcp_session(&user).await; + + let mut connection = harness.pool.acquire().await.expect("acquire for revoke"); + let revoked = revoke_mcp_session(RevokeMcpSessionArgs { + private_session_token: &credential, + mysql_executor: &mut *connection, + phantom: PhantomData, + }) + .await + .expect("revoke mcp session"); + assert_eq!(revoked, 1); + + let request = request_with_bearer(credential.as_str()); + let result = read(&harness, &request).await; + + assert!(matches!(result, Err(MediaFileUploadError::NotAuthorized))); + } + } + + /// Run the helper under test against a fresh connection from the test pool. + async fn read( + harness: &TestHarness, + request: &HttpRequest, + ) -> Result { + let mut connection = harness.pool.acquire().await.expect("acquire for lookup"); + read_upload_session(request, &harness.server_state, &mut connection).await + } + + async fn create_user(harness: &TestHarness) -> TestUser { + create_test_user(&harness.pool).await.expect("create test user") + } + + /// A dummy HTTP request presenting `credential` as a Bearer Authorization header. + fn request_with_bearer(credential: &str) -> HttpRequest { + TestRequest::default() + .insert_header(("Authorization", format!("Bearer {credential}"))) + .to_http_request() + } + + /// A dummy HTTP request carrying the user's session cookie and no Authorization header. + fn request_with_session_cookie(harness: &TestHarness, user: &TestUser) -> HttpRequest { + TestRequest::default() + .cookie(session_cookie(harness, user)) + .to_http_request() + } + + /// The user's session cookie, owned so TestRequest can take it. + fn session_cookie(harness: &TestHarness, user: &TestUser) -> Cookie<'static> { + let cookie = harness + .server_state + .session_cookie_manager + .create_cookie(&user.session_token, &user.user_token) + .expect("create session cookie"); + Cookie::new("session", cookie.value().to_string()) + } +} diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/video/mod.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/video/mod.rs index 0f1fd21489..6e8e6b765b 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/video/mod.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/video/mod.rs @@ -8,5 +8,6 @@ pub mod insert_db_job; // Database fixture tests. Always compiled with `cfg(test)` so IDEs discover // them and they can't bit-rot, but each test is `ignore`d unless the // `database_tests` feature is enabled (see tests/mod.rs). +// `pub(crate)` so other modules' database tests can reuse `support::TestHarness`. #[cfg(test)] -mod tests; +pub(crate) mod tests;