Files
client/crates/forage-server/src/test_support.rs
2026-03-08 23:00:03 +01:00

825 lines
26 KiB
Rust

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<Result<AuthTokens, AuthError>>,
pub login_result: Option<Result<AuthTokens, AuthError>>,
pub refresh_result: Option<Result<AuthTokens, AuthError>>,
pub get_user_result: Option<Result<User, AuthError>>,
pub list_tokens_result: Option<Result<Vec<PersonalAccessToken>, AuthError>>,
pub create_token_result: Option<Result<CreatedToken, AuthError>>,
pub delete_token_result: Option<Result<(), AuthError>>,
pub update_username_result: Option<Result<User, AuthError>>,
pub change_password_result: Option<Result<(), AuthError>>,
pub add_email_result: Option<Result<UserEmail, AuthError>>,
pub remove_email_result: Option<Result<(), AuthError>>,
}
/// Configurable mock behavior for platform (orgs, projects, artifacts).
#[derive(Default)]
pub(crate) struct MockPlatformBehavior {
pub list_orgs_result: Option<Result<Vec<Organisation>, PlatformError>>,
pub list_projects_result: Option<Result<Vec<String>, PlatformError>>,
pub list_artifacts_result: Option<Result<Vec<Artifact>, PlatformError>>,
pub create_organisation_result: Option<Result<String, PlatformError>>,
pub list_members_result: Option<Result<Vec<OrgMember>, PlatformError>>,
pub add_member_result: Option<Result<OrgMember, PlatformError>>,
pub remove_member_result: Option<Result<(), PlatformError>>,
pub update_member_role_result: Option<Result<OrgMember, PlatformError>>,
pub get_artifact_by_slug_result: Option<Result<Artifact, PlatformError>>,
pub list_environments_result: Option<Result<Vec<Environment>, PlatformError>>,
pub list_destinations_result: Option<Result<Vec<Destination>, PlatformError>>,
pub list_triggers_result: Option<Result<Vec<Trigger>, PlatformError>>,
pub create_trigger_result: Option<Result<Trigger, PlatformError>>,
pub update_trigger_result: Option<Result<Trigger, PlatformError>>,
pub delete_trigger_result: Option<Result<(), PlatformError>>,
pub list_release_pipelines_result: Option<Result<Vec<ReleasePipeline>, PlatformError>>,
pub create_release_pipeline_result: Option<Result<ReleasePipeline, PlatformError>>,
pub update_release_pipeline_result: Option<Result<ReleasePipeline, PlatformError>>,
pub delete_release_pipeline_result: Option<Result<(), PlatformError>>,
}
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<MockBehavior>,
}
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<AuthTokens, AuthError> {
let b = self.behavior.lock().unwrap();
b.register_result.clone().unwrap_or(Ok(ok_tokens()))
}
async fn login(
&self,
identifier: &str,
password: &str,
) -> Result<AuthTokens, AuthError> {
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<AuthTokens, AuthError> {
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<User, AuthError> {
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<Vec<PersonalAccessToken>, 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<CreatedToken, AuthError> {
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<User, AuthError> {
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<UserEmail, AuthError> {
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<UserProfile, AuthError> {
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<MockPlatformBehavior>,
}
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<Organisation> {
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<Vec<Organisation>, 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<Vec<String>, 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<Vec<Artifact>, 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<String, PlatformError> {
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<Vec<OrgMember>, 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<OrgMember, PlatformError> {
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<OrgMember, PlatformError> {
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<Artifact, PlatformError> {
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<Vec<Environment>, 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<Vec<Destination>, 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<Environment, PlatformError> {
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<String, String>,
_dest_type: Option<&forage_core::platform::DestinationType>,
) -> Result<(), PlatformError> {
Ok(())
}
async fn update_destination(
&self,
_access_token: &str,
_name: &str,
_metadata: &std::collections::HashMap<String, String>,
) -> Result<(), PlatformError> {
Ok(())
}
async fn get_destination_states(
&self,
_access_token: &str,
_organisation: &str,
_project: Option<&str>,
) -> Result<forage_core::platform::DeploymentStates, PlatformError> {
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<Vec<forage_core::platform::ReleaseIntentState>, 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<Vec<Trigger>, 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<Trigger, PlatformError> {
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<Trigger, PlatformError> {
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<Vec<Policy>, PlatformError> {
Ok(vec![])
}
async fn create_policy(
&self,
_access_token: &str,
_organisation: &str,
_project: &str,
_input: &CreatePolicyInput,
) -> Result<Policy, PlatformError> {
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<Policy, PlatformError> {
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<Vec<ReleasePipeline>, 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<ReleasePipeline, PlatformError> {
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<ReleasePipeline, PlatformError> {
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<InMemorySessionStore>) {
test_state_with(MockForestClient::new(), MockPlatformClient::new())
}
pub(crate) fn test_state_with(
mock: MockForestClient,
platform: MockPlatformClient,
) -> (AppState, Arc<InMemorySessionStore>) {
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<CachedOrg> {
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<InMemorySessionStore>) -> 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<InMemorySessionStore>) -> 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<InMemorySessionStore>) -> 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<InMemorySessionStore>) -> 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)
}