//! Integration tests of the viewer commands: `file.info`, `file.hash`, //! `file.neighbours`, `image.annotations.load` and `image.annotations.save`. mod common; use std::path::Path; use common::{TempVault, temp_vault}; use notescraft_engine::{Error, Registry, Session, register_core}; use serde_json::{Value, json}; /// A registry with the core commands. fn registry() -> Registry { let mut registry = Registry::new(); register_core(&mut registry).expect("register the core commands"); registry } /// A session on a fresh vault. fn session(vault: &TempVault) -> Session { Session::open(vault.path()).expect("open the vault") } /// The header of a PNG of the given size. fn png_bytes(width: u32, height: u32) -> Vec { let mut bytes = vec![0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A, 0, 0, 0, 13, b'I', b'H', b'D', b'R']; bytes.extend_from_slice(&width.to_be_bytes()); bytes.extend_from_slice(&height.to_be_bytes()); bytes } /// Writes bytes into the vault. fn write(vault: &Path, path: &str, bytes: &[u8]) { let target = vault.join(path); if let Some(parent) = target.parent() { std::fs::create_dir_all(parent).expect("create the folder"); } std::fs::write(target, bytes).expect("write the file"); } /// A sidecar document with one stroke. fn sidecar(width: u32, height: u32) -> Value { json!({ "version": 1, "image": { "hash": null, "width": width, "height": height }, "items": [ { "type": "stroke", "tool": "pen", "color": { "theme": 0 }, "width": 2.0, "points": [[1.0, 1.0], [3.0, 2.0]] } ] }) } /// The code a failed command reported. fn code(result: &Result) -> String { match result { Err(Error::Command { source, .. }) => source.code.clone(), other => panic!("expected a command error, got {other:?}"), } } /// `file.info` tells what every file of the vault is. #[test] fn file_info_describes_every_kind_of_file() { let vault = temp_vault("viewers-info"); write(vault.path(), "note.md", b"# Note\n"); write(vault.path(), "shot.png", &png_bytes(800, 600)); write(vault.path(), "clip.mp4", b"\x00\x00\x00\x18ftypmp42 rest of the file"); write(vault.path(), "tool.rs", b"fn main() {}\n"); write(vault.path(), "data.bin", &[0x00, 0x01, 0x02, 0xFF]); let registry = registry(); let mut session = session(&vault); let note = registry.run("file.info", &mut session, &json!({ "path": "note.md" })).expect("note.md"); assert_eq!(note.get("kind"), Some(&json!("note"))); assert_eq!(note.get("mime"), Some(&json!("text/markdown"))); assert_eq!(note.get("text"), Some(&json!(true))); assert_eq!(note.get("name"), Some(&json!("note.md"))); assert_eq!(note.get("ext"), Some(&json!("md"))); let picture = registry.run("file.info", &mut session, &json!({ "path": "shot.png" })).expect("shot.png"); assert_eq!(picture.get("kind"), Some(&json!("image"))); assert_eq!(picture.get("mime"), Some(&json!("image/png"))); assert_eq!(picture.get("width"), Some(&json!(800))); assert_eq!(picture.get("height"), Some(&json!(600))); assert_eq!(picture.get("text"), Some(&json!(false))); let video = registry.run("file.info", &mut session, &json!({ "path": "clip.mp4" })).expect("clip.mp4"); assert_eq!(video.get("kind"), Some(&json!("video"))); assert_eq!(video.get("mime"), Some(&json!("video/mp4"))); let code = registry.run("file.info", &mut session, &json!({ "path": "tool.rs" })).expect("tool.rs"); assert_eq!(code.get("kind"), Some(&json!("code"))); assert_eq!(code.get("text"), Some(&json!(true))); let binary = registry.run("file.info", &mut session, &json!({ "path": "data.bin" })).expect("data.bin"); assert_eq!(binary.get("kind"), Some(&json!("other"))); assert_eq!(binary.get("text"), Some(&json!(false))); assert_eq!(binary.get("mime"), Some(&json!("application/octet-stream"))); assert!(binary.get("size").and_then(Value::as_u64).is_some_and(|size| size == 4)); assert!(binary.get("modified_ms").and_then(Value::as_u64).is_some()); } /// `file.info` refuses a folder, a symlink and a path outside the vault. #[test] fn file_info_refuses_a_folder_a_symlink_and_a_escape() { let vault = temp_vault("viewers-info-refused"); write(vault.path(), "shot.png", &png_bytes(10, 10)); std::fs::create_dir_all(vault.path().join("folder")).expect("create the folder"); let registry = registry(); let mut session = session(&vault); assert_eq!(code(®istry.run("file.info", &mut session, &json!({ "path": "folder" }))), "invalid_path"); assert_eq!(code(®istry.run("file.info", &mut session, &json!({ "path": "../outside.png" }))), "invalid_path"); assert_eq!(code(®istry.run("file.info", &mut session, &json!({ "path": "missing.png" }))), "not_found"); #[cfg(unix)] { std::os::unix::fs::symlink(vault.path().join("shot.png"), vault.path().join("link.png")).expect("make the symlink"); assert_eq!(code(®istry.run("file.info", &mut session, &json!({ "path": "link.png" }))), "invalid_path"); } } /// `file.hash` returns the SHA-256 of the file. #[test] fn file_hash_matches_the_known_vector() { let vault = temp_vault("viewers-hash"); write(vault.path(), "abc.txt", b"abc"); let registry = registry(); let mut session = session(&vault); let hash = registry.run("file.hash", &mut session, &json!({ "path": "abc.txt" })).expect("hash"); assert_eq!(hash.get("sha256"), Some(&json!("ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"))); assert_eq!(hash.get("size"), Some(&json!(3))); } /// `file.neighbours` walks the files of the same kind in natural order. #[test] fn file_neighbours_orders_the_files_of_the_same_kind() { let vault = temp_vault("viewers-neighbours"); for name in ["img1.png", "img2.png", "img10.png", "photo.jpg", "notes.md"] { write(vault.path(), name, &png_bytes(10, 10)); } write(vault.path(), "img2.png.annotations.json", b"{}"); write(vault.path(), "data.json", b"{}"); write(vault.path(), "data.json.annotations.json", b"{}"); let registry = registry(); let mut session = session(&vault); let neighbours = registry.run("file.neighbours", &mut session, &json!({ "path": "img2.png" })).expect("neighbours"); assert_eq!(neighbours.get("count"), Some(&json!(4)), "the four pictures count, the note does not"); assert_eq!(neighbours.get("index"), Some(&json!(1))); assert_eq!(neighbours.get("previous"), Some(&json!("img1.png"))); assert_eq!(neighbours.get("next"), Some(&json!("img10.png")), "the number is read as a number"); let first = registry.run("file.neighbours", &mut session, &json!({ "path": "img1.png" })).expect("first"); assert_eq!(first.get("previous"), None, "nothing comes before the first picture"); assert_eq!(first.get("next"), Some(&json!("img2.png"))); let last = registry.run("file.neighbours", &mut session, &json!({ "path": "photo.jpg" })).expect("last"); assert_eq!(last.get("next"), None, "nothing comes after the last picture"); assert_eq!(last.get("previous"), Some(&json!("img10.png"))); let data = registry.run("file.neighbours", &mut session, &json!({ "path": "data.json" })).expect("data"); assert_eq!(data.get("count"), Some(&json!(1)), "the sidecar of a file is not its neighbour"); } /// The annotations of a picture live in a sidecar next to it. #[test] fn the_annotations_round_trip_through_the_sidecar() { let vault = temp_vault("viewers-annotations"); let image = png_bytes(400, 300); write(vault.path(), "shot.png", &image); let registry = registry(); let mut session = session(&vault); let missing = registry.run("image.annotations.load", &mut session, &json!({ "path": "shot.png" })).expect("load"); assert_eq!(missing.get("exists"), Some(&json!(false))); assert_eq!(missing.get("annotations"), None); let document = sidecar(400, 300); let saved = registry.run("image.annotations.save", &mut session, &json!({ "path": "shot.png", "annotations": document })).expect("save"); assert_eq!(saved.get("saved"), Some(&json!(true))); assert!(vault.path().join("shot.png.annotations.json").exists(), "the sidecar is there"); assert_eq!(std::fs::read(vault.path().join("shot.png")).expect("read the picture"), image, "the picture is untouched"); let loaded = registry.run("image.annotations.load", &mut session, &json!({ "path": "shot.png" })).expect("load"); assert_eq!(loaded.get("exists"), Some(&json!(true))); assert_eq!(loaded.get("annotations").and_then(|value| value.get("items")).and_then(Value::as_array).map(Vec::len), Some(1)); // Writing the same document again changes nothing. let again = registry.run("image.annotations.save", &mut session, &json!({ "path": "shot.png", "annotations": document })).expect("save again"); assert_eq!(again.get("saved"), Some(&json!(false)), "the bytes are the same"); } /// A sidecar that does not validate is reported, never touched. #[test] fn a_corrupt_sidecar_is_reported_and_left_alone() { let vault = temp_vault("viewers-corrupt"); write(vault.path(), "shot.png", &png_bytes(400, 300)); write(vault.path(), "shot.png.annotations.json", b"{ not json at all"); let registry = registry(); let mut session = session(&vault); let loaded = registry.run("image.annotations.load", &mut session, &json!({ "path": "shot.png" })).expect("load"); assert_eq!(loaded.get("exists"), Some(&json!(true))); assert_eq!(loaded.get("notice"), Some(&json!("invalid"))); assert_eq!(loaded.get("annotations"), None); assert_eq!(std::fs::read(vault.path().join("shot.png.annotations.json")).expect("read the sidecar"), b"{ not json at all"); } /// Writing over a sidecar that does not validate keeps the old one as `.bad`. #[test] fn saving_over_a_corrupt_sidecar_keeps_it_as_bad() { let vault = temp_vault("viewers-bad"); write(vault.path(), "shot.png", &png_bytes(400, 300)); write(vault.path(), "shot.png.annotations.json", b"{ not json at all"); let registry = registry(); let mut session = session(&vault); let saved = registry.run("image.annotations.save", &mut session, &json!({ "path": "shot.png", "annotations": sidecar(400, 300) })).expect("save"); assert_eq!(saved.get("saved"), Some(&json!(true))); assert_eq!(std::fs::read(vault.path().join("shot.png.annotations.json.bad")).expect("read the bad copy"), b"{ not json at all"); assert!(vault.path().join("shot.png.annotations.json").exists(), "the new sidecar is there"); } /// Saving an empty list of items removes the sidecar. #[test] fn saving_nothing_removes_the_sidecar() { let vault = temp_vault("viewers-empty"); write(vault.path(), "shot.png", &png_bytes(400, 300)); let registry = registry(); let mut session = session(&vault); registry.run("image.annotations.save", &mut session, &json!({ "path": "shot.png", "annotations": sidecar(400, 300) })).expect("save"); assert!(vault.path().join("shot.png.annotations.json").exists()); let empty = json!({ "version": 1, "image": { "hash": null, "width": 400, "height": 300 }, "items": [] }); let saved = registry.run("image.annotations.save", &mut session, &json!({ "path": "shot.png", "annotations": empty })).expect("save"); assert_eq!(saved.get("saved"), Some(&json!(true))); assert!(!vault.path().join("shot.png.annotations.json").exists(), "the sidecar is gone"); } /// A sidecar that belongs to another size of the picture is reported as such. #[test] fn a_sidecar_of_another_size_is_reported() { let vault = temp_vault("viewers-changed"); write(vault.path(), "shot.png", &png_bytes(400, 300)); let document = sidecar(800, 600); write(vault.path(), "shot.png.annotations.json", &serde_json::to_vec(&document).expect("serialise")); let registry = registry(); let mut session = session(&vault); let loaded = registry.run("image.annotations.load", &mut session, &json!({ "path": "shot.png" })).expect("load"); assert_eq!(loaded.get("exists"), Some(&json!(true))); assert_eq!(loaded.get("notice"), Some(&json!("image_changed"))); assert_eq!(loaded.get("stored"), Some(&json!({ "w": 800, "h": 600 }))); assert_eq!(loaded.get("actual"), Some(&json!({ "w": 400, "h": 300 }))); assert_eq!( loaded.get("annotations").and_then(|value| value.get("items")).and_then(Value::as_array).map(Vec::len), Some(1), "the annotations are kept as they are stored" ); } /// A read-only session writes nothing. #[test] fn a_read_only_session_refuses_to_save() { let vault = temp_vault("viewers-readonly"); write(vault.path(), "shot.png", &png_bytes(400, 300)); let registry = registry(); let mut session = Session::open_read_only(vault.path()).expect("open read-only"); let result = registry.run("image.annotations.save", &mut session, &json!({ "path": "shot.png", "annotations": sidecar(400, 300) })); assert!(result.is_err(), "the save is refused"); assert!(!vault.path().join("shot.png.annotations.json").exists(), "nothing was written"); } /// Hostile params never panic a viewer command and never write anything. #[test] fn hostile_params_are_refused() { let vault = temp_vault("viewers-hostile"); write(vault.path(), "shot.png", &png_bytes(400, 300)); let registry = registry(); let mut session = session(&vault); assert!(registry.run("file.info", &mut session, &json!({ "path": "a\0b.png" })).is_err(), "a NUL in the path"); assert!(registry.run("file.info", &mut session, &json!({ "path": 7 })).is_err(), "a path that is not a string"); assert!(registry.run("file.info", &mut session, &json!({ "path": "x".repeat(4096) })).is_err(), "a path that is too long"); assert!(registry.run("file.hash", &mut session, &json!({ "path": "../secret" })).is_err(), "a path outside the vault"); assert!(registry.run("image.annotations.save", &mut session, &json!({ "path": "shot.png", "annotations": "text" })).is_err(), "wrong type"); assert!(registry.run("image.annotations.save", &mut session, &json!({ "path": "shot.png" })).is_err(), "no annotations"); assert!(registry.run("image.annotations.save", &mut session, &json!({ "path": "shot.png", "annotations": { "version": 9 } })).is_err(), "a bad version"); let huge = "x".repeat(9 * 1024 * 1024); assert!(registry.run("image.annotations.save", &mut session, &json!({ "path": "shot.png", "annotations": { "text": huge } })).is_err(), "too many bytes"); assert!(!vault.path().join("shot.png.annotations.json").exists(), "nothing was written"); assert!(registry.run("file.info", &mut session, &json!({ "path": "shot.png" })).is_ok(), "the registry is still usable"); }