//! Health and API integration tests //! //! These tests verify that the server endpoints are functioning correctly. //! Run with: cargo test --test health //! //! They talk to a server that must already be running at localhost:3000. When //! it is not, every request errors and each test prints a skip notice and //! passes, so a green run here is not evidence the endpoints were exercised. use reqwest::StatusCode; use sqlx::PgPool; use std::time::Duration; /// Base URL for the test server const BASE_URL: &str = "http://localhost:3000"; /// Test helper to make HTTP requests struct TestClient { client: reqwest::Client, } impl TestClient { fn new() -> Self { // reqwest is `rustls-no-provider`, so building a client without a // process-wide provider panics. Idempotent; see `crypto`'s own docs. makenotwork::crypto::install_default_crypto_provider(); TestClient { client: reqwest::Client::builder() .timeout(Duration::from_secs(10)) .cookie_store(true) .build() .expect("Failed to create HTTP client"), } } async fn get(&self, path: &str) -> reqwest::Result { self.client.get(format!("{BASE_URL}{path}")).send().await } /// GET with the `HX-Request` header HTMX sets on every request it makes. /// Fragment endpoints redirect a plain GET to their parent page, so a test /// asserting on the partial has to ask for it the way the page does. async fn htmx_get(&self, path: &str) -> reqwest::Result { self.client .get(format!("{BASE_URL}{path}")) .header("HX-Request", "true") .send() .await } async fn post_form( &self, path: &str, form: &[(&str, &str)], ) -> reqwest::Result { self.client .post(format!("{BASE_URL}{path}")) .form(form) .send() .await } } // Public Endpoint Tests #[tokio::test] async fn test_health_endpoint() { let client = TestClient::new(); // Simple health check should return OK let resp = client.get("/health").await; match resp { Ok(r) => { assert_eq!( r.status(), StatusCode::OK, "Health endpoint should return 200" ); let body = r.text().await.unwrap_or_default(); assert!( body.contains("System Health"), "Health page should contain title" ); } Err(e) => { // Server might not be running - skip test eprintln!("Server not available: {e}. Skipping test."); } } } #[tokio::test] async fn test_index_page() { let client = TestClient::new(); let resp = client.get("/").await; match resp { Ok(r) => { assert_eq!(r.status(), StatusCode::OK, "Index page should return 200"); let body = r.text().await.unwrap_or_default(); assert!( body.contains("Makenot"), "Index page should contain site name" ); } Err(e) => { eprintln!("Server not available: {e}. Skipping test."); } } } #[tokio::test] async fn test_login_page() { let client = TestClient::new(); let resp = client.get("/login").await; match resp { Ok(r) => { assert_eq!(r.status(), StatusCode::OK, "Login page should return 200"); let body = r.text().await.unwrap_or_default(); assert!( body.contains("Log in"), "Login page should contain login form" ); assert!( body.contains("csrf-token"), "Login page should have CSRF token" ); } Err(e) => { eprintln!("Server not available: {e}. Skipping test."); } } } #[tokio::test] async fn test_join_page() { let client = TestClient::new(); let resp = client.get("/join").await; match resp { Ok(r) => { assert_eq!(r.status(), StatusCode::OK, "Join page should return 200"); let body = r.text().await.unwrap_or_default(); assert!( body.contains("Create"), "Join page should contain create form" ); } Err(e) => { eprintln!("Server not available: {e}. Skipping test."); } } } #[tokio::test] async fn test_discover_page() { let client = TestClient::new(); let resp = client.get("/discover").await; match resp { Ok(r) => { assert_eq!( r.status(), StatusCode::OK, "Discover page should return 200" ); let body = r.text().await.unwrap_or_default(); assert!( body.contains("Discover") || body.contains("discover"), "Discover page should contain discover content" ); } Err(e) => { eprintln!("Server not available: {e}. Skipping test."); } } } #[tokio::test] async fn test_nonexistent_page_returns_404() { let client = TestClient::new(); let resp = client.get("/this-page-does-not-exist-12345").await; match resp { Ok(r) => { assert_eq!( r.status(), StatusCode::NOT_FOUND, "Nonexistent page should return 404" ); } Err(e) => { eprintln!("Server not available: {e}. Skipping test."); } } } // Auth Endpoint Tests #[tokio::test] async fn test_login_with_invalid_credentials() { let client = TestClient::new(); let resp = client .post_form( "/login", &[ ("login", "nonexistent@example.com"), ("password", "wrongpassword"), ], ) .await; match resp { Ok(r) => { // POST /login is `with_csrf_manual`: the handler itself calls // `validate_token_consuming`, so it is NOT exempt. This client // carries no cookie jar and mints no `_csrf`, so the request is // rejected at the CSRF gate (403) before any credential check, // which is why 403 is the expected status here and the invalid // credentials never actually get exercised. Reaching the credential // path needs a GET /login first to establish a session and token. let status = r.status(); assert!( status == StatusCode::FORBIDDEN || status == StatusCode::OK || status == StatusCode::BAD_REQUEST || status == StatusCode::UNAUTHORIZED, "Invalid login should return error, got: {status}" ); } Err(e) => { eprintln!("Server not available: {e}. Skipping test."); } } } #[tokio::test] async fn test_protected_route_requires_auth() { let client = TestClient::new(); let resp = client.get("/dashboard").await; match resp { Ok(r) => { // Should redirect to login or return unauthorized let status = r.status(); assert!( status == StatusCode::UNAUTHORIZED || status == StatusCode::SEE_OTHER || status == StatusCode::FOUND || status == StatusCode::TEMPORARY_REDIRECT, "Dashboard should require authentication, got: {status}" ); } Err(e) => { eprintln!("Server not available: {e}. Skipping test."); } } } // API Endpoint Tests #[tokio::test] async fn test_username_validation_endpoint_exists() { let client = TestClient::new(); // Username validation - may require CSRF for authenticated users // Just verify endpoint responds (not 404 or 500) let resp = client .post_form("/api/validate/username", &[("username", "testuser")]) .await; match resp { Ok(r) => { let status = r.status(); // Should not be 404 or 500 - may be 200 (valid), 400 (invalid), or 403 (CSRF) assert!( status != StatusCode::NOT_FOUND && status != StatusCode::INTERNAL_SERVER_ERROR, "Username validation endpoint should exist and not error, got: {status}" ); } Err(e) => { eprintln!("Server not available: {e}. Skipping test."); } } } // Static File Tests #[tokio::test] async fn test_static_css_served() { let client = TestClient::new(); let resp = client.get("/static/style.css").await; match resp { Ok(r) => { assert_eq!(r.status(), StatusCode::OK, "Static CSS should be served"); let content_type = r .headers() .get("content-type") .map(|v| v.to_str().unwrap_or("")); assert!( content_type.is_some_and(|ct| ct.contains("css")), "CSS file should have CSS content type" ); } Err(e) => { eprintln!("Server not available: {e}. Skipping test."); } } } // Database Integration Tests (requires DATABASE_URL) #[tokio::test] async fn test_database_connection() { // Only run if DATABASE_URL is set let Ok(database_url) = std::env::var("DATABASE_URL") else { eprintln!("DATABASE_URL not set, skipping database test"); return; }; let pool = PgPool::connect(&database_url).await; match pool { Ok(p) => { // Test a simple query let result: Result<(i64,), _> = sqlx::query_as("SELECT COUNT(*) FROM users") .fetch_one(&p) .await; assert!(result.is_ok(), "Should be able to query users table"); } Err(e) => { panic!("Failed to connect to database: {e}"); } } } #[tokio::test] async fn test_database_tables_exist() { let Ok(database_url) = std::env::var("DATABASE_URL") else { eprintln!("DATABASE_URL not set, skipping database test"); return; }; let pool = PgPool::connect(&database_url) .await .expect("Failed to connect"); // Check that all expected tables exist (must match migrations 001-025). // Note: the session table was renamed from `sessions` to `user_sessions` // when tower-sessions-sqlx-store config was updated; the old name was left // in this list and broke the assertion in fresh DBs. let tables = vec![ "users", "projects", "items", "versions", "transactions", "custom_links", "user_sessions", "blog_posts", "chapters", "creator_waitlist", "creator_waves", "login_tokens", "license_keys", "license_activations", "sync_apps", "sync_devices", "sync_log", "sync_keys", "oauth_authorization_codes", ]; for table in tables { let result: Result<(bool,), _> = sqlx::query_as( "SELECT EXISTS(SELECT 1 FROM information_schema.tables WHERE table_name = $1)", ) .bind(table) .fetch_one(&pool) .await; match result { Ok((exists,)) => { assert!(exists, "Table '{table}' should exist"); } Err(e) => { panic!("Failed to check table '{table}': {e}"); } } } } // Public Pages (additional) #[tokio::test] async fn test_policy_page() { let client = TestClient::new(); let resp = client.get("/policy").await; match resp { Ok(r) => { assert_eq!(r.status(), StatusCode::OK, "Policy page should return 200"); } Err(e) => { eprintln!("Server not available: {e}. Skipping test."); } } } #[tokio::test] async fn test_creators_page() { let client = TestClient::new(); let resp = client.get("/creators").await; match resp { Ok(r) => { assert_eq!( r.status(), StatusCode::OK, "Creators page should return 200" ); } Err(e) => { eprintln!("Server not available: {e}. Skipping test."); } } } // 404 for nonexistent content #[tokio::test] async fn test_nonexistent_user_returns_404() { let client = TestClient::new(); let resp = client.get("/u/nonexistent_user_99999").await; match resp { Ok(r) => { assert_eq!( r.status(), StatusCode::NOT_FOUND, "Nonexistent user should return 404" ); } Err(e) => { eprintln!("Server not available: {e}. Skipping test."); } } } #[tokio::test] async fn test_nonexistent_project_returns_404() { let client = TestClient::new(); let resp = client.get("/p/nonexistent-project-slug-99999").await; match resp { Ok(r) => { assert_eq!( r.status(), StatusCode::NOT_FOUND, "Nonexistent project should return 404" ); } Err(e) => { eprintln!("Server not available: {e}. Skipping test."); } } } // Auth-required API endpoints reject unauthenticated #[tokio::test] async fn test_api_projects_requires_auth() { let client = TestClient::new(); let resp = client .post_form("/api/projects", &[("title", "Test")]) .await; match resp { Ok(r) => { let status = r.status(); assert!( status == StatusCode::UNAUTHORIZED || status == StatusCode::FORBIDDEN || status == StatusCode::SEE_OTHER || status == StatusCode::FOUND, "POST /api/projects should require auth, got: {status}" ); } Err(e) => { eprintln!("Server not available: {e}. Skipping test."); } } } #[tokio::test] async fn test_api_items_requires_auth() { let client = TestClient::new(); let resp = client .post_form( "/api/projects/00000000-0000-0000-0000-000000000000/items", &[("title", "Test")], ) .await; match resp { Ok(r) => { let status = r.status(); assert!( status == StatusCode::UNAUTHORIZED || status == StatusCode::FORBIDDEN || status == StatusCode::SEE_OTHER || status == StatusCode::FOUND, "POST /api/projects/:id/items should require auth, got: {status}" ); } Err(e) => { eprintln!("Server not available: {e}. Skipping test."); } } } #[tokio::test] async fn test_api_export_projects_requires_auth() { let client = TestClient::new(); let resp = client.post_form("/api/export/projects", &[]).await; match resp { Ok(r) => { let status = r.status(); assert!( status == StatusCode::UNAUTHORIZED || status == StatusCode::FORBIDDEN || status == StatusCode::SEE_OTHER || status == StatusCode::FOUND, "POST /api/export/projects should require auth, got: {status}" ); } Err(e) => { eprintln!("Server not available: {e}. Skipping test."); } } } // Discover variants #[tokio::test] async fn test_discover_projects_mode() { let client = TestClient::new(); let resp = client.get("/discover?mode=projects").await; match resp { Ok(r) => { assert_eq!( r.status(), StatusCode::OK, "Discover projects mode should return 200" ); } Err(e) => { eprintln!("Server not available: {e}. Skipping test."); } } } #[tokio::test] async fn test_discover_results_partial() { let client = TestClient::new(); let resp = client.htmx_get("/discover/results").await; match resp { Ok(r) => { assert_eq!( r.status(), StatusCode::OK, "Discover results partial should return 200" ); } Err(e) => { eprintln!("Server not available: {e}. Skipping test."); } } } // RSS 404s for nonexistent content #[tokio::test] async fn test_nonexistent_user_rss_returns_404() { let client = TestClient::new(); let resp = client.get("/u/nonexistent_user_99999/rss").await; match resp { Ok(r) => { assert_eq!( r.status(), StatusCode::NOT_FOUND, "Nonexistent user RSS should return 404" ); } Err(e) => { eprintln!("Server not available: {e}. Skipping test."); } } } #[tokio::test] async fn test_nonexistent_project_rss_returns_404() { let client = TestClient::new(); let resp = client.get("/p/nonexistent-project-slug-99999/rss").await; match resp { Ok(r) => { assert_eq!( r.status(), StatusCode::NOT_FOUND, "Nonexistent project RSS should return 404" ); } Err(e) => { eprintln!("Server not available: {e}. Skipping test."); } } } // Username validation #[tokio::test] async fn test_username_validation_short_input() { let client = TestClient::new(); let resp = client .post_form("/api/validate/username", &[("username", "ab")]) .await; match resp { Ok(r) => { let status = r.status(); assert!( status != StatusCode::NOT_FOUND && status != StatusCode::INTERNAL_SERVER_ERROR, "Short username validation should not be 404/500, got: {status}" ); } Err(e) => { eprintln!("Server not available: {e}. Skipping test."); } } } #[tokio::test] async fn test_username_validation_invalid_chars() { let client = TestClient::new(); let resp = client .post_form("/api/validate/username", &[("username", "user@name!")]) .await; match resp { Ok(r) => { let status = r.status(); assert!( status != StatusCode::NOT_FOUND && status != StatusCode::INTERNAL_SERVER_ERROR, "Invalid-chars username validation should not be 404/500, got: {status}" ); } Err(e) => { eprintln!("Server not available: {e}. Skipping test."); } } } // JSON Health Endpoint #[tokio::test] async fn test_api_health_json_endpoint() { let client = TestClient::new(); let resp = client.get("/api/health").await; match resp { Ok(r) => { let status = r.status(); assert!( status == StatusCode::OK || status == StatusCode::SERVICE_UNAVAILABLE, "JSON health endpoint should return 200 or 503, got: {status}" ); let content_type = r .headers() .get("content-type") .and_then(|v| v.to_str().ok()) .unwrap_or(""); assert!( content_type.contains("application/json"), "JSON health endpoint should return application/json, got: {content_type}" ); let body: serde_json::Value = r.json().await.expect("Should parse as JSON"); assert!( body.get("status").is_some(), "JSON response should have 'status' field" ); assert!( body.get("version").is_some(), "JSON response should have 'version' field" ); } Err(e) => { eprintln!("Server not available: {e}. Skipping test."); } } } // Health self-check (verifies uptime field) #[tokio::test] async fn test_health_contains_uptime() { let client = TestClient::new(); let resp = client.get("/health").await; match resp { Ok(r) => { assert_eq!(r.status(), StatusCode::OK); let body = r.text().await.unwrap_or_default(); assert!( body.contains("Uptime:"), "Health page should contain uptime field" ); } Err(e) => { eprintln!("Server not available: {e}. Skipping test."); } } } // Test Runner Summary /// Run this to see a summary of all tests /// cargo test --test health -- --nocapture #[tokio::test] async fn test_summary() { println!("\n=== Makenotwork Integration Tests ===\n"); println!("Tests check:"); println!(" - Public pages load correctly"); println!(" - Auth endpoints respond appropriately"); println!(" - Protected routes require authentication"); println!(" - Static files are served"); println!(" - Database connection works (if DATABASE_URL set)"); println!("\nNote: Tests that require the server will be skipped if not running."); println!("\nTo run all tests with server:"); println!(" 1. Start server: cargo run"); println!(" 2. Run tests: cargo test --test health"); }