mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
fix(desktop): instance-level agents nav — test seam, postgres cast, menu gate
Addresses three concrete CI failures from pass-1 + committed tip:
Relay acceptance: both 9035 and 9036 panicked with
'postgres relay_members query: error serializing parameter 0'
Root cause: community_id is UUID NOT NULL; tokio_postgres cannot bind
&str as $1::uuid — the type mismatch is rejected at the client bind layer
before any server-side cast can run. Fix: cast the column side
community_id::text = $1 (text-to-text comparison, no bind type needed)
Both assert_actor_not_relay_member and assert_postgres_consent_path_owner
are updated.
Test observation seam: replace the SubmitObserver duplicate-code pattern
with the production test_hooks module. scoped_archive_operation now calls
test_hooks::notify_submit(&signed) (under #[cfg(test)]) just before the
relay POST, so the observer sees the PRODUCTION-signed event. The old
seam rebuilt auth-tag computation in the test file, which meant gutting
the fresh-mint wouldn't fail the seam — the new seam fails correctly.
Also adds submit_signed_event_at_with_keys usage for hook compatibility.
Dropdown Instances gate: PersonaActionsMenu only receives onViewInstances
when relayInstanceCount > 0. The unconditional prop broke two existing
integration tests expecting the original menu items ("share and keep
export separate", "team-managed personas do not expose editable actions")
because both tests don't seed ownedAgentInventory so count is 0.
Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
This commit is contained in:
@@ -16,7 +16,7 @@ use crate::{
|
||||
events,
|
||||
relay::{
|
||||
classify_request_error, query_relay, query_relay_at_with_keys, relay_http_base_url,
|
||||
relay_ws_url_with_override, submit_event_at_with_keys, SubmitEventResponse,
|
||||
relay_ws_url_with_override, submit_signed_event_at_with_keys, SubmitEventResponse,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -336,7 +336,64 @@ pub(crate) async fn scoped_archive_operation(
|
||||
}
|
||||
};
|
||||
|
||||
submit_event_at_with_keys(builder, state, &api_base_url, &scope.keys).await
|
||||
// Sign here so tests can observe the production event before submission.
|
||||
let signed = builder
|
||||
.sign_with_keys(&scope.keys)
|
||||
.map_err(|e| format!("failed to sign event: {e}"))?;
|
||||
|
||||
#[cfg(test)]
|
||||
test_hooks::notify_submit(&signed);
|
||||
|
||||
submit_signed_event_at_with_keys(&signed, state, &api_base_url, &scope.keys).await
|
||||
}
|
||||
|
||||
// ── Test-only submit observation hook ────────────────────────────────────────
|
||||
//
|
||||
// A narrow `#[cfg(test)]` hook that records every signed event just before
|
||||
// submission. Production code is unchanged — this is gated out of shipping
|
||||
// builds entirely.
|
||||
//
|
||||
// Call `test_hooks::install(observer)` from the test, then run the operation.
|
||||
// The observer closure receives the signed event. An attempt counter increments
|
||||
// on every call. Call `test_hooks::reset()` between tests.
|
||||
#[cfg(test)]
|
||||
pub(crate) mod test_hooks {
|
||||
use std::{cell::RefCell, sync::Arc};
|
||||
|
||||
thread_local! {
|
||||
static OBSERVER: RefCell<Option<Arc<dyn Fn(&nostr::Event) + Send + Sync>>> =
|
||||
const { RefCell::new(None) };
|
||||
static ATTEMPT_COUNT: RefCell<u32> = const { RefCell::new(0) };
|
||||
}
|
||||
|
||||
/// Install an observer for the current thread. Replaces any existing one.
|
||||
pub fn install(f: impl Fn(&nostr::Event) + Send + Sync + 'static) {
|
||||
OBSERVER.with(|o| {
|
||||
*o.borrow_mut() = Some(Arc::new(f));
|
||||
});
|
||||
ATTEMPT_COUNT.with(|c| *c.borrow_mut() = 0);
|
||||
}
|
||||
|
||||
/// Remove the observer and reset the attempt counter.
|
||||
pub fn reset() {
|
||||
OBSERVER.with(|o| *o.borrow_mut() = None);
|
||||
ATTEMPT_COUNT.with(|c| *c.borrow_mut() = 0);
|
||||
}
|
||||
|
||||
/// Return the number of production submit calls observed so far.
|
||||
pub fn attempt_count() -> u32 {
|
||||
ATTEMPT_COUNT.with(|c| *c.borrow())
|
||||
}
|
||||
|
||||
/// Called by `scoped_archive_operation` for each signed event.
|
||||
pub fn notify_submit(event: &nostr::Event) {
|
||||
ATTEMPT_COUNT.with(|c| *c.borrow_mut() += 1);
|
||||
OBSERVER.with(|o| {
|
||||
if let Some(f) = o.borrow().as_ref() {
|
||||
f(event);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ── Archive snapshot ──────────────────────────────────────────────────────────
|
||||
|
||||
+49
-177
@@ -138,7 +138,9 @@ async fn assert_actor_not_relay_member(db_url: &str, actor_pubkey: &str) -> Resu
|
||||
});
|
||||
let rows = client
|
||||
.query(
|
||||
"SELECT 1 FROM relay_members WHERE community_id = $1::uuid AND pubkey = $2",
|
||||
// Cast the UUID column to text so tokio_postgres can bind a &str parameter.
|
||||
// $1::uuid would require a Uuid type; community_id::text = $1 keeps &str.
|
||||
"SELECT 1 FROM relay_members WHERE community_id::text = $1 AND pubkey = $2",
|
||||
&[&TEST_COMMUNITY_ID, &actor_pubkey],
|
||||
)
|
||||
.await
|
||||
@@ -169,8 +171,9 @@ async fn assert_postgres_consent_path_owner(
|
||||
// Scope assertion by community AND pubkey.
|
||||
let rows = client
|
||||
.query(
|
||||
// Cast the UUID column to text so tokio_postgres can bind a &str parameter.
|
||||
"SELECT consent_path FROM archived_identities \
|
||||
WHERE community_id = $1::uuid AND pubkey = $2",
|
||||
WHERE community_id::text = $1 AND pubkey = $2",
|
||||
&[&TEST_COMMUNITY_ID, &agent_pubkey],
|
||||
)
|
||||
.await
|
||||
@@ -231,132 +234,39 @@ fn extract_consent_actor(event: &nostr::Event) -> Option<String> {
|
||||
|
||||
// ── Narrow observation seam ──────────────────────────────────────────────────
|
||||
//
|
||||
// The seam wraps `submit_event_at_with_keys` with a counter + event capture,
|
||||
// without replacing the shipping build/mint function. Production submission
|
||||
// goes through unmodified — we only observe what crossed the wire.
|
||||
// The seam uses `test_hooks` from the parent module — a `#[cfg(test)]`
|
||||
// thread-local hook installed by `test_hooks::install`. `scoped_archive_operation`
|
||||
// calls `test_hooks::notify_submit(&signed)` before every relay POST, so the
|
||||
// observer sees the PRODUCTION-signed event with the PRODUCTION auth tags.
|
||||
// Gutting the fresh-mint in `scoped_archive_operation` → observer sees no auth
|
||||
// tag → wire-form assertions fail locally without a live relay.
|
||||
|
||||
/// A recording wrapper that captures the last signed event submitted and
|
||||
/// the total number of submit attempts.
|
||||
#[derive(Clone, Default)]
|
||||
struct SubmitObserver {
|
||||
last_event: Arc<Mutex<Option<nostr::Event>>>,
|
||||
attempt_count: Arc<Mutex<u32>>,
|
||||
}
|
||||
|
||||
impl SubmitObserver {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
last_event: Arc::new(Mutex::new(None)),
|
||||
attempt_count: Arc::new(Mutex::new(0)),
|
||||
}
|
||||
}
|
||||
|
||||
fn record(&self, event: &nostr::Event) {
|
||||
*self.attempt_count.lock().unwrap() += 1;
|
||||
*self.last_event.lock().unwrap() = Some(event.clone());
|
||||
}
|
||||
|
||||
fn attempts(&self) -> u32 {
|
||||
*self.attempt_count.lock().unwrap()
|
||||
}
|
||||
|
||||
fn last(&self) -> Option<nostr::Event> {
|
||||
self.last_event.lock().unwrap().clone()
|
||||
}
|
||||
}
|
||||
|
||||
/// Run the scoped archive operation but intercept the final event just before
|
||||
/// submission so we can assert on the wire form. Returns `(result, observer)`.
|
||||
///
|
||||
/// Implementation: we build the event ourselves following the same logic as
|
||||
/// `scoped_archive_operation`, capture the built event BEFORE sending, then
|
||||
/// send. This does NOT replace the shipping code — production signing happens
|
||||
/// in the shipping function.
|
||||
async fn scoped_archive_with_observation(
|
||||
/// Helper: install the thread-local hook, run the production operation,
|
||||
/// remove the hook, and return both the result and the captured event.
|
||||
async fn run_with_observation(
|
||||
state: &AppState,
|
||||
scope: &ArchiveScope,
|
||||
kind: ArchiveKind,
|
||||
target_pubkey: &str,
|
||||
observer: &SubmitObserver,
|
||||
) -> Result<crate::relay::SubmitEventResponse, String> {
|
||||
// Use the production scoped_archive_operation but with an observer hook
|
||||
// injected via a thin wrapper. We re-derive the API URL from scope
|
||||
// to peek at the event we'll send.
|
||||
let api_base_url = match &scope.relay_url_override {
|
||||
Some(url) => crate::relay::relay_http_base_url(url),
|
||||
None => crate::relay::relay_api_base_url(),
|
||||
};
|
||||
) -> (
|
||||
Result<crate::relay::SubmitEventResponse, String>,
|
||||
Option<nostr::Event>,
|
||||
u32,
|
||||
) {
|
||||
// Capture the production-signed event via the thread-local hook.
|
||||
let captured: Arc<Mutex<Option<nostr::Event>>> = Arc::new(Mutex::new(None));
|
||||
let captured_clone = Arc::clone(&captured);
|
||||
|
||||
// Re-run the auth-tag computation to get the event that will be sent.
|
||||
// This MIRRORS scoped_archive_operation without replacing it — we duplicate
|
||||
// only the auth-tag logic here to capture the signed event shape.
|
||||
let auth_tag: Option<[String; 4]> = if scope.actor.eq_ignore_ascii_case(target_pubkey) {
|
||||
None
|
||||
} else {
|
||||
let kind0_events = crate::relay::query_relay_at_with_keys(
|
||||
state,
|
||||
&api_base_url,
|
||||
&[serde_json::json!({
|
||||
"kinds": [0u32],
|
||||
"authors": [target_pubkey.to_ascii_lowercase()],
|
||||
"limit": 1,
|
||||
})],
|
||||
&scope.keys,
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
super::test_hooks::install(move |ev| {
|
||||
*captured_clone.lock().unwrap() = Some(ev.clone());
|
||||
});
|
||||
|
||||
match kind0_events.into_iter().next() {
|
||||
None => None,
|
||||
Some(kind0) => match classify_nip_ia_owner_proof(&kind0, &scope.actor) {
|
||||
NipIaOwnerProof::Verified => {
|
||||
let target_compat = nostr::PublicKey::from_hex(&kind0.pubkey.to_hex())
|
||||
.map_err(|e| format!("convert target pubkey: {e}"))?;
|
||||
let owner_secret = scope.keys.secret_key();
|
||||
let owner_compat = nostr::SecretKey::from_slice(owner_secret.as_secret_bytes())
|
||||
.map_err(|e| format!("convert owner secret key: {e}"))?;
|
||||
let owner_compat_keys = nostr::Keys::new(owner_compat);
|
||||
let tag_json = buzz_sdk_pkg::nip_oa::compute_auth_tag(
|
||||
&owner_compat_keys,
|
||||
&target_compat,
|
||||
"",
|
||||
)
|
||||
.map_err(|e| format!("compute_auth_tag: {e}"))?;
|
||||
let compat_tag = buzz_sdk_pkg::nip_oa::parse_auth_tag(&tag_json)
|
||||
.map_err(|e| format!("parse_auth_tag: {e}"))?;
|
||||
let raw: [String; 4] = [
|
||||
compat_tag.as_slice()[0].clone(),
|
||||
compat_tag.as_slice()[1].clone(),
|
||||
compat_tag.as_slice()[2].clone(),
|
||||
compat_tag.as_slice()[3].clone(),
|
||||
];
|
||||
Some(raw)
|
||||
}
|
||||
_ => None,
|
||||
},
|
||||
}
|
||||
};
|
||||
|
||||
let auth_ref = auth_tag.as_ref();
|
||||
let builder = match kind {
|
||||
ArchiveKind::Archive => {
|
||||
crate::events::build_archive_identity_request(target_pubkey, "", None, None, auth_ref)?
|
||||
}
|
||||
ArchiveKind::Unarchive => {
|
||||
crate::events::build_unarchive_identity_request(target_pubkey, "", None, auth_ref)?
|
||||
}
|
||||
};
|
||||
|
||||
// Sign the event to observe it.
|
||||
let signed_event = builder
|
||||
.clone()
|
||||
.sign_with_keys(&scope.keys)
|
||||
.map_err(|e| format!("sign event for observation: {e}"))?;
|
||||
observer.record(&signed_event);
|
||||
|
||||
// Now run the production operation (which re-signs and submits).
|
||||
let result = scoped_archive_operation(state, scope, kind, target_pubkey, "", None, None).await;
|
||||
result
|
||||
let attempts = super::test_hooks::attempt_count();
|
||||
super::test_hooks::reset();
|
||||
|
||||
let event = captured.lock().unwrap().clone();
|
||||
(result, event, attempts)
|
||||
}
|
||||
|
||||
// ── Tests ─────────────────────────────────────────────────────────────────────
|
||||
@@ -390,26 +300,17 @@ async fn owner_consent_archive_9035_records_owner_path() {
|
||||
"owner != agent (Self impossible)"
|
||||
);
|
||||
|
||||
let observer = SubmitObserver::new();
|
||||
let scope = state
|
||||
.capture_archive_scope(8)
|
||||
.expect("capture_archive_scope");
|
||||
|
||||
// Execute the scoped archive operation with observation.
|
||||
let result = scoped_archive_with_observation(
|
||||
&state,
|
||||
&scope,
|
||||
ArchiveKind::Archive,
|
||||
&agent_pubkey,
|
||||
&observer,
|
||||
)
|
||||
.await
|
||||
.expect("scoped_archive_operation 9035");
|
||||
// Execute the production operation via the seam — captures the production-signed event.
|
||||
let (result, observed_event, _) =
|
||||
run_with_observation(&state, &scope, ArchiveKind::Archive, &agent_pubkey).await;
|
||||
let result = result.expect("scoped_archive_operation 9035");
|
||||
|
||||
// ── Assert wire form: exactly one auth tag, empty condition, distinct from profile tag ──
|
||||
let observed_event = observer
|
||||
.last()
|
||||
.expect("must have observed the signed event");
|
||||
let observed_event = observed_event.expect("must have observed the production-signed event");
|
||||
let auth_tags: Vec<&[String]> = observed_event
|
||||
.tags
|
||||
.iter()
|
||||
@@ -565,22 +466,15 @@ async fn expired_bound_profile_mints_fresh_empty_condition_tag() {
|
||||
.expect("submit kind:0 with expired tag");
|
||||
assert!(resp.status().is_success(), "kind:0 submit failed");
|
||||
|
||||
// Use the observation wrapper to capture the wire event.
|
||||
let observer = SubmitObserver::new();
|
||||
// Use the production seam to capture the wire event.
|
||||
let scope = state.capture_archive_scope(8).unwrap();
|
||||
|
||||
let result = scoped_archive_with_observation(
|
||||
&state,
|
||||
&scope,
|
||||
ArchiveKind::Archive,
|
||||
&agent_pubkey,
|
||||
&observer,
|
||||
)
|
||||
.await;
|
||||
let (result, observed_event, _) =
|
||||
run_with_observation(&state, &scope, ArchiveKind::Archive, &agent_pubkey).await;
|
||||
|
||||
// The relay may accept or reject based on condition eval, but the
|
||||
// submitted request MUST carry exactly one fresh empty-condition auth tag.
|
||||
let observed_event = observer.last().expect("must have observed an event");
|
||||
let observed_event = observed_event.expect("must have observed a production-signed event");
|
||||
let auth_tags: Vec<&[String]> = observed_event
|
||||
.tags
|
||||
.iter()
|
||||
@@ -613,22 +507,15 @@ async fn self_requests_are_authless() {
|
||||
let owner_pubkey = owner_keys.public_key().to_hex();
|
||||
|
||||
let state = make_test_state(&owner_keys, &relay_url);
|
||||
let observer = SubmitObserver::new();
|
||||
let scope = state.capture_archive_scope(8).unwrap();
|
||||
|
||||
// Self-archive: actor == target → no auth tag.
|
||||
let result = scoped_archive_with_observation(
|
||||
&state,
|
||||
&scope,
|
||||
ArchiveKind::Archive,
|
||||
&owner_pubkey,
|
||||
&observer,
|
||||
)
|
||||
.await;
|
||||
let (result, observed_event, _) =
|
||||
run_with_observation(&state, &scope, ArchiveKind::Archive, &owner_pubkey).await;
|
||||
|
||||
// The observed event must have ZERO auth tags — self path bypasses auth-tag
|
||||
// The observed production event must have ZERO auth tags — self path bypasses auth-tag
|
||||
// computation entirely.
|
||||
let observed_event = observer.last().expect("must have observed an event");
|
||||
let observed_event = observed_event.expect("must have observed a production-signed event");
|
||||
let auth_tags: Vec<_> = observed_event
|
||||
.tags
|
||||
.iter()
|
||||
@@ -642,16 +529,9 @@ async fn self_requests_are_authless() {
|
||||
|
||||
// Self-unarchive: also authless.
|
||||
let scope2 = state.capture_archive_scope(8).unwrap();
|
||||
let observer2 = SubmitObserver::new();
|
||||
let _ = scoped_archive_with_observation(
|
||||
&state,
|
||||
&scope2,
|
||||
ArchiveKind::Unarchive,
|
||||
&owner_pubkey,
|
||||
&observer2,
|
||||
)
|
||||
.await;
|
||||
let event2 = observer2.last().expect("must have observed event 2");
|
||||
let (_, event2, _) =
|
||||
run_with_observation(&state, &scope2, ArchiveKind::Unarchive, &owner_pubkey).await;
|
||||
let event2 = event2.expect("must have observed event 2");
|
||||
let auth_tags2: Vec<_> = event2
|
||||
.tags
|
||||
.iter()
|
||||
@@ -677,27 +557,19 @@ async fn relay_rejection_is_direct_no_retry() {
|
||||
let unrelated_pubkey = unrelated_keys.public_key().to_hex();
|
||||
|
||||
let state = make_test_state(&owner_keys, &relay_url);
|
||||
let observer = SubmitObserver::new();
|
||||
|
||||
// Target has no kind:0 → classifier-negative → no auth tag → relay rejects.
|
||||
// The operation has NO retry loop — one submit attempt, one result.
|
||||
let scope = state.capture_archive_scope(8).unwrap();
|
||||
let result = scoped_archive_with_observation(
|
||||
&state,
|
||||
&scope,
|
||||
ArchiveKind::Archive,
|
||||
&unrelated_pubkey,
|
||||
&observer,
|
||||
)
|
||||
.await;
|
||||
let (result, _, attempts) =
|
||||
run_with_observation(&state, &scope, ArchiveKind::Archive, &unrelated_pubkey).await;
|
||||
|
||||
// Assert the relay rejected (no authority).
|
||||
assert!(result.is_err(), "expected relay rejection, got success");
|
||||
|
||||
// Assert exactly ONE attempt — the observer count proves no retry loop ran.
|
||||
assert_eq!(
|
||||
observer.attempts(),
|
||||
1,
|
||||
attempts, 1,
|
||||
"relay rejection must produce exactly one submit attempt (no retry)"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -258,8 +258,10 @@ export function UnifiedAgentsSection(props: UnifiedAgentsSectionProps) {
|
||||
effectiveAvatarUrl,
|
||||
)
|
||||
}
|
||||
onViewInstances={(p) =>
|
||||
openInstancesSheet(p, group.agents)
|
||||
onViewInstances={
|
||||
relayInstanceCount > 0
|
||||
? (p) => openInstancesSheet(p, group.agents)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user