Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/gunnar

crates/gunnar-proto/tests/surface.rs

//! The generated surface, asserted rather than assumed.
//!
//! A `.proto` crate's tests can only be blind in one interesting way: they can
//! assert that *something* was generated without asserting *what*. So every
//! test here names the exact string, field or variant it expects, and the
//! negative cases assert a specific rejection rather than "an error happened".

use gunnar_proto::*;
use prost::Message;

#[test]
fn every_service_exists_with_its_wire_name() {
    // These strings are what a gRPC client puts on the wire and what a reverse
    // proxy routes on. A rename here breaks every UI (gRPC is the ONLY
    // control plane), so it is pinned by value, not by existence.
    assert_eq!(repos_server::SERVICE_NAME, "gunnar.v1.Repos");
    assert_eq!(refs_server::SERVICE_NAME, "gunnar.v1.Refs");
    assert_eq!(access_server::SERVICE_NAME, "gunnar.v1.Access");
    assert_eq!(policy_server::SERVICE_NAME, "gunnar.v1.Policy");
    assert_eq!(events_server::SERVICE_NAME, "gunnar.v1.Events");
    assert_eq!(admin_server::SERVICE_NAME, "gunnar.v1.Admin");
    assert_eq!(mirror_server::SERVICE_NAME, "gunnar.v1.Mirror");
    // The two halves of a change proposal, merged 2026-08-30: the record and
    // the review. Both pinned here for the same reason as the seven above —
    // these strings are what a client dials and what a proxy routes on.
    assert_eq!(pull_requests_server::SERVICE_NAME, "gunnar.v1.PullRequests");
    assert_eq!(forge_server::SERVICE_NAME, "gunnar.v1.Forge");
    assert_eq!(
        bootstrap_server::SERVICE_NAME,
        "gunnar.v1.Bootstrap",
        "the one service mounted outside the auth gate, and therefore the one whose name a \
         reverse proxy most needs to be sure of"
    );
}

#[test]
fn every_spec_event_kind_is_on_the_wire() {
    // The event stream lists seven; attestation adds one more; the bus adds the gap marker.
    for (kind, name) in [
        (EventKind::RefUpdated, "EVENT_KIND_REF_UPDATED"),
        (EventKind::RefDeleted, "EVENT_KIND_REF_DELETED"),
        (EventKind::PushRejected, "EVENT_KIND_PUSH_REJECTED"),
        (EventKind::RepoCreated, "EVENT_KIND_REPO_CREATED"),
        (EventKind::RepoDeleted, "EVENT_KIND_REPO_DELETED"),
        (EventKind::MirrorSynced, "EVENT_KIND_MIRROR_SYNCED"),
        (EventKind::KeyUsed, "EVENT_KIND_KEY_USED"),
        (EventKind::PushAttestation, "EVENT_KIND_PUSH_ATTESTATION"),
        (EventKind::Gap, "EVENT_KIND_GAP"),
        (
            EventKind::ReplicationFailed,
            "EVENT_KIND_REPLICATION_FAILED",
        ),
        (
            EventKind::PullRequestOpened,
            "EVENT_KIND_PULL_REQUEST_OPENED",
        ),
        (
            EventKind::PullRequestMerged,
            "EVENT_KIND_PULL_REQUEST_MERGED",
        ),
        (
            EventKind::PullRequestClosed,
            "EVENT_KIND_PULL_REQUEST_CLOSED",
        ),
        (EventKind::StreamOpened, "EVENT_KIND_STREAM_OPENED"),
    ] {
        assert_eq!(kind.as_str_name(), name);
        assert_eq!(
            EventKind::from_str_name(name),
            Some(kind),
            "{name} must round-trip"
        );
    }
}

