feat: add swimlanes

Signed-off-by: kjuulh <contact@kjuulh.io>
This commit is contained in:
2026-03-07 22:53:48 +01:00
parent 9fe1630986
commit 45353089c2
51 changed files with 3845 additions and 147 deletions

View File

@@ -0,0 +1,313 @@
use axum::body::Body;
use axum::http::{Request, StatusCode};
use forage_core::auth::AuthError;
use tower::ServiceExt;
use crate::build_router;
use crate::test_support::*;
// ─── Account settings page ──────────────────────────────────────────
#[tokio::test]
async fn account_page_returns_200() {
let (state, sessions) = test_state();
let cookie = create_test_session(&sessions).await;
let app = build_router(state);
let response = app
.oneshot(
Request::builder()
.uri("/settings/account")
.header("cookie", &cookie)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
let html = String::from_utf8(body.to_vec()).unwrap();
assert!(html.contains("testuser"));
assert!(html.contains("test@example.com"));
}
#[tokio::test]
async fn account_page_unauthenticated_redirects() {
let response = test_app()
.oneshot(
Request::builder()
.uri("/settings/account")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::SEE_OTHER);
assert_eq!(response.headers().get("location").unwrap(), "/login");
}
// ─── Update username ────────────────────────────────────────────────
#[tokio::test]
async fn update_username_success_redirects() {
let (state, sessions) = test_state();
let cookie = create_test_session(&sessions).await;
let app = build_router(state);
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/settings/account/username")
.header("cookie", &cookie)
.header("content-type", "application/x-www-form-urlencoded")
.body(Body::from("username=newname&_csrf=test-csrf"))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::SEE_OTHER);
assert_eq!(
response.headers().get("location").unwrap(),
"/settings/account"
);
}
#[tokio::test]
async fn update_username_invalid_csrf_returns_403() {
let (state, sessions) = test_state();
let cookie = create_test_session(&sessions).await;
let app = build_router(state);
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/settings/account/username")
.header("cookie", &cookie)
.header("content-type", "application/x-www-form-urlencoded")
.body(Body::from("username=newname&_csrf=wrong"))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::FORBIDDEN);
}
#[tokio::test]
async fn update_username_invalid_name_shows_error() {
let (state, sessions) = test_state();
let cookie = create_test_session(&sessions).await;
let app = build_router(state);
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/settings/account/username")
.header("cookie", &cookie)
.header("content-type", "application/x-www-form-urlencoded")
.body(Body::from("username=&_csrf=test-csrf"))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
let html = String::from_utf8(body.to_vec()).unwrap();
assert!(html.contains("required") || html.contains("Username"));
}
// ─── Change password ────────────────────────────────────────────────
#[tokio::test]
async fn change_password_success_redirects() {
let (state, sessions) = test_state();
let cookie = create_test_session(&sessions).await;
let app = build_router(state);
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/settings/account/password")
.header("cookie", &cookie)
.header("content-type", "application/x-www-form-urlencoded")
.body(Body::from(
"current_password=OldPass12345&new_password=NewPass123456&new_password_confirm=NewPass123456&_csrf=test-csrf",
))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::SEE_OTHER);
assert_eq!(
response.headers().get("location").unwrap(),
"/settings/account"
);
}
#[tokio::test]
async fn change_password_mismatch_shows_error() {
let (state, sessions) = test_state();
let cookie = create_test_session(&sessions).await;
let app = build_router(state);
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/settings/account/password")
.header("cookie", &cookie)
.header("content-type", "application/x-www-form-urlencoded")
.body(Body::from(
"current_password=OldPass12345&new_password=NewPass123456&new_password_confirm=Different12345&_csrf=test-csrf",
))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
let html = String::from_utf8(body.to_vec()).unwrap();
assert!(html.contains("match"));
}
#[tokio::test]
async fn change_password_wrong_current_shows_error() {
let mock = MockForestClient::with_behavior(MockBehavior {
change_password_result: Some(Err(AuthError::InvalidCredentials)),
..Default::default()
});
let (state, sessions) = test_state_with(mock, MockPlatformClient::new());
let cookie = create_test_session(&sessions).await;
let app = build_router(state);
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/settings/account/password")
.header("cookie", &cookie)
.header("content-type", "application/x-www-form-urlencoded")
.body(Body::from(
"current_password=WrongPass1234&new_password=NewPass123456&new_password_confirm=NewPass123456&_csrf=test-csrf",
))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
let html = String::from_utf8(body.to_vec()).unwrap();
assert!(html.contains("incorrect") || html.contains("invalid") || html.contains("Invalid") || html.contains("wrong"));
}
// ─── Add email ──────────────────────────────────────────────────────
#[tokio::test]
async fn add_email_success_redirects() {
let (state, sessions) = test_state();
let cookie = create_test_session(&sessions).await;
let app = build_router(state);
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/settings/account/emails")
.header("cookie", &cookie)
.header("content-type", "application/x-www-form-urlencoded")
.body(Body::from("email=new@example.com&_csrf=test-csrf"))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::SEE_OTHER);
assert_eq!(
response.headers().get("location").unwrap(),
"/settings/account"
);
}
#[tokio::test]
async fn add_email_invalid_shows_error() {
let (state, sessions) = test_state();
let cookie = create_test_session(&sessions).await;
let app = build_router(state);
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/settings/account/emails")
.header("cookie", &cookie)
.header("content-type", "application/x-www-form-urlencoded")
.body(Body::from("email=notanemail&_csrf=test-csrf"))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
let html = String::from_utf8(body.to_vec()).unwrap();
assert!(html.contains("invalid") || html.contains("Invalid") || html.contains("valid email"));
}
// ─── Remove email ───────────────────────────────────────────────────
#[tokio::test]
async fn remove_email_success_redirects() {
let (state, sessions) = test_state();
let cookie = create_test_session(&sessions).await;
let app = build_router(state);
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/settings/account/emails/remove")
.header("cookie", &cookie)
.header("content-type", "application/x-www-form-urlencoded")
.body(Body::from("email=old@example.com&_csrf=test-csrf"))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::SEE_OTHER);
assert_eq!(
response.headers().get("location").unwrap(),
"/settings/account"
);
}
#[tokio::test]
async fn remove_email_invalid_csrf_returns_403() {
let (state, sessions) = test_state();
let cookie = create_test_session(&sessions).await;
let app = build_router(state);
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/settings/account/emails/remove")
.header("cookie", &cookie)
.header("content-type", "application/x-www-form-urlencoded")
.body(Body::from("email=old@example.com&_csrf=wrong"))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::FORBIDDEN);
}

