//! End-to-end tests for the TOTP auth gate. //! //! These drive the real router over HTTP (through `tower`'s `oneshot`) rather //! than calling handlers directly, because the thing worth protecting is the //! gate itself: an unauthenticated caller must never observe note content. use axum::body::Body; use axum::http::{header, Request, StatusCode}; use eternalibre_notes::auth::{AuthError, AuthState}; use eternalibre_notes::routes::{self, AppState}; use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicU32, Ordering}; use std::sync::{Arc, OnceLock}; use tower::util::ServiceExt; /// One shared notes directory for the whole test binary. /// /// `ETERNALIBRE_NOTES_DIR` is process-global, so it must be set exactly once /// before any test can race to change it. fn shared_notes_dir() -> &'static Path { static DIR: OnceLock = OnceLock::new(); DIR.get_or_init(|| { let mut p = std::env::temp_dir(); p.push(format!("eternalibre-it-notes-{}", std::process::id())); let _ = std::fs::remove_dir_all(&p); std::fs::create_dir_all(&p).unwrap(); std::env::set_var("ETERNALIBRE_NOTES_DIR", &p); p }) } fn temp_secret_path(tag: &str) -> PathBuf { static COUNTER: AtomicU32 = AtomicU32::new(0); let n = COUNTER.fetch_add(1, Ordering::Relaxed); let mut p = std::env::temp_dir(); p.push(format!( "eternalibre-it-{}-{}-{}", tag, std::process::id(), n )); p.set_extension("secret"); let _ = std::fs::remove_file(&p); p } struct Fixture { app: axum::Router, auth: Arc, secret_file: PathBuf, token: Option, secret: String, base_path: String, } impl Fixture { /// An app whose secret file does not exist yet. /// /// TOTP enrolment happens in the terminal before the listener binds, so /// this state is only reachable in-process. The router still handles it, /// which is what lets these tests assert the defensive 503 path. fn fresh(tag: &str) -> Self { Self::build(tag, "") } /// An app mounted under a reverse-proxy subdirectory. fn with_base_path(tag: &str, base_path: &str) -> Self { Self::build(tag, base_path) } fn build(tag: &str, base_path: &str) -> Self { let secret_file = temp_secret_path(tag); let auth = Arc::new(AuthState::with_secret_path(&secret_file)); Self { // `routes::app`, not `routes::router`: the real binary mounts // static assets alongside the gated routes, and a test that skips // that assembly cannot see asset regressions. app: routes::app(AppState { auth: auth.clone(), base_path: base_path.into(), }), auth, secret_file, token: None, secret: String::new(), base_path: base_path.to_string(), } } /// Completes enrolment against the `AuthState` directly, which is what the /// terminal setup flow does before the server starts. fn provision(mut self) -> Self { let material = self.auth.begin_setup().unwrap(); self.secret = material.secret; let code = valid_code(&self.secret); self.token = Some(self.auth.verify_setup(&code).expect("provisioning")); self } /// An app that has completed setup, with a live session cookie. fn provisioned(tag: &str) -> Self { Self::fresh(tag).provision() } /// Logs in over HTTP, exercising the real `/login` route. /// /// A rejected code just means the 30s TOTP window rolled over between /// enrolment and this call, so retry against the live secret rather than /// failing on a timing coincidence. async fn login_over_http(self) -> Self { // Enrol first if the caller hasn't, so this can be chained onto a // bare fixture. let mut f = if self.secret.is_empty() { self.provision() } else { self }; for _ in 0..3 { let code = valid_code(&f.secret); let (status, _, cookie) = f.post_form("/login", &format!("code={code}")).await; if status == StatusCode::SEE_OTHER { f.token = Some(session_value(&cookie.expect("login sets a cookie"))); return f; } } panic!("could not log in over HTTP with a valid code"); } /// The URL the app is *expected* to emit for an app-absolute path. /// /// Requests themselves are always app-absolute: a reverse proxy strips /// the base prefix before forwarding, so the app never sees it. The base /// path only shows up in generated hrefs, form actions, and redirects. fn expect(&self, app_path: &str) -> String { format!("{}{}", self.base_path, app_path) } fn cookie_header(&self) -> String { format!("eternalibre_session={}", self.token.clone().unwrap()) } async fn get(&self, uri: &str) -> (StatusCode, String) { let res = self .app .clone() .oneshot(Request::builder().uri(uri).body(Body::empty()).unwrap()) .await .unwrap(); finish(res).await } /// GET carrying the session cookie. async fn get_authed(&self, uri: &str) -> (StatusCode, String) { finish(self.get_authed_response(uri, None).await).await } async fn get_authed_with_theme(&self, uri: &str, theme: &str) -> axum::response::Response { self.get_authed_response(uri, Some(theme)).await } async fn get_authed_response( &self, uri: &str, theme: Option<&str>, ) -> axum::response::Response { let mut cookie = self.cookie_header(); if let Some(theme) = theme { cookie.push_str(&format!("; theme={theme}")); } self.app .clone() .oneshot( Request::builder() .uri(uri) .header(header::COOKIE, cookie) .body(Body::empty()) .unwrap(), ) .await .unwrap() } /// POST as an anonymous visitor. async fn post_form(&self, uri: &str, body: &str) -> (StatusCode, String, Option) { let res = self .app .clone() .oneshot( Request::builder() .method("POST") .uri(uri) .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded") .body(Body::from(body.to_string())) .unwrap(), ) .await .unwrap(); respond(res).await } /// POST carrying the session cookie. async fn post_authed(&self, uri: &str, body: &str) -> (StatusCode, String, Option) { let res = self .app .clone() .oneshot( Request::builder() .method("POST") .uri(uri) .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded") .header(header::COOKIE, self.cookie_header()) .body(Body::from(body.to_string())) .unwrap(), ) .await .unwrap(); respond(res).await } /// Creates a note and returns its id. async fn create_note(&self) -> String { let (_, _, loc) = self.post_authed("/notes", "").await; loc.expect("create redirects to the new note") .rsplit('/') .next() .unwrap() .to_string() } /// GET with an arbitrary cookie string, for spoofed-credential checks. async fn get_with_cookie(&self, cookie: &str) -> (StatusCode, String) { let res = self .app .clone() .oneshot( Request::builder() .uri("/") .header(header::COOKIE, cookie) .body(Body::empty()) .unwrap(), ) .await .unwrap(); finish(res).await } } impl Drop for Fixture { fn drop(&mut self) { let _ = std::fs::remove_file(&self.secret_file); } } async fn finish(res: axum::response::Response) -> (StatusCode, String) { let status = res.status(); let loc = res .headers() .get(header::LOCATION) .and_then(|v| v.to_str().ok()) .map(|s| format!("LOCATION: {s}")); let bytes = axum::body::to_bytes(res.into_body(), 1 << 20) .await .unwrap(); let mut text = String::from_utf8_lossy(&bytes).into_owned(); if let Some(l) = loc { text.push('\n'); text.push_str(&l); } (status, text) } async fn respond(res: axum::response::Response) -> (StatusCode, String, Option) { let status = res.status(); let cookie = res .headers() .get(header::SET_COOKIE) .and_then(|v| v.to_str().ok()) .map(|s| s.to_string()); let loc = res .headers() .get(header::LOCATION) .and_then(|v| v.to_str().ok()) .map(|s| s.to_string()); let bytes = axum::body::to_bytes(res.into_body(), 1 << 20) .await .unwrap(); let text = String::from_utf8_lossy(&bytes).into_owned(); (status, text, cookie.or(loc)) } fn session_value(set_cookie: &str) -> String { set_cookie .split(';') .next() .expect("cookie pair") .split_once('=') .expect("key=value") .1 .to_string() } /// What a real authenticator app would display for this secret right now. fn current_code(encoded_secret: &str) -> String { use totp_rs::{Algorithm, Builder, Secret}; Builder::new() .with_algorithm(Algorithm::SHA1) .with_digits(6) .with_skew(1) .with_step_duration(30) .with_account_name("local") .with_issuer(Some("EternaLibre Notes")) .with_secret(Secret::try_from_base32(encoded_secret).unwrap()) .build() .unwrap() .generate_current() .to_string() } /// Rejects a secret that is not a usable Base32 TOTP key, with a readable /// message. A silently empty code would otherwise look like a TOTP mismatch. fn valid_code(secret: &str) -> String { assert!( secret.len() >= 16 && !secret.is_empty(), "test fixture has an unusable secret: {secret:?}" ); let code = current_code(secret); assert_eq!( code.len(), 6, "expected a 6-digit code, got {code:?} from a bad secret" ); code } // ---------------------------------------------------------------- gating ---- /// Every data-bearing route must refuse an anonymous caller. This is the core /// security assertion: note content must not leak to an unauthenticated peer. #[tokio::test] async fn anonymous_cannot_read_any_note_route() { shared_notes_dir(); let f = Fixture::provisioned("anon"); for uri in ["/", "/notes/some-id", "/about", "/export", "/preview"] { let (status, body) = f.get(uri).await; assert!( status == StatusCode::SEE_OTHER || status == StatusCode::UNAUTHORIZED, "{uri} returned {status}, expected a redirect or 401" ); assert!( !body.contains("app-body"), "{uri} leaked the app shell to an anonymous caller" ); } } #[tokio::test] async fn anonymous_always_redirects_to_login() { let f = Fixture::provisioned("anon-login"); let (status, body) = f.get("/").await; assert_eq!(status, StatusCode::SEE_OTHER); assert!(body.contains("LOCATION: /login"), "got: {body}"); } #[tokio::test] async fn forged_session_cookie_is_rejected() { let f = Fixture::provisioned("forged"); let res = f .app .clone() .oneshot( Request::builder() .uri("/") .header(header::COOKIE, "eternalibre_session=0000000000000000") .body(Body::empty()) .unwrap(), ) .await .unwrap(); assert_eq!(res.status(), StatusCode::SEE_OTHER); assert_eq!(f.auth.session_count(), 1, "forged token created no session"); } #[tokio::test] async fn empty_session_cookie_is_rejected() { let f = Fixture::provisioned("emptycookie"); let (status, _) = f.get_with_cookie("eternalibre_session=").await; assert_eq!(status, StatusCode::SEE_OTHER); } // ------------------------------------------------------------ access with ---- #[tokio::test] async fn authenticated_user_can_reach_the_app() { let f = Fixture::provisioned("ok"); let (status, body) = f.get_authed("/").await; assert_eq!(status, StatusCode::OK); assert!(body.contains("app-body"), "expected the app shell"); assert!(body.contains("EternaLibre Notes")); } #[tokio::test] async fn note_content_is_invisible_until_authenticated() { let f = Fixture::provisioned("leak"); let id = f.create_note().await; let marker = "TOPSECRET-CANARY-9f2a"; let (status, _, _) = f .post_authed( &format!("/notes/{id}"), &format!("_method=put&title=Canary&content={marker}"), ) .await; assert_eq!(status, StatusCode::SEE_OTHER); // The authenticated user does see it, proving the marker exists at all. let (status, authed) = f.get_authed(&format!("/notes/{id}")).await; assert_eq!(status, StatusCode::OK); assert!(authed.contains(marker), "test is not exercising real data"); // An anonymous request for the same note must not echo it back. let (status, anon) = f.get(&format!("/notes/{id}")).await; assert_eq!(status, StatusCode::SEE_OTHER); assert!( !anon.contains(marker), "note content leaked to an unauthenticated request" ); assert!(!anon.contains("Canary"), "note title leaked"); // Nor does the index page list it. let (_, index) = f.get("/").await; assert!(!index.contains("Canary"), "note title leaked via index"); } #[tokio::test] async fn preview_requires_authentication() { let f = Fixture::provisioned("preview-gate"); let (status, body, _) = f.post_form("/preview", "content=%23+Hello").await; assert_eq!(status, StatusCode::UNAUTHORIZED); assert!( !body.contains("

