9dfa06ffee
Docker image / Build (linux/amd64) (push) Has been cancelled
Docker image / Build (linux/arm64) (push) Has been cancelled
Docker image / Merge release multi-arch manifest (push) Has been cancelled
Docker image / Merge debug multi-arch manifest (push) Has been cancelled
Docker image / Build public push gateway (linux/amd64) (push) Has been cancelled
Docker image / Build public push gateway (linux/arm64) (push) Has been cancelled
Docker image / Publish public push gateway image (push) Has been cancelled
Sprig image / Build (linux/amd64) (push) Has been cancelled
Sprig image / Build (linux/arm64) (push) Has been cancelled
Sprig image / Merge multi-arch manifest (push) Has been cancelled
Harbor Buzz Orchestra / Python tests and lint (push) Has been cancelled
CI / Detect Changed Paths (push) Has been cancelled
CI / Rust Lint (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Desktop Core (push) Has been cancelled
CI / Desktop Smoke E2E (1) (push) Has been cancelled
CI / Desktop Smoke E2E (2) (push) Has been cancelled
CI / Desktop Smoke E2E (3) (push) Has been cancelled
CI / Desktop Smoke E2E (4) (push) Has been cancelled
CI / Desktop (push) Has been cancelled
CI / Desktop E2E Relay (push) Has been cancelled
CI / Desktop E2E Integration (1/2) (push) Has been cancelled
CI / Desktop E2E Integration (2/2) (push) Has been cancelled
CI / Desktop E2E Integration (push) Has been cancelled
CI / Backend Integration (relay e2e) (push) Has been cancelled
CI / Relay E2E (push) Has been cancelled
CI / Web (push) Has been cancelled
CI / Mobile (push) Has been cancelled
CI / Security (push) Has been cancelled
CI / Dead Token Reference Guard (push) Has been cancelled
CI / Server Cross-Compile (aarch64-unknown-linux-musl) (push) Has been cancelled
CI / Server Cross-Compile (x86_64-unknown-linux-musl) (push) Has been cancelled
CI / Windows Rust (x86_64-pc-windows-msvc) (push) Has been cancelled
CI / Desktop Build (macOS) (push) Has been cancelled
helm chart / lint + unittest + render matrix (push) Has been cancelled
helm chart / install on kind (gated) (push) Has been cancelled
helm chart / publish chart to GHCR (push) Has been cancelled
Mesh Lifecycle / Relay-Driven Mesh Lifecycle Smoke (push) Has been cancelled
Sprig / Build (aarch64-unknown-linux-musl) (push) Has been cancelled
Sprig / Build (x86_64-unknown-linux-musl) (push) Has been cancelled
Sprig / Publish rolling release (push) Has been cancelled
Sprig / Publish tagged release (push) Has been cancelled
Signed-off-by: cls_宁波本机 <908705107@qq.com>
357 lines
11 KiB
Rust
357 lines
11 KiB
Rust
//! Integration tests for git-credential-nostr.
|
|
//!
|
|
//! Each test spawns the compiled binary as a subprocess, feeds it the
|
|
//! credential-helper protocol on stdin, and asserts on stdout/stderr/exit-code.
|
|
|
|
use std::io::Write;
|
|
use std::process::{Command, Stdio};
|
|
|
|
use base64::Engine as _;
|
|
use nostr::{Keys, ToBech32};
|
|
|
|
/// Spawn the binary, write `input` to stdin, collect output.
|
|
/// `env_vars` are added on top of the inherited environment.
|
|
/// `NOSTR_PRIVATE_KEY` is always cleared first to prevent test pollution.
|
|
fn run_helper(input: &str, env_vars: &[(&str, &str)]) -> std::process::Output {
|
|
let bin = env!("CARGO_BIN_EXE_git-credential-nostr");
|
|
let mut cmd = Command::new(bin);
|
|
cmd.stdin(Stdio::piped())
|
|
.stdout(Stdio::piped())
|
|
.stderr(Stdio::piped())
|
|
.current_dir(std::env::temp_dir())
|
|
.env_remove("NOSTR_PRIVATE_KEY")
|
|
.env_remove("BUZZ_AUTH_TAG")
|
|
.env_remove("GIT_CONFIG_COUNT")
|
|
// Prevent git config on the test machine from supplying credentials.
|
|
.env("GIT_CONFIG_GLOBAL", "/dev/null")
|
|
.env("GIT_CONFIG_NOSYSTEM", "1")
|
|
.env("HOME", std::env::temp_dir());
|
|
for (k, v) in env_vars {
|
|
cmd.env(k, v);
|
|
}
|
|
let mut child = cmd.spawn().expect("failed to spawn git-credential-nostr");
|
|
child
|
|
.stdin
|
|
.take()
|
|
.unwrap()
|
|
.write_all(input.as_bytes())
|
|
.unwrap();
|
|
child.wait_with_output().expect("failed to wait on child")
|
|
}
|
|
|
|
/// Generate a fresh nsec string for use in tests.
|
|
fn fresh_nsec() -> String {
|
|
let keys = Keys::generate();
|
|
keys.secret_key().to_bech32().unwrap()
|
|
}
|
|
|
|
/// Standard valid credential-helper input (includes authtype capability).
|
|
fn valid_input() -> String {
|
|
"capability[]=authtype\n\
|
|
capability[]=state\n\
|
|
protocol=https\n\
|
|
host=relay.example.com\n\
|
|
path=git/owner/repo.git/info/refs\n\
|
|
wwwauth[]=Nostr realm=\"buzz\", method=\"GET\"\n\
|
|
\n"
|
|
.to_string()
|
|
}
|
|
|
|
/// Happy path: valid key + valid input → well-formed credential response with
|
|
/// a base64-encoded kind:27235 JSON event.
|
|
#[test]
|
|
fn happy_path() {
|
|
let nsec = fresh_nsec();
|
|
let out = run_helper(&valid_input(), &[("NOSTR_PRIVATE_KEY", &nsec)]);
|
|
|
|
assert!(
|
|
out.status.success(),
|
|
"expected exit 0, got {:?}\nstderr: {}",
|
|
out.status.code(),
|
|
String::from_utf8_lossy(&out.stderr)
|
|
);
|
|
|
|
let stdout = String::from_utf8_lossy(&out.stdout);
|
|
let lines: Vec<&str> = stdout.lines().collect();
|
|
|
|
assert!(
|
|
lines.contains(&"capability[]=authtype"),
|
|
"missing capability[]=authtype in:\n{stdout}"
|
|
);
|
|
assert!(
|
|
lines.contains(&"authtype=Nostr"),
|
|
"missing authtype=Nostr in:\n{stdout}"
|
|
);
|
|
assert!(
|
|
lines.contains(&"ephemeral=true"),
|
|
"missing ephemeral=true in:\n{stdout}"
|
|
);
|
|
assert!(
|
|
lines.contains(&"quit=true"),
|
|
"missing quit=true in:\n{stdout}"
|
|
);
|
|
|
|
// Extract and validate the credential value.
|
|
let cred_line = lines
|
|
.iter()
|
|
.find(|l| l.starts_with("credential="))
|
|
.expect("no credential= line in output");
|
|
let b64 = cred_line.strip_prefix("credential=").unwrap();
|
|
|
|
let json_bytes = base64::engine::general_purpose::STANDARD
|
|
.decode(b64)
|
|
.expect("credential is not valid base64");
|
|
let json_str = String::from_utf8(json_bytes).expect("credential is not valid UTF-8");
|
|
|
|
let event: serde_json::Value =
|
|
serde_json::from_str(&json_str).expect("credential does not decode to JSON");
|
|
|
|
assert_eq!(
|
|
event["kind"],
|
|
serde_json::json!(27235),
|
|
"expected kind 27235, got {}",
|
|
event["kind"]
|
|
);
|
|
|
|
// Sanity-check a few more fields the NIP-98 event must have.
|
|
assert!(event["id"].is_string(), "event missing 'id'");
|
|
assert!(event["pubkey"].is_string(), "event missing 'pubkey'");
|
|
assert!(event["sig"].is_string(), "event missing 'sig'");
|
|
assert!(event["tags"].is_array(), "event missing 'tags'");
|
|
}
|
|
|
|
/// A Buzz-managed agent must carry its NIP-OA owner attestation inside the
|
|
/// signed NIP-98 event so the relay can admit it through the owner's membership.
|
|
#[test]
|
|
fn includes_nip_oa_auth_tag_in_signed_event() {
|
|
let agent_keys = Keys::generate();
|
|
let owner_keys = Keys::generate();
|
|
let nsec = agent_keys.secret_key().to_bech32().unwrap();
|
|
let auth_tag = serde_json::to_string(&[
|
|
"auth",
|
|
owner_keys.public_key().to_hex().as_str(),
|
|
"",
|
|
&"00".repeat(64),
|
|
])
|
|
.expect("serialize auth tag");
|
|
|
|
let out = run_helper(
|
|
&valid_input(),
|
|
&[("NOSTR_PRIVATE_KEY", &nsec), ("BUZZ_AUTH_TAG", &auth_tag)],
|
|
);
|
|
assert!(
|
|
out.status.success(),
|
|
"helper failed: {}",
|
|
String::from_utf8_lossy(&out.stderr)
|
|
);
|
|
|
|
let stdout = String::from_utf8_lossy(&out.stdout);
|
|
let credential = stdout
|
|
.lines()
|
|
.find_map(|line| line.strip_prefix("credential="))
|
|
.expect("credential output");
|
|
let event_json = base64::engine::general_purpose::STANDARD
|
|
.decode(credential)
|
|
.expect("base64 credential");
|
|
let event: nostr::Event = serde_json::from_slice(&event_json).expect("NIP-98 event");
|
|
|
|
assert!(
|
|
event.verify().is_ok(),
|
|
"auth tag must be covered by the event signature"
|
|
);
|
|
assert!(event.tags.iter().any(|tag| tag.as_slice()
|
|
== [
|
|
"auth",
|
|
owner_keys.public_key().to_hex().as_str(),
|
|
"",
|
|
serde_json::from_str::<Vec<String>>(&auth_tag).unwrap()[3].as_str(),
|
|
]));
|
|
}
|
|
|
|
/// A configured but malformed owner attestation must fail closed rather than
|
|
/// silently authenticating the agent without delegation.
|
|
#[test]
|
|
fn malformed_nip_oa_auth_tag_fails_closed() {
|
|
let nsec = fresh_nsec();
|
|
let out = run_helper(
|
|
&valid_input(),
|
|
&[("NOSTR_PRIVATE_KEY", &nsec), ("BUZZ_AUTH_TAG", "not-json")],
|
|
);
|
|
|
|
assert_eq!(out.status.code(), Some(1));
|
|
assert!(String::from_utf8_lossy(&out.stderr).contains("invalid NIP-OA auth tag"));
|
|
assert!(!String::from_utf8_lossy(&out.stdout).contains("credential="));
|
|
}
|
|
|
|
/// Old git (no `capability[]=authtype` in input) → empty line on stdout, exit 0.
|
|
#[test]
|
|
fn old_git_no_authtype_capability() {
|
|
let input = "protocol=https\n\
|
|
host=relay.example.com\n\
|
|
path=git/owner/repo.git/info/refs\n\
|
|
\n";
|
|
|
|
let nsec = fresh_nsec();
|
|
let out = run_helper(input, &[("NOSTR_PRIVATE_KEY", &nsec)]);
|
|
|
|
assert!(
|
|
out.status.success(),
|
|
"expected exit 0 for old-git path, got {:?}",
|
|
out.status.code()
|
|
);
|
|
|
|
let stdout = String::from_utf8_lossy(&out.stdout);
|
|
// Output should be just a blank line — no credential data.
|
|
assert_eq!(
|
|
stdout.trim(),
|
|
"",
|
|
"expected empty output for old-git path, got:\n{stdout}"
|
|
);
|
|
assert!(
|
|
!stdout.contains("credential="),
|
|
"should not emit credential= for old git"
|
|
);
|
|
}
|
|
|
|
/// No key configured at all → exit 1, stderr mentions "no nostr key configured".
|
|
#[test]
|
|
fn missing_key() {
|
|
// run_helper already clears NOSTR_PRIVATE_KEY and points HOME at a temp dir
|
|
// that has no git config, so no keyfile will be found.
|
|
let out = run_helper(&valid_input(), &[]);
|
|
|
|
assert_eq!(
|
|
out.status.code(),
|
|
Some(1),
|
|
"expected exit 1 for missing key"
|
|
);
|
|
|
|
let stderr = String::from_utf8_lossy(&out.stderr);
|
|
assert!(
|
|
stderr.contains("no nostr key configured"),
|
|
"expected 'no nostr key configured' in stderr, got:\n{stderr}"
|
|
);
|
|
}
|
|
|
|
/// `wwwauth[]` present but missing `method="..."` → exit 0, no credential emitted.
|
|
/// The helper gracefully declines rather than erroring, so git can fall through
|
|
/// to the next credential helper (safe for global credential.helper config).
|
|
#[test]
|
|
fn missing_method_hint() {
|
|
let input = "capability[]=authtype\n\
|
|
capability[]=state\n\
|
|
protocol=https\n\
|
|
host=relay.example.com\n\
|
|
path=git/owner/repo.git/info/refs\n\
|
|
wwwauth[]=Nostr realm=\"buzz\"\n\
|
|
\n";
|
|
|
|
let nsec = fresh_nsec();
|
|
let out = run_helper(input, &[("NOSTR_PRIVATE_KEY", &nsec)]);
|
|
|
|
assert!(
|
|
out.status.success(),
|
|
"expected exit 0 for missing method hint (graceful decline), got {:?}",
|
|
out.status.code()
|
|
);
|
|
|
|
let stdout = String::from_utf8_lossy(&out.stdout);
|
|
assert!(
|
|
!stdout.contains("credential="),
|
|
"should not emit credential= when method hint is missing"
|
|
);
|
|
}
|
|
|
|
/// Input without `path=` line (useHttpPath not set) → exit 1, stderr mentions "useHttpPath".
|
|
/// The relay requires the full repo-root URL for NIP-98 verification, so the
|
|
/// credential helper cannot function without the path component.
|
|
#[test]
|
|
fn missing_path() {
|
|
let input = "capability[]=authtype\n\
|
|
capability[]=state\n\
|
|
protocol=https\n\
|
|
host=relay.example.com\n\
|
|
wwwauth[]=Nostr realm=\"buzz\", method=\"GET\"\n\
|
|
\n";
|
|
|
|
let nsec = fresh_nsec();
|
|
let out = run_helper(input, &[("NOSTR_PRIVATE_KEY", &nsec)]);
|
|
|
|
assert_eq!(
|
|
out.status.code(),
|
|
Some(1),
|
|
"expected exit 1 for missing path"
|
|
);
|
|
|
|
let stderr = String::from_utf8_lossy(&out.stderr);
|
|
assert!(
|
|
stderr.contains("useHttpPath"),
|
|
"expected 'useHttpPath' in stderr, got:\n{stderr}"
|
|
);
|
|
}
|
|
|
|
/// Keyfile with 0644 permissions → exit 1, stderr mentions "insecure permissions".
|
|
#[cfg(unix)]
|
|
#[test]
|
|
fn bad_keyfile_permissions() {
|
|
use std::os::unix::fs::PermissionsExt;
|
|
|
|
let nsec = fresh_nsec();
|
|
|
|
// Write keyfile to a temp path.
|
|
let tmp_dir = std::env::temp_dir();
|
|
let keyfile = tmp_dir.join(format!(
|
|
"nostr-test-key-{}.nsec",
|
|
std::time::SystemTime::now()
|
|
.duration_since(std::time::UNIX_EPOCH)
|
|
.unwrap()
|
|
.subsec_nanos()
|
|
));
|
|
std::fs::write(&keyfile, &nsec).expect("failed to write temp keyfile");
|
|
|
|
// Set insecure permissions (0644).
|
|
std::fs::set_permissions(&keyfile, std::fs::Permissions::from_mode(0o644))
|
|
.expect("failed to set permissions");
|
|
|
|
// Point a scratch git config at the keyfile.
|
|
let git_config_dir = tmp_dir.join(format!(
|
|
"nostr-test-gitconfig-{}",
|
|
std::time::SystemTime::now()
|
|
.duration_since(std::time::UNIX_EPOCH)
|
|
.unwrap()
|
|
.subsec_nanos()
|
|
));
|
|
std::fs::create_dir_all(&git_config_dir).unwrap();
|
|
let git_config_file = git_config_dir.join(".gitconfig");
|
|
std::fs::write(
|
|
&git_config_file,
|
|
format!("[nostr]\n\tkeyfile = {}\n", keyfile.display()),
|
|
)
|
|
.expect("failed to write git config");
|
|
|
|
let out = run_helper(
|
|
&valid_input(),
|
|
&[
|
|
("HOME", git_config_dir.to_str().unwrap()),
|
|
("GIT_CONFIG_GLOBAL", git_config_file.to_str().unwrap()),
|
|
],
|
|
);
|
|
|
|
// Clean up regardless of outcome.
|
|
let _ = std::fs::remove_file(&keyfile);
|
|
let _ = std::fs::remove_file(&git_config_file);
|
|
let _ = std::fs::remove_dir(&git_config_dir);
|
|
|
|
assert_eq!(
|
|
out.status.code(),
|
|
Some(1),
|
|
"expected exit 1 for insecure keyfile permissions"
|
|
);
|
|
|
|
let stderr = String::from_utf8_lossy(&out.stderr);
|
|
assert!(
|
|
stderr.contains("insecure permissions"),
|
|
"expected 'insecure permissions' in stderr, got:\n{stderr}"
|
|
);
|
|
}
|