a73x

tests/legacy_data_shapes_test.rs

Ref:   Size: 15.3 KiB   History

//! Event shapes that live in repositories people still hold.
//!
//! Every shape asserted here was taken from a real repository, not invented:
//! `~/code/rad/eitri` and `~/code/rad/waystty` were written by a git-collab
//! from July 2026 and between them hold 39 `patch.create` events with no
//! `commit` and no `tree`, 33 `patch.review` events with no `revision`, 23
//! `patch.merge` events with no `commit`, and one `patch.revise`. Issue
//! `e5096ffc` removed the reader's tolerance for all four after auditing one
//! clone and the hosted server repositories — a sample that happened to contain
//! none of them — and issue `05b18df6` is what that cost.
//!
//! The rule these tests encode is not "keep everything forever". It is: **a
//! shape that can still exist in data someone holds is a shape the reader has
//! to be able to express.** A field absent from an event written before the
//! field existed is not transitional debt, because no migration can invent a
//! value for it — exactly the argument `Revision::base` was already kept on.
//! Code that only ever *wrote* a superseded shape stays removed; nothing here
//! writes any of these.
//!
//! The second, harder requirement is that reading one of these events must not
//! change its bytes. Signatures are verified by re-serializing the event
//! (`signing::canonical_json`), so a reader that helpfully filled in an absent
//! key would invalidate the signature on every event it touched — turning a
//! display bug into a verification failure, which is how a sync drops a patch.
//! Each shape below is therefore asserted to survive a sign/verify round trip.

mod common;

use common::{write_raw_event, TestRepo};
use git_collab::event::Event;
use git_collab::signing;
use git_collab::state::{PatchState, PatchStatus};
use serde_json::json;

/// The `patch.create` shape held by every legacy patch in `eitri`: a title, a
/// body, a base ref, a branch, and nothing at all about where the code stands.
/// Copied field-for-field from `refs/collab/patches/0b72bae8…`.
fn legacy_create() -> serde_json::Value {
    json!({
        "type": "patch.create",
        "title": "feat: one-time stream tickets replace admin token in SSE URLs",
        "body": "[claude 2026-07-04] Fixes 44f5b0ee (DEF-6).",
        "base_ref": "main",
        "branch": "fix/def6-stream-tickets",
        "fixes": "44f5b0ee",
    })
}

/// Write a patch DAG at `refs/collab/patches/<id>/events` from a list of
/// actions, oldest first, and return the patch id.
fn legacy_patch(repo: &TestRepo, actions: &[serde_json::Value]) -> String {
    let git_repo = git2::Repository::open(repo.dir.path()).unwrap();
    let mut parent = None;
    let mut root = None;
    for (i, action) in actions.iter().enumerate() {
        let oid = write_raw_event(&git_repo, parent, action.clone(), i as u64 + 1);
        root.get_or_insert(oid);
        parent = Some(oid);
    }
    let id = root.expect("at least one event").to_string();
    git_repo
        .reference(
            &format!("refs/collab/patches/{}/events", id),
            parent.unwrap(),
            false,
            "events",
        )
        .unwrap();
    id
}

/// Read the patch back through the same fold every command uses.
fn fold(repo: &TestRepo, id: &str) -> PatchState {
    let git_repo = git2::Repository::open(repo.dir.path()).unwrap();
    PatchState::from_ref_uncached(&git_repo, &format!("refs/collab/patches/{}/events", id), id)
        .unwrap_or_else(|e| panic!("the fold must read this patch, got: {}", e))
}

/// Assert that reading and re-writing this event does not change its bytes.
///
/// This is the property that decides whether an absent key may be tolerated at
/// all: `signing::canonical_json` is what both signing and verification run
/// over, so if the round trip is not exact the event's existing signature stops
/// verifying and `sync` rejects the whole patch.
fn assert_round_trips(action: &serde_json::Value) {
    let raw = json!({
        "timestamp": "2026-07-04T15:37:13.808147145+00:00",
        "author": { "name": "a73x", "email": "dev@a73x.sh" },
        "action": action,
        "clock": 1,
    });
    let original = serde_json::to_string(&raw).unwrap();
    let event: Event = serde_json::from_str(&original)
        .unwrap_or_else(|e| panic!("this shape must deserialize, got: {}\n{}", e, original));
    let reserialized = String::from_utf8(signing::canonical_json(&event).unwrap()).unwrap();
    assert_eq!(
        original, reserialized,
        "reading a legacy event must not change its bytes — its signature covers them"
    );
}

// ===========================================================================
// patch.merge with no commit
// ===========================================================================

/// The exact shape in the issue report: a merge recorded before merge-recording
/// stored a commit.
fn legacy_merge() -> serde_json::Value {
    json!({ "type": "patch.merge" })
}

#[test]
fn a_merge_event_with_no_commit_key_deserializes() {
    assert_round_trips(&legacy_merge());
}

