451
crates/forage-server/src/test_support.rs
Normal file
451
crates/forage-server/src/test_support.rs
Normal file
@@ -0,0 +1,451 @@
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use axum::Router;
|
||||
use chrono::Utc;
|
||||
use forage_core::auth::*;
|
||||
use forage_core::platform::{
|
||||
Artifact, ArtifactContext, ForestPlatform, Organisation, OrgMember, PlatformError,
|
||||
};
|
||||
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>>,
|
||||
}
|
||||
|
||||
/// 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(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(()))
|
||||
}
|
||||
}
|
||||
|
||||
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()),
|
||||
},
|
||||
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()),
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user