// SPDX-License-Identifier: AGPL-3.0-or-later #![recursion_limit = "256"] use axum::{ Json, Router, body::{Body, to_bytes}, http::{HeaderMap, Method, Request, StatusCode, Uri, header}, response::{IntoResponse, Response}, routing, }; use fluxer_admin::{ api::{generated::types as generated_types, types::LookupGuildResponse}, build_router, config::{AdminConfig, ProxyConfig, RuntimeEnv}, session, }; use serde_json::{Value, json}; use std::sync::{ Arc, Mutex, atomic::{AtomicUsize, Ordering}, }; use tokio::net::TcpListener; use tower::ServiceExt; const SECRET_KEY: &str = "htmx-acceptance-test-secret"; const ADMIN_API_KEY_SECRET: &str = "fa_1900000000000000001_OneTimeSecretForAcceptance"; struct TestApp { router: Router, session_cookie: String, } #[tokio::test] async fn admin_api_key_create_form_renders_the_one_time_secret() { let app = setup().await; let (headers, page) = get_with_headers(&app, "/admin-api-keys", &[]).await; let csrf_token = csrf_cookie(&headers) .unwrap_or_else(|| panic!("Admin API keys page did not set csrf_token cookie\n{page}")); assert!(page.contains(r#"data-admin-result-form="true""#), "{page}"); let (status, _, response_body) = post_form_with_headers( &app, "/admin-api-keys?action=create", &[ ("HX-Request", "true"), ("HX-Boosted", "true"), ("HX-Target", "body"), ( "Cookie", &format!("{}; csrf_token={}", app.session_cookie, csrf_token), ), ], &format!("_csrf={csrf_token}&name=Acceptance+Key&acls=*"), ) .await; assert_eq!(status, StatusCode::OK, "{response_body}"); assert_full_layout(&response_body); assert!(response_body.contains(r#"hx-history="false""#)); assert!(response_body.contains(ADMIN_API_KEY_SECRET)); } #[tokio::test] async fn search_routes_return_layout_or_fragments_by_hx_target() { let app = setup().await; let cases = [ SearchCase { path: "/users?ids=1500000000000000001", result_target: "users-results", mismatch_target: "guilds-results", result_text: "SearchedUser", }, SearchCase { path: "/guilds?ids=1600000000000000001", result_target: "guilds-results", mismatch_target: "users-results", result_text: "Searched Guild", }, SearchCase { path: "/applications?owner_id=1500000000000000001", result_target: "applications-results", mismatch_target: "users-results", result_text: "Mock Application", }, ]; for case in cases { let full = get(&app, case.path, &[]).await; assert_full_layout(&full); assert!( full.contains(case.result_text), "full response for {}", case.path ); let boosted = get( &app, case.path, &[ ("HX-Request", "true"), ("HX-Boosted", "true"), ("HX-Target", "body"), ], ) .await; assert_full_layout(&boosted); assert!( boosted.contains(case.result_text), "boosted response for {}", case.path ); let fragment = get( &app, case.path, &[("HX-Request", "true"), ("HX-Target", case.result_target)], ) .await; assert_fragment(&fragment); assert!( fragment.contains(case.result_text), "fragment response for {}", case.path ); let mismatch = get( &app, case.path, &[("HX-Request", "true"), ("HX-Target", case.mismatch_target)], ) .await; assert_full_layout(&mismatch); assert!( mismatch.contains(case.result_text), "mismatched target response for {}", case.path ); } } #[tokio::test] async fn detail_tab_routes_return_layout_or_fragments_by_route_shape() { let app = setup().await; let cases = [ TabCase { full_path: "/users/1500000000000000001?tab=applications", fragment_path: "/users/1500000000000000001/tabs/applications", detail_text: "SearchedUser", tab_text: "Mock Application", }, TabCase { full_path: "/guilds/1600000000000000001?tab=applications", fragment_path: "/guilds/1600000000000000001/tabs/applications", detail_text: "Searched Guild", tab_text: "Mock Application", }, ]; for case in cases { let full = get(&app, case.full_path, &[]).await; assert_full_layout(&full); assert!( full.contains(case.detail_text), "full tab URL for {}", case.full_path ); assert!( full.contains(case.tab_text), "full tab URL for {}", case.full_path ); let fragment = get(&app, case.fragment_path, &[]).await; assert_fragment(&fragment); assert!( fragment.contains(case.tab_text), "tab endpoint fragment for {}", case.fragment_path ); } } #[tokio::test] async fn target_audit_log_tabs_request_write_entries_only() { let app = setup().await; for path in [ "/users/1500000000000000001/tabs/audit_logs", "/guilds/1600000000000000001/tabs/audit_logs", ] { let fragment = get(&app, path, &[]).await; assert!(fragment.contains("Temp ban"), "{path}\n{fragment}"); assert!(!fragment.contains("Get user"), "{path}\n{fragment}"); } } #[tokio::test] async fn audit_log_page_forwards_the_access_filter() { let app = setup().await; let all = get(&app, "/audit-logs", &[]).await; assert!(all.contains("Temp ban"), "{all}"); assert!(all.contains("Get user"), "{all}"); assert!( all.contains(r#""#), "{all}" ); let reads = get(&app, "/audit-logs?access=read", &[]).await; assert!(reads.contains("Get user"), "{reads}"); assert!(!reads.contains("Temp ban"), "{reads}"); assert!( reads.contains(r#""#), "{reads}" ); } #[tokio::test] async fn report_routes_keep_layout_and_fragment_contract() { let app = setup().await; let reports = get(&app, "/reports?q=mock", &[]).await; assert_full_layout(&reports); assert!(reports.contains("1800000000000000001"), "{reports}"); assert!( !reports.contains(r#"hx-post="/reports/bulk-resolve""#), "{reports}" ); assert!(!reports.contains(r#"name="report_ids[]""#), "{reports}"); let boosted = get( &app, "/reports?q=mock", &[ ("HX-Request", "true"), ("HX-Boosted", "true"), ("HX-Target", "body"), ], ) .await; assert_full_layout(&boosted); assert!(boosted.contains("1800000000000000001"), "{boosted}"); let detail = get(&app, "/reports/1800000000000000001", &[]).await; assert_full_layout(&detail); assert!(detail.contains("Mock report details"), "{detail}"); let fragment = get(&app, "/reports/1800000000000000001/fragment", &[]).await; assert_fragment(&fragment); assert!(fragment.contains("Mock report details"), "{fragment}"); let message_fragment = get(&app, "/reports/1800000000000000002/fragment", &[]).await; assert_fragment(&message_fragment); assert!( message_fragment.contains("Message Context"), "{message_fragment}" ); assert!( message_fragment.contains("Reported message in drawer"), "{message_fragment}" ); } #[tokio::test] async fn report_list_reason_select_is_rendered_and_wired() { let app = setup().await; let unfiltered = get(&app, "/reports", &[]).await; assert!( unfiltered.contains(r#"').unwrap_or(form.len())]; assert!( tag.contains(r##"data-admin-refresh-on-success="#report-detail""##), "{tag}" ); assert!(!tag.contains("data-admin-result-form"), "{tag}"); } let before_swap = page .split("htmx:beforeSwap") .nth(1) .and_then(|rest| rest.split("htmx:afterRequest").next()) .unwrap_or_else(|| panic!("no beforeSwap handler\n{page}")); let header = before_swap .find("parseAdminToastHeader(xhr)") .unwrap_or_else(|| panic!("beforeSwap ignores the toast header\n{before_swap}")); let body = before_swap .find("parseFlashResponse(") .unwrap_or_else(|| panic!("beforeSwap has no body fallback\n{before_swap}")); assert!(header < body, "{before_swap}"); assert!( before_swap.contains("refreshAfterSuccess("), "{before_swap}" ); assert!( page.contains("if (status >= 400) level = 'error';"), "{page}" ); } #[tokio::test] async fn mutating_admin_pages_render_usable_csrf_tokens() { let app = setup().await; let cases = [ ("/system-dms", &["/system-dms"][..]), ( "/messages", &[ "/messages?action=browse", "/messages?action=lookup", "/messages?action=lookup-by-attachment", "/messages?action=delete", ][..], ), ("/gift-codes", &["/gift-codes"][..]), ("/search-index", &["/search-index?action=reindex"][..]), ("/gateway", &["/gateway?action=reload_all"][..]), ( "/instance-config", &[ "/instance-config?action=update_gateway_rollout", "/instance-config?action=update_sso", "/instance-config?action=update_domain_migration", "/instance-config?action=update_experiment_delivery", ][..], ), ]; for (path, form_actions) in cases { let (headers, body) = get_with_headers(&app, path, &[]).await; let csrf_token = csrf_cookie(&headers) .unwrap_or_else(|| panic!("{path}: response did not set csrf_token cookie\n{body}")); for form_action in form_actions { assert_form_has_csrf(&body, form_action, &csrf_token); } } } #[tokio::test] async fn instance_config_has_no_threads_or_plutonium_page_rollout_sections() { let app = setup().await; let body = get(&app, "/instance-config", &[]).await; assert_full_layout(&body); assert!(!body.contains("Threads and forums"), "{body}"); assert!(!body.contains("update_channel_threads"), "{body}"); assert!(!body.contains("update_plutonium_page"), "{body}"); } #[tokio::test] async fn instance_config_registration_tables_show_copyable_urls_and_compact_pending_actions() { let app = setup().await; let body = get(&app, "/instance-config", &[]).await; assert_full_layout(&body); assert!(body.contains(r#"id="registration-url-list""#), "{body}"); assert!( body.contains( r#"value="https://app.example.test/register?registration_url=11111111-1111-4111-8111-111111111111""# ), "{body}" ); assert!( body.contains( r#"data-copy-value="https://app.example.test/register?registration_url=11111111-1111-4111-8111-111111111111""# ), "{body}" ); assert!(body.contains(r#"id="pending-registration-list""#), "{body}"); assert!(body.contains(">Applicant<"), "{body}"); assert!(!body.contains(">User ID<"), "{body}"); assert!(!body.contains(">Link ID<"), "{body}"); assert!( body.contains(r##"hx-target="#pending-registration-list""##), "{body}" ); assert!(body.contains(r#"hx-swap="outerHTML""#), "{body}"); } #[tokio::test] async fn pending_registration_actions_swap_pending_list_fragment() { let app = setup().await; let (headers, body) = get_with_headers(&app, "/instance-config", &[]).await; let csrf_token = csrf_cookie(&headers) .unwrap_or_else(|| panic!("instance config did not set csrf_token cookie\n{body}")); let (status, response_headers, response_body) = post_form_with_headers( &app, "/instance-config?action=approve_pending_registration", &[ ("HX-Request", "true"), ("HX-Target", "pending-registration-list"), ( "Cookie", &format!("{}; csrf_token={}", app.session_cookie, csrf_token), ), ], &format!("_csrf={csrf_token}&user_id=1500000000000000002"), ) .await; assert_eq!(status, StatusCode::OK, "{response_body}"); assert_fragment(&response_body); assert!(response_body.contains(r#"id="pending-registration-list""#)); assert!(response_body.contains("No pending registrations.")); assert!(!response_body.contains("PendingUser"), "{response_body}"); let toast = response_headers .get("X-Fluxer-Admin-Toast") .and_then(|value| value.to_str().ok()) .unwrap_or_else(|| panic!("missing toast header\n{response_body}")); assert!(toast.contains("success"), "{toast}"); assert!(toast.contains("Registration approved"), "{toast}"); } #[tokio::test] async fn creating_registration_url_swaps_copyable_url_list_fragment() { let app = setup().await; let (headers, body) = get_with_headers(&app, "/instance-config", &[]).await; let csrf_token = csrf_cookie(&headers) .unwrap_or_else(|| panic!("instance config did not set csrf_token cookie\n{body}")); let (status, response_headers, response_body) = post_form_with_headers( &app, "/instance-config?action=create_registration_url", &[ ("HX-Request", "true"), ("HX-Target", "registration-url-list"), ( "Cookie", &format!("{}; csrf_token={}", app.session_cookie, csrf_token), ), ], &format!("_csrf={csrf_token}®istration_url_label=Support®istration_url_max_uses=1"), ) .await; assert_eq!(status, StatusCode::OK, "{response_body}"); assert_fragment(&response_body); assert!(response_body.contains(r#"id="registration-url-list""#)); assert!( response_body.contains( "https://app.example.test/register?registration_url=11111111-1111-4111-8111-111111111111" ), "{response_body}" ); let toast = response_headers .get("X-Fluxer-Admin-Toast") .and_then(|value| value.to_str().ok()) .unwrap_or_else(|| panic!("missing toast header\n{response_body}")); assert!(toast.contains("success"), "{toast}"); assert!(toast.contains("Registration URL created"), "{toast}"); } #[tokio::test] async fn fonts_are_served_locally_content_hashed_and_immutable() { let app = setup().await; let stylesheet_path = format!( "/static/fonts/{}", fluxer_admin::fonts::STYLESHEET_FILE_NAME ); let (headers, css) = get_with_headers(&app, &stylesheet_path, &[]).await; assert_eq!( headers.get(header::CACHE_CONTROL).unwrap(), "public, max-age=31536000, immutable" ); for fragment in css.split("url('").skip(1) { let file_name = fragment.split('\'').next().unwrap(); let response = app .router .clone() .oneshot( Request::builder() .uri(format!("/static/fonts/{file_name}")) .body(Body::empty()) .unwrap(), ) .await .unwrap(); assert_eq!( response.status(), StatusCode::OK, "missing font {file_name}" ); assert_eq!( response.headers().get(header::CONTENT_TYPE).unwrap(), "font/woff2" ); assert_eq!( response.headers().get(header::CACHE_CONTROL).unwrap(), "public, max-age=31536000, immutable" ); } let response = app .router .clone() .oneshot( Request::builder() .uri("/static/fonts/does-not-exist.woff2") .body(Body::empty()) .unwrap(), ) .await .unwrap(); assert_eq!(response.status(), StatusCode::NOT_FOUND); } #[tokio::test] async fn rendered_heads_never_reference_the_static_cdn_for_fonts() { let app = setup().await; let (headers, page) = get_with_headers(&app, "/users", &[]).await; assert!( !page.contains("/fonts/ibm-plex.css"), "the admin layout still links the CDN font stylesheets" ); assert!(page.contains("/static/fonts/"), "{page}"); let csp = headers .get(header::CONTENT_SECURITY_POLICY) .and_then(|value| value.to_str().ok()) .expect("missing CSP"); assert!(csp.contains("font-src 'self';"), "font-src was {csp}"); assert!( csp.contains("style-src 'self' 'unsafe-inline';"), "style-src was {csp}" ); } #[tokio::test] async fn the_csp_allows_images_from_the_configured_reports_bucket() { let app = setup().await; let (headers, _) = get_with_headers(&app, "/reports/1800000000000000001", &[]).await; let csp = headers .get(header::CONTENT_SECURITY_POLICY) .and_then(|value| value.to_str().ok()) .expect("missing CSP"); assert!( csp.contains( "img-src 'self' data: blob: https://static.example.test https://media.example.test \ https://reports.example.test;" ), "img-src was {csp}" ); assert!(!csp.contains("vultrobjects"), "{csp}"); } struct SearchCase { path: &'static str, result_target: &'static str, mismatch_target: &'static str, result_text: &'static str, } struct TabCase { full_path: &'static str, fragment_path: &'static str, detail_text: &'static str, tab_text: &'static str, } async fn setup() -> TestApp { setup_with_api(Router::new().fallback(mock_api)).await } async fn setup_with_api(api: Router) -> TestApp { let api_endpoint = spawn_mock_api(api).await; let router = build_router(test_config(api_endpoint)); let session_value = session::create_session("1500000000000000000", "test-token", SECRET_KEY); TestApp { router, session_cookie: format!("{}={session_value}", session::SESSION_COOKIE_NAME), } } async fn get(app: &TestApp, uri: &str, headers: &[(&str, &str)]) -> String { get_with_headers(app, uri, headers).await.1 } async fn get_status(app: &TestApp, uri: &str) -> StatusCode { let response = app .router .clone() .oneshot( Request::builder() .method(Method::GET) .uri(uri) .header(header::COOKIE, &app.session_cookie) .body(Body::empty()) .unwrap(), ) .await .unwrap(); response.status() } async fn get_with_headers( app: &TestApp, uri: &str, headers: &[(&str, &str)], ) -> (HeaderMap, String) { let mut builder = Request::builder() .method(Method::GET) .uri(uri) .header(header::COOKIE, &app.session_cookie); for (name, value) in headers { builder = builder.header(*name, *value); } let response = app .router .clone() .oneshot(builder.body(Body::empty()).unwrap()) .await .unwrap(); let status = response.status(); let headers = response.headers().clone(); let body = to_bytes(response.into_body(), usize::MAX).await.unwrap(); let text = String::from_utf8(body.to_vec()).unwrap(); assert_eq!(status, StatusCode::OK, "{text}"); (headers, text) } async fn post_form_with_headers( app: &TestApp, uri: &str, headers: &[(&str, &str)], body: &str, ) -> (StatusCode, HeaderMap, String) { let has_cookie_header = headers .iter() .any(|(name, _)| name.eq_ignore_ascii_case("cookie")); let mut builder = Request::builder() .method(Method::POST) .uri(uri) .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded"); if !has_cookie_header { builder = builder.header(header::COOKIE, &app.session_cookie); } for (name, value) in headers { builder = builder.header(*name, *value); } let response = app .router .clone() .oneshot(builder.body(Body::from(body.to_owned())).unwrap()) .await .unwrap(); let status = response.status(); let headers = response.headers().clone(); let body = to_bytes(response.into_body(), usize::MAX).await.unwrap(); let text = String::from_utf8(body.to_vec()).unwrap(); (status, headers, text) } fn csrf_cookie(headers: &HeaderMap) -> Option { headers .get_all(header::SET_COOKIE) .iter() .filter_map(|value| value.to_str().ok()) .find_map(|value| { let pair = value.split(';').next()?; let token = pair .strip_prefix("__Host-csrf_token=") .or_else(|| pair.strip_prefix("csrf_token="))?; (!token.is_empty()).then(|| token.to_owned()) }) } fn assert_form_has_csrf(body: &str, action: &str, csrf_token: &str) { let marker = format!(r#"action="{action}""#); let Some(action_index) = body.find(&marker) else { panic!("missing form action {action:?}\n{body}"); }; let csrf_marker = format!(r#"name="_csrf" value="{csrf_token}""#); let tail = &body[action_index..]; let form = tail.find("").map(|end| &tail[..end]).unwrap_or(tail); assert!( form.contains(&csrf_marker), "form {action:?} did not include matching CSRF token {csrf_token:?}\n{body}" ); } fn assert_full_layout(body: &str) { assert!(body.contains(r#"id="admin-sidebar""#), "{body}"); assert!(body.contains(r#"id="main-content""#), "{body}"); } fn assert_fragment(body: &str) { assert!(!body.contains(r#"id="admin-sidebar""#), "{body}"); assert!(!body.contains(r#"id="main-content""#), "{body}"); } fn select_markup<'a>(body: &'a str, name: &str) -> &'a str { let start = body .find(&format!(r#"