feat: add dashboard

Signed-off-by: kjuulh <contact@kjuulh.io>
This commit is contained in:
2026-03-07 20:31:18 +01:00
parent b439762877
commit d46c365112
21 changed files with 2955 additions and 1367 deletions

View 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)
}