Four tests force a filesystem failure by freezing a directory to 0o555. The CI container runs as root, mode bits do not constrain root, and the rename those tests expect to fail succeeds — main has been red on a_failed_rename_leaves_the_row_alone since the move-on-root-change work landed, with nextest's fail-fast hiding the other three. The guard probes the filesystem rather than the uid: what the tests depend on is the refusal, and a container can hold CAP_DAC_OVERRIDE without being uid 0.
1523 lines
58 KiB
Rust
1523 lines
58 KiB
Rust
//! Root folders (`DESIGN.md` §5.1): kind, audience, path and the one policy
|
|
//! attached. Reads serve the add flow (§9.2); writes are the settings view's.
|
|
|
|
use axum::extract::rejection::JsonRejection;
|
|
use axum::extract::{Path, State};
|
|
use axum::http::StatusCode;
|
|
use axum::Json;
|
|
use serde::{Deserialize, Serialize};
|
|
use utoipa::ToSchema;
|
|
|
|
use crate::movies::{pool, ApiError, ErrorBody};
|
|
use crate::policies::parsed;
|
|
use crate::state::AppState;
|
|
|
|
/// A root folder joined with its policy, enough for the add flow to pre-fill
|
|
/// root and policy (DESIGN.md §9.2) without a second request.
|
|
#[derive(Debug, Clone, Serialize, ToSchema)]
|
|
pub struct Root {
|
|
pub id: i64,
|
|
pub kind: String,
|
|
pub audience: String,
|
|
pub path: String,
|
|
pub policy_id: i64,
|
|
pub policy_name: String,
|
|
}
|
|
|
|
/// The payload for creating or replacing a root.
|
|
#[derive(Debug, Clone, Deserialize, ToSchema)]
|
|
pub struct RootInput {
|
|
/// `movie` or `tv` — the Transmission label and layout prefix (§7.1, §7.4).
|
|
pub kind: String,
|
|
/// `main` or `kids`.
|
|
pub audience: String,
|
|
pub path: String,
|
|
pub policy_id: i64,
|
|
}
|
|
|
|
impl RootInput {
|
|
fn validate(&self) -> Result<(), String> {
|
|
if self.kind != "movie" && self.kind != "tv" {
|
|
return Err(format!("kind: '{}' is neither movie nor tv", self.kind));
|
|
}
|
|
if self.audience != "main" && self.audience != "kids" {
|
|
return Err(format!(
|
|
"audience: '{}' is neither main nor kids",
|
|
self.audience
|
|
));
|
|
}
|
|
if self.path.trim().is_empty() {
|
|
return Err("path: must not be empty".into());
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
async fn policy_exists(&self, state: &AppState) -> Result<(), ApiError> {
|
|
let exists: Option<i64> =
|
|
sqlx::query_scalar!("SELECT id FROM policies WHERE id = ?", self.policy_id)
|
|
.fetch_optional(pool(state)?)
|
|
.await?;
|
|
if exists.is_none() {
|
|
return Err(ApiError::Invalid(format!(
|
|
"policy_id: no policy {}",
|
|
self.policy_id
|
|
)));
|
|
}
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
/// Strips a trailing separator so `/mnt/media` and `/mnt/media/` name the
|
|
/// same root — otherwise a `PUT` that only adds or drops a trailing slash
|
|
/// takes the relocation branch and every planned destination conflicts with
|
|
/// its own source. Leaves a bare `/` alone.
|
|
fn normalize_path(path: &str) -> String {
|
|
let trimmed = path.trim();
|
|
let stripped = trimmed.trim_end_matches('/');
|
|
if stripped.is_empty() {
|
|
"/".to_owned()
|
|
} else {
|
|
stripped.to_owned()
|
|
}
|
|
}
|
|
|
|
async fn load_root(state: &AppState, id: i64) -> Result<Root, ApiError> {
|
|
sqlx::query_as!(
|
|
Root,
|
|
r#"SELECT r.id AS "id!: i64",
|
|
r.kind AS "kind!: String",
|
|
r.audience AS "audience!: String",
|
|
r.path AS "path!: String",
|
|
p.id AS "policy_id!: i64",
|
|
p.name AS "policy_name!: String"
|
|
FROM roots r JOIN policies p ON p.id = r.policy_id
|
|
WHERE r.id = ?"#,
|
|
id
|
|
)
|
|
.fetch_optional(pool(state)?)
|
|
.await?
|
|
.ok_or(ApiError::RootNotFound)
|
|
}
|
|
|
|
#[utoipa::path(
|
|
get, path = "/api/roots", tag = "roots",
|
|
responses(
|
|
(status = 200, body = [Root]),
|
|
(status = 500, body = ErrorBody),
|
|
(status = 503, body = ErrorBody)
|
|
)
|
|
)]
|
|
pub async fn list(State(state): State<AppState>) -> Result<Json<Vec<Root>>, ApiError> {
|
|
let roots = sqlx::query_as!(
|
|
Root,
|
|
r#"SELECT r.id AS "id!: i64", r.kind AS "kind!: String", r.audience AS "audience!: String", r.path AS "path!: String", p.id AS "policy_id!: i64", p.name AS "policy_name!: String" FROM roots r JOIN policies p ON p.id = r.policy_id ORDER BY r.id"#
|
|
)
|
|
.fetch_all(pool(&state)?)
|
|
.await?;
|
|
Ok(Json(roots))
|
|
}
|
|
|
|
#[utoipa::path(
|
|
get, path = "/api/roots/{root_id}", tag = "roots",
|
|
params(("root_id" = i64, Path, description = "Root row id")),
|
|
responses(
|
|
(status = 200, body = Root),
|
|
(status = 404, body = ErrorBody),
|
|
(status = 500, body = ErrorBody),
|
|
(status = 503, body = ErrorBody)
|
|
)
|
|
)]
|
|
pub async fn get(
|
|
State(state): State<AppState>,
|
|
Path(id): Path<i64>,
|
|
) -> Result<Json<Root>, ApiError> {
|
|
Ok(Json(load_root(&state, id).await?))
|
|
}
|
|
|
|
#[utoipa::path(
|
|
post, path = "/api/roots", tag = "roots", request_body = RootInput,
|
|
responses(
|
|
(status = 201, body = Root),
|
|
(status = 409, body = ErrorBody),
|
|
(status = 422, body = ErrorBody),
|
|
(status = 500, body = ErrorBody),
|
|
(status = 503, body = ErrorBody)
|
|
)
|
|
)]
|
|
pub async fn create(
|
|
State(state): State<AppState>,
|
|
body: Result<Json<RootInput>, JsonRejection>,
|
|
) -> Result<(StatusCode, Json<Root>), ApiError> {
|
|
let input = parsed(body)?;
|
|
input.validate().map_err(ApiError::Invalid)?;
|
|
input.policy_exists(&state).await?;
|
|
let path = normalize_path(&input.path);
|
|
path_is_free(&state, None, &path).await?;
|
|
let result = sqlx::query!(
|
|
"INSERT INTO roots (kind, audience, path, policy_id) VALUES (?, ?, ?, ?)",
|
|
input.kind,
|
|
input.audience,
|
|
path,
|
|
input.policy_id,
|
|
)
|
|
.execute(pool(&state)?)
|
|
.await
|
|
.map_err(root_conflict)?;
|
|
let root = load_root(&state, result.last_insert_rowid()).await?;
|
|
Ok((StatusCode::CREATED, Json(root)))
|
|
}
|
|
|
|
#[utoipa::path(
|
|
put, path = "/api/roots/{root_id}", tag = "roots", request_body = RootInput,
|
|
params(("root_id" = i64, Path, description = "Root row id")),
|
|
responses(
|
|
(status = 200, body = Root),
|
|
(status = 404, body = ErrorBody),
|
|
(status = 409, body = ErrorBody),
|
|
(status = 422, body = ErrorBody),
|
|
(status = 500, body = ErrorBody),
|
|
(status = 503, body = ErrorBody)
|
|
)
|
|
)]
|
|
pub async fn update(
|
|
State(state): State<AppState>,
|
|
Path(id): Path<i64>,
|
|
body: Result<Json<RootInput>, JsonRejection>,
|
|
) -> Result<Json<Root>, ApiError> {
|
|
let input = parsed(body)?;
|
|
input.validate().map_err(ApiError::Invalid)?;
|
|
input.policy_exists(&state).await?;
|
|
let current = load_root(&state, id).await?;
|
|
let path = normalize_path(&input.path);
|
|
// #244: rows written before #243 can hold a trailing separator, so the
|
|
// stored value is normalised too. Comparing a normalised payload against
|
|
// a raw stored value means the row can never compare equal: every edit,
|
|
// policy changes included, takes the relocation branch, and there each
|
|
// planned destination is its own source. The normalised value is what
|
|
// `relocate_root` gets as well, so no planned path carries a doubled
|
|
// separator.
|
|
let current_path = normalize_path(¤t.path);
|
|
// A path change moves every §7.4 title folder under this root with the
|
|
// row (issue #236), the same way changing a title's root moves one
|
|
// (#228). Disk first, row second: a failed rename leaves the root row
|
|
// alone, so the operator sees the library where its files actually are
|
|
// and can retry. A path already taken is refused before any of it, since
|
|
// the write would fail afterwards anyway.
|
|
let relocation = if path == current_path {
|
|
None
|
|
} else {
|
|
path_is_free(&state, Some(id), &path).await?;
|
|
Some(crate::relocate::relocate_root(&state, id, ¤t_path, &path).await?)
|
|
};
|
|
let mut transaction = pool(&state)?.begin().await?;
|
|
let written: Result<(), sqlx::Error> = async {
|
|
sqlx::query!(
|
|
r#"UPDATE roots SET kind = ?, audience = ?, path = ?, policy_id = ?,
|
|
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
|
|
WHERE id = ?"#,
|
|
input.kind,
|
|
input.audience,
|
|
path,
|
|
input.policy_id,
|
|
id,
|
|
)
|
|
.execute(&mut *transaction)
|
|
.await?;
|
|
if let Some(relocation) = &relocation {
|
|
relocation.rewrite_rows(&mut transaction).await?;
|
|
}
|
|
Ok(())
|
|
}
|
|
.await;
|
|
let committed = match written {
|
|
Ok(()) => transaction.commit().await.map_err(root_conflict),
|
|
Err(error) => Err(root_conflict(error)),
|
|
};
|
|
if let Err(error) = committed {
|
|
if let Some(relocation) = &relocation {
|
|
relocation.undo().await;
|
|
}
|
|
return Err(error);
|
|
}
|
|
if relocation
|
|
.as_ref()
|
|
.is_some_and(crate::relocate::Relocation::moved_files)
|
|
{
|
|
crate::relocate::refresh_jellyfin(&state).await;
|
|
}
|
|
// Verdicts already stored under this root were reached under the old
|
|
// policy; §9.3's deck and the grab gate both read them (`reclassify`).
|
|
if input.policy_id != current.policy_id {
|
|
crate::reclassify::root(&state, id).await?;
|
|
}
|
|
Ok(Json(load_root(&state, id).await?))
|
|
}
|
|
|
|
/// The unique index on `path` would catch this after the move; catching it
|
|
/// first keeps a doomed write from touching the disk at all. `except` is the
|
|
/// row being updated, or `None` when creating.
|
|
///
|
|
/// The stored side is normalised in SQL, mirroring [`normalize_path`], so
|
|
/// `/mnt/media/x` and `/mnt/media/x/` cannot be two roots for one directory
|
|
/// (#244). The unique index cannot see that — it compares the raw strings —
|
|
/// and migration 0031 leaves any pair that already collides alone rather
|
|
/// than failing to apply, so such a row can still be on disk.
|
|
async fn path_is_free(state: &AppState, except: Option<i64>, path: &str) -> Result<(), ApiError> {
|
|
let taken: Option<i64> = sqlx::query_scalar!(
|
|
r#"SELECT id AS "id!: i64" FROM roots
|
|
WHERE CASE WHEN rtrim(path, '/') = '' THEN '/' ELSE rtrim(path, '/') END = ?
|
|
AND (? IS NULL OR id <> ?)"#,
|
|
path,
|
|
except,
|
|
except
|
|
)
|
|
.fetch_optional(pool(state)?)
|
|
.await?;
|
|
if taken.is_some() {
|
|
return Err(ApiError::Conflict(
|
|
"a root with this path already exists".into(),
|
|
));
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// A duplicate path or a duplicate (kind, audience) pair is a settings
|
|
/// mistake the operator can fix, not a server fault.
|
|
fn root_conflict(error: sqlx::Error) -> ApiError {
|
|
if error
|
|
.as_database_error()
|
|
.is_some_and(sqlx::error::DatabaseError::is_unique_violation)
|
|
{
|
|
return ApiError::Conflict(
|
|
"a root with this path or this kind and audience already exists".into(),
|
|
);
|
|
}
|
|
ApiError::from(error)
|
|
}
|
|
|
|
#[utoipa::path(
|
|
delete, path = "/api/roots/{root_id}", tag = "roots",
|
|
params(("root_id" = i64, Path, description = "Root row id")),
|
|
responses(
|
|
(status = 204),
|
|
(status = 404, body = ErrorBody),
|
|
(status = 409, body = ErrorBody),
|
|
(status = 500, body = ErrorBody),
|
|
(status = 503, body = ErrorBody)
|
|
)
|
|
)]
|
|
pub async fn delete(
|
|
State(state): State<AppState>,
|
|
Path(id): Path<i64>,
|
|
) -> Result<StatusCode, ApiError> {
|
|
let movies: i64 = sqlx::query_scalar!(
|
|
r#"SELECT count(*) AS "count!: i64" FROM movies WHERE root_id = ?"#,
|
|
id
|
|
)
|
|
.fetch_one(pool(&state)?)
|
|
.await?;
|
|
if movies > 0 {
|
|
return Err(ApiError::Conflict(format!(
|
|
"this root still owns {movies} movie{} — move them first",
|
|
if movies == 1 { "" } else { "s" }
|
|
)));
|
|
}
|
|
let series: i64 = sqlx::query_scalar!(
|
|
r#"SELECT count(*) AS "count!: i64" FROM series WHERE root_id = ?"#,
|
|
id
|
|
)
|
|
.fetch_one(pool(&state)?)
|
|
.await?;
|
|
if series > 0 {
|
|
return Err(ApiError::Conflict(format!(
|
|
"this root still owns {series} series — move them first"
|
|
)));
|
|
}
|
|
let result = sqlx::query!("DELETE FROM roots WHERE id = ?", id)
|
|
.execute(pool(&state)?)
|
|
.await?;
|
|
if result.rows_affected() == 0 {
|
|
return Err(ApiError::RootNotFound);
|
|
}
|
|
Ok(StatusCode::NO_CONTENT)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use crate::{router, AppState, Upstreams};
|
|
use axum::http::StatusCode;
|
|
|
|
async fn application() -> (tempfile::TempDir, AppState, String) {
|
|
let dir = tempfile::tempdir().expect("tempdir");
|
|
let database = arr_db::Db::connect(dir.path().join("arr.db"))
|
|
.await
|
|
.expect("database");
|
|
database.migrate().await.expect("migrate");
|
|
let state = AppState::new(Upstreams::new(
|
|
"http://127.0.0.1:1".into(),
|
|
"http://127.0.0.1:1".into(),
|
|
))
|
|
.expect("state")
|
|
.with_database(database);
|
|
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
|
|
.await
|
|
.expect("bind");
|
|
let address = listener.local_addr().expect("address");
|
|
let app = router(state.clone());
|
|
tokio::spawn(async move { axum::serve(listener, app).await.expect("serve") });
|
|
(dir, state, format!("http://{address}"))
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn roots_carry_their_policy_name() {
|
|
let (_dir, _state, base) = application().await;
|
|
let roots: Vec<serde_json::Value> = reqwest::get(format!("{base}/api/roots"))
|
|
.await
|
|
.expect("roots")
|
|
.json()
|
|
.await
|
|
.expect("json");
|
|
assert_eq!(roots.len(), 4, "the seeded movie and TV roots: {roots:?}");
|
|
assert_eq!(roots[0]["audience"], "main");
|
|
assert_eq!(roots[0]["policy_name"], "Movies — main");
|
|
assert_eq!(roots[1]["audience"], "kids");
|
|
assert!(roots[1]["path"]
|
|
.as_str()
|
|
.is_some_and(|p| p.contains("kids")));
|
|
assert_eq!(roots[2]["kind"], "tv");
|
|
assert_eq!(roots[2]["policy_name"], "TV — main");
|
|
assert_eq!(roots[3]["kind"], "tv");
|
|
assert_eq!(roots[3]["policy_name"], "TV — kids");
|
|
}
|
|
|
|
fn root_input(policy_id: i64) -> serde_json::Value {
|
|
serde_json::json!({
|
|
"kind": "movie",
|
|
"audience": "archive",
|
|
"path": "/mnt/media/movies/archive",
|
|
"policy_id": policy_id,
|
|
})
|
|
}
|
|
|
|
async fn first_policy_ids(base: &str) -> Vec<i64> {
|
|
let policies: Vec<serde_json::Value> = reqwest::get(format!("{base}/api/policies"))
|
|
.await
|
|
.expect("policies")
|
|
.json()
|
|
.await
|
|
.expect("policies json");
|
|
policies
|
|
.into_iter()
|
|
.map(|policy| policy["id"].as_i64().expect("policy id"))
|
|
.collect()
|
|
}
|
|
|
|
async fn policy_id_named(base: &str, name: &str) -> i64 {
|
|
let policies: Vec<serde_json::Value> = reqwest::get(format!("{base}/api/policies"))
|
|
.await
|
|
.expect("policies")
|
|
.json()
|
|
.await
|
|
.expect("policies json");
|
|
policies
|
|
.iter()
|
|
.find(|policy| policy["name"] == name)
|
|
.and_then(|policy| policy["id"].as_i64())
|
|
.expect("policy id")
|
|
}
|
|
|
|
async fn stamped_eligible_release(state: &AppState, name: &str, size: i64) -> i64 {
|
|
let parsed = arr_parse::parse(name);
|
|
sqlx::query_scalar::<_, i64>(
|
|
"INSERT INTO releases (indexer_id, guid, name, size, seeders, download_url, parsed, score, verdict)
|
|
VALUES (7, ?, ?, ?, 40, 'url', ?, 0, 'eligible') RETURNING id",
|
|
)
|
|
.bind(name)
|
|
.bind(name)
|
|
.bind(size)
|
|
.bind(serde_json::to_string(&parsed).expect("parsed json"))
|
|
.fetch_one(state.database().expect("database").pool())
|
|
.await
|
|
.expect("release")
|
|
}
|
|
|
|
/// A movie under the movie main root, its release stamped eligible.
|
|
async fn movie_with_eligible_release(state: &AppState, base: &str) -> i64 {
|
|
let movie: serde_json::Value = reqwest::Client::new()
|
|
.post(format!("{base}/api/movies"))
|
|
.json(&serde_json::json!({
|
|
"tmdb_id": 693_134, "title": "Dune Part Two", "year": 2024,
|
|
"original_language": "en", "root_id": 1, "overrides": {}
|
|
}))
|
|
.send()
|
|
.await
|
|
.expect("create movie")
|
|
.json()
|
|
.await
|
|
.expect("movie json");
|
|
let movie_id = movie["id"].as_i64().expect("movie id");
|
|
let release_id = stamped_eligible_release(
|
|
state,
|
|
"Dune Part Two 2024 1080p WEB-DL ENGLISH x264-GROUP",
|
|
6_i64 * (1 << 30),
|
|
)
|
|
.await;
|
|
sqlx::query("INSERT INTO movie_releases (movie_id, release_id) VALUES (?, ?)")
|
|
.bind(movie_id)
|
|
.bind(release_id)
|
|
.execute(state.database().expect("database").pool())
|
|
.await
|
|
.expect("movie association");
|
|
movie_id
|
|
}
|
|
|
|
/// A series under the given TV root, with a season 9 pack stamped
|
|
/// eligible. Ten episodes in 15 GiB: 1.5 GiB each, inside the 1080p band.
|
|
async fn series_with_eligible_pack(state: &AppState, base: &str, root_id: i64) -> i64 {
|
|
let client = reqwest::Client::new();
|
|
let series: serde_json::Value = client
|
|
.post(format!("{base}/api/series"))
|
|
.json(&serde_json::json!({
|
|
"tmdb_id": 82_728, "title": "Bluey", "year": 2018,
|
|
"original_language": "en", "root_id": root_id,
|
|
"auto_track": false
|
|
}))
|
|
.send()
|
|
.await
|
|
.expect("create series")
|
|
.json()
|
|
.await
|
|
.expect("series json");
|
|
let series_id = series["id"].as_i64().expect("series id");
|
|
let episodes: Vec<serde_json::Value> = (1..=10)
|
|
.map(|number| {
|
|
serde_json::json!({
|
|
"number": number, "title": format!("Episode {number}"),
|
|
"air_date": "2025-01-01"
|
|
})
|
|
})
|
|
.collect();
|
|
let season: serde_json::Value = client
|
|
.post(format!("{base}/api/series/{series_id}/seasons"))
|
|
.json(&serde_json::json!({"number": 9, "episodes": episodes}))
|
|
.send()
|
|
.await
|
|
.expect("create season")
|
|
.json()
|
|
.await
|
|
.expect("season json");
|
|
let season_id = season["id"].as_i64().expect("season id");
|
|
let release_id = stamped_eligible_release(
|
|
state,
|
|
"Bluey S09 1080p WEB-DL ENGLISH x264-GROUP",
|
|
15_i64 * (1 << 30),
|
|
)
|
|
.await;
|
|
sqlx::query("INSERT INTO season_releases (season_id, release_id) VALUES (?, ?)")
|
|
.bind(season_id)
|
|
.bind(release_id)
|
|
.execute(state.database().expect("database").pool())
|
|
.await
|
|
.expect("season association");
|
|
series_id
|
|
}
|
|
|
|
async fn verdict_of(url: String) -> serde_json::Value {
|
|
let releases: Vec<serde_json::Value> = reqwest::get(url)
|
|
.await
|
|
.expect("releases")
|
|
.json()
|
|
.await
|
|
.expect("releases json");
|
|
releases[0]["verdict"].clone()
|
|
}
|
|
|
|
/// #241: `PUT /api/roots/{id}` writing a different `policy_id`
|
|
/// re-derives the stored verdicts of every title under the root (§5.1)
|
|
/// — and of nothing outside it.
|
|
#[tokio::test]
|
|
async fn repointing_a_root_at_another_policy_rederives_its_library() {
|
|
let (_dir, state, base) = application().await;
|
|
let pool = state.database().expect("database").pool();
|
|
let client = reqwest::Client::new();
|
|
let movie_id = movie_with_eligible_release(&state, &base).await;
|
|
let tv_main: i64 =
|
|
sqlx::query_scalar("SELECT id FROM roots WHERE kind = 'tv' AND audience = 'main'")
|
|
.fetch_one(pool)
|
|
.await
|
|
.expect("tv root");
|
|
let series_id = series_with_eligible_pack(&state, &base, tv_main).await;
|
|
|
|
// Point the movie main root at the kids policy; the path stays, so
|
|
// nothing on disk is touched.
|
|
let kids_policy = policy_id_named(&base, "Movies — kids").await;
|
|
let response = client
|
|
.put(format!("{base}/api/roots/1"))
|
|
.json(&serde_json::json!({
|
|
"kind": "movie", "audience": "main",
|
|
"path": "/mnt/media/movies/main", "policy_id": kids_policy
|
|
}))
|
|
.send()
|
|
.await
|
|
.expect("repoint movie root");
|
|
assert_eq!(response.status(), StatusCode::OK);
|
|
|
|
assert_eq!(
|
|
verdict_of(format!("{base}/api/movies/{movie_id}/releases")).await,
|
|
"waived",
|
|
"kids requires pt-PT, English is a soft fail"
|
|
);
|
|
// The TV root was not repointed, so its library is untouched.
|
|
assert_eq!(
|
|
verdict_of(format!("{base}/api/series/{series_id}/seasons/9/releases")).await,
|
|
"eligible"
|
|
);
|
|
|
|
// Now the TV root; the series' pack re-derives too.
|
|
let tv_kids_policy = policy_id_named(&base, "TV — kids").await;
|
|
let response = client
|
|
.put(format!("{base}/api/roots/{tv_main}"))
|
|
.json(&serde_json::json!({
|
|
"kind": "tv", "audience": "main",
|
|
"path": "/mnt/media/tv/main", "policy_id": tv_kids_policy
|
|
}))
|
|
.send()
|
|
.await
|
|
.expect("repoint tv root");
|
|
assert_eq!(
|
|
response.status(),
|
|
StatusCode::OK,
|
|
"{:?}",
|
|
response.text().await
|
|
);
|
|
|
|
assert_eq!(
|
|
verdict_of(format!("{base}/api/series/{series_id}/seasons/9/releases")).await,
|
|
"waived",
|
|
"kids requires pt-PT, English is a soft fail"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn a_root_round_trips_through_create_and_update() {
|
|
let (_dir, _state, base) = application().await;
|
|
let policy_ids = first_policy_ids(&base).await;
|
|
let roots: Vec<serde_json::Value> = reqwest::get(format!("{base}/api/roots"))
|
|
.await
|
|
.expect("roots")
|
|
.json()
|
|
.await
|
|
.expect("roots json");
|
|
|
|
// the schema only knows movie and tv, main and kids
|
|
let rejected = reqwest::Client::new()
|
|
.post(format!("{base}/api/roots"))
|
|
.json(&root_input(policy_ids[0]))
|
|
.send()
|
|
.await
|
|
.expect("create root");
|
|
assert_eq!(rejected.status(), StatusCode::UNPROCESSABLE_ENTITY);
|
|
|
|
// (movie, kids) is taken by a seeded root, so free the pair first
|
|
let kids_root = roots[1]["id"].as_i64().expect("movie kids root id");
|
|
let freed = reqwest::Client::new()
|
|
.delete(format!("{base}/api/roots/{kids_root}"))
|
|
.send()
|
|
.await
|
|
.expect("free the seeded movie kids root");
|
|
assert_eq!(freed.status(), StatusCode::NO_CONTENT);
|
|
|
|
let mut payload = root_input(policy_ids[0]);
|
|
payload["audience"] = serde_json::json!("kids");
|
|
payload["path"] = serde_json::json!("/mnt/media/movies/archive");
|
|
let created: serde_json::Value = reqwest::Client::new()
|
|
.post(format!("{base}/api/roots"))
|
|
.json(&payload)
|
|
.send()
|
|
.await
|
|
.expect("create root")
|
|
.json()
|
|
.await
|
|
.expect("created json");
|
|
assert_eq!(created["audience"], "kids");
|
|
assert_eq!(created["policy_name"], "Movies — main");
|
|
let id = created["id"].as_i64().expect("id");
|
|
|
|
payload["path"] = serde_json::json!("/mnt/media/movies/archive-4k");
|
|
payload["policy_id"] = policy_ids[1].into();
|
|
let updated: serde_json::Value = reqwest::Client::new()
|
|
.put(format!("{base}/api/roots/{id}"))
|
|
.json(&payload)
|
|
.send()
|
|
.await
|
|
.expect("update root")
|
|
.json()
|
|
.await
|
|
.expect("updated json");
|
|
assert_eq!(updated["path"], "/mnt/media/movies/archive-4k");
|
|
assert_eq!(updated["policy_name"], "Movies — kids");
|
|
|
|
let deleted = reqwest::Client::new()
|
|
.delete(format!("{base}/api/roots/{id}"))
|
|
.send()
|
|
.await
|
|
.expect("delete root");
|
|
assert_eq!(deleted.status(), StatusCode::NO_CONTENT);
|
|
assert_eq!(
|
|
reqwest::get(format!("{base}/api/roots/{id}"))
|
|
.await
|
|
.expect("get deleted")
|
|
.status(),
|
|
StatusCode::NOT_FOUND
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn an_unknown_kind_or_policy_is_a_422_naming_the_field() {
|
|
let (_dir, _state, base) = application().await;
|
|
let policy_ids = first_policy_ids(&base).await;
|
|
|
|
for (mut payload, field) in [
|
|
(root_input(999), "policy_id"),
|
|
(root_input(policy_ids[0]), "kind"),
|
|
] {
|
|
payload["audience"] = serde_json::json!("main");
|
|
if field == "kind" {
|
|
payload["kind"] = serde_json::json!("book");
|
|
payload["path"] = serde_json::json!("/mnt/media/movies/elsewhere");
|
|
}
|
|
let response = reqwest::Client::new()
|
|
.post(format!("{base}/api/roots"))
|
|
.json(&payload)
|
|
.send()
|
|
.await
|
|
.expect("create root");
|
|
assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY);
|
|
let body: serde_json::Value = response.json().await.expect("error body");
|
|
assert!(
|
|
body["error"].as_str().expect("text").starts_with(field),
|
|
"{field}: {body}"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn a_duplicate_path_or_pair_conflicts() {
|
|
let (_dir, _state, base) = application().await;
|
|
let mut path = root_input(1);
|
|
path["audience"] = serde_json::json!("kids");
|
|
path["path"] = serde_json::json!("/mnt/media/movies/kids");
|
|
let response = reqwest::Client::new()
|
|
.post(format!("{base}/api/roots"))
|
|
.json(&path)
|
|
.send()
|
|
.await
|
|
.expect("duplicate path create");
|
|
assert_eq!(response.status(), StatusCode::CONFLICT);
|
|
|
|
let mut pair = root_input(1);
|
|
pair["audience"] = serde_json::json!("main");
|
|
pair["path"] = serde_json::json!("/mnt/media/movies/somewhere-new");
|
|
let response = reqwest::Client::new()
|
|
.post(format!("{base}/api/roots"))
|
|
.json(&pair)
|
|
.send()
|
|
.await
|
|
.expect("duplicate pair create");
|
|
assert_eq!(response.status(), StatusCode::CONFLICT);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn a_root_with_titles_refuses_to_die() {
|
|
let (_dir, _state, base) = application().await;
|
|
let roots: Vec<serde_json::Value> = reqwest::get(format!("{base}/api/roots"))
|
|
.await
|
|
.expect("roots")
|
|
.json()
|
|
.await
|
|
.expect("roots json");
|
|
let main_root = roots[0]["id"].as_i64().expect("root id");
|
|
|
|
let created = reqwest::Client::new()
|
|
.post(format!("{base}/api/movies"))
|
|
.json(&serde_json::json!({
|
|
"tmdb_id": 693_134, "title": "Dune Part Two", "year": 2024,
|
|
"original_language": "en", "root_id": main_root,
|
|
}))
|
|
.send()
|
|
.await
|
|
.expect("create movie");
|
|
assert_eq!(created.status(), StatusCode::CREATED);
|
|
|
|
let response = reqwest::Client::new()
|
|
.delete(format!("{base}/api/roots/{main_root}"))
|
|
.send()
|
|
.await
|
|
.expect("delete occupied root");
|
|
assert_eq!(response.status(), StatusCode::CONFLICT);
|
|
let body: serde_json::Value = response.json().await.expect("error body");
|
|
assert!(
|
|
body["error"].as_str().expect("text").contains("movie"),
|
|
"{body}"
|
|
);
|
|
|
|
// an empty root deletes fine
|
|
let empty_root = roots[3]["id"].as_i64().expect("tv kids root id");
|
|
let response = reqwest::Client::new()
|
|
.delete(format!("{base}/api/roots/{empty_root}"))
|
|
.send()
|
|
.await
|
|
.expect("delete empty root");
|
|
assert_eq!(response.status(), StatusCode::NO_CONTENT);
|
|
}
|
|
|
|
/// A path written straight into the row, no normalisation — how a root
|
|
/// created before #243 could end up holding a trailing separator.
|
|
async fn point_root_at_raw(state: &AppState, root_id: i64, path: &str) {
|
|
sqlx::query("UPDATE roots SET path = ? WHERE id = ?")
|
|
.bind(path)
|
|
.bind(root_id)
|
|
.execute(state.database().expect("database").pool())
|
|
.await
|
|
.expect("store the raw path");
|
|
}
|
|
|
|
async fn point_root_at(state: &AppState, root_id: i64, path: &std::path::Path) {
|
|
sqlx::query("UPDATE roots SET path = ? WHERE id = ?")
|
|
.bind(path.to_str().expect("utf-8 root"))
|
|
.bind(root_id)
|
|
.execute(state.database().expect("database").pool())
|
|
.await
|
|
.expect("point the root at the tempdir");
|
|
}
|
|
|
|
async fn add_movie(base: &str, tmdb_id: i64, title: &str, root_id: i64) -> i64 {
|
|
let created: serde_json::Value = reqwest::Client::new()
|
|
.post(format!("{base}/api/movies"))
|
|
.json(&serde_json::json!({
|
|
"tmdb_id": tmdb_id, "title": title, "year": 2024,
|
|
"original_language": "en", "root_id": root_id,
|
|
}))
|
|
.send()
|
|
.await
|
|
.expect("create movie")
|
|
.json()
|
|
.await
|
|
.expect("movie json");
|
|
created["id"].as_i64().expect("movie id")
|
|
}
|
|
|
|
/// One movie's §7.4 folder on disk with a feature in it, and the
|
|
/// `media_files` row that points at the feature.
|
|
async fn library_folder(
|
|
state: &AppState,
|
|
movie_id: i64,
|
|
root: &std::path::Path,
|
|
folder: &str,
|
|
) -> std::path::PathBuf {
|
|
let folder = root.join(folder);
|
|
tokio::fs::create_dir_all(&folder)
|
|
.await
|
|
.expect("create title folder");
|
|
let feature = folder.join("feature.mkv");
|
|
tokio::fs::write(&feature, b"feature").await.expect("write");
|
|
sqlx::query(
|
|
"INSERT INTO media_files (owner_kind, owner_id, path, size) VALUES ('movie', ?, ?, 7)",
|
|
)
|
|
.bind(movie_id)
|
|
.bind(feature.to_str().expect("utf-8 path"))
|
|
.execute(state.database().expect("database").pool())
|
|
.await
|
|
.expect("media file");
|
|
folder
|
|
}
|
|
|
|
async fn root_payload(base: &str, id: i64, path: &str) -> serde_json::Value {
|
|
let root: serde_json::Value = reqwest::get(format!("{base}/api/roots/{id}"))
|
|
.await
|
|
.expect("root")
|
|
.json()
|
|
.await
|
|
.expect("root json");
|
|
serde_json::json!({
|
|
"kind": root["kind"],
|
|
"audience": root["audience"],
|
|
"path": path,
|
|
"policy_id": root["policy_id"],
|
|
})
|
|
}
|
|
|
|
async fn file_paths(state: &AppState) -> Vec<String> {
|
|
sqlx::query_scalar("SELECT path FROM media_files ORDER BY id")
|
|
.fetch_all(state.database().expect("database").pool())
|
|
.await
|
|
.expect("media files")
|
|
}
|
|
|
|
async fn stored_path(base: &str, id: i64) -> String {
|
|
let root: serde_json::Value = reqwest::get(format!("{base}/api/roots/{id}"))
|
|
.await
|
|
.expect("root")
|
|
.json()
|
|
.await
|
|
.expect("root json");
|
|
root["path"].as_str().expect("path").to_owned()
|
|
}
|
|
|
|
/// Issue #236: changing a root's path renames every §7.4 title folder
|
|
/// under it into the new path and rewrites the `media_files` rows in the
|
|
/// same write. Titles under other roots are not this root's business.
|
|
#[tokio::test]
|
|
async fn changing_a_root_path_moves_every_title_under_it() {
|
|
let (_dir, state, base) = application().await;
|
|
let old = tempfile::tempdir().expect("old root");
|
|
let other = tempfile::tempdir().expect("other root");
|
|
point_root_at(&state, 1, old.path()).await;
|
|
point_root_at(&state, 2, other.path()).await;
|
|
|
|
let mut folders = Vec::new();
|
|
for (tmdb_id, title) in [(100, "Dune"), (101, "Arrival"), (102, "Sicario")] {
|
|
let id = add_movie(&base, tmdb_id, title, 1).await;
|
|
folders.push(library_folder(&state, id, old.path(), title).await);
|
|
}
|
|
let elsewhere = add_movie(&base, 200, "Prisoners", 2).await;
|
|
let untouched = library_folder(&state, elsewhere, other.path(), "Prisoners").await;
|
|
|
|
// The new path need not exist yet — pointing a root somewhere fresh
|
|
// is the ordinary case.
|
|
let new = old.path().parent().expect("parent").join("relocated-main");
|
|
let response = reqwest::Client::new()
|
|
.put(format!("{base}/api/roots/1"))
|
|
.json(&root_payload(&base, 1, new.to_str().expect("utf-8")).await)
|
|
.send()
|
|
.await
|
|
.expect("move the root");
|
|
assert_eq!(response.status(), StatusCode::OK);
|
|
|
|
for folder in &folders {
|
|
assert!(!folder.exists(), "{} left the old path", folder.display());
|
|
}
|
|
for title in ["Dune", "Arrival", "Sicario"] {
|
|
assert!(
|
|
new.join(title).join("feature.mkv").exists(),
|
|
"{title} arrived under the new path"
|
|
);
|
|
}
|
|
assert!(
|
|
untouched.join("feature.mkv").exists(),
|
|
"a title under another root is untouched"
|
|
);
|
|
|
|
let paths = file_paths(&state).await;
|
|
for path in paths.iter().take(3) {
|
|
assert!(
|
|
std::path::Path::new(path).starts_with(&new),
|
|
"the row follows the file: {path}"
|
|
);
|
|
assert!(
|
|
std::path::Path::new(path).exists(),
|
|
"the rewritten path describes the disk: {path}"
|
|
);
|
|
}
|
|
assert!(
|
|
std::path::Path::new(&paths[3]).starts_with(other.path()),
|
|
"the other root's row is untouched: {}",
|
|
paths[3]
|
|
);
|
|
assert_eq!(stored_path(&base, 1).await, new.to_str().expect("utf-8"));
|
|
|
|
tokio::fs::remove_dir_all(&new).await.expect("clean up");
|
|
}
|
|
|
|
/// Issue #236: a root with nothing under it changes path with no
|
|
/// filesystem work at all — the new path is not even created.
|
|
#[tokio::test]
|
|
async fn an_empty_root_changes_path_with_no_filesystem_work() {
|
|
let (_dir, state, base) = application().await;
|
|
let home = tempfile::tempdir().expect("home");
|
|
let new = home.path().join("nothing-here");
|
|
|
|
let response = reqwest::Client::new()
|
|
.put(format!("{base}/api/roots/1"))
|
|
.json(&root_payload(&base, 1, new.to_str().expect("utf-8")).await)
|
|
.send()
|
|
.await
|
|
.expect("move the root");
|
|
assert_eq!(response.status(), StatusCode::OK);
|
|
assert_eq!(stored_path(&base, 1).await, new.to_str().expect("utf-8"));
|
|
assert!(!new.exists(), "nothing was created on disk");
|
|
assert!(file_paths(&state).await.is_empty());
|
|
}
|
|
|
|
/// Issue #236: a destination already holding a folder of that name is a
|
|
/// conflict, not an overwrite, and it is caught before anything moves.
|
|
#[tokio::test]
|
|
async fn a_squatted_destination_refuses_the_whole_move() {
|
|
let (_dir, state, base) = application().await;
|
|
let old = tempfile::tempdir().expect("old root");
|
|
let new = tempfile::tempdir().expect("new root");
|
|
point_root_at(&state, 1, old.path()).await;
|
|
|
|
let first = add_movie(&base, 100, "Dune", 1).await;
|
|
let second = add_movie(&base, 101, "Arrival", 1).await;
|
|
let dune = library_folder(&state, first, old.path(), "Dune").await;
|
|
let arrival = library_folder(&state, second, old.path(), "Arrival").await;
|
|
tokio::fs::create_dir_all(new.path().join("Arrival"))
|
|
.await
|
|
.expect("squatter");
|
|
|
|
let before = file_paths(&state).await;
|
|
let response = reqwest::Client::new()
|
|
.put(format!("{base}/api/roots/1"))
|
|
.json(&root_payload(&base, 1, new.path().to_str().expect("utf-8")).await)
|
|
.send()
|
|
.await
|
|
.expect("move the root");
|
|
assert_eq!(response.status(), StatusCode::CONFLICT);
|
|
|
|
assert!(dune.exists(), "not even the first folder moved");
|
|
assert!(arrival.exists());
|
|
assert_eq!(file_paths(&state).await, before, "the rows are untouched");
|
|
assert_eq!(
|
|
stored_path(&base, 1).await,
|
|
old.path().to_str().expect("utf-8"),
|
|
"the root still points where the files are"
|
|
);
|
|
}
|
|
|
|
/// Integration review of the Feedback pass 2 blitz: the retry the module
|
|
/// documents has to actually converge. An undo is best-effort, so a move
|
|
/// can fail and leave one folder at the destination with its row still
|
|
/// naming the source. Every later attempt used to 409 against the
|
|
/// operator's own half-moved library, and the only way out was moving the
|
|
/// folder back by hand.
|
|
#[tokio::test]
|
|
async fn a_folder_left_at_the_destination_by_a_failed_undo_does_not_block_the_retry() {
|
|
let (_dir, state, base) = application().await;
|
|
let old = tempfile::tempdir().expect("old root");
|
|
let new = tempfile::tempdir().expect("new root");
|
|
point_root_at(&state, 1, old.path()).await;
|
|
|
|
let first = add_movie(&base, 100, "Dune", 1).await;
|
|
let second = add_movie(&base, 101, "Arrival", 1).await;
|
|
let dune = library_folder(&state, first, old.path(), "Dune").await;
|
|
let arrival = library_folder(&state, second, old.path(), "Arrival").await;
|
|
|
|
// What a failed undo leaves behind: Arrival is already at the new
|
|
// path, its row still points at the old one, and the root row was
|
|
// never changed.
|
|
tokio::fs::create_dir_all(new.path())
|
|
.await
|
|
.expect("the new root");
|
|
tokio::fs::rename(&arrival, new.path().join("Arrival"))
|
|
.await
|
|
.expect("strand the folder");
|
|
assert!(!arrival.exists());
|
|
|
|
let response = reqwest::Client::new()
|
|
.put(format!("{base}/api/roots/1"))
|
|
.json(&root_payload(&base, 1, new.path().to_str().expect("utf-8")).await)
|
|
.send()
|
|
.await
|
|
.expect("retry the move");
|
|
assert_eq!(
|
|
response.status(),
|
|
StatusCode::OK,
|
|
"the stranded folder is the state we wanted, not a conflict"
|
|
);
|
|
|
|
assert!(!dune.exists(), "the folder still on disk moved");
|
|
assert!(new.path().join("Dune").exists());
|
|
assert!(new.path().join("Arrival").exists(), "left where it was");
|
|
let paths = file_paths(&state).await;
|
|
let root = new.path().to_str().expect("utf-8");
|
|
assert!(
|
|
paths.iter().all(|path| path.starts_with(root)),
|
|
"every row follows the root, the stranded one included: {paths:?}"
|
|
);
|
|
assert_eq!(stored_path(&base, 1).await, root);
|
|
}
|
|
|
|
/// Issue #236: the move is all or nothing. A folder that cannot be
|
|
/// renamed part-way through takes the whole change down: what had already
|
|
/// moved is moved back, the root row keeps the old path, and the rows
|
|
/// still describe the disk, so the operator can fix the folder and send
|
|
/// the same request again.
|
|
#[cfg(unix)]
|
|
#[tokio::test]
|
|
async fn one_folder_that_cannot_move_puts_the_others_back() {
|
|
use std::os::unix::fs::PermissionsExt;
|
|
|
|
if !crate::privilege::mode_bits_bind().await {
|
|
crate::privilege::skipped_because_privileged(
|
|
"one_folder_that_cannot_move_puts_the_others_back",
|
|
);
|
|
return;
|
|
}
|
|
|
|
let (_dir, state, base) = application().await;
|
|
let old = tempfile::tempdir().expect("old root");
|
|
let home = tempfile::tempdir().expect("home of the new path");
|
|
let new = home.path().join("relocated-main");
|
|
point_root_at(&state, 1, old.path()).await;
|
|
|
|
let mut folders = Vec::new();
|
|
for (tmdb_id, title) in [(100, "Dune"), (101, "Arrival"), (102, "Sicario")] {
|
|
let id = add_movie(&base, tmdb_id, title, 1).await;
|
|
folders.push(library_folder(&state, id, old.path(), title).await);
|
|
}
|
|
// Moving a directory to another parent rewrites its `..`, which needs
|
|
// write permission on the directory itself: the second title cannot
|
|
// move, the first already has.
|
|
let stuck = folders[1].clone();
|
|
tokio::fs::set_permissions(&stuck, std::fs::Permissions::from_mode(0o555))
|
|
.await
|
|
.expect("freeze the second title folder");
|
|
|
|
let before = file_paths(&state).await;
|
|
let response = reqwest::Client::new()
|
|
.put(format!("{base}/api/roots/1"))
|
|
.json(&root_payload(&base, 1, new.to_str().expect("utf-8")).await)
|
|
.send()
|
|
.await
|
|
.expect("move the root");
|
|
assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
|
|
let body: serde_json::Value = response.json().await.expect("error body");
|
|
assert!(
|
|
body["error"]
|
|
.as_str()
|
|
.expect("text")
|
|
.contains(stuck.to_str().expect("utf-8")),
|
|
"the error names the folder that stopped the move: {body}"
|
|
);
|
|
|
|
tokio::fs::set_permissions(&stuck, std::fs::Permissions::from_mode(0o755))
|
|
.await
|
|
.expect("thaw the second title folder");
|
|
for folder in &folders {
|
|
assert!(
|
|
folder.join("feature.mkv").exists(),
|
|
"{} is back where the row says it is",
|
|
folder.display()
|
|
);
|
|
}
|
|
assert!(
|
|
!new.exists(),
|
|
"the directory created for the failed move was removed, since it never held anything"
|
|
);
|
|
assert_eq!(file_paths(&state).await, before, "the rows are untouched");
|
|
assert_eq!(
|
|
stored_path(&base, 1).await,
|
|
old.path().to_str().expect("utf-8"),
|
|
"the root still points where the files are"
|
|
);
|
|
|
|
// The same request again, with the folder fixed, is the retry path.
|
|
let response = reqwest::Client::new()
|
|
.put(format!("{base}/api/roots/1"))
|
|
.json(&root_payload(&base, 1, new.to_str().expect("utf-8")).await)
|
|
.send()
|
|
.await
|
|
.expect("retry the move");
|
|
assert_eq!(response.status(), StatusCode::OK);
|
|
for title in ["Dune", "Arrival", "Sicario"] {
|
|
assert!(new.join(title).join("feature.mkv").exists());
|
|
}
|
|
}
|
|
|
|
/// Issue #243: a directory a failed move did not create is not this
|
|
/// request's to remove, even though it sits at the same new root path.
|
|
#[cfg(unix)]
|
|
#[tokio::test]
|
|
async fn a_new_root_that_already_existed_survives_a_failed_move() {
|
|
use std::os::unix::fs::PermissionsExt;
|
|
|
|
if !crate::privilege::mode_bits_bind().await {
|
|
crate::privilege::skipped_because_privileged(
|
|
"a_new_root_that_already_existed_survives_a_failed_move",
|
|
);
|
|
return;
|
|
}
|
|
|
|
let (_dir, state, base) = application().await;
|
|
let old = tempfile::tempdir().expect("old root");
|
|
let new = tempfile::tempdir().expect("new root, already there");
|
|
point_root_at(&state, 1, old.path()).await;
|
|
|
|
let id = add_movie(&base, 100, "Dune", 1).await;
|
|
let stuck = library_folder(&state, id, old.path(), "Dune").await;
|
|
tokio::fs::set_permissions(&stuck, std::fs::Permissions::from_mode(0o555))
|
|
.await
|
|
.expect("freeze the title folder");
|
|
|
|
let response = reqwest::Client::new()
|
|
.put(format!("{base}/api/roots/1"))
|
|
.json(&root_payload(&base, 1, new.path().to_str().expect("utf-8")).await)
|
|
.send()
|
|
.await
|
|
.expect("move the root");
|
|
assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
|
|
|
|
tokio::fs::set_permissions(&stuck, std::fs::Permissions::from_mode(0o755))
|
|
.await
|
|
.expect("thaw the title folder");
|
|
assert!(
|
|
new.path().exists(),
|
|
"a directory this request did not create is left alone"
|
|
);
|
|
}
|
|
|
|
/// Issue #243: a path differing only by a trailing separator is the same
|
|
/// root, not a self-conflict — `PUT` with it is a no-op.
|
|
#[tokio::test]
|
|
async fn a_trailing_separator_is_not_a_path_change() {
|
|
let (_dir, state, base) = application().await;
|
|
let old = tempfile::tempdir().expect("old root");
|
|
point_root_at(&state, 1, old.path()).await;
|
|
let id = add_movie(&base, 100, "Dune", 1).await;
|
|
let folder = library_folder(&state, id, old.path(), "Dune").await;
|
|
|
|
let mut trailing = old.path().to_str().expect("utf-8").to_owned();
|
|
trailing.push('/');
|
|
let response = reqwest::Client::new()
|
|
.put(format!("{base}/api/roots/1"))
|
|
.json(&root_payload(&base, 1, &trailing).await)
|
|
.send()
|
|
.await
|
|
.expect("update root with a trailing separator");
|
|
assert_eq!(response.status(), StatusCode::OK);
|
|
|
|
assert!(folder.exists(), "nothing on disk moved");
|
|
assert_eq!(
|
|
stored_path(&base, 1).await,
|
|
old.path().to_str().expect("utf-8"),
|
|
"the stored path keeps no trailing separator"
|
|
);
|
|
}
|
|
|
|
/// Issue #243: `create` normalises the same way, so a trailing separator
|
|
/// cannot dodge the duplicate-path check.
|
|
#[tokio::test]
|
|
async fn create_normalises_a_trailing_separator_before_checking_for_conflicts() {
|
|
let (_dir, _state, base) = application().await;
|
|
let policy_ids = first_policy_ids(&base).await;
|
|
let roots: Vec<serde_json::Value> = reqwest::get(format!("{base}/api/roots"))
|
|
.await
|
|
.expect("roots")
|
|
.json()
|
|
.await
|
|
.expect("roots json");
|
|
let existing_path = roots[0]["path"].as_str().expect("path").to_owned();
|
|
|
|
// Free the (movie, kids) pair so the conflict below is the path
|
|
// check alone, not the (kind, audience) uniqueness check.
|
|
let kids_root = roots[1]["id"].as_i64().expect("movie kids root id");
|
|
let freed = reqwest::Client::new()
|
|
.delete(format!("{base}/api/roots/{kids_root}"))
|
|
.send()
|
|
.await
|
|
.expect("free the seeded movie kids root");
|
|
assert_eq!(freed.status(), StatusCode::NO_CONTENT);
|
|
|
|
let mut payload = root_input(policy_ids[0]);
|
|
payload["audience"] = serde_json::json!("kids");
|
|
payload["path"] = serde_json::json!(format!("{existing_path}/"));
|
|
let response = reqwest::Client::new()
|
|
.post(format!("{base}/api/roots"))
|
|
.json(&payload)
|
|
.send()
|
|
.await
|
|
.expect("create root with a trailing separator");
|
|
assert_eq!(
|
|
response.status(),
|
|
StatusCode::CONFLICT,
|
|
"a trailing separator does not dodge the duplicate-path check"
|
|
);
|
|
}
|
|
|
|
/// Issue #236: a TV root carries series folders, whose files hang off
|
|
/// episodes rather than off the title row. They move with the root too.
|
|
#[tokio::test]
|
|
async fn a_tv_root_moves_its_series_folders() {
|
|
let (_dir, state, base) = application().await;
|
|
let old = tempfile::tempdir().expect("old root");
|
|
let home = tempfile::tempdir().expect("home of the new path");
|
|
let new = home.path().join("relocated-tv");
|
|
point_root_at(&state, 3, old.path()).await;
|
|
|
|
let pool = state.database().expect("database").pool();
|
|
sqlx::query(
|
|
"INSERT INTO series (id, tmdb_id, title, year, original_language, root_id)
|
|
VALUES (1, 82728, 'Bluey', 2018, 'en', 3)",
|
|
)
|
|
.execute(pool)
|
|
.await
|
|
.expect("series");
|
|
sqlx::query("INSERT INTO seasons (id, series_id, number) VALUES (1, 1, 1)")
|
|
.execute(pool)
|
|
.await
|
|
.expect("season");
|
|
sqlx::query(
|
|
"INSERT INTO episodes (id, season_id, number, title) VALUES (1, 1, 2, 'Hospital')",
|
|
)
|
|
.execute(pool)
|
|
.await
|
|
.expect("episode");
|
|
let season = old
|
|
.path()
|
|
.join("Bluey (2018) [tmdbid-82728]")
|
|
.join("Season 01");
|
|
tokio::fs::create_dir_all(&season)
|
|
.await
|
|
.expect("create season folder");
|
|
let episode = season.join("Bluey - S01E02.mkv");
|
|
tokio::fs::write(&episode, b"episode").await.expect("write");
|
|
sqlx::query(
|
|
"INSERT INTO media_files (owner_kind, owner_id, path, size) VALUES ('episode', 1, ?, 9)",
|
|
)
|
|
.bind(episode.to_str().expect("utf-8 path"))
|
|
.execute(pool)
|
|
.await
|
|
.expect("media file");
|
|
|
|
let response = reqwest::Client::new()
|
|
.put(format!("{base}/api/roots/3"))
|
|
.json(&root_payload(&base, 3, new.to_str().expect("utf-8")).await)
|
|
.send()
|
|
.await
|
|
.expect("move the root");
|
|
assert_eq!(response.status(), StatusCode::OK);
|
|
|
|
assert!(
|
|
new.join("Bluey (2018) [tmdbid-82728]")
|
|
.join("Season 01")
|
|
.join("Bluey - S01E02.mkv")
|
|
.exists(),
|
|
"the whole series folder moved, season layout intact"
|
|
);
|
|
let paths = file_paths(&state).await;
|
|
assert!(
|
|
std::path::Path::new(&paths[0]).starts_with(&new)
|
|
&& std::path::Path::new(&paths[0]).exists(),
|
|
"the episode row follows the file: {}",
|
|
paths[0]
|
|
);
|
|
}
|
|
|
|
/// Issue #244: a root *stored* with a trailing separator — creatable
|
|
/// through the API at any point before #243 — could not be edited at
|
|
/// all. The payload was normalised and the stored value was not, so no
|
|
/// payload compared equal: every edit took the relocation branch, where
|
|
/// every planned destination is its own source and the pre-check 409s.
|
|
#[tokio::test]
|
|
async fn a_root_stored_with_a_trailing_separator_can_still_be_edited() {
|
|
let (_dir, state, base) = application().await;
|
|
let old = tempfile::tempdir().expect("old root");
|
|
let unslashed = old.path().to_str().expect("utf-8").to_owned();
|
|
point_root_at_raw(&state, 1, &format!("{unslashed}/")).await;
|
|
let id = add_movie(&base, 100, "Dune", 1).await;
|
|
let folder = library_folder(&state, id, old.path(), "Dune").await;
|
|
|
|
// What the settings view sends back: the path exactly as stored,
|
|
// separator included, with only the policy changed.
|
|
let kids_policy = policy_id_named(&base, "Movies — kids").await;
|
|
let mut payload = root_payload(&base, 1, &format!("{unslashed}/")).await;
|
|
payload["policy_id"] = kids_policy.into();
|
|
let response = reqwest::Client::new()
|
|
.put(format!("{base}/api/roots/1"))
|
|
.json(&payload)
|
|
.send()
|
|
.await
|
|
.expect("edit the root");
|
|
assert_eq!(
|
|
response.status(),
|
|
StatusCode::OK,
|
|
"{:?}",
|
|
response.text().await
|
|
);
|
|
|
|
assert!(folder.join("feature.mkv").exists(), "nothing on disk moved");
|
|
assert_eq!(
|
|
stored_path(&base, 1).await,
|
|
unslashed,
|
|
"the row is left normalised, so the next edit compares equal too"
|
|
);
|
|
|
|
// And the same payload without the separator is not a relocation
|
|
// either.
|
|
let response = reqwest::Client::new()
|
|
.put(format!("{base}/api/roots/1"))
|
|
.json(&root_payload(&base, 1, &unslashed).await)
|
|
.send()
|
|
.await
|
|
.expect("edit the root again");
|
|
assert_eq!(response.status(), StatusCode::OK);
|
|
assert!(folder.join("feature.mkv").exists(), "still nothing moved");
|
|
}
|
|
|
|
/// Issue #244: a real path change from a root stored with a trailing
|
|
/// separator plans from the normalised value, so neither a destination
|
|
/// nor a rewritten row carries a doubled separator.
|
|
#[tokio::test]
|
|
async fn relocating_a_slash_stored_root_plans_no_doubled_separator() {
|
|
let (_dir, state, base) = application().await;
|
|
let old = tempfile::tempdir().expect("old root");
|
|
let home = tempfile::tempdir().expect("home of the new path");
|
|
let new = home.path().join("relocated-main");
|
|
let unslashed = old.path().to_str().expect("utf-8").to_owned();
|
|
point_root_at_raw(&state, 1, &format!("{unslashed}/")).await;
|
|
let id = add_movie(&base, 100, "Dune", 1).await;
|
|
let folder = library_folder(&state, id, old.path(), "Dune").await;
|
|
|
|
let response = reqwest::Client::new()
|
|
.put(format!("{base}/api/roots/1"))
|
|
.json(&root_payload(&base, 1, new.to_str().expect("utf-8")).await)
|
|
.send()
|
|
.await
|
|
.expect("move the root");
|
|
assert_eq!(
|
|
response.status(),
|
|
StatusCode::OK,
|
|
"{:?}",
|
|
response.text().await
|
|
);
|
|
|
|
assert!(!folder.exists(), "the folder left the old path");
|
|
assert!(new.join("Dune").join("feature.mkv").exists());
|
|
let paths = file_paths(&state).await;
|
|
assert!(
|
|
!paths[0].contains("//"),
|
|
"no doubled separator in the rewritten row: {}",
|
|
paths[0]
|
|
);
|
|
assert!(
|
|
std::path::Path::new(&paths[0]).exists(),
|
|
"the rewritten path describes the disk: {}",
|
|
paths[0]
|
|
);
|
|
assert_eq!(stored_path(&base, 1).await, new.to_str().expect("utf-8"));
|
|
}
|
|
|
|
/// Issue #244: `create_dir_all` can materialise more than one level for
|
|
/// a root path pointed somewhere fresh. A failed move removes every
|
|
/// level it created, not only the leaf — and still nothing that was
|
|
/// already on disk.
|
|
#[cfg(unix)]
|
|
#[tokio::test]
|
|
async fn a_failed_move_removes_every_level_it_created() {
|
|
use std::os::unix::fs::PermissionsExt;
|
|
|
|
if !crate::privilege::mode_bits_bind().await {
|
|
crate::privilege::skipped_because_privileged(
|
|
"a_failed_move_removes_every_level_it_created",
|
|
);
|
|
return;
|
|
}
|
|
|
|
let (_dir, state, base) = application().await;
|
|
let old = tempfile::tempdir().expect("old root");
|
|
let home = tempfile::tempdir().expect("home of the new path");
|
|
// Three levels below a directory that is already there.
|
|
let top = home.path().join("media-v2");
|
|
let new = top.join("tv").join("kids");
|
|
point_root_at(&state, 1, old.path()).await;
|
|
|
|
let id = add_movie(&base, 100, "Dune", 1).await;
|
|
let stuck = library_folder(&state, id, old.path(), "Dune").await;
|
|
// Moving a directory to another parent rewrites its `..`, which
|
|
// needs write permission on the directory itself.
|
|
tokio::fs::set_permissions(&stuck, std::fs::Permissions::from_mode(0o555))
|
|
.await
|
|
.expect("freeze the title folder");
|
|
|
|
let response = reqwest::Client::new()
|
|
.put(format!("{base}/api/roots/1"))
|
|
.json(&root_payload(&base, 1, new.to_str().expect("utf-8")).await)
|
|
.send()
|
|
.await
|
|
.expect("move the root");
|
|
assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
|
|
|
|
tokio::fs::set_permissions(&stuck, std::fs::Permissions::from_mode(0o755))
|
|
.await
|
|
.expect("thaw the title folder");
|
|
assert!(
|
|
!top.exists(),
|
|
"every level the failed move created is gone, not only the leaf"
|
|
);
|
|
assert!(
|
|
home.path().exists(),
|
|
"the level that was already there is left alone"
|
|
);
|
|
assert!(
|
|
stuck.join("feature.mkv").exists(),
|
|
"the folder is still where the row says it is"
|
|
);
|
|
assert_eq!(
|
|
stored_path(&base, 1).await,
|
|
old.path().to_str().expect("utf-8")
|
|
);
|
|
}
|
|
|
|
/// Issue #244: two roots naming one directory, differing only by a
|
|
/// trailing separator, are not two roots. The unique index compares raw
|
|
/// strings and cannot see it, so the check normalises both sides.
|
|
#[tokio::test]
|
|
async fn a_slash_stored_path_is_not_free_for_another_root() {
|
|
let (_dir, state, base) = application().await;
|
|
point_root_at_raw(&state, 2, "/mnt/media/movies/archive/").await;
|
|
|
|
// An update onto the stripped form of a path another root holds.
|
|
let response = reqwest::Client::new()
|
|
.put(format!("{base}/api/roots/1"))
|
|
.json(&root_payload(&base, 1, "/mnt/media/movies/archive").await)
|
|
.send()
|
|
.await
|
|
.expect("update onto the other root's path");
|
|
assert_eq!(response.status(), StatusCode::CONFLICT);
|
|
|
|
// And a create, which the unique index would have let through.
|
|
let policy_ids = first_policy_ids(&base).await;
|
|
let mut payload = root_input(policy_ids[0]);
|
|
payload["kind"] = serde_json::json!("tv");
|
|
payload["audience"] = serde_json::json!("main");
|
|
payload["path"] = serde_json::json!("/mnt/media/movies/archive");
|
|
let deleted = reqwest::Client::new()
|
|
.delete(format!("{base}/api/roots/3"))
|
|
.send()
|
|
.await
|
|
.expect("free the (tv, main) pair");
|
|
assert_eq!(deleted.status(), StatusCode::NO_CONTENT);
|
|
let response = reqwest::Client::new()
|
|
.post(format!("{base}/api/roots"))
|
|
.json(&payload)
|
|
.send()
|
|
.await
|
|
.expect("create onto the other root's path");
|
|
assert_eq!(response.status(), StatusCode::CONFLICT);
|
|
}
|
|
|
|
/// Issue #236: a path another root already holds is refused before the
|
|
/// disk is touched at all.
|
|
#[tokio::test]
|
|
async fn a_path_another_root_holds_is_refused_before_any_move() {
|
|
let (_dir, state, base) = application().await;
|
|
let old = tempfile::tempdir().expect("old root");
|
|
let taken = tempfile::tempdir().expect("taken root");
|
|
point_root_at(&state, 1, old.path()).await;
|
|
point_root_at(&state, 2, taken.path()).await;
|
|
let id = add_movie(&base, 100, "Dune", 1).await;
|
|
let folder = library_folder(&state, id, old.path(), "Dune").await;
|
|
|
|
let response = reqwest::Client::new()
|
|
.put(format!("{base}/api/roots/1"))
|
|
.json(&root_payload(&base, 1, taken.path().to_str().expect("utf-8")).await)
|
|
.send()
|
|
.await
|
|
.expect("move the root");
|
|
assert_eq!(response.status(), StatusCode::CONFLICT);
|
|
assert!(folder.exists(), "nothing moved");
|
|
assert!(!taken.path().join("Dune").exists());
|
|
}
|
|
}
|