173 lines
5.4 KiB
Rust
173 lines
5.4 KiB
Rust
use std::collections::HashSet;
|
|
|
|
use ed25519_dalek::SigningKey;
|
|
use ontoref_types::{
|
|
operation::{OpBody, OpPayload, Operation},
|
|
Hlc, OpId, PublicKey,
|
|
};
|
|
|
|
use ontoref_oplog::{Error, OpLog};
|
|
|
|
fn signing_key() -> SigningKey {
|
|
SigningKey::from_bytes(&[42u8; 32])
|
|
}
|
|
|
|
fn make_op(parents: Vec<OpId>, timestamp: Hlc, label: &str) -> Operation {
|
|
let key = signing_key();
|
|
let body = OpBody {
|
|
actor: PublicKey::from_signing_key(&key),
|
|
parents,
|
|
payload: OpPayload::Noop {
|
|
label: label.into(),
|
|
},
|
|
timestamp,
|
|
};
|
|
Operation::sign(body, &key)
|
|
}
|
|
|
|
#[test]
|
|
fn replay_yields_same_heads_for_linear_chain() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let path = dir.path().join("oplog.redb");
|
|
|
|
let original_heads: Vec<OpId> = {
|
|
let log = OpLog::open(&path).unwrap();
|
|
let mut prev_id = None;
|
|
let mut prev_hlc = Hlc::genesis();
|
|
for i in 0..100 {
|
|
prev_hlc = Hlc::tick(prev_hlc, 0);
|
|
let parents = prev_id.map(|id| vec![id]).unwrap_or_default();
|
|
let op = make_op(parents, prev_hlc, &format!("op-{i}"));
|
|
prev_id = Some(log.append(&op).unwrap());
|
|
}
|
|
assert_eq!(log.len().unwrap(), 100);
|
|
log.heads().unwrap()
|
|
};
|
|
|
|
// Drop the OpLog (closes the redb handle), reopen the file, re-read heads.
|
|
let reopened_heads = OpLog::open(&path).unwrap().heads().unwrap();
|
|
assert_eq!(original_heads, reopened_heads);
|
|
assert_eq!(original_heads.len(), 1, "a linear chain has exactly one head");
|
|
}
|
|
|
|
#[test]
|
|
fn replay_yields_same_heads_for_branching_dag() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let path = dir.path().join("oplog.redb");
|
|
|
|
let original_heads: HashSet<OpId> = {
|
|
let log = OpLog::open(&path).unwrap();
|
|
let mut clock = Hlc::genesis();
|
|
|
|
// Root op.
|
|
clock = Hlc::tick(clock, 0);
|
|
let root = log.append(&make_op(vec![], clock, "root")).unwrap();
|
|
|
|
// Two children sharing the same root.
|
|
clock = Hlc::tick(clock, 0);
|
|
let left = log.append(&make_op(vec![root], clock, "left")).unwrap();
|
|
clock = Hlc::tick(clock, 0);
|
|
let right = log.append(&make_op(vec![root], clock, "right")).unwrap();
|
|
|
|
// Merge: a child of both left and right.
|
|
clock = Hlc::tick(clock, 0);
|
|
let merged = log
|
|
.append(&make_op(vec![left, right], clock, "merged"))
|
|
.unwrap();
|
|
|
|
// Extra branch from the root that stays a head.
|
|
clock = Hlc::tick(clock, 0);
|
|
let stray = log.append(&make_op(vec![root], clock, "stray")).unwrap();
|
|
|
|
// Expected heads: {merged, stray}.
|
|
let expected: HashSet<OpId> = [merged, stray].into_iter().collect();
|
|
assert_eq!(
|
|
log.heads().unwrap().into_iter().collect::<HashSet<_>>(),
|
|
expected
|
|
);
|
|
expected
|
|
};
|
|
|
|
let reopened: HashSet<OpId> = OpLog::open(&path)
|
|
.unwrap()
|
|
.heads()
|
|
.unwrap()
|
|
.into_iter()
|
|
.collect();
|
|
assert_eq!(reopened, original_heads);
|
|
}
|
|
|
|
#[test]
|
|
fn parents_validation_rejects_unknown_parent() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let log = OpLog::open(dir.path().join("oplog.redb")).unwrap();
|
|
|
|
let bogus = OpId([0xAA; 32]);
|
|
let op = make_op(vec![bogus], Hlc::new(1, 0), "orphan");
|
|
|
|
match log.append(&op) {
|
|
Err(Error::UnknownParent(parent)) if parent == bogus => {}
|
|
other => panic!("expected UnknownParent({bogus:?}), got {other:?}"),
|
|
}
|
|
assert_eq!(log.len().unwrap(), 0, "rejected op must not be stored");
|
|
}
|
|
|
|
#[test]
|
|
fn parents_validation_accepts_known_parent() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let log = OpLog::open(dir.path().join("oplog.redb")).unwrap();
|
|
|
|
let root = log
|
|
.append(&make_op(vec![], Hlc::new(1, 0), "root"))
|
|
.unwrap();
|
|
let child = log
|
|
.append(&make_op(vec![root], Hlc::new(2, 0), "child"))
|
|
.unwrap();
|
|
|
|
assert!(log.get(&root).unwrap().is_some());
|
|
assert!(log.get(&child).unwrap().is_some());
|
|
assert_eq!(log.heads().unwrap(), vec![child]);
|
|
}
|
|
|
|
#[test]
|
|
fn append_is_idempotent() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let log = OpLog::open(dir.path().join("oplog.redb")).unwrap();
|
|
|
|
let op = make_op(vec![], Hlc::new(1, 0), "same");
|
|
let id1 = log.append(&op).unwrap();
|
|
let id2 = log.append(&op).unwrap();
|
|
assert_eq!(id1, id2);
|
|
assert_eq!(log.len().unwrap(), 1);
|
|
}
|
|
|
|
#[test]
|
|
fn append_rejects_bad_signature() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let log = OpLog::open(dir.path().join("oplog.redb")).unwrap();
|
|
|
|
let mut op = make_op(vec![], Hlc::new(1, 0), "tampered");
|
|
// Flip a bit in the signature so it no longer verifies.
|
|
op.signature.0[0] ^= 0x01;
|
|
|
|
match log.append(&op) {
|
|
Err(Error::BadSignature(_)) => {}
|
|
other => panic!("expected BadSignature, got {other:?}"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn append_rejects_non_monotonic_timestamp() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let log = OpLog::open(dir.path().join("oplog.redb")).unwrap();
|
|
|
|
let root = log
|
|
.append(&make_op(vec![], Hlc::new(5, 0), "root"))
|
|
.unwrap();
|
|
// Child with same timestamp as the parent — must be rejected.
|
|
let bad_child = make_op(vec![root], Hlc::new(5, 0), "child-same-time");
|
|
match log.append(&bad_child) {
|
|
Err(Error::NonMonotonicTimestamp(_)) => {}
|
|
other => panic!("expected NonMonotonicTimestamp, got {other:?}"),
|
|
}
|
|
}
|