mirror of
https://github.com/block/buzz.git
synced 2026-11-14 13:21:02 +00:00
Replaces the four-helper auth resolution path with two focused functions and adds production async tests that count relay round-trips. **Before:** `resolve_auth` called `resolve_auth_from_profile` (warn-emitting probe into a throwaway sink) → `resolve_auth_deciding` (re-classified the same profile) → `handle_auth_failure` → `auth_failure_detail` (third classification). `Option<Option<&Value>>` encoded a sentinel for unreachable state; tests exercised only the pure sync helper, not the actual fetch count. **After:** - `extract_auth(profile, target, signer) -> Result<[String;4], AuthFailure>` — pure typed extractor; `AuthFailure` now covers `NoProfile` and `NoTagsArray` inline, no separate helper needed - `resolve_auth()` is now the linear state machine: self-check → fetch + extract → on failure: fetch again → route final `Err` to `CliError::Usage` (default) or one admin warning (`--admin`). No throwaway sinks, no duplicate classification, no sentinel type. - Five async tests drive the production resolver through a counted Axum test server on `POST /query` and assert on both return value and exact fetch count: first success (1), retry success (2), double failure / no `--admin` (2 + `Err`), double failure / `--admin` (2 + `Ok(None)` + one warning), self path (0). Two parser tests pin `--admin` on both `archive` and `unarchive`. - `--admin` short help text corrected to describe when the flag takes effect (after extraction fails, not unconditionally). 341 tests passing, clippy clean, fmt clean. --------- Signed-off-by: Will Pfleger <pfleger.will@gmail.com> Co-authored-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
1278 lines
47 KiB
Rust
1278 lines
47 KiB
Rust
use buzz_core::kind::KIND_IA_ARCHIVED_LIST;
|
|
use buzz_sdk::builders::{build_archive_identity_request, build_unarchive_identity_request};
|
|
use nostr::PublicKey;
|
|
use serde_json::json;
|
|
|
|
use crate::agent_management::{build_create, build_update, CreateAgentDraft, UpdateAgentDraft};
|
|
use crate::client::BuzzClient;
|
|
use crate::error::CliError;
|
|
use crate::validate::{read_or_stdin, validate_hex64};
|
|
use crate::{AgentsCmd, RespondToArg};
|
|
|
|
pub async fn dispatch(command: AgentsCmd, client: &BuzzClient) -> Result<(), CliError> {
|
|
match command {
|
|
AgentsCmd::DraftCreate {
|
|
channel,
|
|
display_name,
|
|
system_prompt,
|
|
} => {
|
|
let owner = require_owner(client)?;
|
|
let built = build_create(
|
|
client.keys(),
|
|
&owner,
|
|
CreateAgentDraft {
|
|
channel_id: channel,
|
|
display_name,
|
|
system_prompt: read_or_stdin(&system_prompt)?,
|
|
},
|
|
)?;
|
|
let response = client.publish_ephemeral_event(built.event).await?;
|
|
let mut output: serde_json::Value = serde_json::from_str(&response)
|
|
.map_err(|e| CliError::Other(format!("invalid relay response: {e}")))?;
|
|
if let Some(obj) = output.as_object_mut() {
|
|
obj.insert("request_id".into(), built.request_id.into());
|
|
obj.insert("action".into(), built.action.into());
|
|
obj.insert("saved".into(), false.into());
|
|
obj.insert(
|
|
"message".into(),
|
|
"Draft sent to Buzz Desktop for owner review. Nothing changes until the owner saves it."
|
|
.into(),
|
|
);
|
|
}
|
|
println!("{output}");
|
|
Ok(())
|
|
}
|
|
|
|
AgentsCmd::DraftUpdate {
|
|
channel,
|
|
agent_name,
|
|
display_name,
|
|
system_prompt,
|
|
runtime,
|
|
provider,
|
|
model,
|
|
respond_to,
|
|
} => {
|
|
let owner = require_owner(client)?;
|
|
let built = build_update(
|
|
client.keys(),
|
|
&owner,
|
|
UpdateAgentDraft {
|
|
channel_id: channel,
|
|
agent_name,
|
|
display_name,
|
|
system_prompt: system_prompt.map(|v| read_or_stdin(&v)).transpose()?,
|
|
runtime,
|
|
provider,
|
|
model,
|
|
respond_to: respond_to.map(RespondToArg::to_wire),
|
|
},
|
|
)?;
|
|
let response = client.publish_ephemeral_event(built.event).await?;
|
|
let mut output: serde_json::Value = serde_json::from_str(&response)
|
|
.map_err(|e| CliError::Other(format!("invalid relay response: {e}")))?;
|
|
if let Some(obj) = output.as_object_mut() {
|
|
obj.insert("request_id".into(), built.request_id.into());
|
|
obj.insert("action".into(), built.action.into());
|
|
obj.insert("saved".into(), false.into());
|
|
obj.insert(
|
|
"message".into(),
|
|
"Draft sent to Buzz Desktop for owner review. Nothing changes until the owner saves it."
|
|
.into(),
|
|
);
|
|
}
|
|
println!("{output}");
|
|
Ok(())
|
|
}
|
|
|
|
AgentsCmd::Archive {
|
|
target_pubkey,
|
|
reason,
|
|
replaced_by,
|
|
content,
|
|
admin,
|
|
} => {
|
|
validate_hex64(&target_pubkey)?;
|
|
let signer_hex = client.keys().public_key().to_hex();
|
|
let auth = resolve_auth(
|
|
client,
|
|
&target_pubkey,
|
|
&signer_hex,
|
|
admin,
|
|
&mut std::io::stderr(),
|
|
)
|
|
.await?;
|
|
let builder = build_archive_identity_request(
|
|
&target_pubkey,
|
|
&content,
|
|
reason.as_deref(),
|
|
replaced_by.as_deref(),
|
|
auth.as_ref(),
|
|
)
|
|
.map_err(|e| CliError::Usage(format!("invalid archive request: {e}")))?;
|
|
let event = client.sign_event_unchecked(builder)?;
|
|
let event_id = event.id.to_hex();
|
|
client.submit_event(event).await?;
|
|
println!(
|
|
"{}",
|
|
json!({
|
|
"ok": true,
|
|
"event_id": event_id,
|
|
"action": "archive",
|
|
"target": target_pubkey,
|
|
})
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
AgentsCmd::Unarchive {
|
|
target_pubkey,
|
|
reason,
|
|
content,
|
|
admin,
|
|
} => {
|
|
validate_hex64(&target_pubkey)?;
|
|
let signer_hex = client.keys().public_key().to_hex();
|
|
let auth = resolve_auth(
|
|
client,
|
|
&target_pubkey,
|
|
&signer_hex,
|
|
admin,
|
|
&mut std::io::stderr(),
|
|
)
|
|
.await?;
|
|
let builder = build_unarchive_identity_request(
|
|
&target_pubkey,
|
|
&content,
|
|
reason.as_deref(),
|
|
auth.as_ref(),
|
|
)
|
|
.map_err(|e| CliError::Usage(format!("invalid unarchive request: {e}")))?;
|
|
let event = client.sign_event_unchecked(builder)?;
|
|
let event_id = event.id.to_hex();
|
|
client.submit_event(event).await?;
|
|
println!(
|
|
"{}",
|
|
json!({
|
|
"ok": true,
|
|
"event_id": event_id,
|
|
"action": "unarchive",
|
|
"target": target_pubkey,
|
|
})
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
AgentsCmd::Archived => cmd_archived(client).await,
|
|
}
|
|
}
|
|
|
|
/// Require `BUZZ_AUTH_TAG` and parse the owner pubkey from it. Used only by
|
|
/// the `draft-create` and `draft-update` paths.
|
|
fn require_owner(client: &BuzzClient) -> Result<PublicKey, CliError> {
|
|
let hex = client
|
|
.auth_tag_owner_hex()
|
|
.ok_or_else(|| CliError::Auth("agent draft requests require BUZZ_AUTH_TAG".into()))?;
|
|
PublicKey::parse(&hex).map_err(|e| CliError::Auth(format!("invalid owner attestation: {e}")))
|
|
}
|
|
|
|
/// Typed reason why NIP-OA owner-auth could not be extracted.
|
|
///
|
|
/// Covers all distinguishable failure causes from profile fetch through tag
|
|
/// validation so the diagnostic in [`resolve_auth`] is always precise and
|
|
/// never duplicates classification logic.
|
|
#[derive(Debug, PartialEq)]
|
|
enum AuthFailure {
|
|
/// No kind:0 profile was found for the target; target pubkey included.
|
|
NoProfile(String),
|
|
/// kind:0 was found but has no `tags` array; target pubkey included.
|
|
NoTagsArray(String),
|
|
/// `tags` array has no `auth`-labelled entries.
|
|
NoAuthTag,
|
|
/// `tags` array has more than one `auth`-labelled entry; count included.
|
|
AmbiguousAuthTag(usize),
|
|
/// Sole `auth` tag has wrong element count; actual count included.
|
|
WrongArity(usize),
|
|
/// Sole `auth` tag contains a non-string element.
|
|
NonStringElement,
|
|
/// Sole `auth` tag owner field is not a valid 64-hex pubkey; value included.
|
|
InvalidOwnerHex(String),
|
|
/// Sole `auth` tag sig field is not a valid 128-hex signature.
|
|
InvalidSigHex,
|
|
/// Tag is structurally valid but names a different owner; actual owner included.
|
|
OwnerMismatch(String),
|
|
}
|
|
|
|
impl AuthFailure {
|
|
/// Human-readable description suitable for the `"warning"` JSON field.
|
|
fn message(&self) -> String {
|
|
match self {
|
|
AuthFailure::NoProfile(target) => {
|
|
format!("no kind:0 profile found for target {target}")
|
|
}
|
|
AuthFailure::NoTagsArray(target) => {
|
|
format!("target {target} kind:0 has no tags array")
|
|
}
|
|
AuthFailure::NoAuthTag => "target kind:0 has no \"auth\" tag".to_owned(),
|
|
AuthFailure::AmbiguousAuthTag(n) => format!(
|
|
"target kind:0 has {n} \"auth\" tags (expected exactly 1) — ambiguous ownership"
|
|
),
|
|
AuthFailure::WrongArity(n) => format!(
|
|
"sole \"auth\" tag has {n} element(s) (expected 4: label, owner, conditions, sig)"
|
|
),
|
|
AuthFailure::NonStringElement => {
|
|
"sole \"auth\" tag contains a non-string element".to_owned()
|
|
}
|
|
AuthFailure::InvalidOwnerHex(v) => {
|
|
format!("sole \"auth\" tag owner field is not a valid 64-hex pubkey: {v}")
|
|
}
|
|
AuthFailure::InvalidSigHex => {
|
|
"sole \"auth\" tag sig field is not a valid 128-hex signature".to_owned()
|
|
}
|
|
AuthFailure::OwnerMismatch(actual) => {
|
|
format!("sole \"auth\" tag names owner {actual} which does not match your key")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Single classifier: either extract the auth tag or return the typed reason
|
|
/// for failure. [`extract_owner_auth_tag`] is a thin `.ok()` wrapper kept for
|
|
/// the existing tests that assert on `Option`.
|
|
fn classify_owner_auth_tag(
|
|
tags: &[serde_json::Value],
|
|
signer_hex: &str,
|
|
) -> Result<[String; 4], AuthFailure> {
|
|
let auth_tags: Vec<&serde_json::Value> = tags
|
|
.iter()
|
|
.filter(|tag| {
|
|
tag.as_array()
|
|
.and_then(|elems| elems.first())
|
|
.and_then(|v| v.as_str())
|
|
== Some("auth")
|
|
})
|
|
.collect();
|
|
match auth_tags.len() {
|
|
0 => return Err(AuthFailure::NoAuthTag),
|
|
n if n > 1 => return Err(AuthFailure::AmbiguousAuthTag(n)),
|
|
_ => {}
|
|
}
|
|
|
|
// Exactly one auth tag.
|
|
let elems = auth_tags[0]
|
|
.as_array()
|
|
.ok_or(AuthFailure::NonStringElement)?;
|
|
if elems.len() != 4 {
|
|
return Err(AuthFailure::WrongArity(elems.len()));
|
|
}
|
|
let label = elems[0].as_str().ok_or(AuthFailure::NonStringElement)?;
|
|
let owner = elems[1].as_str().ok_or(AuthFailure::NonStringElement)?;
|
|
let conditions = elems[2].as_str().ok_or(AuthFailure::NonStringElement)?;
|
|
let sig = elems[3].as_str().ok_or(AuthFailure::NonStringElement)?;
|
|
if owner.len() != 64 || !owner.chars().all(|c| c.is_ascii_hexdigit()) {
|
|
return Err(AuthFailure::InvalidOwnerHex(owner.to_owned()));
|
|
}
|
|
if sig.len() != 128 || !sig.chars().all(|c| c.is_ascii_hexdigit()) {
|
|
return Err(AuthFailure::InvalidSigHex);
|
|
}
|
|
if !owner.eq_ignore_ascii_case(signer_hex) {
|
|
return Err(AuthFailure::OwnerMismatch(owner.to_owned()));
|
|
}
|
|
Ok([
|
|
label.to_owned(),
|
|
owner.to_owned(),
|
|
conditions.to_owned(),
|
|
sig.to_owned(),
|
|
])
|
|
}
|
|
|
|
/// Pure typed extractor: given a fetched kind:0 profile (or `None` when no
|
|
/// event was found), return the auth tag or the typed failure reason.
|
|
///
|
|
/// Separated from [`resolve_auth`] so pure-unit tests can exercise all
|
|
/// failure cases without a live `BuzzClient` or async runtime.
|
|
fn extract_auth(
|
|
profile: Option<&serde_json::Value>,
|
|
target_hex: &str,
|
|
signer_hex: &str,
|
|
) -> Result<[String; 4], AuthFailure> {
|
|
let event = profile.ok_or_else(|| AuthFailure::NoProfile(target_hex.to_owned()))?;
|
|
let tags = event
|
|
.get("tags")
|
|
.and_then(|v| v.as_array())
|
|
.ok_or_else(|| AuthFailure::NoTagsArray(target_hex.to_owned()))?;
|
|
classify_owner_auth_tag(tags, signer_hex)
|
|
}
|
|
|
|
/// Resolve the optional NIP-OA `auth` tag for archive/unarchive requests,
|
|
/// with one automatic retry on extraction failure.
|
|
///
|
|
/// Resolution logic (linear state machine):
|
|
/// - `target == signer`: self path — no auth needed → `Ok(None)`, silent, zero fetches.
|
|
/// - Otherwise: fetch target's kind:0 and attempt extraction.
|
|
/// - Success (attempt 1) → `Ok(Some(tag))`, one fetch.
|
|
/// - Failure (attempt 1) → fetch again once (transient republish is the
|
|
/// dominant cause), then attempt extraction again.
|
|
/// - Success (attempt 2) → `Ok(Some(tag))`, two fetches.
|
|
/// - Failure (attempt 2), `allow_bare == false` → `Err(CliError::Usage)`
|
|
/// with an actionable message naming the reason. Request is NOT sent.
|
|
/// - Failure (attempt 2), `allow_bare == true` → emit one
|
|
/// `{"warning":"..."}` line to `warn_sink`, return `Ok(None)` (bare
|
|
/// send) for relay-admin callers.
|
|
/// - Network/parse failures surface as `Err` regardless of `allow_bare`.
|
|
async fn resolve_auth(
|
|
client: &BuzzClient,
|
|
target_hex: &str,
|
|
signer_hex: &str,
|
|
allow_bare: bool,
|
|
warn_sink: &mut dyn std::io::Write,
|
|
) -> Result<Option<[String; 4]>, CliError> {
|
|
if target_hex.eq_ignore_ascii_case(signer_hex) {
|
|
return Ok(None);
|
|
}
|
|
|
|
// Attempt 1.
|
|
let profile = fetch_kind0(client, target_hex).await?;
|
|
if let Ok(tag) = extract_auth(profile.as_ref(), target_hex, signer_hex) {
|
|
return Ok(Some(tag));
|
|
}
|
|
|
|
// Attempt 2 — one retry for transient republish churn.
|
|
let profile = fetch_kind0(client, target_hex).await?;
|
|
match extract_auth(profile.as_ref(), target_hex, signer_hex) {
|
|
Ok(tag) => Ok(Some(tag)),
|
|
Err(failure) => {
|
|
let detail = failure.message();
|
|
if allow_bare {
|
|
let msg = format!(
|
|
"{detail}; proceeding without owner attestation (--admin) — \
|
|
this succeeds only if your key is a relay admin"
|
|
);
|
|
let _ = writeln!(warn_sink, "{}", serde_json::json!({"warning": msg}));
|
|
Ok(None)
|
|
} else {
|
|
Err(CliError::Usage(format!(
|
|
"{detail}; refusing to send a bare request that the relay will reject — \
|
|
re-run once the target's profile has finished publishing, or pass --admin \
|
|
if your key is a relay admin"
|
|
)))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Fetch the most-recent kind:0 for `target_hex` from the relay.
|
|
/// Returns `None` when no event was found, `Err` on network/parse failure.
|
|
async fn fetch_kind0(
|
|
client: &BuzzClient,
|
|
target_hex: &str,
|
|
) -> Result<Option<serde_json::Value>, CliError> {
|
|
let filter = json!({"kinds": [0], "authors": [target_hex], "limit": 1});
|
|
let raw = client
|
|
.query(&filter)
|
|
.await
|
|
.map_err(|e| CliError::Other(format!("failed to fetch target kind:0: {e}")))?;
|
|
let events: Vec<serde_json::Value> = serde_json::from_str(&raw)
|
|
.map_err(|e| CliError::Other(format!("invalid kind:0 query response: {e}")))?;
|
|
Ok(events.into_iter().next())
|
|
}
|
|
|
|
/// Pure extraction helper: require exactly one kind:0 tag whose first
|
|
/// element is `"auth"` (a set-level rule — a valid tag alongside a second
|
|
/// malformed or duplicate `auth`-labeled tag is bare, not the valid one),
|
|
/// then structurally validate that sole tag as
|
|
/// `["auth", owner, conditions, sig]` matching `signer_hex`.
|
|
///
|
|
/// Thin wrapper around [`classify_owner_auth_tag`] that collapses the typed
|
|
/// failure reason to `None`. Malformed tags → `None`; valid tag → `Some`.
|
|
#[cfg(test)]
|
|
fn extract_owner_auth_tag(tags: &[serde_json::Value], signer_hex: &str) -> Option<[String; 4]> {
|
|
classify_owner_auth_tag(tags, signer_hex).ok()
|
|
}
|
|
|
|
/// Validate the NIP-11 relay-info `self` field is a 64-hex pubkey and
|
|
/// normalize it to lowercase, so the archived-identities query filter and
|
|
/// the author comparison in [`verify_archived_event`] agree regardless of
|
|
/// the case the relay published `self` in.
|
|
fn normalize_relay_self_hex(self_hex: &str) -> Result<String, CliError> {
|
|
if self_hex.len() != 64 || !self_hex.chars().all(|c| c.is_ascii_hexdigit()) {
|
|
return Err(CliError::Other(format!(
|
|
"relay 'self' field is not a valid 64-hex pubkey: {self_hex}"
|
|
)));
|
|
}
|
|
Ok(self_hex.to_ascii_lowercase())
|
|
}
|
|
|
|
/// Fetch and verify the relay's NIP-IA archived-identities snapshot (kind
|
|
/// 13535). Shared by `cmd_archived` (trust failures are fatal — verifying
|
|
/// repair state is the command's whole purpose) and the `--template`
|
|
/// resolver's archive filter, which fails open on a trust failure instead
|
|
/// (see `channels::resolve_roster_with_archive_filter`'s doc comment for
|
|
/// why).
|
|
///
|
|
/// Three trust states:
|
|
/// - State 1: no events — `Ok(vec![])`
|
|
/// - State 2: event passes all checks — `Ok(<pubkeys>)`
|
|
/// - State 3: trust failure — `Err`, naming the specific failure
|
|
pub(crate) async fn fetch_archived_snapshot(client: &BuzzClient) -> Result<Vec<String>, CliError> {
|
|
// Fetch NIP-11 info to get the relay's self pubkey.
|
|
let nip11_raw = client
|
|
.get_public("/")
|
|
.await
|
|
.map_err(|e| CliError::Other(format!("failed to fetch relay info document: {e}")))?;
|
|
let nip11: serde_json::Value = serde_json::from_str(&nip11_raw)
|
|
.map_err(|e| CliError::Other(format!("relay info document is not valid JSON: {e}")))?;
|
|
let self_hex = nip11
|
|
.get("self")
|
|
.and_then(|v| v.as_str())
|
|
.ok_or_else(|| CliError::Other("relay info document missing 'self' field".into()))?;
|
|
let self_hex = normalize_relay_self_hex(self_hex)?;
|
|
|
|
// Query for the archived-identities list.
|
|
let filter = json!({"kinds": [KIND_IA_ARCHIVED_LIST], "authors": [self_hex], "limit": 1});
|
|
let raw = client
|
|
.query(&filter)
|
|
.await
|
|
.map_err(|e| CliError::Other(format!("failed to query archived-identities list: {e}")))?;
|
|
let events: Vec<serde_json::Value> = serde_json::from_str(&raw)
|
|
.map_err(|e| CliError::Other(format!("invalid query response: {e}")))?;
|
|
|
|
// State 1: no events.
|
|
if events.is_empty() {
|
|
return Ok(Vec::new());
|
|
}
|
|
|
|
// State 2 or 3: verify then collect.
|
|
let raw_event = events.into_iter().next().unwrap();
|
|
let event: nostr::Event = serde_json::from_value(raw_event)
|
|
.map_err(|e| CliError::Other(format!("archived-identities event is malformed: {e}")))?;
|
|
let archived = verify_archived_event(&event, &self_hex)?;
|
|
|
|
Ok(archived.into_iter().map(str::to_string).collect())
|
|
}
|
|
|
|
/// `buzz agents archived`: read path over [`fetch_archived_snapshot`] for
|
|
/// direct invocation — a trust failure (state 3) is fatal here so a
|
|
/// verification command can never look like success.
|
|
async fn cmd_archived(client: &BuzzClient) -> Result<(), CliError> {
|
|
let archived = fetch_archived_snapshot(client).await?;
|
|
println!("{}", json!({"archived": archived}));
|
|
Ok(())
|
|
}
|
|
|
|
/// Pure verification of a kind:13535 archived-identities event.
|
|
///
|
|
/// Returns the list of valid hex64 pubkeys from `p` tags on success, or a
|
|
/// named trust-failure error (State 3).
|
|
fn verify_archived_event<'a>(
|
|
event: &'a nostr::Event,
|
|
relay_self_hex: &str,
|
|
) -> Result<Vec<&'a str>, CliError> {
|
|
if event.kind != nostr::Kind::Custom(KIND_IA_ARCHIVED_LIST as u16) {
|
|
return Err(CliError::Other(format!(
|
|
"archived-identities event has wrong kind: {}",
|
|
event.kind.as_u16()
|
|
)));
|
|
}
|
|
|
|
if event.pubkey.to_hex() != relay_self_hex {
|
|
return Err(CliError::Other(format!(
|
|
"archived-identities event author {} does not match relay self {}",
|
|
event.pubkey.to_hex(),
|
|
relay_self_hex
|
|
)));
|
|
}
|
|
|
|
let mut nip70_count = 0usize;
|
|
for t in event.tags.iter() {
|
|
let s = t.as_slice();
|
|
if s.first().map(String::as_str) != Some("-") {
|
|
continue;
|
|
}
|
|
if s.len() != 1 {
|
|
return Err(CliError::Other(
|
|
"archived-identities event has a malformed NIP-70 '-' tag (expected arity 1)"
|
|
.into(),
|
|
));
|
|
}
|
|
nip70_count += 1;
|
|
}
|
|
if nip70_count != 1 {
|
|
return Err(CliError::Other(format!(
|
|
"archived-identities event must have exactly one NIP-70 '-' tag, found {nip70_count}"
|
|
)));
|
|
}
|
|
|
|
event.verify().map_err(|e| {
|
|
CliError::Other(format!(
|
|
"archived-identities event failed cryptographic verification: {e}"
|
|
))
|
|
})?;
|
|
|
|
let archived: Vec<&str> = event
|
|
.tags
|
|
.iter()
|
|
.filter_map(|t| {
|
|
let s = t.as_slice();
|
|
if s.first().map(String::as_str) == Some("p") {
|
|
let pk = s.get(1).map(String::as_str)?;
|
|
if pk.len() == 64 && pk.chars().all(|c| c.is_ascii_hexdigit()) {
|
|
return Some(pk);
|
|
}
|
|
}
|
|
None
|
|
})
|
|
.collect();
|
|
|
|
Ok(archived)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use buzz_core::kind::KIND_IA_ARCHIVED_LIST;
|
|
use nostr::{EventBuilder, Keys, Kind, Tag};
|
|
use serde_json::json;
|
|
|
|
fn hex64(c: char) -> String {
|
|
std::iter::repeat_n(c, 64).collect()
|
|
}
|
|
|
|
fn hex128(c: char) -> String {
|
|
std::iter::repeat_n(c, 128).collect()
|
|
}
|
|
|
|
// --- (b) auth-selection matrix: extract_owner_auth_tag ---
|
|
|
|
#[test]
|
|
fn auth_selection_owner_match_returns_tag() {
|
|
let signer = hex64('a');
|
|
let sig = hex128('b');
|
|
let tags = vec![json!(["auth", signer, "conditions", sig])];
|
|
let result = extract_owner_auth_tag(&tags, &signer);
|
|
assert!(result.is_some());
|
|
let tag = result.unwrap();
|
|
assert_eq!(tag[0], "auth");
|
|
assert_eq!(tag[1], signer);
|
|
assert_eq!(tag[2], "conditions");
|
|
assert_eq!(tag[3], sig);
|
|
}
|
|
|
|
#[test]
|
|
fn auth_selection_non_owner_returns_none() {
|
|
let signer = hex64('a');
|
|
let other_owner = hex64('b');
|
|
let tags = vec![json!(["auth", other_owner, "", hex128('c')])];
|
|
assert!(extract_owner_auth_tag(&tags, &signer).is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn auth_selection_malformed_three_elements_returns_none() {
|
|
let signer = hex64('a');
|
|
let tags = vec![json!(["auth", signer, "conditions"])];
|
|
assert!(extract_owner_auth_tag(&tags, &signer).is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn auth_selection_malformed_five_elements_returns_none() {
|
|
let signer = hex64('a');
|
|
let tags = vec![json!(["auth", signer, "conditions", hex128('b'), "extra"])];
|
|
assert!(extract_owner_auth_tag(&tags, &signer).is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn auth_selection_malformed_non_hex_owner_returns_none() {
|
|
let signer = "z".repeat(64);
|
|
let tags = vec![json!(["auth", signer, "", hex128('a')])];
|
|
assert!(extract_owner_auth_tag(&tags, &signer).is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn auth_selection_malformed_non_hex_sig_returns_none() {
|
|
let signer = hex64('a');
|
|
let bad_sig = "z".repeat(128);
|
|
let tags = vec![json!(["auth", signer, "", bad_sig])];
|
|
assert!(extract_owner_auth_tag(&tags, &signer).is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn auth_selection_malformed_short_sig_returns_none() {
|
|
let signer = hex64('a');
|
|
let short_sig = hex128('a')[..64].to_string();
|
|
let tags = vec![json!(["auth", signer, "", short_sig])];
|
|
assert!(extract_owner_auth_tag(&tags, &signer).is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn auth_selection_case_insensitive_owner_match() {
|
|
let signer_lower = hex64('a');
|
|
let signer_upper = signer_lower.to_uppercase();
|
|
let sig = hex128('b');
|
|
let tags = vec![json!(["auth", signer_upper, "cond", sig])];
|
|
let result = extract_owner_auth_tag(&tags, &signer_lower);
|
|
assert!(result.is_some());
|
|
}
|
|
|
|
#[test]
|
|
fn auth_selection_non_string_elements_returns_none() {
|
|
let signer = hex64('a');
|
|
let tags = vec![json!(["auth", signer, 42, hex128('b')])];
|
|
assert!(extract_owner_auth_tag(&tags, &signer).is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn auth_selection_non_array_tag_skipped() {
|
|
let signer = hex64('a');
|
|
let tags = vec![
|
|
json!("not an array"),
|
|
json!(["auth", signer, "", hex128('b')]),
|
|
];
|
|
let result = extract_owner_auth_tag(&tags, &signer);
|
|
assert!(result.is_some());
|
|
}
|
|
|
|
#[test]
|
|
fn auth_selection_no_tags_returns_none() {
|
|
assert!(extract_owner_auth_tag(&[], &hex64('a')).is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn auth_selection_wrong_label_returns_none() {
|
|
let signer = hex64('a');
|
|
let tags = vec![json!(["delegation", signer, "", hex128('b')])];
|
|
assert!(extract_owner_auth_tag(&tags, &signer).is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn auth_selection_valid_plus_duplicate_auth_tag_returns_none() {
|
|
// Set-level rule (F6): a structurally valid, owner-matching `auth`
|
|
// tag alongside a second `auth`-labeled tag (malformed or a
|
|
// duplicate) must not be selected — the whole kind:0 is bare.
|
|
let signer = hex64('a');
|
|
let sig = hex128('b');
|
|
let tags = vec![
|
|
json!(["auth", signer, "conditions", sig]),
|
|
json!(["auth", signer, "conditions", sig]),
|
|
];
|
|
assert!(extract_owner_auth_tag(&tags, &signer).is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn auth_selection_valid_plus_malformed_second_auth_tag_returns_none() {
|
|
let signer = hex64('a');
|
|
let sig = hex128('b');
|
|
let tags = vec![
|
|
json!(["auth", signer, "conditions", sig]),
|
|
json!(["auth", "not-hex", "conditions"]),
|
|
];
|
|
assert!(extract_owner_auth_tag(&tags, &signer).is_none());
|
|
}
|
|
|
|
// --- (c) auth-failure classifier: classify_owner_auth_tag ---
|
|
//
|
|
// Tests the typed failure taxonomy. Each case asserts the exact
|
|
// AuthFailure variant so a wrong classification causes a compile-time or
|
|
// assertion failure — not just a message-substring miss.
|
|
|
|
#[test]
|
|
fn classify_no_auth_tag_returns_no_auth_tag() {
|
|
// Case 3 (zero auth tags): tags array has entries but none labelled "auth".
|
|
let signer = hex64('a');
|
|
let tags = vec![json!(["p", hex64('b')]), json!(["e", hex64('c')])];
|
|
assert_eq!(
|
|
classify_owner_auth_tag(&tags, &signer),
|
|
Err(AuthFailure::NoAuthTag)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn classify_empty_tags_returns_no_auth_tag() {
|
|
assert_eq!(
|
|
classify_owner_auth_tag(&[], &hex64('a')),
|
|
Err(AuthFailure::NoAuthTag)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn classify_duplicate_auth_tags_returns_ambiguous() {
|
|
let signer = hex64('a');
|
|
let sig = hex128('b');
|
|
let tags = vec![
|
|
json!(["auth", signer, "conditions", sig]),
|
|
json!(["auth", signer, "conditions", sig]),
|
|
];
|
|
assert_eq!(
|
|
classify_owner_auth_tag(&tags, &signer),
|
|
Err(AuthFailure::AmbiguousAuthTag(2))
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn classify_wrong_arity_returns_wrong_arity() {
|
|
let signer = hex64('a');
|
|
let tags = vec![json!(["auth", signer, "conditions"])];
|
|
assert_eq!(
|
|
classify_owner_auth_tag(&tags, &signer),
|
|
Err(AuthFailure::WrongArity(3))
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn classify_non_string_element_returns_non_string() {
|
|
let signer = hex64('a');
|
|
let tags = vec![json!(["auth", signer, 42, hex128('b')])];
|
|
assert_eq!(
|
|
classify_owner_auth_tag(&tags, &signer),
|
|
Err(AuthFailure::NonStringElement)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn classify_invalid_owner_hex_returns_invalid_owner_hex() {
|
|
let bad_owner = "z".repeat(64);
|
|
let tags = vec![json!(["auth", bad_owner, "", hex128('a')])];
|
|
assert_eq!(
|
|
classify_owner_auth_tag(&tags, &bad_owner),
|
|
Err(AuthFailure::InvalidOwnerHex(bad_owner))
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn classify_invalid_sig_hex_returns_invalid_sig_hex() {
|
|
let signer = hex64('a');
|
|
let bad_sig = "z".repeat(128);
|
|
let tags = vec![json!(["auth", signer, "", bad_sig])];
|
|
assert_eq!(
|
|
classify_owner_auth_tag(&tags, &signer),
|
|
Err(AuthFailure::InvalidSigHex)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn classify_owner_mismatch_returns_owner_mismatch_with_actual_owner() {
|
|
// Case 4: structurally valid tag but owner ≠ signer. The failure must
|
|
// carry the actual owner so resolve_auth can print it in the warning.
|
|
let actual_owner = hex64('a');
|
|
let signer = hex64('b');
|
|
let sig = hex128('c');
|
|
let tags = vec![json!(["auth", actual_owner, "conditions", sig])];
|
|
assert_eq!(
|
|
classify_owner_auth_tag(&tags, &signer),
|
|
Err(AuthFailure::OwnerMismatch(actual_owner.clone()))
|
|
);
|
|
// Message must include the actual owner for actionability.
|
|
let msg = AuthFailure::OwnerMismatch(actual_owner.clone()).message();
|
|
assert!(
|
|
msg.contains(&actual_owner),
|
|
"OwnerMismatch message must include actual owner, got: {msg}"
|
|
);
|
|
}
|
|
|
|
// --- (c2) extract_auth: profile-level failure taxonomy ---
|
|
//
|
|
// Pure-unit tests: exercise `extract_auth` directly with pre-built
|
|
// profiles. No relay, no async runtime. These guard all failure paths
|
|
// that the async production resolver depends on.
|
|
|
|
#[test]
|
|
fn extract_auth_no_profile_returns_no_profile_failure() {
|
|
let target = hex64('t');
|
|
let signer = hex64('s');
|
|
assert_eq!(
|
|
extract_auth(None, &target, &signer),
|
|
Err(AuthFailure::NoProfile(target.clone()))
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn extract_auth_no_tags_array_returns_no_tags_array_failure() {
|
|
let target = hex64('t');
|
|
let signer = hex64('s');
|
|
let profile = json!({"kind": 0, "content": "{}"});
|
|
assert_eq!(
|
|
extract_auth(Some(&profile), &target, &signer),
|
|
Err(AuthFailure::NoTagsArray(target.clone()))
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn extract_auth_no_auth_tag_returns_no_auth_tag_failure() {
|
|
let target = hex64('t');
|
|
let signer = hex64('s');
|
|
let profile = json!({"tags": [["p", hex64('b')]]});
|
|
assert_eq!(
|
|
extract_auth(Some(&profile), &target, &signer),
|
|
Err(AuthFailure::NoAuthTag)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn extract_auth_valid_tag_returns_ok() {
|
|
let signer = hex64('a');
|
|
let sig = hex128('b');
|
|
let profile = json!({"tags": [["auth", signer, "conditions", sig]]});
|
|
let result = extract_auth(Some(&profile), &hex64('t'), &signer);
|
|
assert!(result.is_ok(), "must succeed with a valid tag");
|
|
let tag = result.unwrap();
|
|
assert_eq!(tag[0], "auth");
|
|
assert_eq!(tag[1], signer);
|
|
}
|
|
|
|
#[test]
|
|
fn extract_auth_owner_mismatch_returns_owner_mismatch_failure() {
|
|
let actual_owner = hex64('a');
|
|
let signer = hex64('b');
|
|
let sig = hex128('c');
|
|
let profile = json!({"tags": [["auth", actual_owner, "conditions", sig]]});
|
|
assert_eq!(
|
|
extract_auth(Some(&profile), &hex64('t'), &signer),
|
|
Err(AuthFailure::OwnerMismatch(actual_owner.clone()))
|
|
);
|
|
// Message must include the actual owner for actionability.
|
|
let msg = AuthFailure::OwnerMismatch(actual_owner.clone()).message();
|
|
assert!(
|
|
msg.contains(&actual_owner),
|
|
"OwnerMismatch message must include actual owner, got: {msg}"
|
|
);
|
|
}
|
|
|
|
// --- (c3) resolve_auth: production async resolver via counted test server ---
|
|
//
|
|
// Each test spins up a local Axum server that handles POST /query, counts
|
|
// calls, and returns a canned kind:0 (or empty array) based on the
|
|
// attempt number. The test drives the production `resolve_auth` function
|
|
// and asserts on both return value and exact fetch count.
|
|
|
|
use std::net::SocketAddr;
|
|
use std::sync::atomic::{AtomicU32, Ordering};
|
|
use std::sync::Arc;
|
|
|
|
use axum::body::Body;
|
|
use axum::extract::State;
|
|
use axum::http::{HeaderMap, Response, StatusCode};
|
|
use axum::Router;
|
|
use tokio::net::TcpListener;
|
|
|
|
/// Spin up an Axum server handling POST /query.
|
|
/// `f(n)` is called with the 1-based attempt number and returns the JSON body.
|
|
async fn query_server<F>(f: F) -> (String, Arc<AtomicU32>)
|
|
where
|
|
F: Fn(u32) -> String + Send + Sync + 'static,
|
|
{
|
|
let counter = Arc::new(AtomicU32::new(0));
|
|
let handler: Arc<dyn Fn(u32) -> String + Send + Sync> = Arc::new(f);
|
|
let state = (handler, counter.clone());
|
|
|
|
type S = (Arc<dyn Fn(u32) -> String + Send + Sync>, Arc<AtomicU32>);
|
|
let app = Router::new()
|
|
.route(
|
|
"/query",
|
|
axum::routing::post(
|
|
|State((handler, ctr)): State<S>, _headers: HeaderMap, _body: Body| async move {
|
|
let n = ctr.fetch_add(1, Ordering::SeqCst) + 1;
|
|
let body = handler(n);
|
|
Response::builder()
|
|
.status(StatusCode::OK)
|
|
.header("content-type", "application/json")
|
|
.body(Body::from(body))
|
|
.unwrap()
|
|
},
|
|
),
|
|
)
|
|
.with_state(state);
|
|
|
|
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
|
let addr: SocketAddr = listener.local_addr().unwrap();
|
|
tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
|
|
(format!("http://{addr}"), counter)
|
|
}
|
|
|
|
fn test_client(base_url: &str) -> crate::client::BuzzClient {
|
|
let keys = nostr::Keys::generate();
|
|
crate::client::BuzzClient::new(base_url.to_string(), keys, None, None).unwrap()
|
|
}
|
|
|
|
fn kind0_response(signer_hex: &str) -> String {
|
|
let sig = hex128('b');
|
|
serde_json::json!([{
|
|
"kind": 0,
|
|
"tags": [["auth", signer_hex, "conditions", sig]],
|
|
"content": "{}"
|
|
}])
|
|
.to_string()
|
|
}
|
|
|
|
fn empty_response() -> String {
|
|
"[]".to_string()
|
|
}
|
|
|
|
fn no_auth_response() -> String {
|
|
serde_json::json!([{"kind": 0, "tags": [["p", "xx"]], "content": "{}"}]).to_string()
|
|
}
|
|
|
|
/// First attempt succeeds — exactly 1 fetch, Ok(Some(tag)).
|
|
#[tokio::test]
|
|
async fn resolve_auth_first_success_one_fetch() {
|
|
let signer = hex64('a');
|
|
let signer_clone = signer.clone();
|
|
let (url, counter) = query_server(move |_n| kind0_response(&signer_clone)).await;
|
|
let client = test_client(&url);
|
|
let target = hex64('b'); // target ≠ signer
|
|
let mut sink: Vec<u8> = Vec::new();
|
|
|
|
let result = resolve_auth(&client, &target, &signer, false, &mut sink).await;
|
|
|
|
assert!(result.is_ok(), "first success must return Ok: {result:?}");
|
|
assert!(result.unwrap().is_some(), "must return the extracted tag");
|
|
assert_eq!(
|
|
counter.load(Ordering::SeqCst),
|
|
1,
|
|
"first-success path must issue exactly 1 query"
|
|
);
|
|
assert!(sink.is_empty(), "no warning on success");
|
|
}
|
|
|
|
/// First fails (no auth tag), retry succeeds — exactly 2 fetches, Ok(Some(tag)).
|
|
#[tokio::test]
|
|
async fn resolve_auth_retry_success_two_fetches() {
|
|
let signer = hex64('a');
|
|
let signer_clone = signer.clone();
|
|
let (url, counter) = query_server(move |n| {
|
|
if n == 1 {
|
|
no_auth_response()
|
|
} else {
|
|
kind0_response(&signer_clone)
|
|
}
|
|
})
|
|
.await;
|
|
let client = test_client(&url);
|
|
let target = hex64('b');
|
|
let mut sink: Vec<u8> = Vec::new();
|
|
|
|
let result = resolve_auth(&client, &target, &signer, false, &mut sink).await;
|
|
|
|
assert!(result.is_ok(), "retry success must return Ok: {result:?}");
|
|
assert!(result.unwrap().is_some(), "must return the extracted tag");
|
|
assert_eq!(
|
|
counter.load(Ordering::SeqCst),
|
|
2,
|
|
"retry-success path must issue exactly 2 queries"
|
|
);
|
|
assert!(sink.is_empty(), "no warning on success");
|
|
}
|
|
|
|
/// Both attempts fail, allow_bare == false — exactly 2 fetches, Err (fail closed).
|
|
#[tokio::test]
|
|
async fn resolve_auth_double_failure_no_admin_fail_closed() {
|
|
let (url, counter) = query_server(|_n| no_auth_response()).await;
|
|
let client = test_client(&url);
|
|
let signer = hex64('s');
|
|
let target = hex64('t');
|
|
let mut sink: Vec<u8> = Vec::new();
|
|
|
|
let result = resolve_auth(&client, &target, &signer, false, &mut sink).await;
|
|
|
|
assert!(result.is_err(), "double failure must fail closed");
|
|
let err = result.unwrap_err().to_string();
|
|
assert!(
|
|
err.contains("refusing to send"),
|
|
"error must name the refusal: {err}"
|
|
);
|
|
assert!(
|
|
err.contains("--admin"),
|
|
"error must mention --admin escape: {err}"
|
|
);
|
|
assert_eq!(
|
|
counter.load(Ordering::SeqCst),
|
|
2,
|
|
"double-failure path must issue exactly 2 queries"
|
|
);
|
|
assert!(sink.is_empty(), "no warning on fail-closed path");
|
|
}
|
|
|
|
/// Both attempts fail, allow_bare == true (--admin) — exactly 2 fetches,
|
|
/// Ok(None) + exactly one warning line.
|
|
#[tokio::test]
|
|
async fn resolve_auth_double_failure_admin_allows_bare_with_warning() {
|
|
let (url, counter) = query_server(|_n| no_auth_response()).await;
|
|
let client = test_client(&url);
|
|
let signer = hex64('s');
|
|
let target = hex64('t');
|
|
let mut sink: Vec<u8> = Vec::new();
|
|
|
|
let result = resolve_auth(&client, &target, &signer, true, &mut sink).await;
|
|
|
|
assert!(result.is_ok(), "--admin must allow bare send: {result:?}");
|
|
assert!(result.unwrap().is_none(), "bare path returns None");
|
|
assert_eq!(
|
|
counter.load(Ordering::SeqCst),
|
|
2,
|
|
"--admin double-failure path must issue exactly 2 queries"
|
|
);
|
|
// Exactly one warning line.
|
|
let text = std::str::from_utf8(&sink).expect("UTF-8");
|
|
let lines: Vec<&str> = text.lines().collect();
|
|
assert_eq!(
|
|
lines.len(),
|
|
1,
|
|
"expected exactly one warning, got: {text:?}"
|
|
);
|
|
let parsed: serde_json::Value = serde_json::from_str(lines[0]).expect("valid JSON warning");
|
|
let warning = parsed["warning"].as_str().expect("string warning field");
|
|
assert!(
|
|
warning.contains("proceeding without owner attestation"),
|
|
"warning must name the bare-send: {warning}"
|
|
);
|
|
}
|
|
|
|
/// Self path (target == signer, case-insensitive) — zero fetches, Ok(None).
|
|
#[tokio::test]
|
|
async fn resolve_auth_self_path_zero_fetches() {
|
|
// Server counts every /query call; we assert 0.
|
|
let (url, counter) = query_server(|_n| empty_response()).await;
|
|
let client = test_client(&url);
|
|
let signer = hex64('a');
|
|
let target_upper = signer.to_uppercase(); // case-insensitive self
|
|
let mut sink: Vec<u8> = Vec::new();
|
|
|
|
let result = resolve_auth(&client, &target_upper, &signer, false, &mut sink).await;
|
|
|
|
assert!(result.is_ok(), "self path must return Ok: {result:?}");
|
|
assert!(result.unwrap().is_none(), "self path returns None");
|
|
assert_eq!(
|
|
counter.load(Ordering::SeqCst),
|
|
0,
|
|
"self path must issue zero queries"
|
|
);
|
|
assert!(sink.is_empty(), "no warning on self path");
|
|
}
|
|
|
|
// --- (c4) --admin flag parser: both archive and unarchive ---
|
|
//
|
|
// These tests confirm the flag is declared and parsed on both subcommands
|
|
// so it can't silently disappear from one of them.
|
|
|
|
#[test]
|
|
fn archive_admin_flag_is_parsed() {
|
|
use crate::Cli;
|
|
use clap::Parser;
|
|
let cli = Cli::try_parse_from(["buzz", "agents", "archive", &hex64('a'), "--admin"])
|
|
.expect("--admin must be accepted by agents archive");
|
|
match cli.command {
|
|
crate::Cmd::Agents(crate::AgentsCmd::Archive { admin, .. }) => {
|
|
assert!(admin, "--admin must be true when flag is present");
|
|
}
|
|
_ => panic!("unexpected command variant"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn unarchive_admin_flag_is_parsed() {
|
|
use crate::Cli;
|
|
use clap::Parser;
|
|
let cli = Cli::try_parse_from(["buzz", "agents", "unarchive", &hex64('a'), "--admin"])
|
|
.expect("--admin must be accepted by agents unarchive");
|
|
match cli.command {
|
|
crate::Cmd::Agents(crate::AgentsCmd::Unarchive { admin, .. }) => {
|
|
assert!(admin, "--admin must be true when flag is present");
|
|
}
|
|
_ => panic!("unexpected command variant"),
|
|
}
|
|
}
|
|
|
|
// --- (d) NIP-11 self normalization: normalize_relay_self_hex ---
|
|
|
|
#[test]
|
|
fn normalize_self_lowercases_uppercase_hex() {
|
|
let upper = hex64('A');
|
|
let result = normalize_relay_self_hex(&upper).expect("should pass");
|
|
assert_eq!(result, hex64('a'));
|
|
}
|
|
|
|
#[test]
|
|
fn normalize_self_rejects_wrong_length() {
|
|
assert!(normalize_relay_self_hex(&hex64('a')[..63]).is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn normalize_self_rejects_non_hex() {
|
|
assert!(normalize_relay_self_hex(&"z".repeat(64)).is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn archived_uppercase_self_matches_lowercase_event_author() {
|
|
// F7: an uppercase NIP-11 `self` must still resolve to the same
|
|
// relay identity as the event's (always-lowercase) author hex once
|
|
// normalized — before the fix this was a case-sensitive mismatch.
|
|
let keys = Keys::generate();
|
|
let self_hex_lower = keys.public_key().to_hex();
|
|
let self_hex_upper = self_hex_lower.to_uppercase();
|
|
let normalized = normalize_relay_self_hex(&self_hex_upper).expect("valid hex");
|
|
let event = build_archived_event(&keys, KIND_IA_ARCHIVED_LIST as u16, &[], true);
|
|
let result = verify_archived_event(&event, &normalized).expect("should pass");
|
|
assert!(result.is_empty());
|
|
}
|
|
|
|
// --- (c) snapshot tri-state: verify_archived_event ---
|
|
|
|
fn build_archived_event(
|
|
keys: &Keys,
|
|
kind: u16,
|
|
p_tags: &[&str],
|
|
include_nip70: bool,
|
|
) -> nostr::Event {
|
|
let mut tags: Vec<Tag> = Vec::new();
|
|
if include_nip70 {
|
|
tags.push(Tag::parse(["-"]).unwrap());
|
|
}
|
|
for pk in p_tags {
|
|
tags.push(Tag::parse(["p", pk]).unwrap());
|
|
}
|
|
EventBuilder::new(Kind::Custom(kind), "")
|
|
.tags(tags)
|
|
.sign_with_keys(keys)
|
|
.expect("sign")
|
|
}
|
|
|
|
#[test]
|
|
fn archived_state2_valid_event_returns_pubkeys() {
|
|
let keys = Keys::generate();
|
|
let self_hex = keys.public_key().to_hex();
|
|
let pk1 = hex64('a');
|
|
let pk2 = hex64('b');
|
|
let event = build_archived_event(&keys, KIND_IA_ARCHIVED_LIST as u16, &[&pk1, &pk2], true);
|
|
let result = verify_archived_event(&event, &self_hex).expect("should pass");
|
|
assert_eq!(result, vec![pk1.as_str(), pk2.as_str()]);
|
|
}
|
|
|
|
#[test]
|
|
fn archived_state2_empty_p_tags_returns_empty() {
|
|
let keys = Keys::generate();
|
|
let self_hex = keys.public_key().to_hex();
|
|
let event = build_archived_event(&keys, KIND_IA_ARCHIVED_LIST as u16, &[], true);
|
|
let result = verify_archived_event(&event, &self_hex).expect("should pass");
|
|
assert!(result.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn archived_state3_wrong_kind_errors() {
|
|
let keys = Keys::generate();
|
|
let self_hex = keys.public_key().to_hex();
|
|
let event = build_archived_event(&keys, 9999, &[], true);
|
|
let err = verify_archived_event(&event, &self_hex).unwrap_err();
|
|
assert!(
|
|
err.to_string().contains("wrong kind"),
|
|
"error should name wrong kind: {err}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn archived_state3_wrong_author_errors() {
|
|
let event_keys = Keys::generate();
|
|
let other_self = hex64('f');
|
|
let event = build_archived_event(&event_keys, KIND_IA_ARCHIVED_LIST as u16, &[], true);
|
|
let err = verify_archived_event(&event, &other_self).unwrap_err();
|
|
assert!(
|
|
err.to_string().contains("does not match relay self"),
|
|
"error should name author mismatch: {err}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn archived_state3_no_nip70_tag_errors() {
|
|
let keys = Keys::generate();
|
|
let self_hex = keys.public_key().to_hex();
|
|
let event = build_archived_event(&keys, KIND_IA_ARCHIVED_LIST as u16, &[], false);
|
|
let err = verify_archived_event(&event, &self_hex).unwrap_err();
|
|
assert!(
|
|
err.to_string().contains("NIP-70"),
|
|
"error should name missing NIP-70 tag: {err}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn archived_state3_duplicate_nip70_tags_errors() {
|
|
let keys = Keys::generate();
|
|
let self_hex = keys.public_key().to_hex();
|
|
let event = EventBuilder::new(Kind::Custom(KIND_IA_ARCHIVED_LIST as u16), "")
|
|
.tags([Tag::parse(["-"]).unwrap(), Tag::parse(["-"]).unwrap()])
|
|
.sign_with_keys(&keys)
|
|
.expect("sign");
|
|
let err = verify_archived_event(&event, &self_hex).unwrap_err();
|
|
assert!(
|
|
err.to_string().contains("found 2"),
|
|
"error should report 2 NIP-70 tags: {err}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn archived_state3_lone_malformed_nip70_tag_errors() {
|
|
let keys = Keys::generate();
|
|
let self_hex = keys.public_key().to_hex();
|
|
let event = EventBuilder::new(Kind::Custom(KIND_IA_ARCHIVED_LIST as u16), "")
|
|
.tags([Tag::parse(["-", "extra"]).unwrap()])
|
|
.sign_with_keys(&keys)
|
|
.expect("sign");
|
|
let err = verify_archived_event(&event, &self_hex).unwrap_err();
|
|
assert!(
|
|
err.to_string().contains("malformed NIP-70"),
|
|
"error should name the malformed NIP-70 tag: {err}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn archived_state3_exact_marker_plus_malformed_marker_errors() {
|
|
// F5 (IMPORTANT, discriminating): a valid `["-"]` alongside a
|
|
// malformed `["-", "extra"]` must still poison the snapshot — the
|
|
// old count-of-exact-shape-only check let this bypass through with
|
|
// nip70_count == 1.
|
|
let keys = Keys::generate();
|
|
let self_hex = keys.public_key().to_hex();
|
|
let event = EventBuilder::new(Kind::Custom(KIND_IA_ARCHIVED_LIST as u16), "")
|
|
.tags([
|
|
Tag::parse(["-"]).unwrap(),
|
|
Tag::parse(["-", "extra"]).unwrap(),
|
|
])
|
|
.sign_with_keys(&keys)
|
|
.expect("sign");
|
|
let err = verify_archived_event(&event, &self_hex).unwrap_err();
|
|
assert!(
|
|
err.to_string().contains("malformed NIP-70"),
|
|
"error should name the malformed NIP-70 tag: {err}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn archived_non_hex_p_tag_dropped() {
|
|
let keys = Keys::generate();
|
|
let self_hex = keys.public_key().to_hex();
|
|
let valid_pk = hex64('a');
|
|
let event = EventBuilder::new(Kind::Custom(KIND_IA_ARCHIVED_LIST as u16), "")
|
|
.tags([
|
|
Tag::parse(["-"]).unwrap(),
|
|
Tag::parse(["p", &valid_pk]).unwrap(),
|
|
Tag::parse(["p", "not-hex-at-all"]).unwrap(),
|
|
Tag::parse(["p", &"z".repeat(64)]).unwrap(),
|
|
])
|
|
.sign_with_keys(&keys)
|
|
.expect("sign");
|
|
let result = verify_archived_event(&event, &self_hex).expect("should pass");
|
|
assert_eq!(result, vec![valid_pk.as_str()]);
|
|
}
|
|
|
|
#[test]
|
|
fn archived_short_p_tag_dropped() {
|
|
let keys = Keys::generate();
|
|
let self_hex = keys.public_key().to_hex();
|
|
let event = EventBuilder::new(Kind::Custom(KIND_IA_ARCHIVED_LIST as u16), "")
|
|
.tags([
|
|
Tag::parse(["-"]).unwrap(),
|
|
Tag::parse(["p", &hex64('a')[..32]]).unwrap(),
|
|
])
|
|
.sign_with_keys(&keys)
|
|
.expect("sign");
|
|
let result = verify_archived_event(&event, &self_hex).expect("should pass");
|
|
assert!(result.is_empty());
|
|
}
|
|
}
|