diff --git a/CLAUDE.md b/CLAUDE.md index 9db3af7..50afd0f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 diff --git a/crates/forage-core/src/platform/mod.rs b/crates/forage-core/src/platform/mod.rs index 28d60ac..cc47990 100644 --- a/crates/forage-core/src/platform/mod.rs +++ b/crates/forage-core/src/platform/mod.rs @@ -23,6 +23,12 @@ pub struct Artifact { pub artifact_id: String, pub slug: String, pub context: ArtifactContext, + #[serde(default)] + pub source: Option, + #[serde(default)] + pub git_ref: Option, + #[serde(default)] + pub destinations: Vec, pub created_at: String, } @@ -32,6 +38,37 @@ pub struct ArtifactContext { pub description: Option, } +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ArtifactSource { + pub user: Option, + pub email: Option, + pub source_type: Option, + pub run_url: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ArtifactRef { + pub commit_sha: String, + pub branch: Option, + pub commit_message: Option, + pub version: Option, + pub repo_url: Option, +} + +#[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, +} + #[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, PlatformError>; + + async fn create_organisation( + &self, + access_token: &str, + name: &str, + ) -> Result; + + async fn list_members( + &self, + access_token: &str, + organisation_id: &str, + ) -> Result, PlatformError>; + + async fn add_member( + &self, + access_token: &str, + organisation_id: &str, + user_id: &str, + role: &str, + ) -> Result; + + 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; } #[cfg(test)] diff --git a/crates/forage-core/src/session/mod.rs b/crates/forage-core/src/session/mod.rs index bc8b642..8e4b1f7 100644 --- a/crates/forage-core/src/session/mod.rs +++ b/crates/forage-core/src/session/mod.rs @@ -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, } diff --git a/crates/forage-server/src/auth.rs b/crates/forage-server/src/auth.rs index 055fdb7..77d40bc 100644 --- a/crates/forage-server/src/auth.rs +++ b/crates/forage-server/src/auth.rs @@ -77,6 +77,7 @@ impl FromRequestParts for Session { .unwrap_or_default() .into_iter() .map(|o| CachedOrg { + organisation_id: o.organisation_id, name: o.name, role: o.role, }) diff --git a/crates/forage-server/src/forest_client.rs b/crates/forage-server/src/forest_client.rs index 5fb7823..53177ec 100644 --- a/crates/forage-server/src/forest_client.rs +++ b/crates/forage-server/src/forest_client.rs @@ -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 { + 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, 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 { + 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 { + 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)] diff --git a/crates/forage-server/src/main.rs b/crates/forage-server/src/main.rs index 232962f..4c5404a 100644 --- a/crates/forage-server/src/main.rs +++ b/crates/forage-server/src/main.rs @@ -97,1243 +97,6 @@ async fn main() -> anyhow::Result<()> { } #[cfg(test)] -mod tests { - use super::*; - use axum::body::Body; - use axum::http::{Request, StatusCode}; - use chrono::Utc; - use forage_core::auth::*; - use forage_core::platform::{ - Artifact, ArtifactContext, ForestPlatform, Organisation, PlatformError, - }; - use forage_core::session::{CachedOrg, CachedUser, SessionData, SessionStore}; - use std::sync::Mutex; - use tower::ServiceExt; - - /// Configurable mock behavior for testing different scenarios. - #[derive(Default)] - struct MockBehavior { - register_result: Option>, - login_result: Option>, - refresh_result: Option>, - get_user_result: Option>, - list_tokens_result: Option, AuthError>>, - create_token_result: Option>, - delete_token_result: Option>, - } - - /// Configurable mock behavior for platform (orgs, projects, artifacts). - #[derive(Default)] - struct MockPlatformBehavior { - list_orgs_result: Option, PlatformError>>, - list_projects_result: Option, PlatformError>>, - list_artifacts_result: Option, PlatformError>>, - } - - fn ok_tokens() -> AuthTokens { - AuthTokens { - access_token: "mock-access".into(), - refresh_token: "mock-refresh".into(), - expires_in_seconds: 3600, - } - } - - 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. - struct MockForestClient { - behavior: Mutex, - } - - impl MockForestClient { - fn new() -> Self { - Self { - behavior: Mutex::new(MockBehavior::default()), - } - } - - 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 { - let b = self.behavior.lock().unwrap(); - b.register_result.clone().unwrap_or(Ok(ok_tokens())) - } - - async fn login( - &self, - identifier: &str, - password: &str, - ) -> Result { - 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 { - 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 { - 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, 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 { - 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(())) - } - } - - struct MockPlatformClient { - behavior: Mutex, - } - - impl MockPlatformClient { - fn new() -> Self { - Self { - behavior: Mutex::new(MockPlatformBehavior::default()), - } - } - - fn with_behavior(behavior: MockPlatformBehavior) -> Self { - Self { - behavior: Mutex::new(behavior), - } - } - } - - fn default_orgs() -> Vec { - 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, 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, 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, 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()), - }, - created_at: "2026-03-07T12:00:00Z".into(), - }])) - } - } - - 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") - } - - fn test_state() -> (AppState, Arc) { - test_state_with(MockForestClient::new(), MockPlatformClient::new()) - } - - fn test_state_with( - mock: MockForestClient, - platform: MockPlatformClient, - ) -> (AppState, Arc) { - let sessions = Arc::new(InMemorySessionStore::new()); - let state = AppState::new( - make_templates(), - Arc::new(mock), - Arc::new(platform), - sessions.clone(), - ); - (state, sessions) - } - - fn test_app() -> Router { - let (state, _) = test_state(); - build_router(state) - } - - fn test_app_with(mock: MockForestClient) -> Router { - let (state, _) = test_state_with(mock, MockPlatformClient::new()); - build_router(state) - } - - fn default_test_orgs() -> Vec { - vec![CachedOrg { - name: "testorg".into(), - role: "owner".into(), - }] - } - - /// Create a test session and return the cookie header value. - async fn create_test_session(sessions: &Arc) -> 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. - async fn create_expired_session(sessions: &Arc) -> 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 no cached orgs (for onboarding tests). - async fn create_test_session_no_orgs(sessions: &Arc) -> 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) - } - - // ─── Landing / Pricing ──────────────────────────────────────────── - - #[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); - } - - // ─── Auth routes ──────────────────────────────────────────────── - - #[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 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 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_redirects_to_org() { - 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 redirects to first org's projects - assert_eq!(response.status(), StatusCode::SEE_OTHER); - } - - #[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 (redirect to org) because refresh_token works - assert_eq!(response.status(), StatusCode::SEE_OTHER); - assert!(response - .headers() - .get("location") - .unwrap() - .to_str() - .unwrap() - .starts_with("/orgs/")); - } - - #[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 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 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); - } - - // ─── Error path tests ───────────────────────────────────────────── - - #[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 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 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); - } - - #[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")); - } - - #[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")); - } - - #[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); - } - - // ─── Platform / Projects tests ─────────────────────────────────── - - #[tokio::test] - async fn dashboard_with_orgs_redirects_to_first_org() { - 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::SEE_OTHER); - assert_eq!( - response.headers().get("location").unwrap(), - "/orgs/testorg/projects" - ); - } - - #[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("forest orgs create")); - } - - #[tokio::test] - async fn dashboard_platform_unavailable_shows_onboarding() { - // With cached orgs empty, dashboard shows onboarding regardless of platform state - 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(); - // Graceful degradation: shows onboarding instead of error - 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("forest orgs create")); - } - - #[tokio::test] - async fn projects_list_platform_unavailable_degrades_gracefully() { - // When list_projects fails, the route shows empty projects (graceful degradation) - 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")); - } - - #[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")); - } - - #[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(); - // Should show app nav, not marketing nav - assert!(html.contains("Sign out")); - assert!(html.contains("testorg")); - assert!(!html.contains("Sign in")); - } - - #[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 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 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); - } -} +mod test_support; +#[cfg(test)] +mod tests; diff --git a/crates/forage-server/src/routes/auth.rs b/crates/forage-server/src/routes/auth.rs index d0dd614..2fc889b 100644 --- a/crates/forage-server/src/routes/auth.rs +++ b/crates/forage-server/src/routes/auth.rs @@ -18,7 +18,6 @@ pub fn router() -> Router { .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, - session: Session, -) -> Result { - // 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( diff --git a/crates/forage-server/src/routes/platform.rs b/crates/forage-server/src/routes/platform.rs index 1157177..1286250 100644 --- a/crates/forage-server/src/routes/platform.rs +++ b/crates/forage-server/src/routes/platform.rs @@ -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 { 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 { @@ -24,20 +39,236 @@ fn orgs_context(orgs: &[CachedOrg]) -> Vec { .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, + session: Session, +) -> Result { + 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, + session: Session, + Form(form): Form, +) -> Result { + 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, session: Session, Path(org): Path, ) -> Result { - 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, session: Session, Path((org, project)): Path<(String, String)>, ) -> Result { - 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::>(), + } }).collect::>(), }, ) .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, session: Session, Path(org): Path, ) -> Result { - 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, + session: Session, + Path(org): Path, +) -> Result { + 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::>(), + }, + ) + .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, + session: Session, + Path(org): Path, + Form(form): Form, +) -> Result { + 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, + session: Session, + Path((org, user_id)): Path<(String, String)>, + Form(form): Form, +) -> Result { + 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, + session: Session, + Path((org, user_id)): Path<(String, String)>, + Form(form): Form, +) -> Result { + 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()) +} diff --git a/crates/forage-server/src/test_support.rs b/crates/forage-server/src/test_support.rs new file mode 100644 index 0000000..f349b91 --- /dev/null +++ b/crates/forage-server/src/test_support.rs @@ -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>, + pub login_result: Option>, + pub refresh_result: Option>, + pub get_user_result: Option>, + pub list_tokens_result: Option, AuthError>>, + pub create_token_result: Option>, + pub delete_token_result: Option>, +} + +/// Configurable mock behavior for platform (orgs, projects, artifacts). +#[derive(Default)] +pub(crate) struct MockPlatformBehavior { + pub list_orgs_result: Option, PlatformError>>, + pub list_projects_result: Option, PlatformError>>, + pub list_artifacts_result: Option, PlatformError>>, + pub create_organisation_result: Option>, + pub list_members_result: Option, PlatformError>>, + pub add_member_result: Option>, + pub remove_member_result: Option>, + pub update_member_role_result: Option>, +} + +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, +} + +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 { + let b = self.behavior.lock().unwrap(); + b.register_result.clone().unwrap_or(Ok(ok_tokens())) + } + + async fn login( + &self, + identifier: &str, + password: &str, + ) -> Result { + 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 { + 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 { + 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, 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 { + 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, +} + +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 { + 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, 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, 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, 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 { + 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, 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 { + 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 { + 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) { + test_state_with(MockForestClient::new(), MockPlatformClient::new()) +} + +pub(crate) fn test_state_with( + mock: MockForestClient, + platform: MockPlatformClient, +) -> (AppState, Arc) { + 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 { + 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) -> 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) -> 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) -> 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) -> 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) +} diff --git a/crates/forage-server/src/tests/auth_tests.rs b/crates/forage-server/src/tests/auth_tests.rs new file mode 100644 index 0000000..d92ab4f --- /dev/null +++ b/crates/forage-server/src/tests/auth_tests.rs @@ -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); +} diff --git a/crates/forage-server/src/tests/mod.rs b/crates/forage-server/src/tests/mod.rs new file mode 100644 index 0000000..6b2885f --- /dev/null +++ b/crates/forage-server/src/tests/mod.rs @@ -0,0 +1,4 @@ +mod auth_tests; +mod pages_tests; +mod platform_tests; +mod token_tests; diff --git a/crates/forage-server/src/tests/pages_tests.rs b/crates/forage-server/src/tests/pages_tests.rs new file mode 100644 index 0000000..83ab76c --- /dev/null +++ b/crates/forage-server/src/tests/pages_tests.rs @@ -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); +} diff --git a/crates/forage-server/src/tests/platform_tests.rs b/crates/forage-server/src/tests/platform_tests.rs new file mode 100644 index 0000000..fe5c772 --- /dev/null +++ b/crates/forage-server/src/tests/platform_tests.rs @@ -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")); +} diff --git a/crates/forage-server/src/tests/token_tests.rs b/crates/forage-server/src/tests/token_tests.rs new file mode 100644 index 0000000..32c73e0 --- /dev/null +++ b/crates/forage-server/src/tests/token_tests.rs @@ -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); +} diff --git a/specs/features/005-dashboard-enhancement.md b/specs/features/005-dashboard-enhancement.md new file mode 100644 index 0000000..8dbf9ff --- /dev/null +++ b/specs/features/005-dashboard-enhancement.md @@ -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, + pub git_ref: Option, + pub destinations: Vec, + pub created_at: String, +} + +pub struct ArtifactSource { + pub user: Option, + pub email: Option, + pub source_type: Option, + pub run_url: Option, +} + +pub struct ArtifactRef { + pub commit_sha: String, + pub branch: Option, + pub commit_message: Option, + pub version: Option, + pub repo_url: Option, +} + +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, +} +``` + +Add `organisation_id` to `CachedOrg` for member operations. + +New `ForestPlatform` trait methods: +- `create_organisation(access_token, name) -> Result` +- `list_members(access_token, organisation_id) -> Result, PlatformError>` +- `add_member(access_token, organisation_id, user_id, role) -> Result` +- `remove_member(access_token, organisation_id, user_id) -> Result<(), PlatformError>` +- `update_member_role(access_token, organisation_id, user_id, role) -> Result` + +### 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) diff --git a/specs/reviews/005-adversarial-review.md b/specs/reviews/005-adversarial-review.md new file mode 100644 index 0000000..505e3f9 --- /dev/null +++ b/specs/reviews/005-adversarial-review.md @@ -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 diff --git a/templates/base.html.jinja b/templates/base.html.jinja index 4321d71..dc55aed 100644 --- a/templates/base.html.jinja +++ b/templates/base.html.jinja @@ -22,6 +22,7 @@
{% if current_org is defined and current_org %} Projects + Members Usage {% endif %} {% if orgs is defined and orgs | length > 1 %} diff --git a/templates/pages/dashboard.html.jinja b/templates/pages/dashboard.html.jinja index bc391ad..28aae4f 100644 --- a/templates/pages/dashboard.html.jinja +++ b/templates/pages/dashboard.html.jinja @@ -1,56 +1,65 @@ {% extends "base.html.jinja" %} {% block content %} -
-
-
-

Welcome, {{ user.username }}

-

{{ user.emails[0] if user.emails }}

-
-
- - API Tokens - -
- - -
-
-
+
+
+ {# Org sidebar #} + -
-

- 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. -

-
+ {# Main content #} +
+

Recent activity

-
-
-

Projects

-

No projects yet. Deploy your first forest.cue manifest to get started.

-
- -
-

Organisations

-

You're not part of any organisation yet.

-
-
- -
-

Quick start

-
-
# Install forest CLI
-cargo install forest
-
-# Create a project
-forest init my-project --component forage/service
-
-# Deploy
-forest release create --env dev
+ {% if recent_activity %} + + {% else %} +
+

No recent activity

+

Deploy your first release with forest release create

+
+ {% endif %}
diff --git a/templates/pages/members.html.jinja b/templates/pages/members.html.jinja new file mode 100644 index 0000000..62775b4 --- /dev/null +++ b/templates/pages/members.html.jinja @@ -0,0 +1,87 @@ +{% extends "base.html.jinja" %} + +{% block content %} +
+
+
+ ← {{ org_name }} +

Members

+
+
+ + {% if is_admin %} +
+

Add member

+
+ +
+ + +
+
+ + +
+ +
+
+ {% endif %} + +
+ + + + + + + {% if is_admin %} + + {% endif %} + + + + {% for member in members %} + + + + + {% if is_admin %} + + {% endif %} + + {% endfor %} + +
UsernameRoleJoinedActions
{{ member.username }} + + {{ member.role }} + + {{ member.joined_at or "—" }} + {% if member.role != 'owner' %} +
+
+ + + +
+
+ + +
+
+ {% endif %} +
+
+
+{% endblock %} diff --git a/templates/pages/onboarding.html.jinja b/templates/pages/onboarding.html.jinja index 2516d49..49a1b3e 100644 --- a/templates/pages/onboarding.html.jinja +++ b/templates/pages/onboarding.html.jinja @@ -1,12 +1,34 @@ {% extends "base.html.jinja" %} {% block content %} -
+

Welcome to Forage

-

Create your first organisation with the forest CLI to get started.

+

Create your first organisation to get started.

-
-
# Install forest CLI
+    {% if error %}
+    
+ {{ error }} +
+ {% endif %} + +
+ +
+ + +

Lowercase letters, numbers, and hyphens only.

+
+ +
+ +
+

Or use the CLI:

+
+
# Install forest CLI
 cargo install forest
 
 # Create an organisation
@@ -17,9 +39,10 @@ forest init my-project --component forage/service
 
 # Deploy
 forest release create --env dev
+
-
diff --git a/templates/pages/project_detail.html.jinja b/templates/pages/project_detail.html.jinja index 7358fca..bd1198e 100644 --- a/templates/pages/project_detail.html.jinja +++ b/templates/pages/project_detail.html.jinja @@ -16,13 +16,38 @@ {% for artifact in artifacts %}
-
-

{{ artifact.title }}

+
+
+

{{ artifact.title }}

+ {% if artifact.version %} + {{ artifact.version }} + {% endif %} +
{% if artifact.description %}

{{ artifact.description }}

{% endif %} + {% if artifact.branch or artifact.commit_sha %} +
+ {% if artifact.branch %} + {{ artifact.branch }} + {% endif %} + {% if artifact.commit_sha %} + {{ artifact.commit_sha[:8] }} + {% endif %} +
+ {% endif %} + {% if artifact.source_user %} +

by {{ artifact.source_user }}{% if artifact.source_type %} via {{ artifact.source_type }}{% endif %}

+ {% endif %} + {% if artifact.destinations %} +
+ {% for dest in artifact.destinations %} + {{ dest.name }} ({{ dest.environment }}) + {% endfor %} +
+ {% endif %}
-
+

{{ artifact.slug }}

{{ artifact.created_at }}