/// One payload of every kind, so the mapping below cannot be tested against a
/// subset of the variants it claims to cover.
fn one_of_each_payload() -> Vec<(event::Payload, EventKind)> {
    let repo = || Some(RepoPath::new("nordisk", "gunnar"));
    vec![
        (
            event::Payload::RefUpdated(RefUpdated {
                repo: repo(),
                ref_name: "refs/heads/main".into(),
                ..Default::default()
            }),
            EventKind::RefUpdated,
        ),
        (
            event::Payload::RefDeleted(RefDeleted {
                repo: repo(),
                ..Default::default()
            }),
            EventKind::RefDeleted,
        ),
        (
            event::Payload::PushRejected(PushRejected {
                repo: repo(),
                gate: "branch-protection".into(),
                ..Default::default()
            }),
            EventKind::PushRejected,
        ),
        (
            event::Payload::RepoCreated(RepoCreated {
                repo: Some(Repo {
                    path: repo(),
                    ..Default::default()
                }),
                ..Default::default()
            }),
            EventKind::RepoCreated,
        ),
        (
            event::Payload::RepoDeleted(RepoDeleted {
                repo: repo(),
                ..Default::default()
            }),
            EventKind::RepoDeleted,
        ),
        (
            event::Payload::MirrorSynced(MirrorSynced {
                repo: repo(),
                ..Default::default()
            }),
            EventKind::MirrorSynced,
        ),
        (
            event::Payload::KeyUsed(KeyUsed {
                repo: repo(),
                ..Default::default()
            }),
            EventKind::KeyUsed,
        ),
        (
            event::Payload::PushAttestation(PushAttestation {
                repo: repo(),
                ..Default::default()
            }),
            EventKind::PushAttestation,
        ),
        (
            event::Payload::Gap(EventGap {
                dropped: 7,
                ..Default::default()
            }),
            EventKind::Gap,
        ),
        (
            event::Payload::ReplicationFailed(ReplicationFailed {
                repo: repo(),
                target: "standby".into(),
                ..Default::default()
            }),
            EventKind::ReplicationFailed,
        ),
        (
            event::Payload::PullRequestOpened(PullRequestOpened {
                repo: repo(),
                number: 1,
                title: "the first".into(),
                ..Default::default()
            }),
            EventKind::PullRequestOpened,
        ),
        (
            event::Payload::PullRequestMerged(PullRequestMerged {
                repo: repo(),
                number: 1,
                merge_oid: "feedbeef".into(),
                ..Default::default()
            }),
            EventKind::PullRequestMerged,
        ),
        (
            event::Payload::PullRequestClosed(PullRequestClosed {
                repo: repo(),
                number: 2,
                ..Default::default()
            }),
            EventKind::PullRequestClosed,
        ),
        (
            event::Payload::StreamOpened(StreamOpened {
                epoch: "an-epoch".into(),
                ..Default::default()
            }),
            EventKind::StreamOpened,
        ),
        (
            event::Payload::VisibilityChanged(VisibilityChanged {
                repo: repo(),
                from: Visibility::Private as i32,
                to: Visibility::Public as i32,
                principal_id: "p1".into(),
            }),
            EventKind::VisibilityChanged,
        ),
        (
            event::Payload::EncryptedChanged(EncryptedChanged {
                repo: repo(),
                from: false,
                to: true,
                principal_id: "p1".into(),
            }),
            EventKind::EncryptedChanged,
        ),
    ]
}

#[test]
fn event_kind_agrees_with_its_payload_for_every_variant() {
    let payloads = one_of_each_payload();
    assert_eq!(
        payloads.len(),
        16,
        "one payload per EventKind; add the new variant here too"
    );
    for (payload, expected) in payloads {
        let event = Event::new(1, 0, payload);
        assert_eq!(Event::kind_of(event.payload.as_ref().unwrap()), expected);
        assert!(event.kind_matches_payload());
    }
}

#[test]
fn a_mislabelled_event_is_detected() {
    // Measured: `kind_matches_payload` would be worthless if it could not return
    // false. Prove it can, on a hand-built event whose kind is a lie.
    let mut event = Event::new(1, 0, event::Payload::RepoDeleted(RepoDeleted::default()));
    event.kind = EventKind::RefUpdated as i32;
    assert!(!event.kind_matches_payload());

    let empty = Event {
        seq: 1,
        at_unix_ms: 0,
        kind: EventKind::RefUpdated as i32,
        payload: None,
    };
    assert!(!empty.kind_matches_payload(), "no payload is not a match");
}

