Add test suite: 99 tests, 94.6% server line coverage

Splits the server binary into a lib + thin bin so the router can be
driven from tests via tower oneshot (no ports, fully parallel).

Unit tests (62):
- fs: path safety (resolve_root/path/file, .. and symlink escapes),
  name validation, mkdir/rename/remove/move/copy, save_file mtime
  conflicts, list ordering, broken symlinks
- archive: format parsing + zip/tar/tar.gz/tar.zst round-trips,
  deterministic walk order
- auth: argon2 round-trips, token shapes, session cookie format,
  cookie parsing
- db: fresh state, v1->v2 migration, user CRUD, sessions, shares +
  expiry, settings
- error: ApiError JSON body (+extra merge), io error -> 500
- cli: defaults, overrides, required args
- lib: build_app validation (missing root, file root, bad bind)

Integration tests (37) against the real router:
- auth: first-boot setup, validation, login, sessions, logout,
  disabling (incl. live-session invalidation)
- files: listing, traversal blocking, downloads (all 4 archive
  formats verified by re-reading the archive), preview/content,
  editor save + 409 conflicts + 2MiB cap, mkdir/rename/move/copy,
  delete, multipart upload (conflicts, overwrite, traversal parts),
  read-only roots, foreign-root access control
- shares: create/list/delete, writable gating, file+folder shares,
  anonymous ?share= access (list/nav/download/upload/mkdir),
  signed-in user scoping, expiry (410), target validation
- admin: user lifecycle, lockout guards, settings
- spa: asset serving, client-route fallback, API 404s, 405s

Fixes found by the tests:
- move/copy/rename onto themselves with overwrite=true deleted the
  source before renaming (now a no-op success)
- last-admin guard fired when disabling a non-admin (now only
  counts admins)

Adds just test / just cov recipes.

