Files
buzz/crates/buzz-db/src/user.rs
John Matthew Tennant c3084b36d9 fix(cli,relay): resolve agents by verified owner (#2615)
## Context

`buzz users get --name Honey` searches relay-wide profiles and returns
up to 100 identically named results without verified ownership metadata.
An agent resolving “my Honey” cannot distinguish the requesting human's
agent from another owner's agent, and the owned match can be excluded by
the result limit. This caused the wrong Honey and Bumble pubkeys to be
added to a channel.

## Summary

This bug fix makes personal-agent resolution owner-aware. Callers can
filter profiles by a verified owner identity before result limits are
applied, and all profile results expose enough ownership context to
diagnose duplicate names.

## Changes

- Adds `buzz users get --owner me|<hex>|<npub>` for name and pubkey
lookups.
- Resolves `me` to the NIP-OA owner identity when the CLI runs as an
agent.
- Filters profiles by the relay's verified `agent_owner_pubkey`
relationship before applying the result limit.
- Returns `owner_pubkey`, `owner_display_name`, and client-relative
`owned_by_me` in compact and JSON output.
- Returns an empty result when no owned profile matches instead of
removing the ownership constraint.
- Rejects malformed owner values instead of silently running an unscoped
query; explicit `null` remains equivalent to no owner filter for
ordinary CLI lookups.
- Rejects owner constraints on specialized channel-window, feed, and
thread filters that cannot enforce author filtering.
- Scopes owner filtering and enrichment to the active community.
- Adds a partial `(community_id, agent_owner_pubkey)` index for owner
lookups.
- Documents the safe `users get --name Honey --owner me` lookup.

## Reviewer-reproducible examples

The relay-backed test creates two same-name agents with different
verified owners, queries through the HTTP `/query` route, verifies only
the selected owner's agent is returned with verified owner metadata, and
verifies a missing owner returns `[]`.

```bash
cargo test -p buzz-relay query_agent_owner_returns_only_verified_owner_matches --lib -- --ignored
```

The owner/author intersection and unsupported-specialized-filter
contracts also have infrastructure-free relay tests:

```bash
cargo test -p buzz-relay agent_owner --lib
```

The CLI surface is visible in command help:

```bash
cargo run -q -p buzz-cli -- users get --help | grep -- --owner
```

```text
      --owner <OWNER>     Filter agents by verified owner (`me`, 64-char hex, or npub)
```

## Validation

- `cargo test -p buzz-cli` (252 passed)
- `cargo test -p buzz-db` (84 passed, 122 infrastructure tests ignored)
- `cargo test -p buzz-relay --lib` (owner-filter tests pass; the full
local suite is blocked by unrelated Postgres pool timeouts in
media/admin tests)
- `cargo test -p buzz-relay
query_agent_owner_returns_only_verified_owner_matches --lib --
--ignored` (passed)
- `cargo check --workspace --all-targets`
- `cargo fmt --all -- --check`
- Pre-push Rust, Desktop, and Desktop Tauri suites passed
- Pre-push mobile suite could not start because `flutter` is not
installed
- `pnpm check:file-sizes` (passed after rebasing onto current `main`)

---------

Signed-off-by: npub1qye6rec0htgg3np8yt6plpyyg8cyffaq66emt3kmk05eylckkzhq0hnf2k <0133a1e70fbad088cc2722f41f848441f044a7a0d6b3b5c6dbb3e9927f16b0ae@buzz.block.builderlab.xyz>
Co-authored-by: npub1qye6rec0htgg3np8yt6plpyyg8cyffaq66emt3kmk05eylckkzhq0hnf2k <0133a1e70fbad088cc2722f41f848441f044a7a0d6b3b5c6dbb3e9927f16b0ae@buzz.block.builderlab.xyz>
2026-07-27 13:10:42 -04:00

795 lines
26 KiB
Rust

//! User CRUD operations.
use crate::error::Result;
use buzz_core::CommunityId;
use sqlx::PgPool;
use sqlx::Row;
/// Ownership metadata for an agent profile.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AgentOwner {
/// Raw agent public key.
pub agent_pubkey: Vec<u8>,
/// Raw owner public key, verified when the agent authenticated via NIP-OA.
pub owner_pubkey: Vec<u8>,
/// Owner's current display name, when available.
pub owner_display_name: Option<String>,
}
/// A user's profile fields.
#[derive(Debug, Clone)]
pub struct UserProfile {
/// Raw 32-byte compressed public key.
pub pubkey: Vec<u8>,
/// Human-readable display name chosen by the user.
pub display_name: Option<String>,
/// URL of the user's avatar image.
pub avatar_url: Option<String>,
/// Short bio or description provided by the user.
pub about: Option<String>,
/// NIP-05 identifier (user@domain).
pub nip05_handle: Option<String>,
}
/// Lightweight user record returned from search.
#[derive(Debug, Clone)]
pub struct UserSearchProfile {
/// Raw 32-byte compressed public key.
pub pubkey: Vec<u8>,
/// Human-readable display name chosen by the user.
pub display_name: Option<String>,
/// URL of the user's avatar image.
pub avatar_url: Option<String>,
/// NIP-05 identifier (user@domain).
pub nip05_handle: Option<String>,
}
/// Ensure a user record exists for the given pubkey (upsert).
/// Creates with minimal fields if not present; no-op if already exists.
///
/// Returns `true` if a new row was inserted, `false` if the user already existed.
/// The `true` case is the reliable signal for "user was just registered" — used
/// by callers to increment `buzz_users_created_total`.
pub async fn ensure_user(pool: &PgPool, community_id: CommunityId, pubkey: &[u8]) -> Result<bool> {
let result = sqlx::query(
r#"
INSERT INTO users (community_id, pubkey)
VALUES ($1, $2)
ON CONFLICT DO NOTHING
"#,
)
.bind(community_id.as_uuid())
.bind(pubkey)
.execute(pool)
.await?;
Ok(result.rows_affected() == 1)
}
/// Get a single user record by pubkey.
pub async fn get_user(
pool: &PgPool,
community_id: CommunityId,
pubkey: &[u8],
) -> Result<Option<UserProfile>> {
let row = sqlx::query_as::<
_,
(
Vec<u8>,
Option<String>,
Option<String>,
Option<String>,
Option<String>,
),
>(
r#"
SELECT pubkey, display_name, avatar_url, about, nip05_handle
FROM users
WHERE community_id = $1 AND pubkey = $2
"#,
)
.bind(community_id.as_uuid())
.bind(pubkey)
.fetch_optional(pool)
.await?;
Ok(row.map(
|(pubkey, display_name, avatar_url, about, nip05_handle)| UserProfile {
pubkey,
display_name,
avatar_url,
about,
nip05_handle,
},
))
}
/// Update a user's profile fields (display_name, avatar_url, about, nip05_handle).
/// Only updates fields that are Some -- None fields are left unchanged.
/// At least one field must be Some, otherwise returns Ok(()) without touching the DB.
///
/// Empty strings are treated as "clear to NULL" -- this is important for kind:0
/// absolute-state semantics where absent fields must be cleared, and for the
/// `nip05_handle` column which has a UNIQUE constraint (multiple NULLs are allowed,
/// but multiple empty strings would violate uniqueness).
pub async fn update_user_profile(
pool: &PgPool,
community_id: CommunityId,
pubkey: &[u8],
display_name: Option<&str>,
avatar_url: Option<&str>,
about: Option<&str>,
nip05_handle: Option<&str>,
) -> Result<()> {
let mut set_parts: Vec<String> = Vec::new();
let mut param_idx = 1u32;
if display_name.is_some() {
set_parts.push(format!("display_name = ${param_idx}"));
param_idx += 1;
}
if avatar_url.is_some() {
set_parts.push(format!("avatar_url = ${param_idx}"));
param_idx += 1;
}
if about.is_some() {
set_parts.push(format!("about = ${param_idx}"));
param_idx += 1;
}
if nip05_handle.is_some() {
set_parts.push(format!("nip05_handle = ${param_idx}"));
param_idx += 1;
}
if set_parts.is_empty() {
return Ok(());
}
// Helper: convert empty string to None (NULL in DB). This ensures UNIQUE
// columns like nip05_handle don't collide on empty strings, and keeps
// semantics clean: absent profile data is NULL, not "".
fn empty_to_none(val: Option<&str>) -> Option<&str> {
val.filter(|s| !s.is_empty())
}
let sql = format!(
"UPDATE users SET {} WHERE community_id = ${param_idx} AND pubkey = ${}",
set_parts.join(", "),
param_idx + 1
);
let mut query = sqlx::query(sqlx::AssertSqlSafe(sql));
if display_name.is_some() {
query = query.bind(empty_to_none(display_name));
}
if avatar_url.is_some() {
query = query.bind(empty_to_none(avatar_url));
}
if about.is_some() {
query = query.bind(empty_to_none(about));
}
if nip05_handle.is_some() {
query = query.bind(empty_to_none(nip05_handle));
}
query = query.bind(community_id.as_uuid());
query = query.bind(pubkey);
query.execute(pool).await?;
Ok(())
}
/// Look up a user by their full NIP-05 handle (exact match, case-insensitive).
/// Both `local_part` and `domain` must already be lowercased by the caller.
pub async fn get_user_by_nip05(
pool: &PgPool,
community_id: CommunityId,
local_part: &str,
domain: &str,
) -> Result<Option<UserProfile>> {
let handle = format!("{}@{}", local_part, domain);
let row = sqlx::query_as::<
_,
(
Vec<u8>,
Option<String>,
Option<String>,
Option<String>,
Option<String>,
),
>(
r#"
SELECT pubkey, display_name, avatar_url, about, nip05_handle
FROM users
WHERE community_id = $1 AND LOWER(nip05_handle) = LOWER($2)
LIMIT 1
"#,
)
.bind(community_id.as_uuid())
.bind(&handle)
.fetch_optional(pool)
.await?;
Ok(row.map(
|(pubkey, display_name, avatar_url, about, nip05_handle)| UserProfile {
pubkey,
display_name,
avatar_url,
about,
nip05_handle,
},
))
}
/// Escape SQL LIKE metacharacters (`%`, `_`, `\`) so user input is treated
/// as literal text. Used with `ESCAPE '\'` in the query.
///
/// Without this, a search query of `"%"` would match every row (full table
/// scan) and `"_"` would act as a single-character wildcard.
fn escape_like(input: &str) -> String {
input
.replace('\\', "\\\\")
.replace('%', "\\%")
.replace('_', "\\_")
}
/// Search users by display name, NIP-05 handle, or pubkey prefix.
///
/// Empty queries return an empty vec and do not hit the database.
pub async fn search_users(
pool: &PgPool,
community_id: CommunityId,
query: &str,
limit: u32,
) -> Result<Vec<UserSearchProfile>> {
let normalized = query.trim().to_lowercase();
if normalized.is_empty() {
return Ok(Vec::new());
}
let escaped = escape_like(&normalized);
let contains_pattern = format!("%{escaped}%");
let prefix_pattern = format!("{escaped}%");
let limit = limit.clamp(1, 500) as i64;
let rows = sqlx::query_as::<_, (Vec<u8>, Option<String>, Option<String>, Option<String>)>(
r#"
SELECT pubkey, display_name, avatar_url, nip05_handle
FROM users
WHERE community_id = $1
AND (LOWER(COALESCE(display_name, '')) LIKE $2 ESCAPE '\'
OR LOWER(COALESCE(nip05_handle, '')) LIKE $2 ESCAPE '\'
OR LOWER(encode(pubkey, 'hex')) LIKE $2 ESCAPE '\')
ORDER BY
CASE
WHEN LOWER(COALESCE(display_name, '')) = $3 THEN 0
WHEN LOWER(COALESCE(nip05_handle, '')) = $3 THEN 1
WHEN LOWER(encode(pubkey, 'hex')) = $3 THEN 2
WHEN LOWER(COALESCE(display_name, '')) LIKE $4 ESCAPE '\' THEN 3
WHEN LOWER(COALESCE(nip05_handle, '')) LIKE $4 ESCAPE '\' THEN 4
WHEN LOWER(encode(pubkey, 'hex')) LIKE $4 ESCAPE '\' THEN 5
ELSE 6
END,
COALESCE(NULLIF(display_name, ''), NULLIF(nip05_handle, ''), LOWER(encode(pubkey, 'hex')))
LIMIT $5
"#,
)
.bind(community_id.as_uuid())
.bind(&contains_pattern)
.bind(&normalized)
.bind(&prefix_pattern)
.bind(limit)
.fetch_all(pool)
.await?;
Ok(rows
.into_iter()
.map(
|(pubkey, display_name, avatar_url, nip05_handle)| UserSearchProfile {
pubkey,
display_name,
avatar_url,
nip05_handle,
},
)
.collect())
}
/// Return agent pubkeys whose verified owner matches `owner_pubkey`.
pub async fn list_agent_pubkeys_by_owner(
pool: &PgPool,
community_id: CommunityId,
owner_pubkey: &[u8],
) -> Result<Vec<Vec<u8>>> {
let rows = sqlx::query_scalar::<_, Vec<u8>>(
r#"
SELECT pubkey
FROM users
WHERE community_id = $1 AND agent_owner_pubkey = $2
"#,
)
.bind(community_id.as_uuid())
.bind(owner_pubkey)
.fetch_all(pool)
.await?;
Ok(rows)
}
/// Fetch verified ownership metadata for the requested agent pubkeys.
pub async fn get_agent_owners(
pool: &PgPool,
community_id: CommunityId,
agent_pubkeys: &[Vec<u8>],
) -> Result<Vec<AgentOwner>> {
if agent_pubkeys.is_empty() {
return Ok(Vec::new());
}
let rows = sqlx::query_as::<_, (Vec<u8>, Vec<u8>, Option<String>)>(
r#"
SELECT agent.pubkey, agent.agent_owner_pubkey, owner.display_name
FROM users agent
LEFT JOIN users owner
ON owner.community_id = agent.community_id
AND owner.pubkey = agent.agent_owner_pubkey
WHERE agent.community_id = $1
AND agent.pubkey = ANY($2)
AND agent.agent_owner_pubkey IS NOT NULL
"#,
)
.bind(community_id.as_uuid())
.bind(agent_pubkeys)
.fetch_all(pool)
.await?;
Ok(rows
.into_iter()
.map(
|(agent_pubkey, owner_pubkey, owner_display_name)| AgentOwner {
agent_pubkey,
owner_pubkey,
owner_display_name,
},
)
.collect())
}
/// Set the owner pubkey for an agent user.
/// The owner pubkey must already exist in the users table (FK constraint).
/// Returns an error if the agent pubkey is not found (rows_affected == 0).
/// Atomically set agent owner — only if no owner is currently assigned.
///
/// Returns Ok(true) if ownership was set, Ok(false) if an owner already exists
/// (caller should check whether the existing owner matches). Returns Err if the
/// agent pubkey doesn't exist in the users table.
pub async fn set_agent_owner(
pool: &PgPool,
community_id: CommunityId,
agent_pubkey: &[u8],
owner_pubkey: &[u8],
) -> Result<bool> {
// Conditional UPDATE: only set owner if currently NULL. This makes
// "first mint wins" atomic — no TOCTOU race between concurrent mints.
let result = sqlx::query(
r#"UPDATE users SET agent_owner_pubkey = $1 WHERE community_id = $2 AND pubkey = $3 AND agent_owner_pubkey IS NULL"#,
)
.bind(owner_pubkey)
.bind(community_id.as_uuid())
.bind(agent_pubkey)
.execute(pool)
.await?;
if result.rows_affected() == 0 {
// Could be: (a) pubkey not found, or (b) owner already set.
// Check which case by querying the row.
let exists = sqlx::query(r#"SELECT 1 FROM users WHERE community_id = $1 AND pubkey = $2"#)
.bind(community_id.as_uuid())
.bind(agent_pubkey)
.fetch_optional(pool)
.await?;
if exists.is_none() {
return Err(crate::error::DbError::NotFound(
"agent pubkey not found in users table".into(),
));
}
// Row exists but owner already set — return false (not an error).
return Ok(false);
}
Ok(true)
}
/// Get the channel_add_policy and agent_owner_pubkey for a user.
/// Returns None if the pubkey is not in the users table.
/// Returns Some((policy_str, owner_bytes_or_none)) if found.
pub async fn get_agent_channel_policy(
pool: &PgPool,
community_id: CommunityId,
pubkey: &[u8],
) -> Result<Option<(String, Option<Vec<u8>>)>> {
let row = sqlx::query(
r#"SELECT channel_add_policy::text AS channel_add_policy, agent_owner_pubkey FROM users WHERE community_id = $1 AND pubkey = $2"#,
)
.bind(community_id.as_uuid())
.bind(pubkey)
.fetch_optional(pool)
.await?;
row.map(|r| -> Result<(String, Option<Vec<u8>>)> {
let policy: String = r.try_get("channel_add_policy")?;
let owner: Option<Vec<u8>> = r.try_get("agent_owner_pubkey").unwrap_or(None);
Ok((policy, owner))
})
.transpose()
}
/// Check whether `actor_pubkey` is the `agent_owner_pubkey` of `target_pubkey`.
/// Queries `agent_owner_pubkey` directly rather than going through
/// `get_agent_channel_policy`, which would fetch unrelated fields.
pub async fn is_agent_owner(
pool: &PgPool,
community_id: CommunityId,
target_pubkey: &[u8],
actor_pubkey: &[u8],
) -> Result<bool> {
let row = sqlx::query_scalar::<_, bool>(
"SELECT agent_owner_pubkey = $3 FROM users WHERE community_id = $1 AND pubkey = $2 AND agent_owner_pubkey IS NOT NULL",
)
.bind(community_id.as_uuid())
.bind(target_pubkey)
.bind(actor_pubkey)
.fetch_optional(pool)
.await?;
Ok(row.unwrap_or(false))
}
/// Set the channel_add_policy for a user.
/// Returns an error if the pubkey is not found (rows_affected == 0).
/// Returns an error if `policy` is not one of the valid ENUM values.
pub async fn set_channel_add_policy(
pool: &PgPool,
community_id: CommunityId,
pubkey: &[u8],
policy: &str,
) -> Result<()> {
if !matches!(policy, "anyone" | "owner_only" | "nobody") {
return Err(crate::error::DbError::InvalidData(format!(
"invalid channel_add_policy: {policy}"
)));
}
let result = sqlx::query(
r#"UPDATE users SET channel_add_policy = $1::channel_add_policy WHERE community_id = $2 AND pubkey = $3"#,
)
.bind(policy)
.bind(community_id.as_uuid())
.bind(pubkey)
.execute(pool)
.await?;
if result.rows_affected() == 0 {
return Err(crate::error::DbError::NotFound(
"pubkey not found in users table".into(),
));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::Db;
use nostr::Keys;
const TEST_DB_URL: &str = "postgres://buzz:buzz_dev@localhost:5432/buzz";
async fn setup_db() -> Db {
let pool = PgPool::connect(TEST_DB_URL)
.await
.expect("connect to test DB");
Db::from_pool(pool)
}
fn random_pubkey() -> Vec<u8> {
Keys::generate().public_key().to_bytes().to_vec()
}
async fn make_community(pool: &PgPool) -> CommunityId {
let id = uuid::Uuid::new_v4();
let host = format!("user-test-{}.example", id.simple());
sqlx::query("INSERT INTO communities (id, host) VALUES ($1, $2)")
.bind(id)
.bind(host)
.execute(pool)
.await
.expect("insert test community");
CommunityId::from_uuid(id)
}
/// Setting an agent owner then reading back the policy should return
/// the default "anyone" policy and the owner pubkey.
#[tokio::test]
#[ignore = "requires Postgres"]
async fn test_set_agent_owner_and_get_policy() {
let db = setup_db().await;
let community = make_community(&db.pool).await;
let agent_pk = random_pubkey();
let owner_pk = random_pubkey();
ensure_user(&db.pool, community, &agent_pk)
.await
.expect("ensure agent");
ensure_user(&db.pool, community, &owner_pk)
.await
.expect("ensure owner");
let was_set = set_agent_owner(&db.pool, community, &agent_pk, &owner_pk)
.await
.expect("set_agent_owner");
assert!(was_set, "first set_agent_owner should return true");
let result = get_agent_channel_policy(&db.pool, community, &agent_pk)
.await
.expect("get_agent_channel_policy");
let (policy, owner) = result.expect("should return Some for known pubkey");
assert_eq!(policy, "anyone", "default policy should be 'anyone'");
assert_eq!(
owner,
Some(owner_pk),
"owner pubkey should match what was set"
);
}
#[tokio::test]
#[ignore = "requires Postgres"]
async fn test_agent_owner_queries_are_scoped_and_include_display_name() {
let db = setup_db().await;
let community = make_community(&db.pool).await;
let agent_pk = random_pubkey();
let other_agent_pk = random_pubkey();
let owner_pk = random_pubkey();
let other_owner_pk = random_pubkey();
for pubkey in [&agent_pk, &other_agent_pk, &owner_pk, &other_owner_pk] {
ensure_user(&db.pool, community, pubkey).await.unwrap();
}
update_user_profile(
&db.pool,
community,
&owner_pk,
Some("Owner Name"),
None,
None,
None,
)
.await
.unwrap();
set_agent_owner(&db.pool, community, &agent_pk, &owner_pk)
.await
.unwrap();
set_agent_owner(&db.pool, community, &other_agent_pk, &other_owner_pk)
.await
.unwrap();
assert_eq!(
list_agent_pubkeys_by_owner(&db.pool, community, &owner_pk)
.await
.unwrap(),
vec![agent_pk.clone()]
);
assert_eq!(
get_agent_owners(&db.pool, community, &[agent_pk.clone(), other_agent_pk],)
.await
.unwrap()
.into_iter()
.find(|owner| owner.agent_pubkey == agent_pk)
.unwrap(),
AgentOwner {
agent_pubkey: agent_pk,
owner_pubkey: owner_pk,
owner_display_name: Some("Owner Name".into()),
}
);
}
/// set_channel_add_policy should persist each of the three valid policies.
#[tokio::test]
#[ignore = "requires Postgres"]
async fn test_set_channel_add_policy() {
let db = setup_db().await;
let community = make_community(&db.pool).await;
let pk = random_pubkey();
ensure_user(&db.pool, community, &pk)
.await
.expect("ensure user");
// owner_only
set_channel_add_policy(&db.pool, community, &pk, "owner_only")
.await
.expect("set owner_only");
let (policy, owner) = get_agent_channel_policy(&db.pool, community, &pk)
.await
.expect("get policy")
.expect("should be Some");
assert_eq!(policy, "owner_only");
assert!(owner.is_none(), "no owner was set");
// nobody
set_channel_add_policy(&db.pool, community, &pk, "nobody")
.await
.expect("set nobody");
let (policy, owner) = get_agent_channel_policy(&db.pool, community, &pk)
.await
.expect("get policy")
.expect("should be Some");
assert_eq!(policy, "nobody");
assert!(owner.is_none());
// anyone (reset to default)
set_channel_add_policy(&db.pool, community, &pk, "anyone")
.await
.expect("set anyone");
let (policy, owner) = get_agent_channel_policy(&db.pool, community, &pk)
.await
.expect("get policy")
.expect("should be Some");
assert_eq!(policy, "anyone");
assert!(owner.is_none());
}
/// get_agent_channel_policy should return None for a pubkey that has
/// never been inserted into the users table.
#[tokio::test]
#[ignore = "requires Postgres"]
async fn test_get_policy_unknown_pubkey() {
let db = setup_db().await;
let community = make_community(&db.pool).await;
let pk = random_pubkey();
let result = get_agent_channel_policy(&db.pool, community, &pk)
.await
.expect("query should not error");
assert!(result.is_none(), "unknown pubkey should return None");
}
/// set_agent_owner should return Err when the agent pubkey does not exist
/// in the users table.
#[tokio::test]
#[ignore = "requires Postgres"]
async fn test_set_agent_owner_nonexistent_agent() {
let db = setup_db().await;
let community = make_community(&db.pool).await;
let agent_pk = random_pubkey();
let owner_pk = random_pubkey();
// Only ensure the owner exists -- agent is intentionally absent.
ensure_user(&db.pool, community, &owner_pk)
.await
.expect("ensure owner");
let result = set_agent_owner(&db.pool, community, &agent_pk, &owner_pk).await;
assert!(
result.is_err(),
"should error when agent pubkey is not in users table"
);
}
/// set_agent_owner should return Ok(false) when the agent already has an owner.
#[tokio::test]
#[ignore = "requires Postgres"]
async fn test_set_agent_owner_already_owned() {
let db = setup_db().await;
let community = make_community(&db.pool).await;
let agent_pk = random_pubkey();
let owner1 = random_pubkey();
let owner2 = random_pubkey();
ensure_user(&db.pool, community, &agent_pk)
.await
.expect("ensure agent");
ensure_user(&db.pool, community, &owner1)
.await
.expect("ensure owner1");
ensure_user(&db.pool, community, &owner2)
.await
.expect("ensure owner2");
let first = set_agent_owner(&db.pool, community, &agent_pk, &owner1)
.await
.expect("first set");
assert!(first, "first set should succeed");
let second = set_agent_owner(&db.pool, community, &agent_pk, &owner2)
.await
.expect("second set should not error");
assert!(!second, "second set should return false (already owned)");
// Verify original owner is preserved.
let (_, owner) = get_agent_channel_policy(&db.pool, community, &agent_pk)
.await
.expect("get policy")
.expect("should be Some");
assert_eq!(owner, Some(owner1), "original owner should be preserved");
}
/// set_channel_add_policy should return Err when the pubkey does not exist
/// in the users table (0 rows affected -> NotFound).
#[tokio::test]
#[ignore = "requires Postgres"]
async fn test_set_channel_add_policy_nonexistent_user() {
let db = setup_db().await;
let community = make_community(&db.pool).await;
let pk = random_pubkey();
let result = set_channel_add_policy(&db.pool, community, &pk, "nobody").await;
assert!(
result.is_err(),
"should error when pubkey is not in users table"
);
}
#[tokio::test]
#[ignore = "requires Postgres"]
async fn test_set_channel_add_policy_rejects_invalid() {
let db = setup_db().await;
let community = make_community(&db.pool).await;
let pubkey = nostr::Keys::generate().public_key().to_bytes().to_vec();
ensure_user(&db.pool, community, &pubkey).await.unwrap();
let result = set_channel_add_policy(&db.pool, community, &pubkey, "invalid_policy").await;
assert!(result.is_err(), "should reject invalid policy value");
}
// Use the production `escape_like` function directly — no local mirror.
use super::escape_like;
#[test]
fn like_escape_percent() {
assert_eq!(escape_like("%"), "\\%");
assert_eq!(escape_like("100%match"), "100\\%match");
}
#[test]
fn like_escape_underscore() {
assert_eq!(escape_like("_"), "\\_");
assert_eq!(escape_like("a_b"), "a\\_b");
}
#[test]
fn like_escape_backslash() {
assert_eq!(escape_like("\\"), "\\\\");
assert_eq!(escape_like("a\\b"), "a\\\\b");
}
#[test]
fn like_escape_combined() {
// All three metacharacters in one string
assert_eq!(escape_like("%_\\"), "\\%\\_\\\\");
}
#[test]
fn like_escape_normal_input_unchanged() {
assert_eq!(escape_like("alice"), "alice");
assert_eq!(escape_like("bob@example.com"), "bob@example.com");
assert_eq!(escape_like(""), "");
}
/// A user with "owner_only" policy but no agent_owner_pubkey set should
/// return Some(("owner_only", None)).
#[tokio::test]
#[ignore = "requires Postgres"]
async fn test_owner_only_with_no_owner() {
let db = setup_db().await;
let community = make_community(&db.pool).await;
let pk = random_pubkey();
ensure_user(&db.pool, community, &pk)
.await
.expect("ensure user");
set_channel_add_policy(&db.pool, community, &pk, "owner_only")
.await
.expect("set owner_only");
let result = get_agent_channel_policy(&db.pool, community, &pk)
.await
.expect("get policy")
.expect("should be Some");
assert_eq!(result.0, "owner_only");
assert!(result.1.is_none(), "owner should be None when never set");
}
}