| 1 |
|
| 2 |
|
| 3 |
|
| 4 |
|
| 5 |
use rand::{Rng, SeedableRng}; |
| 6 |
use spectra_core::event::NewEvent; |
| 7 |
use spectra_core::keys::KeyPair; |
| 8 |
use spectra_core::ledger::{Ledger, NoKeys, Problem, CHECKPOINT_EVERY}; |
| 9 |
use std::path::Path; |
| 10 |
|
| 11 |
async fn open(dir: &Path) -> Ledger { |
| 12 |
let instance = KeyPair::instance(dir, "test-instance").unwrap(); |
| 13 |
let ledger = Ledger::open(&dir.join("spectra.db"), Some(instance.clone())).await.unwrap(); |
| 14 |
if ledger.head().await.0 == 0 { |
| 15 |
ledger |
| 16 |
.append_one(NewEvent::system( |
| 17 |
"identity.instance", |
| 18 |
serde_json::json!({ "key": instance.key.to_hex(), "identity": hex::encode(instance.identity.encode()) }), |
| 19 |
)) |
| 20 |
.await |
| 21 |
.unwrap(); |
| 22 |
} |
| 23 |
ledger |
| 24 |
} |
| 25 |
|
| 26 |
fn msg(i: usize) -> NewEvent { |
| 27 |
NewEvent::system("test.message", serde_json::json!({ "n": i, "body": format!("message {i}") })).subject(format!("m{i}")) |
| 28 |
} |
| 29 |
|
| 30 |
async fn raw(dir: &Path, sql: &str) { |
| 31 |
let pool = sqlx::SqlitePool::connect(&format!("sqlite://{}", dir.join("spectra.db").display())).await.unwrap(); |
| 32 |
sqlx::query(sql).execute(&pool).await.unwrap(); |
| 33 |
pool.close().await; |
| 34 |
} |
| 35 |
|
| 36 |
#[tokio::test] |
| 37 |
async fn an_untouched_ledger_verifies_with_signed_checkpoints() { |
| 38 |
let dir = tempfile::tempdir().unwrap(); |
| 39 |
let ledger = open(dir.path()).await; |
| 40 |
for i in 0..(CHECKPOINT_EVERY as usize + 10) { |
| 41 |
ledger.append_one(msg(i)).await.unwrap(); |
| 42 |
} |
| 43 |
let cp = ledger.checkpoint().await.unwrap().expect("events since the last checkpoint"); |
| 44 |
assert_eq!(cp.kind, "ledger.checkpoint"); |
| 45 |
let v = ledger.verify(&mut NoKeys).await.unwrap(); |
| 46 |
assert!(v.clean(), "{:?}", v.findings); |
| 47 |
assert_eq!(v.checkpoints, 2, "one at 256 events, one on demand"); |
| 48 |
assert_eq!(v.uncovered, 0); |
| 49 |
assert_eq!(v.head, ledger.head().await.1); |
| 50 |
assert!(ledger.checkpoint().await.unwrap().is_none(), "nothing new to cover"); |
| 51 |
} |
| 52 |
|
| 53 |
#[tokio::test] |
| 54 |
async fn an_edited_event_is_caught() { |
| 55 |
let dir = tempfile::tempdir().unwrap(); |
| 56 |
let ledger = open(dir.path()).await; |
| 57 |
for i in 0..20 { |
| 58 |
ledger.append_one(msg(i)).await.unwrap(); |
| 59 |
} |
| 60 |
ledger.checkpoint().await.unwrap(); |
| 61 |
drop(ledger); |
| 62 |
raw(dir.path(), r#"UPDATE events SET payload = CAST('{"body":"message 5, edited","n":5}' AS BLOB) WHERE subject = 'm5'"#).await; |
| 63 |
let v = open(dir.path()).await.verify(&mut NoKeys).await.unwrap(); |
| 64 |
assert_eq!(v.findings.len(), 1, "{:?}", v.findings); |
| 65 |
assert_eq!(v.findings[0].problem, Problem::HashMismatch); |
| 66 |
} |
| 67 |
|
| 68 |
#[tokio::test] |
| 69 |
async fn an_edit_with_its_hash_recomputed_breaks_the_chain_or_the_checkpoint() { |
| 70 |
let dir = tempfile::tempdir().unwrap(); |
| 71 |
let ledger = open(dir.path()).await; |
| 72 |
for i in 0..20 { |
| 73 |
ledger.append_one(msg(i)).await.unwrap(); |
| 74 |
} |
| 75 |
ledger.checkpoint().await.unwrap(); |
| 76 |
|
| 77 |
|
| 78 |
let mut e = ledger.events(1, 100).await.unwrap().into_iter().find(|e| e.subject.as_deref() == Some("m19")).unwrap(); |
| 79 |
e.payload = Some(br#"{"body":"forged","n":19}"#.to_vec()); |
| 80 |
let forged = hex::encode(e.computed_hash()); |
| 81 |
drop(ledger); |
| 82 |
raw( |
| 83 |
dir.path(), |
| 84 |
&format!(r#"UPDATE events SET payload = CAST('{{"body":"forged","n":19}}' AS BLOB), hash = X'{forged}' WHERE seq = {}"#, e.seq), |
| 85 |
) |
| 86 |
.await; |
| 87 |
let v = open(dir.path()).await.verify(&mut NoKeys).await.unwrap(); |
| 88 |
assert!(v.findings.iter().any(|f| f.problem == Problem::ChainBreak || f.problem == Problem::BadCheckpoint), "{:?}", v.findings); |
| 89 |
} |
| 90 |
|
| 91 |
#[tokio::test] |
| 92 |
async fn a_removed_event_is_caught() { |
| 93 |
let dir = tempfile::tempdir().unwrap(); |
| 94 |
let ledger = open(dir.path()).await; |
| 95 |
for i in 0..10 { |
| 96 |
ledger.append_one(msg(i)).await.unwrap(); |
| 97 |
} |
| 98 |
drop(ledger); |
| 99 |
raw(dir.path(), "DELETE FROM events WHERE subject = 'm4'").await; |
| 100 |
let v = open(dir.path()).await.verify(&mut NoKeys).await.unwrap(); |
| 101 |
assert!(v.findings.iter().any(|f| f.problem == Problem::Gap), "{:?}", v.findings); |
| 102 |
assert!(v.findings.iter().any(|f| f.problem == Problem::ChainBreak), "{:?}", v.findings); |
| 103 |
} |
| 104 |
|
| 105 |
#[tokio::test] |
| 106 |
async fn a_checkpoint_signed_by_another_key_is_caught() { |
| 107 |
let dir = tempfile::tempdir().unwrap(); |
| 108 |
let ledger = open(dir.path()).await; |
| 109 |
ledger.append_one(msg(1)).await.unwrap(); |
| 110 |
let cp = ledger.checkpoint().await.unwrap().unwrap(); |
| 111 |
drop(ledger); |
| 112 |
let mallory = KeyPair::generate("mallory", ""); |
| 113 |
let sig = mallory.sign(&spectra_core::ledger::checkpoint_message(cp.seq - 1, &cp.prev)); |
| 114 |
let proof = hex::encode(spectra_core::cbor::encode(&sig.to_value())); |
| 115 |
raw(dir.path(), &format!("UPDATE events SET auth_proof = X'{proof}' WHERE seq = {}", cp.seq)).await; |
| 116 |
let v = open(dir.path()).await.verify(&mut NoKeys).await.unwrap(); |
| 117 |
assert_eq!(v.findings.len(), 1, "{:?}", v.findings); |
| 118 |
assert_eq!(v.findings[0].problem, Problem::BadCheckpoint); |
| 119 |
} |
| 120 |
|
| 121 |
#[tokio::test] |
| 122 |
async fn projections_equal_a_rebuild_over_ten_thousand_random_events() { |
| 123 |
let dir = tempfile::tempdir().unwrap(); |
| 124 |
let ledger = open(dir.path()).await; |
| 125 |
let mut rng = rand::rngs::StdRng::seed_from_u64(7); |
| 126 |
|
| 127 |
|
| 128 |
let kinds = [ |
| 129 |
"message.posted", |
| 130 |
"agent.presence", |
| 131 |
"agent.status", |
| 132 |
"agent.session_started", |
| 133 |
"agent.session_ended", |
| 134 |
"agent.added", |
| 135 |
"agent.disabled", |
| 136 |
"identity.owner", |
| 137 |
"identity.rotated", |
| 138 |
"project.added", |
| 139 |
"task.created", |
| 140 |
"tool.called", |
| 141 |
]; |
| 142 |
let started = std::time::Instant::now(); |
| 143 |
let mut appended = 0; |
| 144 |
while appended < 10_000 { |
| 145 |
let n = rng.gen_range(1..50); |
| 146 |
let batch = (0..n) |
| 147 |
.map(|i| { |
| 148 |
NewEvent::system(kinds[rng.gen_range(0..kinds.len())], serde_json::json!({ "i": appended + i, "r": rng.gen::<u32>() })) |
| 149 |
}) |
| 150 |
.collect(); |
| 151 |
ledger.append(batch).await.unwrap(); |
| 152 |
appended += n; |
| 153 |
} |
| 154 |
let per_batch_ms = started.elapsed().as_millis() as f64 / (10_000.0 / 25.0); |
| 155 |
assert!(ledger.check().await.unwrap().is_empty(), "projections drifted from the ledger"); |
| 156 |
ledger.rebuild().await.unwrap(); |
| 157 |
assert!(ledger.check().await.unwrap().is_empty()); |
| 158 |
let v = ledger.verify(&mut NoKeys).await.unwrap(); |
| 159 |
assert!(v.clean(), "{:?}", &v.findings[..v.findings.len().min(3)]); |
| 160 |
assert!(v.checkpoints >= 10_000 / CHECKPOINT_EVERY); |
| 161 |
|
| 162 |
assert!(per_batch_ms < 200.0, "{per_batch_ms} ms a batch"); |
| 163 |
} |
| 164 |
|
| 165 |
#[tokio::test] |
| 166 |
async fn a_ledger_from_a_newer_spectra_is_refused() { |
| 167 |
let dir = tempfile::tempdir().unwrap(); |
| 168 |
drop(open(dir.path()).await); |
| 169 |
raw(dir.path(), "UPDATE meta SET value = '99' WHERE key = 'ledger_format'").await; |
| 170 |
let err = Ledger::open(&dir.path().join("spectra.db"), None).await.err().expect("refused"); |
| 171 |
assert!(err.to_string().contains("newer Spectra"), "{err}"); |
| 172 |
} |
| 173 |
|
| 174 |
#[tokio::test] |
| 175 |
async fn the_bus_delivers_what_was_appended_in_order() { |
| 176 |
let dir = tempfile::tempdir().unwrap(); |
| 177 |
let ledger = open(dir.path()).await; |
| 178 |
let mut rx = ledger.subscribe(); |
| 179 |
ledger.append(vec![msg(1), msg(2)]).await.unwrap(); |
| 180 |
assert_eq!(rx.recv().await.unwrap().subject.as_deref(), Some("m1")); |
| 181 |
assert_eq!(rx.recv().await.unwrap().subject.as_deref(), Some("m2")); |
| 182 |
} |
| 183 |
|
| 184 |
#[tokio::test] |
| 185 |
async fn reopening_continues_the_chain() { |
| 186 |
let dir = tempfile::tempdir().unwrap(); |
| 187 |
let ledger = open(dir.path()).await; |
| 188 |
ledger.append_one(msg(1)).await.unwrap(); |
| 189 |
let (seq, hash) = ledger.head().await; |
| 190 |
drop(ledger); |
| 191 |
let ledger = open(dir.path()).await; |
| 192 |
let e = ledger.append_one(msg(2)).await.unwrap(); |
| 193 |
assert_eq!((e.seq, e.prev), (seq + 1, hash)); |
| 194 |
assert!(ledger.verify(&mut NoKeys).await.unwrap().clean()); |
| 195 |
} |
| 196 |
|
| 197 |
#[tokio::test] |
| 198 |
async fn a_single_append_is_fast() { |
| 199 |
let dir = tempfile::tempdir().unwrap(); |
| 200 |
let ledger = open(dir.path()).await; |
| 201 |
let started = std::time::Instant::now(); |
| 202 |
for i in 0..1_000 { |
| 203 |
ledger.append_one(msg(i)).await.unwrap(); |
| 204 |
} |
| 205 |
let each_us = started.elapsed().as_micros() / 1_000; |
| 206 |
eprintln!("append: {each_us} µs an event over 1,000 single appends"); |
| 207 |
|
| 208 |
assert!(each_us < 50_000, "{each_us} µs"); |
| 209 |
} |
| 210 |
|
| 211 |
#[tokio::test(flavor = "multi_thread", worker_threads = 4)] |
| 212 |
async fn an_append_abandoned_mid_flight_still_commits_and_never_blocks_later_ones() { |
| 213 |
|
| 214 |
|
| 215 |
|
| 216 |
|
| 217 |
let dir = tempfile::tempdir().unwrap(); |
| 218 |
let ledger = open(dir.path()).await; |
| 219 |
for i in 0..300 { |
| 220 |
let f = ledger.append_one(msg(i)); |
| 221 |
tokio::select! { |
| 222 |
biased; |
| 223 |
_ = f => {} |
| 224 |
_ = tokio::task::yield_now() => {} |
| 225 |
} |
| 226 |
} |
| 227 |
tokio::time::sleep(std::time::Duration::from_millis(300)).await; |
| 228 |
let last = ledger.append_one(msg(10_000)).await.unwrap(); |
| 229 |
let (head, _) = ledger.head().await; |
| 230 |
assert_eq!(last.seq, head); |
| 231 |
let other = sqlx::SqlitePool::connect(&format!("sqlite://{}", dir.path().join("spectra.db").display())).await.unwrap(); |
| 232 |
let seen: i64 = sqlx::query_scalar("SELECT MAX(seq) FROM events").fetch_one(&other).await.unwrap(); |
| 233 |
assert_eq!(seen as u64, head, "another connection sees every committed event"); |
| 234 |
let n: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM events WHERE kind = 'test.message'").fetch_one(&other).await.unwrap(); |
| 235 |
assert_eq!(n, 301, "every abandoned append still committed"); |
| 236 |
assert!(ledger.verify(&mut NoKeys).await.unwrap().clean()); |
| 237 |
} |