Skip to content

Commit 27f834c

Browse files
committed
test(node): close four gaps the review found in this PR's own guards
The frozen signing constants pinned only the all-Some optional shape, but the sole publish site always emits cert_id None, so no constant covered the encoding every real event uses. Proven during review: adding skip_serializing_if to cert_id left the suite green while the signing input changed for 100% of production events. A second constant now pins the null-carrying form. The legacy artifact's provenance assertion claimed re-capturing it from current code was what the assertion refused. It was not: skip-when-zero means a regenerated artifact also carries no version key, so the check passed and the claim was unfounded. A SHA-256 over the frozen bytes does the job the message promised, and the message now states only what it checks. WriteFailed was the one outcome variant no test constructed, so the independent-writes property it exists to express was unexercised. Both directions are now driven by failing a real write. The publish arm's body is extracted so the exact bytes handed to gossipsub are asserted signed and accepted by ingest under enforcement. The select! dispatch and the require_signed threading from main.rs still need a live swarm and are named as uncovered rather than implied covered.
1 parent 87ee857 commit 27f834c

1 file changed

Lines changed: 248 additions & 16 deletions

File tree

  • crates/gitlawb-node/src/p2p

‎crates/gitlawb-node/src/p2p/mod.rs‎

Lines changed: 248 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -343,6 +343,29 @@ fn signed_publish_bytes(keypair: &Keypair, event: &RefUpdateEvent) -> serde_json
343343
serde_json::to_vec(&event)
344344
}
345345

