@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user