use crate::harness::{HarnessOptions, TestHarness}; use axum::http::StatusCode; use wiremock::matchers::{header, method, path}; use wiremock::{Mock, MockServer, ResponseTemplate}; #[tokio::test] async fn unauthenticated_sees_login_link() { let mut h = TestHarness::new().await; let resp = h.client.get("/").await; assert!(resp.status.is_success()); assert!( resp.text.contains("Login"), "Expected 'Login' link in unauthenticated page" ); } #[tokio::test] async fn login_redirects_to_mnw() { let mut h = TestHarness::new().await; let resp = h.client.get("/auth/login").await; // Should redirect to the MNW OAuth authorize endpoint assert!( resp.status.is_redirection(), "Expected redirect, got {}", resp.status ); } #[tokio::test] async fn logout_clears_session() { let mut h = TestHarness::new().await; let user_id = h.login_as("logouttest").await; let comm_id = h.create_community("Test", "test").await; let _cat_id = h.create_category(comm_id, "General", "general").await; h.add_membership(user_id, comm_id, "member").await; // Verify logged in — page shows username let resp = h.client.get("/").await; assert!(resp.text.contains("logouttest")); // Logout (POST) h.client.post_form("/auth/logout", "").await; // Should show Login link again let resp = h.client.get("/").await; assert!( resp.text.contains("Login"), "Expected 'Login' link after logout" ); } #[tokio::test] async fn login_redirect_includes_pkce_and_state() { let mut h = TestHarness::new().await; let resp = h.client.get("/auth/login").await; assert!(resp.status.is_redirection()); let location = resp .headers .get("location") .and_then(|v| v.to_str().ok()) .expect("redirect should have location header"); assert!( location.contains("client_id=test-client-id"), "URL should contain client_id" ); assert!( location.contains("code_challenge="), "URL should contain code_challenge" ); assert!( location.contains("code_challenge_method=S256"), "URL should contain S256 method" ); assert!( location.contains("state="), "URL should contain state parameter" ); assert!( location.contains("response_type=code"), "URL should contain response_type=code" ); assert!( location.starts_with("http://127.0.0.1:9999/oauth/authorize"), "Should redirect to MNW OAuth endpoint" ); } #[tokio::test] async fn callback_without_prior_login_rejects_state() { let mut h = TestHarness::new().await; // Establish session without going through login h.client.get("/").await; // Call callback directly — session has no stored state let resp = h .client .get("/auth/callback?code=fake&state=somestate") .await; assert!(resp.status.is_redirection()); let location = resp .headers .get("location") .and_then(|v| v.to_str().ok()) .expect("should have location header"); assert!( location.contains("error=state_mismatch"), "Should redirect with state_mismatch error, got: {}", location ); } #[tokio::test] async fn callback_with_wrong_state_rejects() { let mut h = TestHarness::new().await; // Login sets state + PKCE verifier in session h.client.get("/auth/login").await; // Call callback with wrong state let resp = h .client .get("/auth/callback?code=fake&state=wrong_state_value") .await; assert!(resp.status.is_redirection()); let location = resp .headers .get("location") .and_then(|v| v.to_str().ok()) .expect("should have location header"); assert!( location.contains("error=state_mismatch"), "Should redirect with state_mismatch error, got: {}", location ); } #[tokio::test] async fn callback_with_correct_state_fails_at_token_exchange() { let mut h = TestHarness::new().await; // Login to set state in session let login_resp = h.client.get("/auth/login").await; let location = login_resp .headers .get("location") .and_then(|v| v.to_str().ok()) .expect("login should redirect"); // Extract state from redirect URL let state_start = location.find("state=").expect("state in URL") + 6; let state_end = location[state_start..] .find('&') .map(|i| state_start + i) .unwrap_or(location.len()); let state = &location[state_start..state_end]; // Call callback with correct state — will try HTTP to 127.0.0.1:9999 (no server) let resp = h .client .get(&format!("/auth/callback?code=fake&state={}", state)) .await; assert!(resp.status.is_redirection()); let cb_location = resp .headers .get("location") .and_then(|v| v.to_str().ok()) .expect("should have location header"); assert!( cb_location.contains("error=token_request_failed"), "Should fail at token exchange, got: {}", cb_location ); } // ── Perks refresh (`POST /auth/refresh`) ── // // Refresh re-hits MNW's `/oauth/userinfo` using the cached access token and // overwrites the session's `perks`. These tests use wiremock to stand in for // MNW. /// Spin up a TestHarness pointed at a wiremock MNW. Returns both so individual /// tests can register response expectations on the mock. async fn harness_with_mock_mnw() -> (TestHarness, MockServer) { let mock = MockServer::start().await; let h = TestHarness::with_options(HarnessOptions { mnw_base_url: Some(mock.uri()), ..Default::default() }) .await; (h, mock) } /// Log in via the test harness and seed access token + perks into the session. async fn login_with_token(h: &mut TestHarness, username: &str, token: &str, perks: serde_json::Value) -> uuid::Uuid { let user_id = uuid::Uuid::new_v4(); sqlx::query( "INSERT INTO users (mnw_account_id, username, display_name) \ VALUES ($1, $2, $2) ON CONFLICT (mnw_account_id) DO NOTHING", ) .bind(user_id) .bind(username) .execute(&h.db) .await .expect("insert test user"); h.client.get("/").await; let body = serde_json::json!({ "user_id": user_id.to_string(), "username": username, "access_token": token, "perks": perks, }); h.client.post_json("/_test/login", &body.to_string()).await; user_id } #[tokio::test] async fn refresh_updates_perks_from_mnw() { let (mut h, mock) = harness_with_mock_mnw().await; let user_id = login_with_token( &mut h, "refreshuser", "fake-token", serde_json::json!({ "fan_plus": false, "is_creator": false }), ) .await; Mock::given(method("GET")) .and(path("/oauth/userinfo")) .and(header("authorization", "Bearer fake-token")) .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ "user_id": user_id, "username": "refreshuser", "display_name": "Refresh User", "avatar_url": null, "perks": { "fan_plus": true, "is_creator": false, "creator_tier": null, }, }))) .expect(1) .mount(&mock) .await; let resp = h.client.post_form("/auth/refresh", "").await; assert_eq!(resp.status, StatusCode::OK, "body: {}", resp.text); let body: serde_json::Value = serde_json::from_str(&resp.text).expect("json body"); assert_eq!(body["perks"]["fan_plus"], true); assert_eq!(body["perks"]["is_creator"], false); } #[tokio::test] async fn refresh_returns_creator_tier_features() { let (mut h, mock) = harness_with_mock_mnw().await; let user_id = login_with_token( &mut h, "creatoruser", "creator-token", serde_json::json!({}), ) .await; Mock::given(method("GET")) .and(path("/oauth/userinfo")) .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ "user_id": user_id, "username": "creatoruser", "display_name": null, "avatar_url": null, "perks": { "fan_plus": false, "is_creator": true, "creator_tier": { "tier": "big_files", "features": ["file_uploads", "large_files"] }, }, }))) .mount(&mock) .await; let resp = h.client.post_form("/auth/refresh", "").await; assert_eq!(resp.status, StatusCode::OK); let body: serde_json::Value = serde_json::from_str(&resp.text).unwrap(); assert_eq!(body["perks"]["is_creator"], true); assert_eq!(body["perks"]["creator_tier"]["tier"], "big_files"); let features = body["perks"]["creator_tier"]["features"].as_array().expect("features array"); assert!(features.iter().any(|f| f == "file_uploads")); assert!(features.iter().any(|f| f == "large_files")); } #[tokio::test] async fn refresh_unauthorized_flushes_session() { let (mut h, mock) = harness_with_mock_mnw().await; login_with_token(&mut h, "expireduser", "stale-token", serde_json::json!({})).await; Mock::given(method("GET")) .and(path("/oauth/userinfo")) .respond_with(ResponseTemplate::new(401).set_body_json(serde_json::json!({"error": "invalid_token"}))) .mount(&mock) .await; let resp = h.client.post_form("/auth/refresh", "").await; assert_eq!(resp.status, StatusCode::UNAUTHORIZED); // Session should be flushed — the home page now shows the login link, not the username. let resp = h.client.get("/").await; assert!(resp.text.contains("Login"), "expected login link after session flush"); assert!( !resp.text.contains("expireduser"), "username should not appear after flush" ); } #[tokio::test] async fn refresh_without_session_returns_401() { let mut h = TestHarness::new().await; let resp = h.client.post_form("/auth/refresh", "").await; assert_eq!(resp.status, StatusCode::UNAUTHORIZED); } #[tokio::test] async fn refresh_on_mnw_5xx_returns_bad_gateway() { let (mut h, mock) = harness_with_mock_mnw().await; login_with_token(&mut h, "transientuser", "any-token", serde_json::json!({})).await; Mock::given(method("GET")) .and(path("/oauth/userinfo")) .respond_with(ResponseTemplate::new(503)) .mount(&mock) .await; let resp = h.client.post_form("/auth/refresh", "").await; assert_eq!(resp.status, StatusCode::BAD_GATEWAY); // Session should still be valid — 5xx is transient. let resp = h.client.get("/").await; assert!( resp.text.contains("transientuser"), "session should survive transient MNW error" ); } #[tokio::test] async fn suspended_user_sees_error_page() { let mut h = TestHarness::new().await; let user_id = h.login_as("suspendeduser").await; let comm_id = h.create_community("Test", "test").await; let _cat_id = h.create_category(comm_id, "General", "general").await; h.add_membership(user_id, comm_id, "member").await; // Verify can access community page while not suspended let resp = h.client.get("/p/test").await; assert_eq!(resp.status, StatusCode::OK); // Suspend the user sqlx::query("UPDATE users SET suspended_at = now(), suspension_reason = 'test' WHERE mnw_account_id = $1") .bind(user_id) .execute(&h.db) .await .unwrap(); // Suspended user can still browse with existing session (suspension blocks new logins) // but verify the page still loads (no crash) let resp = h.client.get("/p/test").await; assert!( resp.status == StatusCode::OK || resp.status == StatusCode::FORBIDDEN, "Should either allow (existing session) or block, got: {}", resp.status ); }