feat: add swimlanes

Signed-off-by: kjuulh <contact@kjuulh.io>
This commit is contained in:
2026-03-07 22:53:48 +01:00
parent 9fe1630986
commit 45353089c2
51 changed files with 3845 additions and 147 deletions

View File

@@ -109,4 +109,33 @@ pub trait ForestAuth: Send + Sync {
access_token: &str,
token_id: &str,
) -> Result<(), AuthError>;
async fn update_username(
&self,
access_token: &str,
user_id: &str,
new_username: &str,
) -> Result<User, AuthError>;
async fn change_password(
&self,
access_token: &str,
user_id: &str,
current_password: &str,
new_password: &str,
) -> Result<(), AuthError>;
async fn add_email(
&self,
access_token: &str,
user_id: &str,
email: &str,
) -> Result<UserEmail, AuthError>;
async fn remove_email(
&self,
access_token: &str,
user_id: &str,
email: &str,
) -> Result<(), AuthError>;
}

View File

@@ -36,6 +36,10 @@ pub struct Artifact {
pub struct ArtifactContext {
pub title: String,
pub description: Option<String>,
#[serde(default)]
pub web: Option<String>,
#[serde(default)]
pub pr: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -59,6 +63,12 @@ pub struct ArtifactRef {
pub struct ArtifactDestination {
pub name: String,
pub environment: String,
#[serde(default)]
pub type_organisation: Option<String>,
#[serde(default)]
pub type_name: Option<String>,
#[serde(default)]
pub type_version: Option<u64>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -69,6 +79,24 @@ pub struct OrgMember {
pub joined_at: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Destination {
pub name: String,
pub environment: String,
pub organisation: String,
#[serde(default)]
pub metadata: std::collections::HashMap<String, String>,
#[serde(default)]
pub dest_type: Option<DestinationType>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DestinationType {
pub organisation: String,
pub name: String,
pub version: u64,
}
#[derive(Debug, Clone, thiserror::Error)]
pub enum PlatformError {
#[error("not authenticated")]
@@ -140,6 +168,18 @@ pub trait ForestPlatform: Send + Sync {
user_id: &str,
role: &str,
) -> Result<OrgMember, PlatformError>;
async fn get_artifact_by_slug(
&self,
access_token: &str,
slug: &str,
) -> Result<Artifact, PlatformError>;
async fn list_destinations(
&self,
access_token: &str,
organisation: &str,
) -> Result<Vec<Destination>, PlatformError>;
}
#[cfg(test)]

View File

@@ -0,0 +1,115 @@
use std::path::{Path, PathBuf};
use chrono::{Duration, Utc};
use super::{SessionData, SessionError, SessionId, SessionStore};
/// File-based session store. Each session is a JSON file in a directory.
/// Suitable for local development — sessions survive server restarts.
pub struct FileSessionStore {
dir: PathBuf,
max_inactive: Duration,
}
impl FileSessionStore {
pub fn new(dir: impl AsRef<Path>) -> Result<Self, SessionError> {
let dir = dir.as_ref().to_path_buf();
std::fs::create_dir_all(&dir)
.map_err(|e| SessionError::Store(format!("failed to create session dir: {e}")))?;
Ok(Self {
dir,
max_inactive: Duration::days(30),
})
}
fn session_path(&self, id: &SessionId) -> PathBuf {
// Use a safe filename: replace any non-alphanumeric chars
let safe_name: String = id
.as_str()
.chars()
.map(|c| if c.is_ascii_alphanumeric() || c == '-' || c == '_' { c } else { '_' })
.collect();
self.dir.join(format!("{safe_name}.json"))
}
/// Remove sessions inactive for longer than `max_inactive`.
pub fn reap_expired(&self) {
let cutoff = Utc::now() - self.max_inactive;
let entries = match std::fs::read_dir(&self.dir) {
Ok(e) => e,
Err(_) => return,
};
for entry in entries.flatten() {
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) != Some("json") {
continue;
}
if let Ok(contents) = std::fs::read_to_string(&path) {
if let Ok(data) = serde_json::from_str::<SessionData>(&contents) {
if data.last_seen_at < cutoff {
let _ = std::fs::remove_file(&path);
}
}
}
}
}
pub fn session_count(&self) -> usize {
std::fs::read_dir(&self.dir)
.map(|e| {
e.flatten()
.filter(|e| {
e.path()
.extension()
.and_then(|ext| ext.to_str())
== Some("json")
})
.count()
})
.unwrap_or(0)
}
}
#[async_trait::async_trait]
impl SessionStore for FileSessionStore {
async fn create(&self, data: SessionData) -> Result<SessionId, SessionError> {
let id = SessionId::generate();
let path = self.session_path(&id);
let json = serde_json::to_string_pretty(&data)
.map_err(|e| SessionError::Store(format!("serialize error: {e}")))?;
std::fs::write(&path, json)
.map_err(|e| SessionError::Store(format!("write error: {e}")))?;
Ok(id)
}
async fn get(&self, id: &SessionId) -> Result<Option<SessionData>, SessionError> {
let path = self.session_path(id);
match std::fs::read_to_string(&path) {
Ok(contents) => {
let data: SessionData = serde_json::from_str(&contents)
.map_err(|e| SessionError::Store(format!("deserialize error: {e}")))?;
Ok(Some(data))
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(e) => Err(SessionError::Store(format!("read error: {e}"))),
}
}
async fn update(&self, id: &SessionId, data: SessionData) -> Result<(), SessionError> {
let path = self.session_path(id);
let json = serde_json::to_string_pretty(&data)
.map_err(|e| SessionError::Store(format!("serialize error: {e}")))?;
std::fs::write(&path, json)
.map_err(|e| SessionError::Store(format!("write error: {e}")))?;
Ok(())
}
async fn delete(&self, id: &SessionId) -> Result<(), SessionError> {
let path = self.session_path(id);
match std::fs::remove_file(&path) {
Ok(()) => Ok(()),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(e) => Err(SessionError::Store(format!("delete error: {e}"))),
}
}
}

View File

@@ -1,5 +1,7 @@
mod file_store;
mod store;
pub use file_store::FileSessionStore;
pub use store::InMemorySessionStore;
use crate::auth::UserEmail;
@@ -85,7 +87,7 @@ pub fn generate_csrf_token() -> String {
}
/// Server-side session data. Never exposed to the browser.
#[derive(Debug, Clone)]
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SessionData {
pub access_token: String,
pub refresh_token: String,

View File

@@ -2,8 +2,8 @@ use forage_core::auth::{
AuthError, AuthTokens, CreatedToken, ForestAuth, PersonalAccessToken, User, UserEmail,
};
use forage_core::platform::{
Artifact, ArtifactContext, ArtifactDestination, ArtifactSource, ForestPlatform, Organisation,
OrgMember, PlatformError,
Artifact, ArtifactContext, ArtifactDestination, ArtifactSource, Destination, ForestPlatform,
Organisation, OrgMember, PlatformError,
};
use forage_grpc::organisation_service_client::OrganisationServiceClient;
use forage_grpc::release_service_client::ReleaseServiceClient;
@@ -274,6 +274,103 @@ impl ForestAuth for GrpcForestClient {
.map_err(map_status)?;
Ok(())
}
async fn update_username(
&self,
access_token: &str,
user_id: &str,
new_username: &str,
) -> Result<User, AuthError> {
let req = Self::authed_request(
access_token,
forage_grpc::UpdateUserRequest {
user_id: user_id.into(),
username: Some(new_username.into()),
},
)?;
let resp = self
.client()
.update_user(req)
.await
.map_err(map_status)?
.into_inner();
let user = resp.user.ok_or(AuthError::Other("no user in response".into()))?;
Ok(convert_user(user))
}
async fn change_password(
&self,
access_token: &str,
user_id: &str,
current_password: &str,
new_password: &str,
) -> Result<(), AuthError> {
let req = Self::authed_request(
access_token,
forage_grpc::ChangePasswordRequest {
user_id: user_id.into(),
current_password: current_password.into(),
new_password: new_password.into(),
},
)?;
self.client()
.change_password(req)
.await
.map_err(map_status)?;
Ok(())
}
async fn add_email(
&self,
access_token: &str,
user_id: &str,
email: &str,
) -> Result<UserEmail, AuthError> {
let req = Self::authed_request(
access_token,
forage_grpc::AddEmailRequest {
user_id: user_id.into(),
email: email.into(),
},
)?;
let resp = self
.client()
.add_email(req)
.await
.map_err(map_status)?
.into_inner();
let email = resp.email.ok_or(AuthError::Other("no email in response".into()))?;
Ok(UserEmail {
email: email.email,
verified: email.verified,
})
}
async fn remove_email(
&self,
access_token: &str,
user_id: &str,
email: &str,
) -> Result<(), AuthError> {
let req = Self::authed_request(
access_token,
forage_grpc::RemoveEmailRequest {
user_id: user_id.into(),
email: email.into(),
},
)?;
self.client()
.remove_email(req)
.await
.map_err(map_status)?;
Ok(())
}
}
fn convert_organisations(
@@ -307,6 +404,21 @@ fn convert_artifact(a: forage_grpc::Artifact) -> Artifact {
.map(|d| ArtifactDestination {
name: d.name,
environment: d.environment,
type_organisation: if d.type_organisation.is_empty() {
None
} else {
Some(d.type_organisation)
},
type_name: if d.type_name.is_empty() {
None
} else {
Some(d.type_name)
},
type_version: if d.type_version == 0 {
None
} else {
Some(d.type_version)
},
})
.collect();
Artifact {
@@ -319,6 +431,8 @@ fn convert_artifact(a: forage_grpc::Artifact) -> Artifact {
} else {
ctx.description
},
web: ctx.web.filter(|v| !v.is_empty()),
pr: ctx.pr.filter(|v| !v.is_empty()),
},
source,
git_ref: None,
@@ -548,6 +662,40 @@ impl ForestPlatform for GrpcForestClient {
.ok_or(PlatformError::Other("no member in response".into()))?;
Ok(convert_member(member))
}
async fn get_artifact_by_slug(
&self,
access_token: &str,
slug: &str,
) -> Result<Artifact, PlatformError> {
let req = platform_authed_request(
access_token,
forage_grpc::GetArtifactBySlugRequest {
slug: slug.into(),
},
)?;
let resp = self
.release_client()
.get_artifact_by_slug(req)
.await
.map_err(map_platform_status)?
.into_inner();
let artifact = resp
.artifact
.ok_or(PlatformError::NotFound("artifact not found".into()))?;
Ok(convert_artifact(artifact))
}
async fn list_destinations(
&self,
_access_token: &str,
_organisation: &str,
) -> Result<Vec<Destination>, PlatformError> {
// DestinationService client not yet generated; return empty for now
Ok(vec![])
}
}
#[cfg(test)]

View File

@@ -8,7 +8,7 @@ use std::net::SocketAddr;
use std::sync::Arc;
use axum::Router;
use forage_core::session::{InMemorySessionStore, SessionStore};
use forage_core::session::{FileSessionStore, SessionStore};
use forage_db::PgSessionStore;
use tower_http::services::ServeDir;
use tower_http::trace::TraceLayer;
@@ -63,10 +63,11 @@ async fn main() -> anyhow::Result<()> {
pg_store
} else {
tracing::info!("using in-memory session store (set DATABASE_URL for persistence)");
let mem_store = Arc::new(InMemorySessionStore::new());
let session_dir = std::env::var("SESSION_DIR").unwrap_or_else(|_| "target/sessions".into());
tracing::info!("using file session store at {session_dir} (set DATABASE_URL for PostgreSQL)");
let file_store = Arc::new(FileSessionStore::new(&session_dir).expect("failed to create session dir"));
let reaper = mem_store.clone();
let reaper = file_store.clone();
tokio::spawn(async move {
let mut interval = tokio::time::interval(std::time::Duration::from_secs(300));
loop {
@@ -76,7 +77,7 @@ async fn main() -> anyhow::Result<()> {
}
});
mem_store
file_store
};
let forest_client = Arc::new(forest_client);

View File

@@ -10,7 +10,7 @@ use serde::Deserialize;
use super::error_page;
use crate::auth::{self, MaybeSession, Session};
use crate::state::AppState;
use forage_core::auth::{validate_email, validate_password, validate_username};
use forage_core::auth::{validate_email, validate_password, validate_username, UserEmail};
use forage_core::session::{CachedOrg, CachedUser, SessionData, generate_csrf_token};
pub fn router() -> Router<AppState> {
@@ -23,6 +23,14 @@ pub fn router() -> Router<AppState> {
get(tokens_page).post(create_token_submit),
)
.route("/settings/tokens/{id}/delete", post(delete_token_submit))
.route("/settings/account", get(account_page))
.route("/settings/account/username", post(update_username_submit))
.route("/settings/account/password", post(change_password_submit))
.route("/settings/account/emails", post(add_email_submit))
.route(
"/settings/account/emails/remove",
post(remove_email_submit),
)
}
// ─── Signup ─────────────────────────────────────────────────────────
@@ -367,6 +375,8 @@ async fn tokens_page(
title => "API Tokens - Forage",
description => "Manage your personal access tokens",
user => context! { username => session.user.username },
current_org => session.user.orgs.first().map(|o| &o.name),
orgs => session.user.orgs.iter().map(|o| context! { name => o.name, role => o.role }).collect::<Vec<_>>(),
tokens => tokens.iter().map(|t| context! {
token_id => t.token_id,
name => t.name,
@@ -376,6 +386,7 @@ async fn tokens_page(
}).collect::<Vec<_>>(),
csrf_token => &session.csrf_token,
created_token => None::<String>,
active_tab => "tokens",
},
)
.map_err(|e| {
@@ -429,6 +440,8 @@ async fn create_token_submit(
title => "API Tokens - Forage",
description => "Manage your personal access tokens",
user => context! { username => session.user.username },
current_org => session.user.orgs.first().map(|o| &o.name),
orgs => session.user.orgs.iter().map(|o| context! { name => o.name, role => o.role }).collect::<Vec<_>>(),
tokens => tokens.iter().map(|t| context! {
token_id => t.token_id,
name => t.name,
@@ -438,6 +451,7 @@ async fn create_token_submit(
}).collect::<Vec<_>>(),
csrf_token => &session.csrf_token,
created_token => Some(created.raw_token),
active_tab => "tokens",
},
)
.map_err(|e| {
@@ -469,3 +483,256 @@ async fn delete_token_submit(
Ok(Redirect::to("/settings/tokens").into_response())
}
// ─── Account settings ────────────────────────────────────────────────
async fn account_page(
State(state): State<AppState>,
session: Session,
) -> Result<Response, Response> {
render_account(&state, &session, None)
}
#[allow(clippy::result_large_err)]
fn render_account(
state: &AppState,
session: &Session,
error: Option<&str>,
) -> Result<Response, Response> {
let html = state
.templates
.render(
"pages/account.html.jinja",
context! {
title => "Account Settings - Forage",
description => "Manage your account settings",
user => context! {
username => &session.user.username,
user_id => &session.user.user_id,
emails => session.user.emails.iter().map(|e| context! {
email => &e.email,
verified => e.verified,
}).collect::<Vec<_>>(),
},
current_org => session.user.orgs.first().map(|o| &o.name),
orgs => session.user.orgs.iter().map(|o| context! { name => o.name, role => o.role }).collect::<Vec<_>>(),
csrf_token => &session.csrf_token,
error => error,
active_tab => "account",
},
)
.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 UpdateUsernameForm {
username: String,
_csrf: String,
}
async fn update_username_submit(
State(state): State<AppState>,
session: Session,
Form(form): Form<UpdateUsernameForm>,
) -> Result<Response, Response> {
if !auth::validate_csrf(&session, &form._csrf) {
return Err(error_page(
&state,
StatusCode::FORBIDDEN,
"Invalid request",
"CSRF validation failed.",
));
}
if let Err(e) = validate_username(&form.username) {
return render_account(&state, &session, Some(&e.0));
}
match state
.forest_client
.update_username(&session.access_token, &session.user.user_id, &form.username)
.await
{
Ok(updated_user) => {
// Update cached username in session
if let Ok(Some(mut session_data)) = state.sessions.get(&session.session_id).await {
if let Some(ref mut user) = session_data.user {
user.username = updated_user.username;
}
let _ = state
.sessions
.update(&session.session_id, session_data)
.await;
}
Ok(Redirect::to("/settings/account").into_response())
}
Err(forage_core::auth::AuthError::AlreadyExists(_)) => {
render_account(&state, &session, Some("Username is already taken."))
}
Err(e) => {
tracing::error!("failed to update username: {e}");
render_account(&state, &session, Some("Could not update username. Please try again."))
}
}
}
#[derive(Deserialize)]
struct ChangePasswordForm {
current_password: String,
new_password: String,
new_password_confirm: String,
_csrf: String,
}
async fn change_password_submit(
State(state): State<AppState>,
session: Session,
Form(form): Form<ChangePasswordForm>,
) -> Result<Response, Response> {
if !auth::validate_csrf(&session, &form._csrf) {
return Err(error_page(
&state,
StatusCode::FORBIDDEN,
"Invalid request",
"CSRF validation failed.",
));
}
if form.new_password != form.new_password_confirm {
return render_account(&state, &session, Some("New passwords do not match."));
}
if let Err(e) = validate_password(&form.new_password) {
return render_account(&state, &session, Some(&e.0));
}
match state
.forest_client
.change_password(
&session.access_token,
&session.user.user_id,
&form.current_password,
&form.new_password,
)
.await
{
Ok(()) => Ok(Redirect::to("/settings/account").into_response()),
Err(forage_core::auth::AuthError::InvalidCredentials) => {
render_account(&state, &session, Some("Current password is incorrect."))
}
Err(e) => {
tracing::error!("failed to change password: {e}");
render_account(&state, &session, Some("Could not change password. Please try again."))
}
}
}
#[derive(Deserialize)]
struct AddEmailForm {
email: String,
_csrf: String,
}
async fn add_email_submit(
State(state): State<AppState>,
session: Session,
Form(form): Form<AddEmailForm>,
) -> Result<Response, Response> {
if !auth::validate_csrf(&session, &form._csrf) {
return Err(error_page(
&state,
StatusCode::FORBIDDEN,
"Invalid request",
"CSRF validation failed.",
));
}
if let Err(e) = validate_email(&form.email) {
return render_account(&state, &session, Some(&e.0));
}
match state
.forest_client
.add_email(&session.access_token, &session.user.user_id, &form.email)
.await
{
Ok(new_email) => {
// Update cached emails in session
if let Ok(Some(mut session_data)) = state.sessions.get(&session.session_id).await {
if let Some(ref mut user) = session_data.user {
user.emails.push(UserEmail {
email: new_email.email,
verified: new_email.verified,
});
}
let _ = state
.sessions
.update(&session.session_id, session_data)
.await;
}
Ok(Redirect::to("/settings/account").into_response())
}
Err(forage_core::auth::AuthError::AlreadyExists(_)) => {
render_account(&state, &session, Some("Email is already registered."))
}
Err(e) => {
tracing::error!("failed to add email: {e}");
render_account(&state, &session, Some("Could not add email. Please try again."))
}
}
}
#[derive(Deserialize)]
struct RemoveEmailForm {
email: String,
_csrf: String,
}
async fn remove_email_submit(
State(state): State<AppState>,
session: Session,
Form(form): Form<RemoveEmailForm>,
) -> Result<Response, Response> {
if !auth::validate_csrf(&session, &form._csrf) {
return Err(error_page(
&state,
StatusCode::FORBIDDEN,
"Invalid request",
"CSRF validation failed.",
));
}
match state
.forest_client
.remove_email(&session.access_token, &session.user.user_id, &form.email)
.await
{
Ok(()) => {
// Update cached emails in session
if let Ok(Some(mut session_data)) = state.sessions.get(&session.session_id).await {
if let Some(ref mut user) = session_data.user {
user.emails.retain(|e| e.email != form.email);
}
let _ = state
.sessions
.update(&session.session_id, session_data)
.await;
}
Ok(Redirect::to("/settings/account").into_response())
}
Err(e) => {
tracing::error!("failed to remove email: {e}");
render_account(&state, &session, Some("Could not remove email. Please try again."))
}
}
}

View File

@@ -18,6 +18,12 @@ pub fn router() -> Router<AppState> {
.route("/orgs", post(create_org_submit))
.route("/orgs/{org}/projects", get(projects_list))
.route("/orgs/{org}/projects/{project}", get(project_detail))
.route(
"/orgs/{org}/projects/{project}/releases/{slug}",
get(artifact_detail),
)
.route("/orgs/{org}/releases", get(releases_page))
.route("/orgs/{org}/destinations", get(destinations_page))
.route("/orgs/{org}/usage", get(usage))
.route(
"/orgs/{org}/settings/members",
@@ -97,6 +103,7 @@ async fn dashboard(
description => "Create your first organisation",
user => context! { username => session.user.username },
csrf_token => &session.csrf_token,
active_tab => "dashboard",
},
)
.map_err(|e| {
@@ -111,40 +118,16 @@ async fn dashboard(
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();
// Fetch recent releases for the first org to show the pipeline on dashboard
let first_org = &orgs[0];
let projects = state
.platform_client
.list_projects(&session.access_token, &first_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 items = fetch_org_artifacts(&state, &session.access_token, &first_org.name, &projects).await;
let data = build_timeline(items, &first_org.name);
let html = state
.templates
@@ -155,8 +138,11 @@ async fn dashboard(
description => "Your Forage dashboard",
user => context! { username => session.user.username },
csrf_token => &session.csrf_token,
current_org => &first_org.name,
orgs => orgs_context(orgs),
recent_activity => recent_activity,
timeline => data.timeline,
lanes => data.lanes,
active_tab => "dashboard",
},
)
.map_err(|e| {
@@ -205,6 +191,7 @@ async fn create_org_submit(
description => "Create your first organisation",
user => context! { username => session.user.username },
csrf_token => &session.csrf_token,
active_tab => "dashboard",
error => "Invalid organisation name. Use lowercase letters, numbers, and hyphens only.",
},
)
@@ -248,6 +235,7 @@ async fn create_org_submit(
description => "Create your first organisation",
user => context! { username => session.user.username },
csrf_token => &session.csrf_token,
active_tab => "dashboard",
error => "Could not create organisation. Please try again.",
},
)
@@ -289,6 +277,7 @@ async fn projects_list(
orgs => orgs_context(orgs),
org_name => &org,
projects => projects,
active_tab => "projects",
},
)
.map_err(|e| {
@@ -329,6 +318,15 @@ async fn project_detail(
.await
.unwrap_or_default();
let items: Vec<ArtifactWithProject> = artifacts
.into_iter()
.map(|a| ArtifactWithProject {
artifact: a,
project_name: project.clone(),
})
.collect();
let data = build_timeline(items, &org);
let html = state
.templates
.render(
@@ -342,23 +340,99 @@ 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,
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<_>>(),
active_tab => "projects",
timeline => data.timeline,
lanes => data.lanes,
},
)
.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())
}
// ─── Artifact detail ─────────────────────────────────────────────────
async fn artifact_detail(
State(state): State<AppState>,
session: Session,
Path((org, project, slug)): Path<(String, String, String)>,
) -> Result<Response, Response> {
let orgs = &session.user.orgs;
require_org_membership(&state, orgs, &org)?;
if !validate_slug(&project) {
return Err(error_page(
&state,
StatusCode::BAD_REQUEST,
"Invalid request",
"Invalid project name.",
));
}
let artifact = state
.platform_client
.get_artifact_by_slug(&session.access_token, &slug)
.await
.map_err(|e| match e {
forage_core::platform::PlatformError::NotFound(_) => error_page(
&state,
StatusCode::NOT_FOUND,
"Not found",
"This release could not be found.",
),
other => {
tracing::error!("failed to fetch artifact: {other}");
error_page(
&state,
StatusCode::INTERNAL_SERVER_ERROR,
"Something went wrong",
"Please try again.",
)
}
})?;
let html = state
.templates
.render(
"pages/artifact_detail.html.jinja",
context! {
title => format!("{} - {} - {} - Forage", artifact.context.title, project, org),
description => artifact.context.description,
user => context! { username => session.user.username },
csrf_token => &session.csrf_token,
current_org => &org,
orgs => orgs_context(orgs),
org_name => &org,
project_name => &project,
active_tab => "projects",
artifact => context! {
slug => artifact.slug,
title => artifact.context.title,
description => artifact.context.description,
web => artifact.context.web,
pr => artifact.context.pr,
created_at => artifact.created_at,
source_user => artifact.source.as_ref().and_then(|s| s.user.clone()),
source_email => artifact.source.as_ref().and_then(|s| s.email.clone()),
source_type => artifact.source.as_ref().and_then(|s| s.source_type.clone()),
run_url => artifact.source.as_ref().and_then(|s| s.run_url.clone()),
commit_sha => artifact.git_ref.as_ref().map(|r| r.commit_sha.clone()),
branch => artifact.git_ref.as_ref().and_then(|r| r.branch.clone()),
commit_message => artifact.git_ref.as_ref().and_then(|r| r.commit_message.clone()),
version => artifact.git_ref.as_ref().and_then(|r| r.version.clone()),
repo_url => artifact.git_ref.as_ref().and_then(|r| r.repo_url.clone()),
destinations => artifact.destinations.iter().map(|d| {
context! { name => d.name, environment => d.environment }
}).collect::<Vec<_>>(),
},
},
)
.map_err(|e| {
@@ -404,6 +478,259 @@ async fn usage(
org_name => &org,
role => &current_org_data.role,
project_count => projects.len(),
active_tab => "usage",
},
)
.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())
}
// ─── Timeline builder (shared between dashboard, project detail, releases) ───
struct ArtifactWithProject {
artifact: forage_core::platform::Artifact,
project_name: String,
}
struct TimelineData {
timeline: Vec<minijinja::Value>,
lanes: Vec<minijinja::Value>,
}
fn build_timeline(items: Vec<ArtifactWithProject>, org_name: &str) -> TimelineData {
struct RawRelease {
value: minijinja::Value,
has_dests: bool,
}
let mut raw_releases: Vec<RawRelease> = Vec::new();
let mut env_set = std::collections::BTreeSet::new();
for item in items {
let artifact = item.artifact;
let project = &item.project_name;
let mut release_envs = Vec::new();
let dests: Vec<minijinja::Value> = artifact
.destinations
.iter()
.map(|d| {
env_set.insert(d.environment.clone());
release_envs.push(d.environment.clone());
context! {
name => d.name,
environment => d.environment,
type_name => d.type_name,
type_version => d.type_version,
}
})
.collect();
let has_dests = !dests.is_empty();
let dest_envs_str = release_envs.join(",");
raw_releases.push(RawRelease {
value: context! {
slug => artifact.slug,
title => artifact.context.title,
description => artifact.context.description,
project_name => project,
org_name => org_name,
created_at => artifact.created_at,
commit_sha => artifact.git_ref.as_ref().map(|r| r.commit_sha.clone()),
branch => artifact.git_ref.as_ref().and_then(|r| r.branch.clone()),
version => artifact.git_ref.as_ref().and_then(|r| r.version.clone()),
source_user => artifact.source.as_ref().and_then(|s| s.user.clone()),
source_type => artifact.source.as_ref().and_then(|s| s.source_type.clone()),
destinations => dests,
dest_envs => dest_envs_str,
},
has_dests,
});
}
let lanes: Vec<minijinja::Value> = env_set
.into_iter()
.map(|env| context! { name => env })
.collect();
let mut timeline_items: Vec<minijinja::Value> = Vec::new();
let mut hidden_buf: Vec<minijinja::Value> = Vec::new();
for raw in raw_releases {
if raw.has_dests {
if !hidden_buf.is_empty() {
let count = hidden_buf.len();
timeline_items.push(context! {
kind => "hidden",
count => count,
releases => std::mem::take(&mut hidden_buf),
});
}
timeline_items.push(context! {
kind => "release",
release => raw.value,
});
} else {
hidden_buf.push(raw.value);
}
}
if !hidden_buf.is_empty() {
let count = hidden_buf.len();
timeline_items.push(context! {
kind => "hidden",
count => count,
releases => std::mem::take(&mut hidden_buf),
});
}
TimelineData {
timeline: timeline_items,
lanes,
}
}
/// Fetch all artifacts across projects and return as ArtifactWithProject list.
async fn fetch_org_artifacts(
state: &AppState,
access_token: &str,
org: &str,
projects: &[String],
) -> Vec<ArtifactWithProject> {
let mut items = Vec::new();
for project in projects {
let artifacts = state
.platform_client
.list_artifacts(access_token, org, project)
.await
.unwrap_or_default();
for artifact in artifacts {
items.push(ArtifactWithProject {
artifact,
project_name: project.clone(),
});
}
}
items
}
// ─── Releases (Up-inspired pipeline) ─────────────────────────────────
async fn releases_page(
State(state): State<AppState>,
session: Session,
Path(org): Path<String>,
) -> Result<Response, Response> {
let orgs = &session.user.orgs;
require_org_membership(&state, orgs, &org)?;
let projects = state
.platform_client
.list_projects(&session.access_token, &org)
.await
.unwrap_or_default();
let items = fetch_org_artifacts(&state, &session.access_token, &org, &projects).await;
let data = build_timeline(items, &org);
let html = state
.templates
.render(
"pages/releases.html.jinja",
context! {
title => format!("Releases - {org} - Forage"),
description => format!("Deployment pipeline for {org}"),
user => context! { username => session.user.username },
csrf_token => &session.csrf_token,
current_org => &org,
orgs => orgs_context(orgs),
org_name => &org,
timeline => data.timeline,
lanes => data.lanes,
active_tab => "releases",
},
)
.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())
}
// ─── Destinations ────────────────────────────────────────────────────
async fn destinations_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 is_admin = current_org.role == "owner" || current_org.role == "admin";
let projects = state
.platform_client
.list_projects(&session.access_token, &org)
.await
.unwrap_or_default();
// Aggregate unique destinations from artifacts
let mut destinations = Vec::new();
let mut seen = std::collections::HashSet::new();
for project in &projects {
let artifacts = state
.platform_client
.list_artifacts(&session.access_token, &org, project)
.await
.unwrap_or_default();
for artifact in &artifacts {
for dest in &artifact.destinations {
let key = (dest.name.clone(), dest.environment.clone());
if seen.insert(key) {
destinations.push(context! {
name => dest.name,
environment => dest.environment,
project_name => project,
artifact_title => artifact.context.title,
artifact_slug => artifact.slug,
created_at => artifact.created_at,
});
}
}
}
}
let html = state
.templates
.render(
"pages/destinations.html.jinja",
context! {
title => format!("Destinations - {org} - Forage"),
description => format!("Deployment destinations for {org}"),
user => context! { username => session.user.username },
csrf_token => &session.csrf_token,
current_org => &org,
orgs => orgs_context(orgs),
org_name => &org,
destinations => destinations,
is_admin => is_admin,
active_tab => "destinations",
},
)
.map_err(|e| {
@@ -450,6 +777,7 @@ async fn members_page(
orgs => orgs_context(orgs),
org_name => &org,
is_admin => is_admin,
active_tab => "members",
members => members.iter().map(|m| context! {
user_id => m.user_id,
username => m.username,

View File

@@ -3,6 +3,62 @@ use std::path::Path;
use anyhow::Context;
use minijinja::Environment;
/// Format an ISO 8601 / RFC 3339 timestamp as a human-friendly relative time.
fn timeago(value: &str) -> String {
let Ok(dt) = chrono::DateTime::parse_from_rfc3339(value)
.or_else(|_| chrono::DateTime::parse_from_rfc3339(&format!("{value}Z")))
.or_else(|_| {
// Try parsing "2026-01-01" as a date
chrono::NaiveDate::parse_from_str(value, "%Y-%m-%d")
.map(|d| {
d.and_hms_opt(0, 0, 0)
.unwrap()
.and_utc()
.fixed_offset()
})
})
else {
return value.to_string();
};
let now = chrono::Utc::now();
let diff = now.signed_duration_since(dt);
if diff.num_seconds() < 60 {
"just now".into()
} else if diff.num_minutes() < 60 {
let m = diff.num_minutes();
format!("{m}m ago")
} else if diff.num_hours() < 24 {
let h = diff.num_hours();
format!("{h}h ago")
} else if diff.num_days() < 30 {
let d = diff.num_days();
format!("{d}d ago")
} else {
dt.format("%d %b %Y").to_string()
}
}
/// Format an ISO 8601 / RFC 3339 timestamp as a full human-readable datetime.
fn datetime(value: &str) -> String {
let Ok(dt) = chrono::DateTime::parse_from_rfc3339(value)
.or_else(|_| chrono::DateTime::parse_from_rfc3339(&format!("{value}Z")))
.or_else(|_| {
chrono::NaiveDate::parse_from_str(value, "%Y-%m-%d").map(|d| {
d.and_hms_opt(0, 0, 0)
.unwrap()
.and_utc()
.fixed_offset()
})
})
else {
return value.to_string();
};
dt.format("%d %b %Y %H:%M:%S UTC").to_string()
}
#[derive(Clone)]
pub struct TemplateEngine {
env: Environment<'static>,
@@ -16,6 +72,8 @@ impl TemplateEngine {
let mut env = Environment::new();
env.set_loader(minijinja::path_loader(path));
env.add_filter("timeago", |v: String| -> String { timeago(&v) });
env.add_filter("datetime", |v: String| -> String { datetime(&v) });
Ok(Self { env })
}

View File

@@ -4,7 +4,7 @@ use axum::Router;
use chrono::Utc;
use forage_core::auth::*;
use forage_core::platform::{
Artifact, ArtifactContext, ForestPlatform, Organisation, OrgMember, PlatformError,
Artifact, ArtifactContext, Destination, ForestPlatform, Organisation, OrgMember, PlatformError,
};
use forage_core::session::{
CachedOrg, CachedUser, InMemorySessionStore, SessionData, SessionStore,
@@ -23,6 +23,10 @@ pub(crate) struct MockBehavior {
pub list_tokens_result: Option<Result<Vec<PersonalAccessToken>, AuthError>>,
pub create_token_result: Option<Result<CreatedToken, AuthError>>,
pub delete_token_result: Option<Result<(), AuthError>>,
pub update_username_result: Option<Result<User, AuthError>>,
pub change_password_result: Option<Result<(), AuthError>>,
pub add_email_result: Option<Result<UserEmail, AuthError>>,
pub remove_email_result: Option<Result<(), AuthError>>,
}
/// Configurable mock behavior for platform (orgs, projects, artifacts).
@@ -36,6 +40,8 @@ pub(crate) struct MockPlatformBehavior {
pub add_member_result: Option<Result<OrgMember, PlatformError>>,
pub remove_member_result: Option<Result<(), PlatformError>>,
pub update_member_role_result: Option<Result<OrgMember, PlatformError>>,
pub get_artifact_by_slug_result: Option<Result<Artifact, PlatformError>>,
pub list_destinations_result: Option<Result<Vec<Destination>, PlatformError>>,
}
pub(crate) fn ok_tokens() -> AuthTokens {
@@ -166,6 +172,57 @@ impl ForestAuth for MockForestClient {
let b = self.behavior.lock().unwrap();
b.delete_token_result.clone().unwrap_or(Ok(()))
}
async fn update_username(
&self,
_access_token: &str,
_user_id: &str,
new_username: &str,
) -> Result<User, AuthError> {
let b = self.behavior.lock().unwrap();
b.update_username_result.clone().unwrap_or(Ok(User {
user_id: "user-123".into(),
username: new_username.into(),
emails: vec![UserEmail {
email: "test@example.com".into(),
verified: true,
}],
}))
}
async fn change_password(
&self,
_access_token: &str,
_user_id: &str,
_current_password: &str,
_new_password: &str,
) -> Result<(), AuthError> {
let b = self.behavior.lock().unwrap();
b.change_password_result.clone().unwrap_or(Ok(()))
}
async fn add_email(
&self,
_access_token: &str,
_user_id: &str,
email: &str,
) -> Result<UserEmail, AuthError> {
let b = self.behavior.lock().unwrap();
b.add_email_result.clone().unwrap_or(Ok(UserEmail {
email: email.into(),
verified: false,
}))
}
async fn remove_email(
&self,
_access_token: &str,
_user_id: &str,
_email: &str,
) -> Result<(), AuthError> {
let b = self.behavior.lock().unwrap();
b.remove_email_result.clone().unwrap_or(Ok(()))
}
}
pub(crate) struct MockPlatformClient {
@@ -228,6 +285,8 @@ impl ForestPlatform for MockPlatformClient {
context: ArtifactContext {
title: "Deploy v1.0".into(),
description: Some("Initial release".into()),
web: None,
pr: None,
},
source: None,
git_ref: None,
@@ -302,6 +361,39 @@ impl ForestPlatform for MockPlatformClient {
joined_at: Some("2026-01-01T00:00:00Z".into()),
}))
}
async fn get_artifact_by_slug(
&self,
_access_token: &str,
slug: &str,
) -> Result<Artifact, PlatformError> {
let b = self.behavior.lock().unwrap();
b.get_artifact_by_slug_result
.clone()
.unwrap_or(Ok(Artifact {
artifact_id: "art-1".into(),
slug: slug.into(),
context: ArtifactContext {
title: "Deploy v1.0".into(),
description: Some("Initial release".into()),
web: None,
pr: None,
},
source: None,
git_ref: None,
destinations: vec![],
created_at: "2026-03-07T12:00:00Z".into(),
}))
}
async fn list_destinations(
&self,
_access_token: &str,
_organisation: &str,
) -> Result<Vec<Destination>, PlatformError> {
let b = self.behavior.lock().unwrap();
b.list_destinations_result.clone().unwrap_or(Ok(vec![]))
}
}
pub(crate) fn make_templates() -> TemplateEngine {

View File

@@ -0,0 +1,313 @@
use axum::body::Body;
use axum::http::{Request, StatusCode};
use forage_core::auth::AuthError;
use tower::ServiceExt;
use crate::build_router;
use crate::test_support::*;
// ─── Account settings page ──────────────────────────────────────────
#[tokio::test]
async fn account_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("/settings/account")
.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("test@example.com"));
}
#[tokio::test]
async fn account_page_unauthenticated_redirects() {
let response = test_app()
.oneshot(
Request::builder()
.uri("/settings/account")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::SEE_OTHER);
assert_eq!(response.headers().get("location").unwrap(), "/login");
}
// ─── Update username ────────────────────────────────────────────────
#[tokio::test]
async fn update_username_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("/settings/account/username")
.header("cookie", &cookie)
.header("content-type", "application/x-www-form-urlencoded")
.body(Body::from("username=newname&_csrf=test-csrf"))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::SEE_OTHER);
assert_eq!(
response.headers().get("location").unwrap(),
"/settings/account"
);
}
#[tokio::test]
async fn update_username_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("/settings/account/username")
.header("cookie", &cookie)
.header("content-type", "application/x-www-form-urlencoded")
.body(Body::from("username=newname&_csrf=wrong"))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::FORBIDDEN);
}
#[tokio::test]
async fn update_username_invalid_name_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("/settings/account/username")
.header("cookie", &cookie)
.header("content-type", "application/x-www-form-urlencoded")
.body(Body::from("username=&_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("required") || html.contains("Username"));
}
// ─── Change password ────────────────────────────────────────────────
#[tokio::test]
async fn change_password_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("/settings/account/password")
.header("cookie", &cookie)
.header("content-type", "application/x-www-form-urlencoded")
.body(Body::from(
"current_password=OldPass12345&new_password=NewPass123456&new_password_confirm=NewPass123456&_csrf=test-csrf",
))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::SEE_OTHER);
assert_eq!(
response.headers().get("location").unwrap(),
"/settings/account"
);
}
#[tokio::test]
async fn change_password_mismatch_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("/settings/account/password")
.header("cookie", &cookie)
.header("content-type", "application/x-www-form-urlencoded")
.body(Body::from(
"current_password=OldPass12345&new_password=NewPass123456&new_password_confirm=Different12345&_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("match"));
}
#[tokio::test]
async fn change_password_wrong_current_shows_error() {
let mock = MockForestClient::with_behavior(MockBehavior {
change_password_result: Some(Err(AuthError::InvalidCredentials)),
..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/account/password")
.header("cookie", &cookie)
.header("content-type", "application/x-www-form-urlencoded")
.body(Body::from(
"current_password=WrongPass1234&new_password=NewPass123456&new_password_confirm=NewPass123456&_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("incorrect") || html.contains("invalid") || html.contains("Invalid") || html.contains("wrong"));
}
// ─── Add email ──────────────────────────────────────────────────────
#[tokio::test]
async fn add_email_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("/settings/account/emails")
.header("cookie", &cookie)
.header("content-type", "application/x-www-form-urlencoded")
.body(Body::from("email=new@example.com&_csrf=test-csrf"))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::SEE_OTHER);
assert_eq!(
response.headers().get("location").unwrap(),
"/settings/account"
);
}
#[tokio::test]
async fn add_email_invalid_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("/settings/account/emails")
.header("cookie", &cookie)
.header("content-type", "application/x-www-form-urlencoded")
.body(Body::from("email=notanemail&_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") || html.contains("valid email"));
}
// ─── Remove email ───────────────────────────────────────────────────
#[tokio::test]
async fn remove_email_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("/settings/account/emails/remove")
.header("cookie", &cookie)
.header("content-type", "application/x-www-form-urlencoded")
.body(Body::from("email=old@example.com&_csrf=test-csrf"))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::SEE_OTHER);
assert_eq!(
response.headers().get("location").unwrap(),
"/settings/account"
);
}
#[tokio::test]
async fn remove_email_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("/settings/account/emails/remove")
.header("cookie", &cookie)
.header("content-type", "application/x-www-form-urlencoded")
.body(Body::from("email=old@example.com&_csrf=wrong"))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::FORBIDDEN);
}

View File

@@ -1,3 +1,4 @@
mod account_tests;
mod auth_tests;
mod pages_tests;
mod platform_tests;

View File

@@ -676,6 +676,8 @@ async fn project_detail_shows_enriched_artifact_data() {
context: ArtifactContext {
title: "Deploy v2.0".into(),
description: Some("Major release".into()),
web: None,
pr: None,
},
source: Some(ArtifactSource {
user: Some("ci-bot".into()),
@@ -693,6 +695,9 @@ async fn project_detail_shows_enriched_artifact_data() {
destinations: vec![ArtifactDestination {
name: "production".into(),
environment: "prod".into(),
type_organisation: None,
type_name: None,
type_version: None,
}],
created_at: "2026-03-07T12:00:00Z".into(),
}])),
@@ -723,6 +728,165 @@ async fn project_detail_shows_enriched_artifact_data() {
assert!(html.contains("production"));
}
// ─── Artifact detail ────────────────────────────────────────────────
#[tokio::test]
async fn artifact_detail_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/projects/my-api/releases/my-api-abc123")
.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-abc123"));
assert!(html.contains("Deploy v1.0"));
}
#[tokio::test]
async fn artifact_detail_shows_enriched_data() {
let platform = MockPlatformClient::with_behavior(MockPlatformBehavior {
get_artifact_by_slug_result: Some(Ok(Artifact {
artifact_id: "art-2".into(),
slug: "my-api-def456".into(),
context: ArtifactContext {
title: "Deploy v2.0".into(),
description: Some("Major release".into()),
web: Some("https://example.com".into()),
pr: Some("https://github.com/org/repo/pull/42".into()),
},
source: Some(ArtifactSource {
user: Some("ci-bot".into()),
email: Some("ci@example.com".into()),
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: Some("https://github.com/org/repo".into()),
}),
destinations: vec![
ArtifactDestination {
name: "production".into(),
environment: "prod".into(),
type_organisation: None,
type_name: None,
type_version: None,
},
ArtifactDestination {
name: "staging".into(),
environment: "staging".into(),
type_organisation: None,
type_name: None,
type_version: None,
},
],
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/releases/my-api-def456")
.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("ci-bot"));
assert!(html.contains("production"));
assert!(html.contains("staging"));
assert!(html.contains("Major release"));
}
#[tokio::test]
async fn artifact_detail_not_found_returns_404() {
let platform = MockPlatformClient::with_behavior(MockPlatformBehavior {
get_artifact_by_slug_result: Some(Err(PlatformError::NotFound(
"artifact not found".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/releases/nonexistent")
.header("cookie", &cookie)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn artifact_detail_unauthenticated_redirects() {
let response = test_app()
.oneshot(
Request::builder()
.uri("/orgs/testorg/projects/my-api/releases/my-api-abc123")
.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 artifact_detail_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/my-api/releases/some-slug")
.header("cookie", &cookie)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::FORBIDDEN);
}
// ─── Usage ──────────────────────────────────────────────────────────
#[tokio::test]
@@ -835,3 +999,175 @@ async fn error_403_renders_html() {
let html = String::from_utf8(body.to_vec()).unwrap();
assert!(html.contains("Access denied"));
}
// ─── Destinations ────────────────────────────────────────────────────
#[tokio::test]
async fn destinations_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/destinations")
.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("Destinations"));
}
#[tokio::test]
async fn destinations_page_unauthenticated_redirects() {
let response = test_app()
.oneshot(
Request::builder()
.uri("/orgs/testorg/destinations")
.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 destinations_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/otherorg/destinations")
.header("cookie", &cookie)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::FORBIDDEN);
}
#[tokio::test]
async fn destinations_page_shows_empty_state() {
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/destinations")
.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 destinations yet"));
}
// ─── Releases ────────────────────────────────────────────────────────
#[tokio::test]
async fn releases_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/releases")
.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("Continuous deployment"));
}
#[tokio::test]
async fn releases_page_unauthenticated_redirects() {
let response = test_app()
.oneshot(
Request::builder()
.uri("/orgs/testorg/releases")
.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 releases_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/otherorg/releases")
.header("cookie", &cookie)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::FORBIDDEN);
}
#[tokio::test]
async fn releases_page_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/releases")
.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"));
}