928 lines
33 KiB
Rust
928 lines
33 KiB
Rust
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,
|
|
});
|
|
review.reviewed_files = [Some("live 👩🔬\r\n"), None, Some("")]
|
|
.into_iter()
|
|
.enumerate()
|
|
.map(|(index, after)| ReviewedFile {
|
|
revisions: [format!("before-{index}"), "after".into()],
|
|
path: "日本語.rs".into(),
|
|
old_path: (index == 0).then(|| "old.rs".into()),
|
|
after: after.map(str::to_owned),
|
|
})
|
|
.collect();
|
|
{
|
|
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);
|
|
assert_eq!(listed[0].reviewed_files, review.reviewed_files);
|
|
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.reviewed_files, review.reviewed_files);
|
|
assert_eq!(loaded.verdict, review.verdict);
|
|
assert!(
|
|
loaded.comments[0]
|
|
.anchor
|
|
.mapped_range(
|
|
"working:new-snapshot",
|
|
Path::new("日本語.rs"),
|
|
Side::Before,
|
|
&["changed".into()],
|
|
true,
|
|
)
|
|
.is_none()
|
|
);
|
|
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"], 4);
|
|
assert_eq!(json["reviewed_files"][0]["after"], "live 👩🔬\r\n");
|
|
assert!(json["reviewed_files"][1]["after"].is_null());
|
|
assert_eq!(json["reviewed_files"][2]["after"], "");
|
|
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);
|
|
assert_eq!(imported.reviewed_files, review.reviewed_files);
|
|
store.save(&mut imported).unwrap();
|
|
assert_ne!(imported.storage_id, Some(id));
|
|
assert_eq!(store.list(&root).unwrap().len(), 2);
|
|
assert_eq!(
|
|
store
|
|
.load(&root, imported.storage_id.unwrap())
|
|
.unwrap()
|
|
.reviewed_files,
|
|
review.reviewed_files,
|
|
);
|
|
let comments = loaded.comments.clone();
|
|
let summary = loaded.summary.clone();
|
|
let verdict = loaded.verdict;
|
|
loaded.reviewed_files.clear();
|
|
store.save(&mut loaded).unwrap();
|
|
drop(store);
|
|
let loaded = Store::open(&storage).unwrap().load(&root, id).unwrap();
|
|
assert!(loaded.reviewed_files.is_empty());
|
|
assert_eq!(loaded.comments, comments);
|
|
assert_eq!(loaded.summary, summary);
|
|
assert_eq!(loaded.verdict, verdict);
|
|
}
|
|
|
|
#[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());
|
|
review.reviewed_files.push(ReviewedFile {
|
|
revisions: ["before".into(), "after".into()],
|
|
path: "source.rs".into(),
|
|
old_path: None,
|
|
after: None,
|
|
});
|
|
first.save(&mut review).unwrap();
|
|
let id = review.storage_id.unwrap();
|
|
let mut draft = second.load(&root, id).unwrap();
|
|
review.summary = "First writer".into();
|
|
review.reviewed_files[0].after = Some("First writer buffer\n".into());
|
|
first.save(&mut review).unwrap();
|
|
draft.summary = "Second writer draft".into();
|
|
draft.reviewed_files.clear();
|
|
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");
|
|
assert_eq!(
|
|
second.load(&root, id).unwrap().reviewed_files,
|
|
review.reviewed_files,
|
|
);
|
|
assert!(draft.reviewed_files.is_empty());
|
|
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() {
|
|
let previous = format!(
|
|
"{REVIEWS_SCHEMA}; {COMMENTS_SCHEMA};
|
|
INSERT INTO reviews VALUES (1, '/old/repo', 1, 'Preserve me', 'looks_good');
|
|
PRAGMA user_version = 2"
|
|
);
|
|
for schema in [
|
|
previous.as_str(),
|
|
"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_read_only(&directory.0).is_err());
|
|
assert_eq!(fs::read(&path).unwrap(), original);
|
|
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 anchor_survives_unrelated_edit() {
|
|
let anchor = Anchor {
|
|
revision: "working:old".into(),
|
|
path: "source.rs".into(),
|
|
side: Side::After,
|
|
start: crate::plugin::TextPosition { row: 1, column: 0 },
|
|
end: crate::plugin::TextPosition { row: 1, column: 6 },
|
|
content: "before\ntarget\nafter\n".into(),
|
|
};
|
|
let map = |text: &str| {
|
|
anchor.mapped_range(
|
|
"working:new",
|
|
Path::new("source.rs"),
|
|
Side::After,
|
|
&text
|
|
.trim_end_matches('\n')
|
|
.split('\n')
|
|
.map(str::to_owned)
|
|
.collect::<Vec<_>>(),
|
|
text.ends_with('\n'),
|
|
)
|
|
};
|
|
assert_eq!(
|
|
map("changed\ntarget\nafter\n"),
|
|
Some(anchor.start..anchor.end)
|
|
);
|
|
assert_eq!(
|
|
map("inserted\nbefore\ntarget\nafter\n"),
|
|
Some(
|
|
crate::plugin::TextPosition { row: 2, column: 0 }..crate::plugin::TextPosition {
|
|
row: 2,
|
|
column: 6
|
|
}
|
|
)
|
|
);
|
|
assert!(map("before\ntargEt\nafter\n").is_none());
|
|
assert!(map("before\nafter\n").is_none());
|
|
assert!(map("before\ntarget\nafter\nbefore\ntarget\nafter\n").is_none());
|
|
assert_eq!(map("target\nbefore\ntarget\nafter\n").unwrap().start.row, 2);
|
|
assert_eq!(anchor.content, "before\ntarget\nafter\n");
|
|
let partial = Anchor {
|
|
content: "header\r\nprefix 👩💻e\u{301} suffix\r\nfooter\r\n".into(),
|
|
start: crate::plugin::TextPosition { row: 1, column: 7 },
|
|
end: crate::plugin::TextPosition { row: 1, column: 9 },
|
|
..anchor.clone()
|
|
};
|
|
let moved = partial
|
|
.mapped_range(
|
|
"working:new",
|
|
Path::new("source.rs"),
|
|
Side::After,
|
|
&[
|
|
"inserted\r".into(),
|
|
"header\r".into(),
|
|
"longer prefix 👩💻e\u{301} suffix\r".into(),
|
|
"footer\r".into(),
|
|
],
|
|
true,
|
|
)
|
|
.unwrap();
|
|
assert_eq!(
|
|
moved.start,
|
|
crate::plugin::TextPosition { row: 2, column: 14 }
|
|
);
|
|
assert_eq!(
|
|
moved.end,
|
|
crate::plugin::TextPosition { row: 2, column: 16 }
|
|
);
|
|
assert!(
|
|
partial
|
|
.mapped_range(
|
|
"other-commit",
|
|
Path::new("source.rs"),
|
|
Side::After,
|
|
&[
|
|
"header\r".into(),
|
|
"prefix 👩💻e\u{301} suffix\r".into(),
|
|
"footer\r".into()
|
|
],
|
|
true
|
|
)
|
|
.is_none()
|
|
);
|
|
}
|
|
|
|
#[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
|
|
.mapped_range(
|
|
"working:other",
|
|
Path::new("source.rs"),
|
|
Side::After,
|
|
&["first".into(), "last".into()],
|
|
true
|
|
)
|
|
.is_some()
|
|
);
|
|
assert!(
|
|
anchor
|
|
.mapped_range(
|
|
"working:immutable",
|
|
Path::new("source.rs"),
|
|
Side::After,
|
|
&["first".into(), "last".into()],
|
|
true
|
|
)
|
|
.is_some()
|
|
);
|
|
assert!(
|
|
anchor
|
|
.mapped_range(
|
|
"working:immutable",
|
|
Path::new("source.rs"),
|
|
Side::After,
|
|
&["first".into(), "last".into()],
|
|
false
|
|
)
|
|
.is_none()
|
|
);
|
|
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();
|
|
review.reviewed_files.push(ReviewedFile {
|
|
revisions: ["before".into(), "after".into()],
|
|
path: "source.rs".into(),
|
|
old_path: None,
|
|
after: None,
|
|
});
|
|
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();
|
|
let original_files = review.reviewed_files.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);
|
|
assert_eq!(loaded.reviewed_files, original_files);
|
|
store
|
|
.connection
|
|
.execute_batch("DROP TRIGGER reject_comment")
|
|
.unwrap();
|
|
store
|
|
.connection
|
|
.execute_batch(
|
|
"CREATE TEMP TRIGGER reject_reviewed BEFORE INSERT ON reviewed_files
|
|
BEGIN SELECT RAISE(ABORT, 'blocked reviewed file'); END",
|
|
)
|
|
.unwrap();
|
|
review.reviewed_files[0].after = Some("Unsaved buffer\n".into());
|
|
assert!(store.save(&mut review).is_err());
|
|
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.reviewed_files, original_files);
|
|
assert_eq!(loaded.storage_revision, revision);
|
|
store
|
|
.connection
|
|
.execute_batch("DROP TRIGGER reject_reviewed")
|
|
.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, 4] {
|
|
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();
|
|
if version < 4 {
|
|
assert!(error.contains("Unsupported review version"));
|
|
} else {
|
|
assert!(error.contains("missing field"));
|
|
}
|
|
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());
|
|
}
|
|
|
|
#[test]
|
|
fn reviewed_files_reject_invalid_json_and_preserve_existing_data() {
|
|
let directory = directory();
|
|
let root = directory.0.canonicalize().unwrap();
|
|
let path = root.join("review.json");
|
|
let mut store = Store::open(&root.join("storage")).unwrap();
|
|
let mut review = Review::new(root.clone());
|
|
review.reviewed_files.push(ReviewedFile {
|
|
revisions: ["before".into(), "after".into()],
|
|
path: "source.rs".into(),
|
|
old_path: Some("old.rs".into()),
|
|
after: Some("Exact live text\r\n".into()),
|
|
});
|
|
store.save(&mut review).unwrap();
|
|
let valid = serde_json::to_value(&review).unwrap();
|
|
let mut invalid_documents = Vec::new();
|
|
for (field, value) in [
|
|
("path", serde_json::json!("")),
|
|
("path", serde_json::json!("../outside")),
|
|
("path", serde_json::json!("/absolute")),
|
|
("path", serde_json::json!(".git/config")),
|
|
("path", serde_json::json!("nul\0.rs")),
|
|
("old_path", serde_json::json!("../outside")),
|
|
("old_path", serde_json::json!("/absolute")),
|
|
("old_path", serde_json::json!(".jj/repo")),
|
|
("old_path", serde_json::json!("nul\0.rs")),
|
|
("revisions", serde_json::json!(["", "after"])),
|
|
("revisions", serde_json::json!(["before", ""])),
|
|
("revisions", serde_json::json!(["before\n", "after"])),
|
|
("revisions", serde_json::json!(["before", "after\0"])),
|
|
("revisions", serde_json::json!(["before"])),
|
|
("unexpected", serde_json::json!(true)),
|
|
] {
|
|
let mut invalid = valid.clone();
|
|
invalid["reviewed_files"][0][field] = value;
|
|
invalid_documents.push(invalid);
|
|
}
|
|
let mut duplicate = valid.clone();
|
|
duplicate["reviewed_files"]
|
|
.as_array_mut()
|
|
.unwrap()
|
|
.push(valid["reviewed_files"][0].clone());
|
|
invalid_documents.push(duplicate);
|
|
let mut missing = valid.clone();
|
|
missing.as_object_mut().unwrap().remove("reviewed_files");
|
|
invalid_documents.push(missing);
|
|
let mut old = valid;
|
|
old["version"] = 3.into();
|
|
invalid_documents.push(old);
|
|
for invalid in invalid_documents {
|
|
let bytes = serde_json::to_vec(&invalid).unwrap();
|
|
fs::write(&path, &bytes).unwrap();
|
|
assert!(Review::load(&path, &root).is_err());
|
|
assert_eq!(fs::read(&path).unwrap(), bytes);
|
|
if let Ok(mut invalid) = serde_json::from_value::<Review>(invalid) {
|
|
assert!(store.save(&mut invalid).is_err());
|
|
assert!(invalid.save(&root.join("invalid.json")).is_err());
|
|
assert!(!root.join("invalid.json").exists());
|
|
}
|
|
}
|
|
let loaded = store.load(&root, review.storage_id.unwrap()).unwrap();
|
|
assert_eq!(loaded.reviewed_files, review.reviewed_files);
|
|
assert_eq!(store.list(&root).unwrap().len(), 1);
|
|
#[cfg(unix)]
|
|
{
|
|
use std::os::unix::ffi::OsStringExt;
|
|
for old_path in [false, true] {
|
|
let mut invalid = review.clone();
|
|
let path = PathBuf::from(std::ffi::OsString::from_vec(vec![0xff]));
|
|
if old_path {
|
|
invalid.reviewed_files[0].old_path = Some(path);
|
|
} else {
|
|
invalid.reviewed_files[0].path = path;
|
|
}
|
|
assert!(store.save(&mut invalid).is_err());
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn reviewed_file_rows_enforce_identity_and_limit_snapshots_before_loading() {
|
|
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.reviewed_files.push(ReviewedFile {
|
|
revisions: ["before".into(), "after".into()],
|
|
path: "source.rs".into(),
|
|
old_path: None,
|
|
after: None,
|
|
});
|
|
store.save(&mut review).unwrap();
|
|
let id = review.storage_id.unwrap();
|
|
for update in [
|
|
"review_id = 0",
|
|
"before_revision = ''",
|
|
"after_revision = ''",
|
|
"path = NULL",
|
|
"after_content = zeroblob(1)",
|
|
"position = -1",
|
|
] {
|
|
assert!(
|
|
store
|
|
.connection
|
|
.execute(
|
|
&format!("UPDATE reviewed_files SET {update} WHERE review_id = ?1"),
|
|
[id],
|
|
)
|
|
.is_err(),
|
|
"{update}",
|
|
);
|
|
}
|
|
assert!(
|
|
store
|
|
.connection
|
|
.execute(
|
|
"INSERT INTO reviewed_files
|
|
SELECT review_id, position + 1, before_revision, after_revision,
|
|
path, old_path, after_content FROM reviewed_files WHERE review_id = ?1",
|
|
[id],
|
|
)
|
|
.is_err()
|
|
);
|
|
for update in [
|
|
"before_revision = char(10)",
|
|
"after_revision = 'after' || char(0)",
|
|
"path = '../outside'",
|
|
"old_path = '../outside'",
|
|
"old_path = 'nul' || char(0)",
|
|
"position = 1",
|
|
] {
|
|
store
|
|
.connection
|
|
.execute(
|
|
&format!("UPDATE reviewed_files 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 reviewed_files SET after_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"));
|
|
review.reviewed_files[0].after = Some("x".repeat(LIMIT));
|
|
assert!(store.save(&mut review).unwrap_err().contains("64 MiB"));
|
|
}
|