use std::sync::{Arc, Mutex}; use axum::Router; use chrono::Utc; use forage_core::auth::*; use forage_core::platform::{ Artifact, ArtifactContext, CreatePolicyInput, CreateReleasePipelineInput, CreateTriggerInput, Destination, Environment, ForestPlatform, Organisation, OrgMember, PlatformError, Policy, ReleasePipeline, Trigger, UpdatePolicyInput, UpdateReleasePipelineInput, UpdateTriggerInput, }; use forage_core::session::{ CachedOrg, CachedUser, InMemorySessionStore, SessionData, SessionStore, }; use crate::state::AppState; use crate::templates::TemplateEngine; /// Configurable mock behavior for testing different scenarios. #[derive(Default)] pub(crate) struct MockBehavior { pub register_result: Option>, pub login_result: Option>, pub refresh_result: Option>, pub get_user_result: Option>, pub list_tokens_result: Option, AuthError>>, pub create_token_result: Option>, pub delete_token_result: Option>, pub update_username_result: Option>, pub change_password_result: Option>, pub add_email_result: Option>, pub remove_email_result: Option>, } /// Configurable mock behavior for platform (orgs, projects, artifacts). #[derive(Default)] pub(crate) struct MockPlatformBehavior { pub list_orgs_result: Option, PlatformError>>, pub list_projects_result: Option, PlatformError>>, pub list_artifacts_result: Option, PlatformError>>, pub create_organisation_result: Option>, pub list_members_result: Option, PlatformError>>, pub add_member_result: Option>, pub remove_member_result: Option>, pub update_member_role_result: Option>, pub get_artifact_by_slug_result: Option>, pub list_environments_result: Option, PlatformError>>, pub list_destinations_result: Option, PlatformError>>, pub list_triggers_result: Option, PlatformError>>, pub create_trigger_result: Option>, pub update_trigger_result: Option>, pub delete_trigger_result: Option>, pub list_release_pipelines_result: Option, PlatformError>>, pub create_release_pipeline_result: Option>, pub update_release_pipeline_result: Option>, pub delete_release_pipeline_result: Option>, } pub(crate) fn ok_tokens() -> AuthTokens { AuthTokens { access_token: "mock-access".into(), refresh_token: "mock-refresh".into(), expires_in_seconds: 3600, } } pub(crate) fn ok_user() -> User { User { user_id: "user-123".into(), username: "testuser".into(), emails: vec![UserEmail { email: "test@example.com".into(), verified: true, }], } } /// Mock forest client with per-test configurable behavior. pub(crate) struct MockForestClient { behavior: Mutex, } impl MockForestClient { pub fn new() -> Self { Self { behavior: Mutex::new(MockBehavior::default()), } } pub fn with_behavior(behavior: MockBehavior) -> Self { Self { behavior: Mutex::new(behavior), } } } #[async_trait::async_trait] impl ForestAuth for MockForestClient { async fn register( &self, _username: &str, _email: &str, _password: &str, ) -> Result { let b = self.behavior.lock().unwrap(); b.register_result.clone().unwrap_or(Ok(ok_tokens())) } async fn login( &self, identifier: &str, password: &str, ) -> Result { let b = self.behavior.lock().unwrap(); if let Some(result) = b.login_result.clone() { return result; } if identifier == "testuser" && password == "CorrectPass123" { Ok(ok_tokens()) } else { Err(AuthError::InvalidCredentials) } } async fn refresh_token(&self, _refresh_token: &str) -> Result { let b = self.behavior.lock().unwrap(); b.refresh_result.clone().unwrap_or(Ok(AuthTokens { access_token: "refreshed-access".into(), refresh_token: "refreshed-refresh".into(), expires_in_seconds: 3600, })) } async fn logout(&self, _refresh_token: &str) -> Result<(), AuthError> { Ok(()) } async fn get_user(&self, access_token: &str) -> Result { let b = self.behavior.lock().unwrap(); if let Some(result) = b.get_user_result.clone() { return result; } if access_token == "mock-access" || access_token == "refreshed-access" { Ok(ok_user()) } else { Err(AuthError::NotAuthenticated) } } async fn list_tokens( &self, _access_token: &str, _user_id: &str, ) -> Result, AuthError> { let b = self.behavior.lock().unwrap(); b.list_tokens_result.clone().unwrap_or(Ok(vec![])) } async fn create_token( &self, _access_token: &str, _user_id: &str, name: &str, ) -> Result { let b = self.behavior.lock().unwrap(); b.create_token_result.clone().unwrap_or(Ok(CreatedToken { token: PersonalAccessToken { token_id: "tok-1".into(), name: name.into(), scopes: vec![], created_at: None, last_used: None, expires_at: None, }, raw_token: "forg_abcdef1234567890".into(), })) } async fn delete_token( &self, _access_token: &str, _token_id: &str, ) -> Result<(), AuthError> { let b = self.behavior.lock().unwrap(); b.delete_token_result.clone().unwrap_or(Ok(())) } async fn update_username( &self, _access_token: &str, _user_id: &str, new_username: &str, ) -> Result { let b = self.behavior.lock().unwrap(); b.update_username_result.clone().unwrap_or(Ok(User { user_id: "user-123".into(), username: new_username.into(), emails: vec![UserEmail { email: "test@example.com".into(), verified: true, }], })) } async fn change_password( &self, _access_token: &str, _user_id: &str, _current_password: &str, _new_password: &str, ) -> Result<(), AuthError> { let b = self.behavior.lock().unwrap(); b.change_password_result.clone().unwrap_or(Ok(())) } async fn add_email( &self, _access_token: &str, _user_id: &str, email: &str, ) -> Result { let b = self.behavior.lock().unwrap(); b.add_email_result.clone().unwrap_or(Ok(UserEmail { email: email.into(), verified: false, })) } async fn get_user_by_username( &self, _access_token: &str, username: &str, ) -> Result { Ok(UserProfile { user_id: "user-123".into(), username: username.into(), created_at: Some("2025-01-15T10:00:00Z".into()), }) } async fn remove_email( &self, _access_token: &str, _user_id: &str, _email: &str, ) -> Result<(), AuthError> { let b = self.behavior.lock().unwrap(); b.remove_email_result.clone().unwrap_or(Ok(())) } } pub(crate) struct MockPlatformClient { behavior: Mutex, } impl MockPlatformClient { pub fn new() -> Self { Self { behavior: Mutex::new(MockPlatformBehavior::default()), } } pub fn with_behavior(behavior: MockPlatformBehavior) -> Self { Self { behavior: Mutex::new(behavior), } } } pub(crate) fn default_orgs() -> Vec { vec![Organisation { organisation_id: "org-1".into(), name: "testorg".into(), role: "admin".into(), }] } #[async_trait::async_trait] impl ForestPlatform for MockPlatformClient { async fn list_my_organisations( &self, _access_token: &str, ) -> Result, PlatformError> { let b = self.behavior.lock().unwrap(); b.list_orgs_result.clone().unwrap_or(Ok(default_orgs())) } async fn list_projects( &self, _access_token: &str, _organisation: &str, ) -> Result, PlatformError> { let b = self.behavior.lock().unwrap(); b.list_projects_result .clone() .unwrap_or(Ok(vec!["my-api".into()])) } async fn list_artifacts( &self, _access_token: &str, _organisation: &str, _project: &str, ) -> Result, PlatformError> { let b = self.behavior.lock().unwrap(); b.list_artifacts_result.clone().unwrap_or(Ok(vec![Artifact { artifact_id: "art-1".into(), slug: "my-api-abc123".into(), context: ArtifactContext { title: "Deploy v1.0".into(), description: Some("Initial release".into()), web: None, pr: None, }, source: None, git_ref: None, destinations: vec![], created_at: "2026-03-07T12:00:00Z".into(), }])) } async fn create_organisation( &self, _access_token: &str, name: &str, ) -> Result { let b = self.behavior.lock().unwrap(); b.create_organisation_result .clone() .unwrap_or(Ok(format!("org-{name}"))) } async fn list_members( &self, _access_token: &str, _organisation_id: &str, ) -> Result, PlatformError> { let b = self.behavior.lock().unwrap(); b.list_members_result.clone().unwrap_or(Ok(vec![OrgMember { user_id: "user-123".into(), username: "testuser".into(), role: "owner".into(), joined_at: Some("2026-01-01T00:00:00Z".into()), }])) } async fn add_member( &self, _access_token: &str, _organisation_id: &str, user_id: &str, role: &str, ) -> Result { let b = self.behavior.lock().unwrap(); b.add_member_result.clone().unwrap_or(Ok(OrgMember { user_id: user_id.into(), username: "newuser".into(), role: role.into(), joined_at: Some("2026-03-07T00:00:00Z".into()), })) } async fn remove_member( &self, _access_token: &str, _organisation_id: &str, _user_id: &str, ) -> Result<(), PlatformError> { let b = self.behavior.lock().unwrap(); b.remove_member_result.clone().unwrap_or(Ok(())) } async fn update_member_role( &self, _access_token: &str, _organisation_id: &str, user_id: &str, role: &str, ) -> Result { let b = self.behavior.lock().unwrap(); b.update_member_role_result.clone().unwrap_or(Ok(OrgMember { user_id: user_id.into(), username: "testuser".into(), role: role.into(), joined_at: Some("2026-01-01T00:00:00Z".into()), })) } async fn get_artifact_by_slug( &self, _access_token: &str, slug: &str, ) -> Result { let b = self.behavior.lock().unwrap(); b.get_artifact_by_slug_result .clone() .unwrap_or(Ok(Artifact { artifact_id: "art-1".into(), slug: slug.into(), context: ArtifactContext { title: "Deploy v1.0".into(), description: Some("Initial release".into()), web: None, pr: None, }, source: None, git_ref: None, destinations: vec![], created_at: "2026-03-07T12:00:00Z".into(), })) } async fn list_environments( &self, _access_token: &str, _organisation: &str, ) -> Result, PlatformError> { let b = self.behavior.lock().unwrap(); b.list_environments_result.clone().unwrap_or(Ok(vec![])) } async fn list_destinations( &self, _access_token: &str, _organisation: &str, ) -> Result, PlatformError> { let b = self.behavior.lock().unwrap(); b.list_destinations_result.clone().unwrap_or(Ok(vec![])) } async fn create_environment( &self, _access_token: &str, organisation: &str, name: &str, description: Option<&str>, sort_order: i32, ) -> Result { Ok(Environment { id: format!("env-{name}"), organisation: organisation.into(), name: name.into(), description: description.map(|s| s.to_string()), sort_order, created_at: "2026-03-08T00:00:00Z".into(), }) } async fn create_destination( &self, _access_token: &str, _organisation: &str, _name: &str, _environment: &str, _metadata: &std::collections::HashMap, _dest_type: Option<&forage_core::platform::DestinationType>, ) -> Result<(), PlatformError> { Ok(()) } async fn update_destination( &self, _access_token: &str, _name: &str, _metadata: &std::collections::HashMap, ) -> Result<(), PlatformError> { Ok(()) } async fn get_destination_states( &self, _access_token: &str, _organisation: &str, _project: Option<&str>, ) -> Result { Ok(forage_core::platform::DeploymentStates { destinations: vec![], }) } async fn get_release_intent_states( &self, _access_token: &str, _organisation: &str, _project: Option<&str>, _include_completed: bool, ) -> Result, PlatformError> { Ok(vec![]) } async fn release_artifact( &self, _access_token: &str, _artifact_id: &str, _destinations: &[String], _environments: &[String], _use_pipeline: bool, ) -> Result<(), PlatformError> { Ok(()) } async fn list_triggers( &self, _access_token: &str, _organisation: &str, _project: &str, ) -> Result, PlatformError> { let b = self.behavior.lock().unwrap(); b.list_triggers_result.clone().unwrap_or(Ok(vec![])) } async fn create_trigger( &self, _access_token: &str, _organisation: &str, _project: &str, input: &CreateTriggerInput, ) -> Result { let b = self.behavior.lock().unwrap(); b.create_trigger_result.clone().unwrap_or(Ok(Trigger { id: "trigger-1".into(), name: input.name.clone(), enabled: true, branch_pattern: input.branch_pattern.clone(), title_pattern: input.title_pattern.clone(), author_pattern: input.author_pattern.clone(), commit_message_pattern: input.commit_message_pattern.clone(), source_type_pattern: input.source_type_pattern.clone(), target_environments: input.target_environments.clone(), target_destinations: input.target_destinations.clone(), force_release: input.force_release, use_pipeline: input.use_pipeline, created_at: "2026-03-08T00:00:00Z".into(), updated_at: "2026-03-08T00:00:00Z".into(), })) } async fn update_trigger( &self, _access_token: &str, _organisation: &str, _project: &str, name: &str, input: &UpdateTriggerInput, ) -> Result { let b = self.behavior.lock().unwrap(); b.update_trigger_result.clone().unwrap_or(Ok(Trigger { id: "trigger-1".into(), name: name.into(), enabled: input.enabled.unwrap_or(true), branch_pattern: input.branch_pattern.clone(), title_pattern: input.title_pattern.clone(), author_pattern: input.author_pattern.clone(), commit_message_pattern: input.commit_message_pattern.clone(), source_type_pattern: input.source_type_pattern.clone(), target_environments: input.target_environments.clone(), target_destinations: input.target_destinations.clone(), force_release: input.force_release.unwrap_or(false), use_pipeline: input.use_pipeline.unwrap_or(false), created_at: "2026-03-08T00:00:00Z".into(), updated_at: "2026-03-08T00:00:00Z".into(), })) } async fn delete_trigger( &self, _access_token: &str, _organisation: &str, _project: &str, _name: &str, ) -> Result<(), PlatformError> { let b = self.behavior.lock().unwrap(); b.delete_trigger_result.clone().unwrap_or(Ok(())) } async fn list_policies( &self, _access_token: &str, _organisation: &str, _project: &str, ) -> Result, PlatformError> { Ok(vec![]) } async fn create_policy( &self, _access_token: &str, _organisation: &str, _project: &str, _input: &CreatePolicyInput, ) -> Result { Err(PlatformError::Other("not implemented in mock".into())) } async fn update_policy( &self, _access_token: &str, _organisation: &str, _project: &str, _name: &str, _input: &UpdatePolicyInput, ) -> Result { Err(PlatformError::Other("not implemented in mock".into())) } async fn delete_policy( &self, _access_token: &str, _organisation: &str, _project: &str, _name: &str, ) -> Result<(), PlatformError> { Ok(()) } async fn list_release_pipelines( &self, _access_token: &str, _organisation: &str, _project: &str, ) -> Result, PlatformError> { let b = self.behavior.lock().unwrap(); b.list_release_pipelines_result .clone() .unwrap_or(Ok(vec![])) } async fn create_release_pipeline( &self, _access_token: &str, _organisation: &str, _project: &str, input: &CreateReleasePipelineInput, ) -> Result { let b = self.behavior.lock().unwrap(); b.create_release_pipeline_result .clone() .unwrap_or(Ok(ReleasePipeline { id: "pipeline-1".into(), name: input.name.clone(), enabled: true, stages: input.stages.clone(), created_at: "2026-03-08T00:00:00Z".into(), updated_at: "2026-03-08T00:00:00Z".into(), })) } async fn update_release_pipeline( &self, _access_token: &str, _organisation: &str, _project: &str, name: &str, input: &UpdateReleasePipelineInput, ) -> Result { let b = self.behavior.lock().unwrap(); b.update_release_pipeline_result .clone() .unwrap_or(Ok(ReleasePipeline { id: "pipeline-1".into(), name: name.into(), enabled: input.enabled.unwrap_or(true), stages: input.stages.clone().unwrap_or_default(), created_at: "2026-03-08T00:00:00Z".into(), updated_at: "2026-03-08T00:00:00Z".into(), })) } async fn delete_release_pipeline( &self, _access_token: &str, _organisation: &str, _project: &str, _name: &str, ) -> Result<(), PlatformError> { let b = self.behavior.lock().unwrap(); b.delete_release_pipeline_result.clone().unwrap_or(Ok(())) } } pub(crate) fn make_templates() -> TemplateEngine { let workspace_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) .parent() .unwrap() .parent() .unwrap(); TemplateEngine::from_path(&workspace_root.join("templates")) .expect("templates must load for tests") } pub(crate) fn test_state() -> (AppState, Arc) { test_state_with(MockForestClient::new(), MockPlatformClient::new()) } pub(crate) fn test_state_with( mock: MockForestClient, platform: MockPlatformClient, ) -> (AppState, Arc) { let sessions = Arc::new(InMemorySessionStore::new()); let state = AppState::new( make_templates(), Arc::new(mock), Arc::new(platform), sessions.clone(), ); (state, sessions) } pub(crate) fn test_app() -> Router { let (state, _) = test_state(); crate::build_router(state) } pub(crate) fn test_app_with(mock: MockForestClient) -> Router { let (state, _) = test_state_with(mock, MockPlatformClient::new()); crate::build_router(state) } pub(crate) fn default_test_orgs() -> Vec { vec![CachedOrg { organisation_id: "org-1".into(), name: "testorg".into(), role: "owner".into(), }] } /// Create a test session and return the cookie header value. pub(crate) async fn create_test_session(sessions: &Arc) -> String { let now = Utc::now(); let data = SessionData { access_token: "mock-access".into(), refresh_token: "mock-refresh".into(), csrf_token: "test-csrf".into(), access_expires_at: now + chrono::Duration::hours(1), user: Some(CachedUser { user_id: "user-123".into(), username: "testuser".into(), emails: vec![UserEmail { email: "test@example.com".into(), verified: true, }], orgs: default_test_orgs(), }), created_at: now, last_seen_at: now, }; let session_id = sessions.create(data).await.unwrap(); format!("forage_session={}", session_id) } /// Create a test session with an expired access token but valid refresh token. pub(crate) async fn create_expired_session(sessions: &Arc) -> String { let now = Utc::now(); let data = SessionData { access_token: "expired-access".into(), refresh_token: "mock-refresh".into(), csrf_token: "test-csrf".into(), access_expires_at: now - chrono::Duration::seconds(10), user: Some(CachedUser { user_id: "user-123".into(), username: "testuser".into(), emails: vec![UserEmail { email: "test@example.com".into(), verified: true, }], orgs: default_test_orgs(), }), created_at: now, last_seen_at: now, }; let session_id = sessions.create(data).await.unwrap(); format!("forage_session={}", session_id) } /// Create a test session with "member" role (non-admin, for authorization tests). pub(crate) async fn create_test_session_member(sessions: &Arc) -> String { let now = Utc::now(); let data = SessionData { access_token: "mock-access".into(), refresh_token: "mock-refresh".into(), csrf_token: "test-csrf".into(), access_expires_at: now + chrono::Duration::hours(1), user: Some(CachedUser { user_id: "user-123".into(), username: "testuser".into(), emails: vec![UserEmail { email: "test@example.com".into(), verified: true, }], orgs: vec![CachedOrg { organisation_id: "org-1".into(), name: "testorg".into(), role: "member".into(), }], }), created_at: now, last_seen_at: now, }; let session_id = sessions.create(data).await.unwrap(); format!("forage_session={}", session_id) } /// Create a test session with no cached orgs (for onboarding tests). pub(crate) async fn create_test_session_no_orgs(sessions: &Arc) -> String { let now = Utc::now(); let data = SessionData { access_token: "mock-access".into(), refresh_token: "mock-refresh".into(), csrf_token: "test-csrf".into(), access_expires_at: now + chrono::Duration::hours(1), user: Some(CachedUser { user_id: "user-123".into(), username: "testuser".into(), emails: vec![UserEmail { email: "test@example.com".into(), verified: true, }], orgs: vec![], }), created_at: now, last_seen_at: now, }; let session_id = sessions.create(data).await.unwrap(); format!("forage_session={}", session_id) }