@@ -90,3 +90,8 @@ Follow the VSDD pipeline **religiously**. No shortcuts, no skipping phases.
|
||||
- Routes are organized by feature in `routes/` modules
|
||||
- All public API endpoints return proper HTTP status codes
|
||||
- Configuration via environment variables with sensible defaults
|
||||
- **Tests live in separate files**, never inline in the main source file:
|
||||
- Unit tests for private functions: `#[cfg(test)] mod tests` in the same file (e.g., `forest_client.rs`)
|
||||
- Route/integration tests: `src/tests/` directory with files per feature area (e.g., `auth_tests.rs`, `platform_tests.rs`)
|
||||
- Mock infrastructure and test helpers: `src/test_support.rs` (`pub(crate)` items)
|
||||
- Keep production source files clean - no test code bloat
|
||||
|
||||
@@ -23,6 +23,12 @@ pub struct Artifact {
|
||||
pub artifact_id: String,
|
||||
pub slug: String,
|
||||
pub context: ArtifactContext,
|
||||
#[serde(default)]
|
||||
pub source: Option<ArtifactSource>,
|
||||
#[serde(default)]
|
||||
pub git_ref: Option<ArtifactRef>,
|
||||
#[serde(default)]
|
||||
pub destinations: Vec<ArtifactDestination>,
|
||||
pub created_at: String,
|
||||
}
|
||||
|
||||
@@ -32,6 +38,37 @@ pub struct ArtifactContext {
|
||||
pub description: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ArtifactSource {
|
||||
pub user: Option<String>,
|
||||
pub email: Option<String>,
|
||||
pub source_type: Option<String>,
|
||||
pub run_url: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ArtifactRef {
|
||||
pub commit_sha: String,
|
||||
pub branch: Option<String>,
|
||||
pub commit_message: Option<String>,
|
||||
pub version: Option<String>,
|
||||
pub repo_url: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ArtifactDestination {
|
||||
pub name: String,
|
||||
pub environment: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct OrgMember {
|
||||
pub user_id: String,
|
||||
pub username: String,
|
||||
pub role: String,
|
||||
pub joined_at: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, thiserror::Error)]
|
||||
pub enum PlatformError {
|
||||
#[error("not authenticated")]
|
||||
@@ -68,6 +105,41 @@ pub trait ForestPlatform: Send + Sync {
|
||||
organisation: &str,
|
||||
project: &str,
|
||||
) -> Result<Vec<Artifact>, PlatformError>;
|
||||
|
||||
async fn create_organisation(
|
||||
&self,
|
||||
access_token: &str,
|
||||
name: &str,
|
||||
) -> Result<String, PlatformError>;
|
||||
|
||||
async fn list_members(
|
||||
&self,
|
||||
access_token: &str,
|
||||
organisation_id: &str,
|
||||
) -> Result<Vec<OrgMember>, PlatformError>;
|
||||
|
||||
async fn add_member(
|
||||
&self,
|
||||
access_token: &str,
|
||||
organisation_id: &str,
|
||||
user_id: &str,
|
||||
role: &str,
|
||||
) -> Result<OrgMember, PlatformError>;
|
||||
|
||||
async fn remove_member(
|
||||
&self,
|
||||
access_token: &str,
|
||||
organisation_id: &str,
|
||||
user_id: &str,
|
||||
) -> Result<(), PlatformError>;
|
||||
|
||||
async fn update_member_role(
|
||||
&self,
|
||||
access_token: &str,
|
||||
organisation_id: &str,
|
||||
user_id: &str,
|
||||
role: &str,
|
||||
) -> Result<OrgMember, PlatformError>;
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -70,6 +70,8 @@ pub struct CachedUser {
|
||||
/// Cached organisation membership.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CachedOrg {
|
||||
#[serde(default)]
|
||||
pub organisation_id: String,
|
||||
pub name: String,
|
||||
pub role: String,
|
||||
}
|
||||
|
||||
@@ -77,6 +77,7 @@ impl FromRequestParts<AppState> for Session {
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.map(|o| CachedOrg {
|
||||
organisation_id: o.organisation_id,
|
||||
name: o.name,
|
||||
role: o.role,
|
||||
})
|
||||
|
||||
@@ -2,7 +2,8 @@ use forage_core::auth::{
|
||||
AuthError, AuthTokens, CreatedToken, ForestAuth, PersonalAccessToken, User, UserEmail,
|
||||
};
|
||||
use forage_core::platform::{
|
||||
Artifact, ArtifactContext, ForestPlatform, Organisation, PlatformError,
|
||||
Artifact, ArtifactContext, ArtifactDestination, ArtifactSource, ForestPlatform, Organisation,
|
||||
OrgMember, PlatformError,
|
||||
};
|
||||
use forage_grpc::organisation_service_client::OrganisationServiceClient;
|
||||
use forage_grpc::release_service_client::ReleaseServiceClient;
|
||||
@@ -292,6 +293,22 @@ fn convert_organisations(
|
||||
|
||||
fn convert_artifact(a: forage_grpc::Artifact) -> Artifact {
|
||||
let ctx = a.context.unwrap_or_default();
|
||||
let source = a.source.map(|s| ArtifactSource {
|
||||
user: s.user.filter(|v| !v.is_empty()),
|
||||
email: s.email.filter(|v| !v.is_empty()),
|
||||
source_type: s.source_type.filter(|v| !v.is_empty()),
|
||||
run_url: s.run_url.filter(|v| !v.is_empty()),
|
||||
});
|
||||
// Artifact proto does not carry git ref directly; git info comes from AnnotateRelease.
|
||||
// We leave git_ref as None for now.
|
||||
let destinations = a
|
||||
.destinations
|
||||
.into_iter()
|
||||
.map(|d| ArtifactDestination {
|
||||
name: d.name,
|
||||
environment: d.environment,
|
||||
})
|
||||
.collect();
|
||||
Artifact {
|
||||
artifact_id: a.artifact_id,
|
||||
slug: a.slug,
|
||||
@@ -303,10 +320,22 @@ fn convert_artifact(a: forage_grpc::Artifact) -> Artifact {
|
||||
ctx.description
|
||||
},
|
||||
},
|
||||
source,
|
||||
git_ref: None,
|
||||
destinations,
|
||||
created_at: a.created_at,
|
||||
}
|
||||
}
|
||||
|
||||
fn convert_member(m: forage_grpc::OrganisationMember) -> OrgMember {
|
||||
OrgMember {
|
||||
user_id: m.user_id,
|
||||
username: m.username,
|
||||
role: m.role,
|
||||
joined_at: m.joined_at.map(|ts| ts.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
fn map_platform_status(status: tonic::Status) -> PlatformError {
|
||||
match status.code() {
|
||||
tonic::Code::Unauthenticated | tonic::Code::PermissionDenied => {
|
||||
@@ -394,6 +423,131 @@ impl ForestPlatform for GrpcForestClient {
|
||||
|
||||
Ok(resp.artifact.into_iter().map(convert_artifact).collect())
|
||||
}
|
||||
|
||||
async fn create_organisation(
|
||||
&self,
|
||||
access_token: &str,
|
||||
name: &str,
|
||||
) -> Result<String, PlatformError> {
|
||||
let req = platform_authed_request(
|
||||
access_token,
|
||||
forage_grpc::CreateOrganisationRequest {
|
||||
name: name.into(),
|
||||
},
|
||||
)?;
|
||||
|
||||
let resp = self
|
||||
.org_client()
|
||||
.create_organisation(req)
|
||||
.await
|
||||
.map_err(map_platform_status)?
|
||||
.into_inner();
|
||||
|
||||
Ok(resp.organisation_id)
|
||||
}
|
||||
|
||||
async fn list_members(
|
||||
&self,
|
||||
access_token: &str,
|
||||
organisation_id: &str,
|
||||
) -> Result<Vec<OrgMember>, PlatformError> {
|
||||
let req = platform_authed_request(
|
||||
access_token,
|
||||
forage_grpc::ListMembersRequest {
|
||||
organisation_id: organisation_id.into(),
|
||||
page_size: 100,
|
||||
page_token: String::new(),
|
||||
},
|
||||
)?;
|
||||
|
||||
let resp = self
|
||||
.org_client()
|
||||
.list_members(req)
|
||||
.await
|
||||
.map_err(map_platform_status)?
|
||||
.into_inner();
|
||||
|
||||
Ok(resp.members.into_iter().map(convert_member).collect())
|
||||
}
|
||||
|
||||
async fn add_member(
|
||||
&self,
|
||||
access_token: &str,
|
||||
organisation_id: &str,
|
||||
user_id: &str,
|
||||
role: &str,
|
||||
) -> Result<OrgMember, PlatformError> {
|
||||
let req = platform_authed_request(
|
||||
access_token,
|
||||
forage_grpc::AddMemberRequest {
|
||||
organisation_id: organisation_id.into(),
|
||||
user_id: user_id.into(),
|
||||
role: role.into(),
|
||||
},
|
||||
)?;
|
||||
|
||||
let resp = self
|
||||
.org_client()
|
||||
.add_member(req)
|
||||
.await
|
||||
.map_err(map_platform_status)?
|
||||
.into_inner();
|
||||
|
||||
let member = resp
|
||||
.member
|
||||
.ok_or(PlatformError::Other("no member in response".into()))?;
|
||||
Ok(convert_member(member))
|
||||
}
|
||||
|
||||
async fn remove_member(
|
||||
&self,
|
||||
access_token: &str,
|
||||
organisation_id: &str,
|
||||
user_id: &str,
|
||||
) -> Result<(), PlatformError> {
|
||||
let req = platform_authed_request(
|
||||
access_token,
|
||||
forage_grpc::RemoveMemberRequest {
|
||||
organisation_id: organisation_id.into(),
|
||||
user_id: user_id.into(),
|
||||
},
|
||||
)?;
|
||||
|
||||
self.org_client()
|
||||
.remove_member(req)
|
||||
.await
|
||||
.map_err(map_platform_status)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn update_member_role(
|
||||
&self,
|
||||
access_token: &str,
|
||||
organisation_id: &str,
|
||||
user_id: &str,
|
||||
role: &str,
|
||||
) -> Result<OrgMember, PlatformError> {
|
||||
let req = platform_authed_request(
|
||||
access_token,
|
||||
forage_grpc::UpdateMemberRoleRequest {
|
||||
organisation_id: organisation_id.into(),
|
||||
user_id: user_id.into(),
|
||||
role: role.into(),
|
||||
},
|
||||
)?;
|
||||
|
||||
let resp = self
|
||||
.org_client()
|
||||
.update_member_role(req)
|
||||
.await
|
||||
.map_err(map_platform_status)?
|
||||
.into_inner();
|
||||
|
||||
let member = resp
|
||||
.member
|
||||
.ok_or(PlatformError::Other("no member in response".into()))?;
|
||||
Ok(convert_member(member))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -18,7 +18,6 @@ pub fn router() -> Router<AppState> {
|
||||
.route("/signup", get(signup_page).post(signup_submit))
|
||||
.route("/login", get(login_page).post(login_submit))
|
||||
.route("/logout", post(logout_submit))
|
||||
.route("/dashboard", get(dashboard))
|
||||
.route(
|
||||
"/settings/tokens",
|
||||
get(tokens_page).post(create_token_submit),
|
||||
@@ -106,6 +105,7 @@ async fn signup_submit(
|
||||
user.orgs = orgs
|
||||
.into_iter()
|
||||
.map(|o| CachedOrg {
|
||||
organisation_id: o.organisation_id,
|
||||
name: o.name,
|
||||
role: o.role,
|
||||
})
|
||||
@@ -255,6 +255,7 @@ async fn login_submit(
|
||||
user.orgs = orgs
|
||||
.into_iter()
|
||||
.map(|o| CachedOrg {
|
||||
organisation_id: o.organisation_id,
|
||||
name: o.name,
|
||||
role: o.role,
|
||||
})
|
||||
@@ -343,39 +344,6 @@ async fn logout_submit(
|
||||
Ok((auth::clear_session_cookie(), Redirect::to("/")))
|
||||
}
|
||||
|
||||
// ─── Dashboard ──────────────────────────────────────────────────────
|
||||
|
||||
async fn dashboard(
|
||||
State(state): State<AppState>,
|
||||
session: Session,
|
||||
) -> Result<Response, Response> {
|
||||
// Use cached org memberships from the session
|
||||
let orgs = &session.user.orgs;
|
||||
|
||||
if let Some(first_org) = orgs.first() {
|
||||
return Ok(Redirect::to(&format!("/orgs/{}/projects", first_org.name)).into_response());
|
||||
}
|
||||
|
||||
// No orgs: show onboarding
|
||||
let html = state
|
||||
.templates
|
||||
.render(
|
||||
"pages/onboarding.html.jinja",
|
||||
context! {
|
||||
title => "Get Started - Forage",
|
||||
description => "Create your first organisation",
|
||||
user => context! { username => session.user.username },
|
||||
csrf_token => &session.csrf_token,
|
||||
},
|
||||
)
|
||||
.map_err(|e| {
|
||||
tracing::error!("template error: {e:#}");
|
||||
error_page(&state, StatusCode::INTERNAL_SERVER_ERROR, "Something went wrong", "Please try again.")
|
||||
})?;
|
||||
|
||||
Ok(Html(html).into_response())
|
||||
}
|
||||
|
||||
// ─── Tokens ─────────────────────────────────────────────────────────
|
||||
|
||||
async fn tokens_page(
|
||||
|
||||
@@ -1,21 +1,36 @@
|
||||
use axum::extract::{Path, State};
|
||||
use axum::http::StatusCode;
|
||||
use axum::response::{Html, IntoResponse, Response};
|
||||
use axum::routing::get;
|
||||
use axum::Router;
|
||||
use axum::response::{Html, IntoResponse, Redirect, Response};
|
||||
use axum::routing::{get, post};
|
||||
use axum::{Form, Router};
|
||||
use forage_core::platform::validate_slug;
|
||||
use forage_core::session::CachedOrg;
|
||||
use minijinja::context;
|
||||
use serde::Deserialize;
|
||||
|
||||
use super::error_page;
|
||||
use crate::auth::Session;
|
||||
use crate::auth::{self, Session};
|
||||
use crate::state::AppState;
|
||||
|
||||
pub fn router() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/dashboard", get(dashboard))
|
||||
.route("/orgs", post(create_org_submit))
|
||||
.route("/orgs/{org}/projects", get(projects_list))
|
||||
.route("/orgs/{org}/projects/{project}", get(project_detail))
|
||||
.route("/orgs/{org}/usage", get(usage))
|
||||
.route(
|
||||
"/orgs/{org}/settings/members",
|
||||
get(members_page).post(add_member_submit),
|
||||
)
|
||||
.route(
|
||||
"/orgs/{org}/settings/members/{user_id}/role",
|
||||
post(update_member_role_submit),
|
||||
)
|
||||
.route(
|
||||
"/orgs/{org}/settings/members/{user_id}/remove",
|
||||
post(remove_member_submit),
|
||||
)
|
||||
}
|
||||
|
||||
fn orgs_context(orgs: &[CachedOrg]) -> Vec<minijinja::Value> {
|
||||
@@ -24,20 +39,236 @@ fn orgs_context(orgs: &[CachedOrg]) -> Vec<minijinja::Value> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[allow(clippy::result_large_err)]
|
||||
fn require_org_membership<'a>(
|
||||
state: &AppState,
|
||||
orgs: &'a [CachedOrg],
|
||||
org: &str,
|
||||
) -> Result<&'a CachedOrg, Response> {
|
||||
if !validate_slug(org) {
|
||||
return Err(error_page(
|
||||
state,
|
||||
StatusCode::BAD_REQUEST,
|
||||
"Invalid request",
|
||||
"Invalid organisation name.",
|
||||
));
|
||||
}
|
||||
orgs.iter().find(|o| o.name == org).ok_or_else(|| {
|
||||
error_page(
|
||||
state,
|
||||
StatusCode::FORBIDDEN,
|
||||
"Access denied",
|
||||
"You don't have access to this organisation.",
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/// Require the user to be an admin or owner of the organisation.
|
||||
#[allow(clippy::result_large_err)]
|
||||
fn require_admin(state: &AppState, org: &CachedOrg) -> Result<(), Response> {
|
||||
if org.role == "owner" || org.role == "admin" {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(error_page(
|
||||
state,
|
||||
StatusCode::FORBIDDEN,
|
||||
"Access denied",
|
||||
"You must be an admin to perform this action.",
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Dashboard ──────────────────────────────────────────────────────
|
||||
|
||||
async fn dashboard(
|
||||
State(state): State<AppState>,
|
||||
session: Session,
|
||||
) -> Result<Response, Response> {
|
||||
let orgs = &session.user.orgs;
|
||||
|
||||
if orgs.is_empty() {
|
||||
// No orgs: show onboarding with create org form
|
||||
let html = state
|
||||
.templates
|
||||
.render(
|
||||
"pages/onboarding.html.jinja",
|
||||
context! {
|
||||
title => "Get Started - Forage",
|
||||
description => "Create your first organisation",
|
||||
user => context! { username => session.user.username },
|
||||
csrf_token => &session.csrf_token,
|
||||
},
|
||||
)
|
||||
.map_err(|e| {
|
||||
tracing::error!("template error: {e:#}");
|
||||
error_page(
|
||||
&state,
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"Something went wrong",
|
||||
"Please try again.",
|
||||
)
|
||||
})?;
|
||||
return Ok(Html(html).into_response());
|
||||
}
|
||||
|
||||
// Fetch recent activity: for each org, get projects, then artifacts
|
||||
let mut recent_activity = Vec::new();
|
||||
for org in orgs {
|
||||
let projects = state
|
||||
.platform_client
|
||||
.list_projects(&session.access_token, &org.name)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
|
||||
for project in projects.iter().take(5) {
|
||||
let artifacts = state
|
||||
.platform_client
|
||||
.list_artifacts(&session.access_token, &org.name, project)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
|
||||
for artifact in artifacts {
|
||||
recent_activity.push(context! {
|
||||
org_name => org.name,
|
||||
project_name => project,
|
||||
slug => artifact.slug,
|
||||
title => artifact.context.title,
|
||||
description => artifact.context.description,
|
||||
created_at => artifact.created_at,
|
||||
});
|
||||
if recent_activity.len() >= 10 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if recent_activity.len() >= 10 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let html = state
|
||||
.templates
|
||||
.render(
|
||||
"pages/dashboard.html.jinja",
|
||||
context! {
|
||||
title => "Dashboard - Forage",
|
||||
description => "Your Forage dashboard",
|
||||
user => context! { username => session.user.username },
|
||||
csrf_token => &session.csrf_token,
|
||||
orgs => orgs_context(orgs),
|
||||
recent_activity => recent_activity,
|
||||
},
|
||||
)
|
||||
.map_err(|e| {
|
||||
tracing::error!("template error: {e:#}");
|
||||
error_page(
|
||||
&state,
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"Something went wrong",
|
||||
"Please try again.",
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok(Html(html).into_response())
|
||||
}
|
||||
|
||||
// ─── Create organisation ────────────────────────────────────────────
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct CreateOrgForm {
|
||||
name: String,
|
||||
_csrf: String,
|
||||
}
|
||||
|
||||
async fn create_org_submit(
|
||||
State(state): State<AppState>,
|
||||
session: Session,
|
||||
Form(form): Form<CreateOrgForm>,
|
||||
) -> Result<Response, Response> {
|
||||
if !auth::validate_csrf(&session, &form._csrf) {
|
||||
return Err(error_page(
|
||||
&state,
|
||||
StatusCode::FORBIDDEN,
|
||||
"Invalid request",
|
||||
"CSRF validation failed. Please try again.",
|
||||
));
|
||||
}
|
||||
|
||||
if !validate_slug(&form.name) {
|
||||
// Re-render onboarding/dashboard with error
|
||||
let html = state
|
||||
.templates
|
||||
.render(
|
||||
"pages/onboarding.html.jinja",
|
||||
context! {
|
||||
title => "Get Started - Forage",
|
||||
description => "Create your first organisation",
|
||||
user => context! { username => session.user.username },
|
||||
csrf_token => &session.csrf_token,
|
||||
error => "Invalid organisation name. Use lowercase letters, numbers, and hyphens only.",
|
||||
},
|
||||
)
|
||||
.map_err(|e| {
|
||||
tracing::error!("template error: {e:#}");
|
||||
error_page(&state, StatusCode::INTERNAL_SERVER_ERROR, "Something went wrong", "Please try again.")
|
||||
})?;
|
||||
return Ok(Html(html).into_response());
|
||||
}
|
||||
|
||||
match state
|
||||
.platform_client
|
||||
.create_organisation(&session.access_token, &form.name)
|
||||
.await
|
||||
{
|
||||
Ok(org_id) => {
|
||||
// Update session with new org
|
||||
if let Ok(Some(mut session_data)) = state.sessions.get(&session.session_id).await {
|
||||
if let Some(ref mut user) = session_data.user {
|
||||
user.orgs.push(CachedOrg {
|
||||
organisation_id: org_id,
|
||||
name: form.name.clone(),
|
||||
role: "owner".into(),
|
||||
});
|
||||
}
|
||||
let _ = state
|
||||
.sessions
|
||||
.update(&session.session_id, session_data)
|
||||
.await;
|
||||
}
|
||||
Ok(Redirect::to(&format!("/orgs/{}/projects", form.name)).into_response())
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("failed to create org: {e}");
|
||||
let html = state
|
||||
.templates
|
||||
.render(
|
||||
"pages/onboarding.html.jinja",
|
||||
context! {
|
||||
title => "Get Started - Forage",
|
||||
description => "Create your first organisation",
|
||||
user => context! { username => session.user.username },
|
||||
csrf_token => &session.csrf_token,
|
||||
error => "Could not create organisation. Please try again.",
|
||||
},
|
||||
)
|
||||
.map_err(|e| {
|
||||
tracing::error!("template error: {e:#}");
|
||||
error_page(&state, StatusCode::INTERNAL_SERVER_ERROR, "Something went wrong", "Please try again.")
|
||||
})?;
|
||||
Ok(Html(html).into_response())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Projects list ──────────────────────────────────────────────────
|
||||
|
||||
async fn projects_list(
|
||||
State(state): State<AppState>,
|
||||
session: Session,
|
||||
Path(org): Path<String>,
|
||||
) -> Result<Response, Response> {
|
||||
if !validate_slug(&org) {
|
||||
return Err(error_page(&state, StatusCode::BAD_REQUEST, "Invalid request", "Invalid organisation name."));
|
||||
}
|
||||
|
||||
let orgs = &session.user.orgs;
|
||||
|
||||
if !orgs.iter().any(|o| o.name == org) {
|
||||
return Err(error_page(&state, StatusCode::FORBIDDEN, "Access denied", "You don't have access to this organisation."));
|
||||
}
|
||||
require_org_membership(&state, orgs, &org)?;
|
||||
|
||||
let projects = state
|
||||
.platform_client
|
||||
@@ -62,25 +293,34 @@ async fn projects_list(
|
||||
)
|
||||
.map_err(|e| {
|
||||
tracing::error!("template error: {e:#}");
|
||||
error_page(&state, StatusCode::INTERNAL_SERVER_ERROR, "Something went wrong", "Please try again.")
|
||||
error_page(
|
||||
&state,
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"Something went wrong",
|
||||
"Please try again.",
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok(Html(html).into_response())
|
||||
}
|
||||
|
||||
// ─── Project detail ─────────────────────────────────────────────────
|
||||
|
||||
async fn project_detail(
|
||||
State(state): State<AppState>,
|
||||
session: Session,
|
||||
Path((org, project)): Path<(String, String)>,
|
||||
) -> Result<Response, Response> {
|
||||
if !validate_slug(&org) || !validate_slug(&project) {
|
||||
return Err(error_page(&state, StatusCode::BAD_REQUEST, "Invalid request", "Invalid organisation or project name."));
|
||||
}
|
||||
|
||||
let orgs = &session.user.orgs;
|
||||
require_org_membership(&state, orgs, &org)?;
|
||||
|
||||
if !orgs.iter().any(|o| o.name == org) {
|
||||
return Err(error_page(&state, StatusCode::FORBIDDEN, "Access denied", "You don't have access to this organisation."));
|
||||
if !validate_slug(&project) {
|
||||
return Err(error_page(
|
||||
&state,
|
||||
StatusCode::BAD_REQUEST,
|
||||
"Invalid request",
|
||||
"Invalid project name.",
|
||||
));
|
||||
}
|
||||
|
||||
let artifacts = state
|
||||
@@ -102,38 +342,47 @@ async fn project_detail(
|
||||
orgs => orgs_context(orgs),
|
||||
org_name => &org,
|
||||
project_name => &project,
|
||||
artifacts => artifacts.iter().map(|a| context! {
|
||||
slug => a.slug,
|
||||
title => a.context.title,
|
||||
description => a.context.description,
|
||||
created_at => a.created_at,
|
||||
artifacts => artifacts.iter().map(|a| {
|
||||
context! {
|
||||
slug => a.slug,
|
||||
title => a.context.title,
|
||||
description => a.context.description,
|
||||
created_at => a.created_at,
|
||||
source_user => a.source.as_ref().and_then(|s| s.user.clone()),
|
||||
source_type => a.source.as_ref().and_then(|s| s.source_type.clone()),
|
||||
run_url => a.source.as_ref().and_then(|s| s.run_url.clone()),
|
||||
commit_sha => a.git_ref.as_ref().map(|r| r.commit_sha.clone()),
|
||||
branch => a.git_ref.as_ref().and_then(|r| r.branch.clone()),
|
||||
version => a.git_ref.as_ref().and_then(|r| r.version.clone()),
|
||||
destinations => a.destinations.iter().map(|d| {
|
||||
context! { name => d.name, environment => d.environment }
|
||||
}).collect::<Vec<_>>(),
|
||||
}
|
||||
}).collect::<Vec<_>>(),
|
||||
},
|
||||
)
|
||||
.map_err(|e| {
|
||||
tracing::error!("template error: {e:#}");
|
||||
error_page(&state, StatusCode::INTERNAL_SERVER_ERROR, "Something went wrong", "Please try again.")
|
||||
error_page(
|
||||
&state,
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"Something went wrong",
|
||||
"Please try again.",
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok(Html(html).into_response())
|
||||
}
|
||||
|
||||
// ─── Usage ──────────────────────────────────────────────────────────
|
||||
|
||||
async fn usage(
|
||||
State(state): State<AppState>,
|
||||
session: Session,
|
||||
Path(org): Path<String>,
|
||||
) -> Result<Response, Response> {
|
||||
if !validate_slug(&org) {
|
||||
return Err(error_page(&state, StatusCode::BAD_REQUEST, "Invalid request", "Invalid organisation name."));
|
||||
}
|
||||
|
||||
let orgs = &session.user.orgs;
|
||||
|
||||
let current_org_data = orgs.iter().find(|o| o.name == org);
|
||||
let current_org_data = match current_org_data {
|
||||
Some(o) => o,
|
||||
None => return Err(error_page(&state, StatusCode::FORBIDDEN, "Access denied", "You don't have access to this organisation.")),
|
||||
};
|
||||
let current_org_data = require_org_membership(&state, orgs, &org)?;
|
||||
|
||||
let projects = state
|
||||
.platform_client
|
||||
@@ -159,8 +408,205 @@ async fn usage(
|
||||
)
|
||||
.map_err(|e| {
|
||||
tracing::error!("template error: {e:#}");
|
||||
error_page(&state, StatusCode::INTERNAL_SERVER_ERROR, "Something went wrong", "Please try again.")
|
||||
error_page(
|
||||
&state,
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"Something went wrong",
|
||||
"Please try again.",
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok(Html(html).into_response())
|
||||
}
|
||||
|
||||
// ─── Members ────────────────────────────────────────────────────────
|
||||
|
||||
async fn members_page(
|
||||
State(state): State<AppState>,
|
||||
session: Session,
|
||||
Path(org): Path<String>,
|
||||
) -> Result<Response, Response> {
|
||||
let orgs = &session.user.orgs;
|
||||
let current_org = require_org_membership(&state, orgs, &org)?;
|
||||
|
||||
let members = state
|
||||
.platform_client
|
||||
.list_members(&session.access_token, ¤t_org.organisation_id)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
|
||||
let is_admin = current_org.role == "owner" || current_org.role == "admin";
|
||||
|
||||
let html = state
|
||||
.templates
|
||||
.render(
|
||||
"pages/members.html.jinja",
|
||||
context! {
|
||||
title => format!("Members - {org} - Forage"),
|
||||
description => format!("Members of {org}"),
|
||||
user => context! { username => session.user.username },
|
||||
csrf_token => &session.csrf_token,
|
||||
current_org => &org,
|
||||
orgs => orgs_context(orgs),
|
||||
org_name => &org,
|
||||
is_admin => is_admin,
|
||||
members => members.iter().map(|m| context! {
|
||||
user_id => m.user_id,
|
||||
username => m.username,
|
||||
role => m.role,
|
||||
joined_at => m.joined_at,
|
||||
}).collect::<Vec<_>>(),
|
||||
},
|
||||
)
|
||||
.map_err(|e| {
|
||||
tracing::error!("template error: {e:#}");
|
||||
error_page(
|
||||
&state,
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"Something went wrong",
|
||||
"Please try again.",
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok(Html(html).into_response())
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct AddMemberForm {
|
||||
username: String,
|
||||
role: String,
|
||||
_csrf: String,
|
||||
}
|
||||
|
||||
async fn add_member_submit(
|
||||
State(state): State<AppState>,
|
||||
session: Session,
|
||||
Path(org): Path<String>,
|
||||
Form(form): Form<AddMemberForm>,
|
||||
) -> Result<Response, Response> {
|
||||
let orgs = &session.user.orgs;
|
||||
let current_org = require_org_membership(&state, orgs, &org)?;
|
||||
require_admin(&state, current_org)?;
|
||||
|
||||
if !auth::validate_csrf(&session, &form._csrf) {
|
||||
return Err(error_page(
|
||||
&state,
|
||||
StatusCode::FORBIDDEN,
|
||||
"Invalid request",
|
||||
"CSRF validation failed. Please try again.",
|
||||
));
|
||||
}
|
||||
|
||||
let _ = state
|
||||
.platform_client
|
||||
.add_member(
|
||||
&session.access_token,
|
||||
¤t_org.organisation_id,
|
||||
&form.username,
|
||||
&form.role,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!("failed to add member: {e}");
|
||||
error_page(
|
||||
&state,
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"Something went wrong",
|
||||
"Please try again.",
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok(Redirect::to(&format!("/orgs/{org}/settings/members")).into_response())
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct UpdateRoleForm {
|
||||
role: String,
|
||||
_csrf: String,
|
||||
}
|
||||
|
||||
async fn update_member_role_submit(
|
||||
State(state): State<AppState>,
|
||||
session: Session,
|
||||
Path((org, user_id)): Path<(String, String)>,
|
||||
Form(form): Form<UpdateRoleForm>,
|
||||
) -> Result<Response, Response> {
|
||||
let orgs = &session.user.orgs;
|
||||
let current_org = require_org_membership(&state, orgs, &org)?;
|
||||
require_admin(&state, current_org)?;
|
||||
|
||||
if !auth::validate_csrf(&session, &form._csrf) {
|
||||
return Err(error_page(
|
||||
&state,
|
||||
StatusCode::FORBIDDEN,
|
||||
"Invalid request",
|
||||
"CSRF validation failed. Please try again.",
|
||||
));
|
||||
}
|
||||
|
||||
let _ = state
|
||||
.platform_client
|
||||
.update_member_role(
|
||||
&session.access_token,
|
||||
¤t_org.organisation_id,
|
||||
&user_id,
|
||||
&form.role,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!("failed to update member role: {e}");
|
||||
error_page(
|
||||
&state,
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"Something went wrong",
|
||||
"Please try again.",
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok(Redirect::to(&format!("/orgs/{org}/settings/members")).into_response())
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct CsrfForm {
|
||||
_csrf: String,
|
||||
}
|
||||
|
||||
async fn remove_member_submit(
|
||||
State(state): State<AppState>,
|
||||
session: Session,
|
||||
Path((org, user_id)): Path<(String, String)>,
|
||||
Form(form): Form<CsrfForm>,
|
||||
) -> Result<Response, Response> {
|
||||
let orgs = &session.user.orgs;
|
||||
let current_org = require_org_membership(&state, orgs, &org)?;
|
||||
require_admin(&state, current_org)?;
|
||||
|
||||
if !auth::validate_csrf(&session, &form._csrf) {
|
||||
return Err(error_page(
|
||||
&state,
|
||||
StatusCode::FORBIDDEN,
|
||||
"Invalid request",
|
||||
"CSRF validation failed. Please try again.",
|
||||
));
|
||||
}
|
||||
|
||||
state
|
||||
.platform_client
|
||||
.remove_member(
|
||||
&session.access_token,
|
||||
¤t_org.organisation_id,
|
||||
&user_id,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!("failed to remove member: {e}");
|
||||
error_page(
|
||||
&state,
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"Something went wrong",
|
||||
"Please try again.",
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok(Redirect::to(&format!("/orgs/{org}/settings/members")).into_response())
|
||||
}
|
||||
|
||||
451
crates/forage-server/src/test_support.rs
Normal file
451
crates/forage-server/src/test_support.rs
Normal file
@@ -0,0 +1,451 @@
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use axum::Router;
|
||||
use chrono::Utc;
|
||||
use forage_core::auth::*;
|
||||
use forage_core::platform::{
|
||||
Artifact, ArtifactContext, ForestPlatform, Organisation, OrgMember, PlatformError,
|
||||
};
|
||||
use forage_core::session::{
|
||||
CachedOrg, CachedUser, InMemorySessionStore, SessionData, SessionStore,
|
||||
};
|
||||
|
||||
use crate::state::AppState;
|
||||
use crate::templates::TemplateEngine;
|
||||
|
||||
/// Configurable mock behavior for testing different scenarios.
|
||||
#[derive(Default)]
|
||||
pub(crate) struct MockBehavior {
|
||||
pub register_result: Option<Result<AuthTokens, AuthError>>,
|
||||
pub login_result: Option<Result<AuthTokens, AuthError>>,
|
||||
pub refresh_result: Option<Result<AuthTokens, AuthError>>,
|
||||
pub get_user_result: Option<Result<User, AuthError>>,
|
||||
pub list_tokens_result: Option<Result<Vec<PersonalAccessToken>, AuthError>>,
|
||||
pub create_token_result: Option<Result<CreatedToken, AuthError>>,
|
||||
pub delete_token_result: Option<Result<(), AuthError>>,
|
||||
}
|
||||
|
||||
/// Configurable mock behavior for platform (orgs, projects, artifacts).
|
||||
#[derive(Default)]
|
||||
pub(crate) struct MockPlatformBehavior {
|
||||
pub list_orgs_result: Option<Result<Vec<Organisation>, PlatformError>>,
|
||||
pub list_projects_result: Option<Result<Vec<String>, PlatformError>>,
|
||||
pub list_artifacts_result: Option<Result<Vec<Artifact>, PlatformError>>,
|
||||
pub create_organisation_result: Option<Result<String, PlatformError>>,
|
||||
pub list_members_result: Option<Result<Vec<OrgMember>, PlatformError>>,
|
||||
pub add_member_result: Option<Result<OrgMember, PlatformError>>,
|
||||
pub remove_member_result: Option<Result<(), PlatformError>>,
|
||||
pub update_member_role_result: Option<Result<OrgMember, PlatformError>>,
|
||||
}
|
||||
|
||||
pub(crate) fn ok_tokens() -> AuthTokens {
|
||||
AuthTokens {
|
||||
access_token: "mock-access".into(),
|
||||
refresh_token: "mock-refresh".into(),
|
||||
expires_in_seconds: 3600,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn ok_user() -> User {
|
||||
User {
|
||||
user_id: "user-123".into(),
|
||||
username: "testuser".into(),
|
||||
emails: vec![UserEmail {
|
||||
email: "test@example.com".into(),
|
||||
verified: true,
|
||||
}],
|
||||
}
|
||||
}
|
||||
|
||||
/// Mock forest client with per-test configurable behavior.
|
||||
pub(crate) struct MockForestClient {
|
||||
behavior: Mutex<MockBehavior>,
|
||||
}
|
||||
|
||||
impl MockForestClient {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
behavior: Mutex::new(MockBehavior::default()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_behavior(behavior: MockBehavior) -> Self {
|
||||
Self {
|
||||
behavior: Mutex::new(behavior),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl ForestAuth for MockForestClient {
|
||||
async fn register(
|
||||
&self,
|
||||
_username: &str,
|
||||
_email: &str,
|
||||
_password: &str,
|
||||
) -> Result<AuthTokens, AuthError> {
|
||||
let b = self.behavior.lock().unwrap();
|
||||
b.register_result.clone().unwrap_or(Ok(ok_tokens()))
|
||||
}
|
||||
|
||||
async fn login(
|
||||
&self,
|
||||
identifier: &str,
|
||||
password: &str,
|
||||
) -> Result<AuthTokens, AuthError> {
|
||||
let b = self.behavior.lock().unwrap();
|
||||
if let Some(result) = b.login_result.clone() {
|
||||
return result;
|
||||
}
|
||||
if identifier == "testuser" && password == "CorrectPass123" {
|
||||
Ok(ok_tokens())
|
||||
} else {
|
||||
Err(AuthError::InvalidCredentials)
|
||||
}
|
||||
}
|
||||
|
||||
async fn refresh_token(&self, _refresh_token: &str) -> Result<AuthTokens, AuthError> {
|
||||
let b = self.behavior.lock().unwrap();
|
||||
b.refresh_result.clone().unwrap_or(Ok(AuthTokens {
|
||||
access_token: "refreshed-access".into(),
|
||||
refresh_token: "refreshed-refresh".into(),
|
||||
expires_in_seconds: 3600,
|
||||
}))
|
||||
}
|
||||
|
||||
async fn logout(&self, _refresh_token: &str) -> Result<(), AuthError> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn get_user(&self, access_token: &str) -> Result<User, AuthError> {
|
||||
let b = self.behavior.lock().unwrap();
|
||||
if let Some(result) = b.get_user_result.clone() {
|
||||
return result;
|
||||
}
|
||||
if access_token == "mock-access" || access_token == "refreshed-access" {
|
||||
Ok(ok_user())
|
||||
} else {
|
||||
Err(AuthError::NotAuthenticated)
|
||||
}
|
||||
}
|
||||
|
||||
async fn list_tokens(
|
||||
&self,
|
||||
_access_token: &str,
|
||||
_user_id: &str,
|
||||
) -> Result<Vec<PersonalAccessToken>, AuthError> {
|
||||
let b = self.behavior.lock().unwrap();
|
||||
b.list_tokens_result.clone().unwrap_or(Ok(vec![]))
|
||||
}
|
||||
|
||||
async fn create_token(
|
||||
&self,
|
||||
_access_token: &str,
|
||||
_user_id: &str,
|
||||
name: &str,
|
||||
) -> Result<CreatedToken, AuthError> {
|
||||
let b = self.behavior.lock().unwrap();
|
||||
b.create_token_result.clone().unwrap_or(Ok(CreatedToken {
|
||||
token: PersonalAccessToken {
|
||||
token_id: "tok-1".into(),
|
||||
name: name.into(),
|
||||
scopes: vec![],
|
||||
created_at: None,
|
||||
last_used: None,
|
||||
expires_at: None,
|
||||
},
|
||||
raw_token: "forg_abcdef1234567890".into(),
|
||||
}))
|
||||
}
|
||||
|
||||
async fn delete_token(
|
||||
&self,
|
||||
_access_token: &str,
|
||||
_token_id: &str,
|
||||
) -> Result<(), AuthError> {
|
||||
let b = self.behavior.lock().unwrap();
|
||||
b.delete_token_result.clone().unwrap_or(Ok(()))
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) struct MockPlatformClient {
|
||||
behavior: Mutex<MockPlatformBehavior>,
|
||||
}
|
||||
|
||||
impl MockPlatformClient {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
behavior: Mutex::new(MockPlatformBehavior::default()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_behavior(behavior: MockPlatformBehavior) -> Self {
|
||||
Self {
|
||||
behavior: Mutex::new(behavior),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn default_orgs() -> Vec<Organisation> {
|
||||
vec![Organisation {
|
||||
organisation_id: "org-1".into(),
|
||||
name: "testorg".into(),
|
||||
role: "admin".into(),
|
||||
}]
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl ForestPlatform for MockPlatformClient {
|
||||
async fn list_my_organisations(
|
||||
&self,
|
||||
_access_token: &str,
|
||||
) -> Result<Vec<Organisation>, PlatformError> {
|
||||
let b = self.behavior.lock().unwrap();
|
||||
b.list_orgs_result.clone().unwrap_or(Ok(default_orgs()))
|
||||
}
|
||||
|
||||
async fn list_projects(
|
||||
&self,
|
||||
_access_token: &str,
|
||||
_organisation: &str,
|
||||
) -> Result<Vec<String>, PlatformError> {
|
||||
let b = self.behavior.lock().unwrap();
|
||||
b.list_projects_result
|
||||
.clone()
|
||||
.unwrap_or(Ok(vec!["my-api".into()]))
|
||||
}
|
||||
|
||||
async fn list_artifacts(
|
||||
&self,
|
||||
_access_token: &str,
|
||||
_organisation: &str,
|
||||
_project: &str,
|
||||
) -> Result<Vec<Artifact>, PlatformError> {
|
||||
let b = self.behavior.lock().unwrap();
|
||||
b.list_artifacts_result.clone().unwrap_or(Ok(vec![Artifact {
|
||||
artifact_id: "art-1".into(),
|
||||
slug: "my-api-abc123".into(),
|
||||
context: ArtifactContext {
|
||||
title: "Deploy v1.0".into(),
|
||||
description: Some("Initial release".into()),
|
||||
},
|
||||
source: None,
|
||||
git_ref: None,
|
||||
destinations: vec![],
|
||||
created_at: "2026-03-07T12:00:00Z".into(),
|
||||
}]))
|
||||
}
|
||||
|
||||
async fn create_organisation(
|
||||
&self,
|
||||
_access_token: &str,
|
||||
name: &str,
|
||||
) -> Result<String, PlatformError> {
|
||||
let b = self.behavior.lock().unwrap();
|
||||
b.create_organisation_result
|
||||
.clone()
|
||||
.unwrap_or(Ok(format!("org-{name}")))
|
||||
}
|
||||
|
||||
async fn list_members(
|
||||
&self,
|
||||
_access_token: &str,
|
||||
_organisation_id: &str,
|
||||
) -> Result<Vec<OrgMember>, PlatformError> {
|
||||
let b = self.behavior.lock().unwrap();
|
||||
b.list_members_result.clone().unwrap_or(Ok(vec![OrgMember {
|
||||
user_id: "user-123".into(),
|
||||
username: "testuser".into(),
|
||||
role: "owner".into(),
|
||||
joined_at: Some("2026-01-01T00:00:00Z".into()),
|
||||
}]))
|
||||
}
|
||||
|
||||
async fn add_member(
|
||||
&self,
|
||||
_access_token: &str,
|
||||
_organisation_id: &str,
|
||||
user_id: &str,
|
||||
role: &str,
|
||||
) -> Result<OrgMember, PlatformError> {
|
||||
let b = self.behavior.lock().unwrap();
|
||||
b.add_member_result.clone().unwrap_or(Ok(OrgMember {
|
||||
user_id: user_id.into(),
|
||||
username: "newuser".into(),
|
||||
role: role.into(),
|
||||
joined_at: Some("2026-03-07T00:00:00Z".into()),
|
||||
}))
|
||||
}
|
||||
|
||||
async fn remove_member(
|
||||
&self,
|
||||
_access_token: &str,
|
||||
_organisation_id: &str,
|
||||
_user_id: &str,
|
||||
) -> Result<(), PlatformError> {
|
||||
let b = self.behavior.lock().unwrap();
|
||||
b.remove_member_result.clone().unwrap_or(Ok(()))
|
||||
}
|
||||
|
||||
async fn update_member_role(
|
||||
&self,
|
||||
_access_token: &str,
|
||||
_organisation_id: &str,
|
||||
user_id: &str,
|
||||
role: &str,
|
||||
) -> Result<OrgMember, PlatformError> {
|
||||
let b = self.behavior.lock().unwrap();
|
||||
b.update_member_role_result.clone().unwrap_or(Ok(OrgMember {
|
||||
user_id: user_id.into(),
|
||||
username: "testuser".into(),
|
||||
role: role.into(),
|
||||
joined_at: Some("2026-01-01T00:00:00Z".into()),
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn make_templates() -> TemplateEngine {
|
||||
let workspace_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.parent()
|
||||
.unwrap()
|
||||
.parent()
|
||||
.unwrap();
|
||||
TemplateEngine::from_path(&workspace_root.join("templates"))
|
||||
.expect("templates must load for tests")
|
||||
}
|
||||
|
||||
pub(crate) fn test_state() -> (AppState, Arc<InMemorySessionStore>) {
|
||||
test_state_with(MockForestClient::new(), MockPlatformClient::new())
|
||||
}
|
||||
|
||||
pub(crate) fn test_state_with(
|
||||
mock: MockForestClient,
|
||||
platform: MockPlatformClient,
|
||||
) -> (AppState, Arc<InMemorySessionStore>) {
|
||||
let sessions = Arc::new(InMemorySessionStore::new());
|
||||
let state = AppState::new(
|
||||
make_templates(),
|
||||
Arc::new(mock),
|
||||
Arc::new(platform),
|
||||
sessions.clone(),
|
||||
);
|
||||
(state, sessions)
|
||||
}
|
||||
|
||||
pub(crate) fn test_app() -> Router {
|
||||
let (state, _) = test_state();
|
||||
crate::build_router(state)
|
||||
}
|
||||
|
||||
pub(crate) fn test_app_with(mock: MockForestClient) -> Router {
|
||||
let (state, _) = test_state_with(mock, MockPlatformClient::new());
|
||||
crate::build_router(state)
|
||||
}
|
||||
|
||||
pub(crate) fn default_test_orgs() -> Vec<CachedOrg> {
|
||||
vec![CachedOrg {
|
||||
organisation_id: "org-1".into(),
|
||||
name: "testorg".into(),
|
||||
role: "owner".into(),
|
||||
}]
|
||||
}
|
||||
|
||||
/// Create a test session and return the cookie header value.
|
||||
pub(crate) async fn create_test_session(sessions: &Arc<InMemorySessionStore>) -> String {
|
||||
let now = Utc::now();
|
||||
let data = SessionData {
|
||||
access_token: "mock-access".into(),
|
||||
refresh_token: "mock-refresh".into(),
|
||||
csrf_token: "test-csrf".into(),
|
||||
access_expires_at: now + chrono::Duration::hours(1),
|
||||
user: Some(CachedUser {
|
||||
user_id: "user-123".into(),
|
||||
username: "testuser".into(),
|
||||
emails: vec![UserEmail {
|
||||
email: "test@example.com".into(),
|
||||
verified: true,
|
||||
}],
|
||||
orgs: default_test_orgs(),
|
||||
}),
|
||||
created_at: now,
|
||||
last_seen_at: now,
|
||||
};
|
||||
let session_id = sessions.create(data).await.unwrap();
|
||||
format!("forage_session={}", session_id)
|
||||
}
|
||||
|
||||
/// Create a test session with an expired access token but valid refresh token.
|
||||
pub(crate) async fn create_expired_session(sessions: &Arc<InMemorySessionStore>) -> String {
|
||||
let now = Utc::now();
|
||||
let data = SessionData {
|
||||
access_token: "expired-access".into(),
|
||||
refresh_token: "mock-refresh".into(),
|
||||
csrf_token: "test-csrf".into(),
|
||||
access_expires_at: now - chrono::Duration::seconds(10),
|
||||
user: Some(CachedUser {
|
||||
user_id: "user-123".into(),
|
||||
username: "testuser".into(),
|
||||
emails: vec![UserEmail {
|
||||
email: "test@example.com".into(),
|
||||
verified: true,
|
||||
}],
|
||||
orgs: default_test_orgs(),
|
||||
}),
|
||||
created_at: now,
|
||||
last_seen_at: now,
|
||||
};
|
||||
let session_id = sessions.create(data).await.unwrap();
|
||||
format!("forage_session={}", session_id)
|
||||
}
|
||||
|
||||
/// Create a test session with "member" role (non-admin, for authorization tests).
|
||||
pub(crate) async fn create_test_session_member(sessions: &Arc<InMemorySessionStore>) -> String {
|
||||
let now = Utc::now();
|
||||
let data = SessionData {
|
||||
access_token: "mock-access".into(),
|
||||
refresh_token: "mock-refresh".into(),
|
||||
csrf_token: "test-csrf".into(),
|
||||
access_expires_at: now + chrono::Duration::hours(1),
|
||||
user: Some(CachedUser {
|
||||
user_id: "user-123".into(),
|
||||
username: "testuser".into(),
|
||||
emails: vec![UserEmail {
|
||||
email: "test@example.com".into(),
|
||||
verified: true,
|
||||
}],
|
||||
orgs: vec![CachedOrg {
|
||||
organisation_id: "org-1".into(),
|
||||
name: "testorg".into(),
|
||||
role: "member".into(),
|
||||
}],
|
||||
}),
|
||||
created_at: now,
|
||||
last_seen_at: now,
|
||||
};
|
||||
let session_id = sessions.create(data).await.unwrap();
|
||||
format!("forage_session={}", session_id)
|
||||
}
|
||||
|
||||
/// Create a test session with no cached orgs (for onboarding tests).
|
||||
pub(crate) async fn create_test_session_no_orgs(sessions: &Arc<InMemorySessionStore>) -> String {
|
||||
let now = Utc::now();
|
||||
let data = SessionData {
|
||||
access_token: "mock-access".into(),
|
||||
refresh_token: "mock-refresh".into(),
|
||||
csrf_token: "test-csrf".into(),
|
||||
access_expires_at: now + chrono::Duration::hours(1),
|
||||
user: Some(CachedUser {
|
||||
user_id: "user-123".into(),
|
||||
username: "testuser".into(),
|
||||
emails: vec![UserEmail {
|
||||
email: "test@example.com".into(),
|
||||
verified: true,
|
||||
}],
|
||||
orgs: vec![],
|
||||
}),
|
||||
created_at: now,
|
||||
last_seen_at: now,
|
||||
};
|
||||
let session_id = sessions.create(data).await.unwrap();
|
||||
format!("forage_session={}", session_id)
|
||||
}
|
||||
440
crates/forage-server/src/tests/auth_tests.rs
Normal file
440
crates/forage-server/src/tests/auth_tests.rs
Normal file
@@ -0,0 +1,440 @@
|
||||
use axum::body::Body;
|
||||
use axum::http::{Request, StatusCode};
|
||||
use forage_core::auth::*;
|
||||
use tower::ServiceExt;
|
||||
|
||||
use crate::build_router;
|
||||
use crate::test_support::*;
|
||||
|
||||
// ─── Signup ─────────────────────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn signup_page_returns_200() {
|
||||
let response = test_app()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/signup")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn signup_page_contains_form() {
|
||||
let response = test_app()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/signup")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.unwrap();
|
||||
let html = String::from_utf8(body.to_vec()).unwrap();
|
||||
assert!(html.contains("username"));
|
||||
assert!(html.contains("email"));
|
||||
assert!(html.contains("password"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn signup_duplicate_shows_error() {
|
||||
let mock = MockForestClient::with_behavior(MockBehavior {
|
||||
register_result: Some(Err(AuthError::AlreadyExists("username taken".into()))),
|
||||
..Default::default()
|
||||
});
|
||||
let response = test_app_with(mock)
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/signup")
|
||||
.header("content-type", "application/x-www-form-urlencoded")
|
||||
.body(Body::from(
|
||||
"username=testuser&email=test@example.com&password=SecurePass123&password_confirm=SecurePass123",
|
||||
))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.unwrap();
|
||||
let html = String::from_utf8(body.to_vec()).unwrap();
|
||||
assert!(html.contains("already registered"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn signup_when_forest_unavailable_shows_error() {
|
||||
let mock = MockForestClient::with_behavior(MockBehavior {
|
||||
register_result: Some(Err(AuthError::Unavailable("connection refused".into()))),
|
||||
..Default::default()
|
||||
});
|
||||
let response = test_app_with(mock)
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/signup")
|
||||
.header("content-type", "application/x-www-form-urlencoded")
|
||||
.body(Body::from(
|
||||
"username=testuser&email=test@example.com&password=SecurePass123&password_confirm=SecurePass123",
|
||||
))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.unwrap();
|
||||
let html = String::from_utf8(body.to_vec()).unwrap();
|
||||
assert!(html.contains("temporarily unavailable"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn signup_password_too_short_shows_validation_error() {
|
||||
let response = test_app()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/signup")
|
||||
.header("content-type", "application/x-www-form-urlencoded")
|
||||
.body(Body::from(
|
||||
"username=testuser&email=test@example.com&password=short&password_confirm=short",
|
||||
))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.unwrap();
|
||||
let html = String::from_utf8(body.to_vec()).unwrap();
|
||||
assert!(html.contains("at least 12"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn signup_password_mismatch_shows_error() {
|
||||
let response = test_app()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/signup")
|
||||
.header("content-type", "application/x-www-form-urlencoded")
|
||||
.body(Body::from(
|
||||
"username=testuser&email=test@example.com&password=SecurePass123&password_confirm=differentpassword",
|
||||
))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.unwrap();
|
||||
let html = String::from_utf8(body.to_vec()).unwrap();
|
||||
assert!(html.contains("do not match"));
|
||||
}
|
||||
|
||||
// ─── Login ──────────────────────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn login_page_returns_200() {
|
||||
let response = test_app()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/login")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn login_page_contains_form() {
|
||||
let response = test_app()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/login")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.unwrap();
|
||||
let html = String::from_utf8(body.to_vec()).unwrap();
|
||||
assert!(html.contains("identifier"));
|
||||
assert!(html.contains("password"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn login_submit_success_sets_session_cookie() {
|
||||
let response = test_app()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/login")
|
||||
.header("content-type", "application/x-www-form-urlencoded")
|
||||
.body(Body::from(
|
||||
"identifier=testuser&password=CorrectPass123",
|
||||
))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::SEE_OTHER);
|
||||
assert_eq!(response.headers().get("location").unwrap(), "/dashboard");
|
||||
// Should have a single forage_session cookie
|
||||
let cookies: Vec<_> = response.headers().get_all("set-cookie").iter().collect();
|
||||
assert!(!cookies.is_empty());
|
||||
let cookie_str = cookies[0].to_str().unwrap();
|
||||
assert!(cookie_str.contains("forage_session="));
|
||||
assert!(cookie_str.contains("HttpOnly"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn login_submit_bad_credentials_shows_error() {
|
||||
let response = test_app()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/login")
|
||||
.header("content-type", "application/x-www-form-urlencoded")
|
||||
.body(Body::from("identifier=testuser&password=wrongpassword"))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.unwrap();
|
||||
let html = String::from_utf8(body.to_vec()).unwrap();
|
||||
assert!(html.contains("Invalid"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn login_when_forest_unavailable_shows_error() {
|
||||
let mock = MockForestClient::with_behavior(MockBehavior {
|
||||
login_result: Some(Err(AuthError::Unavailable("connection refused".into()))),
|
||||
..Default::default()
|
||||
});
|
||||
let response = test_app_with(mock)
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/login")
|
||||
.header("content-type", "application/x-www-form-urlencoded")
|
||||
.body(Body::from("identifier=testuser&password=CorrectPass123"))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.unwrap();
|
||||
let html = String::from_utf8(body.to_vec()).unwrap();
|
||||
assert!(html.contains("temporarily unavailable"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn login_empty_fields_shows_validation_error() {
|
||||
let response = test_app()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/login")
|
||||
.header("content-type", "application/x-www-form-urlencoded")
|
||||
.body(Body::from("identifier=&password="))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.unwrap();
|
||||
let html = String::from_utf8(body.to_vec()).unwrap();
|
||||
assert!(html.contains("required"));
|
||||
}
|
||||
|
||||
// ─── Session / Dashboard ────────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn dashboard_without_auth_redirects_to_login() {
|
||||
let response = test_app()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/dashboard")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::SEE_OTHER);
|
||||
assert_eq!(response.headers().get("location").unwrap(), "/login");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn dashboard_with_session_shows_page() {
|
||||
let (state, sessions) = test_state();
|
||||
let cookie = create_test_session(&sessions).await;
|
||||
let app = build_router(state);
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/dashboard")
|
||||
.header("cookie", cookie)
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
// Dashboard now renders a proper page
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn dashboard_with_expired_token_refreshes_transparently() {
|
||||
let (state, sessions) = test_state();
|
||||
let cookie = create_expired_session(&sessions).await;
|
||||
let app = build_router(state);
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/dashboard")
|
||||
.header("cookie", cookie)
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
// Should succeed (render dashboard) because refresh_token works
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn dashboard_with_invalid_session_redirects() {
|
||||
let response = test_app()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/dashboard")
|
||||
.header("cookie", "forage_session=nonexistent")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::SEE_OTHER);
|
||||
assert_eq!(response.headers().get("location").unwrap(), "/login");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn old_token_cookies_are_ignored() {
|
||||
// Old-style cookies should not authenticate
|
||||
let response = test_app()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/dashboard")
|
||||
.header("cookie", "forage_access=mock-access; forage_refresh=mock-refresh")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::SEE_OTHER);
|
||||
assert_eq!(response.headers().get("location").unwrap(), "/login");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn expired_session_with_failed_refresh_redirects_to_login() {
|
||||
let mock = MockForestClient::with_behavior(MockBehavior {
|
||||
refresh_result: Some(Err(AuthError::NotAuthenticated)),
|
||||
..Default::default()
|
||||
});
|
||||
let (state, sessions) = test_state_with(mock, MockPlatformClient::new());
|
||||
let cookie = create_expired_session(&sessions).await;
|
||||
let app = build_router(state);
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/dashboard")
|
||||
.header("cookie", cookie)
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::SEE_OTHER);
|
||||
assert_eq!(response.headers().get("location").unwrap(), "/login");
|
||||
|
||||
// Session should be destroyed
|
||||
assert_eq!(sessions.session_count(), 0);
|
||||
}
|
||||
|
||||
// ─── Logout ─────────────────────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn logout_destroys_session_and_redirects() {
|
||||
let (state, sessions) = test_state();
|
||||
let cookie = create_test_session(&sessions).await;
|
||||
let app = build_router(state);
|
||||
|
||||
assert_eq!(sessions.session_count(), 1);
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/logout")
|
||||
.header("cookie", &cookie)
|
||||
.header("content-type", "application/x-www-form-urlencoded")
|
||||
.body(Body::from("_csrf=test-csrf"))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::SEE_OTHER);
|
||||
assert_eq!(response.headers().get("location").unwrap(), "/");
|
||||
|
||||
// Session should be destroyed
|
||||
assert_eq!(sessions.session_count(), 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn logout_with_invalid_csrf_returns_403() {
|
||||
let (state, sessions) = test_state();
|
||||
let cookie = create_test_session(&sessions).await;
|
||||
let app = build_router(state);
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/logout")
|
||||
.header("cookie", &cookie)
|
||||
.header("content-type", "application/x-www-form-urlencoded")
|
||||
.body(Body::from("_csrf=wrong-token"))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::FORBIDDEN);
|
||||
|
||||
// Session should NOT be destroyed
|
||||
assert_eq!(sessions.session_count(), 1);
|
||||
}
|
||||
4
crates/forage-server/src/tests/mod.rs
Normal file
4
crates/forage-server/src/tests/mod.rs
Normal file
@@ -0,0 +1,4 @@
|
||||
mod auth_tests;
|
||||
mod pages_tests;
|
||||
mod platform_tests;
|
||||
mod token_tests;
|
||||
82
crates/forage-server/src/tests/pages_tests.rs
Normal file
82
crates/forage-server/src/tests/pages_tests.rs
Normal file
@@ -0,0 +1,82 @@
|
||||
use axum::body::Body;
|
||||
use axum::http::{Request, StatusCode};
|
||||
use tower::ServiceExt;
|
||||
|
||||
use crate::test_support::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn landing_page_returns_200() {
|
||||
let response = test_app()
|
||||
.oneshot(Request::builder().uri("/").body(Body::empty()).unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn landing_page_contains_expected_content() {
|
||||
let response = test_app()
|
||||
.oneshot(Request::builder().uri("/").body(Body::empty()).unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.unwrap();
|
||||
let html = String::from_utf8(body.to_vec()).unwrap();
|
||||
assert!(html.contains("Forage - The Platform for Forest"));
|
||||
assert!(html.contains("forest.cue"));
|
||||
assert!(html.contains("Component Registry"));
|
||||
assert!(html.contains("Managed Deployments"));
|
||||
assert!(html.contains("Container Deployments"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn pricing_page_returns_200() {
|
||||
let response = test_app()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/pricing")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn pricing_page_contains_all_tiers() {
|
||||
let response = test_app()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/pricing")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.unwrap();
|
||||
let html = String::from_utf8(body.to_vec()).unwrap();
|
||||
assert!(html.contains("Free"));
|
||||
assert!(html.contains("Developer"));
|
||||
assert!(html.contains("Team"));
|
||||
assert!(html.contains("Enterprise"));
|
||||
assert!(html.contains("$10"));
|
||||
assert!(html.contains("$25"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn unknown_route_returns_404() {
|
||||
let response = test_app()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/nonexistent")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::NOT_FOUND);
|
||||
}
|
||||
837
crates/forage-server/src/tests/platform_tests.rs
Normal file
837
crates/forage-server/src/tests/platform_tests.rs
Normal file
@@ -0,0 +1,837 @@
|
||||
use axum::body::Body;
|
||||
use axum::http::{Request, StatusCode};
|
||||
use forage_core::platform::{
|
||||
Artifact, ArtifactContext, ArtifactDestination, ArtifactRef, ArtifactSource, PlatformError,
|
||||
};
|
||||
use tower::ServiceExt;
|
||||
|
||||
use crate::build_router;
|
||||
use crate::test_support::*;
|
||||
|
||||
// ─── Dashboard ─────────────────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn dashboard_with_orgs_shows_dashboard_page() {
|
||||
let (state, sessions) = test_state();
|
||||
let cookie = create_test_session(&sessions).await;
|
||||
let app = build_router(state);
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/dashboard")
|
||||
.header("cookie", &cookie)
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.unwrap();
|
||||
let html = String::from_utf8(body.to_vec()).unwrap();
|
||||
assert!(html.contains("testorg"));
|
||||
assert!(html.contains("Recent activity"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn dashboard_shows_recent_artifacts() {
|
||||
let (state, sessions) = test_state();
|
||||
let cookie = create_test_session(&sessions).await;
|
||||
let app = build_router(state);
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/dashboard")
|
||||
.header("cookie", &cookie)
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.unwrap();
|
||||
let html = String::from_utf8(body.to_vec()).unwrap();
|
||||
assert!(html.contains("Deploy v1.0"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn dashboard_empty_activity_shows_empty_state() {
|
||||
let platform = MockPlatformClient::with_behavior(MockPlatformBehavior {
|
||||
list_projects_result: Some(Ok(vec!["my-api".into()])),
|
||||
list_artifacts_result: Some(Ok(vec![])),
|
||||
..Default::default()
|
||||
});
|
||||
let (state, sessions) = test_state_with(MockForestClient::new(), platform);
|
||||
let cookie = create_test_session(&sessions).await;
|
||||
let app = build_router(state);
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/dashboard")
|
||||
.header("cookie", &cookie)
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.unwrap();
|
||||
let html = String::from_utf8(body.to_vec()).unwrap();
|
||||
assert!(html.contains("No recent activity"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn dashboard_no_orgs_shows_onboarding() {
|
||||
let (state, sessions) = test_state();
|
||||
let cookie = create_test_session_no_orgs(&sessions).await;
|
||||
let app = build_router(state);
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/dashboard")
|
||||
.header("cookie", &cookie)
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.unwrap();
|
||||
let html = String::from_utf8(body.to_vec()).unwrap();
|
||||
assert!(html.contains("Create organisation"));
|
||||
}
|
||||
|
||||
// ─── Create organisation ───────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn create_org_success_redirects_to_new_org() {
|
||||
let (state, sessions) = test_state();
|
||||
let cookie = create_test_session(&sessions).await;
|
||||
let app = build_router(state);
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/orgs")
|
||||
.header("cookie", &cookie)
|
||||
.header("content-type", "application/x-www-form-urlencoded")
|
||||
.body(Body::from("name=my-new-org&_csrf=test-csrf"))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::SEE_OTHER);
|
||||
assert_eq!(
|
||||
response.headers().get("location").unwrap(),
|
||||
"/orgs/my-new-org/projects"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn create_org_invalid_slug_shows_error() {
|
||||
let (state, sessions) = test_state();
|
||||
let cookie = create_test_session(&sessions).await;
|
||||
let app = build_router(state);
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/orgs")
|
||||
.header("cookie", &cookie)
|
||||
.header("content-type", "application/x-www-form-urlencoded")
|
||||
.body(Body::from("name=INVALID ORG&_csrf=test-csrf"))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.unwrap();
|
||||
let html = String::from_utf8(body.to_vec()).unwrap();
|
||||
assert!(html.contains("invalid") || html.contains("Invalid"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn create_org_invalid_csrf_returns_403() {
|
||||
let (state, sessions) = test_state();
|
||||
let cookie = create_test_session(&sessions).await;
|
||||
let app = build_router(state);
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/orgs")
|
||||
.header("cookie", &cookie)
|
||||
.header("content-type", "application/x-www-form-urlencoded")
|
||||
.body(Body::from("name=my-org&_csrf=wrong-token"))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::FORBIDDEN);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn create_org_grpc_failure_shows_error() {
|
||||
let platform = MockPlatformClient::with_behavior(MockPlatformBehavior {
|
||||
create_organisation_result: Some(Err(PlatformError::Unavailable(
|
||||
"connection refused".into(),
|
||||
))),
|
||||
..Default::default()
|
||||
});
|
||||
let (state, sessions) = test_state_with(MockForestClient::new(), platform);
|
||||
let cookie = create_test_session(&sessions).await;
|
||||
let app = build_router(state);
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/orgs")
|
||||
.header("cookie", &cookie)
|
||||
.header("content-type", "application/x-www-form-urlencoded")
|
||||
.body(Body::from("name=my-org&_csrf=test-csrf"))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.unwrap();
|
||||
let html = String::from_utf8(body.to_vec()).unwrap();
|
||||
assert!(
|
||||
html.contains("unavailable") || html.contains("error") || html.contains("try again")
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Members page ──────────────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn members_page_returns_200_with_members() {
|
||||
let (state, sessions) = test_state();
|
||||
let cookie = create_test_session(&sessions).await;
|
||||
let app = build_router(state);
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/orgs/testorg/settings/members")
|
||||
.header("cookie", &cookie)
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.unwrap();
|
||||
let html = String::from_utf8(body.to_vec()).unwrap();
|
||||
assert!(html.contains("testuser"));
|
||||
assert!(html.contains("owner"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn members_page_non_member_returns_403() {
|
||||
let (state, sessions) = test_state();
|
||||
let cookie = create_test_session(&sessions).await;
|
||||
let app = build_router(state);
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/orgs/unknown-org/settings/members")
|
||||
.header("cookie", &cookie)
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::FORBIDDEN);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn members_page_invalid_slug_returns_400() {
|
||||
let (state, sessions) = test_state();
|
||||
let cookie = create_test_session(&sessions).await;
|
||||
let app = build_router(state);
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/orgs/INVALID%20ORG/settings/members")
|
||||
.header("cookie", &cookie)
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn members_page_unauthenticated_redirects() {
|
||||
let response = test_app()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/orgs/testorg/settings/members")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::SEE_OTHER);
|
||||
assert_eq!(response.headers().get("location").unwrap(), "/login");
|
||||
}
|
||||
|
||||
// ─── Member management ─────────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn add_member_success_redirects() {
|
||||
let (state, sessions) = test_state();
|
||||
let cookie = create_test_session(&sessions).await;
|
||||
let app = build_router(state);
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/orgs/testorg/settings/members")
|
||||
.header("cookie", &cookie)
|
||||
.header("content-type", "application/x-www-form-urlencoded")
|
||||
.body(Body::from(
|
||||
"username=newuser&role=member&_csrf=test-csrf",
|
||||
))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::SEE_OTHER);
|
||||
assert_eq!(
|
||||
response.headers().get("location").unwrap(),
|
||||
"/orgs/testorg/settings/members"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn add_member_invalid_csrf_returns_403() {
|
||||
let (state, sessions) = test_state();
|
||||
let cookie = create_test_session(&sessions).await;
|
||||
let app = build_router(state);
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/orgs/testorg/settings/members")
|
||||
.header("cookie", &cookie)
|
||||
.header("content-type", "application/x-www-form-urlencoded")
|
||||
.body(Body::from(
|
||||
"username=newuser&role=member&_csrf=wrong-token",
|
||||
))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::FORBIDDEN);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn remove_member_success_redirects() {
|
||||
let (state, sessions) = test_state();
|
||||
let cookie = create_test_session(&sessions).await;
|
||||
let app = build_router(state);
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/orgs/testorg/settings/members/user-456/remove")
|
||||
.header("cookie", &cookie)
|
||||
.header("content-type", "application/x-www-form-urlencoded")
|
||||
.body(Body::from("_csrf=test-csrf"))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::SEE_OTHER);
|
||||
assert_eq!(
|
||||
response.headers().get("location").unwrap(),
|
||||
"/orgs/testorg/settings/members"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn update_member_role_success_redirects() {
|
||||
let (state, sessions) = test_state();
|
||||
let cookie = create_test_session(&sessions).await;
|
||||
let app = build_router(state);
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/orgs/testorg/settings/members/user-456/role")
|
||||
.header("cookie", &cookie)
|
||||
.header("content-type", "application/x-www-form-urlencoded")
|
||||
.body(Body::from("role=admin&_csrf=test-csrf"))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::SEE_OTHER);
|
||||
assert_eq!(
|
||||
response.headers().get("location").unwrap(),
|
||||
"/orgs/testorg/settings/members"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn add_member_non_admin_returns_403() {
|
||||
let (state, sessions) = test_state();
|
||||
let cookie = create_test_session_member(&sessions).await;
|
||||
let app = build_router(state);
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/orgs/testorg/settings/members")
|
||||
.header("cookie", &cookie)
|
||||
.header("content-type", "application/x-www-form-urlencoded")
|
||||
.body(Body::from(
|
||||
"username=newuser&role=member&_csrf=test-csrf",
|
||||
))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::FORBIDDEN);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn remove_member_non_admin_returns_403() {
|
||||
let (state, sessions) = test_state();
|
||||
let cookie = create_test_session_member(&sessions).await;
|
||||
let app = build_router(state);
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/orgs/testorg/settings/members/user-456/remove")
|
||||
.header("cookie", &cookie)
|
||||
.header("content-type", "application/x-www-form-urlencoded")
|
||||
.body(Body::from("_csrf=test-csrf"))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::FORBIDDEN);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn update_role_non_admin_returns_403() {
|
||||
let (state, sessions) = test_state();
|
||||
let cookie = create_test_session_member(&sessions).await;
|
||||
let app = build_router(state);
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/orgs/testorg/settings/members/user-456/role")
|
||||
.header("cookie", &cookie)
|
||||
.header("content-type", "application/x-www-form-urlencoded")
|
||||
.body(Body::from("role=admin&_csrf=test-csrf"))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::FORBIDDEN);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn members_page_non_admin_can_view() {
|
||||
let (state, sessions) = test_state();
|
||||
let cookie = create_test_session_member(&sessions).await;
|
||||
let app = build_router(state);
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/orgs/testorg/settings/members")
|
||||
.header("cookie", &cookie)
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.unwrap();
|
||||
let html = String::from_utf8(body.to_vec()).unwrap();
|
||||
// Can see members but NOT the add member form
|
||||
assert!(html.contains("testuser"));
|
||||
assert!(!html.contains("Add member"));
|
||||
}
|
||||
|
||||
// ─── Projects list ──────────────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn projects_list_returns_200_with_projects() {
|
||||
let (state, sessions) = test_state();
|
||||
let cookie = create_test_session(&sessions).await;
|
||||
let app = build_router(state);
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/orgs/testorg/projects")
|
||||
.header("cookie", &cookie)
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.unwrap();
|
||||
let html = String::from_utf8(body.to_vec()).unwrap();
|
||||
assert!(html.contains("my-api"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn projects_list_empty_shows_empty_state() {
|
||||
let platform = MockPlatformClient::with_behavior(MockPlatformBehavior {
|
||||
list_projects_result: Some(Ok(vec![])),
|
||||
..Default::default()
|
||||
});
|
||||
let (state, sessions) = test_state_with(MockForestClient::new(), platform);
|
||||
let cookie = create_test_session(&sessions).await;
|
||||
let app = build_router(state);
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/orgs/testorg/projects")
|
||||
.header("cookie", &cookie)
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.unwrap();
|
||||
let html = String::from_utf8(body.to_vec()).unwrap();
|
||||
assert!(html.contains("No projects yet"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn projects_list_unauthenticated_redirects() {
|
||||
let response = test_app()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/orgs/testorg/projects")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::SEE_OTHER);
|
||||
assert_eq!(response.headers().get("location").unwrap(), "/login");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn projects_list_non_member_returns_403() {
|
||||
let (state, sessions) = test_state();
|
||||
let cookie = create_test_session(&sessions).await;
|
||||
let app = build_router(state);
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/orgs/unknown-org/projects")
|
||||
.header("cookie", &cookie)
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::FORBIDDEN);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn projects_list_platform_unavailable_degrades_gracefully() {
|
||||
let platform = MockPlatformClient::with_behavior(MockPlatformBehavior {
|
||||
list_projects_result: Some(Err(PlatformError::Unavailable(
|
||||
"connection refused".into(),
|
||||
))),
|
||||
..Default::default()
|
||||
});
|
||||
let (state, sessions) = test_state_with(MockForestClient::new(), platform);
|
||||
let cookie = create_test_session(&sessions).await;
|
||||
let app = build_router(state);
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/orgs/testorg/projects")
|
||||
.header("cookie", &cookie)
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.unwrap();
|
||||
let html = String::from_utf8(body.to_vec()).unwrap();
|
||||
assert!(html.contains("No projects yet"));
|
||||
}
|
||||
|
||||
// ─── Project detail ─────────────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn project_detail_returns_200_with_artifacts() {
|
||||
let (state, sessions) = test_state();
|
||||
let cookie = create_test_session(&sessions).await;
|
||||
let app = build_router(state);
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/orgs/testorg/projects/my-api")
|
||||
.header("cookie", &cookie)
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.unwrap();
|
||||
let html = String::from_utf8(body.to_vec()).unwrap();
|
||||
assert!(html.contains("my-api"));
|
||||
assert!(html.contains("Deploy v1.0"));
|
||||
assert!(html.contains("my-api-abc123"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn project_detail_empty_artifacts_shows_empty_state() {
|
||||
let platform = MockPlatformClient::with_behavior(MockPlatformBehavior {
|
||||
list_artifacts_result: Some(Ok(vec![])),
|
||||
..Default::default()
|
||||
});
|
||||
let (state, sessions) = test_state_with(MockForestClient::new(), platform);
|
||||
let cookie = create_test_session(&sessions).await;
|
||||
let app = build_router(state);
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/orgs/testorg/projects/my-api")
|
||||
.header("cookie", &cookie)
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.unwrap();
|
||||
let html = String::from_utf8(body.to_vec()).unwrap();
|
||||
assert!(html.contains("No releases yet"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn project_detail_shows_enriched_artifact_data() {
|
||||
let platform = MockPlatformClient::with_behavior(MockPlatformBehavior {
|
||||
list_artifacts_result: Some(Ok(vec![Artifact {
|
||||
artifact_id: "art-2".into(),
|
||||
slug: "my-api-def456".into(),
|
||||
context: ArtifactContext {
|
||||
title: "Deploy v2.0".into(),
|
||||
description: Some("Major release".into()),
|
||||
},
|
||||
source: Some(ArtifactSource {
|
||||
user: Some("ci-bot".into()),
|
||||
email: None,
|
||||
source_type: Some("github-actions".into()),
|
||||
run_url: Some("https://github.com/org/repo/actions/runs/123".into()),
|
||||
}),
|
||||
git_ref: Some(ArtifactRef {
|
||||
commit_sha: "abc1234".into(),
|
||||
branch: Some("main".into()),
|
||||
commit_message: Some("feat: add new feature".into()),
|
||||
version: Some("v2.0.0".into()),
|
||||
repo_url: None,
|
||||
}),
|
||||
destinations: vec![ArtifactDestination {
|
||||
name: "production".into(),
|
||||
environment: "prod".into(),
|
||||
}],
|
||||
created_at: "2026-03-07T12:00:00Z".into(),
|
||||
}])),
|
||||
..Default::default()
|
||||
});
|
||||
let (state, sessions) = test_state_with(MockForestClient::new(), platform);
|
||||
let cookie = create_test_session(&sessions).await;
|
||||
let app = build_router(state);
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/orgs/testorg/projects/my-api")
|
||||
.header("cookie", &cookie)
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.unwrap();
|
||||
let html = String::from_utf8(body.to_vec()).unwrap();
|
||||
assert!(html.contains("v2.0.0"));
|
||||
assert!(html.contains("main"));
|
||||
assert!(html.contains("abc1234"));
|
||||
assert!(html.contains("production"));
|
||||
}
|
||||
|
||||
// ─── Usage ──────────────────────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn usage_page_returns_200() {
|
||||
let (state, sessions) = test_state();
|
||||
let cookie = create_test_session(&sessions).await;
|
||||
let app = build_router(state);
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/orgs/testorg/usage")
|
||||
.header("cookie", &cookie)
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.unwrap();
|
||||
let html = String::from_utf8(body.to_vec()).unwrap();
|
||||
assert!(html.contains("Early Access"));
|
||||
assert!(html.contains("testorg"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn usage_page_unauthenticated_redirects() {
|
||||
let response = test_app()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/orgs/testorg/usage")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::SEE_OTHER);
|
||||
assert_eq!(response.headers().get("location").unwrap(), "/login");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn usage_page_non_member_returns_403() {
|
||||
let (state, sessions) = test_state();
|
||||
let cookie = create_test_session(&sessions).await;
|
||||
let app = build_router(state);
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/orgs/unknown-org/usage")
|
||||
.header("cookie", &cookie)
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::FORBIDDEN);
|
||||
}
|
||||
|
||||
// ─── Nav & Error rendering ──────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn authenticated_pages_show_app_nav() {
|
||||
let (state, sessions) = test_state();
|
||||
let cookie = create_test_session(&sessions).await;
|
||||
let app = build_router(state);
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/orgs/testorg/projects")
|
||||
.header("cookie", &cookie)
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.unwrap();
|
||||
let html = String::from_utf8(body.to_vec()).unwrap();
|
||||
assert!(html.contains("Sign out"));
|
||||
assert!(html.contains("testorg"));
|
||||
assert!(!html.contains("Sign in"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn error_403_renders_html() {
|
||||
let (state, sessions) = test_state();
|
||||
let cookie = create_test_session(&sessions).await;
|
||||
let app = build_router(state);
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/orgs/unknown-org/projects")
|
||||
.header("cookie", &cookie)
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::FORBIDDEN);
|
||||
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.unwrap();
|
||||
let html = String::from_utf8(body.to_vec()).unwrap();
|
||||
assert!(html.contains("Access denied"));
|
||||
}
|
||||
32
crates/forage-server/src/tests/token_tests.rs
Normal file
32
crates/forage-server/src/tests/token_tests.rs
Normal file
@@ -0,0 +1,32 @@
|
||||
use axum::body::Body;
|
||||
use axum::http::{Request, StatusCode};
|
||||
use forage_core::auth::*;
|
||||
use tower::ServiceExt;
|
||||
|
||||
use crate::build_router;
|
||||
use crate::test_support::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn delete_token_error_returns_500() {
|
||||
let mock = MockForestClient::with_behavior(MockBehavior {
|
||||
delete_token_result: Some(Err(AuthError::Other("db error".into()))),
|
||||
..Default::default()
|
||||
});
|
||||
let (state, sessions) = test_state_with(mock, MockPlatformClient::new());
|
||||
let cookie = create_test_session(&sessions).await;
|
||||
let app = build_router(state);
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/settings/tokens/tok-1/delete")
|
||||
.header("cookie", cookie)
|
||||
.header("content-type", "application/x-www-form-urlencoded")
|
||||
.body(Body::from("_csrf=test-csrf"))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
145
specs/features/005-dashboard-enhancement.md
Normal file
145
specs/features/005-dashboard-enhancement.md
Normal file
@@ -0,0 +1,145 @@
|
||||
# 005 - Enhanced Dashboard & Org Management
|
||||
|
||||
**Status**: Phase 2 - Implementation
|
||||
**Depends on**: 004 (Projects and Usage)
|
||||
|
||||
## Problem
|
||||
|
||||
The dashboard is a redirect stub. Projects pages show bare names with no release detail. There is no way to create organisations or manage members from the web UI. Users must use the CLI for everything.
|
||||
|
||||
## Scope
|
||||
|
||||
This spec covers:
|
||||
- **Proper dashboard page**: GitHub-inspired layout with org sidebar and recent activity feed
|
||||
- **Create organisation**: POST form on dashboard/onboarding
|
||||
- **Richer artifact detail**: Show git ref, source, version, destinations on project detail
|
||||
- **Org members page**: Read-only member list with roles
|
||||
- **Org member management**: Add/remove members, update roles (admin-only)
|
||||
|
||||
Out of scope:
|
||||
- Billing/Stripe integration
|
||||
- Deployment logs/streaming
|
||||
- Component registry browsing
|
||||
|
||||
## Architecture
|
||||
|
||||
### Domain Model Changes (forage-core)
|
||||
|
||||
Expand `Artifact` with source, ref, and destination data:
|
||||
|
||||
```rust
|
||||
pub struct Artifact {
|
||||
pub artifact_id: String,
|
||||
pub slug: String,
|
||||
pub context: ArtifactContext,
|
||||
pub source: Option<ArtifactSource>,
|
||||
pub git_ref: Option<ArtifactRef>,
|
||||
pub destinations: Vec<ArtifactDestination>,
|
||||
pub created_at: String,
|
||||
}
|
||||
|
||||
pub struct ArtifactSource {
|
||||
pub user: Option<String>,
|
||||
pub email: Option<String>,
|
||||
pub source_type: Option<String>,
|
||||
pub run_url: Option<String>,
|
||||
}
|
||||
|
||||
pub struct ArtifactRef {
|
||||
pub commit_sha: String,
|
||||
pub branch: Option<String>,
|
||||
pub commit_message: Option<String>,
|
||||
pub version: Option<String>,
|
||||
pub repo_url: Option<String>,
|
||||
}
|
||||
|
||||
pub struct ArtifactDestination {
|
||||
pub name: String,
|
||||
pub environment: String,
|
||||
}
|
||||
|
||||
pub struct OrgMember {
|
||||
pub user_id: String,
|
||||
pub username: String,
|
||||
pub role: String,
|
||||
pub joined_at: Option<String>,
|
||||
}
|
||||
```
|
||||
|
||||
Add `organisation_id` to `CachedOrg` for member operations.
|
||||
|
||||
New `ForestPlatform` trait methods:
|
||||
- `create_organisation(access_token, name) -> Result<String, PlatformError>`
|
||||
- `list_members(access_token, organisation_id) -> Result<Vec<OrgMember>, PlatformError>`
|
||||
- `add_member(access_token, organisation_id, user_id, role) -> Result<OrgMember, PlatformError>`
|
||||
- `remove_member(access_token, organisation_id, user_id) -> Result<(), PlatformError>`
|
||||
- `update_member_role(access_token, organisation_id, user_id, role) -> Result<OrgMember, PlatformError>`
|
||||
|
||||
### Routes
|
||||
|
||||
| Route | Method | Auth | Description |
|
||||
|-------|--------|------|-------------|
|
||||
| `GET /dashboard` | GET | Required | Proper page: org sidebar + recent activity feed |
|
||||
| `POST /orgs` | POST | Required + CSRF | Create organisation, redirect to new org |
|
||||
| `GET /orgs/{org}/settings/members` | GET | Required | Members list |
|
||||
| `POST /orgs/{org}/settings/members` | POST | Required + CSRF | Add member (admin-only) |
|
||||
| `POST /orgs/{org}/settings/members/{user_id}/role` | POST | Required + CSRF | Update role (admin-only) |
|
||||
| `POST /orgs/{org}/settings/members/{user_id}/remove` | POST | Required + CSRF | Remove member (admin-only) |
|
||||
|
||||
### Templates
|
||||
|
||||
- `pages/dashboard.html.jinja` - Rewrite: org sidebar + activity feed + create org form
|
||||
- `pages/project_detail.html.jinja` - Enhance: git ref, source, destinations
|
||||
- `pages/onboarding.html.jinja` - Enhance: add create org form
|
||||
- `pages/members.html.jinja` - New: members table with admin actions
|
||||
- `base.html.jinja` - Add Settings/Members nav link
|
||||
|
||||
### Dashboard Data Flow
|
||||
|
||||
For each cached org, call `list_projects`, then `list_artifacts` for the first few projects. Cap at 10 total artifacts. Use `tokio::join!` for parallelism.
|
||||
|
||||
## Behavioral Contract
|
||||
|
||||
### Dashboard
|
||||
- Authenticated with orgs: show dashboard page with org sidebar and recent activity
|
||||
- Authenticated no orgs: show onboarding with create org form
|
||||
- Recent activity: up to 10 artifacts across all orgs, newest first
|
||||
|
||||
### Create organisation
|
||||
- Validates name with `validate_slug()`
|
||||
- CSRF protection
|
||||
- On success: cache new org in session, redirect to `/orgs/{name}/projects`
|
||||
- On duplicate name: show error on form
|
||||
|
||||
### Members page
|
||||
- Shows all members with username, role, join date
|
||||
- Admin users see add/remove/role-change forms
|
||||
- Non-members get 403
|
||||
|
||||
### Member management (admin-only)
|
||||
- Add: username input + role select, CSRF
|
||||
- Remove: confirmation form, CSRF
|
||||
- Role update: role select dropdown, CSRF
|
||||
- Non-admin gets 403
|
||||
|
||||
### Richer project detail
|
||||
- Each artifact shows: title, description, slug (existing)
|
||||
- Plus: version badge, branch + commit SHA, source user, destinations
|
||||
- Missing fields gracefully hidden (not all artifacts have git refs)
|
||||
|
||||
## Test Strategy
|
||||
|
||||
~20 new tests:
|
||||
- Dashboard renders page with org list and activity feed
|
||||
- Dashboard empty activity shows empty state
|
||||
- POST /orgs creates org and redirects
|
||||
- POST /orgs invalid slug shows error
|
||||
- POST /orgs invalid CSRF returns 403
|
||||
- POST /orgs gRPC failure shows error
|
||||
- Members page returns 200 with members
|
||||
- Members page non-member returns 403
|
||||
- Members page invalid slug returns 400
|
||||
- Add/remove/update member with CSRF
|
||||
- Non-admin member management returns 403
|
||||
- Project detail shows enriched artifact data
|
||||
- Existing dashboard tests updated (no longer redirect)
|
||||
41
specs/reviews/005-adversarial-review.md
Normal file
41
specs/reviews/005-adversarial-review.md
Normal file
@@ -0,0 +1,41 @@
|
||||
# 005 - Dashboard Enhancement: Adversarial Review
|
||||
|
||||
**Spec**: 005 - Enhanced Dashboard & Org Management
|
||||
**Date**: 2026-03-07
|
||||
|
||||
## Findings
|
||||
|
||||
### Critical (fixed)
|
||||
|
||||
1. **Missing server-side admin authorization on member management routes**
|
||||
- `add_member_submit`, `update_member_role_submit`, `remove_member_submit` only checked org membership, not admin/owner role
|
||||
- Template hid forms for non-admins, but POST requests could be made directly
|
||||
- **Fix**: Added `require_admin()` helper, called in all three handlers before CSRF check
|
||||
- **Tests added**: `add_member_non_admin_returns_403`, `remove_member_non_admin_returns_403`, `update_role_non_admin_returns_403`, `members_page_non_admin_can_view`
|
||||
|
||||
### Minor (accepted)
|
||||
|
||||
2. **Dashboard fetches artifacts sequentially**
|
||||
- For each org, projects are fetched sequentially, then artifacts per project
|
||||
- Could be slow with many orgs/projects
|
||||
- Mitigated by: cap of 10 artifacts, `take(5)` on projects per org, `unwrap_or_default()` on failures
|
||||
- Future improvement: use `tokio::join!` or `FuturesUnordered` for parallelism
|
||||
|
||||
3. **Create org error always renders onboarding template**
|
||||
- If a user with existing orgs creates a new org and it fails, they see the onboarding page instead of the dashboard
|
||||
- Acceptable for now since the form is on both pages; the user can navigate back
|
||||
|
||||
### Verified secure
|
||||
|
||||
- **XSS**: MiniJinja auto-escapes all `{{ }}` expressions. Error messages are hardcoded strings. URL paths use `validate_slug()` (only `[a-z0-9-]`).
|
||||
- **CSRF**: All POST handlers validate CSRF token before performing mutations.
|
||||
- **Authorization**: All org-scoped routes check membership via `require_org_membership()`. Member management routes additionally check admin/owner role via `require_admin()`.
|
||||
- **Input validation**: `validate_slug()` on org/project names. Form deserialization rejects missing fields.
|
||||
- **Graceful degradation**: gRPC failures return `unwrap_or_default()` (empty lists) rather than 500 errors.
|
||||
|
||||
## Test Coverage
|
||||
|
||||
- 86 total tests (22 core + 66 server), all passing
|
||||
- 23 new tests for spec 005 features
|
||||
- Authorization tests cover: non-member (403), non-admin member management (403), valid admin operations (303)
|
||||
- Template rendering verified: dashboard content, empty states, enriched artifact fields, admin-only UI
|
||||
@@ -22,6 +22,7 @@
|
||||
<div class="flex items-center gap-6">
|
||||
{% if current_org is defined and current_org %}
|
||||
<a href="/orgs/{{ current_org }}/projects" class="text-sm text-gray-600 hover:text-gray-900">Projects</a>
|
||||
<a href="/orgs/{{ current_org }}/settings/members" class="text-sm text-gray-600 hover:text-gray-900">Members</a>
|
||||
<a href="/orgs/{{ current_org }}/usage" class="text-sm text-gray-600 hover:text-gray-900">Usage</a>
|
||||
{% endif %}
|
||||
{% if orgs is defined and orgs | length > 1 %}
|
||||
|
||||
@@ -1,56 +1,65 @@
|
||||
{% extends "base.html.jinja" %}
|
||||
|
||||
{% block content %}
|
||||
<section class="max-w-4xl mx-auto px-4 pt-12">
|
||||
<div class="flex items-center justify-between mb-8">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold">Welcome, {{ user.username }}</h1>
|
||||
<p class="text-sm text-gray-600 mt-1">{{ user.emails[0] if user.emails }}</p>
|
||||
</div>
|
||||
<div class="flex gap-3">
|
||||
<a href="/settings/tokens" class="px-4 py-2 text-sm border border-gray-300 rounded-md hover:border-gray-400">
|
||||
API Tokens
|
||||
</a>
|
||||
<form method="POST" action="/logout">
|
||||
<input type="hidden" name="_csrf" value="{{ csrf_token }}">
|
||||
<button type="submit" class="px-4 py-2 text-sm border border-gray-300 rounded-md hover:border-gray-400">
|
||||
Sign out
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<section class="max-w-6xl mx-auto px-4 pt-8">
|
||||
<div class="flex gap-8">
|
||||
{# Org sidebar #}
|
||||
<aside class="w-64 shrink-0">
|
||||
<h2 class="text-sm font-semibold text-gray-500 uppercase tracking-wide mb-3">Organisations</h2>
|
||||
<ul class="space-y-1">
|
||||
{% for org in orgs %}
|
||||
<li>
|
||||
<a href="/orgs/{{ org.name }}/projects" class="block px-3 py-2 text-sm rounded-md hover:bg-gray-100">
|
||||
{{ org.name }}
|
||||
<span class="text-xs text-gray-400 ml-1">{{ org.role }}</span>
|
||||
</a>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
<div class="mt-4 pt-4 border-t border-gray-200">
|
||||
<form method="POST" action="/orgs" class="space-y-2">
|
||||
<input type="hidden" name="_csrf" value="{{ csrf_token }}">
|
||||
<input type="text" name="name" placeholder="new-org-name"
|
||||
class="w-full px-3 py-1.5 text-sm border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-gray-900">
|
||||
<button type="submit" class="w-full px-3 py-1.5 text-sm bg-gray-900 text-white rounded-md hover:bg-gray-800">
|
||||
Create organisation
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<div class="p-6 border border-gray-200 rounded-lg mb-6">
|
||||
<p class="text-sm text-gray-500">
|
||||
Forage is in early access. Container deployments and the component registry
|
||||
are under active development. You can manage your API tokens now and deploy
|
||||
once the platform is live.
|
||||
</p>
|
||||
</div>
|
||||
{# Main content #}
|
||||
<div class="flex-1 min-w-0">
|
||||
<h1 class="text-2xl font-bold mb-6">Recent activity</h1>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div class="p-6 border border-gray-200 rounded-lg">
|
||||
<h2 class="font-bold text-lg mb-4">Projects</h2>
|
||||
<p class="text-sm text-gray-600">No projects yet. Deploy your first forest.cue manifest to get started.</p>
|
||||
</div>
|
||||
|
||||
<div class="p-6 border border-gray-200 rounded-lg">
|
||||
<h2 class="font-bold text-lg mb-4">Organisations</h2>
|
||||
<p class="text-sm text-gray-600">You're not part of any organisation yet.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-8 p-6 border border-gray-200 rounded-lg">
|
||||
<h2 class="font-bold text-lg mb-4">Quick start</h2>
|
||||
<div class="bg-gray-950 rounded-md p-4 text-sm font-mono text-gray-300">
|
||||
<pre><span class="text-gray-500"># Install forest CLI</span>
|
||||
cargo install forest
|
||||
|
||||
<span class="text-gray-500"># Create a project</span>
|
||||
forest init my-project --component forage/service
|
||||
|
||||
<span class="text-gray-500"># Deploy</span>
|
||||
forest release create --env dev</pre>
|
||||
{% if recent_activity %}
|
||||
<div class="space-y-3">
|
||||
{% for item in recent_activity %}
|
||||
<a href="/orgs/{{ item.org_name }}/projects/{{ item.project_name }}" class="block p-4 border border-gray-200 rounded-lg hover:border-gray-300 transition-colors">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="min-w-0">
|
||||
<p class="font-medium truncate">{{ item.title }}</p>
|
||||
<p class="text-sm text-gray-500 mt-0.5">
|
||||
{{ item.org_name }} / {{ item.project_name }}
|
||||
</p>
|
||||
{% if item.description %}
|
||||
<p class="text-sm text-gray-600 mt-1 truncate">{{ item.description }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="text-right text-sm text-gray-400 shrink-0 ml-4">
|
||||
<p class="font-mono text-xs">{{ item.slug }}</p>
|
||||
<p>{{ item.created_at }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="p-8 border border-gray-200 rounded-lg text-center">
|
||||
<p class="text-gray-500">No recent activity</p>
|
||||
<p class="text-sm text-gray-400 mt-2">Deploy your first release with <code class="bg-gray-100 px-1 rounded">forest release create</code></p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
87
templates/pages/members.html.jinja
Normal file
87
templates/pages/members.html.jinja
Normal file
@@ -0,0 +1,87 @@
|
||||
{% extends "base.html.jinja" %}
|
||||
|
||||
{% block content %}
|
||||
<section class="max-w-4xl mx-auto px-4 pt-12">
|
||||
<div class="flex items-center justify-between mb-8">
|
||||
<div>
|
||||
<a href="/orgs/{{ org_name }}/projects" class="text-sm text-gray-500 hover:text-gray-700">← {{ org_name }}</a>
|
||||
<h1 class="text-2xl font-bold mt-1">Members</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if is_admin %}
|
||||
<div class="mb-8 p-4 border border-gray-200 rounded-lg">
|
||||
<h2 class="font-medium text-sm mb-3">Add member</h2>
|
||||
<form method="POST" action="/orgs/{{ org_name }}/settings/members" class="flex gap-3 items-end">
|
||||
<input type="hidden" name="_csrf" value="{{ csrf_token }}">
|
||||
<div class="flex-1">
|
||||
<label for="username" class="block text-xs text-gray-500 mb-1">Username</label>
|
||||
<input type="text" id="username" name="username" required
|
||||
class="w-full px-3 py-1.5 text-sm border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-gray-900">
|
||||
</div>
|
||||
<div>
|
||||
<label for="role" class="block text-xs text-gray-500 mb-1">Role</label>
|
||||
<select id="role" name="role" class="px-3 py-1.5 text-sm border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-gray-900">
|
||||
<option value="member">Member</option>
|
||||
<option value="admin">Admin</option>
|
||||
</select>
|
||||
</div>
|
||||
<button type="submit" class="px-4 py-1.5 text-sm bg-gray-900 text-white rounded-md hover:bg-gray-800">
|
||||
Add
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="border border-gray-200 rounded-lg overflow-hidden">
|
||||
<table class="w-full text-sm">
|
||||
<thead class="bg-gray-50 border-b border-gray-200">
|
||||
<tr>
|
||||
<th class="text-left px-4 py-3 font-medium text-gray-500">Username</th>
|
||||
<th class="text-left px-4 py-3 font-medium text-gray-500">Role</th>
|
||||
<th class="text-left px-4 py-3 font-medium text-gray-500">Joined</th>
|
||||
{% if is_admin %}
|
||||
<th class="text-right px-4 py-3 font-medium text-gray-500">Actions</th>
|
||||
{% endif %}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-100">
|
||||
{% for member in members %}
|
||||
<tr>
|
||||
<td class="px-4 py-3 font-medium">{{ member.username }}</td>
|
||||
<td class="px-4 py-3">
|
||||
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium
|
||||
{% if member.role == 'owner' %}bg-purple-100 text-purple-800
|
||||
{% elif member.role == 'admin' %}bg-blue-100 text-blue-800
|
||||
{% else %}bg-gray-100 text-gray-800{% endif %}">
|
||||
{{ member.role }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-gray-500">{{ member.joined_at or "—" }}</td>
|
||||
{% if is_admin %}
|
||||
<td class="px-4 py-3 text-right">
|
||||
{% if member.role != 'owner' %}
|
||||
<div class="flex gap-2 justify-end">
|
||||
<form method="POST" action="/orgs/{{ org_name }}/settings/members/{{ member.user_id }}/role" class="inline-flex gap-1">
|
||||
<input type="hidden" name="_csrf" value="{{ csrf_token }}">
|
||||
<select name="role" class="px-2 py-1 text-xs border border-gray-300 rounded">
|
||||
<option value="member" {% if member.role == 'member' %}selected{% endif %}>Member</option>
|
||||
<option value="admin" {% if member.role == 'admin' %}selected{% endif %}>Admin</option>
|
||||
</select>
|
||||
<button type="submit" class="px-2 py-1 text-xs border border-gray-300 rounded hover:bg-gray-50">Update</button>
|
||||
</form>
|
||||
<form method="POST" action="/orgs/{{ org_name }}/settings/members/{{ member.user_id }}/remove" class="inline">
|
||||
<input type="hidden" name="_csrf" value="{{ csrf_token }}">
|
||||
<button type="submit" class="px-2 py-1 text-xs text-red-600 border border-red-200 rounded hover:bg-red-50">Remove</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endif %}
|
||||
</td>
|
||||
{% endif %}
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
{% endblock %}
|
||||
@@ -1,12 +1,34 @@
|
||||
{% extends "base.html.jinja" %}
|
||||
|
||||
{% block content %}
|
||||
<section class="max-w-4xl mx-auto px-4 pt-12 text-center">
|
||||
<section class="max-w-lg mx-auto px-4 pt-16 text-center">
|
||||
<h1 class="text-2xl font-bold">Welcome to Forage</h1>
|
||||
<p class="mt-4 text-gray-600">Create your first organisation with the forest CLI to get started.</p>
|
||||
<p class="mt-4 text-gray-600">Create your first organisation to get started.</p>
|
||||
|
||||
<div class="mt-8 max-w-lg mx-auto bg-gray-950 rounded-md p-4 text-sm font-mono text-gray-300 text-left">
|
||||
<pre><span class="text-gray-500"># Install forest CLI</span>
|
||||
{% if error %}
|
||||
<div class="mt-4 p-3 bg-red-50 border border-red-200 rounded-md text-sm text-red-700">
|
||||
{{ error }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<form method="POST" action="/orgs" class="mt-8 text-left space-y-4">
|
||||
<input type="hidden" name="_csrf" value="{{ csrf_token }}">
|
||||
<div>
|
||||
<label for="name" class="block text-sm font-medium text-gray-700 mb-1">Organisation name</label>
|
||||
<input type="text" id="name" name="name" placeholder="my-org"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-gray-900"
|
||||
pattern="[a-z0-9][a-z0-9-]*[a-z0-9]" title="Lowercase letters, numbers, and hyphens">
|
||||
<p class="text-xs text-gray-400 mt-1">Lowercase letters, numbers, and hyphens only.</p>
|
||||
</div>
|
||||
<button type="submit" class="w-full px-4 py-2 bg-gray-900 text-white rounded-md hover:bg-gray-800 font-medium">
|
||||
Create organisation
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div class="mt-8 pt-8 border-t border-gray-200">
|
||||
<p class="text-sm text-gray-500 mb-4">Or use the CLI:</p>
|
||||
<div class="bg-gray-950 rounded-md p-4 text-sm font-mono text-gray-300 text-left">
|
||||
<pre><span class="text-gray-500"># Install forest CLI</span>
|
||||
cargo install forest
|
||||
|
||||
<span class="text-gray-500"># Create an organisation</span>
|
||||
@@ -17,9 +39,10 @@ forest init my-project --component forage/service
|
||||
|
||||
<span class="text-gray-500"># Deploy</span>
|
||||
forest release create --env dev</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-8">
|
||||
<div class="mt-6">
|
||||
<a href="/settings/tokens" class="text-sm text-gray-500 hover:text-gray-700">Manage API tokens →</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -16,13 +16,38 @@
|
||||
{% for artifact in artifacts %}
|
||||
<div class="p-4 border border-gray-200 rounded-lg">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="font-medium">{{ artifact.title }}</p>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<p class="font-medium">{{ artifact.title }}</p>
|
||||
{% if artifact.version %}
|
||||
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-green-100 text-green-800">{{ artifact.version }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if artifact.description %}
|
||||
<p class="text-sm text-gray-600 mt-1">{{ artifact.description }}</p>
|
||||
{% endif %}
|
||||
{% if artifact.branch or artifact.commit_sha %}
|
||||
<div class="flex items-center gap-2 mt-2 text-xs text-gray-500">
|
||||
{% if artifact.branch %}
|
||||
<span class="inline-flex items-center px-1.5 py-0.5 rounded bg-blue-50 text-blue-700 font-mono">{{ artifact.branch }}</span>
|
||||
{% endif %}
|
||||
{% if artifact.commit_sha %}
|
||||
<span class="font-mono">{{ artifact.commit_sha[:8] }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if artifact.source_user %}
|
||||
<p class="text-xs text-gray-400 mt-1">by {{ artifact.source_user }}{% if artifact.source_type %} via {{ artifact.source_type }}{% endif %}</p>
|
||||
{% endif %}
|
||||
{% if artifact.destinations %}
|
||||
<div class="flex gap-1.5 mt-2">
|
||||
{% for dest in artifact.destinations %}
|
||||
<span class="inline-flex items-center px-1.5 py-0.5 rounded text-xs bg-gray-100 text-gray-600">{{ dest.name }} ({{ dest.environment }})</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="text-right text-sm text-gray-500">
|
||||
<div class="text-right text-sm text-gray-500 shrink-0 ml-4">
|
||||
<p class="font-mono">{{ artifact.slug }}</p>
|
||||
<p>{{ artifact.created_at }}</p>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user