feat: add dashboard

Signed-off-by: kjuulh <contact@kjuulh.io>
This commit is contained in:
2026-03-07 20:31:18 +01:00
parent b439762877
commit d46c365112
21 changed files with 2955 additions and 1367 deletions

View File

@@ -0,0 +1,440 @@
use axum::body::Body;
use axum::http::{Request, StatusCode};
use forage_core::auth::*;
use tower::ServiceExt;
use crate::build_router;
use crate::test_support::*;
// ─── Signup ─────────────────────────────────────────────────────────
#[tokio::test]
async fn signup_page_returns_200() {
let response = test_app()
.oneshot(
Request::builder()
.uri("/signup")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
}
#[tokio::test]
async fn signup_page_contains_form() {
let response = test_app()
.oneshot(
Request::builder()
.uri("/signup")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
let html = String::from_utf8(body.to_vec()).unwrap();
assert!(html.contains("username"));
assert!(html.contains("email"));
assert!(html.contains("password"));
}
#[tokio::test]
async fn signup_duplicate_shows_error() {
let mock = MockForestClient::with_behavior(MockBehavior {
register_result: Some(Err(AuthError::AlreadyExists("username taken".into()))),
..Default::default()
});
let response = test_app_with(mock)
.oneshot(
Request::builder()
.method("POST")
.uri("/signup")
.header("content-type", "application/x-www-form-urlencoded")
.body(Body::from(
"username=testuser&email=test@example.com&password=SecurePass123&password_confirm=SecurePass123",
))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
let html = String::from_utf8(body.to_vec()).unwrap();
assert!(html.contains("already registered"));
}
#[tokio::test]
async fn signup_when_forest_unavailable_shows_error() {
let mock = MockForestClient::with_behavior(MockBehavior {
register_result: Some(Err(AuthError::Unavailable("connection refused".into()))),
..Default::default()
});
let response = test_app_with(mock)
.oneshot(
Request::builder()
.method("POST")
.uri("/signup")
.header("content-type", "application/x-www-form-urlencoded")
.body(Body::from(
"username=testuser&email=test@example.com&password=SecurePass123&password_confirm=SecurePass123",
))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
let html = String::from_utf8(body.to_vec()).unwrap();
assert!(html.contains("temporarily unavailable"));
}
#[tokio::test]
async fn signup_password_too_short_shows_validation_error() {
let response = test_app()
.oneshot(
Request::builder()
.method("POST")
.uri("/signup")
.header("content-type", "application/x-www-form-urlencoded")
.body(Body::from(
"username=testuser&email=test@example.com&password=short&password_confirm=short",
))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
let html = String::from_utf8(body.to_vec()).unwrap();
assert!(html.contains("at least 12"));
}
#[tokio::test]
async fn signup_password_mismatch_shows_error() {
let response = test_app()
.oneshot(
Request::builder()
.method("POST")
.uri("/signup")
.header("content-type", "application/x-www-form-urlencoded")
.body(Body::from(
"username=testuser&email=test@example.com&password=SecurePass123&password_confirm=differentpassword",
))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
let html = String::from_utf8(body.to_vec()).unwrap();
assert!(html.contains("do not match"));
}
// ─── Login ──────────────────────────────────────────────────────────
#[tokio::test]
async fn login_page_returns_200() {
let response = test_app()
.oneshot(
Request::builder()
.uri("/login")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
}
#[tokio::test]
async fn login_page_contains_form() {
let response = test_app()
.oneshot(
Request::builder()
.uri("/login")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
let html = String::from_utf8(body.to_vec()).unwrap();
assert!(html.contains("identifier"));
assert!(html.contains("password"));
}
#[tokio::test]
async fn login_submit_success_sets_session_cookie() {
let response = test_app()
.oneshot(
Request::builder()
.method("POST")
.uri("/login")
.header("content-type", "application/x-www-form-urlencoded")
.body(Body::from(
"identifier=testuser&password=CorrectPass123",
))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::SEE_OTHER);
assert_eq!(response.headers().get("location").unwrap(), "/dashboard");
// Should have a single forage_session cookie
let cookies: Vec<_> = response.headers().get_all("set-cookie").iter().collect();
assert!(!cookies.is_empty());
let cookie_str = cookies[0].to_str().unwrap();
assert!(cookie_str.contains("forage_session="));
assert!(cookie_str.contains("HttpOnly"));
}
#[tokio::test]
async fn login_submit_bad_credentials_shows_error() {
let response = test_app()
.oneshot(
Request::builder()
.method("POST")
.uri("/login")
.header("content-type", "application/x-www-form-urlencoded")
.body(Body::from("identifier=testuser&password=wrongpassword"))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
let html = String::from_utf8(body.to_vec()).unwrap();
assert!(html.contains("Invalid"));
}
#[tokio::test]
async fn login_when_forest_unavailable_shows_error() {
let mock = MockForestClient::with_behavior(MockBehavior {
login_result: Some(Err(AuthError::Unavailable("connection refused".into()))),
..Default::default()
});
let response = test_app_with(mock)
.oneshot(
Request::builder()
.method("POST")
.uri("/login")
.header("content-type", "application/x-www-form-urlencoded")
.body(Body::from("identifier=testuser&password=CorrectPass123"))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
let html = String::from_utf8(body.to_vec()).unwrap();
assert!(html.contains("temporarily unavailable"));
}
#[tokio::test]
async fn login_empty_fields_shows_validation_error() {
let response = test_app()
.oneshot(
Request::builder()
.method("POST")
.uri("/login")
.header("content-type", "application/x-www-form-urlencoded")
.body(Body::from("identifier=&password="))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
let html = String::from_utf8(body.to_vec()).unwrap();
assert!(html.contains("required"));
}
// ─── Session / Dashboard ────────────────────────────────────────────
#[tokio::test]
async fn dashboard_without_auth_redirects_to_login() {
let response = test_app()
.oneshot(
Request::builder()
.uri("/dashboard")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::SEE_OTHER);
assert_eq!(response.headers().get("location").unwrap(), "/login");
}
#[tokio::test]
async fn dashboard_with_session_shows_page() {
let (state, sessions) = test_state();
let cookie = create_test_session(&sessions).await;
let app = build_router(state);
let response = app
.oneshot(
Request::builder()
.uri("/dashboard")
.header("cookie", cookie)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
// Dashboard now renders a proper page
assert_eq!(response.status(), StatusCode::OK);
}
#[tokio::test]
async fn dashboard_with_expired_token_refreshes_transparently() {
let (state, sessions) = test_state();
let cookie = create_expired_session(&sessions).await;
let app = build_router(state);
let response = app
.oneshot(
Request::builder()
.uri("/dashboard")
.header("cookie", cookie)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
// Should succeed (render dashboard) because refresh_token works
assert_eq!(response.status(), StatusCode::OK);
}
#[tokio::test]
async fn dashboard_with_invalid_session_redirects() {
let response = test_app()
.oneshot(
Request::builder()
.uri("/dashboard")
.header("cookie", "forage_session=nonexistent")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::SEE_OTHER);
assert_eq!(response.headers().get("location").unwrap(), "/login");
}
#[tokio::test]
async fn old_token_cookies_are_ignored() {
// Old-style cookies should not authenticate
let response = test_app()
.oneshot(
Request::builder()
.uri("/dashboard")
.header("cookie", "forage_access=mock-access; forage_refresh=mock-refresh")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::SEE_OTHER);
assert_eq!(response.headers().get("location").unwrap(), "/login");
}
#[tokio::test]
async fn expired_session_with_failed_refresh_redirects_to_login() {
let mock = MockForestClient::with_behavior(MockBehavior {
refresh_result: Some(Err(AuthError::NotAuthenticated)),
..Default::default()
});
let (state, sessions) = test_state_with(mock, MockPlatformClient::new());
let cookie = create_expired_session(&sessions).await;
let app = build_router(state);
let response = app
.oneshot(
Request::builder()
.uri("/dashboard")
.header("cookie", cookie)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::SEE_OTHER);
assert_eq!(response.headers().get("location").unwrap(), "/login");
// Session should be destroyed
assert_eq!(sessions.session_count(), 0);
}
// ─── Logout ─────────────────────────────────────────────────────────
#[tokio::test]
async fn logout_destroys_session_and_redirects() {
let (state, sessions) = test_state();
let cookie = create_test_session(&sessions).await;
let app = build_router(state);
assert_eq!(sessions.session_count(), 1);
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/logout")
.header("cookie", &cookie)
.header("content-type", "application/x-www-form-urlencoded")
.body(Body::from("_csrf=test-csrf"))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::SEE_OTHER);
assert_eq!(response.headers().get("location").unwrap(), "/");
// Session should be destroyed
assert_eq!(sessions.session_count(), 0);
}
#[tokio::test]
async fn logout_with_invalid_csrf_returns_403() {
let (state, sessions) = test_state();
let cookie = create_test_session(&sessions).await;
let app = build_router(state);
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/logout")
.header("cookie", &cookie)
.header("content-type", "application/x-www-form-urlencoded")
.body(Body::from("_csrf=wrong-token"))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::FORBIDDEN);
// Session should NOT be destroyed
assert_eq!(sessions.session_count(), 1);
}

View File

@@ -0,0 +1,4 @@
mod auth_tests;
mod pages_tests;
mod platform_tests;
mod token_tests;

View File

@@ -0,0 +1,82 @@
use axum::body::Body;
use axum::http::{Request, StatusCode};
use tower::ServiceExt;
use crate::test_support::*;
#[tokio::test]
async fn landing_page_returns_200() {
let response = test_app()
.oneshot(Request::builder().uri("/").body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
}
#[tokio::test]
async fn landing_page_contains_expected_content() {
let response = test_app()
.oneshot(Request::builder().uri("/").body(Body::empty()).unwrap())
.await
.unwrap();
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
let html = String::from_utf8(body.to_vec()).unwrap();
assert!(html.contains("Forage - The Platform for Forest"));
assert!(html.contains("forest.cue"));
assert!(html.contains("Component Registry"));
assert!(html.contains("Managed Deployments"));
assert!(html.contains("Container Deployments"));
}
#[tokio::test]
async fn pricing_page_returns_200() {
let response = test_app()
.oneshot(
Request::builder()
.uri("/pricing")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
}
#[tokio::test]
async fn pricing_page_contains_all_tiers() {
let response = test_app()
.oneshot(
Request::builder()
.uri("/pricing")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
let html = String::from_utf8(body.to_vec()).unwrap();
assert!(html.contains("Free"));
assert!(html.contains("Developer"));
assert!(html.contains("Team"));
assert!(html.contains("Enterprise"));
assert!(html.contains("$10"));
assert!(html.contains("$25"));
}
#[tokio::test]
async fn unknown_route_returns_404() {
let response = test_app()
.oneshot(
Request::builder()
.uri("/nonexistent")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::NOT_FOUND);
}

View File

@@ -0,0 +1,837 @@
use axum::body::Body;
use axum::http::{Request, StatusCode};
use forage_core::platform::{
Artifact, ArtifactContext, ArtifactDestination, ArtifactRef, ArtifactSource, PlatformError,
};
use tower::ServiceExt;
use crate::build_router;
use crate::test_support::*;
// ─── Dashboard ─────────────────────────────────────────────────────
#[tokio::test]
async fn dashboard_with_orgs_shows_dashboard_page() {
let (state, sessions) = test_state();
let cookie = create_test_session(&sessions).await;
let app = build_router(state);
let response = app
.oneshot(
Request::builder()
.uri("/dashboard")
.header("cookie", &cookie)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
let html = String::from_utf8(body.to_vec()).unwrap();
assert!(html.contains("testorg"));
assert!(html.contains("Recent activity"));
}
#[tokio::test]
async fn dashboard_shows_recent_artifacts() {
let (state, sessions) = test_state();
let cookie = create_test_session(&sessions).await;
let app = build_router(state);
let response = app
.oneshot(
Request::builder()
.uri("/dashboard")
.header("cookie", &cookie)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
let html = String::from_utf8(body.to_vec()).unwrap();
assert!(html.contains("Deploy v1.0"));
}
#[tokio::test]
async fn dashboard_empty_activity_shows_empty_state() {
let platform = MockPlatformClient::with_behavior(MockPlatformBehavior {
list_projects_result: Some(Ok(vec!["my-api".into()])),
list_artifacts_result: Some(Ok(vec![])),
..Default::default()
});
let (state, sessions) = test_state_with(MockForestClient::new(), platform);
let cookie = create_test_session(&sessions).await;
let app = build_router(state);
let response = app
.oneshot(
Request::builder()
.uri("/dashboard")
.header("cookie", &cookie)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
let html = String::from_utf8(body.to_vec()).unwrap();
assert!(html.contains("No recent activity"));
}
#[tokio::test]
async fn dashboard_no_orgs_shows_onboarding() {
let (state, sessions) = test_state();
let cookie = create_test_session_no_orgs(&sessions).await;
let app = build_router(state);
let response = app
.oneshot(
Request::builder()
.uri("/dashboard")
.header("cookie", &cookie)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
let html = String::from_utf8(body.to_vec()).unwrap();
assert!(html.contains("Create organisation"));
}
// ─── Create organisation ───────────────────────────────────────────
#[tokio::test]
async fn create_org_success_redirects_to_new_org() {
let (state, sessions) = test_state();
let cookie = create_test_session(&sessions).await;
let app = build_router(state);
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/orgs")
.header("cookie", &cookie)
.header("content-type", "application/x-www-form-urlencoded")
.body(Body::from("name=my-new-org&_csrf=test-csrf"))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::SEE_OTHER);
assert_eq!(
response.headers().get("location").unwrap(),
"/orgs/my-new-org/projects"
);
}
#[tokio::test]
async fn create_org_invalid_slug_shows_error() {
let (state, sessions) = test_state();
let cookie = create_test_session(&sessions).await;
let app = build_router(state);
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/orgs")
.header("cookie", &cookie)
.header("content-type", "application/x-www-form-urlencoded")
.body(Body::from("name=INVALID ORG&_csrf=test-csrf"))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
let html = String::from_utf8(body.to_vec()).unwrap();
assert!(html.contains("invalid") || html.contains("Invalid"));
}
#[tokio::test]
async fn create_org_invalid_csrf_returns_403() {
let (state, sessions) = test_state();
let cookie = create_test_session(&sessions).await;
let app = build_router(state);
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/orgs")
.header("cookie", &cookie)
.header("content-type", "application/x-www-form-urlencoded")
.body(Body::from("name=my-org&_csrf=wrong-token"))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::FORBIDDEN);
}
#[tokio::test]
async fn create_org_grpc_failure_shows_error() {
let platform = MockPlatformClient::with_behavior(MockPlatformBehavior {
create_organisation_result: Some(Err(PlatformError::Unavailable(
"connection refused".into(),
))),
..Default::default()
});
let (state, sessions) = test_state_with(MockForestClient::new(), platform);
let cookie = create_test_session(&sessions).await;
let app = build_router(state);
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/orgs")
.header("cookie", &cookie)
.header("content-type", "application/x-www-form-urlencoded")
.body(Body::from("name=my-org&_csrf=test-csrf"))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
let html = String::from_utf8(body.to_vec()).unwrap();
assert!(
html.contains("unavailable") || html.contains("error") || html.contains("try again")
);
}
// ─── Members page ──────────────────────────────────────────────────
#[tokio::test]
async fn members_page_returns_200_with_members() {
let (state, sessions) = test_state();
let cookie = create_test_session(&sessions).await;
let app = build_router(state);
let response = app
.oneshot(
Request::builder()
.uri("/orgs/testorg/settings/members")
.header("cookie", &cookie)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
let html = String::from_utf8(body.to_vec()).unwrap();
assert!(html.contains("testuser"));
assert!(html.contains("owner"));
}
#[tokio::test]
async fn members_page_non_member_returns_403() {
let (state, sessions) = test_state();
let cookie = create_test_session(&sessions).await;
let app = build_router(state);
let response = app
.oneshot(
Request::builder()
.uri("/orgs/unknown-org/settings/members")
.header("cookie", &cookie)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::FORBIDDEN);
}
#[tokio::test]
async fn members_page_invalid_slug_returns_400() {
let (state, sessions) = test_state();
let cookie = create_test_session(&sessions).await;
let app = build_router(state);
let response = app
.oneshot(
Request::builder()
.uri("/orgs/INVALID%20ORG/settings/members")
.header("cookie", &cookie)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn members_page_unauthenticated_redirects() {
let response = test_app()
.oneshot(
Request::builder()
.uri("/orgs/testorg/settings/members")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::SEE_OTHER);
assert_eq!(response.headers().get("location").unwrap(), "/login");
}
// ─── Member management ─────────────────────────────────────────────
#[tokio::test]
async fn add_member_success_redirects() {
let (state, sessions) = test_state();
let cookie = create_test_session(&sessions).await;
let app = build_router(state);
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/orgs/testorg/settings/members")
.header("cookie", &cookie)
.header("content-type", "application/x-www-form-urlencoded")
.body(Body::from(
"username=newuser&role=member&_csrf=test-csrf",
))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::SEE_OTHER);
assert_eq!(
response.headers().get("location").unwrap(),
"/orgs/testorg/settings/members"
);
}
#[tokio::test]
async fn add_member_invalid_csrf_returns_403() {
let (state, sessions) = test_state();
let cookie = create_test_session(&sessions).await;
let app = build_router(state);
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/orgs/testorg/settings/members")
.header("cookie", &cookie)
.header("content-type", "application/x-www-form-urlencoded")
.body(Body::from(
"username=newuser&role=member&_csrf=wrong-token",
))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::FORBIDDEN);
}
#[tokio::test]
async fn remove_member_success_redirects() {
let (state, sessions) = test_state();
let cookie = create_test_session(&sessions).await;
let app = build_router(state);
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/orgs/testorg/settings/members/user-456/remove")
.header("cookie", &cookie)
.header("content-type", "application/x-www-form-urlencoded")
.body(Body::from("_csrf=test-csrf"))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::SEE_OTHER);
assert_eq!(
response.headers().get("location").unwrap(),
"/orgs/testorg/settings/members"
);
}
#[tokio::test]
async fn update_member_role_success_redirects() {
let (state, sessions) = test_state();
let cookie = create_test_session(&sessions).await;
let app = build_router(state);
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/orgs/testorg/settings/members/user-456/role")
.header("cookie", &cookie)
.header("content-type", "application/x-www-form-urlencoded")
.body(Body::from("role=admin&_csrf=test-csrf"))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::SEE_OTHER);
assert_eq!(
response.headers().get("location").unwrap(),
"/orgs/testorg/settings/members"
);
}
#[tokio::test]
async fn add_member_non_admin_returns_403() {
let (state, sessions) = test_state();
let cookie = create_test_session_member(&sessions).await;
let app = build_router(state);
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/orgs/testorg/settings/members")
.header("cookie", &cookie)
.header("content-type", "application/x-www-form-urlencoded")
.body(Body::from(
"username=newuser&role=member&_csrf=test-csrf",
))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::FORBIDDEN);
}
#[tokio::test]
async fn remove_member_non_admin_returns_403() {
let (state, sessions) = test_state();
let cookie = create_test_session_member(&sessions).await;
let app = build_router(state);
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/orgs/testorg/settings/members/user-456/remove")
.header("cookie", &cookie)
.header("content-type", "application/x-www-form-urlencoded")
.body(Body::from("_csrf=test-csrf"))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::FORBIDDEN);
}
#[tokio::test]
async fn update_role_non_admin_returns_403() {
let (state, sessions) = test_state();
let cookie = create_test_session_member(&sessions).await;
let app = build_router(state);
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/orgs/testorg/settings/members/user-456/role")
.header("cookie", &cookie)
.header("content-type", "application/x-www-form-urlencoded")
.body(Body::from("role=admin&_csrf=test-csrf"))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::FORBIDDEN);
}
#[tokio::test]
async fn members_page_non_admin_can_view() {
let (state, sessions) = test_state();
let cookie = create_test_session_member(&sessions).await;
let app = build_router(state);
let response = app
.oneshot(
Request::builder()
.uri("/orgs/testorg/settings/members")
.header("cookie", &cookie)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
let html = String::from_utf8(body.to_vec()).unwrap();
// Can see members but NOT the add member form
assert!(html.contains("testuser"));
assert!(!html.contains("Add member"));
}
// ─── Projects list ──────────────────────────────────────────────────
#[tokio::test]
async fn projects_list_returns_200_with_projects() {
let (state, sessions) = test_state();
let cookie = create_test_session(&sessions).await;
let app = build_router(state);
let response = app
.oneshot(
Request::builder()
.uri("/orgs/testorg/projects")
.header("cookie", &cookie)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
let html = String::from_utf8(body.to_vec()).unwrap();
assert!(html.contains("my-api"));
}
#[tokio::test]
async fn projects_list_empty_shows_empty_state() {
let platform = MockPlatformClient::with_behavior(MockPlatformBehavior {
list_projects_result: Some(Ok(vec![])),
..Default::default()
});
let (state, sessions) = test_state_with(MockForestClient::new(), platform);
let cookie = create_test_session(&sessions).await;
let app = build_router(state);
let response = app
.oneshot(
Request::builder()
.uri("/orgs/testorg/projects")
.header("cookie", &cookie)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
let html = String::from_utf8(body.to_vec()).unwrap();
assert!(html.contains("No projects yet"));
}
#[tokio::test]
async fn projects_list_unauthenticated_redirects() {
let response = test_app()
.oneshot(
Request::builder()
.uri("/orgs/testorg/projects")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::SEE_OTHER);
assert_eq!(response.headers().get("location").unwrap(), "/login");
}
#[tokio::test]
async fn projects_list_non_member_returns_403() {
let (state, sessions) = test_state();
let cookie = create_test_session(&sessions).await;
let app = build_router(state);
let response = app
.oneshot(
Request::builder()
.uri("/orgs/unknown-org/projects")
.header("cookie", &cookie)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::FORBIDDEN);
}
#[tokio::test]
async fn projects_list_platform_unavailable_degrades_gracefully() {
let platform = MockPlatformClient::with_behavior(MockPlatformBehavior {
list_projects_result: Some(Err(PlatformError::Unavailable(
"connection refused".into(),
))),
..Default::default()
});
let (state, sessions) = test_state_with(MockForestClient::new(), platform);
let cookie = create_test_session(&sessions).await;
let app = build_router(state);
let response = app
.oneshot(
Request::builder()
.uri("/orgs/testorg/projects")
.header("cookie", &cookie)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
let html = String::from_utf8(body.to_vec()).unwrap();
assert!(html.contains("No projects yet"));
}
// ─── Project detail ─────────────────────────────────────────────────
#[tokio::test]
async fn project_detail_returns_200_with_artifacts() {
let (state, sessions) = test_state();
let cookie = create_test_session(&sessions).await;
let app = build_router(state);
let response = app
.oneshot(
Request::builder()
.uri("/orgs/testorg/projects/my-api")
.header("cookie", &cookie)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
let html = String::from_utf8(body.to_vec()).unwrap();
assert!(html.contains("my-api"));
assert!(html.contains("Deploy v1.0"));
assert!(html.contains("my-api-abc123"));
}
#[tokio::test]
async fn project_detail_empty_artifacts_shows_empty_state() {
let platform = MockPlatformClient::with_behavior(MockPlatformBehavior {
list_artifacts_result: Some(Ok(vec![])),
..Default::default()
});
let (state, sessions) = test_state_with(MockForestClient::new(), platform);
let cookie = create_test_session(&sessions).await;
let app = build_router(state);
let response = app
.oneshot(
Request::builder()
.uri("/orgs/testorg/projects/my-api")
.header("cookie", &cookie)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
let html = String::from_utf8(body.to_vec()).unwrap();
assert!(html.contains("No releases yet"));
}
#[tokio::test]
async fn project_detail_shows_enriched_artifact_data() {
let platform = MockPlatformClient::with_behavior(MockPlatformBehavior {
list_artifacts_result: Some(Ok(vec![Artifact {
artifact_id: "art-2".into(),
slug: "my-api-def456".into(),
context: ArtifactContext {
title: "Deploy v2.0".into(),
description: Some("Major release".into()),
},
source: Some(ArtifactSource {
user: Some("ci-bot".into()),
email: None,
source_type: Some("github-actions".into()),
run_url: Some("https://github.com/org/repo/actions/runs/123".into()),
}),
git_ref: Some(ArtifactRef {
commit_sha: "abc1234".into(),
branch: Some("main".into()),
commit_message: Some("feat: add new feature".into()),
version: Some("v2.0.0".into()),
repo_url: None,
}),
destinations: vec![ArtifactDestination {
name: "production".into(),
environment: "prod".into(),
}],
created_at: "2026-03-07T12:00:00Z".into(),
}])),
..Default::default()
});
let (state, sessions) = test_state_with(MockForestClient::new(), platform);
let cookie = create_test_session(&sessions).await;
let app = build_router(state);
let response = app
.oneshot(
Request::builder()
.uri("/orgs/testorg/projects/my-api")
.header("cookie", &cookie)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
let html = String::from_utf8(body.to_vec()).unwrap();
assert!(html.contains("v2.0.0"));
assert!(html.contains("main"));
assert!(html.contains("abc1234"));
assert!(html.contains("production"));
}
// ─── Usage ──────────────────────────────────────────────────────────
#[tokio::test]
async fn usage_page_returns_200() {
let (state, sessions) = test_state();
let cookie = create_test_session(&sessions).await;
let app = build_router(state);
let response = app
.oneshot(
Request::builder()
.uri("/orgs/testorg/usage")
.header("cookie", &cookie)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
let html = String::from_utf8(body.to_vec()).unwrap();
assert!(html.contains("Early Access"));
assert!(html.contains("testorg"));
}
#[tokio::test]
async fn usage_page_unauthenticated_redirects() {
let response = test_app()
.oneshot(
Request::builder()
.uri("/orgs/testorg/usage")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::SEE_OTHER);
assert_eq!(response.headers().get("location").unwrap(), "/login");
}
#[tokio::test]
async fn usage_page_non_member_returns_403() {
let (state, sessions) = test_state();
let cookie = create_test_session(&sessions).await;
let app = build_router(state);
let response = app
.oneshot(
Request::builder()
.uri("/orgs/unknown-org/usage")
.header("cookie", &cookie)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::FORBIDDEN);
}
// ─── Nav & Error rendering ──────────────────────────────────────────
#[tokio::test]
async fn authenticated_pages_show_app_nav() {
let (state, sessions) = test_state();
let cookie = create_test_session(&sessions).await;
let app = build_router(state);
let response = app
.oneshot(
Request::builder()
.uri("/orgs/testorg/projects")
.header("cookie", &cookie)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
let html = String::from_utf8(body.to_vec()).unwrap();
assert!(html.contains("Sign out"));
assert!(html.contains("testorg"));
assert!(!html.contains("Sign in"));
}
#[tokio::test]
async fn error_403_renders_html() {
let (state, sessions) = test_state();
let cookie = create_test_session(&sessions).await;
let app = build_router(state);
let response = app
.oneshot(
Request::builder()
.uri("/orgs/unknown-org/projects")
.header("cookie", &cookie)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::FORBIDDEN);
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
let html = String::from_utf8(body.to_vec()).unwrap();
assert!(html.contains("Access denied"));
}

View File

@@ -0,0 +1,32 @@
use axum::body::Body;
use axum::http::{Request, StatusCode};
use forage_core::auth::*;
use tower::ServiceExt;
use crate::build_router;
use crate::test_support::*;
#[tokio::test]
async fn delete_token_error_returns_500() {
let mock = MockForestClient::with_behavior(MockBehavior {
delete_token_result: Some(Err(AuthError::Other("db error".into()))),
..Default::default()
});
let (state, sessions) = test_state_with(mock, MockPlatformClient::new());
let cookie = create_test_session(&sessions).await;
let app = build_router(state);
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/settings/tokens/tok-1/delete")
.header("cookie", cookie)
.header("content-type", "application/x-www-form-urlencoded")
.body(Body::from("_csrf=test-csrf"))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
}