#[test]
fn every_repo_scoped_event_carries_its_namespace() {
    // Multi-tenancy in every path. A UI filtering by namespace can only
    // work if the namespace is actually on each event.
    for (payload, kind) in one_of_each_payload() {
        let event = Event::new(1, 0, payload);
        match kind {
            // Facts about the STREAM, not about a repository.
            EventKind::Gap | EventKind::StreamOpened => assert!(event.repo_path().is_none()),
            _ => {
                let path = event
                    .repo_path()
                    .unwrap_or_else(|| panic!("{kind:?} lost its RepoPath"));
                assert_eq!(path.namespace, "nordisk");
                assert_eq!(path.repo, "gunnar");
                assert_eq!(path.to_string(), "nordisk/gunnar");
            }
        }
    }
}

#[test]
fn repo_path_accepts_legitimate_paths() {
    // A validator that rejected everything would pass every negative test
    // below while breaking the server. That is the identity-value trap, so the
    // accepting direction goes first.
    for input in [
        "nordisk/gunnar",
        "nordisk/gunnar.git",
        "a/b",
        "with-dash/under_score",
        "Ünicöde/repö",
        "ns/repo.with.dots",
        "ns/..hidden-prefix-but-not-dotdot",
        // Nested namespaces are first class, so the control plane must
        // be able to spell one.
        "team/sub/repo",
        "team/sub/deeper/repo.git",
    ] {
        let path = RepoPath::parse(input).unwrap_or_else(|e| panic!("{input} rejected: {e}"));
        assert!(!path.namespace.is_empty());
        assert!(!path.repo.is_empty());
        path.validate().expect("a parsed path must validate");
    }
    assert_eq!(
        RepoPath::parse("nordisk/gunnar.git").unwrap(),
        RepoPath::new("nordisk", "gunnar"),
        "the SSH spelling and the gRPC spelling must name the same repo"
    );
}

#[test]
fn repo_path_rejects_each_bad_shape_for_the_right_reason() {
    for (input, expected) in [
        ("", RepoPathError::Empty),
        (".git", RepoPathError::Empty),
        ("bare", RepoPathError::NotTwoSegments),
        // `a/b/c` is a NESTED NAMESPACE and is accepted; see
        // `repo_path_accepts_legitimate_paths` and `tests/nested_namespace.rs`.
        // It used to be rejected here, which is the divergence: the SSH sandbox serves
        // `team/sub/repo.git` and the control plane could not name it.
        ("a/b/c/d/e", RepoPathError::TooManyComponents),
        (
            "../etc/passwd",
            RepoPathError::IllegalCharacter("`.` or `..`"),
        ),
        ("/repo", RepoPathError::EmptyNamespace),
        ("ns//repo", RepoPathError::EmptyNamespace),
        ("ns/", RepoPathError::EmptyRepo),
        ("../repo", RepoPathError::IllegalCharacter("`.` or `..`")),
        ("ns/..", RepoPathError::IllegalCharacter("`.` or `..`")),
        ("./repo", RepoPathError::IllegalCharacter("`.` or `..`")),
        ("ns/re\0po", RepoPathError::IllegalCharacter("a NUL byte")),
        (
            "ns/re\npo",
            RepoPathError::IllegalCharacter("a control character"),
        ),
        (
            "ns\\evil/repo",
            RepoPathError::IllegalCharacter("a backslash"),
        ),
        ("host:ns/repo", RepoPathError::IllegalCharacter("a colon")),
    ] {
        let error = RepoPath::parse(input)
            .map(|p| p.to_string())
            .expect_err(&format!("{input:?} must be rejected"));
        assert_eq!(error, expected, "wrong reason for {input:?}");
    }
}

#[test]
fn validate_catches_a_path_that_never_went_through_parse() {
    // Requests arrive with namespace and repo already split, so `validate` —
    // not `parse` — is what actually guards the control plane.
    assert!(RepoPath::new("ns", "repo").validate().is_ok());
    assert_eq!(
        RepoPath::new("..", "repo").validate().unwrap_err(),
        RepoPathError::IllegalCharacter("`.` or `..`")
    );
    assert_eq!(
        RepoPath::new("ns", "sub/repo").validate().unwrap_err(),
        RepoPathError::NotTwoSegments,
        "a slash smuggled into the repo half is still two segments' worth"
    );
    assert_eq!(
        RepoPath::new("", "repo").validate().unwrap_err(),
        RepoPathError::EmptyNamespace,
        "there is no default namespace"
    );
}