#[test]
fn a_merge_event_with_no_commit_key_folds_to_merged_with_no_merge_commit() {
    // `merge_commit` is already `Option`, and absent has to mean `None` rather
    // than an empty string surfacing as a commit id — "not recorded" is not
    // "the null OID". The status is the part that matters to a reader: the
    // patch *was* merged, and losing that because the commit is unknown would
    // report merged work as still open.
    let repo = TestRepo::new("Alice", "alice@example.com");
    let id = legacy_patch(&repo, &[legacy_create(), legacy_merge()]);

    let state = fold(&repo, &id);
    assert_eq!(state.status, PatchStatus::Merged);
    assert_eq!(
        state.merge_commit, None,
        "an unrecorded merge commit is unknown, not empty"
    );
}

#[test]
fn a_merge_event_with_no_commit_key_is_listed_not_skipped() {
    let repo = TestRepo::new("Alice", "alice@example.com");
    let id = legacy_patch(&repo, &[legacy_create(), legacy_merge()]);

    let out = repo.run_ok(&["patch", "list", "--all"]);
    assert!(
        out.contains(&id[..8]),
        "a legacy merged patch must appear in the list, got:\n{}",
        out
    );
}

// ===========================================================================
// patch.create with no commit and no tree
// ===========================================================================

#[test]
fn a_create_event_with_no_commit_or_tree_deserializes() {
    // This, not the merge event, is what actually made `eitri` unreadable:
    // every one of its 17 patches failed with
    // `missing field `commit` at line 14 column 3`, which is the closing brace
    // of the action object in a `patch.create`.
    assert_round_trips(&legacy_create());
}

#[test]
fn a_create_event_with_no_commit_or_tree_folds_to_a_revision_with_no_commit() {
    let repo = TestRepo::new("Alice", "alice@example.com");
    let id = legacy_patch(&repo, &[legacy_create()]);

    let state = fold(&repo, &id);
    assert_eq!(state.revisions.len(), 1);
    assert!(
        state.revisions[0].commit.is_empty(),
        "a revision that recorded no commit must say so, not guess one"
    );
    assert_eq!(
        state.revisions[0].short_commit(),
        None,
        "nothing may render an unrecorded commit as an abbreviated OID"
    );
}

#[test]
fn a_repository_of_legacy_patches_is_not_an_empty_list() {
    // The regression as the user met it: 17 patches, 17 warnings,
    // `No patches found.`
    let repo = TestRepo::new("Alice", "alice@example.com");
    let mut ids = Vec::new();
    for n in 0..3 {
        let mut create = legacy_create();
        create["title"] = json!(format!("legacy patch {}", n));
        ids.push(legacy_patch(&repo, &[create, legacy_merge()]));
    }

    let output = repo.run(&["patch", "list", "--all"]);
    let stdout = String::from_utf8_lossy(&output.stdout);
    let stderr = String::from_utf8_lossy(&output.stderr);
    assert!(
        output.status.success(),
        "patch list must succeed: {}",
        stderr
    );
    for id in &ids {
        assert!(
            stdout.contains(&id[..8]),
            "patch {:.8} must be listed, got:\n{}\n{}",
            id,
            stdout,
            stderr
        );
    }
    assert!(
        !stderr.contains("skipping patch"),
        "no legacy patch may be skipped, got:\n{}",
        stderr
    );
}

#[test]
fn a_patch_that_never_recorded_a_commit_is_not_told_to_fetch_one() {
    // The same defect as the ref-layout advice, one layer down. A patch with no
    // recorded commit has no diff, and saying "fetch the patch's revision refs,
    // or the commit it stands on" sends its owner looking for objects that were
    // never written by anyone — the message reads as a fetchable problem and is
    // not one. It has to say the record is what is empty, and that the rest of
    // the patch is fine.
    let repo = TestRepo::new("Alice", "alice@example.com");
    let id = legacy_patch(&repo, &[legacy_create(), legacy_merge()]);

    let output = repo.run(&["patch", "show", &id[..8]]);
    let combined = format!(
        "{}{}",
        String::from_utf8_lossy(&output.stdout),
        String::from_utf8_lossy(&output.stderr)
    );
    assert!(
        combined.contains("nothing to fetch"),
        "the message must say there is nothing to fetch, got:\n{}",
        combined
    );
    assert!(
        !combined.contains("fetch the patch's revision refs"),
        "and must not send the reader after objects nobody has, got:\n{}",
        combined
    );
    // The rest of the patch still reads, which is the claim the message makes.
    assert!(
        String::from_utf8_lossy(&output.stdout).contains("one-time stream tickets"),
        "the patch's own content must still be shown, got:\n{}",
        combined
    );
}

// ===========================================================================
// patch.review with no revision
// ===========================================================================

fn legacy_review(body: &str) -> serde_json::Value {
    json!({ "type": "patch.review", "verdict": "Comment", "body": body })
}