"), "markdown was rendered for an anonymous caller" ); } #[tokio::test] async fn preview_works_once_authenticated() { let f = Fixture::provisioned("preview-ok"); let (status, body, _) = f.post_authed("/preview", "content=%23+Hello").await; assert_eq!(status, StatusCode::OK); assert!(body.contains("

Hello

"), "got: {body}"); } #[tokio::test] async fn export_requires_authentication() { let f = Fixture::provisioned("export-gate"); let (status, body) = f.get("/export").await; assert_eq!(status, StatusCode::SEE_OTHER); assert!(!body.contains("EternaLibre Notes Export")); } #[tokio::test] async fn single_note_export_menu_is_disabled_without_a_selected_note() { let f = Fixture::provisioned("note-export-menu"); let (status, body) = f.get_authed("/").await; assert_eq!(status, StatusCode::OK); assert!(body.contains("aria-disabled=\"true\"")); assert!(!body.contains("/export/pdf")); assert!(!body.contains("/export/markdown")); assert!(!body.contains("/export/html")); let id = f.create_note().await; let (status, selected) = f.get_authed(&format!("/notes/{id}")).await; assert_eq!(status, StatusCode::OK); assert!(selected.contains("Print/PDF")); assert!(selected.contains("menu-submenu-list")); for format in ["pdf", "markdown", "html"] { assert!( selected.contains(&format!("href=\"/notes/{id}/export/{format}\"")), "selected note should expose the {format} export" ); } } #[tokio::test] async fn single_note_exports_require_authentication() { let f = Fixture::provisioned("note-export-gate"); let id = f.create_note().await; for format in ["pdf", "markdown", "html"] { let (status, body) = f.get(&format!("/notes/{id}/export/{format}")).await; assert_eq!(status, StatusCode::SEE_OTHER); assert!(!body.contains("Export Secret")); } } #[tokio::test] async fn markdown_export_downloads_the_saved_note() { let f = Fixture::provisioned("markdown-export"); let id = f.create_note().await; let (status, _, _) = f .post_authed( &format!("/notes/{id}"), "_method=put&title=Saved+Version&content=First+paragraph", ) .await; assert_eq!(status, StatusCode::SEE_OTHER); let response = f .get_authed_response(&format!("/notes/{id}/export/markdown"), None) .await; assert_eq!(response.status(), StatusCode::OK); assert_eq!( response.headers()[header::CONTENT_TYPE], "text/markdown; charset=utf-8" ); assert_eq!( response.headers()[header::CONTENT_DISPOSITION], "attachment; filename=\"saved-version.md\"" ); assert_eq!( response.headers()[header::CACHE_CONTROL], "private, no-store" ); let body = axum::body::to_bytes(response.into_body(), 1 << 20) .await .unwrap(); assert_eq!( String::from_utf8_lossy(&body), "# Saved Version\n\nFirst paragraph" ); } #[tokio::test] async fn html_export_is_standalone_and_uses_the_selected_theme() { let f = Fixture::provisioned("html-export"); let id = f.create_note().await; let (status, _, _) = f .post_authed( &format!("/notes/{id}"), "_method=put&title=HTML+Note&content=%23+Heading%0A%0ARendered+body", ) .await; assert_eq!(status, StatusCode::SEE_OTHER); let response = f .get_authed_with_theme(&format!("/notes/{id}/export/html"), "Dracula") .await; assert_eq!(response.status(), StatusCode::OK); assert_eq!( response.headers()[header::CONTENT_DISPOSITION], "attachment; filename=\"html-note.html\"" ); let body = axum::body::to_bytes(response.into_body(), 1 << 20) .await .unwrap(); let body = String::from_utf8_lossy(&body); assert!(body.contains("data-theme=\"Dracula\"")); assert!(body.contains("--app-bg: #282a36;")); assert!(body.contains("