Co-Authored-By: Qwen3.8 27b
AuthorKonata <konata@posteo.jp>
Date
Commit8d282cf6615ff803b453ceb89e9e61ddcedf332d
Parent41678b2
18 files changed, 3220 insertions(+), 58 deletions(-)
▾MCargo.lock
@@ -733,6 +733,12 @@ version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a"
[[package]]
name = "fastrand"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223"
[[package]]
name = "filetime"
version = "0.2.29"
@@ -2204,10 +2210,12 @@ dependencies = [
"anyhow",
"argon2",
"axum",
"bytes",
"chrono",
"clap",
"flate2",
"futures-util",
"http-body-util",
"mime_guess",
"multer",
"rand",
@@ -2216,9 +2224,11 @@ dependencies = [
"serde",
"serde_json",
"tar",
"tempfile",
"thiserror 2.0.20",
"tokio",
"tokio-stream",
"tower",
"tower-http",
"tracing",
"tracing-subscriber",
@@ -2497,6 +2507,19 @@ dependencies = [
"xattr",
]
[[package]]
name = "tempfile"
version = "3.27.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
dependencies = [
"fastrand",
"getrandom 0.4.3",
"once_cell",
"rustix",
"windows-sys",
]
[[package]]
name = "thiserror"
version = "1.0.69"
▾Mjustfile
@@ -37,6 +37,14 @@ build-cm:
run: build
./target/release/filebrowser-ng --root {{dev-root}} --db {{dev-db}}
# Server test suite (unit + API integration tests).
test:
cargo test -p server
# Coverage report (requires `cargo install cargo-llvm-cov` + `rustup component add llvm-tools`).
cov:
cargo llvm-cov -p server
# Remove the dev database (factory reset of users/shares/settings).
reset-db:
rm -f {{dev-db}} {{dev-db}}-wal {{dev-db}}-shm
▾Mserver/Cargo.toml
@@ -31,6 +31,16 @@ tokio-stream = { version = "0.1", features = ["sync"] }
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
[dev-dependencies]
tempfile = "3"
tower = { version = "0.5", features = ["util"] }
http-body-util = "0.1"
bytes = "1"
flate2 = "1"
zstd = "0.13"
tar = "0.4"
zip = "9.0.0-pre3"
[dependencies.rust-embed]
version = "8"
optional = true
▾Mserver/src/api/admin.rs
@@ -227,7 +227,10 @@ pub async fn update_user(
let demoting = id != admin.user.id
&& body.is_admin == Some(false)
&& target.is_admin;
let disabling = id != admin.user.id && body.active == Some(false) && target.active;
let disabling = id != admin.user.id
&& body.active == Some(false)
&& target.active
&& target.is_admin;
if (demoting || disabling) && state.db.count_admins().await <= 1 {
return Err(ApiError::new(
StatusCode::BAD_REQUEST,
▾Mserver/src/archive.rs
@@ -200,3 +200,193 @@ fn build_zip<W: Write>(dir: &Path, top: &str, sink: W) -> io::Result<()> {
zip.finish()?;
Ok(())
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
use std::collections::BTreeMap;
use std::io::Read as _;
fn sample_dir() -> (tempfile::TempDir, PathBuf) {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path().to_path_buf();
std::fs::write(dir.join("alpha.txt"), "alpha content").unwrap();
std::fs::create_dir_all(dir.join("sub/deep")).unwrap();
std::fs::create_dir(dir.join("empty-dir")).unwrap();
std::fs::write(dir.join("sub/beta.txt"), "beta").unwrap();
std::fs::write(dir.join("sub/deep/gamma.bin"), (0u8..=255).collect::<Vec<_>>()).unwrap();
(tmp, dir)
}
fn build_to_mem(fmt: ArchiveFormat, dir: &Path) -> Vec<u8> {
let mut out: Vec<u8> = Vec::new();
build(fmt, dir, "top", &mut out).unwrap();
out
}
#[test]
fn format_parsing() {
assert_eq!(ArchiveFormat::parse("zip"), Some(ArchiveFormat::Zip));
assert_eq!(ArchiveFormat::parse("tar"), Some(ArchiveFormat::Tar));
assert_eq!(ArchiveFormat::parse("tar.gz"), Some(ArchiveFormat::TarGz));
assert_eq!(ArchiveFormat::parse("tgz"), Some(ArchiveFormat::TarGz));
assert_eq!(ArchiveFormat::parse("tar.zst"), Some(ArchiveFormat::TarZst));
assert_eq!(ArchiveFormat::parse("tzst"), Some(ArchiveFormat::TarZst));
for bad in ["", "ZIP", "gzip", "rar", "7z", "tar.bz2", "tar.xz", "tar.zstx", "tar.gz "] {
assert_eq!(ArchiveFormat::parse(bad), None, "{bad:?}");
}
}
#[test]
fn format_metadata() {
assert_eq!(ArchiveFormat::Zip.extension(), "zip");
assert_eq!(ArchiveFormat::Zip.mime(), "application/zip");
assert_eq!(ArchiveFormat::Tar.extension(), "tar");
assert_eq!(ArchiveFormat::Tar.mime(), "application/x-tar");
assert_eq!(ArchiveFormat::TarGz.extension(), "tar.gz");
assert_eq!(ArchiveFormat::TarGz.mime(), "application/gzip");
assert_eq!(ArchiveFormat::TarZst.extension(), "tar.zst");
assert_eq!(ArchiveFormat::TarZst.mime(), "application/zstd");
}
/// Read a tar stream into a name → content map (files only).
fn tar_map<R: std::io::Read>(r: R) -> BTreeMap<String, Vec<u8>> {
let mut map = BTreeMap::new();
for entry in tar::Archive::new(r).entries().unwrap() {
let mut e = entry.unwrap();
if !e.header().entry_type().is_file() {
continue;
}
let name = e.path().unwrap().to_string_lossy().into_owned();
let mut buf = Vec::new();
e.read_to_end(&mut buf).unwrap();
map.insert(name, buf);
}
map
}
fn expected_map() -> BTreeMap<String, Vec<u8>> {
let mut m = BTreeMap::new();
m.insert("top/alpha.txt".to_string(), b"alpha content".to_vec());
m.insert("top/sub/beta.txt".to_string(), b"beta".to_vec());
m.insert(
"top/sub/deep/gamma.bin".to_string(),
(0u8..=255).collect(),
);
m
}
#[test]
fn zip_round_trip() {
let (_tmp, dir) = sample_dir();
let bytes = build_to_mem(ArchiveFormat::Zip, &dir);
let mut zip = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap();
let mut map = BTreeMap::new();
for i in 0..zip.len() {
let mut f = zip.by_index(i).unwrap();
let name = f.name().unwrap().to_string();
if name.ends_with('/') {
continue; // directory entry
}
let mut buf = Vec::new();
f.read_to_end(&mut buf).unwrap();
map.insert(name, buf);
}
assert_eq!(map, expected_map());
// Directory entries are present and the order is deterministic.
let names: Vec<String> = zip
.file_names()
.map(|n| n.unwrap().to_string())
.collect();
let has = |n: &str| names.iter().any(|x| x == n);
assert!(has("top/"));
assert!(has("top/sub/"));
assert!(has("top/sub/deep/"));
assert!(has("top/empty-dir/"));
let mut sorted = names.clone();
sorted.sort_unstable();
assert_eq!(names, sorted);
}
#[test]
fn tar_round_trip() {
let (_tmp, dir) = sample_dir();
let bytes = build_to_mem(ArchiveFormat::Tar, &dir);
let map = tar_map(std::io::Cursor::new(bytes));
assert_eq!(map, expected_map());
}
#[test]
fn tar_gz_round_trip() {
let (_tmp, dir) = sample_dir();
let bytes = build_to_mem(ArchiveFormat::TarGz, &dir);
let gz = flate2::read::GzDecoder::new(std::io::Cursor::new(bytes));
let map = tar_map(gz);
assert_eq!(map, expected_map());
}
#[test]
fn tar_zst_round_trip() {
let (_tmp, dir) = sample_dir();
let bytes = build_to_mem(ArchiveFormat::TarZst, &dir);
let dec = zstd::stream::read::Decoder::new(std::io::Cursor::new(bytes)).unwrap();
let map = tar_map(dec);
assert_eq!(map, expected_map());
}
#[test]
fn walk_is_sorted_case_insensitively() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path();
for name in ["Zeta", "alpha", "Beta", "a.txt", "B.txt"] {
if name.ends_with(".txt") {
std::fs::write(dir.join(name), name).unwrap();
} else {
std::fs::create_dir(dir.join(name)).unwrap();
}
}
let mut order = Vec::new();
walk(dir, "top", &mut |name, _p, _is_dir| {
order.push(name.to_string());
Ok(())
})
.unwrap();
assert_eq!(
order,
vec![
"top",
"top/a.txt",
"top/alpha",
"top/B.txt",
"top/Beta",
"top/Zeta"
]
);
}
#[test]
fn build_fails_on_missing_dir() {
let mut out = Vec::new();
let r = build(
ArchiveFormat::Zip,
Path::new("/nonexistent-filebrowser-ng-test-dir"),
"top",
&mut out,
);
assert!(r.is_err());
// The tar path fails too.
let mut out = Vec::new();
let r = build(
ArchiveFormat::Tar,
Path::new("/nonexistent-filebrowser-ng-test-dir"),
"top",
&mut out,
);
assert!(r.is_err());
}
}
▾Mserver/src/auth.rs
@@ -72,3 +72,96 @@ pub fn parse_session_cookie(headers: &axum::http::HeaderMap) -> Option<String> {
}
None
}
#[cfg(test)]
mod tests {
use super::*;
use axum::http::{header, HeaderMap};
#[test]
fn password_hash_round_trip() {
let h = hash_password("hunter22").unwrap();
assert!(verify_password("hunter22", &h));
assert!(!verify_password("wrong-password", &h));
assert!(!verify_password("hunter23", &h));
// Fresh salt on every hash.
assert_ne!(h, hash_password("hunter22").unwrap());
// Argon2id marker is present.
assert!(h.starts_with("$argon2id$"));
}
#[test]
fn verify_rejects_garbage_hashes() {
assert!(!verify_password("x", ""));
assert!(!verify_password("x", "not-a-hash"));
assert!(!verify_password("x", "$argon2id$"));
}
#[test]
fn token_shapes_and_uniqueness() {
let t = random_token();
assert_eq!(t.len(), 64);
assert!(t.chars().all(|c| c.is_ascii_hexdigit()));
let s = share_token();
assert_eq!(s.len(), 32);
assert!(s.chars().all(|c| c.is_ascii_hexdigit()));
let mut seen = std::collections::HashSet::new();
for _ in 0..100 {
assert!(seen.insert(random_token()), "session token collision");
assert!(seen.insert(share_token()), "share token collision");
}
}
#[test]
fn session_cookie_shape() {
let c = session_cookie("tok123", false);
assert!(c.starts_with("fbng_session=tok123;"));
assert!(c.contains("Path=/"));
assert!(c.contains("HttpOnly"));
assert!(c.contains("SameSite=Lax"));
assert!(c.contains(&format!("Max-Age={SESSION_MAX_AGE}")));
assert!(!c.contains("Secure"));
let c = session_cookie("tok123", true);
assert!(c.ends_with("; Secure"));
let c = clear_session_cookie(true);
assert!(c.starts_with("fbng_session=;"));
assert!(c.contains("Max-Age=0"));
assert!(c.contains("Secure"));
assert!(!clear_session_cookie(false).contains("Secure"));
}
#[test]
fn parse_session_cookie_variants() {
let mut h = HeaderMap::new();
h.insert(header::COOKIE, "other=1; fbng_session=abc123; x=y".parse().unwrap());
assert_eq!(parse_session_cookie(&h).as_deref(), Some("abc123"));
let mut h = HeaderMap::new();
h.insert(header::COOKIE, "other=1".parse().unwrap());
assert_eq!(parse_session_cookie(&h), None);
// Empty value → treated as absent.
let mut h = HeaderMap::new();
h.insert(header::COOKIE, "fbng_session=".parse().unwrap());
assert_eq!(parse_session_cookie(&h), None);
assert_eq!(parse_session_cookie(&HeaderMap::new()), None);
// First occurrence wins.
let mut h = HeaderMap::new();
h.insert(
header::COOKIE,
"fbng_session=first; fbng_session=second".parse().unwrap(),
);
assert_eq!(parse_session_cookie(&h).as_deref(), Some("first"));
// Cookie name must match exactly.
let mut h = HeaderMap::new();
h.insert(header::COOKIE, "fbng_session2=x; Xfbng_session=y".parse().unwrap());
assert_eq!(parse_session_cookie(&h), None);
}
}
▾Mserver/src/cli.rs
@@ -31,3 +31,46 @@ pub struct Cli {
#[arg(long)]
pub https: bool,
}
#[cfg(test)]
mod tests {
use super::*;
use clap::Parser;
use std::path::PathBuf;
#[test]
fn defaults_applied() {
let c = Cli::try_parse_from(["filebrowser-ng", "--root", "/r", "--db", "/d"]).unwrap();
assert_eq!(c.root, PathBuf::from("/r"));
assert_eq!(c.db, PathBuf::from("/d"));
assert_eq!(c.port, 8080);
assert_eq!(c.bind, "127.0.0.1");
assert!(!c.https);
}
#[test]
fn flags_override_defaults() {
let c = Cli::try_parse_from([
"filebrowser-ng",
"--root",
"/r",
"--db",
"/d",
"--port",
"9000",
"--bind",
"0.0.0.0",
"--https",
])
.unwrap();
assert_eq!(c.port, 9000);
assert_eq!(c.bind, "0.0.0.0");
assert!(c.https);
}
#[test]
fn required_args_enforced() {
assert!(Cli::try_parse_from(["filebrowser-ng", "--root", "/r"]).is_err());
assert!(Cli::try_parse_from(["filebrowser-ng"]).is_err());
}
}
▾Mserver/src/db.rs
@@ -540,3 +540,226 @@ CREATE TABLE IF NOT EXISTS settings (
);
INSERT OR IGNORE INTO settings (key, value) VALUES ('allow_writable_shares', '0');
"#;
#[cfg(test)]
mod tests {
use super::*;
async fn tmp() -> (tempfile::TempDir, Db) {
let dir = tempfile::tempdir().unwrap();
let db = Db::open(&dir.path().join("db.sqlite")).await.unwrap();
(dir, db)
}
async fn db_with_admin() -> (tempfile::TempDir, Db, User) {
let (dir, db) = tmp().await;
let hash = crate::auth::hash_password("admin1234").unwrap();
let admin = db.create_admin("admin", &hash).await.unwrap();
(dir, db, admin)
}
#[tokio::test]
async fn fresh_db_state() {
let (_d, db) = tmp().await;
assert_eq!(db.user_count().await, 0);
assert_eq!(db.count_admins().await, 0);
assert!(!db.allow_writable_shares().await);
assert!(db.find_user_by_name("nobody").await.is_none());
assert!(db.find_user_by_id(1).await.is_none());
assert!(db.all_users().await.is_empty());
}
#[tokio::test]
async fn v1_db_migrates_to_v2() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("legacy.sqlite");
{
let conn = rusqlite::Connection::open(&path).unwrap();
conn.execute_batch(SCHEMA_V1).unwrap();
conn.execute(
"INSERT INTO users (name, pass_hash, is_admin, created_at)
VALUES ('legacy', 'hash', 1, '2024-01-01T00:00:00Z')",
[],
)
.unwrap();
conn.execute(
"INSERT INTO user_roots (user_id, path, mode) VALUES (1, 'docs', 'rw')",
[],
)
.unwrap();
}
let db = Db::open(&path).await.unwrap();
assert_eq!(db.user_count().await, 1);
let u = db.find_user_by_name("legacy").await.unwrap();
assert!(u.active, "v2 migration must default active to true");
assert!(u.is_admin);
assert_eq!(db.user_roots(u.id).await.len(), 1);
// Migrations are idempotent.
let db2 = Db::open(&path).await.unwrap();
assert_eq!(db2.user_count().await, 1);
assert!(db2.find_user_by_name("legacy").await.unwrap().active);
}
#[tokio::test]
async fn admin_user_and_passwords() {
let (_d, db, admin) = db_with_admin().await;
assert!(admin.is_admin);
assert!(admin.active);
// Root "." rw is assigned by create_admin.
let roots = db.user_roots(admin.id).await;
assert_eq!(roots.len(), 1);
assert_eq!(roots[0].path, ".");
assert_eq!(roots[0].mode, "rw");
assert!(db.verify_password("admin", "admin1234").await.is_some());
assert!(db.verify_password("admin", "nope").await.is_none());
// Name lookup is case-insensitive (COLLATE NOCASE).
assert!(db.verify_password("ADMIN", "admin1234").await.is_some());
// Disabled users cannot verify.
db.set_user_active(admin.id, false).await.unwrap();
assert!(db.verify_password("admin", "admin1234").await.is_none());
db.set_user_active(admin.id, true).await.unwrap();
assert!(db.verify_password("admin", "admin1234").await.is_some());
}
#[tokio::test]
async fn sessions_lifecycle() {
let (_d, db, admin) = db_with_admin().await;
assert!(db.session_user("ghost-token").await.is_none());
db.create_session(admin.id, "tok1").await.unwrap();
let u = db.session_user("tok1").await.unwrap();
assert_eq!(u.id, admin.id);
// Disabling the user invalidates existing sessions.
db.set_user_active(admin.id, false).await.unwrap();
assert!(db.session_user("tok1").await.is_none());
db.set_user_active(admin.id, true).await.unwrap();
assert!(db.session_user("tok1").await.is_some());
db.delete_session("tok1").await.unwrap();
assert!(db.session_user("tok1").await.is_none());
}
#[tokio::test]
async fn user_crud_and_roots() {
let (_d, db, _admin) = db_with_admin().await;
let h = crate::auth::hash_password("bobpass1").unwrap();
let bob = db
.create_user("bob", &h, false, &[("docs".into(), "rw".into())])
.await
.unwrap();
assert!(!bob.is_admin);
assert!(bob.active);
// Duplicate name (case-insensitive) is rejected.
let h2 = crate::auth::hash_password("carolpass1").unwrap();
assert!(db.create_user("BOB", &h2, false, &[]).await.is_err());
assert!(db.create_user("carol", &h2, false, &[]).await.is_ok());
// Lookup helpers.
assert_eq!(db.find_user_by_name("Bob").await.unwrap().id, bob.id);
assert_eq!(db.find_user_by_id(bob.id).await.unwrap().name, "bob");
assert!(db.find_user_by_name("dave").await.is_none());
assert_eq!(db.all_users().await.len(), 3);
// Root replacement semantics.
let roots = db.user_roots(bob.id).await;
assert_eq!(roots.len(), 1);
db.set_user_roots(bob.id, &[(".".into(), "ro".into()), ("docs".into(), "rw".into())])
.await
.unwrap();
let roots = db.user_roots(bob.id).await;
assert_eq!(roots.len(), 2);
assert!(roots.iter().any(|r| r.path == "." && r.mode == "ro"));
db.set_user_roots(bob.id, &[]).await.unwrap();
assert!(db.user_roots(bob.id).await.is_empty());
// Password update.
let new_h = crate::auth::hash_password("bobpass2").unwrap();
db.update_user_password(bob.id, &new_h).await.unwrap();
assert!(db.verify_password("bob", "bobpass1").await.is_none());
assert!(db.verify_password("bob", "bobpass2").await.is_some());
// Admin flag + count (only active admins count).
db.set_user_admin(bob.id, true).await.unwrap();
assert_eq!(db.count_admins().await, 2);
db.set_user_active(bob.id, false).await.unwrap();
assert_eq!(db.count_admins().await, 1);
db.set_user_admin(bob.id, false).await.unwrap();
// Deletion.
assert!(db.delete_user(bob.id).await);
assert!(db.find_user_by_id(bob.id).await.is_none());
assert!(!db.delete_user(bob.id).await);
assert_eq!(db.user_count().await, 2);
}
fn share_row(expires_at: Option<&str>) -> ShareRow {
ShareRow {
id: 1,
token: "t".into(),
creator_id: 1,
target: "docs".into(),
is_file: false,
mode: "ro".into(),
created_at: "2024-01-01T00:00:00Z".into(),
expires_at: expires_at.map(str::to_string),
}
}
#[test]
fn share_expiry_logic() {
assert!(!share_row(None).is_expired());
assert!(!share_row(Some("2999-01-01T00:00:00Z")).is_expired());
assert!(share_row(Some("2000-01-01T00:00:00Z")).is_expired());
// Unparseable expiry → treated as not expired (fail open for reads).
assert!(!share_row(Some("not-a-date")).is_expired());
}
#[tokio::test]
async fn shares_crud() {
let (_d, db, admin) = db_with_admin().await;
let s1 = db
.create_share(admin.id, "tok-a", "docs", false, "ro", None)
.await
.unwrap();
let s2 = db
.create_share(admin.id, "tok-b", "file.txt", true, "rw", Some("2999-01-01T00:00:00Z"))
.await
.unwrap();
assert!(s2.id > s1.id);
let found = db.share_by_token("tok-b").await.unwrap();
assert!(found.is_file);
assert_eq!(found.mode, "rw");
assert!(db.share_by_token("nope").await.is_none());
// Listed newest-first.
let list = db.user_shares(admin.id).await;
assert_eq!(list.len(), 2);
assert_eq!(list[0].id, s2.id);
// Other users see nothing.
let h = crate::auth::hash_password("bobpass1").unwrap();
let bob = db.create_user("bob", &h, false, &[]).await.unwrap();
assert!(db.user_shares(bob.id).await.is_empty());
// Only the creator can delete.
assert!(!db.delete_share(s1.id, bob.id).await);
assert!(db.delete_share(s1.id, admin.id).await);
assert!(db.share_by_token("tok-a").await.is_none());
assert!(!db.delete_share(s1.id, admin.id).await);
}
#[tokio::test]
async fn settings_round_trip() {
let (_d, db, _admin) = db_with_admin().await;
assert!(!db.allow_writable_shares().await);
db.set_allow_writable_shares(true).await.unwrap();
assert!(db.allow_writable_shares().await);
// Upsert semantics.
db.set_allow_writable_shares(false).await.unwrap();
assert!(!db.allow_writable_shares().await);
// Generic get/set.
db.set_setting("custom", "v").await.unwrap();
assert_eq!(db.get_setting("custom").await.as_deref(), Some("v"));
assert_eq!(db.get_setting("missing").await, None);
}
}
▾Mserver/src/error.rs
@@ -58,3 +58,74 @@ impl From<rusqlite::Error> for ApiError {
ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, "internal error")
}
}
#[cfg(test)]
mod tests {
use super::*;
use axum::http::StatusCode;
use http_body_util::BodyExt;
fn status_and_body(e: ApiError) -> (StatusCode, String) {
let resp = e.into_response();
let status = resp.status();
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap();
let bytes = rt
.block_on(async { resp.into_body().collect().await.unwrap().to_bytes() })
.to_vec();
(status, String::from_utf8(bytes).unwrap())
}
#[test]
fn plain_error_body() {
let (status, body) = status_and_body(ApiError::new(
StatusCode::NOT_FOUND,
"folder not found",
));
assert_eq!(status, StatusCode::NOT_FOUND);
assert_eq!(
serde_json::from_str::<serde_json::Value>(&body).unwrap(),
serde_json::json!({ "error": "folder not found" })
);
}
#[test]
fn extra_object_is_merged_into_body() {
let e = ApiError::new(StatusCode::CONFLICT, "some files already exist")
.with_extra(serde_json::json!({ "skipped": ["a.txt"], "uploaded": 2 }));
let (status, body) = status_and_body(e);
assert_eq!(status, StatusCode::CONFLICT);
assert_eq!(
serde_json::from_str::<serde_json::Value>(&body).unwrap(),
serde_json::json!({
"error": "some files already exist",
"skipped": ["a.txt"],
"uploaded": 2
})
);
}
#[test]
fn non_object_extra_is_ignored() {
let e = ApiError::new(StatusCode::CONFLICT, "conflict")
.with_extra(serde_json::json!(["not", "an", "object"]));
let (_status, body) = status_and_body(e);
assert_eq!(
serde_json::from_str::<serde_json::Value>(&body).unwrap(),
serde_json::json!({ "error": "conflict" })
);
}
#[test]
fn io_error_maps_to_500() {
let e = ApiError::from(std::io::Error::new(
std::io::ErrorKind::NotFound,
"disk vanished",
));
let (status, body) = status_and_body(e);
assert_eq!(status, StatusCode::INTERNAL_SERVER_ERROR);
assert!(body.contains("internal error"));
}
}
▾Mserver/src/fs.rs
@@ -222,6 +222,11 @@ pub fn rename_item(
.parent()
.ok_or_else(|| FsError::Invalid("invalid path".to_string()))?;
let to = parent.join(new_name);
// Renaming onto itself is a no-op (the overwrite path below would
// delete the file before the rename).
if to == from {
return Ok(());
}
if to.exists() {
if !overwrite || to.is_dir() || from.is_dir() {
return Err(FsError::Conflict);
@@ -316,6 +321,11 @@ pub fn move_item(
.to_owned();
let to = dst_dir.join(&name);
// A no-op (item already at the destination) — treat as success.
if to == from {
return Ok(());
}
// Refuse moving a directory into itself or a descendant.
if from.is_dir() && is_within_or_eq(&from, &dst_dir) {
return Err(FsError::Invalid(
@@ -356,6 +366,11 @@ pub fn copy_item(
.to_owned();
let to = dst_dir.join(&name);
// A no-op (item already at the destination) — treat as success.
if to == from {
return Ok(());
}
if from.is_dir() && is_within_or_eq(&from, &dst_dir) {
return Err(FsError::Invalid(
"cannot copy a folder into itself".to_string(),
@@ -400,3 +415,591 @@ fn set_mtime(p: &Path, t: Option<std::time::SystemTime>) {
let _ = f.set_modified(t);
}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
/// A temp dir used as the "server root" with a small fixture tree:
///
/// ```text
/// root/
/// docs/
/// inner/
/// hello.txt
/// a.txt
/// src/
/// main.rs
/// file.txt
/// ```
struct T {
tmp: tempfile::TempDir,
root: PathBuf,
}
impl T {
fn new() -> Self {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().to_path_buf();
std::fs::create_dir_all(root.join("docs/inner")).unwrap();
std::fs::create_dir_all(root.join("src")).unwrap();
std::fs::write(root.join("docs/inner/hello.txt"), "hello").unwrap();
std::fs::write(root.join("docs/a.txt"), "a").unwrap();
std::fs::write(root.join("src/main.rs"), "fn main() {}").unwrap();
std::fs::write(root.join("file.txt"), "top file").unwrap();
Self { tmp, root }
}
/// A directory that lives *next to* the root (outside of it), for
/// symlink/escape tests. The tempdir name is unique, so the sibling
/// name is unique too.
fn sibling(&self, name: &str) -> PathBuf {
let base = self
.tmp
.path()
.file_name()
.unwrap()
.to_string_lossy()
.into_owned();
let p = self.tmp.path().with_file_name(format!("{base}-{name}"));
std::fs::create_dir_all(&p).unwrap();
p
}
}
// ---------- validate_name ----------
#[test]
fn validate_name_accepts_simple_names() {
for ok in ["a", "file.txt", "my folder", "Ünïcödé", "with-dash_1.2.3"] {
assert!(validate_name(ok).is_ok(), "{ok:?} should be valid");
}
}
#[test]
fn validate_name_rejects_traversal_and_paths() {
for bad in [
"", ".", "..", "a/b", "a\\b", "a\0b", "/abs", "../x", "x/../y", "x/", "/x",
] {
assert!(validate_name(bad).is_err(), "{bad:?} should be invalid");
}
}
// ---------- resolve_root ----------
#[test]
fn resolve_root_whole_root_and_subdir() {
let t = T::new();
let root = t.root.canonicalize().unwrap();
// "." means the whole root.
assert_eq!(resolve_root(&root, ".").unwrap(), root);
assert_eq!(resolve_root(&root, "docs").unwrap(), root.join("docs"));
assert_eq!(
resolve_root(&root, "docs/inner").unwrap(),
root.join("docs/inner")
);
}
#[test]
fn resolve_root_rejects_escape_and_missing() {
let t = T::new();
let root = t.root.canonicalize().unwrap();
let sib = t.sibling("escape");
let sib_rel = sib
.file_name()
.unwrap()
.to_string_lossy()
.into_owned();
// Escapes that land on *existing* paths outside the root.
for esc in ["..".to_string(), "docs/../..".to_string(), format!("../{sib_rel}")] {
assert!(
matches!(resolve_root(&root, &esc), Err(FsError::Forbidden)),
"{esc:?} should be forbidden"
);
}
// Escapes to non-existing paths simply don't exist.
for esc in ["../no-such-dir", "a/b/../../..", "nope"] {
assert!(
matches!(resolve_root(&root, &esc), Err(FsError::RootMissing)),
"{esc:?} should be missing"
);
}
// A file is not a valid root.
assert!(matches!(
resolve_root(&root, "file.txt"),
Err(FsError::RootMissing)
));
}
#[cfg(unix)]
#[test]
fn resolve_root_rejects_symlink_escape() {
let t = T::new();
let root = t.root.canonicalize().unwrap();
let outside = t.sibling("outside");
std::os::unix::fs::symlink(&outside, root.join("link")).unwrap();
assert!(matches!(
resolve_root(&root, "link"),
Err(FsError::Forbidden)
));
}
// ---------- resolve_path ----------
#[test]
fn resolve_path_traverses_inside_root() {
let t = T::new();
let root = t.root.canonicalize().unwrap();
// Empty relative path → the root itself.
assert_eq!(resolve_path(&root, ".", "").unwrap(), root);
assert_eq!(
resolve_path(&root, "docs", "inner/hello.txt").unwrap(),
root.join("docs/inner/hello.txt")
);
assert_eq!(resolve_path(&root, ".", "file.txt").unwrap(), root.join("file.txt"));
}
#[test]
fn resolve_path_rejects_parent_traversal() {
let t = T::new();
let root = t.root.canonicalize().unwrap();
for p in ["..", "../file.txt", "docs/../../file.txt", "a/../../b"] {
assert!(
matches!(resolve_path(&root, ".", p), Err(FsError::Forbidden)),
"{p:?} should be forbidden"
);
}
}
#[test]
fn resolve_path_missing_is_not_found() {
let t = T::new();
let root = t.root.canonicalize().unwrap();
assert!(matches!(
resolve_path(&root, "docs", "nope.txt"),
Err(FsError::NotFound)
));
assert!(matches!(
resolve_path(&root, "missing-root", ""),
Err(FsError::RootMissing)
));
}
#[cfg(unix)]
#[test]
fn resolve_path_rejects_symlink_escape() {
let t = T::new();
let root = t.root.canonicalize().unwrap();
let outside = t.sibling("outside");
let secret = outside.join("secret.txt");
std::fs::write(&secret, "top secret").unwrap();
std::os::unix::fs::symlink(&secret, root.join("evil")).unwrap();
assert!(matches!(
resolve_path(&root, ".", "evil"),
Err(FsError::Forbidden)
));
// A symlink that stays inside the root is fine.
std::os::unix::fs::symlink(root.join("file.txt"), root.join("alias")).unwrap();
assert_eq!(
resolve_path(&root, ".", "alias").unwrap(),
root.join("file.txt")
);
}
// ---------- resolve_file / resolve_dir ----------
#[test]
fn resolve_file_targets_files() {
let t = T::new();
let root = t.root.canonicalize().unwrap();
assert_eq!(resolve_file(&root, "file.txt").unwrap(), root.join("file.txt"));
assert!(matches!(
resolve_file(&root, "nope.txt"),
Err(FsError::NotFound)
));
// Escape to an existing sibling file.
let sib = t.sibling("escape");
let sib_rel = sib
.file_name()
.unwrap()
.to_string_lossy()
.into_owned();
std::fs::write(sib.join("s.txt"), "x").unwrap();
assert!(matches!(
resolve_file(&root, &format!("../{sib_rel}/s.txt")),
Err(FsError::Forbidden)
));
}
#[test]
fn resolve_dir_requires_existing_directory() {
let t = T::new();
let root = t.root.canonicalize().unwrap();
assert_eq!(resolve_dir(&root, ".", "docs").unwrap(), root.join("docs"));
assert!(matches!(
resolve_dir(&root, ".", "file.txt"),
Err(FsError::NotADirectory)
));
assert!(matches!(resolve_dir(&root, ".", "nope"), Err(FsError::NotFound)));
}
// ---------- list_dir ----------
#[test]
fn list_dir_sorts_folders_first_then_case_insensitive() {
let t = T::new();
let d = t.root.join("sortme");
std::fs::create_dir_all(d.join("Zeta")).unwrap();
std::fs::create_dir_all(d.join("alpha-dir")).unwrap();
std::fs::write(d.join("b.txt"), "x").unwrap();
std::fs::write(d.join("A.txt"), "x").unwrap();
std::fs::write(d.join("C.md"), "x").unwrap();
let entries = list_dir(&d).unwrap();
let names: Vec<&str> = entries.iter().map(|e| e.name.as_str()).collect();
// Folders first (alpha-dir, Zeta), then files case-insensitively.
assert_eq!(names, vec!["alpha-dir", "Zeta", "A.txt", "b.txt", "C.md"]);
let a = &entries[2];
assert!(!a.is_dir);
assert_eq!(a.size, 1);
assert!(!a.mtime.is_empty());
}
#[test]
fn list_dir_error_cases() {
let t = T::new();
let root = t.root.canonicalize().unwrap();
assert!(matches!(
list_dir(&root.join("missing")),
Err(FsError::NotFound)
));
assert!(matches!(
list_dir(&root.join("file.txt")),
Err(FsError::NotADirectory)
));
}
#[cfg(unix)]
#[test]
fn list_dir_reports_broken_symlink_as_empty_file() {
let t = T::new();
let d = t.root.join("withlink");
std::fs::create_dir_all(&d).unwrap();
std::os::unix::fs::symlink(d.join("does-not-exist"), d.join("broken")).unwrap();
let entries = list_dir(&d).unwrap();
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].name, "broken");
assert!(!entries[0].is_dir);
assert_eq!(entries[0].size, 0);
}
// ---------- mkdir ----------
#[test]
fn mkdir_creates_nested_dirs() {
let t = T::new();
let root = t.root.canonicalize().unwrap();
// The parent must exist; "new" first, then "new/sub".
mkdir(&root, ".", "new").unwrap();
assert!(root.join("new").is_dir());
mkdir(&root, ".", "new/sub").unwrap();
assert!(root.join("new/sub").is_dir());
}
#[test]
fn mkdir_rejects_conflict_and_bad_names() {
let t = T::new();
let root = t.root.canonicalize().unwrap();
assert!(matches!(
mkdir(&root, ".", "docs"),
Err(FsError::Conflict)
));
assert!(matches!(
mkdir(&root, ".", "a/b/../../c"),
Err(FsError::Forbidden)
));
assert!(matches!(
mkdir(&root, ".", "file.txt/x"),
Err(FsError::Forbidden) // parent is a file → ENOTDIR
));
}
// ---------- rename ----------
#[test]
fn rename_moves_file_and_dir() {
let t = T::new();
let root = t.root.canonicalize().unwrap();
rename_item(&root, ".", "file.txt", "renamed.txt", false).unwrap();
assert!(!root.join("file.txt").exists());
assert_eq!(
std::fs::read_to_string(root.join("renamed.txt")).unwrap(),
"top file"
);
rename_item(&root, ".", "docs", "docs2", false).unwrap();
assert!(root.join("docs2/inner/hello.txt").exists());
}
#[test]
fn rename_conflicts_and_overwrite() {
let t = T::new();
let root = t.root.canonicalize().unwrap();
std::fs::write(root.join("other.txt"), "other").unwrap();
// Target file exists, no overwrite → conflict.
assert!(matches!(
rename_item(&root, ".", "file.txt", "other.txt", false),
Err(FsError::Conflict)
));
// Overwrite a file target → replaces it.
rename_item(&root, ".", "file.txt", "other.txt", true).unwrap();
assert_eq!(
std::fs::read_to_string(root.join("other.txt")).unwrap(),
"top file"
);
// A dir target is never overwritten, even with the flag.
assert!(matches!(
rename_item(&root, ".", "other.txt", "docs", true),
Err(FsError::Conflict)
));
// Renaming into a free slot works, then onto itself is a no-op.
rename_item(&root, ".", "other.txt", "free.txt", false).unwrap();
assert!(root.join("free.txt").exists());
rename_item(&root, ".", "free.txt", "free.txt", false).unwrap();
assert!(root.join("free.txt").exists());
assert!(root.join("free.txt").is_file());
}
#[test]
fn rename_validates_new_name() {
let t = T::new();
let root = t.root.canonicalize().unwrap();
for bad in ["a/b", "", ".", ".."] {
assert!(matches!(
rename_item(&root, ".", "file.txt", bad, false),
Err(FsError::Invalid(_))
));
}
assert!(matches!(
rename_item(&root, ".", "missing", "x", false),
Err(FsError::NotFound)
));
}
// ---------- remove ----------
#[test]
fn remove_file_and_dir() {
let t = T::new();
let root = t.root.canonicalize().unwrap();
assert_eq!(remove_item(&root, ".", "file.txt").unwrap(), false);
assert!(!root.join("file.txt").exists());
assert_eq!(remove_item(&root, ".", "docs").unwrap(), true);
assert!(!root.join("docs").exists());
assert!(matches!(
remove_item(&root, ".", "file.txt"),
Err(FsError::NotFound)
));
}
// ---------- save_file ----------
fn mtime_of(p: &Path) -> i64 {
std::fs::metadata(p)
.unwrap()
.modified()
.unwrap()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs() as i64
}
#[test]
fn save_file_updates_content_and_returns_new_mtime() {
let t = T::new();
let root = t.root.canonicalize().unwrap();
let before = mtime_of(&root.join("file.txt"));
// Sleep so the mtime actually advances (filesystem granularity).
std::thread::sleep(std::time::Duration::from_millis(1100));
let new = save_file(&root, ".", "file.txt", b"brand new", Some(before)).unwrap();
assert_eq!(std::fs::read(root.join("file.txt")).unwrap(), b"brand new");
assert!(new >= before);
// A second save with the *returned* mtime succeeds.
let new2 = save_file(&root, ".", "file.txt", b"again", Some(new)).unwrap();
assert!(new2 >= new);
// Without an expected mtime, always saves.
let _ = save_file(&root, ".", "file.txt", b"force", None).unwrap();
assert_eq!(std::fs::read(root.join("file.txt")).unwrap(), b"force");
}
#[test]
fn save_file_conflict_on_stale_mtime() {
let t = T::new();
let root = t.root.canonicalize().unwrap();
std::thread::sleep(std::time::Duration::from_millis(1100));
// The mtime we pass is older than the file's real mtime → conflict.
assert!(matches!(
save_file(&root, ".", "file.txt", b"x", Some(1)),
Err(FsError::Conflict)
));
}
#[test]
fn save_file_error_cases() {
let t = T::new();
let root = t.root.canonicalize().unwrap();
assert!(matches!(
save_file(&root, ".", "nope.txt", b"x", None),
Err(FsError::NotFound)
));
assert!(matches!(
save_file(&root, ".", "docs", b"x", None),
Err(FsError::NotADirectory)
));
assert!(matches!(
save_file(&root, ".", "../evil.txt", b"x", None),
Err(FsError::Forbidden)
));
}
// ---------- move / copy ----------
#[test]
fn move_file_and_dir_across_dirs() {
let t = T::new();
let root = t.root.canonicalize().unwrap();
move_item(&root, ".", "file.txt", ".", "src", false).unwrap();
assert!(!root.join("file.txt").exists());
assert!(root.join("src/file.txt").exists());
move_item(&root, ".", "src", ".", "docs", false).unwrap();
assert!(root.join("docs/src/main.rs").exists());
assert!(!root.join("src").exists());
}
#[test]
fn move_refuses_into_self_and_conflicts() {
let t = T::new();
let root = t.root.canonicalize().unwrap();
// A dir cannot be moved into itself or a descendant.
assert!(matches!(
move_item(&root, ".", "docs", ".", "docs", false),
Err(FsError::Invalid(_))
));
assert!(matches!(
move_item(&root, ".", "docs", ".", "docs/inner", false),
Err(FsError::Invalid(_))
));
// A dir target always conflicts, even with overwrite: move the file
// "x" into a folder that already contains a subfolder "x".
std::fs::create_dir_all(root.join("mv/case/x")).unwrap();
std::fs::create_dir_all(root.join("mv/out")).unwrap();
std::fs::write(root.join("mv/out/x"), "a file named x").unwrap();
assert!(matches!(
move_item(&root, ".", "mv/out/x", ".", "mv/case", true),
Err(FsError::Conflict)
));
// File onto file: conflict without overwrite, replaced with.
std::fs::write(root.join("tmp-x.txt"), "x").unwrap();
std::fs::write(root.join("tmp-y.txt"), "y").unwrap();
std::fs::rename(&root.join("tmp-x.txt"), &root.join("tmp-target.txt")).unwrap();
std::fs::rename(&root.join("tmp-y.txt"), &root.join("tmp-target2.txt")).unwrap();
// Two distinct files with the same name in one folder.
std::fs::create_dir_all(root.join("mv/dst")).unwrap();
std::fs::create_dir_all(root.join("mv/out2")).unwrap();
std::fs::write(root.join("mv/dst/dup.txt"), "old").unwrap();
std::fs::write(root.join("mv/out2/dup.txt"), "new").unwrap();
assert!(matches!(
move_item(&root, ".", "mv/out2/dup.txt", ".", "mv/dst", false),
Err(FsError::Conflict)
));
move_item(&root, ".", "mv/out2/dup.txt", ".", "mv/dst", true).unwrap();
assert_eq!(
std::fs::read_to_string(root.join("mv/dst/dup.txt")).unwrap(),
"new"
);
// Moving onto itself is a no-op success.
move_item(&root, ".", "tmp-target.txt", ".", ".", false).unwrap();
assert!(root.join("tmp-target.txt").exists());
// Missing destination dir.
assert!(matches!(
move_item(&root, ".", "file.txt", ".", "nope", false),
Err(FsError::NotFound)
));
}
#[test]
fn copy_file_and_dir_preserves_mtime() {
let t = T::new();
let root = t.root.canonicalize().unwrap();
let before = mtime_of(&root.join("file.txt"));
copy_item(&root, ".", "file.txt", ".", "src", false).unwrap();
let copy = root.join("src/file.txt");
assert_eq!(std::fs::read(&copy).unwrap(), b"top file");
assert_eq!(mtime_of(&copy), before);
// Dir copy.
copy_item(&root, ".", "docs", ".", "src", false).unwrap();
assert_eq!(
std::fs::read_to_string(root.join("src/docs/inner/hello.txt")).unwrap(),
"hello"
);
// Originals still there.
assert!(root.join("file.txt").exists());
assert!(root.join("docs/a.txt").exists());
}
#[test]
fn copy_refuses_into_self_and_handles_conflict() {
let t = T::new();
let root = t.root.canonicalize().unwrap();
assert!(matches!(
copy_item(&root, ".", "docs", ".", "docs", false),
Err(FsError::Invalid(_))
));
assert!(matches!(
copy_item(&root, ".", "docs", ".", "docs/inner", false),
Err(FsError::Invalid(_))
));
// First copy is fine, the second one conflicts, overwrite replaces.
copy_item(&root, ".", "file.txt", ".", "src", false).unwrap();
assert!(matches!(
copy_item(&root, ".", "file.txt", ".", "src", false),
Err(FsError::Conflict)
));
std::fs::write(root.join("file.txt"), "v2").unwrap();
copy_item(&root, ".", "file.txt", ".", "src", true).unwrap();
assert_eq!(std::fs::read_to_string(root.join("src/file.txt")).unwrap(), "v2");
// Copying onto itself is a no-op success.
copy_item(&root, ".", "src/file.txt", ".", "src", false).unwrap();
assert_eq!(std::fs::read_to_string(root.join("src/file.txt")).unwrap(), "v2");
// Missing destination dir.
assert!(matches!(
copy_item(&root, ".", "file.txt", ".", "nope", false),
Err(FsError::NotFound)
));
}
#[test]
fn copy_recursive_missing_source() {
let t = T::new();
let dst = t.tmp.path().join("dst");
assert!(matches!(
copy_recursive(&t.root.join("nope"), &dst),
Err(FsError::NotFound)
));
}
// ---------- is_within_or_eq ----------
#[test]
fn is_within_or_eq_matrix() {
let t = T::new();
let root = t.root.canonicalize().unwrap();
let docs = root.join("docs");
assert!(is_within_or_eq(&docs, &docs));
assert!(is_within_or_eq(&docs, &root.join("docs/inner")));
assert!(!is_within_or_eq(&docs, &root));
assert!(!is_within_or_eq(&docs, &root.join("src")));
}
}
▾Aserver/src/lib.rs
@@ -0,0 +1,138 @@
//! filebrowser-ng server.
//!
//! Library target so the HTTP app can be exercised from integration tests
//! (via `tower::ServiceExt::oneshot`) without binding a real port. The
//! `filebrowser-ng` binary is a thin wrapper around [`run`].
use std::net::{IpAddr, SocketAddr};
use std::sync::Arc;
use anyhow::{bail, Context};
use clap::Parser;
use tower_http::trace::TraceLayer;
pub mod api;
pub mod archive;
mod assets;
pub mod auth;
pub mod cli;
pub mod db;
pub mod error;
pub mod fs;
use crate::cli::Cli;
use crate::db::Db;
use crate::error::AppState;
/// Validate the CLI config and build the running state + router without
/// binding the port (so tests can exercise everything up to `serve`).
pub async fn build_app(cli: &Cli) -> anyhow::Result<(axum::Router, SocketAddr)> {
let root = cli
.root
.canonicalize()
.with_context(|| format!("cannot resolve root folder: {}", cli.root.display()))?;
if !root.is_dir() {
bail!("root folder is not a directory: {}", root.display());
}
let db = Db::open(&cli.db).await?;
let state = Arc::new(AppState {
db,
root: root.clone(),
https: cli.https,
});
let app = api::router(state).layer(TraceLayer::new_for_http());
let ip: IpAddr = cli
.bind
.parse()
.context("invalid --bind address")?;
let addr = SocketAddr::new(ip, cli.port);
Ok((app, addr))
}
/// Parse the CLI, initialize logging and serve until the process is killed.
pub async fn run() -> anyhow::Result<()> {
tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| "info,tower_http=warn".into()),
)
.init();
let cli = Cli::parse();
let (app, addr) = build_app(&cli).await?;
let listener = tokio::net::TcpListener::bind(addr)
.await
.with_context(|| format!("cannot bind to {addr}"))?;
tracing::info!(root = %cli.root.display(), "filebrowser-ng starting");
tracing::info!(addr = %addr, "listening (pass --bind 0.0.0.0 to expose beyond localhost)");
axum::serve(listener, app).await?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::cli::Cli;
fn cli(root: &std::path::Path, db: &std::path::Path) -> Cli {
Cli {
root: root.to_path_buf(),
db: db.to_path_buf(),
port: 8080,
bind: "127.0.0.1".into(),
https: false,
}
}
#[tokio::test]
async fn build_app_ok() {
let tmp = tempfile::tempdir().unwrap();
std::fs::create_dir_all(tmp.path().join("sub")).unwrap();
let c = cli(tmp.path(), &tmp.path().join("db.sqlite"));
let (app, addr) = build_app(&c).await.unwrap();
let _ = app; // Router built fine
assert_eq!(addr.to_string(), "127.0.0.1:8080");
}
#[tokio::test]
async fn build_app_missing_root_fails() {
let tmp = tempfile::tempdir().unwrap();
let c = cli(
&tmp.path().join("no-such-root"),
&tmp.path().join("db.sqlite"),
);
assert!(build_app(&c).await.is_err());
}
#[tokio::test]
async fn build_app_root_must_be_directory() {
let tmp = tempfile::tempdir().unwrap();
let file = tmp.path().join("a-file");
std::fs::write(&file, "x").unwrap();
let c = cli(&file, &tmp.path().join("db.sqlite"));
assert!(build_app(&c).await.is_err());
}
#[tokio::test]
async fn build_app_rejects_bad_bind() {
let tmp = tempfile::tempdir().unwrap();
let mut c = cli(tmp.path(), &tmp.path().join("db.sqlite"));
c.bind = "not-an-ip".into();
assert!(build_app(&c).await.is_err());
}
#[tokio::test]
async fn build_app_custom_port_bind() {
let tmp = tempfile::tempdir().unwrap();
let mut c = cli(tmp.path(), &tmp.path().join("db.sqlite"));
c.port = 9999;
c.bind = "0.0.0.0".into();
let (_app, addr) = build_app(&c).await.unwrap();
assert_eq!(addr.to_string(), "0.0.0.0:9999");
}
}
▾Mserver/src/main.rs
@@ -1,60 +1,4 @@
mod api;
mod archive;
mod assets;
mod auth;
mod cli;
mod db;
mod error;
mod fs;
use std::net::{IpAddr, SocketAddr};
use std::sync::Arc;
use anyhow::{bail, Context};
use clap::Parser;
use tower_http::trace::TraceLayer;
use crate::cli::Cli;
use crate::db::Db;
use crate::error::AppState;
#[tokio::main]
async fn main() -> anyhow::Result<()> {
tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| "info,tower_http=warn".into()),
)
.init();
let cli = Cli::parse();
let root = cli
.root
.canonicalize()
.with_context(|| format!("cannot resolve root folder: {}", cli.root.display()))?;
if !root.is_dir() {
bail!("root folder is not a directory: {}", root.display());
}
let db = Db::open(&cli.db).await?;
let state = Arc::new(AppState {
db,
root: root.clone(),
https: cli.https,
});
let app = api::router(state).layer(TraceLayer::new_for_http());
let ip: IpAddr = cli.bind.parse().context("invalid --bind address")?;
let addr = SocketAddr::new(ip, cli.port);
let listener = tokio::net::TcpListener::bind(addr)
.await
.with_context(|| format!("cannot bind to {addr}"))?;
tracing::info!(root = %root.display(), "filebrowser-ng starting");
tracing::info!(addr = %addr, "listening (pass --bind 0.0.0.0 to expose beyond localhost)");
axum::serve(listener, app).await?;
Ok(())
server::run().await
}
▾Aserver/tests/api_admin.rs
@@ -0,0 +1,256 @@
//! Admin API: user management, lockout guards, server settings.
mod common;
use axum::http::StatusCode;
use common::*;
use serde_json::json;
#[tokio::test]
async fn admin_routes_require_admin() {
let env = Env::new().await;
let admin = env.admin().await;
// No session → 401.
let anon = Client::new(env.app.clone());
assert_eq!(anon.get("/api/admin/users").await.status, StatusCode::UNAUTHORIZED);
assert_eq!(anon.get("/api/admin/settings").await.status, StatusCode::UNAUTHORIZED);
// Non-admin session → 403.
create_user(&admin, "bob", "bobpass123", &[("docs", "rw")]).await;
let bob = login(&env, "bob", "bobpass123").await;
assert_eq!(bob.get("/api/admin/users").await.status, StatusCode::FORBIDDEN);
assert_eq!(
bob.put_json("/api/admin/settings", &json!({ "allow_writable_shares": true }))
.await
.status,
StatusCode::FORBIDDEN
);
}
#[tokio::test]
async fn user_lifecycle() {
let env = Env::new().await;
let admin = env.admin().await;
// Create.
let j = create_user(&admin, "bob", "bobpass123", &[("docs", "rw"), ("src", "ro")]).await;
let bob_id = j["id"].as_i64().unwrap();
assert_eq!(j["name"], "bob");
assert_eq!(j["is_admin"], false);
assert_eq!(j["active"], true);
assert_eq!(j["roots"].as_array().unwrap().len(), 2);
// bob can log in and sees his roots.
let bob = login(&env, "bob", "bobpass123").await;
let me = bob.get("/api/auth/me").await.json();
let roots = me["roots"].as_array().unwrap();
assert_eq!(roots.len(), 2);
// Duplicate name (case-insensitive) → 409.
let r = admin
.post_json(
"/api/admin/users",
&json!({ "name": "BOB", "password": "whatever12", "roots": [] }),
)
.await;
assert_eq!(r.status, StatusCode::CONFLICT);
// Weak password / bad name → 400.
let r = admin
.post_json(
"/api/admin/users",
&json!({ "name": "carol", "password": "short", "roots": [] }),
)
.await;
assert_eq!(r.status, StatusCode::BAD_REQUEST);
let r = admin
.post_json(
"/api/admin/users",
&json!({ "name": " ", "password": "longenough1", "roots": [] }),
)
.await;
assert_eq!(r.status, StatusCode::BAD_REQUEST);
// Bad root paths → 400.
for bad in ["no-such-dir", "../escape", "config.json"] {
let r = admin
.post_json(
"/api/admin/users",
&json!({ "name": "dave", "password": "longenough1", "roots": [{ "path": bad, "mode": "rw" }] }),
)
.await;
assert_eq!(r.status, StatusCode::BAD_REQUEST, "root '{bad}' should be rejected");
}
// Bad mode → 400.
let r = admin
.post_json(
"/api/admin/users",
&json!({ "name": "dave", "password": "longenough1", "roots": [{ "path": "docs", "mode": "both" }] }),
)
.await;
assert_eq!(r.status, StatusCode::BAD_REQUEST);
// Update: password reset.
let r = admin
.put_json(&format!("/api/admin/users/{bob_id}"), &json!({ "password": "newpass123" }))
.await;
assert_eq!(r.status, StatusCode::OK);
assert!(login(&env, "bob", "newpass123").await.get("/api/auth/me").await.status == StatusCode::OK);
let anon = Client::new(env.app.clone());
assert_eq!(
anon.post_json("/api/auth/login", &json!({ "name": "bob", "password": "bobpass123" }))
.await
.status,
StatusCode::UNAUTHORIZED
);
// Update: replace roots.
let r = admin
.put_json(
&format!("/api/admin/users/{bob_id}"),
&json!({ "roots": [{ "path": "src", "mode": "rw" }] }),
)
.await;
assert_eq!(r.status, StatusCode::OK);
let updated = r.json();
let roots = updated["roots"].as_array().unwrap();
assert_eq!(roots.len(), 1);
assert_eq!(roots[0]["path"], "src");
// Update: make admin, then demote.
let r = admin
.put_json(&format!("/api/admin/users/{bob_id}"), &json!({ "is_admin": true }))
.await;
assert_eq!(r.json()["is_admin"], true);
// bob can now use the admin API.
let bob = login(&env, "bob", "newpass123").await;
assert_eq!(bob.get("/api/admin/users").await.status, StatusCode::OK);
let r = admin
.put_json(&format!("/api/admin/users/{bob_id}"), &json!({ "is_admin": false }))
.await;
assert_eq!(r.json()["is_admin"], false);
// Update: disable → re-enable.
let r = admin
.put_json(&format!("/api/admin/users/{bob_id}"), &json!({ "active": false }))
.await;
assert_eq!(r.json()["active"], false);
let anon = Client::new(env.app.clone());
assert_eq!(
anon.post_json("/api/auth/login", &json!({ "name": "bob", "password": "newpass123" }))
.await
.status,
StatusCode::UNAUTHORIZED
);
let r = admin
.put_json(&format!("/api/admin/users/{bob_id}"), &json!({ "active": true }))
.await;
assert_eq!(r.json()["active"], true);
// Delete.
let r = admin.delete(&format!("/api/admin/users/{bob_id}")).await;
assert_eq!(r.status, StatusCode::OK);
let r = admin.get("/api/admin/users").await;
let names: Vec<String> = r
.json()
.as_array()
.unwrap()
.iter()
.map(|u| u["name"].as_str().unwrap().to_string())
.collect();
assert_eq!(names, vec!["admin"]);
assert_eq!(
admin.delete(&format!("/api/admin/users/{bob_id}")).await.status,
StatusCode::NOT_FOUND
);
}
#[tokio::test]
async fn lockout_guards() {
let env = Env::new().await;
let admin = env.admin().await;
let me = admin.get("/api/auth/me").await;
let my_id = me.json()["user"]["id"].as_i64().unwrap();
// Cannot demote yourself.
let r = admin
.put_json(&format!("/api/admin/users/{my_id}"), &json!({ "is_admin": false }))
.await;
assert_eq!(r.status, StatusCode::BAD_REQUEST);
// Cannot disable yourself.
let r = admin
.put_json(&format!("/api/admin/users/{my_id}"), &json!({ "active": false }))
.await;
assert_eq!(r.status, StatusCode::BAD_REQUEST);
// Cannot delete yourself.
let r = admin.delete(&format!("/api/admin/users/{my_id}")).await;
assert_eq!(r.status, StatusCode::BAD_REQUEST);
// You're still fine.
assert_eq!(admin.get("/api/auth/me").await.status, StatusCode::OK);
// Promote a second admin, so the first is no longer the "last" admin.
create_user(&admin, "eve", "evepass123", &[("docs", "rw")]).await;
let eve_id = user_id(&admin, "eve").await;
let r = admin
.put_json(&format!("/api/admin/users/{eve_id}"), &json!({ "is_admin": true }))
.await;
assert_eq!(r.status, StatusCode::OK);
// Now demoting eve is allowed (two active admins exist).
let r = admin
.put_json(&format!("/api/admin/users/{eve_id}"), &json!({ "is_admin": false }))
.await;
assert_eq!(r.status, StatusCode::OK);
assert_eq!(r.json()["is_admin"], false);
// But demoting the (now only) remaining admin via another account is
// blocked: promote eve again, demote admin would need admin to act on
// themselves — already covered. Instead: with exactly one active admin
// left (admin), demoting eve (not an admin anymore) is fine, and the
// last-admin guard still fires for admins. Re-promote eve and confirm
// the guard message when count == 1 after disabling admin — but admin
// can't disable self. So: two admins, disable one (allowed), then the
// remaining one is the last → deleting the *disabled* one is allowed.
let r = admin
.put_json(&format!("/api/admin/users/{eve_id}"), &json!({ "is_admin": true, "active": false }))
.await;
// eve was an admin but is now disabled → not counted. Deleting her:
// target.active == false → the guard (which requires active) does not
// fire → allowed.
assert_eq!(r.status, StatusCode::OK);
let r = admin.delete(&format!("/api/admin/users/{eve_id}")).await;
assert_eq!(r.status, StatusCode::OK);
// admin still works.
assert_eq!(admin.get("/api/auth/me").await.status, StatusCode::OK);
}
#[tokio::test]
async fn settings_round_trip_visible_in_me() {
let env = Env::new().await;
let admin = env.admin().await;
let r = admin.get("/api/admin/settings").await;
assert_eq!(r.status, StatusCode::OK);
assert_eq!(r.json()["allow_writable_shares"], false);
let r = admin
.put_json("/api/admin/settings", &json!({ "allow_writable_shares": true }))
.await;
assert_eq!(r.json()["allow_writable_shares"], true);
// Reflected in /me for clients.
assert_eq!(
admin.get("/api/auth/me").await.json()["allow_writable_shares"],
true
);
let r = admin
.put_json("/api/admin/settings", &json!({ "allow_writable_shares": false }))
.await;
assert_eq!(r.json()["allow_writable_shares"], false);
}
#[tokio::test]
async fn update_unknown_user_is_404() {
let env = Env::new().await;
let admin = env.admin().await;
let r = admin.put_json("/api/admin/users/9999", &json!({ "active": true })).await;
assert_eq!(r.status, StatusCode::NOT_FOUND);
}
▾Aserver/tests/api_auth.rs
@@ -0,0 +1,167 @@
//! Authentication: first-boot setup, login, session, logout, disabling.
mod common;
use axum::http::StatusCode;
use common::*;
use serde_json::json;
#[tokio::test]
async fn first_boot_me_reports_first_boot() {
let env = Env::new().await;
let c = Client::new(env.app.clone());
let r = c.get("/api/auth/me").await;
assert_eq!(r.status, StatusCode::OK);
let j = r.json();
assert_eq!(j["first_boot"], true);
assert!(j["user"].is_null());
assert_eq!(j["roots"], json!([]));
}
#[tokio::test]
async fn setup_validates_input() {
let env = Env::new().await;
let c = Client::new(env.app.clone());
// Blank name.
let r = c
.post_json("/api/auth/setup", &json!({ "name": " ", "password": "longenough1" }))
.await;
assert_eq!(r.status, StatusCode::BAD_REQUEST);
// Name too long (65 chars).
let r = c
.post_json(
"/api/auth/setup",
&json!({ "name": "a".repeat(65), "password": "longenough1" }),
)
.await;
assert_eq!(r.status, StatusCode::BAD_REQUEST);
// Password too short.
let r = c
.post_json("/api/auth/setup", &json!({ "name": "admin", "password": "short" }))
.await;
assert_eq!(r.status, StatusCode::BAD_REQUEST);
// Nothing was created.
assert_eq!(c.get("/api/auth/me").await.json()["first_boot"], true);
}
#[tokio::test]
async fn setup_creates_admin_and_sets_cookie() {
let env = Env::new().await;
let admin = env.admin().await;
// /me reports the admin with the whole root (rw).
let r = admin.get("/api/auth/me").await;
assert_eq!(r.status, StatusCode::OK);
let j = r.json();
assert_eq!(j["first_boot"], false);
assert_eq!(j["user"]["name"], "admin");
assert_eq!(j["user"]["is_admin"], true);
let roots = j["roots"].as_array().unwrap();
assert_eq!(roots.len(), 1);
assert_eq!(roots[0]["path"], ".");
assert_eq!(roots[0]["mode"], "rw");
// Setup is only available on first boot.
let anon = Client::new(env.app.clone());
let r = anon
.post_json("/api/auth/setup", &json!({ "name": "x", "password": "longenough1" }))
.await;
assert_eq!(r.status, StatusCode::CONFLICT);
}
#[tokio::test]
async fn login_flows() {
let env = Env::new().await;
let _admin = env.admin().await;
let c = Client::new(env.app.clone());
let r = c
.post_json("/api/auth/login", &json!({ "name": "admin", "password": "wrongpass1" }))
.await;
assert_eq!(r.status, StatusCode::UNAUTHORIZED);
let r = c
.post_json("/api/auth/login", &json!({ "name": "ghost", "password": "whatever1" }))
.await;
assert_eq!(r.status, StatusCode::UNAUTHORIZED);
let r = c
.post_json("/api/auth/login", &json!({ "name": "admin", "password": "admin1234" }))
.await;
assert_eq!(r.status, StatusCode::OK);
assert!(session_cookie(&r).is_some());
}
#[tokio::test]
async fn me_requires_valid_session() {
let env = Env::new().await;
let _admin = env.admin().await;
// No cookie at all.
let anon = Client::new(env.app.clone());
assert_eq!(
anon.get("/api/auth/me").await.status,
StatusCode::UNAUTHORIZED
);
// Bogus session token.
let mut bogus = Client::new(env.app.clone());
bogus.set_cookie("not-a-real-token");
assert_eq!(
bogus.get("/api/auth/me").await.status,
StatusCode::UNAUTHORIZED
);
}
#[tokio::test]
async fn logout_invalidates_session() {
let env = Env::new().await;
let admin = env.admin().await;
let r = admin.post_json("/api/auth/logout", &json!({})).await;
assert_eq!(r.status, StatusCode::OK);
// The Set-Cookie header clears the cookie.
assert!(r
.header("set-cookie")
.unwrap()
.starts_with("fbng_session=;"));
// The old cookie no longer authenticates.
assert_eq!(
admin.get("/api/auth/me").await.status,
StatusCode::UNAUTHORIZED
);
}
#[tokio::test]
async fn disabled_user_loses_session_and_cannot_login() {
let env = Env::new().await;
let admin = env.admin().await;
create_user(&admin, "bob", "bobpass123", &[("docs", "rw")]).await;
let bob = login(&env, "bob", "bobpass123").await;
assert_eq!(
bob.get("/api/auth/me").await.status,
StatusCode::OK
);
// Admin disables bob.
let id = user_id(&admin, "bob").await;
let r = admin
.put_json(&format!("/api/admin/users/{id}"), &json!({ "active": false }))
.await;
assert_eq!(r.status, StatusCode::OK);
// bob's live session is rejected…
assert_eq!(
bob.get("/api/auth/me").await.status,
StatusCode::UNAUTHORIZED
);
// …and he cannot log in again.
let anon = Client::new(env.app.clone());
let r = anon
.post_json(
"/api/auth/login",
&json!({ "name": "bob", "password": "bobpass123" }),
)
.await;
assert_eq!(r.status, StatusCode::UNAUTHORIZED);
}
▾Aserver/tests/api_files.rs
@@ -0,0 +1,611 @@
//! File API: listing, download/preview/content, editor save, mutations,
//! upload, access control and path-safety.
mod common;
use axum::http::StatusCode;
use common::*;
use serde_json::json;
/// Root id for the whole-root (".") user root is 1 (first row inserted).
const ROOT: i64 = 1;
fn root_path(rel: &str) -> String {
// No trailing slash for the bare root: axum's routes are
// `/api/files/{root_id}` and `/api/files/{root_id}/{*path}`.
if rel.is_empty() {
format!("/api/files/{ROOT}")
} else {
format!("/api/files/{ROOT}/{rel}")
}
}
#[tokio::test]
async fn list_root_sorted_folders_first() {
let env = Env::new().await;
let admin = env.admin().await;
let r = admin.get(&root_path("")).await;
assert_eq!(r.status, StatusCode::OK);
let j = r.json();
let entries = j["entries"].as_array().unwrap();
let names: Vec<&str> = entries.iter().map(|e| e["name"].as_str().unwrap()).collect();
assert_eq!(
names,
vec!["docs", "src", "blob.bin", "config.json", "editme.txt", "notes.md"]
);
// Entry fields.
let docs = &entries[0];
assert_eq!(docs["is_dir"], true);
let editme = entries.iter().find(|e| e["name"] == "editme.txt").unwrap();
assert_eq!(editme["is_dir"], false);
assert_eq!(editme["size"], 2);
assert!(editme["mtime"].as_str().unwrap().ends_with('Z'));
}
#[tokio::test]
async fn list_subdir_and_errors() {
let env = Env::new().await;
let admin = env.admin().await;
let r = admin.get(&root_path("docs")).await;
let j = r.json();
let names: Vec<&str> = j
.get("entries")
.unwrap()
.as_array()
.unwrap()
.iter()
.map(|e| e["name"].as_str().unwrap())
.collect();
assert_eq!(names, vec!["inner", "a.txt"]);
// Missing path → 404.
assert_eq!(
admin.get(&root_path("nope")).await.status,
StatusCode::NOT_FOUND
);
// Listing a file → 400.
assert_eq!(
admin.get(&root_path("editme.txt")).await.status,
StatusCode::BAD_REQUEST
);
// Unknown root id → 403.
assert_eq!(
admin.get("/api/files/999").await.status,
StatusCode::FORBIDDEN
);
// No session → 401.
let anon = Client::new(env.app.clone());
assert_eq!(
anon.get(&root_path("")).await.status,
StatusCode::UNAUTHORIZED
);
}
#[tokio::test]
async fn path_traversal_is_blocked() {
let env = Env::new().await;
let admin = env.admin().await;
// Encoded `..` segments reach the handler and are rejected.
let r = admin.get("/api/files/1/%2e%2e%2f%2e%2e%2fetc").await;
assert!(
r.status == StatusCode::FORBIDDEN || r.status == StatusCode::NOT_FOUND,
"traversal returned {:?}",
r.status
);
// Literal `..` segments: must never succeed.
let r = admin.get("/api/files/1/../../etc").await;
assert_ne!(r.status, StatusCode::OK, "literal traversal must not be served");
// Traversal inside a deeper path.
let r = admin.get("/api/files/1/docs/..%2f..%2fsrc").await;
assert!(
r.status == StatusCode::FORBIDDEN || r.status == StatusCode::NOT_FOUND,
"deep traversal returned {:?}",
r.status
);
}
#[tokio::test]
async fn download_single_file() {
let env = Env::new().await;
let admin = env.admin().await;
let r = admin.get(&format!("{}?action=download", root_path("editme.txt"))).await;
assert_eq!(r.status, StatusCode::OK);
assert_eq!(r.header("content-disposition").as_deref(), Some("attachment; filename=\"editme.txt\""));
assert_eq!(r.header("content-type").as_deref(), Some("text/plain"));
assert_eq!(r.body, b"v1");
// Binary content survives.
let r = admin.get(&format!("{}?action=download", root_path("blob.bin"))).await;
assert_eq!(r.body, (0..64u8).collect::<Vec<_>>());
}
#[tokio::test]
async fn download_folder_as_all_archive_formats() {
let env = Env::new().await;
let admin = env.admin().await;
let path = format!("{}?action=download", root_path("docs"));
let r = admin.get(&format!("{path}&format=zip")).await;
assert_eq!(r.status, StatusCode::OK);
assert_eq!(
r.header("content-type").as_deref(),
Some("application/zip")
);
assert_eq!(
r.header("content-disposition").as_deref(),
Some("attachment; filename=\"docs.zip\"")
);
let map = zip_map(&r.body);
assert_eq!(map.get("docs/a.txt").unwrap(), b"file a");
assert_eq!(map.get("docs/inner/hello.txt").unwrap(), b"hello world");
let r = admin.get(&format!("{path}&format=tar")).await;
assert_eq!(r.header("content-type").as_deref(), Some("application/x-tar"));
assert_eq!(r.header("content-disposition").as_deref(), Some("attachment; filename=\"docs.tar\""));
let map = tar_map(&r.body, Compress::None);
assert_eq!(map.get("docs/a.txt").unwrap(), b"file a");
assert_eq!(map.get("docs/inner/hello.txt").unwrap(), b"hello world");
let r = admin.get(&format!("{path}&format=tar.gz")).await;
assert_eq!(r.header("content-type").as_deref(), Some("application/gzip"));
assert_eq!(
r.header("content-disposition").as_deref(),
Some("attachment; filename=\"docs.tar.gz\"")
);
let map = tar_map(&r.body, Compress::Gz);
assert_eq!(map.get("docs/inner/hello.txt").unwrap(), b"hello world");
let r = admin.get(&format!("{path}&format=tar.zst")).await;
assert_eq!(r.header("content-type").as_deref(), Some("application/zstd"));
assert_eq!(
r.header("content-disposition").as_deref(),
Some("attachment; filename=\"docs.tar.zst\"")
);
let map = tar_map(&r.body, Compress::Zst);
assert_eq!(map.get("docs/a.txt").unwrap(), b"file a");
}
#[tokio::test]
async fn download_folder_requires_valid_format() {
let env = Env::new().await;
let admin = env.admin().await;
let path = format!("{}?action=download", root_path("docs"));
// No format → 400.
assert_eq!(admin.get(&path).await.status, StatusCode::BAD_REQUEST);
// Unknown format → 400.
assert_eq!(
admin.get(&format!("{path}&format=rar")).await.status,
StatusCode::BAD_REQUEST
);
// Downloading a file with a format is fine (format ignored).
let r = admin
.get(&format!("{}?action=download&format=zip", root_path("editme.txt")))
.await;
assert_eq!(r.status, StatusCode::OK);
assert_eq!(r.body, b"v1");
}
#[tokio::test]
async fn preview_serves_inline_and_rejects_dirs() {
let env = Env::new().await;
let admin = env.admin().await;
let r = admin.get(&format!("{}?action=preview", root_path("config.json"))).await;
assert_eq!(r.status, StatusCode::OK);
assert!(r
.header("content-disposition")
.unwrap()
.starts_with("inline;"));
assert_eq!(r.body, b"{\"k\": 1}");
assert_eq!(
admin.get(&format!("{}?action=preview", root_path("docs"))).await.status,
StatusCode::BAD_REQUEST
);
}
#[tokio::test]
async fn content_action_serves_raw_bytes_with_mtime() {
let env = Env::new().await;
let admin = env.admin().await;
let r = admin.get(&format!("{}?action=content", root_path("notes.md"))).await;
assert_eq!(r.status, StatusCode::OK);
assert_eq!(r.header("content-type").as_deref(), Some("text/plain; charset=utf-8"));
let mtime = r.header("x-file-mtime").unwrap();
assert!(mtime.parse::<i64>().is_ok());
assert_eq!(r.body, b"# notes");
assert_eq!(
admin.get(&format!("{}?action=content", root_path("docs"))).await.status,
StatusCode::BAD_REQUEST
);
}
#[tokio::test]
async fn content_is_capped_at_two_mibibytes() {
let env = Env::new().await;
let admin = env.admin().await;
let big = vec![b'x'; 2 * 1024 * 1024 + 1];
std::fs::write(env.file("big.bin"), &big).unwrap();
let r = admin.get(&format!("{}?action=content", root_path("big.bin"))).await;
assert_eq!(r.status, StatusCode::PAYLOAD_TOO_LARGE);
// The file itself still downloads fine.
let r = admin.get(&format!("{}?action=download", root_path("big.bin"))).await;
assert_eq!(r.status, StatusCode::OK);
assert_eq!(r.body.len(), big.len());
}
#[tokio::test]
async fn editor_save_round_trip_and_conflict() {
let env = Env::new().await;
let admin = env.admin().await;
let path = format!("{}?action=content", root_path("editme.txt"));
// Read current mtime via the content endpoint.
let r = admin.get(&path).await;
assert_eq!(r.status, StatusCode::OK);
let mtime: i64 = r.header("x-file-mtime").unwrap().parse().unwrap();
// Save with a matching expected mtime.
let r = admin.put_content(&path, b"v2", Some(mtime)).await;
assert_eq!(r.status, StatusCode::OK);
let new_mtime = r.json()["mtime"].as_i64().unwrap();
assert!(new_mtime >= mtime);
assert_eq!(std::fs::read(env.file("editme.txt")).unwrap(), b"v2");
// A stale/wrong expected mtime conflicts (409). Use a value far from the
// current mtime so this is deterministic regardless of the filesystem's
// timestamp granularity (the mtime may not have advanced after the save).
let r = admin.put_content(&path, b"v3", Some(mtime + 999_999)).await;
assert_eq!(r.status, StatusCode::CONFLICT);
// A conflict must not modify the file.
assert_eq!(std::fs::read(env.file("editme.txt")).unwrap(), b"v2");
// No expected mtime → force save.
let r = admin.put_content(&path, b"v4", None).await;
assert_eq!(r.status, StatusCode::OK);
assert_eq!(std::fs::read(env.file("editme.txt")).unwrap(), b"v4");
// Saving a missing file → 404; a directory → 400.
// (PUT without action=content → 400.)
let r = admin
.raw(
axum::http::Method::PUT,
&root_path("editme.txt"),
&[("content-type", "text/plain")],
b"x".to_vec(),
)
.await;
assert_eq!(r.status, StatusCode::BAD_REQUEST);
let r = admin
.put_content(&format!("{}?action=content", root_path("ghost.txt")), b"x", None)
.await;
assert_eq!(r.status, StatusCode::NOT_FOUND);
let r = admin
.put_content(&format!("{}?action=content", root_path("docs")), b"x", None)
.await;
assert_eq!(r.status, StatusCode::BAD_REQUEST);
// Oversized body → 413.
let r = admin
.put_content(&path, &vec![b'a'; 2 * 1024 * 1024 + 1], None)
.await;
assert_eq!(r.status, StatusCode::PAYLOAD_TOO_LARGE);
}
#[tokio::test]
async fn mkdir_and_rename() {
let env = Env::new().await;
let admin = env.admin().await;
// mkdir (no content-type → mkdir dispatch).
let r = admin.raw(axum::http::Method::POST, &root_path("newdir"), &[], Vec::new()).await;
assert_eq!(r.status, StatusCode::OK);
assert!(env.file("newdir").is_dir());
// Duplicate → 409.
let r = admin.raw(axum::http::Method::POST, &root_path("newdir"), &[], Vec::new()).await;
assert_eq!(r.status, StatusCode::CONFLICT);
// Empty name → 400 (bare root POST with JSON op is rejected too).
let r = admin
.raw(axum::http::Method::POST, &root_path(""), &[], Vec::new())
.await;
assert_eq!(r.status, StatusCode::BAD_REQUEST);
// Rename.
let r = admin
.post_json(
&root_path("editme.txt"),
&json!({ "op": "rename", "new_name": "renamed.txt" }),
)
.await;
assert_eq!(r.status, StatusCode::OK);
assert!(env.file("renamed.txt").exists());
// Conflict.
let r = admin
.post_json(
&root_path("renamed.txt"),
&json!({ "op": "rename", "new_name": "config.json" }),
)
.await;
assert_eq!(r.status, StatusCode::CONFLICT);
// With overwrite.
let r = admin
.post_json(
&root_path("renamed.txt"),
&json!({ "op": "rename", "new_name": "config.json", "overwrite": true }),
)
.await;
assert_eq!(r.status, StatusCode::OK);
assert_eq!(std::fs::read(env.file("config.json")).unwrap(), b"v1");
// Invalid name.
let r = admin
.post_json(
&root_path("notes.md"),
&json!({ "op": "rename", "new_name": "a/b" }),
)
.await;
assert_eq!(r.status, StatusCode::BAD_REQUEST);
// Missing source.
let r = admin
.post_json(&root_path("ghost"), &json!({ "op": "rename", "new_name": "x" }))
.await;
assert_eq!(r.status, StatusCode::NOT_FOUND);
// Unknown op.
let r = admin
.post_json(&root_path("notes.md"), &json!({ "op": "explode" }))
.await;
assert_eq!(r.status, StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn move_and_copy_across_dirs() {
let env = Env::new().await;
let admin = env.admin().await;
// Move notes.md into docs/.
let r = admin
.post_json(
&root_path("notes.md"),
&json!({ "op": "move", "dst_root_id": ROOT, "dst": "docs" }),
)
.await;
assert_eq!(r.status, StatusCode::OK);
assert!(!env.file("notes.md").exists());
assert_eq!(std::fs::read(env.file("docs/notes.md")).unwrap(), b"# notes");
// Copy docs/inner back out — as a folder.
let r = admin
.post_json(
&root_path("docs/inner"),
&json!({ "op": "copy", "dst_root_id": ROOT, "dst": "src" }),
)
.await;
assert_eq!(r.status, StatusCode::OK);
assert_eq!(std::fs::read(env.file("src/inner/hello.txt")).unwrap(), b"hello world");
assert!(env.file("docs/inner/hello.txt").exists());
// Conflict without overwrite, ok with: copy into a folder that already
// holds a file with the same name.
let r = admin
.post_json(
&root_path("docs/a.txt"),
&json!({ "op": "copy", "dst_root_id": ROOT, "dst": "src" }),
)
.await;
assert_eq!(r.status, StatusCode::OK);
assert_eq!(std::fs::read(env.file("src/a.txt")).unwrap(), b"file a");
std::fs::write(env.file("docs/a.txt"), "file a2").unwrap();
let r = admin
.post_json(
&root_path("docs/a.txt"),
&json!({ "op": "copy", "dst_root_id": ROOT, "dst": "src" }),
)
.await;
assert_eq!(r.status, StatusCode::CONFLICT);
let r = admin
.post_json(
&root_path("docs/a.txt"),
&json!({ "op": "copy", "dst_root_id": ROOT, "dst": "src", "overwrite": true }),
)
.await;
assert_eq!(r.status, StatusCode::OK);
assert_eq!(std::fs::read(env.file("src/a.txt")).unwrap(), b"file a2");
// Copying an item into its own folder (same path) is a no-op success.
let r = admin
.post_json(
&root_path("docs/a.txt"),
&json!({ "op": "copy", "dst_root_id": ROOT, "dst": "docs" }),
)
.await;
assert_eq!(r.status, StatusCode::OK);
// Moving a folder into itself → 400.
let r = admin
.post_json(
&root_path("docs"),
&json!({ "op": "move", "dst_root_id": ROOT, "dst": "docs" }),
)
.await;
assert_eq!(r.status, StatusCode::BAD_REQUEST);
// Missing dst_root_id / dst dir.
let r = admin
.post_json(&root_path("docs/a.txt"), &json!({ "op": "move" }))
.await;
assert_eq!(r.status, StatusCode::BAD_REQUEST);
let r = admin
.post_json(
&root_path("docs/a.txt"),
&json!({ "op": "move", "dst_root_id": ROOT, "dst": "no-such-dir" }),
)
.await;
assert_eq!(r.status, StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn delete_file_and_folder() {
let env = Env::new().await;
let admin = env.admin().await;
let r = admin.delete(&root_path("editme.txt")).await;
assert_eq!(r.status, StatusCode::OK);
assert_eq!(r.json()["is_dir"], false);
assert!(!env.file("editme.txt").exists());
let r = admin.delete(&root_path("docs")).await;
assert_eq!(r.json()["is_dir"], true);
assert!(!env.file("docs").exists());
// Missing → 404. A DELETE on the bare root matches no route's method →
// 405 (the path only has GET/POST routes).
assert_eq!(
admin.delete(&root_path("ghost")).await.status,
StatusCode::NOT_FOUND
);
assert_eq!(
admin.delete("/api/files/1").await.status,
StatusCode::METHOD_NOT_ALLOWED
);
// DELETE with a trailing-slash root matches no route at all → 404 via
// the SPA fallback's API guard.
let r = admin.delete("/api/files/1/").await;
assert_eq!(r.status, StatusCode::NOT_FOUND);
assert_eq!(r.text(), "unknown endpoint");
}
#[tokio::test]
async fn upload_creates_files_and_folders() {
let env = Env::new().await;
let admin = env.admin().await;
// Single file into the root, nested part name creates the folder.
let r = admin
.post_multipart(&root_path(""), &[("docs/uploaded.txt", b"up1"), ("new/nested.txt", b"up2")], "")
.await;
assert_eq!(r.status, StatusCode::OK);
assert_eq!(r.json()["uploaded"], 2);
assert_eq!(std::fs::read(env.file("docs/uploaded.txt")).unwrap(), b"up1");
assert_eq!(std::fs::read(env.file("new/nested.txt")).unwrap(), b"up2");
// Conflict: existing file, no overwrite → 409 with the skipped list.
let r = admin
.post_multipart(&root_path(""), &[("docs/uploaded.txt", b"again")], "")
.await;
assert_eq!(r.status, StatusCode::CONFLICT);
assert_eq!(r.json()["skipped"], json!(["docs/uploaded.txt"]));
assert_eq!(std::fs::read(env.file("docs/uploaded.txt")).unwrap(), b"up1");
// Mixed: one conflict + one new file → 409, the new one is uploaded.
let r = admin
.post_multipart(
&root_path(""),
&[("docs/uploaded.txt", b"again"), ("fresh.txt", b"new")],
"",
)
.await;
assert_eq!(r.status, StatusCode::CONFLICT);
assert_eq!(r.json()["skipped"], json!(["docs/uploaded.txt"]));
assert_eq!(r.json()["uploaded"], 1);
assert_eq!(std::fs::read(env.file("fresh.txt")).unwrap(), b"new");
// overwrite=true replaces.
let r = admin
.post_multipart(&root_path(""), &[("docs/uploaded.txt", b"v3")], "overwrite=true")
.await;
assert_eq!(r.status, StatusCode::OK);
assert_eq!(std::fs::read(env.file("docs/uploaded.txt")).unwrap(), b"v3");
// A part name that is an existing directory → 409.
let r = admin
.post_multipart(&root_path(""), &[("new", b"dir?")], "")
.await;
assert_eq!(r.status, StatusCode::CONFLICT);
// Path traversal in a part name → 400.
let r = admin
.post_multipart(&root_path(""), &[("../evil.txt", b"x")], "")
.await;
assert!(matches!(
r.status,
StatusCode::BAD_REQUEST | StatusCode::FORBIDDEN
));
assert!(!env.file("../evil.txt").exists());
assert!(!env.root.path().parent().unwrap().join("evil.txt").exists());
// No parts at all → 400.
let (ct, body) = multipart_body(&[], "b");
let r = admin
.raw(axum::http::Method::POST, &root_path(""), &[("content-type", &ct)], body)
.await;
assert_eq!(r.status, StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn read_only_root_blocks_writes_but_allows_reads() {
let env = Env::new().await;
let admin = env.admin().await;
create_user(&admin, "carol", "carolpass1", &[("docs", "ro")]).await;
let carol = login(&env, "carol", "carolpass1").await;
let carol_root_id = carol.get("/api/auth/me").await.json()["roots"][0]["id"]
.as_i64()
.unwrap();
// Reads work.
let r = carol.get(&format!("/api/files/{carol_root_id}")).await;
assert_eq!(r.status, StatusCode::OK);
assert!(!r.json()["entries"].as_array().unwrap().is_empty());
let r = carol
.get(&format!(
"/api/files/{carol_root_id}/a.txt?action=download"
))
.await;
assert_eq!(r.body, b"file a");
// Writes are blocked.
let base = format!("/api/files/{carol_root_id}/x");
assert_eq!(
carol.raw(axum::http::Method::POST, &base, &[], Vec::new()).await.status,
StatusCode::FORBIDDEN
);
assert_eq!(
carol.delete(&format!("/api/files/{carol_root_id}/a.txt")).await.status,
StatusCode::FORBIDDEN
);
assert_eq!(
carol
.post_json(
&format!("/api/files/{carol_root_id}/a.txt"),
&json!({ "op": "rename", "new_name": "b.txt" })
)
.await
.status,
StatusCode::FORBIDDEN
);
}
#[tokio::test]
async fn user_cannot_touch_foreign_root() {
let env = Env::new().await;
let admin = env.admin().await;
create_user(&admin, "dave", "davepass12", &[("src", "rw")]).await;
let dave = login(&env, "dave", "davepass12").await;
let dave_root_id = dave.get("/api/auth/me").await.json()["roots"][0]["id"]
.as_i64()
.unwrap();
// His own root works.
assert_eq!(
dave.get(&format!("/api/files/{dave_root_id}")).await.status,
StatusCode::OK
);
// The admin's root id (1) is not his → 403.
assert_eq!(dave.get("/api/files/1").await.status, StatusCode::FORBIDDEN);
// Writing into a root he doesn't have → 403.
assert_eq!(
dave
.raw(axum::http::Method::POST, "/api/files/1/evil", &[], Vec::new())
.await
.status,
StatusCode::FORBIDDEN
);
}
▾Aserver/tests/api_shares.rs
@@ -0,0 +1,332 @@
//! Token-based shares: creation (incl. writable gating), public resolve,
//! anonymous access via `?share=`, expiry, scoping, deletion.
mod common;
use axum::http::StatusCode;
use common::*;
use serde_json::json;
/// Create a share via the admin (who has the whole root).
async fn share(admin: &Client, path: &str, writable: bool, expires_at: Option<&str>) -> serde_json::Value {
let r = admin
.post_json(
"/api/shares",
&json!({
"root_id": 1,
"path": path,
"writable": writable,
"expires_at": expires_at,
}),
)
.await;
assert_eq!(r.status, StatusCode::OK, "create share: {}", r.text());
r.json()
}
#[tokio::test]
async fn create_list_and_delete_shares() {
let env = Env::new().await;
let admin = env.admin().await;
let s = share(&admin, "docs", false, None).await;
assert_eq!(s["is_file"], false);
assert_eq!(s["writable"], false);
assert_eq!(s["target"], "docs");
let token = s["token"].as_str().unwrap();
assert_eq!(token.len(), 32);
let root_id = s["root_id"].as_i64().unwrap();
// Shows up in the list.
let r = admin.get("/api/shares").await;
let j = r.json();
let list = j.as_array().unwrap();
assert_eq!(list.len(), 1);
assert_eq!(list[0]["id"], s["id"]);
// Delete by id works for the owner.
let id = s["id"].as_i64().unwrap();
let r = admin.delete(&format!("/api/shares/{id}")).await;
assert_eq!(r.status, StatusCode::OK);
// The token is now dead.
let r = admin.get(&format!("/api/share/{token}")).await;
assert_eq!(r.status, StatusCode::NOT_FOUND);
// Anonymous access dies too.
let anon = Client::new(env.app.clone());
let r = anon.get(&format!("/api/files/{root_id}?share={token}")).await;
assert_eq!(r.status, StatusCode::NOT_FOUND);
// Deleting an unknown id → 404.
let r = admin.delete("/api/shares/99999").await;
assert_eq!(r.status, StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn share_of_a_single_file() {
let env = Env::new().await;
let admin = env.admin().await;
let s = share(&admin, "notes.md", false, None).await;
assert_eq!(s["is_file"], true);
let token = s["token"].as_str().unwrap();
let root_id = s["root_id"].as_i64().unwrap();
// Anonymous: the share root *is* the file — download it without any
// session cookie.
let anon = Client::new(env.app.clone());
let r = anon
.get(&format!("/api/files/{root_id}?share={token}&action=download"))
.await;
assert_eq!(r.status, StatusCode::OK);
assert_eq!(r.body, b"# notes");
// Preview and content work too.
let r = anon.get(&format!("/api/files/{root_id}?share={token}&action=content")).await;
assert_eq!(r.body, b"# notes");
assert!(r.header("x-file-mtime").is_some());
// A path *below* the file share doesn't exist.
let r = anon
.get(&format!("/api/files/{root_id}/subdir?share={token}"))
.await;
assert!(
matches!(r.status, StatusCode::NOT_FOUND | StatusCode::BAD_REQUEST)
);
}
#[tokio::test]
async fn share_folder_browsing_and_download_anonymously() {
let env = Env::new().await;
let admin = env.admin().await;
let s = share(&admin, "docs", false, None).await;
let token = s["token"].as_str().unwrap();
let root_id = s["root_id"].as_i64().unwrap();
let anon = Client::new(env.app.clone());
// List the share root.
let r = anon.get(&format!("/api/files/{root_id}?share={token}")).await;
assert_eq!(r.status, StatusCode::OK);
let j = r.json();
let names: Vec<&str> = j
.get("entries")
.unwrap()
.as_array()
.unwrap()
.iter()
.map(|e| e["name"].as_str().unwrap())
.collect();
assert_eq!(names, vec!["inner", "a.txt"]);
// Navigate into a subfolder via the token.
let r = anon.get(&format!("/api/files/{root_id}/inner?share={token}")).await;
assert_eq!(r.status, StatusCode::OK);
assert_eq!(
r.json()["entries"][0]["name"],
"hello.txt"
);
// Folder download via the token.
let r = anon
.get(&format!(
"/api/files/{root_id}?share={token}&action=download&format=zip"
))
.await;
assert_eq!(r.status, StatusCode::OK);
let map = zip_map(&r.body);
assert_eq!(map.get("docs/a.txt").unwrap(), b"file a");
// The token also works as a header.
let r = anon
.raw(
axum::http::Method::GET,
&format!("/api/files/{root_id}/a.txt?action=download"),
&[("x-share-token", token)],
Vec::new(),
)
.await;
assert_eq!(r.status, StatusCode::OK);
assert_eq!(r.body, b"file a");
}
#[tokio::test]
async fn share_scopes_a_signed_in_user() {
let env = Env::new().await;
let admin = env.admin().await;
// Two shares so the share's synthetic root id (the share row id) is
// *different* from the admin's real root id (1).
let _distractor = share(&admin, "docs", false, None).await;
let s = share(&admin, "docs", false, None).await;
let token = s["token"].as_str().unwrap();
let share_root_id = s["root_id"].as_i64().unwrap();
assert_ne!(share_root_id, 1);
// A signed-in user who opens the share link sees the share scope.
let r = admin
.get(&format!("/api/files/{share_root_id}?share={token}"))
.await;
assert_eq!(r.status, StatusCode::OK);
// Without the token the same session sees their own roots.
let r = admin.get("/api/files/1").await;
assert_eq!(r.status, StatusCode::OK);
// …but through the share scope their own root id (1) is unreachable.
let r = admin
.get(&format!("/api/files/1?share={token}"))
.await;
assert_eq!(r.status, StatusCode::FORBIDDEN, "share scope hides own roots");
}
#[tokio::test]
async fn read_only_share_blocks_writes_writable_share_allows() {
let env = Env::new().await;
let admin = env.admin().await;
admin
.put_json(
"/api/admin/settings",
&json!({ "allow_writable_shares": true }),
)
.await;
// Writable shares are gated behind the admin setting.
let ro = share(&admin, "docs", false, None).await;
let rw = share(&admin, "src", true, None).await;
assert_eq!(rw["writable"], true);
let anon = Client::new(env.app.clone());
// RO share: anonymous write → 403.
let ro_tok = ro["token"].as_str().unwrap();
let ro_id = ro["root_id"].as_i64().unwrap();
let r = anon
.raw(
axum::http::Method::POST,
&format!("/api/files/{ro_id}/x?share={ro_tok}"),
&[],
Vec::new(),
)
.await;
assert_eq!(r.status, StatusCode::FORBIDDEN);
// RW share: anonymous mkdir works (really on disk).
let rw_tok = rw["token"].as_str().unwrap();
let rw_id = rw["root_id"].as_i64().unwrap();
let r = anon
.raw(
axum::http::Method::POST,
&format!("/api/files/{rw_id}/made-by-share?share={rw_tok}"),
&[],
Vec::new(),
)
.await;
assert_eq!(r.status, StatusCode::OK);
assert!(env.file("src/made-by-share").is_dir());
// RW share: upload works.
let up_path = format!("/api/files/{rw_id}?share={rw_tok}");
let r = anon
.post_multipart(&up_path, &[("uploaded.txt", b"share-up")], "")
.await;
assert_eq!(r.status, StatusCode::OK);
assert_eq!(std::fs::read(env.file("src/uploaded.txt")).unwrap(), b"share-up");
}
#[tokio::test]
async fn writable_shares_gated_by_setting() {
let env = Env::new().await;
let admin = env.admin().await;
// Default: off.
let r = admin
.post_json(
"/api/shares",
&json!({ "root_id": 1, "path": "docs", "writable": true, "expires_at": null }),
)
.await;
assert_eq!(r.status, StatusCode::FORBIDDEN);
// Enable, retry → ok.
let r = admin
.put_json("/api/admin/settings", &json!({ "allow_writable_shares": true }))
.await;
assert_eq!(r.status, StatusCode::OK);
let r = admin
.post_json(
"/api/shares",
&json!({ "root_id": 1, "path": "docs", "writable": true, "expires_at": null }),
)
.await;
assert_eq!(r.status, StatusCode::OK);
assert_eq!(r.json()["writable"], true);
}
#[tokio::test]
async fn share_expiry() {
let env = Env::new().await;
let admin = env.admin().await;
// Past expiry → 410 on resolve and on file access.
let s = share(&admin, "docs", false, Some("2000-01-01T00:00:00Z")).await;
let token = s["token"].as_str().unwrap();
let root_id = s["root_id"].as_i64().unwrap();
let anon = Client::new(env.app.clone());
assert_eq!(
admin.get(&format!("/api/share/{token}")).await.status,
StatusCode::GONE
);
assert_eq!(
anon.get(&format!("/api/files/{root_id}?share={token}")).await.status,
StatusCode::GONE
);
// Far-future expiry → live.
let s = share(&admin, "docs", false, Some("2999-01-01T00:00:00Z")).await;
let token = s["token"].as_str().unwrap();
let root_id = s["root_id"].as_i64().unwrap();
assert_eq!(
admin.get(&format!("/api/share/{token}")).await.status,
StatusCode::OK
);
assert_eq!(
anon.get(&format!("/api/files/{root_id}?share={token}")).await.status,
StatusCode::OK
);
}
#[tokio::test]
async fn share_target_validation() {
let env = Env::new().await;
let admin = env.admin().await;
// Missing target → 404.
let r = admin
.post_json(
"/api/shares",
&json!({ "root_id": 1, "path": "no-such-folder", "writable": false, "expires_at": null }),
)
.await;
assert_eq!(r.status, StatusCode::NOT_FOUND);
// Escaping the root → 403.
let r = admin
.post_json(
"/api/shares",
&json!({ "root_id": 1, "path": "../../etc", "writable": false, "expires_at": null }),
)
.await;
assert_eq!(r.status, StatusCode::FORBIDDEN);
// Foreign root id → 403.
let r = admin
.post_json(
"/api/shares",
&json!({ "root_id": 999, "path": "docs", "writable": false, "expires_at": null }),
)
.await;
assert_eq!(r.status, StatusCode::FORBIDDEN);
// Public resolve of a garbage token → 404.
let r = admin.get("/api/share/doesnotexist1234567890abcdef1234567890abcdef1234567890abcdef").await;
assert_eq!(r.status, StatusCode::NOT_FOUND);
// Share management requires auth.
let anon = Client::new(env.app.clone());
assert_eq!(anon.get("/api/shares").await.status, StatusCode::UNAUTHORIZED);
}
▾Aserver/tests/api_spa.rs
@@ -0,0 +1,62 @@
//! SPA serving: static assets, client-route fallback, API 404s, method
//! checks. Uses $FBNG_DIST (the dev-mode asset directory) via a tempdir so
//! the test is independent of any built frontend.
mod common;
use axum::http::StatusCode;
use common::*;
#[tokio::test]
async fn spa_fallback_and_api_guards() {
let env = Env::new().await;
let c = Client::new(env.app.clone());
// Unknown /api/ endpoints get a plain 404, not the SPA page.
let r = c.get("/api/unknown/endpoint").await;
assert_eq!(r.status, StatusCode::NOT_FOUND);
assert_eq!(r.text(), "unknown endpoint");
// Non-GET to a non-API path → 405.
let r = c
.raw(axum::http::Method::POST, "/some/page", &[], b"x".to_vec())
.await;
assert_eq!(r.status, StatusCode::METHOD_NOT_ALLOWED);
// Point the dev asset dir at a controlled tempdir.
let dist = tempfile::tempdir().unwrap();
std::fs::write(dist.path().join("index.html"), "DIST-INDEX").unwrap();
std::fs::write(dist.path().join("app.css"), "body{}").unwrap();
std::env::set_var("FBNG_DIST", dist.path());
// Root serves index.html.
let r = c.get("/").await;
assert_eq!(r.status, StatusCode::OK);
assert_eq!(r.text(), "DIST-INDEX");
assert_eq!(r.header("content-type").as_deref(), Some("text/html"));
assert_eq!(r.header("cache-control").as_deref(), Some("no-cache"));
// A known asset is served with the right type.
let r = c.get("/app.css").await;
assert_eq!(r.status, StatusCode::OK);
assert_eq!(r.text(), "body{}");
assert_eq!(r.header("content-type").as_deref(), Some("text/css"));
// Unknown paths fall back to index.html (SPA client routes, deep links).
let r = c.get("/some/deep/client/route").await;
assert_eq!(r.status, StatusCode::OK);
assert_eq!(r.text(), "DIST-INDEX");
assert_eq!(r.header("cache-control").as_deref(), Some("no-cache"));
let r = c.get("/s/abc123token/deeper/path").await;
assert_eq!(r.status, StatusCode::OK);
assert_eq!(r.text(), "DIST-INDEX");
// Now with an *empty* dist dir → the friendly "build the frontend" hint.
let empty = tempfile::tempdir().unwrap();
std::env::set_var("FBNG_DIST", empty.path());
let r = c.get("/").await;
assert_eq!(r.status, StatusCode::OK);
assert!(r.text().contains("frontend has not been built"));
std::env::remove_var("FBNG_DIST");
}
▾Aserver/tests/common/mod.rs
@@ -0,0 +1,385 @@
//! Shared integration-test harness: drives the real router with
//! `tower::ServiceExt::oneshot` — no ports, no network, fully parallel.
//
// Every test binary compiles this module privately, so helpers unused by a
// particular binary would warn as dead code.
#![allow(dead_code)]
use std::collections::BTreeMap;
use std::sync::Arc;
use axum::body::Body;
use axum::http::{header, HeaderMap, Method, StatusCode};
use axum::Router;
use http_body_util::BodyExt;
use server::db::Db;
use server::error::AppState;
use tower::ServiceExt;
// ---------------------------------------------------------------------------
// Environment
// ---------------------------------------------------------------------------
/// A server with a small fixture tree:
///
/// ```text
/// root/
/// docs/
/// inner/
/// hello.txt ("hello world")
/// a.txt ("file a")
/// src/
/// main.rs
/// config.json
/// notes.md
/// editme.txt ("v1")
/// blob.bin (64 bytes)
/// ```
pub struct Env {
pub root: tempfile::TempDir,
pub dbdir: tempfile::TempDir,
pub state: Arc<AppState>,
pub app: Router,
}
impl Env {
pub async fn new() -> Self {
let root = tempfile::tempdir().unwrap();
let p = root.path();
std::fs::create_dir_all(p.join("docs/inner")).unwrap();
std::fs::create_dir_all(p.join("src")).unwrap();
std::fs::write(p.join("docs/inner/hello.txt"), "hello world").unwrap();
std::fs::write(p.join("docs/a.txt"), "file a").unwrap();
std::fs::write(p.join("src/main.rs"), "fn main() {}").unwrap();
std::fs::write(p.join("config.json"), "{\"k\": 1}").unwrap();
std::fs::write(p.join("notes.md"), "# notes").unwrap();
std::fs::write(p.join("editme.txt"), "v1").unwrap();
std::fs::write(p.join("blob.bin"), (0..64u8).collect::<Vec<_>>()).unwrap();
let dbdir = tempfile::tempdir().unwrap();
let db = Db::open(&dbdir.path().join("db.sqlite")).await.unwrap();
let state = Arc::new(AppState {
db,
root: p.canonicalize().unwrap(),
https: false,
});
let app = server::api::router(state.clone());
Self {
root,
dbdir,
state,
app,
}
}
/// Absolute path of a fixture file inside the root.
pub fn file(&self, rel: &str) -> std::path::PathBuf {
self.root.path().join(rel)
}
/// Create an admin account (first boot) and return a signed-in client.
pub async fn admin(&self) -> Client {
let c = Client::new(self.app.clone());
let r = c
.post_json(
"/api/auth/setup",
&serde_json::json!({ "name": "admin", "password": "admin1234" }),
)
.await;
assert_eq!(
r.status,
StatusCode::OK,
"setup failed: {}",
r.text()
);
let token = session_cookie(&r).expect("setup must set a session cookie");
let mut c = Client::new(self.app.clone());
c.set_cookie(&token);
c
}
}
// ---------------------------------------------------------------------------
// Client
// ---------------------------------------------------------------------------
pub struct Client {
app: Router,
pub cookie: Option<String>,
}
pub struct Resp {
pub status: StatusCode,
pub headers: HeaderMap,
pub body: Vec<u8>,
}
impl Resp {
pub fn json(&self) -> serde_json::Value {
serde_json::from_slice(&self.body).unwrap_or_else(|e| {
panic!(
"body is not JSON ({e}):\n{}",
String::from_utf8_lossy(&self.body)
)
})
}
pub fn text(&self) -> String {
String::from_utf8_lossy(&self.body).into_owned()
}
pub fn header(&self, name: &str) -> Option<String> {
self.headers
.get(name)
.and_then(|v| v.to_str().ok())
.map(str::to_string)
}
}
impl Client {
pub fn new(app: Router) -> Self {
Self { app, cookie: None }
}
pub fn set_cookie(&mut self, token: &str) {
self.cookie = Some(token.to_string());
}
pub async fn raw(
&self,
method: Method,
path: &str,
extra_headers: &[(&str, &str)],
body: Vec<u8>,
) -> Resp {
let mut b = axum::http::Request::builder().method(method).uri(path);
for (k, v) in extra_headers {
b = b.header(*k, *v);
}
if let Some(c) = &self.cookie {
b = b.header(header::COOKIE, format!("fbng_session={c}"));
}
let req = b
.body(Body::from(body))
.unwrap_or_else(|e| panic!("bad request: {e}"));
let res = self
.app
.clone()
.oneshot(req)
.await
.unwrap_or_else(|e| panic!("request failed: {e}"));
let status = res.status();
let headers = res.headers().clone();
let bytes = res.into_body().collect().await.unwrap().to_bytes();
Resp {
status,
headers,
body: bytes.to_vec(),
}
}
pub async fn get(&self, path: &str) -> Resp {
self.raw(Method::GET, path, &[], Vec::new()).await
}
pub async fn delete(&self, path: &str) -> Resp {
self.raw(Method::DELETE, path, &[], Vec::new()).await
}
pub async fn post_json(&self, path: &str, v: &serde_json::Value) -> Resp {
self.raw(
Method::POST,
path,
&[("content-type", "application/json")],
v.to_string().into_bytes(),
)
.await
}
pub async fn put_json(&self, path: &str, v: &serde_json::Value) -> Resp {
self.raw(
Method::PUT,
path,
&[("content-type", "application/json")],
v.to_string().into_bytes(),
)
.await
}
/// `PUT ...?action=content` (editor save).
pub async fn put_content(&self, path: &str, content: &[u8], expected_mtime: Option<i64>) -> Resp {
let mut extra: Vec<(&str, String)> = vec![("content-type", "text/plain".to_string())];
if let Some(m) = expected_mtime {
extra.push(("x-expected-mtime", format!("{m}")));
}
let owned: Vec<(String, String)> = extra
.into_iter()
.map(|(k, v)| (k.to_string(), v))
.collect();
let hdrs: Vec<(&str, &str)> = owned
.iter()
.map(|(k, v)| (k.as_str(), v.as_str()))
.collect();
self.raw(Method::PUT, path, &hdrs, content.to_vec())
.await
}
/// POST a multipart upload with one file per part name.
pub async fn post_multipart(
&self,
path: &str,
parts: &[(&str, &[u8])],
query: &str,
) -> Resp {
let boundary = "testboundary123";
let (ct, body) = multipart_body(parts, boundary);
let full = if query.is_empty() {
path.to_string()
} else {
format!("{path}?{query}")
};
self.raw(Method::POST, &full, &[("content-type", &ct)], body)
.await
}
}
/// Extract the `fbng_session` cookie value from `Set-Cookie` headers.
pub fn session_cookie(r: &Resp) -> Option<String> {
for v in r.headers.get_all(header::SET_COOKIE) {
let s = v.to_str().ok()?;
let first = s.split(';').next().unwrap_or("");
if let Some(tok) = first.strip_prefix("fbng_session=") {
if !tok.is_empty() {
return Some(tok.to_string());
}
}
}
None
}
/// POST /api/admin/users helper (used by several test files).
pub async fn create_user(
admin: &Client,
name: &str,
password: &str,
roots: &[(&str, &str)],
) -> serde_json::Value {
let roots_json: Vec<serde_json::Value> = roots
.iter()
.map(|(p, m)| serde_json::json!({ "path": p, "mode": m }))
.collect();
let r = admin
.post_json(
"/api/admin/users",
&serde_json::json!({
"name": name,
"password": password,
"is_admin": false,
"roots": roots_json,
}),
)
.await;
assert_eq!(r.status, StatusCode::OK, "create {name}: {}", r.text());
r.json()
}
/// Log in and return a signed-in client.
pub async fn login(env: &Env, name: &str, password: &str) -> Client {
let c = Client::new(env.app.clone());
let r = c
.post_json(
"/api/auth/login",
&serde_json::json!({ "name": name, "password": password }),
)
.await;
assert_eq!(r.status, StatusCode::OK, "login {name}: {}", r.text());
let mut c = Client::new(env.app.clone());
c.set_cookie(&session_cookie(&r).unwrap());
c
}
/// Find a user id by name via the admin API.
pub async fn user_id(admin: &Client, name: &str) -> i64 {
let r = admin.get("/api/admin/users").await;
assert_eq!(r.status, StatusCode::OK);
let j = r.json();
let users = j.as_array().unwrap();
users
.iter()
.find(|u| u["name"] == name)
.unwrap_or_else(|| panic!("user {name} not found"))
.as_object()
.unwrap()
.get("id")
.unwrap()
.as_i64()
.unwrap()
}
// ---------------------------------------------------------------------------
// Helpers: multipart + archive readers
// ---------------------------------------------------------------------------
/// Build a minimal multipart/form-data body. Each part is a file whose
/// `name` attribute is the (relative) target path — as the web client sends.
pub fn multipart_body(parts: &[(&str, &[u8])], boundary: &str) -> (String, Vec<u8>) {
let mut buf = Vec::new();
for (name, content) in parts {
buf.extend_from_slice(format!("--{boundary}\r\n").as_bytes());
buf.extend_from_slice(
format!("Content-Disposition: form-data; name=\"{name}\"\r\n").as_bytes(),
);
buf.extend_from_slice(b"Content-Type: application/octet-stream\r\n\r\n");
buf.extend_from_slice(content);
buf.extend_from_slice(b"\r\n");
}
buf.extend_from_slice(format!("--{boundary}--\r\n").as_bytes());
(format!("multipart/form-data; boundary={boundary}"), buf)
}
/// Read a zip into a name → content map (files only).
pub fn zip_map(bytes: &[u8]) -> BTreeMap<String, Vec<u8>> {
let mut zip =
zip::ZipArchive::new(std::io::Cursor::new(bytes)).expect("valid zip archive");
let mut map = BTreeMap::new();
for i in 0..zip.len() {
let mut f = zip.by_index(i).unwrap();
let name = f.name().unwrap().to_string();
if name.ends_with('/') {
continue;
}
let mut buf = Vec::new();
std::io::Read::read_to_end(&mut f, &mut buf).unwrap();
map.insert(name, buf);
}
map
}
/// Read a tar (optionally gz/zst compressed) into a name → content map.
pub fn tar_map(raw: &[u8], compress: Compress) -> BTreeMap<String, Vec<u8>> {
let decompressed: Box<dyn std::io::Read> = match compress {
Compress::None => Box::new(std::io::Cursor::new(raw)),
Compress::Gz => Box::new(flate2::read::GzDecoder::new(std::io::Cursor::new(raw))),
Compress::Zst => Box::new(
zstd::stream::read::Decoder::new(std::io::Cursor::new(raw)).unwrap(),
),
};
let mut map = BTreeMap::new();
for entry in tar::Archive::new(decompressed).entries().unwrap() {
let mut e = entry.unwrap();
if !e.header().entry_type().is_file() {
continue;
}
let name = e.path().unwrap().to_string_lossy().into_owned();
let mut buf = Vec::new();
std::io::Read::read_to_end(&mut e, &mut buf).unwrap();
map.insert(name, buf);
}
map
}
pub enum Compress {
None,
Gz,
Zst,
}