#[test]
fn a_review_with_no_revision_deserializes() {
    assert_round_trips(&legacy_review("looks good"));
}

#[test]
fn a_review_with_no_revision_is_read_and_left_unattributed() {
    // Restoring the *field* is not restoring the attribution. Recovering a
    // missing revision from the event's position in the DAG was removed for a
    // real reason (issue 33b5e541): position is not signed, so two clones
    // holding the same events in different parent orders could attribute one
    // review to different revisions, and because attribution feeds vote
    // supersession that could drop a vote rather than merely mislabel it.
    //
    // So an unattributed review stays unattributed. `None` is the honest
    // answer and it is stable across clones, which is the property the
    // guessing lacked.
    let repo = TestRepo::new("Alice", "alice@example.com");
    let id = legacy_patch(
        &repo,
        &[
            legacy_create(),
            legacy_review("r1 era"),
            legacy_review("r2 era"),
        ],
    );

    let state = fold(&repo, &id);
    assert_eq!(state.reviews.len(), 2, "both reviews must survive the fold");
    for review in &state.reviews {
        assert_eq!(
            review.revision, None,
            "a review that recorded no revision is not attributed to one"
        );
    }
}

#[test]
fn an_unattributed_vote_does_not_supersede_an_attributed_one() {
    // Vote supersession is per (author, revision). An unattributed review is
    // its own bucket rather than a wildcard: letting it collide with every
    // revision would let a legacy review silently retract a current vote.
    let repo = TestRepo::new("Alice", "alice@example.com");
    let id = legacy_patch(
        &repo,
        &[
            legacy_create(),
            json!({ "type": "patch.review", "verdict": "Approve", "body": "old" }),
            json!({ "type": "patch.review", "verdict": "Approve", "body": "new", "revision": 1 }),
        ],
    );

    let state = fold(&repo, &id);
    assert_eq!(
        state.reviews.len(),
        2,
        "an unattributed vote and a revision-1 vote are different votes, got: {:?}",
        state
            .reviews
            .iter()
            .map(|r| (&r.body, r.revision))
            .collect::<Vec<_>>()
    );
}

// ===========================================================================
// patch.revise
// ===========================================================================

/// The one `patch.revise` in `eitri`, byte for byte.
fn legacy_revise() -> serde_json::Value {
    json!({ "type": "patch.revise", "body": null })
}

#[test]
fn a_patch_revise_event_deserializes_and_keeps_its_own_name() {
    // Deliberately its own variant rather than a `#[serde(alias)]` on
    // `patch.revision`. An alias reads the event and then re-serializes it
    // under the *new* name, which changes the signed bytes and fails
    // verification — so an alias would read the patch locally and still have
    // `sync` reject it. A variant that keeps its own tag round trips.
    assert_round_trips(&legacy_revise());
}

#[test]
fn a_patch_revise_event_folds_to_a_revision_with_no_commit() {
    let repo = TestRepo::new("Alice", "alice@example.com");
    let id = legacy_patch(&repo, &[legacy_create(), legacy_revise()]);

    let state = fold(&repo, &id);
    assert_eq!(
        state.revisions.len(),
        2,
        "a revise event records a revision, even one that says nothing about a commit"
    );
    assert!(state.revisions[1].commit.is_empty());
    assert_eq!(state.revisions[1].number, 2);
}

#[test]
fn two_revisions_that_recorded_no_commit_do_not_collapse_into_one() {
    // Revisions are deduplicated by commit OID. With the `""`-means-unknown
    // convention back, two revisions that both recorded nothing are not the
    // same revision — dropping one would renumber every revision after it and
    // silently lose a step of the patch's history.
    let repo = TestRepo::new("Alice", "alice@example.com");
    let id = legacy_patch(&repo, &[legacy_create(), legacy_revise(), legacy_revise()]);

    let state = fold(&repo, &id);
    assert_eq!(state.revisions.len(), 3);
}

// ===========================================================================
// Signatures over the whole DAG
// ===========================================================================

#[test]
fn every_legacy_shape_still_verifies_as_signed() {
    // The end-to-end version of `assert_round_trips`: this is the same path
    // `sync` runs before it will reconcile anything. If any shape above did not
    // round trip exactly, the patch would read locally and still be refused on
    // the way in from a remote.
    let repo = TestRepo::new("Alice", "alice@example.com");
    let id = legacy_patch(
        &repo,
        &[
            legacy_create(),
            legacy_revise(),
            legacy_review("no revision recorded"),
            legacy_merge(),
        ],
    );

    let git_repo = git2::Repository::open(repo.dir.path()).unwrap();
    let results =
        signing::verify_ref(&git_repo, &format!("refs/collab/patches/{}/events", id)).unwrap();
    assert_eq!(results.len(), 4);
    for r in &results {
        assert_eq!(
            r.status,
            signing::VerifyStatus::Valid,
            "commit {} must still verify: {:?}",
            r.commit_id,
            r.error
        );
    }
}