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); } #[tokio::test] async fn login_with_remember_me_sets_persistent_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&remember_me=on", )) .unwrap(), ) .await .unwrap(); assert_eq!(response.status(), StatusCode::SEE_OTHER); let cookie_str = response .headers() .get("set-cookie") .unwrap() .to_str() .unwrap(); assert!(cookie_str.contains("forage_session=")); assert!(cookie_str.contains("Max-Age=")); } #[tokio::test] async fn login_without_remember_me_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); let cookie_str = response .headers() .get("set-cookie") .unwrap() .to_str() .unwrap(); assert!(cookie_str.contains("forage_session=")); assert!(!cookie_str.contains("Max-Age=")); } // ─── 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); }