tests/signing_test.rs
Ref: Size: 7.0 KiB
use git_collab::event::{Action, Author, Event};
use git_collab::signing::{
canonical_json, generate_keypair, load_signing_key, load_verifying_key, sign_event,
verify_detached, DetachedSignature, VerifyStatus,
};
use tempfile::tempdir;
fn make_event() -> Event {
Event {
timestamp: "2026-03-21T00:00:00Z".to_string(),
author: Author {
name: "Alice".to_string(),
email: "alice@example.com".to_string(),
},
action: Action::IssueOpen {
title: "Test issue".to_string(),
body: "This is a test".to_string(),
relates_to: None,
},
clock: 0,
}
}
// -- T004: Key generation and storage --
#[test]
fn generate_keypair_creates_key_files() {
let dir = tempdir().unwrap();
let config = dir.path().join("git-collab");
let vk = generate_keypair(&config).unwrap();
assert!(config.join("signing-key").exists());
assert!(config.join("signing-key.pub").exists());
// Verify the public key file content matches the returned key
let loaded_vk = load_verifying_key(&config).unwrap();
assert_eq!(vk.to_bytes(), loaded_vk.to_bytes());
}
#[cfg(unix)]
#[test]
fn generate_keypair_sets_private_key_permissions() {
use std::os::unix::fs::PermissionsExt;
let dir = tempdir().unwrap();
let config = dir.path().join("git-collab");
generate_keypair(&config).unwrap();
let meta = std::fs::metadata(config.join("signing-key")).unwrap();
let mode = meta.permissions().mode() & 0o777;
assert_eq!(mode, 0o600, "private key should have 0o600 permissions");
}
#[test]
fn load_keypair_from_disk() {
let dir = tempdir().unwrap();
let config = dir.path().join("git-collab");
generate_keypair(&config).unwrap();
let sk = load_signing_key(&config).unwrap();
let vk = load_verifying_key(&config).unwrap();
// The verifying key derived from loaded signing key should match stored pub key
let derived_vk = sk.verifying_key();
assert_eq!(derived_vk.to_bytes(), vk.to_bytes());
}
#[test]
fn load_signing_key_missing_returns_error() {
let dir = tempdir().unwrap();
let config = dir.path().join("nonexistent");
let err = load_signing_key(&config).unwrap_err();
let msg = format!("{}", err);
assert!(
msg.contains("signing key"),
"error should mention signing key: {}",
msg
);
}
#[test]
fn load_verifying_key_missing_returns_error() {
let dir = tempdir().unwrap();
let config = dir.path().join("nonexistent");
let err = load_verifying_key(&config).unwrap_err();
let msg = format!("{}", err);
assert!(
msg.contains("signing key"),
"error should mention signing key: {}",
msg
);
}
// -- T005: Sign/verify round-trip --
#[test]
fn sign_event_produces_nonempty_signature_and_pubkey() {
let dir = tempdir().unwrap();
let config = dir.path().join("git-collab");
generate_keypair(&config).unwrap();
let sk = load_signing_key(&config).unwrap();
let event = make_event();
let detached = sign_event(&event, &sk).unwrap();
assert!(!detached.signature.is_empty(), "signature should not be empty");
assert!(!detached.pubkey.is_empty(), "pubkey should not be empty");
// Verify they are valid base64
use base64::engine::general_purpose::STANDARD;
use base64::Engine;
STANDARD
.decode(&detached.signature)
.expect("signature should be valid base64");
STANDARD
.decode(&detached.pubkey)
.expect("pubkey should be valid base64");
}
#[test]
fn verify_valid_detached_signature_returns_valid() {
let dir = tempdir().unwrap();
let config = dir.path().join("git-collab");
generate_keypair(&config).unwrap();
let sk = load_signing_key(&config).unwrap();
let event = make_event();
let detached = sign_event(&event, &sk).unwrap();
let status = verify_detached(&event, &detached).unwrap();
assert_eq!(status, VerifyStatus::Valid);
}
#[test]
fn verify_tampered_event_returns_invalid() {
let dir = tempdir().unwrap();
let config = dir.path().join("git-collab");
generate_keypair(&config).unwrap();
let sk = load_signing_key(&config).unwrap();
let event = make_event();
let detached = sign_event(&event, &sk).unwrap();
// Tamper with the event
let mut tampered = event.clone();
tampered.author.name = "Mallory".to_string();
let status = verify_detached(&tampered, &detached).unwrap();
assert_eq!(status, VerifyStatus::Invalid);
}
#[test]
fn verify_missing_signature_returns_missing() {
let event = make_event();
let detached = DetachedSignature {
signature: String::new(),
pubkey: String::new(),
};
let status = verify_detached(&event, &detached).unwrap();
assert_eq!(status, VerifyStatus::Missing);
}
// -- T006: Canonical serialization --
#[test]
fn canonical_json_deterministic() {
let event = make_event();
let bytes1 = canonical_json(&event).unwrap();
let bytes2 = canonical_json(&event).unwrap();
assert_eq!(bytes1, bytes2, "canonical_json should produce identical output");
}
#[test]
fn detached_signature_fields_are_valid() {
let dir = tempdir().unwrap();
let config = dir.path().join("git-collab");
generate_keypair(&config).unwrap();
let sk = load_signing_key(&config).unwrap();
let event = make_event();
let detached = sign_event(&event, &sk).unwrap();
// Verify signature and pubkey are valid base64 strings
use base64::engine::general_purpose::STANDARD;
use base64::Engine;
let sig_bytes = STANDARD.decode(&detached.signature).unwrap();
let pk_bytes = STANDARD.decode(&detached.pubkey).unwrap();
assert_eq!(sig_bytes.len(), 64, "Ed25519 signature should be 64 bytes");
assert_eq!(pk_bytes.len(), 32, "Ed25519 public key should be 32 bytes");
}
#[test]
fn event_json_uses_namespaced_action_types() {
let event = Event {
timestamp: "2026-03-21T12:00:00Z".to_string(),
author: Author {
name: "Bob".to_string(),
email: "bob@example.com".to_string(),
},
action: Action::PatchCreate {
title: "Fix bug".to_string(),
body: "Fixes #42".to_string(),
base_ref: "main".to_string(),
branch: "feature/fix-bug".to_string(),
fixes: Some("deadbeef".to_string()),
commit: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".to_string(),
tree: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb".to_string(),
base_commit: None,
},
clock: 0,
};
let json = serde_json::to_string(&event).unwrap();
assert!(json.contains("\"type\":\"patch.create\""), "action type should be namespaced: {}", json);
// Round-trip
let deserialized: Event = serde_json::from_str(&json).unwrap();
match deserialized.action {
Action::PatchCreate { ref title, ref fixes, .. } => {
assert_eq!(title, "Fix bug");
assert_eq!(fixes.as_deref(), Some("deadbeef"));
}
_ => panic!("Wrong action type after round-trip"),
}
}