View File

@@ -1,3 +1,4 @@
mod account_tests;
mod auth_tests;
mod pages_tests;
mod platform_tests;

View File

@@ -676,6 +676,8 @@ async fn project_detail_shows_enriched_artifact_data() {
context: ArtifactContext {
title: "Deploy v2.0".into(),
description: Some("Major release".into()),
web: None,
pr: None,
},
source: Some(ArtifactSource {
user: Some("ci-bot".into()),
@@ -693,6 +695,9 @@ async fn project_detail_shows_enriched_artifact_data() {
destinations: vec![ArtifactDestination {
name: "production".into(),
environment: "prod".into(),
type_organisation: None,
type_name: None,
type_version: None,
}],
created_at: "2026-03-07T12:00:00Z".into(),
}])),
@@ -723,6 +728,165 @@ async fn project_detail_shows_enriched_artifact_data() {
assert!(html.contains("production"));
}
// ─── Artifact detail ────────────────────────────────────────────────
#[tokio::test]
async fn artifact_detail_returns_200() {
let (state, sessions) = test_state();
let cookie = create_test_session(&sessions).await;
let app = build_router(state);
let response = app
.oneshot(
Request::builder()
.uri("/orgs/testorg/projects/my-api/releases/my-api-abc123")
.header("cookie", &cookie)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
let html = String::from_utf8(body.to_vec()).unwrap();
assert!(html.contains("my-api-abc123"));
assert!(html.contains("Deploy v1.0"));
}
#[tokio::test]
async fn artifact_detail_shows_enriched_data() {
let platform = MockPlatformClient::with_behavior(MockPlatformBehavior {
get_artifact_by_slug_result: Some(Ok(Artifact {
artifact_id: "art-2".into(),
slug: "my-api-def456".into(),
context: ArtifactContext {
title: "Deploy v2.0".into(),
description: Some("Major release".into()),
web: Some("https://example.com".into()),
pr: Some("https://github.com/org/repo/pull/42".into()),
},
source: Some(ArtifactSource {
user: Some("ci-bot".into()),
email: Some("ci@example.com".into()),
source_type: Some("github-actions".into()),
run_url: Some("https://github.com/org/repo/actions/runs/123".into()),
}),
git_ref: Some(ArtifactRef {
commit_sha: "abc1234".into(),
branch: Some("main".into()),
commit_message: Some("feat: add new feature".into()),
version: Some("v2.0.0".into()),
repo_url: Some("https://github.com/org/repo".into()),
}),
destinations: vec![
ArtifactDestination {
name: "production".into(),
environment: "prod".into(),
type_organisation: None,
type_name: None,
type_version: None,
},
ArtifactDestination {
name: "staging".into(),
environment: "staging".into(),
type_organisation: None,
type_name: None,
type_version: None,
},
],
created_at: "2026-03-07T12:00:00Z".into(),
})),
..Default::default()
});
let (state, sessions) = test_state_with(MockForestClient::new(), platform);
let cookie = create_test_session(&sessions).await;
let app = build_router(state);
let response = app
.oneshot(
Request::builder()
.uri("/orgs/testorg/projects/my-api/releases/my-api-def456")
.header("cookie", &cookie)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
let html = String::from_utf8(body.to_vec()).unwrap();
assert!(html.contains("v2.0.0"));
assert!(html.contains("main"));
assert!(html.contains("abc1234"));
assert!(html.contains("ci-bot"));
assert!(html.contains("production"));
assert!(html.contains("staging"));
assert!(html.contains("Major release"));
}
#[tokio::test]
async fn artifact_detail_not_found_returns_404() {
let platform = MockPlatformClient::with_behavior(MockPlatformBehavior {
get_artifact_by_slug_result: Some(Err(PlatformError::NotFound(
"artifact not found".into(),
))),
..Default::default()
});
let (state, sessions) = test_state_with(MockForestClient::new(), platform);
let cookie = create_test_session(&sessions).await;
let app = build_router(state);
let response = app
.oneshot(
Request::builder()
.uri("/orgs/testorg/projects/my-api/releases/nonexistent")
.header("cookie", &cookie)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn artifact_detail_unauthenticated_redirects() {
let response = test_app()
.oneshot(
Request::builder()
.uri("/orgs/testorg/projects/my-api/releases/my-api-abc123")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::SEE_OTHER);
assert_eq!(response.headers().get("location").unwrap(), "/login");
}
#[tokio::test]
async fn artifact_detail_non_member_returns_403() {
let (state, sessions) = test_state();
let cookie = create_test_session(&sessions).await;
let app = build_router(state);
let response = app
.oneshot(
Request::builder()
.uri("/orgs/unknown-org/projects/my-api/releases/some-slug")
.header("cookie", &cookie)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::FORBIDDEN);
}
// ─── Usage ──────────────────────────────────────────────────────────
#[tokio::test]
@@ -835,3 +999,175 @@ async fn error_403_renders_html() {
let html = String::from_utf8(body.to_vec()).unwrap();
assert!(html.contains("Access denied"));
}
// ─── Destinations ────────────────────────────────────────────────────
#[tokio::test]
async fn destinations_page_returns_200() {
let (state, sessions) = test_state();
let cookie = create_test_session(&sessions).await;
let app = build_router(state);
let response = app
.oneshot(
Request::builder()
.uri("/orgs/testorg/destinations")
.header("cookie", &cookie)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
let html = String::from_utf8(body.to_vec()).unwrap();
assert!(html.contains("Destinations"));
}
#[tokio::test]
async fn destinations_page_unauthenticated_redirects() {
let response = test_app()
.oneshot(
Request::builder()
.uri("/orgs/testorg/destinations")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::SEE_OTHER);
assert_eq!(response.headers().get("location").unwrap(), "/login");
}
#[tokio::test]
async fn destinations_page_non_member_returns_403() {
let (state, sessions) = test_state();
let cookie = create_test_session(&sessions).await;
let app = build_router(state);
let response = app
.oneshot(
Request::builder()
.uri("/orgs/otherorg/destinations")
.header("cookie", &cookie)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::FORBIDDEN);
}
#[tokio::test]
async fn destinations_page_shows_empty_state() {
let (state, sessions) = test_state();
let cookie = create_test_session(&sessions).await;
let app = build_router(state);
let response = app
.oneshot(
Request::builder()
.uri("/orgs/testorg/destinations")
.header("cookie", &cookie)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
let html = String::from_utf8(body.to_vec()).unwrap();
assert!(html.contains("No destinations yet"));
}
// ─── Releases ────────────────────────────────────────────────────────
#[tokio::test]
async fn releases_page_returns_200() {
let (state, sessions) = test_state();
let cookie = create_test_session(&sessions).await;
let app = build_router(state);
let response = app
.oneshot(
Request::builder()
.uri("/orgs/testorg/releases")
.header("cookie", &cookie)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
let html = String::from_utf8(body.to_vec()).unwrap();
assert!(html.contains("Continuous deployment"));
}
#[tokio::test]
async fn releases_page_unauthenticated_redirects() {
let response = test_app()
.oneshot(
Request::builder()
.uri("/orgs/testorg/releases")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::SEE_OTHER);
assert_eq!(response.headers().get("location").unwrap(), "/login");
}
#[tokio::test]
async fn releases_page_non_member_returns_403() {
let (state, sessions) = test_state();
let cookie = create_test_session(&sessions).await;
let app = build_router(state);
let response = app
.oneshot(
Request::builder()
.uri("/orgs/otherorg/releases")
.header("cookie", &cookie)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::FORBIDDEN);
}
#[tokio::test]
async fn releases_page_shows_empty_state() {
let platform = MockPlatformClient::with_behavior(MockPlatformBehavior {
list_projects_result: Some(Ok(vec![])),
..Default::default()
});
let (state, sessions) = test_state_with(MockForestClient::new(), platform);
let cookie = create_test_session(&sessions).await;
let app = build_router(state);
let response = app
.oneshot(
Request::builder()
.uri("/orgs/testorg/releases")
.header("cookie", &cookie)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
let html = String::from_utf8(body.to_vec()).unwrap();
assert!(html.contains("No releases yet"));
}