use super::*; struct Directory(PathBuf); impl Drop for Directory { fn drop(&mut self) { let _ = fs::remove_dir_all(&self.0); } } fn directory() -> Directory { let directory = Directory(std::env::temp_dir().join(format!( "vamp-review-test-{}-{}", std::process::id(), NEXT_TEMP.fetch_add(1, Ordering::Relaxed), ))); fs::create_dir(&directory.0).unwrap(); directory } #[test] fn database_restarts_preserve_identity_and_exact_stale_anchors() { let directory = directory(); let root = directory.0.canonicalize().unwrap(); let storage = root.join("storage"); let mut review = Review::new(root.clone()); review.summary = "Unicode review\nMore context".into(); review.verdict = Some(Verdict::ChangesRequested); review.comments.push(Comment { id: 7, anchor: Anchor { revision: "working:old-snapshot".into(), path: "日本語.rs".into(), side: Side::Before, start: crate::plugin::TextPosition { row: 0, column: 1 }, end: crate::plugin::TextPosition { row: 1, column: 1 }, content: "a👩‍🔬e\u{301}\r\n界x\n".into(), }, body: "Please preserve 👩‍🔬".into(), resolved: true, }); { let mut store = Store::open(&storage).unwrap(); store.save(&mut review).unwrap(); assert!(review.storage_id.is_some()); assert_eq!(review.storage_revision, 1); } let mut store = Store::open(&storage).unwrap(); let id = review.storage_id.unwrap(); let listed = store.list(&root).unwrap(); assert_eq!(listed[0].storage_id, Some(id)); assert_eq!(listed[0].summary, review.summary); assert_eq!(listed[0].comments, review.comments); let mut loaded = store.load(&root, id).unwrap(); assert_eq!(loaded.storage_id, Some(id)); assert_eq!(loaded.storage_revision, 1); assert_eq!(loaded.comments, review.comments); assert_eq!(loaded.verdict, review.verdict); assert!(!loaded.comments[0].anchor.matches_lines( "working:new-snapshot", Path::new("日本語.rs"), Side::Before, &["changed".into()], true, )); let path = root.join("export.json"); loaded.save(&path).unwrap(); let exported = fs::read(&path).unwrap(); let json: serde_json::Value = serde_json::from_slice(&exported).unwrap(); assert_eq!(json["comments"][0]["resolved"], true); assert_eq!(json["version"], 3); assert!(json.get("storage_id").is_none()); assert!(json.get("storage_revision").is_none()); loaded.summary = "Database only".into(); store.save(&mut loaded).unwrap(); assert_eq!(loaded.storage_id, Some(id)); assert_eq!(loaded.storage_revision, 2); assert_eq!(fs::read(&path).unwrap(), exported); let mut imported = Review::load(&path, &root).unwrap(); assert_eq!(imported.storage_id, None); assert_eq!(imported.storage_revision, 0); store.save(&mut imported).unwrap(); assert_ne!(imported.storage_id, Some(id)); assert_eq!(store.list(&root).unwrap().len(), 2); } #[test] fn database_repository_identity_is_canonical_and_independent() { let first = directory(); let second = directory(); let first_root = first.0.canonicalize().unwrap(); let second_root = second.0.canonicalize().unwrap(); let mut store = Store::open(&first_root.join("storage")).unwrap(); let mut a = Review::new(first_root.clone()); let mut b = Review::new(second_root.clone()); store.save(&mut a).unwrap(); store.save(&mut b).unwrap(); assert_ne!(a.storage_id, b.storage_id); assert_eq!(store.list(&first_root).unwrap().len(), 1); assert_eq!(store.list(&second_root).unwrap().len(), 1); assert!(store.load(&second_root, a.storage_id.unwrap()).is_err()); let id = a.storage_id; let revision = a.storage_revision; a.root = second_root; assert!(store.save(&mut a).is_err()); assert_eq!((a.storage_id, a.storage_revision), (id, revision)); #[cfg(unix)] { let alias = first_root.join("alias"); std::os::unix::fs::symlink(&first_root, &alias).unwrap(); assert_eq!( store.list(&alias).unwrap()[0].storage_id, store.list(&first_root).unwrap()[0].storage_id ); } } #[test] fn database_conflicts_and_locked_writes_keep_drafts_and_identity() { let directory = directory(); let root = directory.0.canonicalize().unwrap(); let storage = root.join("storage"); let mut first = Store::open(&storage).unwrap(); let mut second = Store::open(&storage).unwrap(); let mut review = Review::new(root.clone()); first.save(&mut review).unwrap(); let id = review.storage_id.unwrap(); let mut draft = second.load(&root, id).unwrap(); review.summary = "First writer".into(); first.save(&mut review).unwrap(); draft.summary = "Second writer draft".into(); assert!( second .save(&mut draft) .unwrap_err() .contains("another session") ); assert_eq!(draft.summary, "Second writer draft"); assert_eq!((draft.storage_id, draft.storage_revision), (Some(id), 1)); assert_eq!(second.load(&root, id).unwrap().summary, "First writer"); first.connection.execute_batch("BEGIN IMMEDIATE").unwrap(); let mut fresh = Review::new(root.clone()); assert!(second.save(&mut fresh).unwrap_err().contains("locked")); assert_eq!((fresh.storage_id, fresh.storage_revision), (None, 0)); first.connection.execute_batch("ROLLBACK").unwrap(); second.save(&mut fresh).unwrap(); assert!(fresh.storage_id.is_some()); } #[test] fn incompatible_database_schemas_are_preserved() { for schema in [ "CREATE TABLE other (value TEXT); PRAGMA user_version = 42", "CREATE TABLE other (value TEXT); PRAGMA user_version = 1", "CREATE TABLE reviews (id INTEGER PRIMARY KEY, root TEXT NOT NULL, revision INTEGER NOT NULL, document TEXT NOT NULL) STRICT; INSERT INTO reviews VALUES (1, '/old/repo', 1, '{\"version\":3}'); PRAGMA user_version = 1", "", ] { let directory = directory(); let path = directory.0.join("reviews.sqlite3"); let connection = Connection::open(&path).unwrap(); connection.execute_batch(schema).unwrap(); drop(connection); let original = fs::read(&path).unwrap(); assert!(Store::open(&directory.0).err().unwrap().contains("schema")); assert_eq!(fs::read(&path).unwrap(), original); } } #[test] fn malformed_database_rows_are_rejected_before_loading_snapshots() { let directory = directory(); let root = directory.0.canonicalize().unwrap(); let mut store = Store::open(&root.join("storage")).unwrap(); let mut review = Review::new(root.clone()); review.comments.push(Comment { id: 1, body: "Feedback".into(), resolved: false, anchor: Anchor { revision: "snapshot".into(), path: "file.rs".into(), side: Side::After, start: crate::plugin::TextPosition { row: 0, column: 0 }, end: crate::plugin::TextPosition { row: 0, column: 1 }, content: "界\n".into(), }, }); store.save(&mut review).unwrap(); let id = review.storage_id.unwrap(); for update in [ "resolved = 2", "side = 'invalid'", "id = '0'", "id = '18446744073709551616'", "start_row = -1", "end_column = -1", ] { assert!( store .connection .execute( &format!("UPDATE comments SET {update} WHERE review_id = ?1"), [id], ) .is_err() ); assert_eq!(store.load(&root, id).unwrap().comments, review.comments); } store .connection .execute_batch("PRAGMA ignore_check_constraints = ON") .unwrap(); for update in [ "path = '../outside'", "end_column = 2", "resolved = 2", "side = 'invalid'", "anchor_revision = ''", "id = '0'", "position = 1", ] { store .connection .execute( &format!("UPDATE comments SET {update} WHERE review_id = ?1"), [id], ) .unwrap(); assert!(store.load(&root, id).is_err(), "{update}"); assert!(store.list(&root).is_err(), "{update}"); store.save(&mut review).unwrap(); } store .connection .execute( "UPDATE comments SET content = CAST(zeroblob(?1) AS TEXT) WHERE review_id = ?2", params![LIMIT as i64 + 1, id], ) .unwrap(); assert!(store.load(&root, id).unwrap_err().contains("64 MiB")); assert!(store.list(&root).unwrap_err().contains("64 MiB")); } #[test] fn failed_database_and_export_writes_preserve_metadata_and_documents() { let directory = directory(); let root = directory.0.canonicalize().unwrap(); let mut store = Store::open(&root.join("storage")).unwrap(); let mut review = Review::new(root.clone()); store.save(&mut review).unwrap(); let identity = (review.storage_id, review.storage_revision); store .connection .execute_batch("PRAGMA query_only = ON") .unwrap(); review.summary = "Unsaved draft".into(); assert!(store.save(&mut review).is_err()); assert_eq!((review.storage_id, review.storage_revision), identity); assert_eq!(review.summary, "Unsaved draft"); assert_eq!(store.load(&root, identity.0.unwrap()).unwrap().summary, ""); let mut fresh = Review::new(root.clone()); assert!(store.save(&mut fresh).is_err()); assert_eq!((fresh.storage_id, fresh.storage_revision), (None, 0)); store .connection .execute_batch("PRAGMA query_only = OFF") .unwrap(); review.summary = "x".repeat(LIMIT); assert!(store.save(&mut review).unwrap_err().contains("64 MiB")); assert!( review .save(&root.join("oversized.json")) .unwrap_err() .contains("64 MiB") ); assert!(!root.join("oversized.json").exists()); assert_eq!((review.storage_id, review.storage_revision), identity); assert_eq!(store.load(&root, identity.0.unwrap()).unwrap().summary, ""); let blocked = root.join("not-a-directory"); fs::write(&blocked, b"preserve").unwrap(); assert!(Store::open(&blocked).is_err()); assert_eq!(fs::read(&blocked).unwrap(), b"preserve"); } #[cfg(unix)] #[test] fn database_files_are_private_and_target_symlinks_are_refused() { use std::os::unix::fs::PermissionsExt; let directory = directory(); let storage = directory.0.join("storage"); drop(Store::open(&storage).unwrap()); let path = storage.join("reviews.sqlite3"); assert_eq!( fs::metadata(&storage).unwrap().permissions().mode() & 0o777, 0o700 ); assert_eq!( fs::metadata(&path).unwrap().permissions().mode() & 0o777, 0o600 ); let original = fs::read(&path).unwrap(); let links = directory.0.join("links"); fs::create_dir(&links).unwrap(); std::os::unix::fs::symlink(&path, links.join("reviews.sqlite3")).unwrap(); assert!(Store::open(&links).err().unwrap().contains("symbolic link")); assert_eq!(fs::read(&path).unwrap(), original); } #[test] fn default_directory_uses_absolute_xdg_then_absolute_home() { let resolve = |xdg: Option<&str>, home: Option<&str>| { directory_from(xdg.map(OsStr::new), home.map(OsStr::new)) }; assert_eq!( resolve(Some("/data"), Some("/home/me")).unwrap(), Path::new("/data/vamp/reviews") ); for xdg in [None, Some("relative"), Some("")] { assert_eq!( resolve(xdg, Some("/home/me")).unwrap(), Path::new("/home/me/.local/share/vamp/reviews") ); } assert_eq!( resolve(Some("/data"), None).unwrap(), Path::new("/data/vamp/reviews") ); assert!(resolve(None, None).is_err()); assert!(resolve(None, Some("relative")).is_err()); assert!(resolve(None, Some("")).is_err()); } #[test] fn persistence_validates_anchors_and_preserves_files_on_failure() { let directory = directory(); let root = directory.0.canonicalize().unwrap(); let path = root.join("review.json"); let mut review = Review::new(root.clone()); review.summary = "Local review".into(); review.verdict = Some(Verdict::ChangesRequested); review.comments.push(Comment { id: 1, anchor: Anchor { revision: "working:immutable".into(), path: "source.rs".into(), side: Side::After, start: crate::plugin::TextPosition { row: 0, column: 0 }, end: crate::plugin::TextPosition { row: 2, column: 0 }, content: "first\nlast\n".into(), }, body: "Please adjust this".into(), resolved: false, }); review.save(&path).unwrap(); let mut loaded = Review::load(&path, &root).unwrap(); assert_eq!(loaded.comments, review.comments); assert_eq!(loaded.verdict, review.verdict); let anchor = &loaded.comments[0].anchor; assert!(!anchor.matches_lines( "working:other", Path::new("source.rs"), Side::After, &["first".into(), "last".into()], true )); assert!(anchor.matches_lines( "working:immutable", Path::new("source.rs"), Side::After, &["first".into(), "last".into()], true )); assert!(!anchor.matches_lines( "working:immutable", Path::new("source.rs"), Side::After, &["first".into(), "last".into()], false )); loaded.summary = "Updated".into(); loaded.save(&path).unwrap(); assert_eq!(Review::load(&path, &root).unwrap().summary, "Updated"); assert!(Review::new(root.clone()).save(&path).is_err()); let valid = fs::read(&path).unwrap(); for (field, value) in [ ("version", serde_json::json!(2)), ("root", serde_json::json!(root.join("other"))), ( "comments", serde_json::json!([loaded.comments[0], loaded.comments[0]]), ), ] { let mut invalid = serde_json::to_value(&loaded).unwrap(); invalid[field] = value; fs::write(&path, serde_json::to_vec(&invalid).unwrap()).unwrap(); assert!(Review::load(&path, &root).is_err()); } for (field, value) in [ ("path", serde_json::json!("../outside")), ("path", serde_json::json!("/absolute")), ("path", serde_json::json!(".git/config")), ("start", serde_json::json!({"row": 2, "column": 1})), ("start", serde_json::json!({"row": 3, "column": 0})), ("end", serde_json::json!({"row": 3, "column": 0})), ("revision", serde_json::json!("")), ] { let mut invalid = serde_json::to_value(&loaded).unwrap(); invalid["comments"][0]["anchor"][field] = value; fs::write(&path, serde_json::to_vec(&invalid).unwrap()).unwrap(); assert!(Review::load(&path, &root).is_err()); } fs::create_dir(root.join("other")).unwrap(); fs::write(&path, &valid).unwrap(); assert!(Review::load(&path, &root.join("other")).is_err()); fs::write(&path, b"external edit").unwrap(); assert!(loaded.save(&path).is_err()); assert_eq!(fs::read(&path).unwrap(), b"external edit"); assert_eq!(loaded.summary, "Updated"); let invalid = root.join("invalid.json"); loaded.comments[0].anchor.start.row = usize::MAX; assert!(loaded.save(&invalid).is_err()); assert!(!invalid.exists()); let large = root.join("large.json"); fs::File::create(&large) .unwrap() .set_len(LIMIT as u64 + 1) .unwrap(); assert!(Review::load(&large, &root).is_err()); #[cfg(unix)] { let link = root.join("link.json"); std::os::unix::fs::symlink(&path, &link).unwrap(); assert!(Review::load(&link, &root).is_err()); assert!(review.save(&link).is_err()); assert_eq!(fs::read(&path).unwrap(), b"external edit"); } } #[test] fn comment_rows_preserve_order_full_ids_and_atomic_replacement() { let directory = directory(); let root = directory.0.canonicalize().unwrap(); let mut store = Store::open(&root.join("storage")).unwrap(); let mut review = Review::new(root.clone()); review.comments = [u64::MAX, 7] .into_iter() .map(|id| Comment { id, body: format!("Feedback {id}"), resolved: id == 7, anchor: Anchor { revision: "snapshot".into(), path: "source.rs".into(), side: Side::Before, start: crate::plugin::TextPosition { row: 0, column: 0 }, end: crate::plugin::TextPosition { row: 0, column: 1 }, content: "界\n".into(), }, }) .collect(); review.summary = "Original".into(); store.save(&mut review).unwrap(); let id = review.storage_id.unwrap(); assert_eq!(store.load(&root, id).unwrap().comments, review.comments); assert!( store .connection .execute( "UPDATE comments SET review_id = ?1 WHERE review_id = ?2", params![id + 1, id], ) .is_err() ); assert!( store .connection .execute("UPDATE comments SET id = '7' WHERE review_id = ?1", [id],) .is_err() ); store .connection .execute_batch( "CREATE TEMP TRIGGER reject_comment BEFORE INSERT ON comments WHEN NEW.id = '7' BEGIN SELECT RAISE(ABORT, 'blocked comment'); END", ) .unwrap(); let revision = review.storage_revision; let original = review.comments.clone(); review.summary = "Unsaved".into(); review.comments[0].body = "Changed".into(); assert!( store .save(&mut review) .unwrap_err() .contains("blocked comment") ); assert_eq!(review.storage_revision, revision); let loaded = store.load(&root, id).unwrap(); assert_eq!(loaded.summary, "Original"); assert_eq!(loaded.comments, original); assert_eq!(loaded.storage_revision, revision); store .connection .execute_batch("DROP TRIGGER reject_comment") .unwrap(); review.comments.reverse(); store.save(&mut review).unwrap(); assert_eq!(store.load(&root, id).unwrap().comments, review.comments); review.comments.pop(); store.save(&mut review).unwrap(); assert_eq!(store.load(&root, id).unwrap().comments, review.comments); review.comments.clear(); store.save(&mut review).unwrap(); assert!(store.load(&root, id).unwrap().comments.is_empty()); assert_eq!(store.list(&root).unwrap()[0].summary, "Unsaved"); } #[test] fn exact_grapheme_ranges_roundtrip_and_reject_invalid_coordinates() { use crate::plugin::TextPosition; let directory = directory(); let root = directory.0.canonicalize().unwrap(); let path = root.join("review.json"); let mut review = Review::new(root.clone()); review.comments.push(Comment { id: 1, anchor: Anchor { revision: "working:immutable".into(), path: "source.rs".into(), side: Side::After, start: TextPosition { row: 0, column: 1 }, end: TextPosition { row: 1, column: 1 }, content: "a👩‍🔬e\u{301}\n界x\n".into(), }, body: "Exact text".into(), resolved: false, }); review.save(&path).unwrap(); let mut loaded = Review::load(&path, &root).unwrap(); assert_eq!(loaded.comments, review.comments); assert_eq!(loaded.comments[0].anchor.rows(), 0..=1); let valid = serde_json::to_value(&review).unwrap(); let mut missing_resolution = valid.clone(); missing_resolution["comments"][0] .as_object_mut() .unwrap() .remove("resolved"); for version in [2, 3] { missing_resolution["version"] = version.into(); let bytes = serde_json::to_vec(&missing_resolution).unwrap(); fs::write(&path, &bytes).unwrap(); let error = Review::load(&path, &root).unwrap_err(); assert!(error.contains(if version == 2 { "Unsupported review version 2" } else { "missing field `resolved`" })); assert_eq!(fs::read(&path).unwrap(), bytes); } for (field, value) in [ ("start", serde_json::json!({"row": 0, "column": 4})), ("end", serde_json::json!({"row": 1, "column": 3})), ("end", serde_json::json!({"row": 2, "column": 1})), ("end", serde_json::json!({"row": 3, "column": 0})), ("end", serde_json::json!({"row": 0, "column": 0})), ("start", serde_json::json!({"row": -1, "column": 0})), ( "start", serde_json::json!({"row": 0, "column": 0, "extra": 0}), ), ("start_line", serde_json::json!(1)), ] { let mut invalid = valid.clone(); invalid["comments"][0]["anchor"][field] = value; fs::write(&path, serde_json::to_vec(&invalid).unwrap()).unwrap(); assert!(Review::load(&path, &root).is_err(), "{field}"); } let mut wrong_version = valid; wrong_version["version"] = 1.into(); fs::write(&path, serde_json::to_vec(&wrong_version).unwrap()).unwrap(); assert!(Review::load(&path, &root).is_err()); loaded.comments[0].anchor.end = TextPosition { row: 0, column: 0 }; assert!(loaded.save(&root.join("backwards.json")).is_err()); }