@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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)]
|
||||
|
||||
115
crates/forage-core/src/session/file_store.rs
Normal file
115
crates/forage-core/src/session/file_store.rs
Normal 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}"))),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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."))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 => ¤t_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,
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
313
crates/forage-server/src/tests/account_tests.rs
Normal file
313
crates/forage-server/src/tests/account_tests.rs
Normal 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);
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
mod account_tests;
|
||||
mod auth_tests;
|
||||
mod pages_tests;
|
||||
mod platform_tests;
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user