use std::path::Path;
use base64::Engine;
use sha2::{Digest as _, Sha256};
use crate::auth::httpsig::{HttpSignature, SignedStringComponents};
#[allow(dead_code)]
pub(crate) enum SshSigner {
Cert {
principal: String,
key: ssh_key::PrivateKey,
certificate_bytes: Option<Vec<u8>>,
},
Pubkey {
fingerprint: String,
key: ssh_key::PrivateKey,
},
}
impl SshSigner {
pub(crate) fn key(&self) -> &ssh_key::PrivateKey {
match self {
SshSigner::Cert { key, .. } => key,
SshSigner::Pubkey { key, .. } => key,
}
}
pub(crate) fn key_id(&self) -> String {
match self {
SshSigner::Pubkey { fingerprint, .. } => fingerprint.clone(),
SshSigner::Cert { .. } => "gitea".to_string(),
}
}
pub(crate) fn algorithm(&self) -> &str {
match self.key().algorithm() {
ssh_key::Algorithm::Ed25519 => "ed25519",
ssh_key::Algorithm::Rsa { .. } => "rsa-sha2-256",
ssh_key::Algorithm::Dsa => "ssh-dss",
_ => "ssh-rsa",
}
}
}
const SIGNATURE_TTL_SECS: i64 = 10;
pub(crate) fn sign_request(
req: &mut reqwest::Request,
signer: &SshSigner,
use_legacy: bool,
) -> crate::Result<()> {
let url = req.url().clone();
let method = req.method().as_str().to_string();
let path_and_query = url
.query()
.map(|q| format!("{}?{}", url.path(), q))
.unwrap_or_else(|| url.path().to_string());
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_err(|e| crate::Error::SshSign(format!("system clock error: {e}")))?
.as_secs() as i64;
let created = now;
let expires = now + SIGNATURE_TTL_SECS;
let digest = match req.body() {
Some(body) => compute_digest(body)?,
None => None,
};
if let Some(ref digest_value) = digest {
req.headers_mut().insert(
"Digest",
digest_value
.parse()
.map_err(|e: reqwest::header::InvalidHeaderValue| {
crate::Error::SshSign(format!("invalid Digest header: {e}"))
})?,
);
}
let mut extra_headers = Vec::new();
if let SshSigner::Cert {
certificate_bytes: Some(cert_bytes),
..
} = signer
{
let cert_b64 = base64::engine::general_purpose::STANDARD.encode(cert_bytes);
req.headers_mut().insert(
"x-ssh-certificate",
cert_b64
.parse()
.map_err(|e: reqwest::header::InvalidHeaderValue| {
crate::Error::SshSign(format!("invalid x-ssh-certificate header: {e}"))
})?,
);
extra_headers.push(("x-ssh-certificate".to_string(), cert_b64));
}
let components = SignedStringComponents {
method,
path: path_and_query,
created,
expires,
digest,
extra_headers,
};
let signed_string = components.build()?;
let signature_bytes = sign_data(signer.key(), signed_string.as_bytes())?;
let signature_b64 = base64::engine::general_purpose::STANDARD.encode(&signature_bytes);
let http_sig = HttpSignature {
key_id: signer.key_id(),
algorithm: signer.algorithm().to_string(),
headers: components.headers_list(),
signature: signature_b64,
};
if use_legacy {
let auth_value = http_sig.to_authorization_header();
req.headers_mut().insert(
"Authorization",
auth_value
.parse()
.map_err(|e: reqwest::header::InvalidHeaderValue| {
crate::Error::SshSign(format!("invalid Authorization header: {e}"))
})?,
);
} else {
let sig_value = http_sig.to_signature_header();
req.headers_mut().insert(
"Signature",
sig_value
.parse()
.map_err(|e: reqwest::header::InvalidHeaderValue| {
crate::Error::SshSign(format!("invalid Signature header: {e}"))
})?,
);
}
Ok(())
}
fn compute_digest(body: &reqwest::Body) -> crate::Result<Option<String>> {
match body.as_bytes() {
Some(bytes) if !bytes.is_empty() => {
let hash = Sha256::digest(bytes);
Ok(Some(format!("SHA-256={:x}", hash)))
}
_ => Ok(None),
}
}
#[allow(dead_code)]
pub(crate) fn fingerprint(key: &ssh_key::PublicKey) -> String {
key.fingerprint(ssh_key::HashAlg::Sha256).to_string()
}
pub(crate) fn sign_data(key: &ssh_key::PrivateKey, data: &[u8]) -> crate::Result<Vec<u8>> {
use signature::{SignatureEncoding, Signer};
let sig = key
.try_sign(data)
.map_err(|e| crate::Error::SshSign(format!("signing failed: {e}")))?;
Ok(sig.to_bytes())
}
pub(crate) fn load_private_key(
path: &Path,
passphrase: Option<&str>,
) -> crate::Result<ssh_key::PrivateKey> {
let key_bytes = std::fs::read(path)
.map_err(|e| crate::Error::SshSign(format!("failed to read {}: {e}", path.display())))?;
load_private_key_bytes(&key_bytes, passphrase)
}
pub(crate) fn load_private_key_bytes(
key_bytes: &[u8],
passphrase: Option<&str>,
) -> crate::Result<ssh_key::PrivateKey> {
let key = ssh_key::PrivateKey::from_openssh(key_bytes)
.map_err(|e| crate::Error::SshSign(format!("failed to parse OpenSSH key: {e}")))?;
if key.is_encrypted() {
let pw = passphrase.ok_or_else(|| {
crate::Error::SshSign("key is encrypted but no passphrase was provided".to_string())
})?;
key.decrypt(pw)
.map_err(|e| crate::Error::SshSign(format!("failed to decrypt key: {e}")))
} else {
Ok(key)
}
}
#[cfg(test)]
mod tests {
use std::path::Path;
use super::*;
use ssh_key::{HashAlg, PrivateKey, Signature};
const ED25519_KEY_BYTES: &[u8] = include_bytes!("../../tests/ssh_fixtures/id_ed25519_test");
const RSA_KEY_BYTES: &[u8] = include_bytes!("../../tests/ssh_fixtures/id_rsa_test");
const RSA_PASSPHRASE_KEY_BYTES: &[u8] =
include_bytes!("../../tests/ssh_fixtures/id_rsa_passphrase_test");
const RSA_PASSPHRASE: &str = "testpassphrase";
fn ed25519_key() -> PrivateKey {
PrivateKey::from_openssh(ED25519_KEY_BYTES).expect("ED25519 test key should load")
}
fn rsa_key() -> PrivateKey {
PrivateKey::from_openssh(RSA_KEY_BYTES).expect("RSA test key should load")
}
#[test]
fn test_fingerprint_starts_with_sha256() {
let key = ed25519_key();
let fp = fingerprint(key.public_key());
assert!(
fp.starts_with("SHA256:"),
"fingerprint should start with SHA256:, got: {fp}"
);
}
#[test]
fn test_fingerprint_matches_ssh_key_crate() {
let key = ed25519_key();
let our_fp = fingerprint(key.public_key());
let crate_fp = key.public_key().fingerprint(HashAlg::Sha256).to_string();
assert_eq!(our_fp, crate_fp);
}
#[test]
fn test_fingerprint_is_base64url() {
let key = ed25519_key();
let fp = fingerprint(key.public_key());
let b64_part = fp
.strip_prefix("SHA256:")
.expect("should have SHA256: prefix");
assert!(
b64_part.chars().all(|c| c.is_ascii_alphanumeric()
|| c == '+'
|| c == '/'
|| c == '='
|| c == '-'
|| c == '_'),
"fingerprint body should be base64url, got: {b64_part}"
);
}
#[test]
fn test_sign_ed25519_returns_bytes() {
let key = ed25519_key();
let data = b"hello world";
let sig = sign_data(&key, data).expect("ED25519 signing should succeed");
assert!(!sig.is_empty(), "signature should not be empty");
}
#[test]
fn test_sign_ed25519_deterministic() {
let key = ed25519_key();
let data = b"deterministic test";
let sig1 = sign_data(&key, data).expect("first sign should succeed");
let sig2 = sign_data(&key, data).expect("second sign should succeed");
assert_eq!(sig1, sig2, "ED25519 signatures should be deterministic");
}
#[test]
fn test_sign_rsa_returns_bytes() {
let key = rsa_key();
let data = b"rsa test payload";
let result = sign_data(&key, data);
assert!(
result.is_err(),
"RSA signing is expected to fail due to ssh-key v0.6.7 CRT bug"
);
}
#[test]
fn test_load_ed25519_key_from_bytes() {
let loaded = load_private_key_bytes(ED25519_KEY_BYTES, None)
.expect("load from bytes should succeed");
assert_eq!(loaded.algorithm(), ssh_key::Algorithm::Ed25519);
}
#[test]
fn test_load_passphrase_key_from_bytes() {
let loaded = load_private_key_bytes(RSA_PASSPHRASE_KEY_BYTES, Some(RSA_PASSPHRASE))
.expect("load with correct passphrase should succeed");
assert!(!loaded.is_encrypted(), "loaded key should be decrypted");
}
#[test]
fn test_load_passphrase_key_from_bytes_wrong_passphrase() {
let result = load_private_key_bytes(RSA_PASSPHRASE_KEY_BYTES, Some("wrong"));
assert!(result.is_err(), "wrong passphrase should fail");
}
#[test]
fn test_load_key_from_bytes_invalid_data() {
let result = load_private_key_bytes(b"not a valid key", None);
assert!(result.is_err(), "invalid key bytes should fail");
}
#[test]
fn test_load_ed25519_key_from_path() {
let tmp = std::env::temp_dir().join("gitea_sdk_test_ed25519_key");
std::fs::write(&tmp, ED25519_KEY_BYTES).expect("write temp key");
let loaded = load_private_key(&tmp, None).expect("load should succeed");
assert_eq!(loaded.algorithm(), ssh_key::Algorithm::Ed25519);
let _ = std::fs::remove_file(&tmp);
}
#[test]
fn test_load_rsa_key_from_path() {
let tmp = std::env::temp_dir().join("gitea_sdk_test_rsa_key");
std::fs::write(&tmp, RSA_KEY_BYTES).expect("write temp key");
let loaded = load_private_key(&tmp, None).expect("load should succeed");
assert_eq!(loaded.algorithm(), ssh_key::Algorithm::Rsa { hash: None });
let _ = std::fs::remove_file(&tmp);
}
#[test]
fn test_load_passphrase_key_with_correct_passphrase() {
let tmp = std::env::temp_dir().join("gitea_sdk_test_rsa_passphrase_key");
std::fs::write(&tmp, RSA_PASSPHRASE_KEY_BYTES).expect("write temp key");
let loaded = load_private_key(&tmp, Some(RSA_PASSPHRASE))
.expect("load with correct passphrase should succeed");
assert!(!loaded.is_encrypted(), "loaded key should be decrypted");
let _ = std::fs::remove_file(&tmp);
}
#[test]
fn test_load_passphrase_key_with_wrong_passphrase_fails() {
let tmp = std::env::temp_dir().join("gitea_sdk_test_rsa_wrong_pass");
std::fs::write(&tmp, RSA_PASSPHRASE_KEY_BYTES).expect("write temp key");
let result = load_private_key(&tmp, Some("wrong-passphrase"));
assert!(result.is_err(), "wrong passphrase should fail");
let _ = std::fs::remove_file(&tmp);
}
#[test]
fn test_load_nonexistent_file_fails() {
let result = load_private_key(Path::new("/nonexistent/path/key"), None);
assert!(result.is_err(), "nonexistent file should fail");
match result.unwrap_err() {
crate::Error::SshSign(msg) => {
assert!(
msg.contains("failed to read"),
"error should mention read failure: {msg}"
);
}
other => panic!("expected SshSign error, got: {other}"),
}
}
#[test]
fn test_ssh_signer_cert_variant() {
let key = ed25519_key();
let signer = SshSigner::Cert {
principal: "test-user".to_string(),
key,
certificate_bytes: None,
};
match signer {
SshSigner::Cert { principal, .. } => {
assert_eq!(principal, "test-user");
}
SshSigner::Pubkey { .. } => panic!("expected Cert variant"),
}
}
#[test]
fn test_ssh_signer_pubkey_variant() {
let key = ed25519_key();
let fp = fingerprint(key.public_key());
let signer = SshSigner::Pubkey {
fingerprint: fp.clone(),
key,
};
match signer {
SshSigner::Pubkey { fingerprint, .. } => {
assert!(fingerprint.starts_with("SHA256:"));
}
SshSigner::Cert { .. } => panic!("expected Pubkey variant"),
}
}
#[test]
fn test_sign_ed25519_output_roundtrip() {
let key = ed25519_key();
let data = b"roundtrip test data";
let sig_bytes = sign_data(&key, data).expect("sign should succeed");
assert!(!sig_bytes.is_empty(), "signature should not be empty");
let sig = Signature::try_from(sig_bytes.as_slice())
.expect("sign_data output should be decodable as SSH wire-format Signature");
assert_eq!(sig.algorithm(), key.algorithm());
assert!(!sig.as_bytes().is_empty());
}
#[test]
fn test_sign_rsa_output_roundtrip() {
let key = rsa_key();
let data = b"rsa roundtrip data";
let result = sign_data(&key, data);
assert!(
result.is_err(),
"RSA signing is expected to fail due to ssh-key v0.6.7 CRT bug"
);
}
#[test]
fn test_ed25519_signature_deterministic_roundtrip() {
let key = ed25519_key();
let data = b"consistent signing data";
let sig1 = sign_data(&key, data).expect("first sign should succeed");
let sig2 = sign_data(&key, data).expect("second sign should succeed");
assert_eq!(sig1, sig2);
assert_eq!(
sig1.len(),
83,
"ED25519 SSH wire-format signature is 83 bytes"
);
}
use super::sign_request;
fn ed25519_pubkey_signer() -> SshSigner {
let key = ed25519_key();
let fp = fingerprint(key.public_key());
SshSigner::Pubkey {
fingerprint: fp,
key,
}
}
fn ed25519_cert_signer() -> SshSigner {
let key = ed25519_key();
SshSigner::Cert {
principal: "test-user".to_string(),
key,
certificate_bytes: None,
}
}
fn make_request(method: &str, url: &str) -> reqwest::Request {
reqwest::Client::new()
.request(
reqwest::Method::from_bytes(method.as_bytes()).expect("valid method"),
url,
)
.build()
.expect("request should build")
}
#[test]
fn test_sign_request_adds_signature_header_modern() {
let signer = ed25519_pubkey_signer();
let mut req = make_request("POST", "https://gitea.example.com/api/v1/repos/owner/repo");
sign_request(&mut req, &signer, false).expect("sign_request should succeed");
let sig_header = req
.headers()
.get("Signature")
.expect("Signature header should be present")
.to_str()
.expect("Signature header should be valid UTF-8");
assert!(
sig_header.contains("keyId=\""),
"missing keyId: {sig_header}"
);
assert!(
sig_header.contains("algorithm=\"ed25519\""),
"missing algorithm: {sig_header}"
);
assert!(
sig_header.contains("headers=\""),
"missing headers: {sig_header}"
);
assert!(
sig_header.contains("signature=\""),
"missing signature: {sig_header}"
);
}
#[test]
fn test_sign_request_legacy_uses_authorization_header() {
let signer = ed25519_pubkey_signer();
let mut req = make_request("GET", "https://gitea.example.com/api/v1/user");
sign_request(&mut req, &signer, true).expect("sign_request should succeed");
let auth_header = req
.headers()
.get("Authorization")
.expect("Authorization header should be present in legacy mode")
.to_str()
.expect("Authorization header should be valid UTF-8");
assert!(
auth_header.starts_with("Signature "),
"legacy Authorization should start with 'Signature ': {auth_header}"
);
assert!(
req.headers().get("Signature").is_none(),
"Signature header should not be present in legacy mode"
);
}
#[test]
fn test_sign_request_modern_uses_signature_header() {
let signer = ed25519_pubkey_signer();
let mut req = make_request("GET", "https://gitea.example.com/api/v1/user");
sign_request(&mut req, &signer, false).expect("sign_request should succeed");
assert!(
req.headers().get("Signature").is_some(),
"Signature header should be present in modern mode"
);
assert!(
req.headers().get("Authorization").is_none(),
"Authorization header should not be present in modern mode"
);
}
#[test]
fn test_sign_request_keyid_format_pubkey() {
let signer = ed25519_pubkey_signer();
let mut req = make_request("POST", "https://gitea.example.com/api/v1/repos/owner/repo");
sign_request(&mut req, &signer, false).expect("sign_request should succeed");
let sig_header = req
.headers()
.get("Signature")
.expect("Signature header should be present")
.to_str()
.expect("Signature header should be valid UTF-8");
assert!(
sig_header.contains("keyId=\"SHA256:"),
"keyId should be 'SHA256:...', got: {sig_header}"
);
assert!(
!sig_header.contains("keyId=\"ssh-ed25519 "),
"keyId should NOT have algorithm prefix: {sig_header}"
);
}
#[test]
fn test_sign_request_keyid_format_cert() {
let signer = ed25519_cert_signer();
let mut req = make_request("POST", "https://gitea.example.com/api/v1/repos/owner/repo");
sign_request(&mut req, &signer, false).expect("sign_request should succeed");
let sig_header = req
.headers()
.get("Signature")
.expect("Signature header should be present")
.to_str()
.expect("Signature header should be valid UTF-8");
assert!(
sig_header.contains("keyId=\"gitea\""),
"cert keyId should be 'gitea', got: {sig_header}"
);
}
#[test]
fn test_sign_request_uses_created_expires_headers() {
let signer = ed25519_pubkey_signer();
let mut req = make_request("GET", "https://gitea.example.com/api/v1/user");
sign_request(&mut req, &signer, false).expect("sign_request should succeed");
let sig_header = req
.headers()
.get("Signature")
.expect("Signature header should be present")
.to_str()
.expect("Signature header should be valid UTF-8");
assert!(
sig_header.contains("headers=\"(request-target) (created) (expires)\""),
"headers list should use (created)/(expires), got: {sig_header}"
);
assert!(
!sig_header.contains("host date"),
"headers list should NOT contain host/date: {sig_header}"
);
}
#[test]
fn test_sign_request_digest_for_body() {
let signer = ed25519_pubkey_signer();
let mut req = reqwest::Client::new()
.post("https://gitea.example.com/api/v1/repos/owner/repo")
.body(r#"{"name":"test"}"#)
.build()
.expect("request should build");
sign_request(&mut req, &signer, false).expect("sign_request should succeed");
let digest_header = req
.headers()
.get("Digest")
.expect("Digest header should be set for body")
.to_str()
.expect("Digest header should be valid UTF-8");
assert!(
digest_header.starts_with("SHA-256="),
"Digest should be SHA-256, got: {digest_header}"
);
let sig_header = req
.headers()
.get("Signature")
.expect("Signature header should be present")
.to_str()
.expect("Signature header should be valid UTF-8");
assert!(
sig_header.contains("digest"),
"headers list should include 'digest' when body is present: {sig_header}"
);
}
#[test]
fn test_sign_request_no_digest_for_empty_body() {
let signer = ed25519_pubkey_signer();
let mut req = make_request("GET", "https://gitea.example.com/api/v1/user");
sign_request(&mut req, &signer, false).expect("sign_request should succeed");
assert!(
req.headers().get("Digest").is_none(),
"Digest header should NOT be set for bodyless request"
);
}
#[test]
fn test_sign_request_headers_list_without_digest() {
let signer = ed25519_pubkey_signer();
let mut req = make_request("GET", "https://gitea.example.com/api/v1/user");
sign_request(&mut req, &signer, false).expect("sign_request should succeed");
let sig_header = req
.headers()
.get("Signature")
.expect("Signature header should be present")
.to_str()
.expect("Signature header should be valid UTF-8");
assert!(
sig_header.contains("headers=\"(request-target) (created) (expires)\""),
"headers list should be '(request-target) (created) (expires)' for bodyless request: {sig_header}"
);
}
#[test]
fn test_sign_request_get_method_lowercased() {
let signer = ed25519_pubkey_signer();
let mut req = make_request("GET", "https://gitea.example.com/api/v1/user");
sign_request(&mut req, &signer, false).expect("sign_request should succeed");
let sig_header = req
.headers()
.get("Signature")
.expect("Signature header should be present")
.to_str()
.expect("Signature header should be valid UTF-8");
assert!(
sig_header.contains("headers=\"(request-target) (created) (expires)\""),
"should include (created)/(expires) headers for GET: {sig_header}"
);
}
#[test]
fn test_sign_request_signature_is_base64() {
let signer = ed25519_pubkey_signer();
let mut req = make_request("POST", "https://gitea.example.com/api/v1/repos/owner/repo");
sign_request(&mut req, &signer, false).expect("sign_request should succeed");
let sig_header = req
.headers()
.get("Signature")
.expect("Signature header should be present")
.to_str()
.expect("Signature header should be valid UTF-8");
let sig_start = sig_header
.find("signature=\"")
.expect("should have signature field");
let sig_value = &sig_header[sig_start + 11..];
let sig_end = sig_value
.find('"')
.expect("signature value should be quoted");
let sig_b64 = &sig_value[..sig_end];
assert!(!sig_b64.is_empty(), "signature should not be empty");
base64::Engine::decode(&base64::engine::general_purpose::STANDARD, sig_b64)
.expect("signature should be valid base64");
}
#[test]
fn test_sign_request_deterministic() {
let signer = ed25519_pubkey_signer();
let mut req1 = make_request("GET", "https://gitea.example.com/api/v1/user");
sign_request(&mut req1, &signer, false).expect("first sign should succeed");
let mut req2 = make_request("GET", "https://gitea.example.com/api/v1/user");
sign_request(&mut req2, &signer, false).expect("second sign should succeed");
let sig1 = req1.headers().get("Signature").unwrap().to_str().unwrap();
let sig2 = req2.headers().get("Signature").unwrap().to_str().unwrap();
assert!(
sig1.starts_with("keyId=\"") && sig2.starts_with("keyId=\""),
"both signatures should have keyId"
);
}
#[test]
fn test_sign_request_cert_adds_x_ssh_certificate_header() {
let key = ed25519_key();
let fake_cert = b"fake-cert-bytes-for-testing";
let signer = SshSigner::Cert {
principal: "test-user".to_string(),
key,
certificate_bytes: Some(fake_cert.to_vec()),
};
let mut req = make_request("POST", "https://gitea.example.com/api/v1/repos/owner/repo");
sign_request(&mut req, &signer, false).expect("sign_request should succeed");
let cert_header = req
.headers()
.get("x-ssh-certificate")
.expect("x-ssh-certificate header should be present for cert signer")
.to_str()
.expect("x-ssh-certificate should be valid UTF-8");
assert!(!cert_header.is_empty(), "cert header should not be empty");
let sig_header = req
.headers()
.get("Signature")
.expect("Signature header should be present")
.to_str()
.expect("Signature header should be valid UTF-8");
assert!(
sig_header.contains("x-ssh-certificate"),
"headers list should include x-ssh-certificate: {sig_header}"
);
}
#[tokio::test]
async fn test_sign_request_wiremock_modern() {
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/api/v1/repos/owner/repo"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({"id": 1})))
.mount(&server)
.await;
let signer = ed25519_pubkey_signer();
let url = server.uri();
let mut req = make_request("POST", &format!("{url}/api/v1/repos/owner/repo"));
sign_request(&mut req, &signer, false).expect("sign_request should succeed");
assert!(
req.headers().get("Signature").is_some(),
"request should have Signature header"
);
let client = reqwest::Client::new();
let resp = client.execute(req).await.expect("request should execute");
assert_eq!(resp.status(), 200);
}
#[tokio::test]
async fn test_sign_request_wiremock_legacy() {
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/v1/user"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({"id": 1})))
.mount(&server)
.await;
let signer = ed25519_pubkey_signer();
let url = server.uri();
let mut req = make_request("GET", &format!("{url}/api/v1/user"));
sign_request(&mut req, &signer, true).expect("sign_request should succeed");
assert!(
req.headers().get("Authorization").is_some(),
"request should have Authorization header in legacy mode"
);
let client = reqwest::Client::new();
let resp = client.execute(req).await.expect("request should execute");
assert_eq!(resp.status(), 200);
}
#[tokio::test]
async fn test_sign_request_wiremock_captures_signature() {
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/api/v1/repos/owner/repo"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({"ok": true})))
.mount(&server)
.await;
let signer = ed25519_pubkey_signer();
let url = server.uri();
let mut req = make_request("POST", &format!("{url}/api/v1/repos/owner/repo"));
sign_request(&mut req, &signer, false).expect("sign_request should succeed");
let sig_val = req
.headers()
.get("Signature")
.expect("Signature header must be present")
.to_str()
.expect("valid UTF-8");
assert!(
sig_val.contains("keyId=\"SHA256:"),
"missing keyId: {sig_val}"
);
assert!(
sig_val.contains("algorithm=\"ed25519\""),
"missing algorithm: {sig_val}"
);
assert!(
sig_val.contains("headers=\"(request-target) (created) (expires)\""),
"missing headers: {sig_val}"
);
assert!(
sig_val.contains("signature=\""),
"missing signature: {sig_val}"
);
let client = reqwest::Client::new();
let resp = client.execute(req).await.expect("request should execute");
assert_eq!(resp.status(), 200);
}
}