Skip to main content

max / makenotwork

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