use gunnar_proto::*;
use prost::Message;
#[test]
fn every_service_exists_with_its_wire_name() {
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");
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() {
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"
);
}
}
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() {
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() {
for (payload, kind) in one_of_each_payload() {
let event = Event::new(1, 0, payload);
match kind {
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() {
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",
"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/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() {
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() {
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,
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}");
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() {
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() {
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() {
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() {
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() {
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
);
let grant = GrantRequest {
principal_id: "p".into(),
namespace: "nordisk".into(),
repo: String::new(),
level: GrantLevel::Admin as i32,
};
assert!(!grant.namespace.is_empty());
}