346+
/// Exactly the pair the swarm loop hands `gossipsub.publish` for one outbound
347+
/// ref-update: the topic, and the signed bytes.
348+
///
349+
/// Extracted from the publish arm so a test can hold what the loop publishes.
350+
/// The arm itself sits inside a `select!` that no test drives, so before this
351+
/// existed the only thing standing between a regression and the mesh was that
352+
/// the arm happened to call `signed_publish_bytes`; an arm rewritten to
353+
/// `serde_json::to_vec(&event)` would have published unsigned bytes with the
354+
/// whole suite green. Now that regression has to be made HERE to stay silent.
355+
///
356+
/// Be exact about what this does and does not close. It closes
357+
/// sign-before-publish and the topic the bytes go out on. It does NOT observe
358+
/// the `select!` arm dispatching to it, and it does not observe `require_signed`
359+
/// arriving from `main.rs`; both need a live swarm. Those remain uncovered
360+
/// seams, named rather than implied away.
361+
fn ref_update_publish_args(
362+
keypair: &Keypair,
363+
event: &RefUpdateEvent,
364+
) -> serde_json::Result<(gossipsub::IdentTopic, Vec<u8>)> {
365+
let bytes = signed_publish_bytes(keypair, event)?;
366+
Ok((gossipsub::IdentTopic::new(REF_UPDATES_TOPIC), bytes))
367+
}
368+
346369
/// Resolve the public key behind a claimed `node_did`, refusing anything that
347370
/// is not a resolvable `did:key`.
348371
///
@@ -1069,9 +1092,8 @@ pub async fn start(
10691092
Some(cmd) = cmd_rx.recv() => {
10701093
match cmd {
10711094
P2pCommand::PublishRefUpdate(event) => {
1072-
match signed_publish_bytes(&keypair, &event) {
1073-
Ok(bytes) => {
1074-
let topic = gossipsub::IdentTopic::new(REF_UPDATES_TOPIC);
1095+
match ref_update_publish_args(&keypair, &event) {
1096+
Ok((topic, bytes)) => {
10751097
match swarm.behaviour_mut().gossipsub.publish(topic, bytes) {
10761098
Ok(id) => info!(msg_id = %id, repo = %event.repo, "published ref-update"),
10771099
Err(e) => warn!(err = %e, "failed to publish ref-update"),
@@ -1360,6 +1382,55 @@ mod tests {
13601382
);
13611383
}
13621384

1385+
/// The same golden discipline, applied to the optional shape production
1386+
/// actually emits.
1387+
///
1388+
/// `GOLDEN_SIGNING_BYTES` above pins an event with `owner_did`, `cert_id`,
1389+
/// and `cid` all populated, and no real publish looks like that. The sole
1390+
/// production publish site, `api::repos::post_receive_replication_tail`,
1391+
/// always passes `cert_id: None`, and `cid` is None on every push whose
1392+
/// pinning has not finished. So the encoding of a null-valued optional, the
1393+
/// one carried by essentially every live event, was pinned nowhere.
1394+
///
1395+
/// What this catches that the all-`Some` constant structurally cannot:
1396+
/// adding `skip_serializing_if = "Option::is_none"` to any of those three
1397+
/// fields omits the key rather than writing `null`, which changes the
1398+
/// signing input for every event in flight while leaving the all-`Some`
1399+
/// golden byte-identical. That is not hypothetical; injecting exactly that
1400+
/// attribute on `cert_id` left the whole suite green, both goldens passing,
1401+
/// with the production signing input silently changed.
1402+
///
1403+
/// Frozen for the same reason as the constant above: a failure here is a
1404+
/// wire-format change that needs a rollout plan, not a constant to re-pin.
1405+
const GOLDEN_SIGNING_BYTES_ALL_NONE: &str = concat!(
1406+
r#"{"node_did":"did:key:zNode","pusher_did":"did:key:zPusher","#,
1407+
r#""repo":"zOwner/myrepo","owner_did":null,"#,
1408+
r#""ref_name":"refs/heads/main","#,
1409+
r#""old_sha":"0000000000000000000000000000000000000000","#,
1410+
r#""new_sha":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","#,
1411+
r#""timestamp":"2026-07-02T12:00:00Z","cert_id":null,"cid":null}"#,
1412+
);
1413+
1414+
/// The all-`None` optional shape: what the production publish site emits.
1415+
fn all_none_optionals_event() -> RefUpdateEvent {
1416+
RefUpdateEvent {
1417+
owner_did: None,
1418+
cert_id: None,
1419+
cid: None,
1420+
..populated_event()
1421+
}
1422+
}
1423+
1424+
#[test]
1425+
fn signing_bytes_of_the_all_none_shape_match_the_golden_constant() {
1426+
let bytes = signing_bytes(&all_none_optionals_event()).unwrap();
1427+
assert_eq!(
1428+
String::from_utf8(bytes).unwrap(),
1429+
GOLDEN_SIGNING_BYTES_ALL_NONE,
1430+
"the wire signing input for null-valued optionals changed; see the comment on GOLDEN_SIGNING_BYTES_ALL_NONE"
1431+
);
1432+
}
1433+
13631434
/// The signature must be excluded from its own input, so a signed event and
13641435
/// its unsigned original produce identical signing bytes.
13651436
#[test]
@@ -1389,14 +1460,33 @@ mod tests {
13891460
/// silently stopped accepting every event already in flight. That is the
13901461
/// only failure this constant can see, and regenerating it is precisely
13911462
/// how you blind it. Same discipline as `GOLDEN_SIGNING_BYTES`, for the
1392-
/// same reason: freeze it forever.
1463+
/// same reason: freeze it forever. `LEGACY_SIGNED_EVENT_V0_SHA256` below is
1464+
/// what makes "never regenerate it" a check rather than a request.
13931465
///
13941466
/// Non-degenerate on purpose: `owner_did`, `cert_id`, and `cid` are all
13951467
/// populated, so the interaction between the optional fields' encoding and
13961468
/// the version field's is pinned rather than left unexercised by an
13971469
/// artifact that happened to carry none of them.
13981470
const LEGACY_SIGNED_EVENT_V0: &str = r#"{"node_did":"did:key:z6MkiAJwX3dtfEY6KGeDDgxXB6ZZWCAxTSHDtJEyUVynqYtq","pusher_did":"did:key:zPusher","repo":"zOwner/myrepo","owner_did":"did:key:zOwner","ref_name":"refs/heads/main","old_sha":"0000000000000000000000000000000000000000","new_sha":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","timestamp":"2026-07-02T12:00:00Z","cert_id":"cert-1","cid":"bafycid","sig":"-lH5aObROlqoTFjnjSXjbDgCVscLfVaKb1Y1gJL1tVsiBZlZnLKi55QgSo0ALTNtI_DyKo0ColzJMxL7w7ZODQ"}"#;
13991471

1472+
/// SHA-256 of `LEGACY_SIGNED_EVENT_V0`, hex, lowercase. This is what makes
1473+
/// the capture claim above checkable instead of merely attested.
1474+
///
1475+
/// The obvious guard does not work, which is why this one exists. Asserting
1476+
/// the artifact carries no `"v"` key proves nothing about its provenance: a
1477+
/// v0 event re-serializes with no version key under `skip_serializing_if`,
1478+
/// so an artifact regenerated from CURRENT code carries no `"v"` key either
1479+
/// and that assertion passes against precisely the regeneration it was meant
1480+
/// to refuse. A digest has no such blind spot. Any edit to those bytes,
1481+
/// regeneration included, moves it.
1482+
///
1483+
/// Frozen alongside the artifact. If it fails, the constant was edited:
1484+
/// restore the original from commit e3dc6f07 rather than re-pinning the
1485+
/// digest, since re-pinning is exactly the act of blinding the test that the
1486+
/// "never regenerate it" paragraph above warns against.
1487+
const LEGACY_SIGNED_EVENT_V0_SHA256: &str =
1488+
"2482e053c8ab1841d784f523f1ef5e3d0bd5f9d563565af8fca8dd34a1e264fc";
1489+
14001490
/// The compatibility test the rest of this module cannot substitute for:
14011491
/// the only one here that verifies an artifact it did not itself sign.
14021492
///
@@ -1408,18 +1498,19 @@ mod tests {
14081498
/// observation that can tell those two worlds apart.
14091499
#[test]
14101500
fn an_event_signed_before_the_version_field_existed_still_verifies() {
1411-
// The artifact's legacy shape is CHECKED, not attested in a comment.
1412-
// If a later edit ever regenerates the constant from current code, a
1413-
// `"v"` key could appear in it and this test would quietly decay into
1414-
// the fresh-artifact round trip it exists to not be.
1415-
let raw: serde_json::Value = serde_json::from_str(LEGACY_SIGNED_EVENT_V0)
1416-
.expect("the frozen artifact must be valid JSON");
1417-
assert!(
1418-
!raw.as_object()
1419-
.expect("the frozen artifact must be a JSON object")
1420-
.contains_key("v"),
1421-
"the frozen artifact must carry no version key; it predates the field and \
1422-
re-capturing it from current code is what this assertion refuses"
1501+
// What this checks, exactly: that the constant still holds the bytes
1502+
// captured at e3dc6f07, byte for byte. It does not, and cannot, observe
1503+
// that those bytes predate the version field; that is established by the
1504+
// capture commit and by review, not by anything a test run can see. What
1505+
// the digest does buy is that a later edit which regenerates the artifact
1506+
// from current code fails HERE, loudly, instead of quietly decaying this
1507+
// test into the fresh-artifact round trip it exists to not be.
1508+
use sha2::{Digest, Sha256};
1509+
let digest = hex::encode(Sha256::digest(LEGACY_SIGNED_EVENT_V0.as_bytes()));
1510+
assert_eq!(
1511+
digest, LEGACY_SIGNED_EVENT_V0_SHA256,
1512+
"the frozen legacy artifact was edited; restore it from commit e3dc6f07 rather than \
1513+
re-pinning this digest"
14231514
);
14241515

14251516
// Exactly the bytes a peer would receive, straight off the wire.
@@ -2064,6 +2155,147 @@ mod tests {
20642155
assert_eq!(count(&pool, "received_ref_updates").await, 1);
20652156
}
20662157

2158+
/// The publish arm's own output, driven end to end: whatever
2159+
/// `ref_update_publish_args` hands gossipsub must be signed, must go out on
2160+
/// the wire topic peers subscribe to, and must survive ingest with
2161+
/// enforcement ON.
2162+
///
2163+
/// This is deliberately not a second copy of the round trip above. That test
2164+
/// calls `signed_publish_bytes`, one layer below the loop; this one calls
2165+
/// the function the `select!` arm calls, so an arm that stopped signing
2166+
/// would have to be rewritten rather than merely reordered to keep the suite
2167+
/// green. The topic is asserted against the literal string, not against
2168+
/// `REF_UPDATES_TOPIC`, because comparing the constant to itself proves
2169+
/// nothing: the topic name is a wire format shared with every deployed peer,
2170+
/// and renaming it silently partitions the mesh into two meshes that each
2171+
/// look healthy.
2172+
///
2173+
/// What is still NOT observed, and is not implied to be: the `select!` arm
2174+
/// dispatching a `P2pCommand::PublishRefUpdate` into this function, and
2175+
/// `require_signed` reaching `ingest_ref_update` from `main.rs` the right
2176+
/// way round. Both live inside `p2p::start`, which needs a live swarm to
2177+
/// drive, so an inverted flag threaded from the config would still leave
2178+
/// this green. Uncovered seam, named.
2179+
#[sqlx::test]
2180+
async fn the_publish_arms_output_is_signed_and_survives_enforced_ingest(pool: PgPool) {
2181+
let db = ingest_db(&pool).await;
2182+
let keypair = Keypair::generate();
2183+
// Unsigned on the way in, exactly as `publish_ref_update` hands it over.
2184+
let event = event_for(&keypair);
2185+
assert_eq!(
2186+
event.sig, None,
2187+
"the publish path is what adds the signature"
2188+
);
2189+
seed_peer(&pool, &event.node_did).await;
2190+
2191+
let (topic, bytes) =
2192+
ref_update_publish_args(&keypair, &event).expect("the publish arm must produce args");
2193+
2194+
assert_eq!(
2195+
topic.to_string(),
2196+
"gitlawb/ref-updates/v1",
2197+
"the publish topic is a wire format; renaming it partitions the mesh"
2198+
);
2199+
2200+
let published: RefUpdateEvent =
2201+
serde_json::from_slice(&bytes).expect("published bytes must parse");
2202+
assert!(
2203+
published.sig.is_some(),
2204+
"the bytes the loop hands gossipsub must carry a signature"
2205+
);
2206+
2207+
let outcome = ingest_with_fresh_limiter(&db, true, true, &bytes, &PeerId::random()).await;
2208+
assert!(
2209+
matches!(outcome, IngestOutcome::Accepted),
2210+
"the publish arm's bytes must survive ingest with enforcement on, got {outcome:?}"
2211+
);
2212+
assert_eq!(count(&pool, "received_ref_updates").await, 1);
2213+
}
2214+
2215+
// ── Durable-write failure ─────────────────────────────────────────────
2216+
//
2217+
// `WriteFailed` is unreachable from every other test here: a rejection stops
2218+
// above both writes, and an acceptance has both succeed. So the variant that
2219+
// exists to stop `Accepted` from meaning "authenticated but never stored"
2220+
// was never once observed, and `rejection_reason` panics rather than
2221+
// distinguishes if it turns up.
2222+
//
2223+
// The failure is made REAL by dropping the target table on the live test
2224+
// database, so the error comes back from Postgres on the actual write rather
2225+
// than from a stub standing in for one. Both directions are driven, because
2226+
// the property is that the two writes are attempted INDEPENDENTLY: a test
2227+
// that only broke the first could not tell that from "the first failure
2228+
// aborts the rest", and each case therefore asserts what landed in the sink
2229+
// that was left intact.
2230+
2231+
/// The ref-update row fails, the queue entry still lands.
2232+
#[sqlx::test]
2233+
async fn a_failed_ref_update_insert_reports_write_failed_and_still_enqueues(pool: PgPool) {
2234+
let db = ingest_db(&pool).await;
2235+
let keypair = Keypair::generate();
2236+
let mut event = event_for(&keypair);
2237+
sign_ref_update(&keypair, &mut event).unwrap();
2238+
seed_peer(&pool, &event.node_did).await;
2239+
2240+
sqlx::query("DROP TABLE received_ref_updates")
2241+
.execute(&pool)
2242+
.await
2243+
.expect("drop the ref-update sink so its write genuinely fails");
2244+
2245+
let outcome =
2246+
ingest_with_fresh_limiter(&db, true, true, &bytes_of(&event), &PeerId::random()).await;
2247+
2248+
match outcome {
2249+
IngestOutcome::WriteFailed(reason) => assert!(
2250+
reason.contains("failed to store received ref-update"),
2251+
"the outcome must name the write that failed, got: {reason}"
2252+
),
2253+
other => panic!(
2254+
"an event whose durable write failed must not be reported as accepted, got {other:?}"
2255+
),
2256+
}
2257+
2258+
assert_eq!(
2259+
count(&pool, "sync_queue").await,
2260+
1,
2261+
"the queue entry is a separate write and must not be lost to the row's failure"
2262+
);
2263+
}
2264+
2265+
/// The mirror: the queue entry fails, the ref-update row still lands.
2266+
#[sqlx::test]
2267+
async fn a_failed_enqueue_reports_write_failed_and_still_stores_the_row(pool: PgPool) {
2268+
let db = ingest_db(&pool).await;
2269+
let keypair = Keypair::generate();
2270+
let mut event = event_for(&keypair);
2271+
sign_ref_update(&keypair, &mut event).unwrap();
2272+
seed_peer(&pool, &event.node_did).await;
2273+
2274+
sqlx::query("DROP TABLE sync_queue")
2275+
.execute(&pool)
2276+
.await
2277+
.expect("drop the queue sink so its write genuinely fails");
2278+
2279+
let outcome =
2280+
ingest_with_fresh_limiter(&db, true, true, &bytes_of(&event), &PeerId::random()).await;
2281+
2282+
match outcome {
2283+
IngestOutcome::WriteFailed(reason) => assert!(
2284+
reason.contains("failed to enqueue sync"),
2285+
"the outcome must name the write that failed, got: {reason}"
2286+
),
2287+
other => panic!(
2288+
"an event whose enqueue failed must not be reported as accepted, got {other:?}"
2289+
),
2290+
}
2291+
2292+
assert_eq!(
2293+
count(&pool, "received_ref_updates").await,
2294+
1,
2295+
"the ref-update row is a separate write and must not be lost to the enqueue failure"
2296+
);
2297+
}
2298+
20672299
// ── Ingest rate limits ────────────────────────────────────────────────
20682300

20692301
/// The documented numbers, and then the check that matters: each cap

0 commit comments

Comments
 (0)