519 lines
19 KiB
Rust
519 lines
19 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,
|
|
});
|
|
{
|
|
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();
|
|
assert_eq!(
|
|
store.list(&root).unwrap(),
|
|
vec![(id, "Unicode review".into())]
|
|
);
|
|
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(
|
|
"working:new-snapshot",
|
|
Path::new("日本語.rs"),
|
|
Side::Before,
|
|
"changed\n",
|
|
));
|
|
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(),
|
|
store.list(&first_root).unwrap()
|
|
);
|
|
}
|
|
}
|
|
|
|
#[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",
|
|
"",
|
|
] {
|
|
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_or_mismatched_database_documents_are_rejected() {
|
|
let other = directory();
|
|
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 id = review.storage_id.unwrap();
|
|
let mut wrong_root = serde_json::to_value(&review).unwrap();
|
|
wrong_root["root"] = serde_json::json!(other.0.canonicalize().unwrap());
|
|
let mut invalid_version = serde_json::to_value(&review).unwrap();
|
|
invalid_version["version"] = 1.into();
|
|
let mut inherited_identity = serde_json::to_value(&review).unwrap();
|
|
inherited_identity["storage_id"] = id.into();
|
|
for document in [
|
|
"{".to_owned(),
|
|
serde_json::to_string(&wrong_root).unwrap(),
|
|
serde_json::to_string(&invalid_version).unwrap(),
|
|
serde_json::to_string(&inherited_identity).unwrap(),
|
|
] {
|
|
store
|
|
.connection
|
|
.execute(
|
|
"UPDATE reviews SET document = ?1 WHERE id = ?2",
|
|
params![document, id],
|
|
)
|
|
.unwrap();
|
|
assert!(store.load(&root, id).is_err());
|
|
assert!(store.list(&root).is_err());
|
|
}
|
|
store
|
|
.connection
|
|
.execute_batch("PRAGMA ignore_check_constraints = ON")
|
|
.unwrap();
|
|
store
|
|
.connection
|
|
.execute(
|
|
"UPDATE reviews SET document = CAST(zeroblob(?1) AS TEXT) WHERE 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(
|
|
"working:immutable",
|
|
Path::new("source.rs"),
|
|
Side::After,
|
|
"first\nlast\n"
|
|
));
|
|
assert!(!anchor.matches(
|
|
"working:other",
|
|
Path::new("source.rs"),
|
|
Side::After,
|
|
"first\nlast\n"
|
|
));
|
|
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 stored_version_two_is_rejected_without_rewriting_it() {
|
|
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 mut old = serde_json::to_value(&review).unwrap();
|
|
old["version"] = 2.into();
|
|
let document = serde_json::to_string(&old).unwrap();
|
|
store
|
|
.connection
|
|
.execute(
|
|
"UPDATE reviews SET document = ?1 WHERE id = ?2",
|
|
params![document, review.storage_id.unwrap()],
|
|
)
|
|
.unwrap();
|
|
assert!(
|
|
store
|
|
.load(&root, review.storage_id.unwrap())
|
|
.unwrap_err()
|
|
.contains("Unsupported review version 2")
|
|
);
|
|
assert!(
|
|
store
|
|
.list(&root)
|
|
.unwrap_err()
|
|
.contains("Unsupported review version 2")
|
|
);
|
|
let preserved: String = store
|
|
.connection
|
|
.query_row(
|
|
"SELECT document FROM reviews WHERE id = ?1",
|
|
[review.storage_id.unwrap()],
|
|
|row| row.get(0),
|
|
)
|
|
.unwrap();
|
|
assert_eq!(preserved, document);
|
|
}
|
|
|
|
#[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());
|
|
}
|