Skip to main content

max / makenotwork

20.9 KB · 735 lines History Blame Raw
1 //! Health and API integration tests
2 //!
3 //! These tests verify that the server endpoints are functioning correctly.
4 //! Run with: cargo test --test health
5
6 use reqwest::StatusCode;
7 use sqlx::PgPool;
8 use std::time::Duration;
9
10 /// Base URL for the test server
11 const BASE_URL: &str = "http://localhost:3000";
12
13 /// Test helper to make HTTP requests
14 struct TestClient {
15 client: reqwest::Client,
16 }
17
18 impl TestClient {
19 fn new() -> Self {
20 // reqwest is `rustls-no-provider`, so building a client without a
21 // process-wide provider panics. Idempotent; see `crypto`'s own docs.
22 makenotwork::crypto::install_default_crypto_provider();
23
24 TestClient {
25 client: reqwest::Client::builder()
26 .timeout(Duration::from_secs(10))
27 .cookie_store(true)
28 .build()
29 .expect("Failed to create HTTP client"),
30 }
31 }
32
33 async fn get(&self, path: &str) -> reqwest::Result<reqwest::Response> {
34 self.client.get(format!("{BASE_URL}{path}")).send().await
35 }
36
37 /// GET with the `HX-Request` header HTMX sets on every request it makes.
38 /// Fragment endpoints redirect a plain GET to their parent page, so a test
39 /// asserting on the partial has to ask for it the way the page does.
40 async fn htmx_get(&self, path: &str) -> reqwest::Result<reqwest::Response> {
41 self.client
42 .get(format!("{BASE_URL}{path}"))
43 .header("HX-Request", "true")
44 .send()
45 .await
46 }
47
48 async fn post_form(
49 &self,
50 path: &str,
51 form: &[(&str, &str)],
52 ) -> reqwest::Result<reqwest::Response> {
53 self.client
54 .post(format!("{BASE_URL}{path}"))
55 .form(form)
56 .send()
57 .await
58 }
59 }
60
61 // Public Endpoint Tests
62
63 #[tokio::test]
64 async fn test_health_endpoint() {
65 let client = TestClient::new();
66
67 // Simple health check should return OK
68 let resp = client.get("/health").await;
69 match resp {
70 Ok(r) => {
71 assert_eq!(
72 r.status(),
73 StatusCode::OK,
74 "Health endpoint should return 200"
75 );
76 let body = r.text().await.unwrap_or_default();
77 assert!(
78 body.contains("System Health"),
79 "Health page should contain title"
80 );
81 }
82 Err(e) => {
83 // Server might not be running - skip test
84 eprintln!("Server not available: {e}. Skipping test.");
85 }
86 }
87 }
88
89 #[tokio::test]
90 async fn test_index_page() {
91 let client = TestClient::new();
92
93 let resp = client.get("/").await;
94 match resp {
95 Ok(r) => {
96 assert_eq!(r.status(), StatusCode::OK, "Index page should return 200");
97 let body = r.text().await.unwrap_or_default();
98 assert!(
99 body.contains("Makenot"),
100 "Index page should contain site name"
101 );
102 }
103 Err(e) => {
104 eprintln!("Server not available: {e}. Skipping test.");
105 }
106 }
107 }
108
109 #[tokio::test]
110 async fn test_login_page() {
111 let client = TestClient::new();
112
113 let resp = client.get("/login").await;
114 match resp {
115 Ok(r) => {
116 assert_eq!(r.status(), StatusCode::OK, "Login page should return 200");
117 let body = r.text().await.unwrap_or_default();
118 assert!(
119 body.contains("Log in"),
120 "Login page should contain login form"
121 );
122 assert!(
123 body.contains("csrf-token"),
124 "Login page should have CSRF token"
125 );
126 }
127 Err(e) => {
128 eprintln!("Server not available: {e}. Skipping test.");
129 }
130 }
131 }
132
133 #[tokio::test]
134 async fn test_join_page() {
135 let client = TestClient::new();
136
137 let resp = client.get("/join").await;
138 match resp {
139 Ok(r) => {
140 assert_eq!(r.status(), StatusCode::OK, "Join page should return 200");
141 let body = r.text().await.unwrap_or_default();
142 assert!(
143 body.contains("Create"),
144 "Join page should contain create form"
145 );
146 }
147 Err(e) => {
148 eprintln!("Server not available: {e}. Skipping test.");
149 }
150 }
151 }
152
153 #[tokio::test]
154 async fn test_discover_page() {
155 let client = TestClient::new();
156
157 let resp = client.get("/discover").await;
158 match resp {
159 Ok(r) => {
160 assert_eq!(
161 r.status(),
162 StatusCode::OK,
163 "Discover page should return 200"
164 );
165 let body = r.text().await.unwrap_or_default();
166 assert!(
167 body.contains("Discover") || body.contains("discover"),
168 "Discover page should contain discover content"
169 );
170 }
171 Err(e) => {
172 eprintln!("Server not available: {e}. Skipping test.");
173 }
174 }
175 }
176
177 #[tokio::test]
178 async fn test_nonexistent_page_returns_404() {
179 let client = TestClient::new();
180
181 let resp = client.get("/this-page-does-not-exist-12345").await;
182 match resp {
183 Ok(r) => {
184 assert_eq!(
185 r.status(),
186 StatusCode::NOT_FOUND,
187 "Nonexistent page should return 404"
188 );
189 }
190 Err(e) => {
191 eprintln!("Server not available: {e}. Skipping test.");
192 }
193 }
194 }
195
196 // Auth Endpoint Tests
197
198 #[tokio::test]
199 async fn test_login_with_invalid_credentials() {
200 let client = TestClient::new();
201
202 let resp = client
203 .post_form(
204 "/login",
205 &[
206 ("login", "nonexistent@example.com"),
207 ("password", "wrongpassword"),
208 ],
209 )
210 .await;
211
212 match resp {
213 Ok(r) => {
214 // POST /login is `with_csrf_manual`: the handler itself calls
215 // `validate_token_consuming`, so it is NOT exempt. This client
216 // carries no cookie jar and mints no `_csrf`, so the request is
217 // rejected at the CSRF gate (403) before any credential check,
218 // which is why 403 is the expected status here and the invalid
219 // credentials never actually get exercised. Reaching the credential
220 // path needs a GET /login first to establish a session and token.
221 let status = r.status();
222 assert!(
223 status == StatusCode::FORBIDDEN
224 || status == StatusCode::OK
225 || status == StatusCode::BAD_REQUEST
226 || status == StatusCode::UNAUTHORIZED,
227 "Invalid login should return error, got: {status}"
228 );
229 }
230 Err(e) => {
231 eprintln!("Server not available: {e}. Skipping test.");
232 }
233 }
234 }
235
236 #[tokio::test]
237 async fn test_protected_route_requires_auth() {
238 let client = TestClient::new();
239
240 let resp = client.get("/dashboard").await;
241 match resp {
242 Ok(r) => {
243 // Should redirect to login or return unauthorized
244 let status = r.status();
245 assert!(
246 status == StatusCode::UNAUTHORIZED
247 || status == StatusCode::SEE_OTHER
248 || status == StatusCode::FOUND
249 || status == StatusCode::TEMPORARY_REDIRECT,
250 "Dashboard should require authentication, got: {status}"
251 );
252 }
253 Err(e) => {
254 eprintln!("Server not available: {e}. Skipping test.");
255 }
256 }
257 }
258
259 // API Endpoint Tests
260
261 #[tokio::test]
262 async fn test_username_validation_endpoint_exists() {
263 let client = TestClient::new();
264
265 // Username validation - may require CSRF for authenticated users
266 // Just verify endpoint responds (not 404 or 500)
267 let resp = client
268 .post_form("/api/validate/username", &[("username", "testuser")])
269 .await;
270
271 match resp {
272 Ok(r) => {
273 let status = r.status();
274 // Should not be 404 or 500 - may be 200 (valid), 400 (invalid), or 403 (CSRF)
275 assert!(
276 status != StatusCode::NOT_FOUND && status != StatusCode::INTERNAL_SERVER_ERROR,
277 "Username validation endpoint should exist and not error, got: {status}"
278 );
279 }
280 Err(e) => {
281 eprintln!("Server not available: {e}. Skipping test.");
282 }
283 }
284 }
285
286 // Static File Tests
287
288 #[tokio::test]
289 async fn test_static_css_served() {
290 let client = TestClient::new();
291
292 let resp = client.get("/static/style.css").await;
293 match resp {
294 Ok(r) => {
295 assert_eq!(r.status(), StatusCode::OK, "Static CSS should be served");
296 let content_type = r
297 .headers()
298 .get("content-type")
299 .map(|v| v.to_str().unwrap_or(""));
300 assert!(
301 content_type.is_some_and(|ct| ct.contains("css")),
302 "CSS file should have CSS content type"
303 );
304 }
305 Err(e) => {
306 eprintln!("Server not available: {e}. Skipping test.");
307 }
308 }
309 }
310
311 // Database Integration Tests (requires DATABASE_URL)
312
313 #[tokio::test]
314 async fn test_database_connection() {
315 // Only run if DATABASE_URL is set
316 let Ok(database_url) = std::env::var("DATABASE_URL") else {
317 eprintln!("DATABASE_URL not set, skipping database test");
318 return;
319 };
320
321 let pool = PgPool::connect(&database_url).await;
322 match pool {
323 Ok(p) => {
324 // Test a simple query
325 let result: Result<(i64,), _> = sqlx::query_as("SELECT COUNT(*) FROM users")
326 .fetch_one(&p)
327 .await;
328
329 assert!(result.is_ok(), "Should be able to query users table");
330 }
331 Err(e) => {
332 panic!("Failed to connect to database: {e}");
333 }
334 }
335 }
336
337 #[tokio::test]
338 async fn test_database_tables_exist() {
339 let Ok(database_url) = std::env::var("DATABASE_URL") else {
340 eprintln!("DATABASE_URL not set, skipping database test");
341 return;
342 };
343
344 let pool = PgPool::connect(&database_url)
345 .await
346 .expect("Failed to connect");
347
348 // Check that all expected tables exist (must match migrations 001-025).
349 // Note: the session table was renamed from `sessions` to `user_sessions`
350 // when tower-sessions-sqlx-store config was updated; the old name was left
351 // in this list and broke the assertion in fresh DBs.
352 let tables = vec![
353 "users",
354 "projects",
355 "items",
356 "versions",
357 "transactions",
358 "custom_links",
359 "user_sessions",
360 "blog_posts",
361 "chapters",
362 "creator_waitlist",
363 "creator_waves",
364 "login_tokens",
365 "license_keys",
366 "license_activations",
367 "sync_apps",
368 "sync_devices",
369 "sync_log",
370 "sync_keys",
371 "oauth_authorization_codes",
372 ];
373
374 for table in tables {
375 let result: Result<(bool,), _> = sqlx::query_as(
376 "SELECT EXISTS(SELECT 1 FROM information_schema.tables WHERE table_name = $1)",
377 )
378 .bind(table)
379 .fetch_one(&pool)
380 .await;
381
382 match result {
383 Ok((exists,)) => {
384 assert!(exists, "Table '{table}' should exist");
385 }
386 Err(e) => {
387 panic!("Failed to check table '{table}': {e}");
388 }
389 }
390 }
391 }
392
393 // Public Pages (additional)
394
395 #[tokio::test]
396 async fn test_policy_page() {
397 let client = TestClient::new();
398 let resp = client.get("/policy").await;
399 match resp {
400 Ok(r) => {
401 assert_eq!(r.status(), StatusCode::OK, "Policy page should return 200");
402 }
403 Err(e) => {
404 eprintln!("Server not available: {e}. Skipping test.");
405 }
406 }
407 }
408
409 #[tokio::test]
410 async fn test_creators_page() {
411 let client = TestClient::new();
412 let resp = client.get("/creators").await;
413 match resp {
414 Ok(r) => {
415 assert_eq!(
416 r.status(),
417 StatusCode::OK,
418 "Creators page should return 200"
419 );
420 }
421 Err(e) => {
422 eprintln!("Server not available: {e}. Skipping test.");
423 }
424 }
425 }
426
427 // 404 for nonexistent content
428
429 #[tokio::test]
430 async fn test_nonexistent_user_returns_404() {
431 let client = TestClient::new();
432 let resp = client.get("/u/nonexistent_user_99999").await;
433 match resp {
434 Ok(r) => {
435 assert_eq!(
436 r.status(),
437 StatusCode::NOT_FOUND,
438 "Nonexistent user should return 404"
439 );
440 }
441 Err(e) => {
442 eprintln!("Server not available: {e}. Skipping test.");
443 }
444 }
445 }
446
447 #[tokio::test]
448 async fn test_nonexistent_project_returns_404() {
449 let client = TestClient::new();
450 let resp = client.get("/p/nonexistent-project-slug-99999").await;
451 match resp {
452 Ok(r) => {
453 assert_eq!(
454 r.status(),
455 StatusCode::NOT_FOUND,
456 "Nonexistent project should return 404"
457 );
458 }
459 Err(e) => {
460 eprintln!("Server not available: {e}. Skipping test.");
461 }
462 }
463 }
464
465 // Auth-required API endpoints reject unauthenticated
466
467 #[tokio::test]
468 async fn test_api_projects_requires_auth() {
469 let client = TestClient::new();
470 let resp = client
471 .post_form("/api/projects", &[("title", "Test")])
472 .await;
473 match resp {
474 Ok(r) => {
475 let status = r.status();
476 assert!(
477 status == StatusCode::UNAUTHORIZED
478 || status == StatusCode::FORBIDDEN
479 || status == StatusCode::SEE_OTHER
480 || status == StatusCode::FOUND,
481 "POST /api/projects should require auth, got: {status}"
482 );
483 }
484 Err(e) => {
485 eprintln!("Server not available: {e}. Skipping test.");
486 }
487 }
488 }
489
490 #[tokio::test]
491 async fn test_api_items_requires_auth() {
492 let client = TestClient::new();
493 let resp = client
494 .post_form(
495 "/api/projects/00000000-0000-0000-0000-000000000000/items",
496 &[("title", "Test")],
497 )
498 .await;
499 match resp {
500 Ok(r) => {
501 let status = r.status();
502 assert!(
503 status == StatusCode::UNAUTHORIZED
504 || status == StatusCode::FORBIDDEN
505 || status == StatusCode::SEE_OTHER
506 || status == StatusCode::FOUND,
507 "POST /api/projects/:id/items should require auth, got: {status}"
508 );
509 }
510 Err(e) => {
511 eprintln!("Server not available: {e}. Skipping test.");
512 }
513 }
514 }
515
516 #[tokio::test]
517 async fn test_api_export_projects_requires_auth() {
518 let client = TestClient::new();
519 let resp = client.post_form("/api/export/projects", &[]).await;
520 match resp {
521 Ok(r) => {
522 let status = r.status();
523 assert!(
524 status == StatusCode::UNAUTHORIZED
525 || status == StatusCode::FORBIDDEN
526 || status == StatusCode::SEE_OTHER
527 || status == StatusCode::FOUND,
528 "POST /api/export/projects should require auth, got: {status}"
529 );
530 }
531 Err(e) => {
532 eprintln!("Server not available: {e}. Skipping test.");
533 }
534 }
535 }
536
537 // Discover variants
538
539 #[tokio::test]
540 async fn test_discover_projects_mode() {
541 let client = TestClient::new();
542 let resp = client.get("/discover?mode=projects").await;
543 match resp {
544 Ok(r) => {
545 assert_eq!(
546 r.status(),
547 StatusCode::OK,
548 "Discover projects mode should return 200"
549 );
550 }
551 Err(e) => {
552 eprintln!("Server not available: {e}. Skipping test.");
553 }
554 }
555 }
556
557 #[tokio::test]
558 async fn test_discover_results_partial() {
559 let client = TestClient::new();
560 let resp = client.htmx_get("/discover/results").await;
561 match resp {
562 Ok(r) => {
563 assert_eq!(
564 r.status(),
565 StatusCode::OK,
566 "Discover results partial should return 200"
567 );
568 }
569 Err(e) => {
570 eprintln!("Server not available: {e}. Skipping test.");
571 }
572 }
573 }
574
575 // RSS 404s for nonexistent content
576
577 #[tokio::test]
578 async fn test_nonexistent_user_rss_returns_404() {
579 let client = TestClient::new();
580 let resp = client.get("/u/nonexistent_user_99999/rss").await;
581 match resp {
582 Ok(r) => {
583 assert_eq!(
584 r.status(),
585 StatusCode::NOT_FOUND,
586 "Nonexistent user RSS should return 404"
587 );
588 }
589 Err(e) => {
590 eprintln!("Server not available: {e}. Skipping test.");
591 }
592 }
593 }
594
595 #[tokio::test]
596 async fn test_nonexistent_project_rss_returns_404() {
597 let client = TestClient::new();
598 let resp = client.get("/p/nonexistent-project-slug-99999/rss").await;
599 match resp {
600 Ok(r) => {
601 assert_eq!(
602 r.status(),
603 StatusCode::NOT_FOUND,
604 "Nonexistent project RSS should return 404"
605 );
606 }
607 Err(e) => {
608 eprintln!("Server not available: {e}. Skipping test.");
609 }
610 }
611 }
612
613 // Username validation
614
615 #[tokio::test]
616 async fn test_username_validation_short_input() {
617 let client = TestClient::new();
618 let resp = client
619 .post_form("/api/validate/username", &[("username", "ab")])
620 .await;
621 match resp {
622 Ok(r) => {
623 let status = r.status();
624 assert!(
625 status != StatusCode::NOT_FOUND && status != StatusCode::INTERNAL_SERVER_ERROR,
626 "Short username validation should not be 404/500, got: {status}"
627 );
628 }
629 Err(e) => {
630 eprintln!("Server not available: {e}. Skipping test.");
631 }
632 }
633 }
634
635 #[tokio::test]
636 async fn test_username_validation_invalid_chars() {
637 let client = TestClient::new();
638 let resp = client
639 .post_form("/api/validate/username", &[("username", "user@name!")])
640 .await;
641 match resp {
642 Ok(r) => {
643 let status = r.status();
644 assert!(
645 status != StatusCode::NOT_FOUND && status != StatusCode::INTERNAL_SERVER_ERROR,
646 "Invalid-chars username validation should not be 404/500, got: {status}"
647 );
648 }
649 Err(e) => {
650 eprintln!("Server not available: {e}. Skipping test.");
651 }
652 }
653 }
654
655 // JSON Health Endpoint
656
657 #[tokio::test]
658 async fn test_api_health_json_endpoint() {
659 let client = TestClient::new();
660
661 let resp = client.get("/api/health").await;
662 match resp {
663 Ok(r) => {
664 let status = r.status();
665 assert!(
666 status == StatusCode::OK || status == StatusCode::SERVICE_UNAVAILABLE,
667 "JSON health endpoint should return 200 or 503, got: {status}"
668 );
669
670 let content_type = r
671 .headers()
672 .get("content-type")
673 .and_then(|v| v.to_str().ok())
674 .unwrap_or("");
675 assert!(
676 content_type.contains("application/json"),
677 "JSON health endpoint should return application/json, got: {content_type}"
678 );
679
680 let body: serde_json::Value = r.json().await.expect("Should parse as JSON");
681 assert!(
682 body.get("status").is_some(),
683 "JSON response should have 'status' field"
684 );
685 assert!(
686 body.get("version").is_some(),
687 "JSON response should have 'version' field"
688 );
689 }
690 Err(e) => {
691 eprintln!("Server not available: {e}. Skipping test.");
692 }
693 }
694 }
695
696 // Health self-check (verifies uptime field)
697
698 #[tokio::test]
699 async fn test_health_contains_uptime() {
700 let client = TestClient::new();
701 let resp = client.get("/health").await;
702 match resp {
703 Ok(r) => {
704 assert_eq!(r.status(), StatusCode::OK);
705 let body = r.text().await.unwrap_or_default();
706 assert!(
707 body.contains("Uptime:"),
708 "Health page should contain uptime field"
709 );
710 }
711 Err(e) => {
712 eprintln!("Server not available: {e}. Skipping test.");
713 }
714 }
715 }
716
717 // Test Runner Summary
718
719 /// Run this to see a summary of all tests
720 /// cargo test --test health -- --nocapture
721 #[tokio::test]
722 async fn test_summary() {
723 println!("\n=== Makenotwork Integration Tests ===\n");
724 println!("Tests check:");
725 println!(" - Public pages load correctly");
726 println!(" - Auth endpoints respond appropriately");
727 println!(" - Protected routes require authentication");
728 println!(" - Static files are served");
729 println!(" - Database connection works (if DATABASE_URL set)");
730 println!("\nNote: Tests that require the server will be skipped if not running.");
731 println!("\nTo run all tests with server:");
732 println!(" 1. Start server: cargo run");
733 println!(" 2. Run tests: cargo test --test health");
734 }
735