test(private-repos): exercise the HTTP authentication boundary

The private-service implementation spans command-line configuration, HTTP routing, canonical repository identity, and credential validation. Unit tests of those pieces cannot prove that the production subprocess composes them into a fail-closed endpoint.

Extend TestRelay with an explicit private-service constructor and exercise the real process over HTTP. The scenario verifies that missing and non-member credentials receive the same empty Nostr 401 challenge while a configured member crosses the authentication boundary. A nonexistent repository is intentional: authentication is the behavior under test, and successful authorization must remain distinguishable only by proceeding to ordinary Git routing.

NIP-42 protocol framing remains covered by its focused state-machine tests; duplicating that protocol through an SDK client is excluded from this process-level HTTP regression.

Validation: cargo fmt --check passed; cargo test --locked --test private_mode passed (45 tests including the new subprocess scenario).
This commit is contained in:
DanConwayDev
2026-08-14 21:34:20 +00:00
parent 6b4d585bf0
commit 84096df0a1
2 changed files with 91 additions and 0 deletions
+26
View File
@@ -87,6 +87,7 @@ struct RelayOptions {
rejected_hot_cache_duration_secs: Option<u64>,
relay_max_subscriptions: Option<usize>,
sync_recursive_descendant_limit: Option<usize>,
private_members: Option<String>,
/// Run with the production outbound target policy (reject non-global
/// event-directed sync targets). The fixture default is permissive
/// because the entire test infrastructure lives on loopback.
@@ -128,6 +129,23 @@ impl TestRelay {
Self::start_internal(port::reserve_port(), RelayOptions::default()).await
}
/// Start one GRASP-08 private service whose static membership contains
/// `member`.
pub async fn start_private(member: &nostr_sdk::prelude::PublicKey) -> Self {
Self::start_internal(
port::reserve_port(),
RelayOptions {
private_members: Some(
member
.to_bech32()
.expect("Failed to encode private test member"),
),
..RelayOptions::default()
},
)
.await
}
/// Start relay with sync from another relay (bootstrap relay)
///
/// # Example
@@ -638,6 +656,14 @@ impl TestRelay {
if let Some(ref fallback_relays) = options.sync_plus_fallback_relays {
cmd.env("NGIT_SYNC_PLUS_FALLBACK_RELAYS", fallback_relays);
}
if let Some(ref private_members) = options.private_members {
cmd.env("NGIT_PRIVATE_MODE", "true")
.env("NGIT_PRIVATE_MEMBERS", private_members)
.env(
"NGIT_PRIVATE_PUBLIC_ORIGIN",
format!("http://{bind_address}"),
);
}
// The test infrastructure runs entirely on loopback, which the
// production outbound target policy rejects for event-directed sync.
+65
View File
@@ -0,0 +1,65 @@
mod common;
use base64::Engine;
use common::TestRelay;
use nostr_sdk::prelude::{EventBuilder, FinalizeEvent, Keys, Kind, Tag, ToBech32};
use reqwest::header::{AUTHORIZATION, WWW_AUTHENTICATE};
fn credential(keys: &Keys, repository_url: &str) -> String {
let event = EventBuilder::new(Kind::HttpAuth, "")
.tags(vec![
Tag::parse(["u", repository_url]).expect("URL tag"),
Tag::parse(["method", "GET"]).expect("method tag"),
])
.finalize(keys)
.expect("signed NIP-98 credential");
format!(
"Nostr {}",
base64::engine::general_purpose::STANDARD.encode(event.as_json())
)
}
#[tokio::test]
async fn private_git_endpoint_requires_a_service_member() {
let member = Keys::generate();
let outsider = Keys::generate();
let repository_owner = Keys::generate()
.public_key()
.to_bech32()
.expect("repository owner npub");
let relay = TestRelay::start_private(&member.public_key()).await;
let repository_url = format!(
"http://{}/{repository_owner}/private-repository.git",
relay.domain()
);
let client = reqwest::Client::new();
for authorization in [None, Some(credential(&outsider, &repository_url))] {
let mut request = client.get(&repository_url);
if let Some(authorization) = authorization {
request = request.header(AUTHORIZATION, authorization);
}
let response = request.send().await.expect("private Git response");
assert_eq!(response.status(), reqwest::StatusCode::UNAUTHORIZED);
assert_eq!(
response
.headers()
.get(WWW_AUTHENTICATE)
.expect("Nostr challenge")
.to_str()
.expect("ASCII challenge"),
format!("Nostr realm=\"{}\", method=\"GET\"", relay.domain())
);
assert!(response.bytes().await.expect("response body").is_empty());
}
let response = client
.get(&repository_url)
.header(AUTHORIZATION, credential(&member, &repository_url))
.send()
.await
.expect("authenticated Git response");
assert_ne!(response.status(), reqwest::StatusCode::UNAUTHORIZED);
relay.stop().await;
}