#[test]
fn null_oid_width_follows_the_hash_kind() {
    assert_eq!(null_oid(HashKind::Sha1).len(), 40);
    assert_eq!(null_oid(HashKind::Sha256).len(), 64);
    assert!(null_oid(HashKind::Sha256).bytes().all(|b| b == b'0'));
    assert_ne!(
        null_oid(HashKind::Sha1),
        null_oid(HashKind::Sha256),
        "answering the wrong width is the object-format bug that cost a session"
    );
}

#[test]
fn an_attestation_survives_the_wire() {
    // Attestation: encode/decode through prost, then assert the FIELDS, not that
    // decoding returned Ok.
    let original = PushAttestation {
        attestation_id: "att-42".into(),
        repo: Some(RepoPath::new("nordisk", "gunnar")),
        principal_id: "principal-00000001".into(),
        principal_name: "alice".into(),
        key_fingerprint: "SHA256:zzz".into(),
        credential: "key:SHA256:zzz".into(),
        hash_kind: HashKind::Sha256 as i32,
        ref_updates: vec![RefUpdate {
            ref_name: "refs/heads/main".into(),
            old_oid: null_oid(HashKind::Sha256).into(),
            new_oid: "9".repeat(64),
        }],
        new_object_oids: vec!["a".repeat(64), "b".repeat(64)],
        new_object_oids_unknown: String::new(),
        new_object_count: 2,
        // Empty: this attestation's object set is known, so the count
        // above is a count. A non-empty value here means a reader must
        // treat the count as absent rather than as zero.
        new_object_bytes: 8192,
        pack_hash: "c".repeat(64),
        received_at_unix_ms: 1_700_000_000_000,
        server_agent: AGENT.into(),
        transport: "ssh".into(),
    };

    let bytes = original.encode_to_vec();
    let decoded = PushAttestation::decode(bytes.as_slice()).expect("decodes");

    assert_eq!(decoded, original);
    assert_eq!(decoded.repo.as_ref().unwrap().to_string(), "nordisk/gunnar");
    assert_eq!(decoded.ref_updates[0].new_oid.len(), 64);
    assert_eq!(decoded.new_object_oids.len(), 2);
    assert!(
        decoded.new_object_oids_unknown.is_empty(),
        "an enumerated set leaves the reason empty"
    );
    assert!(AGENT.starts_with("gunnar/"), "agent string is {AGENT}");

    // The other shape, over the same wire: no list, a count that is still
    // true, and the sentence that reconciles them. An empty `repeated` field
    // and an empty `string` are both the proto default, so this is the one
    // assertion that proves the reason SURVIVES encoding rather than being
    // re-defaulted on the far side.
    let unenumerated = PushAttestation {
        new_object_oids: Vec::new(),
        new_object_oids_unknown: "the policy has no gate over objects, so the object set was \
                                  not enumerated"
            .into(),
        new_object_count: 13,
        ..original
    };
    let back = PushAttestation::decode(unenumerated.encode_to_vec().as_slice()).expect("decodes");
    assert!(back.new_object_oids.is_empty());
    assert_eq!(back.new_object_count, 13);
    assert_eq!(
        back.new_object_oids_unknown,
        unenumerated.new_object_oids_unknown
    );
}

#[test]
fn an_attestation_is_json_line_shaped() {
    // Attestation's second half: one JSON line appended to gunnar's own log. The serde
    // derives are applied by build.rs to exactly the attestation types, so if
    // that list ever loses one this stops compiling.
    let attestation = PushAttestation {
        repo: Some(RepoPath::new("nordisk", "gunnar")),
        ref_updates: vec![RefUpdate {
            ref_name: "refs/heads/main".into(),
            ..Default::default()
        }],
        ..Default::default()
    };
    let line = serde_json::to_string(&attestation).expect("serialises");
    assert!(!line.contains('\n'));
    assert!(line.contains("\"namespace\":\"nordisk\""), "{line}");
    assert!(line.contains("\"ref_name\":\"refs/heads/main\""), "{line}");

    let back: PushAttestation = serde_json::from_str(&line).expect("round-trips");
    assert_eq!(back, attestation);
}