HTML Note

")); assert!(body.contains("

Heading

")); assert!(body.contains("

Rendered body

")); assert!( body.contains(".markdown-body pre"), "application styles are inlined" ); } #[tokio::test] async fn pdf_export_opens_a_theme_aware_print_document() { let f = Fixture::provisioned("pdf-export"); let id = f.create_note().await; let (status, _, _) = f .post_authed( &format!("/notes/{id}"), "_method=put&title=Print+Note&content=Printed+body", ) .await; assert_eq!(status, StatusCode::SEE_OTHER); let response = f .get_authed_with_theme(&format!("/notes/{id}/export/pdf"), "Breeze Dark") .await; assert_eq!(response.status(), StatusCode::OK); assert_eq!( response.headers()[header::CONTENT_TYPE], "text/html; charset=utf-8" ); assert_eq!( response.headers()[header::CONTENT_DISPOSITION], "inline; filename=\"print-note.html\"" ); let body = axum::body::to_bytes(response.into_body(), 1 << 20) .await .unwrap(); let body = String::from_utf8_lossy(&body); assert!(body.contains("data-theme=\"Breeze Dark\"")); assert!(body.contains("--app-bg: #232629;")); assert!(body.contains("print-color-adjust: exact;")); assert!(body.contains("window.print()")); assert!(body.contains("Printed body")); } #[tokio::test] async fn exporting_a_missing_note_returns_not_found() { let f = Fixture::provisioned("missing-export"); for format in ["pdf", "markdown", "html"] { let (status, _) = f .get_authed(&format!("/notes/does-not-exist/export/{format}")) .await; assert_eq!(status, StatusCode::NOT_FOUND); } } #[tokio::test] async fn write_routes_require_authentication() { let f = Fixture::provisioned("write-gate"); // Creating a note anonymously must not create anything. let before = std::fs::read_dir(shared_notes_dir()).unwrap().count(); let (status, _, _) = f.post_form("/notes", "").await; assert_eq!(status, StatusCode::SEE_OTHER); let after = std::fs::read_dir(shared_notes_dir()).unwrap().count(); assert_eq!(before, after, "anonymous POST created a note"); // Theme changes are gated too. let (status, _, _) = f.post_form("/theme", "theme=Dracula").await; assert_eq!(status, StatusCode::SEE_OTHER); } #[tokio::test] async fn deleting_a_note_requires_authentication() { let f = Fixture::provisioned("delete-gate"); let id = f.create_note().await; let (status, _, _) = f.post_form(&format!("/notes/{id}"), "_method=delete").await; assert_eq!(status, StatusCode::SEE_OTHER); // Still present, i.e. the anonymous delete did not take effect. let (status, _) = f.get_authed(&format!("/notes/{id}")).await; assert_eq!(status, StatusCode::OK); } // ------------------------------------------------------------------ auth ---- /// There is deliberately no `/setup` route. Enrolment happens in the /// terminal before the listener binds, so an HTTP-reachable setup endpoint /// could be claimed by anyone who reaches the port first. #[tokio::test] async fn there_is_no_web_setup_route() { let f = Fixture::fresh("no-setup-route"); let (status, _) = f.get("/setup").await; assert_eq!( status, StatusCode::NOT_FOUND, "/setup must not exist over HTTP" ); } /// A wrong code during terminal enrolment must leave no trace: no secret file /// written and the device still unprovisioned. #[tokio::test] async fn wrong_enrolment_code_writes_nothing() { let f = Fixture::fresh("bad-enrol"); f.auth.begin_setup().unwrap(); assert_eq!(f.auth.verify_setup("000000"), Err(AuthError::InvalidCode)); assert!(f.auth.needs_setup()); assert!( !f.secret_file.exists(), "a failed enrolment must not write the secret" ); } #[tokio::test] async fn login_page_is_shown_after_provisioning() { let f = Fixture::provisioned("loginpage"); let (status, body) = f.get("/login").await; assert_eq!(status, StatusCode::OK); assert!(body.contains("Unlock")); assert!(!body.contains(" h1` and so needs /// no class, but the link does. #[tokio::test] async fn about_page_back_link_is_styled() { let f = Fixture::fresh("about-link").login_over_http().await; let (status, body) = f.get_authed("/about").await; assert_eq!(status, StatusCode::OK); assert!( body.contains(r#"class="back-link""#), "about page is missing the back-link class that style.css targets" ); assert!( body.contains("Back to notes"), "about page should still offer a way back" ); } /// The about page is a separate document from the app shell, so it inlines /// `style.css` itself. If that inlining were dropped, every mobile rule /// would be dead on this page specifically. #[tokio::test] async fn about_page_inlines_the_stylesheet() { let f = Fixture::fresh("about-css").login_over_http().await; let (status, body) = f.get_authed("/about").await; assert_eq!(status, StatusCode::OK); assert!( body.contains(".about-page main > h1"), "about page should inline the stylesheet that styles it" ); // Match the full selector, not just the class name. A substring check on // `.back-link` also matches `.back-link-foo`, which is how this test // passed the first time it was run against a deliberately broken sheet. assert!( body.contains(".about-page .back-link {"), "the inlined sheet should carry the back-link rules" ); } /// Every screen must declare a viewport, or mobile browsers fall back to a /// ~980px layout viewport and scale the whole page down. /// /// That silently disables every `@media (max-width: 600px)` rule in the /// stylesheet, so a screen can carry a full set of mobile rules and still /// render the desktop layout — which is exactly what happened to `/about`. /// The failure is invisible in a desktop browser and invisible to any test /// that only asserts the CSS was inlined, which is why it is pinned here /// per-screen rather than once. #[tokio::test] async fn every_screen_declares_a_viewport() { let f = Fixture::fresh("viewport").login_over_http().await; for path in ["/", "/about"] { let (status, body) = f.get_authed(path).await; assert_eq!(status, StatusCode::OK, "{path}"); assert!( body.contains(r#"name="viewport""#) && body.contains(r#"content="width=device-width, initial-scale=1"#), "{path} is missing a viewport meta tag; mobile will scale it down \ and none of the max-width media queries will match" ); } } /// `app.js` is inlined into the page, so it cannot call the server-side /// `base.url()` helper. Its one `fetch()` has to build the URL itself, which /// is what `window.__BASE_PATH__` is for. /// /// Without it, `fetch('/preview')` resolves against the origin root. Behind /// a reverse proxy mounted at a subdirectory that never reaches the app, and /// the proxy's own 404 body gets swapped into the preview pane. The symptom /// is a preview pane full of "404 Not Found" while typing. #[tokio::test] async fn the_base_path_is_handed_to_client_side_js() { // With a base path set, the browser must be told about it. let f = Fixture::with_base_path("base-js", "/forgejo") .login_over_http() .await; let (status, body) = f.get_authed("/").await; assert_eq!(status, StatusCode::OK); // Backtick template literal, matching the `__INITIAL_PREVIEW__` line // above it and escaped by the same `escape_js` helper. assert!( body.contains("window.__BASE_PATH__ = `/forgejo`;"), "client JS should be handed the base path" ); // Without one, the value must be empty rather than the string "undefined", // so the fetch URL stays byte-identical to the pre-fix behaviour. let plain = Fixture::fresh("base-js-none").login_over_http().await; let (status, body) = plain.get_authed("/").await; assert_eq!(status, StatusCode::OK); assert!( body.contains("window.__BASE_PATH__ = ``;"), "with no base path the client value should be empty" ); } /// The `fetch()` in `app.js` must actually use the injected value. This is /// the assertion that would have caught the original bug: `app.js` is /// inlined verbatim, so a regression there is invisible to every other test. #[tokio::test] async fn the_preview_fetch_uses_the_injected_base_path() { let f = Fixture::with_base_path("fetch-base", "/forgejo") .login_over_http() .await; let (status, body) = f.get_authed("/").await; assert_eq!(status, StatusCode::OK); assert!( body.contains("fetch(base + '/preview'"), "app.js should build the preview URL from __BASE_PATH__" ); assert!( !body.contains("fetch('/preview'"), "app.js must not hardcode an origin-absolute /preview" ); } /// The about page advertises a version, and it must be the one in /// `Cargo.toml`. /// /// The title bar used to carry a hand-written `"v0.1"` that had already /// drifted from the crate's `0.1.0`. Both now read `CARGO_PKG_VERSION`, so /// this pins that they agree with the manifest rather than with each other. #[tokio::test] async fn about_page_reports_the_manifest_version() { let f = Fixture::fresh("about-version").login_over_http().await; let (status, body) = f.get_authed("/about").await; assert_eq!(status, StatusCode::OK); let version = env!("CARGO_PKG_VERSION"); assert!( body.contains(&format!("Version {version}")), "about page should report version {version}" ); // The title bar must show the same string, not a separately maintained one. let (shell_status, shell) = f.get_authed("/").await; assert_eq!(shell_status, StatusCode::OK); assert!( shell.contains(&format!("EternaLibre Notes v{version}")), "title bar should show v{version} from the manifest" ); } /// The theme counts are derived from the palette, not typed in. If someone /// adds or removes a theme, this keeps the page honest without a manual edit. #[tokio::test] async fn about_page_theme_counts_come_from_the_palette() { use eternalibre_notes::themes::PREDEFINED_THEMES; let total = PREDEFINED_THEMES.len(); let dark = PREDEFINED_THEMES.iter().filter(|t| t.is_dark()).count(); let light = total - dark; let f = Fixture::fresh("about-themes").login_over_http().await; let (status, body) = f.get_authed("/about").await; assert_eq!(status, StatusCode::OK); assert!( body.contains(&format!( "{total} built-in themes ({dark} dark, {light} light)" )), "about page should report {total} themes split {dark} dark / {light} light" ); } /// Outbound links must carry `rel="noopener noreferrer"`. The app advertises /// that it never talks to a third party, and these are the only links that /// leave the machine, so they should not leak a referrer when followed. #[tokio::test] async fn about_page_external_links_are_hardened() { let f = Fixture::fresh("about-links").login_over_http().await; let (status, body) = f.get_authed("/about").await; assert_eq!(status, StatusCode::OK); for target in [ "https://github.com/MARKMENTAL", "https://mentalnet.xyz/forgejo-v2/", "https://github.com/MARKMENTAL/tuxdock", "https://github.com/MARKMENTAL/mentalnet-gnu-linux", ] { assert!(body.contains(target), "about page should link to {target}"); } // Check every external anchor, so a link added later without the // hardening attributes fails here instead of shipping quietly. let externals: Vec<&str> = body .match_indices("').expect("unterminated anchor")]; assert!( tag.contains(r#"rel="noopener noreferrer""#), "external link missing rel hardening: {tag}" ); } } // ------------------------------------------------------------- base path ---- /// Every generated URL must carry the base prefix, or a browser behind the /// proxy would resolve `/about` against the site root and 404. #[tokio::test] async fn base_path_prefixes_generated_links() { let f = Fixture::with_base_path("base-links", "/forgejo") .login_over_http() .await; let (status, body) = f.get_authed("/").await; assert_eq!(status, StatusCode::OK); // `/static/alpine.min.js` is the only externally-loaded asset; // `app.js` is inlined via `include_str!`, so it has no URL to rewrite. for target in [ "/about", "/export", "/notes", "/theme", "/static/alpine.min.js", ] { let expected = f.expect(target); assert!( body.contains(&format!("\"{expected}\"")) || body.contains(&format!("'{expected}'")), "expected {expected} in generated markup" ); } assert!( !body.contains("href=\"/about\""), "found a root-absolute link that would escape the prefix" ); assert!( !body.contains("src=\"/static/"), "found a root-absolute asset URL that would escape the prefix" ); } #[tokio::test] async fn base_path_prefixes_single_note_export_links() { let f = Fixture::with_base_path("base-note-export", "/forgejo") .login_over_http() .await; let id = f.create_note().await; let (status, body) = f.get_authed(&format!("/notes/{id}")).await; assert_eq!(status, StatusCode::OK); for format in ["pdf", "markdown", "html"] { assert!( body.contains(&format!("href=\"/forgejo/notes/{id}/export/{format}\"")), "{format} export link should include the base path" ); } } #[tokio::test] async fn base_path_prefixes_redirects() { let f = Fixture::with_base_path("base-redirect", "/forgejo") .login_over_http() .await; // Creating a note redirects to the new note's URL. let (_, _, loc) = f.post_authed("/notes", "").await; let loc = loc.expect("create redirects"); assert!( loc.starts_with("/forgejo/notes/"), "redirect escaped the base path: {loc}" ); // An unauthenticated request must bounce within the prefix too. let anon = Fixture::with_base_path("base-anon", "/forgejo"); let (status, body) = anon.get("/").await; assert_eq!(status, StatusCode::SEE_OTHER); assert!( body.contains("LOCATION: /forgejo/login"), "login redirect escaped the base path: {body}" ); } #[tokio::test] async fn base_path_scopes_the_session_cookie() { let f = Fixture::with_base_path("base-cookie", "/forgejo") .login_over_http() .await; // The fixture captured only the token value, so log in again to inspect // the raw Set-Cookie header. let code = valid_code(&f.secret); let (_, _, set_cookie) = f.post_form("/login", &format!("code={code}")).await; let raw = set_cookie.expect("login sets a cookie"); assert!( raw.contains("Path=/forgejo"), "cookie must be scoped to the prefix, got: {raw}" ); } #[tokio::test] async fn base_path_login_form_posts_to_the_prefix() { let f = Fixture::with_base_path("base-form", "/forgejo").provision(); let (status, body) = f.get("/login").await; assert_eq!(status, StatusCode::OK); assert!( body.contains(r#"action="/forgejo/login""#), "login form action escaped the prefix" ); } /// A reverse proxy strips the base path before forwarding, so the path the /// app actually receives for a static asset is app-absolute. /// /// This is the assertion that catches the bug where the static nest was /// registered at `{base}/static`: the asset 404'd, `x-data="app()"` never /// initialised, and every Alpine directive — including the File menu /// dropdowns — silently stopped working. /// /// It drives `routes::app`, the same assembly `main.rs` uses, rather than /// hand-rolling a router, so it fails if the real configuration regresses. #[tokio::test] async fn alpine_is_served_app_absolute_even_with_a_base_path() { use eternalibre_notes::routes::{self, AppState}; let secret_file = temp_secret_path("static-abs"); let auth = Arc::new(AuthState::with_secret_path(&secret_file)); let app = routes::app(AppState { auth, base_path: "/forgejo".into(), }); let (status, body) = fetch(&app, "/static/alpine.min.js").await; assert_eq!( status, StatusCode::OK, "Alpine must be served at the app-absolute path; a miss here leaves \ x-data=\"app()\" uninitialised and the menus dead" ); assert!( body.contains("Alpine"), "expected the Alpine bundle, got {} bytes", body.len() ); // The prefixed path is not a route on the app: the proxy is what strips // the prefix, so the app must not also accept it. assert_ne!( fetch(&app, "/forgejo/static/alpine.min.js").await.0, StatusCode::OK, "static should not be reachable at the prefixed path" ); let _ = std::fs::remove_file(&secret_file); } /// Alpine must be reachable *without* a session: it is what makes every /// Alpine directive work, and it sits outside the auth gate. #[tokio::test] async fn alpine_is_served_without_a_session() { let f = Fixture::fresh("static-anon"); let (status, body) = f.get("/static/alpine.min.js").await; assert_eq!(status, StatusCode::OK); assert!(body.contains("Alpine")); } /// The bytes are baked in at compile time, so the response can be cached /// indefinitely and is unaffected by the working directory. #[tokio::test] async fn alpine_response_is_immutably_cacheable() { let f = Fixture::fresh("static-cache"); let res = f .app .clone() .oneshot( Request::builder() .uri("/static/alpine.min.js") .body(Body::empty()) .unwrap(), ) .await .unwrap(); assert_eq!(res.status(), StatusCode::OK); let cache = res .headers() .get(header::CACHE_CONTROL) .and_then(|v| v.to_str().ok()) .unwrap_or_default(); assert!( cache.contains("immutable"), "expected an immutable cache header, got {cache:?}" ); let ctype = res .headers() .get(header::CONTENT_TYPE) .and_then(|v| v.to_str().ok()) .unwrap_or_default(); assert!( ctype.contains("javascript"), "expected a JavaScript content type, got {ctype:?}" ); } /// The app must not depend on a `static/` directory existing at runtime. /// This is the regression that bit a real deployment: the binary was moved /// without its `static/` folder and every Alpine directive went dead. #[tokio::test] async fn alpine_is_available_without_a_static_directory() { use eternalibre_notes::routes::{self, AppState}; let secret_file = temp_secret_path("static-nodir"); let auth = Arc::new(AuthState::with_secret_path(&secret_file)); let app = routes::app(AppState { auth, base_path: "".into(), }); // Nothing is read from disk at request time; the bundle is linked in. let (status, body) = fetch(&app, "/static/alpine.min.js").await; assert_eq!(status, StatusCode::OK); assert!( body.len() > 10_000, "bundle looks truncated: {} bytes", body.len() ); let _ = std::fs::remove_file(&secret_file); } /// GET a path and return its status and body. async fn fetch(app: &axum::Router, path: &str) -> (StatusCode, String) { let res = app .clone() .oneshot(Request::builder().uri(path).body(Body::empty()).unwrap()) .await .unwrap(); let status = res.status(); let bytes = axum::body::to_bytes(res.into_body(), 4 << 20) .await .unwrap(); (status, String::from_utf8_lossy(&bytes).into_owned()) }