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

@@ -77,6 +77,7 @@ impl FromRequestParts<AppState> for Session {
.unwrap_or_default()
.into_iter()
.map(|o| CachedOrg {
organisation_id: o.organisation_id,
name: o.name,
role: o.role,
})

View File

@@ -2,7 +2,8 @@ use forage_core::auth::{
AuthError, AuthTokens, CreatedToken, ForestAuth, PersonalAccessToken, User, UserEmail,
};
use forage_core::platform::{
Artifact, ArtifactContext, ForestPlatform, Organisation, PlatformError,
Artifact, ArtifactContext, ArtifactDestination, ArtifactSource, ForestPlatform, Organisation,
OrgMember, PlatformError,
};
use forage_grpc::organisation_service_client::OrganisationServiceClient;
use forage_grpc::release_service_client::ReleaseServiceClient;
@@ -292,6 +293,22 @@ fn convert_organisations(
fn convert_artifact(a: forage_grpc::Artifact) -> Artifact {
let ctx = a.context.unwrap_or_default();
let source = a.source.map(|s| ArtifactSource {
user: s.user.filter(|v| !v.is_empty()),
email: s.email.filter(|v| !v.is_empty()),
source_type: s.source_type.filter(|v| !v.is_empty()),
run_url: s.run_url.filter(|v| !v.is_empty()),
});
// Artifact proto does not carry git ref directly; git info comes from AnnotateRelease.
// We leave git_ref as None for now.
let destinations = a
.destinations
.into_iter()
.map(|d| ArtifactDestination {
name: d.name,
environment: d.environment,
})
.collect();
Artifact {
artifact_id: a.artifact_id,
slug: a.slug,
@@ -303,10 +320,22 @@ fn convert_artifact(a: forage_grpc::Artifact) -> Artifact {
ctx.description
},
},
source,
git_ref: None,
destinations,
created_at: a.created_at,
}
}
fn convert_member(m: forage_grpc::OrganisationMember) -> OrgMember {
OrgMember {
user_id: m.user_id,
username: m.username,
role: m.role,
joined_at: m.joined_at.map(|ts| ts.to_string()),
}
}
fn map_platform_status(status: tonic::Status) -> PlatformError {
match status.code() {
tonic::Code::Unauthenticated | tonic::Code::PermissionDenied => {
@@ -394,6 +423,131 @@ impl ForestPlatform for GrpcForestClient {
Ok(resp.artifact.into_iter().map(convert_artifact).collect())
}
async fn create_organisation(
&self,
access_token: &str,
name: &str,
) -> Result<String, PlatformError> {
let req = platform_authed_request(
access_token,
forage_grpc::CreateOrganisationRequest {
name: name.into(),
},
)?;
let resp = self
.org_client()
.create_organisation(req)
.await
.map_err(map_platform_status)?
.into_inner();
Ok(resp.organisation_id)
}
async fn list_members(
&self,
access_token: &str,
organisation_id: &str,
) -> Result<Vec<OrgMember>, PlatformError> {
let req = platform_authed_request(
access_token,
forage_grpc::ListMembersRequest {
organisation_id: organisation_id.into(),
page_size: 100,
page_token: String::new(),
},
)?;
let resp = self
.org_client()
.list_members(req)
.await
.map_err(map_platform_status)?
.into_inner();
Ok(resp.members.into_iter().map(convert_member).collect())
}
async fn add_member(
&self,
access_token: &str,
organisation_id: &str,
user_id: &str,
role: &str,
) -> Result<OrgMember, PlatformError> {
let req = platform_authed_request(
access_token,
forage_grpc::AddMemberRequest {
organisation_id: organisation_id.into(),
user_id: user_id.into(),
role: role.into(),
},
)?;
let resp = self
.org_client()
.add_member(req)
.await
.map_err(map_platform_status)?
.into_inner();
let member = resp
.member
.ok_or(PlatformError::Other("no member in response".into()))?;
Ok(convert_member(member))
}
async fn remove_member(
&self,
access_token: &str,
organisation_id: &str,
user_id: &str,
) -> Result<(), PlatformError> {
let req = platform_authed_request(
access_token,
forage_grpc::RemoveMemberRequest {
organisation_id: organisation_id.into(),
user_id: user_id.into(),
},
)?;
self.org_client()
.remove_member(req)
.await
.map_err(map_platform_status)?;
Ok(())
}
async fn update_member_role(
&self,
access_token: &str,
organisation_id: &str,
user_id: &str,
role: &str,
) -> Result<OrgMember, PlatformError> {
let req = platform_authed_request(
access_token,
forage_grpc::UpdateMemberRoleRequest {
organisation_id: organisation_id.into(),
user_id: user_id.into(),
role: role.into(),
},
)?;
let resp = self
.org_client()
.update_member_role(req)
.await
.map_err(map_platform_status)?
.into_inner();
let member = resp
.member
.ok_or(PlatformError::Other("no member in response".into()))?;
Ok(convert_member(member))
}
}
#[cfg(test)]

File diff suppressed because it is too large Load Diff

View File

@@ -18,7 +18,6 @@ pub fn router() -> Router<AppState> {
.route("/signup", get(signup_page).post(signup_submit))
.route("/login", get(login_page).post(login_submit))
.route("/logout", post(logout_submit))
.route("/dashboard", get(dashboard))
.route(
"/settings/tokens",
get(tokens_page).post(create_token_submit),
@@ -106,6 +105,7 @@ async fn signup_submit(
user.orgs = orgs
.into_iter()
.map(|o| CachedOrg {
organisation_id: o.organisation_id,
name: o.name,
role: o.role,
})
@@ -255,6 +255,7 @@ async fn login_submit(
user.orgs = orgs
.into_iter()
.map(|o| CachedOrg {
organisation_id: o.organisation_id,
name: o.name,
role: o.role,
})
@@ -343,39 +344,6 @@ async fn logout_submit(
Ok((auth::clear_session_cookie(), Redirect::to("/")))
}
// ─── Dashboard ──────────────────────────────────────────────────────
async fn dashboard(
State(state): State<AppState>,
session: Session,
) -> Result<Response, Response> {
// Use cached org memberships from the session
let orgs = &session.user.orgs;
if let Some(first_org) = orgs.first() {
return Ok(Redirect::to(&format!("/orgs/{}/projects", first_org.name)).into_response());
}
// No orgs: show onboarding
let html = state
.templates
.render(
"pages/onboarding.html.jinja",
context! {
title => "Get Started - Forage",
description => "Create your first organisation",
user => context! { username => session.user.username },
csrf_token => &session.csrf_token,
},
)
.map_err(|e| {
tracing::error!("template error: {e:#}");
error_page(&state, StatusCode::INTERNAL_SERVER_ERROR, "Something went wrong", "Please try again.")
})?;
Ok(Html(html).into_response())
}
// ─── Tokens ─────────────────────────────────────────────────────────
async fn tokens_page(

View File

@@ -1,21 +1,36 @@
use axum::extract::{Path, State};
use axum::http::StatusCode;
use axum::response::{Html, IntoResponse, Response};
use axum::routing::get;
use axum::Router;
use axum::response::{Html, IntoResponse, Redirect, Response};
use axum::routing::{get, post};
use axum::{Form, Router};
use forage_core::platform::validate_slug;
use forage_core::session::CachedOrg;
use minijinja::context;
use serde::Deserialize;
use super::error_page;
use crate::auth::Session;
use crate::auth::{self, Session};
use crate::state::AppState;
pub fn router() -> Router<AppState> {
Router::new()
.route("/dashboard", get(dashboard))
.route("/orgs", post(create_org_submit))
.route("/orgs/{org}/projects", get(projects_list))
.route("/orgs/{org}/projects/{project}", get(project_detail))
.route("/orgs/{org}/usage", get(usage))
.route(
"/orgs/{org}/settings/members",
get(members_page).post(add_member_submit),
)
.route(
"/orgs/{org}/settings/members/{user_id}/role",
post(update_member_role_submit),
)
.route(
"/orgs/{org}/settings/members/{user_id}/remove",
post(remove_member_submit),
)
}
fn orgs_context(orgs: &[CachedOrg]) -> Vec<minijinja::Value> {
@@ -24,20 +39,236 @@ fn orgs_context(orgs: &[CachedOrg]) -> Vec<minijinja::Value> {
.collect()
}
#[allow(clippy::result_large_err)]
fn require_org_membership<'a>(
state: &AppState,
orgs: &'a [CachedOrg],
org: &str,
) -> Result<&'a CachedOrg, Response> {
if !validate_slug(org) {
return Err(error_page(
state,
StatusCode::BAD_REQUEST,
"Invalid request",
"Invalid organisation name.",
));
}
orgs.iter().find(|o| o.name == org).ok_or_else(|| {
error_page(
state,
StatusCode::FORBIDDEN,
"Access denied",
"You don't have access to this organisation.",
)
})
}
/// Require the user to be an admin or owner of the organisation.
#[allow(clippy::result_large_err)]
fn require_admin(state: &AppState, org: &CachedOrg) -> Result<(), Response> {
if org.role == "owner" || org.role == "admin" {
Ok(())
} else {
Err(error_page(
state,
StatusCode::FORBIDDEN,
"Access denied",
"You must be an admin to perform this action.",
))
}
}
// ─── Dashboard ──────────────────────────────────────────────────────
async fn dashboard(
State(state): State<AppState>,
session: Session,
) -> Result<Response, Response> {
let orgs = &session.user.orgs;
if orgs.is_empty() {
// No orgs: show onboarding with create org form
let html = state
.templates
.render(
"pages/onboarding.html.jinja",
context! {
title => "Get Started - Forage",
description => "Create your first organisation",
user => context! { username => session.user.username },
csrf_token => &session.csrf_token,
},
)
.map_err(|e| {
tracing::error!("template error: {e:#}");
error_page(
&state,
StatusCode::INTERNAL_SERVER_ERROR,
"Something went wrong",
"Please try again.",
)
})?;
return Ok(Html(html).into_response());
}
// Fetch recent activity: for each org, get projects, then artifacts
let mut recent_activity = Vec::new();
for org in orgs {
let projects = state
.platform_client
.list_projects(&session.access_token, &org.name)
.await
.unwrap_or_default();
for project in projects.iter().take(5) {
let artifacts = state
.platform_client
.list_artifacts(&session.access_token, &org.name, project)
.await
.unwrap_or_default();
for artifact in artifacts {
recent_activity.push(context! {
org_name => org.name,
project_name => project,
slug => artifact.slug,
title => artifact.context.title,
description => artifact.context.description,
created_at => artifact.created_at,
});
if recent_activity.len() >= 10 {
break;
}
}
if recent_activity.len() >= 10 {
break;
}
}
}
let html = state
.templates
.render(
"pages/dashboard.html.jinja",
context! {
title => "Dashboard - Forage",
description => "Your Forage dashboard",
user => context! { username => session.user.username },
csrf_token => &session.csrf_token,
orgs => orgs_context(orgs),
recent_activity => recent_activity,
},
)
.map_err(|e| {
tracing::error!("template error: {e:#}");
error_page(
&state,
StatusCode::INTERNAL_SERVER_ERROR,
"Something went wrong",
"Please try again.",
)
})?;
Ok(Html(html).into_response())
}
// ─── Create organisation ────────────────────────────────────────────
#[derive(Deserialize)]
struct CreateOrgForm {
name: String,
_csrf: String,
}
async fn create_org_submit(
State(state): State<AppState>,
session: Session,
Form(form): Form<CreateOrgForm>,
) -> Result<Response, Response> {
if !auth::validate_csrf(&session, &form._csrf) {
return Err(error_page(
&state,
StatusCode::FORBIDDEN,
"Invalid request",
"CSRF validation failed. Please try again.",
));
}
if !validate_slug(&form.name) {
// Re-render onboarding/dashboard with error
let html = state
.templates
.render(
"pages/onboarding.html.jinja",
context! {
title => "Get Started - Forage",
description => "Create your first organisation",
user => context! { username => session.user.username },
csrf_token => &session.csrf_token,
error => "Invalid organisation name. Use lowercase letters, numbers, and hyphens only.",
},
)
.map_err(|e| {
tracing::error!("template error: {e:#}");
error_page(&state, StatusCode::INTERNAL_SERVER_ERROR, "Something went wrong", "Please try again.")
})?;
return Ok(Html(html).into_response());
}
match state
.platform_client
.create_organisation(&session.access_token, &form.name)
.await
{
Ok(org_id) => {
// Update session with new org
if let Ok(Some(mut session_data)) = state.sessions.get(&session.session_id).await {
if let Some(ref mut user) = session_data.user {
user.orgs.push(CachedOrg {
organisation_id: org_id,
name: form.name.clone(),
role: "owner".into(),
});
}
let _ = state
.sessions
.update(&session.session_id, session_data)
.await;
}
Ok(Redirect::to(&format!("/orgs/{}/projects", form.name)).into_response())
}
Err(e) => {
tracing::error!("failed to create org: {e}");
let html = state
.templates
.render(
"pages/onboarding.html.jinja",
context! {
title => "Get Started - Forage",
description => "Create your first organisation",
user => context! { username => session.user.username },
csrf_token => &session.csrf_token,
error => "Could not create organisation. Please try again.",
},
)
.map_err(|e| {
tracing::error!("template error: {e:#}");
error_page(&state, StatusCode::INTERNAL_SERVER_ERROR, "Something went wrong", "Please try again.")
})?;
Ok(Html(html).into_response())
}
}
}
// ─── Projects list ──────────────────────────────────────────────────
async fn projects_list(
State(state): State<AppState>,
session: Session,
Path(org): Path<String>,
) -> Result<Response, Response> {
if !validate_slug(&org) {
return Err(error_page(&state, StatusCode::BAD_REQUEST, "Invalid request", "Invalid organisation name."));
}
let orgs = &session.user.orgs;
if !orgs.iter().any(|o| o.name == org) {
return Err(error_page(&state, StatusCode::FORBIDDEN, "Access denied", "You don't have access to this organisation."));
}
require_org_membership(&state, orgs, &org)?;
let projects = state
.platform_client
@@ -62,25 +293,34 @@ async fn projects_list(
)
.map_err(|e| {
tracing::error!("template error: {e:#}");
error_page(&state, StatusCode::INTERNAL_SERVER_ERROR, "Something went wrong", "Please try again.")
error_page(
&state,
StatusCode::INTERNAL_SERVER_ERROR,
"Something went wrong",
"Please try again.",
)
})?;
Ok(Html(html).into_response())
}
// ─── Project detail ─────────────────────────────────────────────────
async fn project_detail(
State(state): State<AppState>,
session: Session,
Path((org, project)): Path<(String, String)>,
) -> Result<Response, Response> {
if !validate_slug(&org) || !validate_slug(&project) {
return Err(error_page(&state, StatusCode::BAD_REQUEST, "Invalid request", "Invalid organisation or project name."));
}
let orgs = &session.user.orgs;
require_org_membership(&state, orgs, &org)?;
if !orgs.iter().any(|o| o.name == org) {
return Err(error_page(&state, StatusCode::FORBIDDEN, "Access denied", "You don't have access to this organisation."));
if !validate_slug(&project) {
return Err(error_page(
&state,
StatusCode::BAD_REQUEST,
"Invalid request",
"Invalid project name.",
));
}
let artifacts = state
@@ -102,38 +342,47 @@ async fn project_detail(
orgs => orgs_context(orgs),
org_name => &org,
project_name => &project,
artifacts => artifacts.iter().map(|a| context! {
slug => a.slug,
title => a.context.title,
description => a.context.description,
created_at => a.created_at,
artifacts => artifacts.iter().map(|a| {
context! {
slug => a.slug,
title => a.context.title,
description => a.context.description,
created_at => a.created_at,
source_user => a.source.as_ref().and_then(|s| s.user.clone()),
source_type => a.source.as_ref().and_then(|s| s.source_type.clone()),
run_url => a.source.as_ref().and_then(|s| s.run_url.clone()),
commit_sha => a.git_ref.as_ref().map(|r| r.commit_sha.clone()),
branch => a.git_ref.as_ref().and_then(|r| r.branch.clone()),
version => a.git_ref.as_ref().and_then(|r| r.version.clone()),
destinations => a.destinations.iter().map(|d| {
context! { name => d.name, environment => d.environment }
}).collect::<Vec<_>>(),
}
}).collect::<Vec<_>>(),
},
)
.map_err(|e| {
tracing::error!("template error: {e:#}");
error_page(&state, StatusCode::INTERNAL_SERVER_ERROR, "Something went wrong", "Please try again.")
error_page(
&state,
StatusCode::INTERNAL_SERVER_ERROR,
"Something went wrong",
"Please try again.",
)
})?;
Ok(Html(html).into_response())
}
// ─── Usage ──────────────────────────────────────────────────────────
async fn usage(
State(state): State<AppState>,
session: Session,
Path(org): Path<String>,
) -> Result<Response, Response> {
if !validate_slug(&org) {
return Err(error_page(&state, StatusCode::BAD_REQUEST, "Invalid request", "Invalid organisation name."));
}
let orgs = &session.user.orgs;
let current_org_data = orgs.iter().find(|o| o.name == org);
let current_org_data = match current_org_data {
Some(o) => o,
None => return Err(error_page(&state, StatusCode::FORBIDDEN, "Access denied", "You don't have access to this organisation.")),
};
let current_org_data = require_org_membership(&state, orgs, &org)?;
let projects = state
.platform_client
@@ -159,8 +408,205 @@ async fn usage(
)
.map_err(|e| {
tracing::error!("template error: {e:#}");
error_page(&state, StatusCode::INTERNAL_SERVER_ERROR, "Something went wrong", "Please try again.")
error_page(
&state,
StatusCode::INTERNAL_SERVER_ERROR,
"Something went wrong",
"Please try again.",
)
})?;
Ok(Html(html).into_response())
}
// ─── Members ────────────────────────────────────────────────────────
async fn members_page(
State(state): State<AppState>,
session: Session,
Path(org): Path<String>,
) -> Result<Response, Response> {
let orgs = &session.user.orgs;
let current_org = require_org_membership(&state, orgs, &org)?;
let members = state
.platform_client
.list_members(&session.access_token, &current_org.organisation_id)
.await
.unwrap_or_default();
let is_admin = current_org.role == "owner" || current_org.role == "admin";
let html = state
.templates
.render(
"pages/members.html.jinja",
context! {
title => format!("Members - {org} - Forage"),
description => format!("Members of {org}"),
user => context! { username => session.user.username },
csrf_token => &session.csrf_token,
current_org => &org,
orgs => orgs_context(orgs),
org_name => &org,
is_admin => is_admin,
members => members.iter().map(|m| context! {
user_id => m.user_id,
username => m.username,
role => m.role,
joined_at => m.joined_at,
}).collect::<Vec<_>>(),
},
)
.map_err(|e| {
tracing::error!("template error: {e:#}");
error_page(
&state,
StatusCode::INTERNAL_SERVER_ERROR,
"Something went wrong",
"Please try again.",
)
})?;
Ok(Html(html).into_response())
}
#[derive(Deserialize)]
struct AddMemberForm {
username: String,
role: String,
_csrf: String,
}
async fn add_member_submit(
State(state): State<AppState>,
session: Session,
Path(org): Path<String>,
Form(form): Form<AddMemberForm>,
) -> Result<Response, Response> {
let orgs = &session.user.orgs;
let current_org = require_org_membership(&state, orgs, &org)?;
require_admin(&state, current_org)?;
if !auth::validate_csrf(&session, &form._csrf) {
return Err(error_page(
&state,
StatusCode::FORBIDDEN,
"Invalid request",
"CSRF validation failed. Please try again.",
));
}
let _ = state
.platform_client
.add_member(
&session.access_token,
&current_org.organisation_id,
&form.username,
&form.role,
)
.await
.map_err(|e| {
tracing::error!("failed to add member: {e}");
error_page(
&state,
StatusCode::INTERNAL_SERVER_ERROR,
"Something went wrong",
"Please try again.",
)
})?;
Ok(Redirect::to(&format!("/orgs/{org}/settings/members")).into_response())
}
#[derive(Deserialize)]
struct UpdateRoleForm {
role: String,
_csrf: String,
}
async fn update_member_role_submit(
State(state): State<AppState>,
session: Session,
Path((org, user_id)): Path<(String, String)>,
Form(form): Form<UpdateRoleForm>,
) -> Result<Response, Response> {
let orgs = &session.user.orgs;
let current_org = require_org_membership(&state, orgs, &org)?;
require_admin(&state, current_org)?;
if !auth::validate_csrf(&session, &form._csrf) {
return Err(error_page(
&state,
StatusCode::FORBIDDEN,
"Invalid request",
"CSRF validation failed. Please try again.",
));
}
let _ = state
.platform_client
.update_member_role(
&session.access_token,
&current_org.organisation_id,
&user_id,
&form.role,
)
.await
.map_err(|e| {
tracing::error!("failed to update member role: {e}");
error_page(
&state,
StatusCode::INTERNAL_SERVER_ERROR,
"Something went wrong",
"Please try again.",
)
})?;
Ok(Redirect::to(&format!("/orgs/{org}/settings/members")).into_response())
}
#[derive(Deserialize)]
struct CsrfForm {
_csrf: String,
}
async fn remove_member_submit(
State(state): State<AppState>,
session: Session,
Path((org, user_id)): Path<(String, String)>,
Form(form): Form<CsrfForm>,
) -> Result<Response, Response> {
let orgs = &session.user.orgs;
let current_org = require_org_membership(&state, orgs, &org)?;
require_admin(&state, current_org)?;
if !auth::validate_csrf(&session, &form._csrf) {
return Err(error_page(
&state,
StatusCode::FORBIDDEN,
"Invalid request",
"CSRF validation failed. Please try again.",
));
}
state
.platform_client
.remove_member(
&session.access_token,
&current_org.organisation_id,
&user_id,
)
.await
.map_err(|e| {
tracing::error!("failed to remove member: {e}");
error_page(
&state,
StatusCode::INTERNAL_SERVER_ERROR,
"Something went wrong",
"Please try again.",
)
})?;
Ok(Redirect::to(&format!("/orgs/{org}/settings/members")).into_response())
}

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

View File

@@ -0,0 +1,440 @@
use axum::body::Body;
use axum::http::{Request, StatusCode};
use forage_core::auth::*;
use tower::ServiceExt;
use crate::build_router;
use crate::test_support::*;
// ─── Signup ─────────────────────────────────────────────────────────
#[tokio::test]
async fn signup_page_returns_200() {
let response = test_app()
.oneshot(
Request::builder()
.uri("/signup")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
}
#[tokio::test]
async fn signup_page_contains_form() {
let response = test_app()
.oneshot(
Request::builder()
.uri("/signup")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
let html = String::from_utf8(body.to_vec()).unwrap();
assert!(html.contains("username"));
assert!(html.contains("email"));
assert!(html.contains("password"));
}
#[tokio::test]
async fn signup_duplicate_shows_error() {
let mock = MockForestClient::with_behavior(MockBehavior {
register_result: Some(Err(AuthError::AlreadyExists("username taken".into()))),
..Default::default()
});
let response = test_app_with(mock)
.oneshot(
Request::builder()
.method("POST")
.uri("/signup")
.header("content-type", "application/x-www-form-urlencoded")
.body(Body::from(
"username=testuser&email=test@example.com&password=SecurePass123&password_confirm=SecurePass123",
))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
let html = String::from_utf8(body.to_vec()).unwrap();
assert!(html.contains("already registered"));
}
#[tokio::test]
async fn signup_when_forest_unavailable_shows_error() {
let mock = MockForestClient::with_behavior(MockBehavior {
register_result: Some(Err(AuthError::Unavailable("connection refused".into()))),
..Default::default()
});
let response = test_app_with(mock)
.oneshot(
Request::builder()
.method("POST")
.uri("/signup")
.header("content-type", "application/x-www-form-urlencoded")
.body(Body::from(
"username=testuser&email=test@example.com&password=SecurePass123&password_confirm=SecurePass123",
))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
let html = String::from_utf8(body.to_vec()).unwrap();
assert!(html.contains("temporarily unavailable"));
}
#[tokio::test]
async fn signup_password_too_short_shows_validation_error() {
let response = test_app()
.oneshot(
Request::builder()
.method("POST")
.uri("/signup")
.header("content-type", "application/x-www-form-urlencoded")
.body(Body::from(
"username=testuser&email=test@example.com&password=short&password_confirm=short",
))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
let html = String::from_utf8(body.to_vec()).unwrap();
assert!(html.contains("at least 12"));
}
#[tokio::test]
async fn signup_password_mismatch_shows_error() {
let response = test_app()
.oneshot(
Request::builder()
.method("POST")
.uri("/signup")
.header("content-type", "application/x-www-form-urlencoded")
.body(Body::from(
"username=testuser&email=test@example.com&password=SecurePass123&password_confirm=differentpassword",
))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
let html = String::from_utf8(body.to_vec()).unwrap();
assert!(html.contains("do not match"));
}
// ─── Login ──────────────────────────────────────────────────────────
#[tokio::test]
async fn login_page_returns_200() {
let response = test_app()
.oneshot(
Request::builder()
.uri("/login")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
}
#[tokio::test]
async fn login_page_contains_form() {
let response = test_app()
.oneshot(
Request::builder()
.uri("/login")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
let html = String::from_utf8(body.to_vec()).unwrap();
assert!(html.contains("identifier"));
assert!(html.contains("password"));
}
#[tokio::test]
async fn login_submit_success_sets_session_cookie() {
let response = test_app()
.oneshot(
Request::builder()
.method("POST")
.uri("/login")
.header("content-type", "application/x-www-form-urlencoded")
.body(Body::from(
"identifier=testuser&password=CorrectPass123",
))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::SEE_OTHER);
assert_eq!(response.headers().get("location").unwrap(), "/dashboard");
// Should have a single forage_session cookie
let cookies: Vec<_> = response.headers().get_all("set-cookie").iter().collect();
assert!(!cookies.is_empty());
let cookie_str = cookies[0].to_str().unwrap();
assert!(cookie_str.contains("forage_session="));
assert!(cookie_str.contains("HttpOnly"));
}
#[tokio::test]
async fn login_submit_bad_credentials_shows_error() {
let response = test_app()
.oneshot(
Request::builder()
.method("POST")
.uri("/login")
.header("content-type", "application/x-www-form-urlencoded")
.body(Body::from("identifier=testuser&password=wrongpassword"))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
let html = String::from_utf8(body.to_vec()).unwrap();
assert!(html.contains("Invalid"));
}
#[tokio::test]
async fn login_when_forest_unavailable_shows_error() {
let mock = MockForestClient::with_behavior(MockBehavior {
login_result: Some(Err(AuthError::Unavailable("connection refused".into()))),
..Default::default()
});
let response = test_app_with(mock)
.oneshot(
Request::builder()
.method("POST")
.uri("/login")
.header("content-type", "application/x-www-form-urlencoded")
.body(Body::from("identifier=testuser&password=CorrectPass123"))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
let html = String::from_utf8(body.to_vec()).unwrap();
assert!(html.contains("temporarily unavailable"));
}
#[tokio::test]
async fn login_empty_fields_shows_validation_error() {
let response = test_app()
.oneshot(
Request::builder()
.method("POST")
.uri("/login")
.header("content-type", "application/x-www-form-urlencoded")
.body(Body::from("identifier=&password="))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
let html = String::from_utf8(body.to_vec()).unwrap();
assert!(html.contains("required"));
}
// ─── Session / Dashboard ────────────────────────────────────────────
#[tokio::test]
async fn dashboard_without_auth_redirects_to_login() {
let response = test_app()
.oneshot(
Request::builder()
.uri("/dashboard")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::SEE_OTHER);
assert_eq!(response.headers().get("location").unwrap(), "/login");
}
#[tokio::test]
async fn dashboard_with_session_shows_page() {
let (state, sessions) = test_state();
let cookie = create_test_session(&sessions).await;
let app = build_router(state);
let response = app
.oneshot(
Request::builder()
.uri("/dashboard")
.header("cookie", cookie)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
// Dashboard now renders a proper page
assert_eq!(response.status(), StatusCode::OK);
}
#[tokio::test]
async fn dashboard_with_expired_token_refreshes_transparently() {
let (state, sessions) = test_state();
let cookie = create_expired_session(&sessions).await;
let app = build_router(state);
let response = app
.oneshot(
Request::builder()
.uri("/dashboard")
.header("cookie", cookie)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
// Should succeed (render dashboard) because refresh_token works
assert_eq!(response.status(), StatusCode::OK);
}
#[tokio::test]
async fn dashboard_with_invalid_session_redirects() {
let response = test_app()
.oneshot(
Request::builder()
.uri("/dashboard")
.header("cookie", "forage_session=nonexistent")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::SEE_OTHER);
assert_eq!(response.headers().get("location").unwrap(), "/login");
}
#[tokio::test]
async fn old_token_cookies_are_ignored() {
// Old-style cookies should not authenticate
let response = test_app()
.oneshot(
Request::builder()
.uri("/dashboard")
.header("cookie", "forage_access=mock-access; forage_refresh=mock-refresh")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::SEE_OTHER);
assert_eq!(response.headers().get("location").unwrap(), "/login");
}
#[tokio::test]
async fn expired_session_with_failed_refresh_redirects_to_login() {
let mock = MockForestClient::with_behavior(MockBehavior {
refresh_result: Some(Err(AuthError::NotAuthenticated)),
..Default::default()
});
let (state, sessions) = test_state_with(mock, MockPlatformClient::new());
let cookie = create_expired_session(&sessions).await;
let app = build_router(state);
let response = app
.oneshot(
Request::builder()
.uri("/dashboard")
.header("cookie", cookie)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::SEE_OTHER);
assert_eq!(response.headers().get("location").unwrap(), "/login");
// Session should be destroyed
assert_eq!(sessions.session_count(), 0);
}
// ─── Logout ─────────────────────────────────────────────────────────
#[tokio::test]
async fn logout_destroys_session_and_redirects() {
let (state, sessions) = test_state();
let cookie = create_test_session(&sessions).await;
let app = build_router(state);
assert_eq!(sessions.session_count(), 1);
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/logout")
.header("cookie", &cookie)
.header("content-type", "application/x-www-form-urlencoded")
.body(Body::from("_csrf=test-csrf"))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::SEE_OTHER);
assert_eq!(response.headers().get("location").unwrap(), "/");
// Session should be destroyed
assert_eq!(sessions.session_count(), 0);
}
#[tokio::test]
async fn logout_with_invalid_csrf_returns_403() {
let (state, sessions) = test_state();
let cookie = create_test_session(&sessions).await;
let app = build_router(state);
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/logout")
.header("cookie", &cookie)
.header("content-type", "application/x-www-form-urlencoded")
.body(Body::from("_csrf=wrong-token"))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::FORBIDDEN);
// Session should NOT be destroyed
assert_eq!(sessions.session_count(), 1);
}

View File

@@ -0,0 +1,4 @@
mod auth_tests;
mod pages_tests;
mod platform_tests;
mod token_tests;

View File

@@ -0,0 +1,82 @@
use axum::body::Body;
use axum::http::{Request, StatusCode};
use tower::ServiceExt;
use crate::test_support::*;
#[tokio::test]
async fn landing_page_returns_200() {
let response = test_app()
.oneshot(Request::builder().uri("/").body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
}
#[tokio::test]
async fn landing_page_contains_expected_content() {
let response = test_app()
.oneshot(Request::builder().uri("/").body(Body::empty()).unwrap())
.await
.unwrap();
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
let html = String::from_utf8(body.to_vec()).unwrap();
assert!(html.contains("Forage - The Platform for Forest"));
assert!(html.contains("forest.cue"));
assert!(html.contains("Component Registry"));
assert!(html.contains("Managed Deployments"));
assert!(html.contains("Container Deployments"));
}
#[tokio::test]
async fn pricing_page_returns_200() {
let response = test_app()
.oneshot(
Request::builder()
.uri("/pricing")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
}
#[tokio::test]
async fn pricing_page_contains_all_tiers() {
let response = test_app()
.oneshot(
Request::builder()
.uri("/pricing")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
let html = String::from_utf8(body.to_vec()).unwrap();
assert!(html.contains("Free"));
assert!(html.contains("Developer"));
assert!(html.contains("Team"));
assert!(html.contains("Enterprise"));
assert!(html.contains("$10"));
assert!(html.contains("$25"));
}
#[tokio::test]
async fn unknown_route_returns_404() {
let response = test_app()
.oneshot(
Request::builder()
.uri("/nonexistent")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::NOT_FOUND);
}

View File

@@ -0,0 +1,837 @@
use axum::body::Body;
use axum::http::{Request, StatusCode};
use forage_core::platform::{
Artifact, ArtifactContext, ArtifactDestination, ArtifactRef, ArtifactSource, PlatformError,
};
use tower::ServiceExt;
use crate::build_router;
use crate::test_support::*;
// ─── Dashboard ─────────────────────────────────────────────────────
#[tokio::test]
async fn dashboard_with_orgs_shows_dashboard_page() {
let (state, sessions) = test_state();
let cookie = create_test_session(&sessions).await;
let app = build_router(state);
let response = app
.oneshot(
Request::builder()
.uri("/dashboard")
.header("cookie", &cookie)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
let html = String::from_utf8(body.to_vec()).unwrap();
assert!(html.contains("testorg"));
assert!(html.contains("Recent activity"));
}
#[tokio::test]
async fn dashboard_shows_recent_artifacts() {
let (state, sessions) = test_state();
let cookie = create_test_session(&sessions).await;
let app = build_router(state);
let response = app
.oneshot(
Request::builder()
.uri("/dashboard")
.header("cookie", &cookie)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
let html = String::from_utf8(body.to_vec()).unwrap();
assert!(html.contains("Deploy v1.0"));
}
#[tokio::test]
async fn dashboard_empty_activity_shows_empty_state() {
let platform = MockPlatformClient::with_behavior(MockPlatformBehavior {
list_projects_result: Some(Ok(vec!["my-api".into()])),
list_artifacts_result: Some(Ok(vec![])),
..Default::default()
});
let (state, sessions) = test_state_with(MockForestClient::new(), platform);
let cookie = create_test_session(&sessions).await;
let app = build_router(state);
let response = app
.oneshot(
Request::builder()
.uri("/dashboard")
.header("cookie", &cookie)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
let html = String::from_utf8(body.to_vec()).unwrap();
assert!(html.contains("No recent activity"));
}
#[tokio::test]
async fn dashboard_no_orgs_shows_onboarding() {
let (state, sessions) = test_state();
let cookie = create_test_session_no_orgs(&sessions).await;
let app = build_router(state);
let response = app
.oneshot(
Request::builder()
.uri("/dashboard")
.header("cookie", &cookie)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
let html = String::from_utf8(body.to_vec()).unwrap();
assert!(html.contains("Create organisation"));
}
// ─── Create organisation ───────────────────────────────────────────
#[tokio::test]
async fn create_org_success_redirects_to_new_org() {
let (state, sessions) = test_state();
let cookie = create_test_session(&sessions).await;
let app = build_router(state);
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/orgs")
.header("cookie", &cookie)
.header("content-type", "application/x-www-form-urlencoded")
.body(Body::from("name=my-new-org&_csrf=test-csrf"))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::SEE_OTHER);
assert_eq!(
response.headers().get("location").unwrap(),
"/orgs/my-new-org/projects"
);
}
#[tokio::test]
async fn create_org_invalid_slug_shows_error() {
let (state, sessions) = test_state();
let cookie = create_test_session(&sessions).await;
let app = build_router(state);
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/orgs")
.header("cookie", &cookie)
.header("content-type", "application/x-www-form-urlencoded")
.body(Body::from("name=INVALID ORG&_csrf=test-csrf"))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
let html = String::from_utf8(body.to_vec()).unwrap();
assert!(html.contains("invalid") || html.contains("Invalid"));
}
#[tokio::test]
async fn create_org_invalid_csrf_returns_403() {
let (state, sessions) = test_state();
let cookie = create_test_session(&sessions).await;
let app = build_router(state);
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/orgs")
.header("cookie", &cookie)
.header("content-type", "application/x-www-form-urlencoded")
.body(Body::from("name=my-org&_csrf=wrong-token"))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::FORBIDDEN);
}
#[tokio::test]
async fn create_org_grpc_failure_shows_error() {
let platform = MockPlatformClient::with_behavior(MockPlatformBehavior {
create_organisation_result: Some(Err(PlatformError::Unavailable(
"connection refused".into(),
))),
..Default::default()
});
let (state, sessions) = test_state_with(MockForestClient::new(), platform);
let cookie = create_test_session(&sessions).await;
let app = build_router(state);
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/orgs")
.header("cookie", &cookie)
.header("content-type", "application/x-www-form-urlencoded")
.body(Body::from("name=my-org&_csrf=test-csrf"))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
let html = String::from_utf8(body.to_vec()).unwrap();
assert!(
html.contains("unavailable") || html.contains("error") || html.contains("try again")
);
}
// ─── Members page ──────────────────────────────────────────────────
#[tokio::test]
async fn members_page_returns_200_with_members() {
let (state, sessions) = test_state();
let cookie = create_test_session(&sessions).await;
let app = build_router(state);
let response = app
.oneshot(
Request::builder()
.uri("/orgs/testorg/settings/members")
.header("cookie", &cookie)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
let html = String::from_utf8(body.to_vec()).unwrap();
assert!(html.contains("testuser"));
assert!(html.contains("owner"));
}
#[tokio::test]
async fn members_page_non_member_returns_403() {
let (state, sessions) = test_state();
let cookie = create_test_session(&sessions).await;
let app = build_router(state);
let response = app
.oneshot(
Request::builder()
.uri("/orgs/unknown-org/settings/members")
.header("cookie", &cookie)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::FORBIDDEN);
}
#[tokio::test]
async fn members_page_invalid_slug_returns_400() {
let (state, sessions) = test_state();
let cookie = create_test_session(&sessions).await;
let app = build_router(state);
let response = app
.oneshot(
Request::builder()
.uri("/orgs/INVALID%20ORG/settings/members")
.header("cookie", &cookie)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn members_page_unauthenticated_redirects() {
let response = test_app()
.oneshot(
Request::builder()
.uri("/orgs/testorg/settings/members")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::SEE_OTHER);
assert_eq!(response.headers().get("location").unwrap(), "/login");
}
// ─── Member management ─────────────────────────────────────────────
#[tokio::test]
async fn add_member_success_redirects() {
let (state, sessions) = test_state();
let cookie = create_test_session(&sessions).await;
let app = build_router(state);
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/orgs/testorg/settings/members")
.header("cookie", &cookie)
.header("content-type", "application/x-www-form-urlencoded")
.body(Body::from(
"username=newuser&role=member&_csrf=test-csrf",
))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::SEE_OTHER);
assert_eq!(
response.headers().get("location").unwrap(),
"/orgs/testorg/settings/members"
);
}
#[tokio::test]
async fn add_member_invalid_csrf_returns_403() {
let (state, sessions) = test_state();
let cookie = create_test_session(&sessions).await;
let app = build_router(state);
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/orgs/testorg/settings/members")
.header("cookie", &cookie)
.header("content-type", "application/x-www-form-urlencoded")
.body(Body::from(
"username=newuser&role=member&_csrf=wrong-token",
))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::FORBIDDEN);
}
#[tokio::test]
async fn remove_member_success_redirects() {
let (state, sessions) = test_state();
let cookie = create_test_session(&sessions).await;
let app = build_router(state);
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/orgs/testorg/settings/members/user-456/remove")
.header("cookie", &cookie)
.header("content-type", "application/x-www-form-urlencoded")
.body(Body::from("_csrf=test-csrf"))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::SEE_OTHER);
assert_eq!(
response.headers().get("location").unwrap(),
"/orgs/testorg/settings/members"
);
}
#[tokio::test]
async fn update_member_role_success_redirects() {
let (state, sessions) = test_state();
let cookie = create_test_session(&sessions).await;
let app = build_router(state);
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/orgs/testorg/settings/members/user-456/role")
.header("cookie", &cookie)
.header("content-type", "application/x-www-form-urlencoded")
.body(Body::from("role=admin&_csrf=test-csrf"))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::SEE_OTHER);
assert_eq!(
response.headers().get("location").unwrap(),
"/orgs/testorg/settings/members"
);
}
#[tokio::test]
async fn add_member_non_admin_returns_403() {
let (state, sessions) = test_state();
let cookie = create_test_session_member(&sessions).await;
let app = build_router(state);
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/orgs/testorg/settings/members")
.header("cookie", &cookie)
.header("content-type", "application/x-www-form-urlencoded")
.body(Body::from(
"username=newuser&role=member&_csrf=test-csrf",
))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::FORBIDDEN);
}
#[tokio::test]
async fn remove_member_non_admin_returns_403() {
let (state, sessions) = test_state();
let cookie = create_test_session_member(&sessions).await;
let app = build_router(state);
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/orgs/testorg/settings/members/user-456/remove")
.header("cookie", &cookie)
.header("content-type", "application/x-www-form-urlencoded")
.body(Body::from("_csrf=test-csrf"))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::FORBIDDEN);
}
#[tokio::test]
async fn update_role_non_admin_returns_403() {
let (state, sessions) = test_state();
let cookie = create_test_session_member(&sessions).await;
let app = build_router(state);
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/orgs/testorg/settings/members/user-456/role")
.header("cookie", &cookie)
.header("content-type", "application/x-www-form-urlencoded")
.body(Body::from("role=admin&_csrf=test-csrf"))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::FORBIDDEN);
}
#[tokio::test]
async fn members_page_non_admin_can_view() {
let (state, sessions) = test_state();
let cookie = create_test_session_member(&sessions).await;
let app = build_router(state);
let response = app
.oneshot(
Request::builder()
.uri("/orgs/testorg/settings/members")
.header("cookie", &cookie)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
let html = String::from_utf8(body.to_vec()).unwrap();
// Can see members but NOT the add member form
assert!(html.contains("testuser"));
assert!(!html.contains("Add member"));
}
// ─── Projects list ──────────────────────────────────────────────────
#[tokio::test]
async fn projects_list_returns_200_with_projects() {
let (state, sessions) = test_state();
let cookie = create_test_session(&sessions).await;
let app = build_router(state);
let response = app
.oneshot(
Request::builder()
.uri("/orgs/testorg/projects")
.header("cookie", &cookie)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
let html = String::from_utf8(body.to_vec()).unwrap();
assert!(html.contains("my-api"));
}
#[tokio::test]
async fn projects_list_empty_shows_empty_state() {
let platform = MockPlatformClient::with_behavior(MockPlatformBehavior {
list_projects_result: Some(Ok(vec![])),
..Default::default()
});
let (state, sessions) = test_state_with(MockForestClient::new(), platform);
let cookie = create_test_session(&sessions).await;
let app = build_router(state);
let response = app
.oneshot(
Request::builder()
.uri("/orgs/testorg/projects")
.header("cookie", &cookie)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
let html = String::from_utf8(body.to_vec()).unwrap();
assert!(html.contains("No projects yet"));
}
#[tokio::test]
async fn projects_list_unauthenticated_redirects() {
let response = test_app()
.oneshot(
Request::builder()
.uri("/orgs/testorg/projects")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::SEE_OTHER);
assert_eq!(response.headers().get("location").unwrap(), "/login");
}
#[tokio::test]
async fn projects_list_non_member_returns_403() {
let (state, sessions) = test_state();
let cookie = create_test_session(&sessions).await;
let app = build_router(state);
let response = app
.oneshot(
Request::builder()
.uri("/orgs/unknown-org/projects")
.header("cookie", &cookie)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::FORBIDDEN);
}
#[tokio::test]
async fn projects_list_platform_unavailable_degrades_gracefully() {
let platform = MockPlatformClient::with_behavior(MockPlatformBehavior {
list_projects_result: Some(Err(PlatformError::Unavailable(
"connection refused".into(),
))),
..Default::default()
});
let (state, sessions) = test_state_with(MockForestClient::new(), platform);
let cookie = create_test_session(&sessions).await;
let app = build_router(state);
let response = app
.oneshot(
Request::builder()
.uri("/orgs/testorg/projects")
.header("cookie", &cookie)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
let html = String::from_utf8(body.to_vec()).unwrap();
assert!(html.contains("No projects yet"));
}
// ─── Project detail ─────────────────────────────────────────────────
#[tokio::test]
async fn project_detail_returns_200_with_artifacts() {
let (state, sessions) = test_state();
let cookie = create_test_session(&sessions).await;
let app = build_router(state);
let response = app
.oneshot(
Request::builder()
.uri("/orgs/testorg/projects/my-api")
.header("cookie", &cookie)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
let html = String::from_utf8(body.to_vec()).unwrap();
assert!(html.contains("my-api"));
assert!(html.contains("Deploy v1.0"));
assert!(html.contains("my-api-abc123"));
}
#[tokio::test]
async fn project_detail_empty_artifacts_shows_empty_state() {
let platform = MockPlatformClient::with_behavior(MockPlatformBehavior {
list_artifacts_result: Some(Ok(vec![])),
..Default::default()
});
let (state, sessions) = test_state_with(MockForestClient::new(), platform);
let cookie = create_test_session(&sessions).await;
let app = build_router(state);
let response = app
.oneshot(
Request::builder()
.uri("/orgs/testorg/projects/my-api")
.header("cookie", &cookie)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
let html = String::from_utf8(body.to_vec()).unwrap();
assert!(html.contains("No releases yet"));
}
#[tokio::test]
async fn project_detail_shows_enriched_artifact_data() {
let platform = MockPlatformClient::with_behavior(MockPlatformBehavior {
list_artifacts_result: Some(Ok(vec![Artifact {
artifact_id: "art-2".into(),
slug: "my-api-def456".into(),
context: ArtifactContext {
title: "Deploy v2.0".into(),
description: Some("Major release".into()),
},
source: Some(ArtifactSource {
user: Some("ci-bot".into()),
email: None,
source_type: Some("github-actions".into()),
run_url: Some("https://github.com/org/repo/actions/runs/123".into()),
}),
git_ref: Some(ArtifactRef {
commit_sha: "abc1234".into(),
branch: Some("main".into()),
commit_message: Some("feat: add new feature".into()),
version: Some("v2.0.0".into()),
repo_url: None,
}),
destinations: vec![ArtifactDestination {
name: "production".into(),
environment: "prod".into(),
}],
created_at: "2026-03-07T12:00:00Z".into(),
}])),
..Default::default()
});
let (state, sessions) = test_state_with(MockForestClient::new(), platform);
let cookie = create_test_session(&sessions).await;
let app = build_router(state);
let response = app
.oneshot(
Request::builder()
.uri("/orgs/testorg/projects/my-api")
.header("cookie", &cookie)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
let html = String::from_utf8(body.to_vec()).unwrap();
assert!(html.contains("v2.0.0"));
assert!(html.contains("main"));
assert!(html.contains("abc1234"));
assert!(html.contains("production"));
}
// ─── Usage ──────────────────────────────────────────────────────────
#[tokio::test]
async fn usage_page_returns_200() {
let (state, sessions) = test_state();
let cookie = create_test_session(&sessions).await;
let app = build_router(state);
let response = app
.oneshot(
Request::builder()
.uri("/orgs/testorg/usage")
.header("cookie", &cookie)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
let html = String::from_utf8(body.to_vec()).unwrap();
assert!(html.contains("Early Access"));
assert!(html.contains("testorg"));
}
#[tokio::test]
async fn usage_page_unauthenticated_redirects() {
let response = test_app()
.oneshot(
Request::builder()
.uri("/orgs/testorg/usage")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::SEE_OTHER);
assert_eq!(response.headers().get("location").unwrap(), "/login");
}
#[tokio::test]
async fn usage_page_non_member_returns_403() {
let (state, sessions) = test_state();
let cookie = create_test_session(&sessions).await;
let app = build_router(state);
let response = app
.oneshot(
Request::builder()
.uri("/orgs/unknown-org/usage")
.header("cookie", &cookie)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::FORBIDDEN);
}
// ─── Nav & Error rendering ──────────────────────────────────────────
#[tokio::test]
async fn authenticated_pages_show_app_nav() {
let (state, sessions) = test_state();
let cookie = create_test_session(&sessions).await;
let app = build_router(state);
let response = app
.oneshot(
Request::builder()
.uri("/orgs/testorg/projects")
.header("cookie", &cookie)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
let html = String::from_utf8(body.to_vec()).unwrap();
assert!(html.contains("Sign out"));
assert!(html.contains("testorg"));
assert!(!html.contains("Sign in"));
}
#[tokio::test]
async fn error_403_renders_html() {
let (state, sessions) = test_state();
let cookie = create_test_session(&sessions).await;
let app = build_router(state);
let response = app
.oneshot(
Request::builder()
.uri("/orgs/unknown-org/projects")
.header("cookie", &cookie)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::FORBIDDEN);
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
let html = String::from_utf8(body.to_vec()).unwrap();
assert!(html.contains("Access denied"));
}

View File

@@ -0,0 +1,32 @@
use axum::body::Body;
use axum::http::{Request, StatusCode};
use forage_core::auth::*;
use tower::ServiceExt;
use crate::build_router;
use crate::test_support::*;
#[tokio::test]
async fn delete_token_error_returns_500() {
let mock = MockForestClient::with_behavior(MockBehavior {
delete_token_result: Some(Err(AuthError::Other("db error".into()))),
..Default::default()
});
let (state, sessions) = test_state_with(mock, MockPlatformClient::new());
let cookie = create_test_session(&sessions).await;
let app = build_router(state);
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/settings/tokens/tok-1/delete")
.header("cookie", cookie)
.header("content-type", "application/x-www-form-urlencoded")
.body(Body::from("_csrf=test-csrf"))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
}