#[test]
fn watch_request_filters_on_all_three_axes() {
    // The event stream: filterable by namespace / repo / kind.
    let request = WatchRequest {
        namespaces: vec!["nordisk".into()],
        repos: vec![RepoPath::new("nordisk", "gunnar")],
        kinds: vec![EventKind::RefUpdated as i32, EventKind::PushRejected as i32],
        include_attestation_object_ids: true,
        from_seq: 0,
    };
    let bytes = request.encode_to_vec();
    let decoded = WatchRequest::decode(bytes.as_slice()).expect("decodes");
    assert_eq!(decoded, request);
    assert_eq!(decoded.repos[0].namespace, "nordisk");
}

#[test]
fn a_watch_request_carries_a_resume_cursor_on_the_wire() {
    // The resume cursor is a wire field, not a client-side convention. proto3
    // does not encode a zero, so BOTH halves are asserted: a cursor survives
    // the round trip, and its absence still decodes as "no cursor" rather than
    // as something a server might mistake for one.
    let with_cursor = WatchRequest {
        from_seq: 4096,
        ..Default::default()
    };
    let decoded = WatchRequest::decode(with_cursor.encode_to_vec().as_slice()).expect("decodes");
    assert_eq!(
        decoded.from_seq, 4096,
        "a cursor a client sent must reach the server unchanged"
    );

    let without = WatchRequest::default();
    assert!(
        without.encode_to_vec().is_empty(),
        "an empty WatchRequest must still encode to nothing: `from_seq: 0` is the default and \
         costs no bytes, so no existing client's request changed shape"
    );
    assert_eq!(
        WatchRequest::decode(without.encode_to_vec().as_slice())
            .expect("decodes")
            .from_seq,
        0,
        "absent means no cursor"
    );
}

#[test]
fn a_stream_prologue_and_a_gap_both_carry_the_epoch() {
    // The epoch is what makes a cursor meaningful across a reconnect, so it has
    // to survive the wire on both carriers. An epoch that encoded but did not
    // decode would look exactly like a server that never restarted.
    let opened = StreamOpened {
        epoch: "0000000000000001000000ff0000".into(),
        last_seq: 900,
        oldest_buffered_seq: 837,
        replaying: 63,
        from_seq: 837,
    };
    let decoded = StreamOpened::decode(opened.encode_to_vec().as_slice()).expect("decodes");
    assert_eq!(decoded, opened);
    assert_eq!(decoded.epoch, "0000000000000001000000ff0000");

    let gap = EventGap {
        dropped: 12,
        last_delivered_seq: 800,
        resume_seq: 813,
        epoch: "0000000000000001000000ff0000".into(),
    };
    let decoded = EventGap::decode(gap.encode_to_vec().as_slice()).expect("decodes");
    assert_eq!(decoded, gap);
    assert_eq!(
        decoded.epoch, gap.epoch,
        "a gap must be self-describing: the numbers either side of it are only comparable \
         within one run of the server"
    );
}

#[test]
fn every_repo_scoped_request_carries_a_repo_path() {
    // Multi-tenancy, at the type level: each of these would not compile if its request
    // dropped the namespace half of the path.
    let path = RepoPath::new("nordisk", "gunnar");
    assert_eq!(
        GetRepoRequest {
            path: Some(path.clone())
        }
        .path
        .unwrap(),
        path
    );
    assert_eq!(
        ListRefsRequest {
            repo: Some(path.clone()),
            ..Default::default()
        }
        .repo
        .unwrap(),
        path
    );
    assert_eq!(
        GetProtectionRequest {
            repo: Some(path.clone()),
            ..Default::default()
        }
        .repo
        .unwrap(),
        path
    );
    assert_eq!(
        AddMirrorRequest {
            repo: Some(path.clone()),
            ..Default::default()
        }
        .repo
        .unwrap(),
        path
    );
    // Grants scope to a namespace OR a repo within it, never to a bare repo.
    let grant = GrantRequest {
        principal_id: "p".into(),
        namespace: "nordisk".into(),
        repo: String::new(),
        level: GrantLevel::Admin as i32,
    };
    assert!(!grant.namespace.is_empty());
}