OXYGENHUB
◆ main spectra / crates / spectra-core / tests / ledger.rs
9.9 KiB · Rust · last changed Spectra stands alone: its own identities, signatures and CBOR; projects of any kind of work (#26, #27) 1 day ago source blame history raw
1
//! The ledger's promises (spec §9.5, goal G6): an untouched ledger verifies,
2
//! with checkpoints signed by the instance key; an edited, removed or
3
//! re-signed event is caught; projections always equal a rebuild.
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
    // A careful forger recomputes the edited event's hash: the next event's
77
    // prev no longer matches, and the checkpoint names the old head.
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
    // Kinds that projections read, with payloads that are often malformed:
127
    // a projection must skip what it cannot use, never refuse the append.
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
    // Generous in a debug build; the release number is in docs/plan.md.
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
    // The target is 10 ms with fsync (spec §24); debug builds get headroom.
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
    // A client that hangs up mid-request drops the future doing its append.
214
    // Before the writer ran in a task of its own, that could leave a pooled
215
    // connection inside an open transaction, and every later "commit" on it
216
    // went nowhere (found by hand in the Radar demo, 2026-11-02).
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
}