//! 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); } // ---------------------------------------------------------------- folders ---- #[tokio::test] async fn folder_creation_creates_a_note_in_the_folder() { let f = Fixture::provisioned("folder-create"); let (status, _, loc) = f.post_authed("/folders", "name=Project+Notes").await; assert_eq!(status, StatusCode::SEE_OTHER); let loc = loc.expect("folder creation redirects"); assert!(loc.starts_with("/notes/"), "got: {loc}"); // The new note should appear in the folder view. let (status, body) = f.get_authed("/?folder=Project%20Notes").await; assert_eq!(status, StatusCode::OK); assert!( body.contains("Project Notes"), "folder nav should list the folder" ); assert!( body.contains("Untitled"), "the new note should be in the folder" ); } #[tokio::test] async fn folder_filtering_shows_only_folder_notes() { let f = Fixture::provisioned("folder-filter"); let root_id = f.create_note().await; let (_, _, loc) = f.post_authed("/folders", "name=Work").await; let work_loc = loc.expect("folder creation redirects"); let work_id = work_loc.rsplit('/').next().unwrap().to_string(); // Root view shows both. let (status, body) = f.get_authed("/").await; assert_eq!(status, StatusCode::OK); assert!( body.contains(&root_id), "root note should appear in All Notes" ); assert!( body.contains(&work_id), "folder note should appear in All Notes" ); // Folder view shows only the folder note. let (status, body) = f.get_authed("/?folder=Work").await; assert_eq!(status, StatusCode::OK); assert!( !body.contains(&root_id), "root note should not appear in folder view" ); assert!( body.contains(&work_id), "folder note should appear in folder view" ); } #[tokio::test] async fn new_note_in_folder_creates_with_prefix() { let f = Fixture::provisioned("folder-new-note"); let (status, _, loc) = f.post_authed("/notes", "folder=Personal").await; assert_eq!(status, StatusCode::SEE_OTHER); let loc = loc.expect("create redirects"); let id = loc.rsplit('/').next().unwrap().to_string(); // The file on disk should have the [Personal] prefix. let dir = shared_notes_dir(); let entries: Vec<_> = std::fs::read_dir(dir) .unwrap() .filter_map(|e| e.ok()) .map(|e| e.file_name().to_string_lossy().to_string()) .collect(); let found = entries .iter() .any(|name| name.starts_with("[Personal]") && name.contains(&id)); assert!( found, "expected a [Personal]-prefixed file for {id}, got: {entries:?}" ); } #[tokio::test] async fn folder_route_requires_authentication() { let f = Fixture::provisioned("folder-gate"); let (status, _, _) = f.post_form("/folders", "name=Secret").await; assert_eq!(status, StatusCode::SEE_OTHER); // No note should have been created. let (status, body) = f.get("/").await; assert_eq!(status, StatusCode::SEE_OTHER); assert!(!body.contains("Secret")); } #[tokio::test] async fn invalid_folder_name_is_rejected() { use percent_encoding::{utf8_percent_encode, NON_ALPHANUMERIC}; let f = Fixture::provisioned("folder-invalid"); for name in [ "", " ", "has]bracket", "has/slash", "has\\backslash", "..", ".hidden", "foo;bar", "foo|bar", "foo&bar", "foo$bar", "foo`bar", "foo(bar)", "foo", ] { let encoded = utf8_percent_encode(name, NON_ALPHANUMERIC).to_string(); let body = format!("name={encoded}"); let (status, _, loc) = f.post_authed("/folders", &body).await; // Should redirect back to / with an error, not to a new note. assert_eq!( status, StatusCode::SEE_OTHER, "folder name {name:?} should redirect back" ); let loc = loc.expect("should have a Location header"); assert!( loc.starts_with("/?error="), "expected redirect to /?error=..., got: {loc}" ); } } #[tokio::test] async fn folder_creation_error_shows_flash_message() { let f = Fixture::provisioned("folder-flash"); let (_, _, loc) = f.post_authed("/folders", "name=has%2Fslash").await; let loc = loc.expect("should redirect"); assert!(loc.starts_with("/?error="), "got: {loc}"); // Follow the redirect and verify the flash message appears. let (status, body) = f .get_authed("/?error=folder+name+cannot+contain+%27%2F%27+or+%27%5C%27") .await; assert_eq!(status, StatusCode::OK); assert!( body.contains("flash-message"), "page should contain the flash-message element" ); assert!( body.contains("folder name cannot contain"), "flash message should show the validation error" ); } // ------------------------------------------------------- moving a note ---- /// Every on-disk note file whose `[Folder]uuid` stem parses to `id`. fn files_for_id(id: &str) -> Vec { let Ok(entries) = std::fs::read_dir(shared_notes_dir()) else { return Vec::new(); }; let mut found: Vec = entries .filter_map(|e| e.ok()) .map(|e| e.file_name().to_string_lossy().to_string()) .filter(|name| { let stem = name.strip_suffix(".md").unwrap_or(name); let stripped = stem.strip_prefix('[').and_then(|s| s.split_once(']')); let parsed = stripped.map(|(_, rest)| rest).unwrap_or(stem); parsed == id }) .collect(); found.sort(); found } #[tokio::test] async fn move_route_renames_the_note_file() { let f = Fixture::provisioned("move-rename"); let id = f.create_note().await; f.post_authed(&format!("/notes/{id}"), "title=Movable&content=Body") .await; let (status, _, loc) = f .post_authed(&format!("/notes/{id}/move"), "folder=Work") .await; assert_eq!(status, StatusCode::SEE_OTHER); assert_eq!(loc.as_deref(), Some(format!("/notes/{id}").as_str())); // The file was renamed, not duplicated: the bare name is gone and exactly // one prefixed file claims the id. assert_eq!(files_for_id(&id), vec![format!("[Work]{id}.md")]); // Content survived the move. let (status, body) = f.get_authed(&format!("/notes/{id}")).await; assert_eq!(status, StatusCode::OK); assert!(body.contains("Movable"), "title should survive the move"); assert!(body.contains("Body"), "content should survive the move"); } #[tokio::test] async fn move_route_to_root_strips_the_prefix() { let f = Fixture::provisioned("move-root"); let (_, _, loc) = f.post_authed("/folders", "name=Work").await; let work_id = loc .expect("redirect") .rsplit('/') .next() .unwrap() .to_string(); assert_eq!(files_for_id(&work_id), vec![format!("[Work]{work_id}.md")]); // An empty folder is the root, which is how "All Notes" and the move // form's Unfiled option both express "no folder". let (status, _, _) = f .post_authed(&format!("/notes/{work_id}/move"), "folder=") .await; assert_eq!(status, StatusCode::SEE_OTHER); assert_eq!(files_for_id(&work_id), vec![format!("{work_id}.md")]); } /// The bug this whole feature hangs on. /// /// `save_note` derives the filename from `note.folder` but never removes the /// file it replaced, so a "move" implemented as a re-save leaves the original /// behind. `list_notes` then returns the same id twice and the sidebar renders /// the note twice, with the two copies ordered by whatever `updated_at` each /// file happened to carry. This pins the rename rather than the re-save. #[tokio::test] async fn move_route_leaves_no_duplicate_file_or_listing() { let f = Fixture::provisioned("move-nodup"); let id = f.create_note().await; f.post_authed(&format!("/notes/{id}/move"), "folder=Work") .await; assert_eq!( files_for_id(&id).len(), 1, "one file on disk should claim the id, got: {:?}", files_for_id(&id) ); f.post_authed(&format!("/notes/{id}/move"), "folder=Home") .await; assert_eq!( files_for_id(&id).len(), 1, "repeated moves must not accumulate files, got: {:?}", files_for_id(&id) ); let (_, body) = f.get_authed("/").await; assert_eq!( body.matches(&format!("data-note-id=\"{id}\"")).count(), 1, "the sidebar should list the note exactly once" ); // The folder listing shows one copy, the root listing none. let (_, home) = f.get_authed("/?folder=Home").await; assert_eq!(home.matches(&format!("data-note-id=\"{id}\"")).count(), 1); let (_, work) = f.get_authed("/?folder=Work").await; assert!( !work.contains(&id), "the note left Work, so it must not appear there" ); } #[tokio::test] async fn move_route_rejects_invalid_folder_names_without_touching_the_file() { let f = Fixture::provisioned("move-invalid"); let id = f.create_note().await; for (name, body) in [ ("has]bracket", "folder=has%5Dbracket"), ("has/slash", "folder=has%2Fslash"), ("path traversal", "folder=..%2F..%2Fetc"), ("empty after trim", "folder=%20%20"), ] { let (status, _, loc) = f.post_authed(&format!("/notes/{id}/move"), body).await; assert_eq!( status, StatusCode::SEE_OTHER, "{name} should redirect back with an error" ); assert!( loc.expect("redirect").starts_with("/?error="), "{name} should land on /?error=" ); assert_eq!( files_for_id(&id), vec![format!("{id}.md")], "{name} must leave the file where it was" ); } } #[tokio::test] async fn move_route_requires_authentication() { let f = Fixture::provisioned("move-gate"); let id = f.create_note().await; let (status, _, loc) = f .post_form(&format!("/notes/{id}/move"), "folder=Secret") .await; assert_eq!(status, StatusCode::SEE_OTHER); assert_eq!(loc.as_deref(), Some("/login")); assert_eq!( files_for_id(&id), vec![format!("{id}.md")], "an unauthenticated move must not reach the filesystem" ); } #[tokio::test] async fn move_route_redirects_through_the_base_path() { let f = Fixture::with_base_path("move-base", "/forgejo") .login_over_http() .await; let id = f.create_note().await; let (status, _, loc) = f .post_authed(&format!("/notes/{id}/move"), "folder=Work") .await; assert_eq!(status, StatusCode::SEE_OTHER); assert_eq!( loc.as_deref(), Some(format!("/forgejo/notes/{id}").as_str()) ); assert_eq!(files_for_id(&id), vec![format!("[Work]{id}.md")]); } /// The drag affordances are server-rendered, so the hooks are assertable even /// though the drag itself needs JavaScript. #[tokio::test] async fn sidebar_note_items_and_folder_items_carry_drag_hooks() { let f = Fixture::provisioned("drag-hooks"); let id = f.create_note().await; f.post_authed(&format!("/notes/{id}/move"), "folder=Work") .await; let (status, body) = f.get_authed("/").await; assert_eq!(status, StatusCode::OK); assert!( body.contains(&format!("data-note-id=\"{id}\"")), "note items must expose their id for the drag payload" ); assert!( body.contains("draggable=\"true\""), "note items must be draggable" ); // The anchor must opt out, or the browser starts a native link drag and // the folder never lights up as a drop target. assert!( body.contains("draggable=\"false\""), "the inner anchor must opt out of native link dragging" ); assert!( body.contains("data-drop-folder=\"Work\""), "each folder nav item must name its folder" ); assert!( body.contains("data-drop-folder=\"\""), "All Notes must be a drop target that means the root" ); } /// Desktop re-files through File > Move to Folder…. Each entry is its own form /// posting to `/notes/:id/move`, so a click is a real POST and no JavaScript /// performs the move. #[tokio::test] async fn the_move_menu_posts_the_move_route() { let f = Fixture::provisioned("move-menu"); f.post_authed("/folders", "name=Alpha").await; f.post_authed("/folders", "name=Beta").await; let id = f.create_note().await; let (status, body) = f.get_authed(&format!("/notes/{id}")).await; assert_eq!(status, StatusCode::OK); assert!( body.contains("Move to Folder"), "the File menu should offer a Move to Folder submenu" ); // Scope to the submenu before counting. `list_folders()` reads the shared // notes directory, which every test in this binary writes to, so the app's // folder list legitimately holds folders created by unrelated tests. A // whole-page count would therefore be asserting on global state. // // Anchored on the Alpine binding, not on the label text: `style.css` is // inlined into the page and its comments mention the menu by name, so a // text search finds the stylesheet first. let trigger = body .find("toggleSubmenu('noteMove'") .expect("the submenu trigger"); let submenu_end = body[trigger..] .find("") .map(|i| trigger + i) .expect("the submenu list should close"); let submenu = &body[trigger..submenu_end]; let action = format!("action=\"/notes/{id}/move\""); // One destination per folder in the sidebar, plus (Unfiled). Counted from // the sidebar rather than hardcoded, because the shared notes directory // means other tests' folders are legitimately present. The invariant worth // pinning is that the menu and the sidebar agree on which folders exist. let nav = folder_nav_region(&body); let nav_items = nav.matches("data-drop-folder=\"").count(); assert_eq!( submenu.matches(&action).count(), nav_items, "the submenu should offer (Unfiled) plus one entry per sidebar \ folder ({nav_items} destinations), got:\n{submenu}" ); // The two folders this test created must both be offered. assert!( submenu.contains("value=\"Alpha\""), "Alpha should be listed" ); assert!(submenu.contains("value=\"Beta\""), "Beta should be listed"); // Folder names run to 100 characters and the flyout is capped to the // viewport, so a long name wraps across lines. The untruncated name stays // reachable via the title attribute. assert!( submenu.contains("title=\"Alpha\""), "each folder button should carry its full name as a title" ); // Un-filing comes first, matching the sidebar's own ordering. let unfiled = submenu .find("value=\"\"") .expect("the submenu should offer (Unfiled)"); let alpha = submenu.find("value=\"Alpha\"").expect("Alpha"); assert!(unfiled < alpha, "(Unfiled) should be listed first"); // (Unfiled) posts an empty folder, which the route reads as the root. assert!( body.contains(""), "the submenu should offer a way to un-file a note" ); assert!( body.contains(""), "each folder should post its own name" ); // Disabled with no note open, matching Export Note As…. let (status, empty) = f.get_authed("/").await; assert_eq!(status, StatusCode::OK); assert!( empty.contains("Move to Folder"), "the trigger should still render without a note" ); } /// Mobile's re-filing control. It must stay in the markup at every breakpoint — /// CSS decides which entry point shows — and it must work without JavaScript, /// which is the whole reason it exists beside the menu. #[tokio::test] async fn the_move_bar_survives_for_mobile() { let f = Fixture::provisioned("move-bar"); f.post_authed("/folders", "name=Alpha").await; let id = f.create_note().await; let (status, body) = f.get_authed(&format!("/notes/{id}")).await; assert_eq!(status, StatusCode::OK); assert!( body.contains("class=\"move-folder-bar\""), "the mobile bar should be in the markup" ); assert!( body.contains(&format!("action=\"/notes/{id}/move\"")), "the bar should post to the move route" ); assert!( body.contains("name=\"folder\""), "the bar needs a folder field" ); // A select cannot submit a form by itself. If an auto-submit handler came // back, the button would look redundant and the control would silently stop // working with JS off — which is the only reason it is here. // // Both bounds are anchored on markup. `style.css` is inlined into the page, // so a search for `move-folder-bar` finds the stylesheet first and the // slice covers CSS, where both assertions pass without inspecting // anything. The bar holds exactly one form, so that form's close tag is a // reliable end bound covering both its attributes and its button. let bar_start = body .find("class=\"move-folder-bar\"") .expect("the bar markup"); let bar_end = body[bar_start..] .find("") .map(|i| bar_start + i + "".len()) .expect("the bar holds one form"); let bar = &body[bar_start..bar_end]; assert!( !bar.contains("x-on:change"), "the bar must not auto-submit on change; the button is the only \ submit path and the control has to work without JS" ); assert!( bar.contains("