mod harness;
use std::collections::HashMap;
use std::sync::Arc;
use agent_pay::{
did_key_from_public_key, generate_key_pair, FetchOptions, MemoryLedger, MemoryNode, Paywall,
PaywallOptions,
};
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use base64::Engine;
use harness::{echo_handler, make_fetch, make_raw_fetch, SECRET};
fn setup() -> (Arc<Paywall>, std::sync::Arc<MemoryLedger>, String) {
let kp = generate_key_pair();
let did = did_key_from_public_key(&kp.public_key).unwrap();
let ledger = MemoryLedger::new();
let lightning: Arc<dyn agent_pay::LightningNode> =
Arc::new(MemoryNode::new(ledger.clone(), "server"));
let opts = PaywallOptions::new(
did.clone(),
kp.private_key,
1000,
"/report",
lightning,
SECRET.to_vec(),
);
(Arc::new(Paywall::new(opts)), ledger, did)
}
#[tokio::test]
async fn first_request_returns_402_with_x_did_invoice() {
let (paywall, _ledger, _did) = setup();
let raw = make_raw_fetch(paywall.clone(), echo_handler());
let res = (raw)("http://x/report".into(), HashMap::new())
.await
.unwrap();
assert_eq!(res.status, 402);
let auth = res.header("www-authenticate").unwrap();
assert!(auth.starts_with("L402 "));
assert!(auth.contains("macaroon=\""));
assert!(auth.contains("invoice=\""));
assert!(res.header("x-did-invoice").is_some());
}
#[tokio::test]
async fn each_402_carries_a_fresh_nonce() {
let (paywall, _ledger, _did) = setup();
let raw = make_raw_fetch(paywall.clone(), echo_handler());
let nonce_from = || {
let raw = raw.clone();
async move {
let res = (raw)("http://x/report".into(), HashMap::new())
.await
.unwrap();
let jws = res.header("x-did-invoice").unwrap().to_string();
let parts: Vec<&str> = jws.split('.').collect();
let payload_bytes = URL_SAFE_NO_PAD.decode(parts[1]).unwrap();
let v: serde_json::Value = serde_json::from_slice(&payload_bytes).unwrap();
v["nonce"].as_str().unwrap().to_string()
}
};
let n1 = nonce_from().await;
let n2 = nonce_from().await;
assert_ne!(n1, n2);
}
#[tokio::test]
async fn replayed_preimage_returns_401() {
let (paywall, ledger, _did) = setup();
let wallet: Arc<dyn agent_pay::LightningNode> =
Arc::new(MemoryNode::new(ledger.clone(), "wallet"));
let captured = Arc::new(std::sync::Mutex::new(None::<String>));
let captured_clone = captured.clone();
let inner = make_fetch(paywall.clone(), echo_handler());
let recorder: agent_pay::FetchFn =
Arc::new(move |url: String, headers: HashMap<String, String>| {
let captured_clone = captured_clone.clone();
let inner = inner.clone();
Box::pin(async move {
if let Some(auth) = headers.get("authorization") {
if auth.starts_with("L402 ") {
*captured_clone.lock().unwrap() = Some(auth.clone());
}
}
(inner)(url, headers).await
})
});
let mut opts = FetchOptions::new(wallet, 5000, recorder);
opts.expected_did = None;
let ok = agent_pay::fetch_with_l402("http://x/report", opts)
.await
.unwrap();
assert_eq!(ok.status, 200);
let auth = captured.lock().unwrap().clone().unwrap();
let raw = make_raw_fetch(paywall, echo_handler());
let mut headers = HashMap::new();
headers.insert("authorization".into(), auth);
let replay = (raw)("http://x/report".into(), headers).await.unwrap();
assert_eq!(replay.status, 401);
let body = replay.json.unwrap();
assert!(body["error"].as_str().unwrap().contains("replay"));
}