a73x

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"),
    }
}