feat: import Chinese-localized Buzz source snapshot
Docker image / Build (linux/amd64) (push) Has been cancelled
Docker image / Build (linux/arm64) (push) Has been cancelled
Docker image / Merge release multi-arch manifest (push) Has been cancelled
Docker image / Merge debug multi-arch manifest (push) Has been cancelled
Docker image / Build public push gateway (linux/amd64) (push) Has been cancelled
Docker image / Build public push gateway (linux/arm64) (push) Has been cancelled
Docker image / Publish public push gateway image (push) Has been cancelled
Sprig image / Build (linux/amd64) (push) Has been cancelled
Sprig image / Build (linux/arm64) (push) Has been cancelled
Sprig image / Merge multi-arch manifest (push) Has been cancelled
Harbor Buzz Orchestra / Python tests and lint (push) Has been cancelled
CI / Detect Changed Paths (push) Has been cancelled
CI / Rust Lint (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Desktop Core (push) Has been cancelled
CI / Desktop Smoke E2E (1) (push) Has been cancelled
CI / Desktop Smoke E2E (2) (push) Has been cancelled
CI / Desktop Smoke E2E (3) (push) Has been cancelled
CI / Desktop Smoke E2E (4) (push) Has been cancelled
CI / Desktop (push) Has been cancelled
CI / Desktop E2E Relay (push) Has been cancelled
CI / Desktop E2E Integration (1/2) (push) Has been cancelled
CI / Desktop E2E Integration (2/2) (push) Has been cancelled
CI / Desktop E2E Integration (push) Has been cancelled
CI / Backend Integration (relay e2e) (push) Has been cancelled
CI / Relay E2E (push) Has been cancelled
CI / Web (push) Has been cancelled
CI / Mobile (push) Has been cancelled
CI / Security (push) Has been cancelled
CI / Dead Token Reference Guard (push) Has been cancelled
CI / Server Cross-Compile (aarch64-unknown-linux-musl) (push) Has been cancelled
CI / Server Cross-Compile (x86_64-unknown-linux-musl) (push) Has been cancelled
CI / Windows Rust (x86_64-pc-windows-msvc) (push) Has been cancelled
CI / Desktop Build (macOS) (push) Has been cancelled
helm chart / lint + unittest + render matrix (push) Has been cancelled
helm chart / install on kind (gated) (push) Has been cancelled
helm chart / publish chart to GHCR (push) Has been cancelled
Mesh Lifecycle / Relay-Driven Mesh Lifecycle Smoke (push) Has been cancelled
Sprig / Build (aarch64-unknown-linux-musl) (push) Has been cancelled
Sprig / Build (x86_64-unknown-linux-musl) (push) Has been cancelled
Sprig / Publish rolling release (push) Has been cancelled
Sprig / Publish tagged release (push) Has been cancelled
Docker image / Build (linux/amd64) (push) Has been cancelled
Docker image / Build (linux/arm64) (push) Has been cancelled
Docker image / Merge release multi-arch manifest (push) Has been cancelled
Docker image / Merge debug multi-arch manifest (push) Has been cancelled
Docker image / Build public push gateway (linux/amd64) (push) Has been cancelled
Docker image / Build public push gateway (linux/arm64) (push) Has been cancelled
Docker image / Publish public push gateway image (push) Has been cancelled
Sprig image / Build (linux/amd64) (push) Has been cancelled
Sprig image / Build (linux/arm64) (push) Has been cancelled
Sprig image / Merge multi-arch manifest (push) Has been cancelled
Harbor Buzz Orchestra / Python tests and lint (push) Has been cancelled
CI / Detect Changed Paths (push) Has been cancelled
CI / Rust Lint (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Desktop Core (push) Has been cancelled
CI / Desktop Smoke E2E (1) (push) Has been cancelled
CI / Desktop Smoke E2E (2) (push) Has been cancelled
CI / Desktop Smoke E2E (3) (push) Has been cancelled
CI / Desktop Smoke E2E (4) (push) Has been cancelled
CI / Desktop (push) Has been cancelled
CI / Desktop E2E Relay (push) Has been cancelled
CI / Desktop E2E Integration (1/2) (push) Has been cancelled
CI / Desktop E2E Integration (2/2) (push) Has been cancelled
CI / Desktop E2E Integration (push) Has been cancelled
CI / Backend Integration (relay e2e) (push) Has been cancelled
CI / Relay E2E (push) Has been cancelled
CI / Web (push) Has been cancelled
CI / Mobile (push) Has been cancelled
CI / Security (push) Has been cancelled
CI / Dead Token Reference Guard (push) Has been cancelled
CI / Server Cross-Compile (aarch64-unknown-linux-musl) (push) Has been cancelled
CI / Server Cross-Compile (x86_64-unknown-linux-musl) (push) Has been cancelled
CI / Windows Rust (x86_64-pc-windows-msvc) (push) Has been cancelled
CI / Desktop Build (macOS) (push) Has been cancelled
helm chart / lint + unittest + render matrix (push) Has been cancelled
helm chart / install on kind (gated) (push) Has been cancelled
helm chart / publish chart to GHCR (push) Has been cancelled
Mesh Lifecycle / Relay-Driven Mesh Lifecycle Smoke (push) Has been cancelled
Sprig / Build (aarch64-unknown-linux-musl) (push) Has been cancelled
Sprig / Build (x86_64-unknown-linux-musl) (push) Has been cancelled
Sprig / Publish rolling release (push) Has been cancelled
Sprig / Publish tagged release (push) Has been cancelled
Signed-off-by: cls_宁波本机 <908705107@qq.com>
This commit is contained in:
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,555 @@
|
||||
//! End-to-end git-over-object-storage tests.
|
||||
//!
|
||||
//! Drives the real `git` binary (clone / push / fetch / force-push / tag,
|
||||
//! plus a best-effort concurrent push race) against a running relay backed by
|
||||
//! S3/MinIO, exercising the full manifest-pointer CAS commit path described in
|
||||
//! `docs/git-on-object-storage.md`.
|
||||
//!
|
||||
//! Requires: relay at localhost:3000 with git + S3/MinIO configured, `git` on
|
||||
//! PATH, and the `git-credential-nostr` helper built. All tests are `#[ignore]`
|
||||
//! so they don't run in CI by default.
|
||||
//!
|
||||
//! # Running
|
||||
//!
|
||||
//! ```text
|
||||
//! cargo build --release -p git-credential-nostr
|
||||
//! GIT_CREDENTIAL_NOSTR_BIN=$PWD/target/release/git-credential-nostr \
|
||||
//! cargo test -p buzz-test-client --test e2e_git -- --ignored --nocapture
|
||||
//! ```
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
use std::time::Duration;
|
||||
|
||||
use buzz_media::S3AddressingStyle;
|
||||
use nostr::{EventBuilder, Keys, Kind, Tag};
|
||||
use s3::creds::Credentials;
|
||||
use s3::{Bucket, Region};
|
||||
|
||||
fn relay_http_url() -> String {
|
||||
std::env::var("RELAY_HTTP_URL").unwrap_or_else(|_| "http://localhost:3000".to_string())
|
||||
}
|
||||
|
||||
/// Path to the compiled credential helper. Defaults to the workspace release
|
||||
/// build; override with `GIT_CREDENTIAL_NOSTR_BIN`.
|
||||
fn credential_helper() -> PathBuf {
|
||||
if let Ok(p) = std::env::var("GIT_CREDENTIAL_NOSTR_BIN") {
|
||||
return PathBuf::from(p);
|
||||
}
|
||||
// tests run from the crate dir; the workspace target is two levels up.
|
||||
let mut p = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
|
||||
p.pop();
|
||||
p.pop();
|
||||
p.push("target/release/git-credential-nostr");
|
||||
p
|
||||
}
|
||||
|
||||
/// Submit a signed event to the relay's HTTP bridge (`POST /events`).
|
||||
async fn post_event(event: &nostr::Event) {
|
||||
let client = reqwest::Client::new();
|
||||
let resp = client
|
||||
.post(format!("{}/events", relay_http_url()))
|
||||
.header("X-Pubkey", event.pubkey.to_hex())
|
||||
.header("Content-Type", "application/json")
|
||||
.body(serde_json::to_string(event).unwrap())
|
||||
.send()
|
||||
.await
|
||||
.expect("post event");
|
||||
assert!(
|
||||
resp.status().is_success(),
|
||||
"event rejected: {}",
|
||||
resp.text().await.unwrap_or_default()
|
||||
);
|
||||
}
|
||||
|
||||
/// Create a channel (kind:9007) owned by `keys` and return its UUID.
|
||||
///
|
||||
/// The git read gate (SEC-005) authorizes against membership in the channel
|
||||
/// named by the announcement's `buzz-channel` tag, so every repo these tests
|
||||
/// announce must be bound to a channel its owner belongs to — creating the
|
||||
/// channel makes the creator its owner-member.
|
||||
async fn create_test_channel(keys: &Keys) -> String {
|
||||
let channel_uuid = uuid::Uuid::new_v4().to_string();
|
||||
let event = EventBuilder::new(Kind::Custom(9007), "")
|
||||
.tags(vec![
|
||||
Tag::parse(["h", &channel_uuid]).unwrap(),
|
||||
Tag::parse(["name", &format!("git-e2e-{channel_uuid}")]).unwrap(),
|
||||
Tag::parse(["channel_type", "stream"]).unwrap(),
|
||||
Tag::parse(["visibility", "open"]).unwrap(),
|
||||
])
|
||||
.sign_with_keys(keys)
|
||||
.unwrap();
|
||||
post_event(&event).await;
|
||||
channel_uuid
|
||||
}
|
||||
|
||||
/// Run `git` with the Buzz credential helper and isolated config.
|
||||
fn git_status(args: &[&str], cwd: &Path, owner_nsec: &str) -> std::process::Output {
|
||||
let helper = credential_helper();
|
||||
Command::new("git")
|
||||
.args([
|
||||
"-c",
|
||||
"credential.useHttpPath=true",
|
||||
"-c",
|
||||
&format!("credential.helper={}", helper.display()),
|
||||
"-c",
|
||||
"commit.gpgsign=false",
|
||||
"-c",
|
||||
"tag.gpgsign=false",
|
||||
"-c",
|
||||
"user.name=E2E",
|
||||
"-c",
|
||||
"user.email=e2e@example.com",
|
||||
])
|
||||
.args(args)
|
||||
.current_dir(cwd)
|
||||
// Isolate from any machine/agent git config (signing, etc.).
|
||||
.env("GIT_CONFIG_GLOBAL", "/dev/null")
|
||||
.env("GIT_CONFIG_NOSYSTEM", "1")
|
||||
.env_remove("GIT_CONFIG_COUNT")
|
||||
.env("NOSTR_PRIVATE_KEY", owner_nsec)
|
||||
.output()
|
||||
.expect("spawn git")
|
||||
}
|
||||
|
||||
/// Run `git` with the Buzz credential helper and isolated config. Asserts the
|
||||
/// command succeeds; returns stdout.
|
||||
fn git(args: &[&str], cwd: &Path, owner_nsec: &str) -> String {
|
||||
let out = git_status(args, cwd, owner_nsec);
|
||||
assert!(
|
||||
out.status.success(),
|
||||
"git {:?} failed:\nstdout: {}\nstderr: {}",
|
||||
args,
|
||||
String::from_utf8_lossy(&out.stdout),
|
||||
String::from_utf8_lossy(&out.stderr)
|
||||
);
|
||||
String::from_utf8_lossy(&out.stdout).into_owned()
|
||||
}
|
||||
|
||||
struct GitS3Probe {
|
||||
bucket: Box<Bucket>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
struct PointerSnapshot {
|
||||
etag: String,
|
||||
digest: String,
|
||||
}
|
||||
|
||||
impl GitS3Probe {
|
||||
fn bucket(
|
||||
endpoint: String,
|
||||
access_key: &str,
|
||||
secret_key: &str,
|
||||
bucket_name: &str,
|
||||
region_name: String,
|
||||
addressing_style: S3AddressingStyle,
|
||||
) -> Box<Bucket> {
|
||||
let region = Region::Custom {
|
||||
region: region_name,
|
||||
endpoint,
|
||||
};
|
||||
let creds = Credentials::new(Some(access_key), Some(secret_key), None, None, None)
|
||||
.expect("S3 credentials");
|
||||
let bucket = Bucket::new(bucket_name, region, creds).expect("S3 bucket");
|
||||
match addressing_style {
|
||||
S3AddressingStyle::Path => bucket.with_path_style(),
|
||||
S3AddressingStyle::Virtual => bucket,
|
||||
}
|
||||
}
|
||||
|
||||
fn from_env() -> Self {
|
||||
// These E2E assertions inspect the relay's backing bucket directly, so
|
||||
// they must receive the same provider connection and URL style as the
|
||||
// relay. Unit/live MinIO probes in buzz-relay keep explicit local
|
||||
// fixtures and do not need provider overrides.
|
||||
let endpoint = std::env::var("BUZZ_S3_ENDPOINT")
|
||||
.unwrap_or_else(|_| "http://localhost:9000".to_string());
|
||||
let access_key =
|
||||
std::env::var("BUZZ_S3_ACCESS_KEY").unwrap_or_else(|_| "buzz_dev".to_string());
|
||||
let secret_key =
|
||||
std::env::var("BUZZ_S3_SECRET_KEY").unwrap_or_else(|_| "buzz_dev_secret".to_string());
|
||||
let bucket_name =
|
||||
std::env::var("BUZZ_S3_BUCKET").unwrap_or_else(|_| "buzz-media".to_string());
|
||||
let region_name =
|
||||
std::env::var("BUZZ_S3_REGION").unwrap_or_else(|_| "us-east-1".to_string());
|
||||
let addressing_style = std::env::var("BUZZ_S3_ADDRESSING_STYLE")
|
||||
.unwrap_or_else(|_| "path".to_string())
|
||||
.parse::<S3AddressingStyle>()
|
||||
.expect("BUZZ_S3_ADDRESSING_STYLE must be 'path' or 'virtual'");
|
||||
|
||||
let bucket = Self::bucket(
|
||||
endpoint,
|
||||
&access_key,
|
||||
&secret_key,
|
||||
&bucket_name,
|
||||
region_name,
|
||||
addressing_style,
|
||||
);
|
||||
Self { bucket }
|
||||
}
|
||||
|
||||
fn pointer_key(owner: &str, repo: &str) -> String {
|
||||
let repo = repo.strip_suffix(".git").unwrap_or(repo);
|
||||
if let Ok(community) = std::env::var("BUZZ_E2E_GIT_COMMUNITY_ID") {
|
||||
return format!("repos/{community}/{owner}/{repo}/pointer");
|
||||
}
|
||||
format!("repos/{owner}/{repo}/pointer")
|
||||
}
|
||||
|
||||
async fn pointer(&self, owner: &str, repo: &str) -> Option<PointerSnapshot> {
|
||||
let key = Self::pointer_key(owner, repo);
|
||||
match self.bucket.get_object(&key).await {
|
||||
Ok(resp) => {
|
||||
let etag = resp
|
||||
.headers()
|
||||
.iter()
|
||||
.find(|(k, _)| k.eq_ignore_ascii_case("etag"))
|
||||
.map(|(_, v)| v.to_string())
|
||||
.expect("pointer GET must include ETag");
|
||||
let digest = String::from_utf8(resp.to_vec()).expect("pointer body utf-8");
|
||||
assert_eq!(digest.len(), 64, "pointer body is manifest digest");
|
||||
assert!(digest.chars().all(|c| c.is_ascii_hexdigit()));
|
||||
Some(PointerSnapshot { etag, digest })
|
||||
}
|
||||
Err(s3::error::S3Error::HttpFailWithBody(404, _)) => None,
|
||||
Err(e) => panic!("GET S3 pointer {key} failed: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
async fn require_pointer(&self, owner: &str, repo: &str) -> PointerSnapshot {
|
||||
for _ in 0..40 {
|
||||
if let Some(p) = self.pointer(owner, repo).await {
|
||||
self.assert_manifest_exists(&p.digest).await;
|
||||
return p;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(250)).await;
|
||||
}
|
||||
panic!(
|
||||
"S3 manifest pointer {} never appeared; git may have fallen back to disk",
|
||||
Self::pointer_key(owner, repo)
|
||||
);
|
||||
}
|
||||
|
||||
async fn assert_manifest_exists(&self, digest: &str) {
|
||||
let key = format!("manifests/{digest}");
|
||||
match self.bucket.get_object(&key).await {
|
||||
Ok(_) => {}
|
||||
Err(e) => panic!("pointer named manifest {key}, but GET failed: {e}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn git_s3_probe_builds_both_addressing_styles() {
|
||||
let path = GitS3Probe::bucket(
|
||||
"https://storage.example".to_string(),
|
||||
"access",
|
||||
"secret",
|
||||
"buzz-media",
|
||||
"us-east-1".to_string(),
|
||||
S3AddressingStyle::Path,
|
||||
);
|
||||
assert!(path.is_path_style());
|
||||
assert_eq!(path.url(), "https://storage.example/buzz-media");
|
||||
|
||||
let virtual_hosted = GitS3Probe::bucket(
|
||||
"https://storage.example".to_string(),
|
||||
"access",
|
||||
"secret",
|
||||
"buzz-media",
|
||||
"auto".to_string(),
|
||||
S3AddressingStyle::Virtual,
|
||||
);
|
||||
assert!(virtual_hosted.is_subdomain_style());
|
||||
assert_eq!(virtual_hosted.url(), "https://buzz-media.storage.example");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "requires live relay + MinIO + git"]
|
||||
async fn git_clone_push_fetch_force_roundtrip() {
|
||||
use nostr::ToBech32;
|
||||
|
||||
let owner = Keys::generate();
|
||||
let owner_hex = owner.public_key().to_hex();
|
||||
let owner_nsec = owner.secret_key().to_bech32().unwrap();
|
||||
let repo = format!("e2e-git-{}", std::process::id());
|
||||
let s3 = GitS3Probe::from_env();
|
||||
|
||||
// Announce the repo (kind:30617) so the relay creates the bare repo + hook.
|
||||
// The `buzz-channel` binding is the repo's ACL: without it the read gate
|
||||
// 404s even for the owner (issue #3527), so bind to a channel the owner
|
||||
// just created (and therefore belongs to).
|
||||
let channel = create_test_channel(&owner).await;
|
||||
let announce = EventBuilder::new(Kind::from(30617), "")
|
||||
.tags(vec![
|
||||
Tag::parse(["d", &repo]).unwrap(),
|
||||
Tag::parse(["name", "e2e git repo"]).unwrap(),
|
||||
Tag::parse(["buzz-channel", &channel]).unwrap(),
|
||||
])
|
||||
.sign_with_keys(&owner)
|
||||
.unwrap();
|
||||
post_event(&announce).await;
|
||||
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
|
||||
|
||||
let tmp = tempdir();
|
||||
let url = format!("{}/git/{}/{}", relay_http_url(), owner_hex, repo);
|
||||
|
||||
// 1. Clone the empty repo.
|
||||
git(
|
||||
&["clone", "--quiet", &url, "clone1"],
|
||||
tmp.path(),
|
||||
&owner_nsec,
|
||||
);
|
||||
let clone1 = tmp.path().join("clone1");
|
||||
assert!(clone1.exists(), "clone1 created");
|
||||
let empty_pointer = s3.require_pointer(&owner_hex, &repo).await;
|
||||
|
||||
// 2. Push an initial commit.
|
||||
std::fs::write(clone1.join("README.md"), "hello\n").unwrap();
|
||||
git(&["add", "."], &clone1, &owner_nsec);
|
||||
git(
|
||||
&["commit", "--quiet", "-m", "initial"],
|
||||
&clone1,
|
||||
&owner_nsec,
|
||||
);
|
||||
git(&["branch", "-M", "main"], &clone1, &owner_nsec);
|
||||
git(&["push", "--quiet", "origin", "main"], &clone1, &owner_nsec);
|
||||
let p1 = s3.require_pointer(&owner_hex, &repo).await;
|
||||
assert_ne!(
|
||||
p1, empty_pointer,
|
||||
"initial push must advance S3 manifest pointer"
|
||||
);
|
||||
let sha1 = git(&["rev-parse", "main"], &clone1, &owner_nsec)
|
||||
.trim()
|
||||
.to_string();
|
||||
|
||||
// 3. A fresh clone observes the pushed content and exact SHA.
|
||||
git(
|
||||
&["clone", "--quiet", &url, "clone2"],
|
||||
tmp.path(),
|
||||
&owner_nsec,
|
||||
);
|
||||
let clone2 = tmp.path().join("clone2");
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(clone2.join("README.md")).unwrap(),
|
||||
"hello\n",
|
||||
"fresh clone sees pushed content"
|
||||
);
|
||||
assert_eq!(
|
||||
git(&["rev-parse", "main"], &clone2, &owner_nsec).trim(),
|
||||
sha1,
|
||||
"fresh clone main == pushed SHA"
|
||||
);
|
||||
|
||||
// 4. Second commit, push, pull into the other clone.
|
||||
std::fs::write(clone1.join("README.md"), "hello\nmore\n").unwrap();
|
||||
git(
|
||||
&["commit", "--quiet", "-am", "second"],
|
||||
&clone1,
|
||||
&owner_nsec,
|
||||
);
|
||||
git(&["push", "--quiet", "origin", "main"], &clone1, &owner_nsec);
|
||||
let p2 = s3.require_pointer(&owner_hex, &repo).await;
|
||||
assert_ne!(p2, p1, "second push must advance S3 manifest pointer");
|
||||
let sha2 = git(&["rev-parse", "main"], &clone1, &owner_nsec)
|
||||
.trim()
|
||||
.to_string();
|
||||
git(&["pull", "--quiet", "origin", "main"], &clone2, &owner_nsec);
|
||||
assert_eq!(
|
||||
git(&["rev-parse", "main"], &clone2, &owner_nsec).trim(),
|
||||
sha2,
|
||||
"clone2 fetched second commit"
|
||||
);
|
||||
|
||||
// 5. Force-push a rewritten history.
|
||||
git(&["reset", "--quiet", "--hard", &sha1], &clone1, &owner_nsec);
|
||||
std::fs::write(clone1.join("README.md"), "forced\n").unwrap();
|
||||
git(
|
||||
&["commit", "--quiet", "-am", "forced"],
|
||||
&clone1,
|
||||
&owner_nsec,
|
||||
);
|
||||
let sha_f = git(&["rev-parse", "main"], &clone1, &owner_nsec)
|
||||
.trim()
|
||||
.to_string();
|
||||
git(
|
||||
&["push", "--quiet", "--force", "origin", "main"],
|
||||
&clone1,
|
||||
&owner_nsec,
|
||||
);
|
||||
let p3 = s3.require_pointer(&owner_hex, &repo).await;
|
||||
assert_ne!(p3, p2, "force push must advance S3 manifest pointer");
|
||||
assert_ne!(sha_f, sha2);
|
||||
|
||||
// 6. A new clone after the force-push gets the rewritten history.
|
||||
git(
|
||||
&["clone", "--quiet", &url, "clone3"],
|
||||
tmp.path(),
|
||||
&owner_nsec,
|
||||
);
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(tmp.path().join("clone3/README.md")).unwrap(),
|
||||
"forced\n",
|
||||
"clone3 has force-pushed content"
|
||||
);
|
||||
|
||||
// 7. Tag push survives the round-trip.
|
||||
git(&["tag", "v1.0"], &clone1, &owner_nsec);
|
||||
git(&["push", "--quiet", "origin", "v1.0"], &clone1, &owner_nsec);
|
||||
let p4 = s3.require_pointer(&owner_hex, &repo).await;
|
||||
assert_ne!(p4, p3, "tag push must advance S3 manifest pointer");
|
||||
git(
|
||||
&["clone", "--quiet", &url, "clone4"],
|
||||
tmp.path(),
|
||||
&owner_nsec,
|
||||
);
|
||||
let tags = git(&["tag"], &tmp.path().join("clone4"), &owner_nsec);
|
||||
assert!(tags.contains("v1.0"), "tag v1.0 cloned back: {tags}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "requires live relay + MinIO + git"]
|
||||
async fn git_concurrent_push_one_wins_and_repo_recovers() {
|
||||
use nostr::ToBech32;
|
||||
|
||||
let owner = Keys::generate();
|
||||
let owner_hex = owner.public_key().to_hex();
|
||||
let owner_nsec = owner.secret_key().to_bech32().unwrap();
|
||||
let repo = format!("e2e-git-concurrent-{}", std::process::id());
|
||||
let s3 = GitS3Probe::from_env();
|
||||
|
||||
let channel = create_test_channel(&owner).await;
|
||||
let announce = EventBuilder::new(Kind::from(30617), "")
|
||||
.tags(vec![
|
||||
Tag::parse(["d", &repo]).unwrap(),
|
||||
Tag::parse(["name", "e2e concurrent git repo"]).unwrap(),
|
||||
Tag::parse(["buzz-channel", &channel]).unwrap(),
|
||||
])
|
||||
.sign_with_keys(&owner)
|
||||
.unwrap();
|
||||
post_event(&announce).await;
|
||||
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
|
||||
|
||||
let tmp = tempdir_named("buzz-e2e-git-concurrent");
|
||||
let url = format!("{}/git/{}/{}", relay_http_url(), owner_hex, repo);
|
||||
|
||||
git(&["clone", "--quiet", &url, "seed"], tmp.path(), &owner_nsec);
|
||||
let seed = tmp.path().join("seed");
|
||||
std::fs::write(seed.join("README.md"), "base\n").unwrap();
|
||||
git(&["add", "."], &seed, &owner_nsec);
|
||||
git(&["commit", "--quiet", "-m", "base"], &seed, &owner_nsec);
|
||||
git(&["branch", "-M", "main"], &seed, &owner_nsec);
|
||||
git(&["push", "--quiet", "origin", "main"], &seed, &owner_nsec);
|
||||
let base_pointer = s3.require_pointer(&owner_hex, &repo).await;
|
||||
let base_sha = git(&["rev-parse", "main"], &seed, &owner_nsec)
|
||||
.trim()
|
||||
.to_string();
|
||||
|
||||
let contenders = 8usize;
|
||||
let mut contenders_info = Vec::new();
|
||||
for i in 0..contenders {
|
||||
let dir = format!("c{i}");
|
||||
git(&["clone", "--quiet", &url, &dir], tmp.path(), &owner_nsec);
|
||||
let worktree = tmp.path().join(&dir);
|
||||
std::fs::write(
|
||||
worktree.join(format!("file-{i}.txt")),
|
||||
format!("winner? {i}\n"),
|
||||
)
|
||||
.unwrap();
|
||||
git(&["add", "."], &worktree, &owner_nsec);
|
||||
git(
|
||||
&["commit", "--quiet", "-m", &format!("contender {i}")],
|
||||
&worktree,
|
||||
&owner_nsec,
|
||||
);
|
||||
let sha = git(&["rev-parse", "main"], &worktree, &owner_nsec)
|
||||
.trim()
|
||||
.to_string();
|
||||
contenders_info.push((i, sha));
|
||||
}
|
||||
|
||||
let mut children = Vec::new();
|
||||
for i in 0..contenders {
|
||||
let worktree = tmp.path().join(format!("c{i}"));
|
||||
let owner_nsec = owner_nsec.clone();
|
||||
children.push(std::thread::spawn(move || {
|
||||
git_status(
|
||||
&["push", "--quiet", "origin", "main"],
|
||||
&worktree,
|
||||
&owner_nsec,
|
||||
)
|
||||
}));
|
||||
}
|
||||
|
||||
let mut successes = Vec::new();
|
||||
let mut failures = 0usize;
|
||||
for (i, child) in children.into_iter().enumerate() {
|
||||
let out = child.join().expect("push thread panicked");
|
||||
if out.status.success() {
|
||||
successes.push(i);
|
||||
} else {
|
||||
failures += 1;
|
||||
}
|
||||
}
|
||||
assert_eq!(successes.len(), 1, "exactly one concurrent push should win");
|
||||
assert_eq!(failures, contenders - 1, "the rest should lose cleanly");
|
||||
let winner_index = successes[0];
|
||||
let winner_sha = contenders_info
|
||||
.iter()
|
||||
.find_map(|(i, sha)| (*i == winner_index).then_some(sha.clone()))
|
||||
.expect("winner sha recorded");
|
||||
|
||||
let after_pointer = s3.require_pointer(&owner_hex, &repo).await;
|
||||
assert_ne!(
|
||||
after_pointer, base_pointer,
|
||||
"winning push must advance S3 manifest pointer"
|
||||
);
|
||||
|
||||
git(
|
||||
&["clone", "--quiet", &url, "after"],
|
||||
tmp.path(),
|
||||
&owner_nsec,
|
||||
);
|
||||
let after = tmp.path().join("after");
|
||||
let after_sha = git(&["rev-parse", "main"], &after, &owner_nsec)
|
||||
.trim()
|
||||
.to_string();
|
||||
assert_ne!(after_sha, base_sha, "winner advanced main");
|
||||
assert_eq!(
|
||||
after_sha, winner_sha,
|
||||
"published head must equal the successful contender's tip"
|
||||
);
|
||||
let log = git(
|
||||
&["log", "--oneline", "--decorate", "-1"],
|
||||
&after,
|
||||
&owner_nsec,
|
||||
);
|
||||
assert!(
|
||||
log.contains("contender"),
|
||||
"published head is one contender: {log}"
|
||||
);
|
||||
}
|
||||
|
||||
struct TempDir(PathBuf);
|
||||
impl TempDir {
|
||||
fn path(&self) -> &Path {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
impl Drop for TempDir {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
fn tempdir() -> TempDir {
|
||||
tempdir_named("buzz-e2e-git")
|
||||
}
|
||||
|
||||
fn tempdir_named(prefix: &str) -> TempDir {
|
||||
let mut p = std::env::temp_dir();
|
||||
p.push(format!("{prefix}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&p);
|
||||
std::fs::create_dir_all(&p).unwrap();
|
||||
TempDir(p)
|
||||
}
|
||||
@@ -0,0 +1,991 @@
|
||||
//! End-to-end tests for human owners editing/managing content authored by
|
||||
//! their agents — all five authorization predicate sites:
|
||||
//!
|
||||
//! - kind:40003 message edit (`validate_edit_ownership`)
|
||||
//! - kind:5 standard deletion (`validate_standard_deletion_event`)
|
||||
//! - kind:9005 DELETE_EVENT (`validate_admin_event` 9005 branch)
|
||||
//! - kind:9002 EDIT_METADATA privileged-tag branch
|
||||
//! - kind:9008 DELETE_GROUP
|
||||
//!
|
||||
//! The owner→agent relationship is established via NIP-OA: the agent
|
||||
//! connects and authenticates with an `auth` tag signed by the owner.
|
||||
//!
|
||||
//! # Running
|
||||
//!
|
||||
//! Start the relay, then run:
|
||||
//!
|
||||
//! ```text
|
||||
//! cargo test --test e2e_human_edit_agent_content -- --ignored
|
||||
//! ```
|
||||
|
||||
use buzz_sdk::nip_oa;
|
||||
use buzz_test_client::BuzzTestClient;
|
||||
use nostr::{EventBuilder, Keys, Kind, Tag};
|
||||
|
||||
fn relay_url() -> String {
|
||||
std::env::var("RELAY_URL").unwrap_or_else(|_| "ws://localhost:3000".to_string())
|
||||
}
|
||||
|
||||
fn relay_http_url() -> String {
|
||||
relay_url()
|
||||
.replace("wss://", "https://")
|
||||
.replace("ws://", "http://")
|
||||
.trim_end_matches('/')
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// Create a fresh channel owned by `owner_keys`, return the channel UUID string.
|
||||
async fn create_agent_owned_channel(agent_keys: &Keys) -> String {
|
||||
let http = reqwest::Client::new();
|
||||
let channel_uuid = uuid::Uuid::new_v4();
|
||||
|
||||
let event = EventBuilder::new(Kind::Custom(9007), "")
|
||||
.tags(vec![
|
||||
Tag::parse(["h", &channel_uuid.to_string()]).unwrap(),
|
||||
Tag::parse(["name", &format!("haec-test-{}", channel_uuid.simple())]).unwrap(),
|
||||
Tag::parse(["channel_type", "stream"]).unwrap(),
|
||||
Tag::parse(["visibility", "open"]).unwrap(),
|
||||
])
|
||||
.sign_with_keys(agent_keys)
|
||||
.unwrap();
|
||||
|
||||
let resp = http
|
||||
.post(format!("{}/events", relay_http_url()))
|
||||
.header("X-Pubkey", &agent_keys.public_key().to_hex())
|
||||
.header("Content-Type", "application/json")
|
||||
.body(serde_json::to_string(&event).unwrap())
|
||||
.send()
|
||||
.await
|
||||
.expect("submit create-channel event");
|
||||
assert!(
|
||||
resp.status().is_success(),
|
||||
"channel creation failed: {}",
|
||||
resp.status()
|
||||
);
|
||||
let body: serde_json::Value = resp.json().await.unwrap();
|
||||
assert!(
|
||||
body["accepted"].as_bool().unwrap_or(false),
|
||||
"channel creation not accepted: {body}"
|
||||
);
|
||||
channel_uuid.to_string()
|
||||
}
|
||||
|
||||
/// Build a NIP-OA auth tag for `agent_keys` signed by `owner_keys`.
|
||||
fn make_nip_oa_auth_tag(owner_keys: &Keys, agent_keys: &Keys) -> Tag {
|
||||
let tag_json = nip_oa::compute_auth_tag(owner_keys, &agent_keys.public_key(), "kind=9")
|
||||
.expect("compute_auth_tag");
|
||||
nip_oa::parse_auth_tag(&tag_json).expect("parse_auth_tag")
|
||||
}
|
||||
|
||||
/// Connect `agent_keys` to the relay with NIP-OA, establishing owner→agent in the DB.
|
||||
/// Returns the connected (authenticated) client for the agent.
|
||||
async fn connect_agent_with_owner(agent_keys: &Keys, owner_keys: &Keys) -> BuzzTestClient {
|
||||
let url = relay_url();
|
||||
let auth_tag = make_nip_oa_auth_tag(owner_keys, agent_keys);
|
||||
let mut client = BuzzTestClient::connect_unauthenticated(&url)
|
||||
.await
|
||||
.expect("connect agent unauthenticated");
|
||||
client
|
||||
.authenticate_with_nip_oa(agent_keys, &auth_tag)
|
||||
.await
|
||||
.expect("NIP-OA auth");
|
||||
client
|
||||
}
|
||||
|
||||
// ─── kind:40003 message edit ───────────────────────────────────────────────
|
||||
|
||||
/// Owner can edit a message authored by their agent via kind:40003.
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn test_owner_can_edit_agent_message() {
|
||||
let owner_keys = Keys::generate();
|
||||
let agent_keys = Keys::generate();
|
||||
let channel_id = create_agent_owned_channel(&agent_keys).await;
|
||||
|
||||
// Establish NIP-OA ownership in DB.
|
||||
let mut agent_client = connect_agent_with_owner(&agent_keys, &owner_keys).await;
|
||||
|
||||
// Agent sends a message.
|
||||
let content = format!("agent-msg-{}", uuid::Uuid::new_v4());
|
||||
let ok = agent_client
|
||||
.send_text_message(&agent_keys, &channel_id, &content, 9)
|
||||
.await
|
||||
.expect("agent send message");
|
||||
assert!(ok.accepted, "agent message rejected: {}", ok.message);
|
||||
let msg_event_id = ok.event_id;
|
||||
|
||||
// Owner sends a kind:40003 edit event targeting the agent's message.
|
||||
let mut owner_client = BuzzTestClient::connect(&relay_url(), &owner_keys)
|
||||
.await
|
||||
.expect("connect owner");
|
||||
|
||||
let edit_event = EventBuilder::new(Kind::Custom(40003), "corrected content")
|
||||
.tags(vec![
|
||||
Tag::parse(["e", &msg_event_id]).unwrap(),
|
||||
Tag::parse(["h", &channel_id]).unwrap(),
|
||||
])
|
||||
.sign_with_keys(&owner_keys)
|
||||
.unwrap();
|
||||
|
||||
let ok = owner_client
|
||||
.send_event(edit_event)
|
||||
.await
|
||||
.expect("send edit");
|
||||
assert!(
|
||||
ok.accepted,
|
||||
"owner edit of agent message rejected: {}",
|
||||
ok.message
|
||||
);
|
||||
|
||||
agent_client.disconnect().await.ok();
|
||||
owner_client.disconnect().await.ok();
|
||||
}
|
||||
|
||||
/// An unrelated third party cannot edit an agent's message.
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn test_third_party_cannot_edit_agent_message() {
|
||||
let owner_keys = Keys::generate();
|
||||
let agent_keys = Keys::generate();
|
||||
let third_party_keys = Keys::generate();
|
||||
let channel_id = create_agent_owned_channel(&agent_keys).await;
|
||||
|
||||
let mut agent_client = connect_agent_with_owner(&agent_keys, &owner_keys).await;
|
||||
|
||||
let content = format!("agent-msg-{}", uuid::Uuid::new_v4());
|
||||
let ok = agent_client
|
||||
.send_text_message(&agent_keys, &channel_id, &content, 9)
|
||||
.await
|
||||
.expect("agent send message");
|
||||
assert!(ok.accepted, "agent message rejected: {}", ok.message);
|
||||
let msg_event_id = ok.event_id;
|
||||
|
||||
let mut third_party_client = BuzzTestClient::connect(&relay_url(), &third_party_keys)
|
||||
.await
|
||||
.expect("connect third party");
|
||||
|
||||
let edit_event = EventBuilder::new(Kind::Custom(40003), "malicious edit")
|
||||
.tags(vec![
|
||||
Tag::parse(["e", &msg_event_id]).unwrap(),
|
||||
Tag::parse(["h", &channel_id]).unwrap(),
|
||||
])
|
||||
.sign_with_keys(&third_party_keys)
|
||||
.unwrap();
|
||||
|
||||
let ok = third_party_client
|
||||
.send_event(edit_event)
|
||||
.await
|
||||
.expect("send edit attempt");
|
||||
assert!(
|
||||
!ok.accepted,
|
||||
"third party should NOT be able to edit agent message, but was accepted"
|
||||
);
|
||||
|
||||
agent_client.disconnect().await.ok();
|
||||
third_party_client.disconnect().await.ok();
|
||||
}
|
||||
|
||||
/// The agent itself can still edit its own message (self-edit unchanged).
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn test_agent_can_self_edit_message() {
|
||||
let owner_keys = Keys::generate();
|
||||
let agent_keys = Keys::generate();
|
||||
let channel_id = create_agent_owned_channel(&agent_keys).await;
|
||||
|
||||
let mut agent_client = connect_agent_with_owner(&agent_keys, &owner_keys).await;
|
||||
|
||||
let content = format!("agent-msg-{}", uuid::Uuid::new_v4());
|
||||
let ok = agent_client
|
||||
.send_text_message(&agent_keys, &channel_id, &content, 9)
|
||||
.await
|
||||
.expect("agent send message");
|
||||
assert!(ok.accepted, "agent message rejected: {}", ok.message);
|
||||
let msg_event_id = ok.event_id;
|
||||
|
||||
let edit_event = EventBuilder::new(Kind::Custom(40003), "agent self-edit")
|
||||
.tags(vec![
|
||||
Tag::parse(["e", &msg_event_id]).unwrap(),
|
||||
Tag::parse(["h", &channel_id]).unwrap(),
|
||||
])
|
||||
.sign_with_keys(&agent_keys)
|
||||
.unwrap();
|
||||
|
||||
let ok = agent_client
|
||||
.send_event(edit_event)
|
||||
.await
|
||||
.expect("send edit");
|
||||
assert!(ok.accepted, "agent self-edit rejected: {}", ok.message);
|
||||
|
||||
agent_client.disconnect().await.ok();
|
||||
}
|
||||
|
||||
// ─── kind:9005 DELETE_EVENT ─────────────────────────────────────────────────
|
||||
|
||||
/// Owner can delete a message authored by their agent via kind:9005.
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn test_owner_can_delete_agent_message() {
|
||||
let owner_keys = Keys::generate();
|
||||
let agent_keys = Keys::generate();
|
||||
let channel_id = create_agent_owned_channel(&agent_keys).await;
|
||||
|
||||
let mut agent_client = connect_agent_with_owner(&agent_keys, &owner_keys).await;
|
||||
|
||||
let content = format!("agent-msg-{}", uuid::Uuid::new_v4());
|
||||
let ok = agent_client
|
||||
.send_text_message(&agent_keys, &channel_id, &content, 9)
|
||||
.await
|
||||
.expect("agent send message");
|
||||
assert!(ok.accepted, "agent message rejected: {}", ok.message);
|
||||
let msg_event_id = ok.event_id;
|
||||
|
||||
let mut owner_client = BuzzTestClient::connect(&relay_url(), &owner_keys)
|
||||
.await
|
||||
.expect("connect owner");
|
||||
|
||||
let delete_event = EventBuilder::new(Kind::Custom(9005), "")
|
||||
.tags(vec![
|
||||
Tag::parse(["e", &msg_event_id]).unwrap(),
|
||||
Tag::parse(["h", &channel_id]).unwrap(),
|
||||
])
|
||||
.sign_with_keys(&owner_keys)
|
||||
.unwrap();
|
||||
|
||||
let ok = owner_client
|
||||
.send_event(delete_event)
|
||||
.await
|
||||
.expect("send delete");
|
||||
assert!(
|
||||
ok.accepted,
|
||||
"owner delete of agent message rejected: {}",
|
||||
ok.message
|
||||
);
|
||||
|
||||
agent_client.disconnect().await.ok();
|
||||
owner_client.disconnect().await.ok();
|
||||
}
|
||||
|
||||
/// An unrelated third party cannot delete an agent's message.
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn test_third_party_cannot_delete_agent_message() {
|
||||
let owner_keys = Keys::generate();
|
||||
let agent_keys = Keys::generate();
|
||||
let third_party_keys = Keys::generate();
|
||||
let channel_id = create_agent_owned_channel(&agent_keys).await;
|
||||
|
||||
let mut agent_client = connect_agent_with_owner(&agent_keys, &owner_keys).await;
|
||||
|
||||
let content = format!("agent-msg-{}", uuid::Uuid::new_v4());
|
||||
let ok = agent_client
|
||||
.send_text_message(&agent_keys, &channel_id, &content, 9)
|
||||
.await
|
||||
.expect("agent send message");
|
||||
assert!(ok.accepted, "agent message rejected: {}", ok.message);
|
||||
let msg_event_id = ok.event_id;
|
||||
|
||||
let mut third_party_client = BuzzTestClient::connect(&relay_url(), &third_party_keys)
|
||||
.await
|
||||
.expect("connect third party");
|
||||
|
||||
let delete_event = EventBuilder::new(Kind::Custom(9005), "")
|
||||
.tags(vec![
|
||||
Tag::parse(["e", &msg_event_id]).unwrap(),
|
||||
Tag::parse(["h", &channel_id]).unwrap(),
|
||||
])
|
||||
.sign_with_keys(&third_party_keys)
|
||||
.unwrap();
|
||||
|
||||
let ok = third_party_client
|
||||
.send_event(delete_event)
|
||||
.await
|
||||
.expect("send delete attempt");
|
||||
assert!(
|
||||
!ok.accepted,
|
||||
"third party should NOT be able to delete agent message, but was accepted"
|
||||
);
|
||||
|
||||
agent_client.disconnect().await.ok();
|
||||
third_party_client.disconnect().await.ok();
|
||||
}
|
||||
|
||||
// ─── kind:5 standard deletion ───────────────────────────────────────────────
|
||||
|
||||
/// Owner can delete a message authored by their agent via standard NIP-09
|
||||
/// kind:5 (the deletion kind the desktop app sends).
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn test_owner_can_delete_agent_message_kind5() {
|
||||
let owner_keys = Keys::generate();
|
||||
let agent_keys = Keys::generate();
|
||||
let channel_id = create_agent_owned_channel(&agent_keys).await;
|
||||
|
||||
let mut agent_client = connect_agent_with_owner(&agent_keys, &owner_keys).await;
|
||||
|
||||
let content = format!("agent-msg-{}", uuid::Uuid::new_v4());
|
||||
let ok = agent_client
|
||||
.send_text_message(&agent_keys, &channel_id, &content, 9)
|
||||
.await
|
||||
.expect("agent send message");
|
||||
assert!(ok.accepted, "agent message rejected: {}", ok.message);
|
||||
let msg_event_id = ok.event_id;
|
||||
|
||||
let mut owner_client = BuzzTestClient::connect(&relay_url(), &owner_keys)
|
||||
.await
|
||||
.expect("connect owner");
|
||||
|
||||
let delete_event = EventBuilder::new(Kind::Custom(5), "")
|
||||
.tags(vec![
|
||||
Tag::parse(["e", &msg_event_id]).unwrap(),
|
||||
Tag::parse(["h", &channel_id]).unwrap(),
|
||||
])
|
||||
.sign_with_keys(&owner_keys)
|
||||
.unwrap();
|
||||
|
||||
let ok = owner_client
|
||||
.send_event(delete_event)
|
||||
.await
|
||||
.expect("send delete");
|
||||
assert!(
|
||||
ok.accepted,
|
||||
"owner kind:5 delete of agent message rejected: {}",
|
||||
ok.message
|
||||
);
|
||||
|
||||
agent_client.disconnect().await.ok();
|
||||
owner_client.disconnect().await.ok();
|
||||
}
|
||||
|
||||
/// An unrelated third party cannot delete an agent's message via kind:5.
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn test_third_party_cannot_delete_agent_message_kind5() {
|
||||
let owner_keys = Keys::generate();
|
||||
let agent_keys = Keys::generate();
|
||||
let third_party_keys = Keys::generate();
|
||||
let channel_id = create_agent_owned_channel(&agent_keys).await;
|
||||
|
||||
let mut agent_client = connect_agent_with_owner(&agent_keys, &owner_keys).await;
|
||||
|
||||
let content = format!("agent-msg-{}", uuid::Uuid::new_v4());
|
||||
let ok = agent_client
|
||||
.send_text_message(&agent_keys, &channel_id, &content, 9)
|
||||
.await
|
||||
.expect("agent send message");
|
||||
assert!(ok.accepted, "agent message rejected: {}", ok.message);
|
||||
let msg_event_id = ok.event_id;
|
||||
|
||||
let mut third_party_client = BuzzTestClient::connect(&relay_url(), &third_party_keys)
|
||||
.await
|
||||
.expect("connect third party");
|
||||
|
||||
let delete_event = EventBuilder::new(Kind::Custom(5), "")
|
||||
.tags(vec![
|
||||
Tag::parse(["e", &msg_event_id]).unwrap(),
|
||||
Tag::parse(["h", &channel_id]).unwrap(),
|
||||
])
|
||||
.sign_with_keys(&third_party_keys)
|
||||
.unwrap();
|
||||
|
||||
let ok = third_party_client
|
||||
.send_event(delete_event)
|
||||
.await
|
||||
.expect("send delete attempt");
|
||||
assert!(
|
||||
!ok.accepted,
|
||||
"third party should NOT be able to kind:5-delete agent message, but was accepted"
|
||||
);
|
||||
|
||||
agent_client.disconnect().await.ok();
|
||||
third_party_client.disconnect().await.ok();
|
||||
}
|
||||
|
||||
// ─── kind:9002 EDIT_METADATA ────────────────────────────────────────────────
|
||||
|
||||
/// Owner can edit metadata (name/archived) of a channel owned by their agent,
|
||||
/// even when the owner is not a channel member.
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn test_owner_can_edit_agent_channel_metadata() {
|
||||
let owner_keys = Keys::generate();
|
||||
let agent_keys = Keys::generate();
|
||||
|
||||
// Agent creates the channel (agent is the channel owner-member).
|
||||
let channel_id = create_agent_owned_channel(&agent_keys).await;
|
||||
|
||||
// Establish NIP-OA ownership.
|
||||
let agent_client = connect_agent_with_owner(&agent_keys, &owner_keys).await;
|
||||
agent_client.disconnect().await.ok();
|
||||
|
||||
// Owner sends kind:9002 to rename the channel — owner is NOT a member.
|
||||
let mut owner_client = BuzzTestClient::connect(&relay_url(), &owner_keys)
|
||||
.await
|
||||
.expect("connect owner");
|
||||
|
||||
let edit_event = EventBuilder::new(Kind::Custom(9002), "")
|
||||
.tags(vec![
|
||||
Tag::parse(["h", &channel_id]).unwrap(),
|
||||
Tag::parse(["name", "owner-renamed-channel"]).unwrap(),
|
||||
])
|
||||
.sign_with_keys(&owner_keys)
|
||||
.unwrap();
|
||||
|
||||
let ok = owner_client
|
||||
.send_event(edit_event)
|
||||
.await
|
||||
.expect("send edit metadata");
|
||||
assert!(
|
||||
ok.accepted,
|
||||
"owner edit of agent channel metadata rejected: {}",
|
||||
ok.message
|
||||
);
|
||||
|
||||
owner_client.disconnect().await.ok();
|
||||
}
|
||||
|
||||
/// Owner can archive an agent's channel via kind:9002.
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn test_owner_can_archive_agent_channel() {
|
||||
let owner_keys = Keys::generate();
|
||||
let agent_keys = Keys::generate();
|
||||
let channel_id = create_agent_owned_channel(&agent_keys).await;
|
||||
|
||||
let agent_client = connect_agent_with_owner(&agent_keys, &owner_keys).await;
|
||||
agent_client.disconnect().await.ok();
|
||||
|
||||
let mut owner_client = BuzzTestClient::connect(&relay_url(), &owner_keys)
|
||||
.await
|
||||
.expect("connect owner");
|
||||
|
||||
let archive_event = EventBuilder::new(Kind::Custom(9002), "")
|
||||
.tags(vec![
|
||||
Tag::parse(["h", &channel_id]).unwrap(),
|
||||
Tag::parse(["archived", "true"]).unwrap(),
|
||||
])
|
||||
.sign_with_keys(&owner_keys)
|
||||
.unwrap();
|
||||
|
||||
let ok = owner_client
|
||||
.send_event(archive_event)
|
||||
.await
|
||||
.expect("send archive");
|
||||
assert!(
|
||||
ok.accepted,
|
||||
"owner archive of agent channel rejected: {}",
|
||||
ok.message
|
||||
);
|
||||
|
||||
owner_client.disconnect().await.ok();
|
||||
}
|
||||
|
||||
/// Unrelated third party cannot edit metadata of an agent's channel.
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn test_third_party_cannot_edit_agent_channel_metadata() {
|
||||
let owner_keys = Keys::generate();
|
||||
let agent_keys = Keys::generate();
|
||||
let third_party_keys = Keys::generate();
|
||||
let channel_id = create_agent_owned_channel(&agent_keys).await;
|
||||
|
||||
let agent_client = connect_agent_with_owner(&agent_keys, &owner_keys).await;
|
||||
agent_client.disconnect().await.ok();
|
||||
|
||||
let mut third_party_client = BuzzTestClient::connect(&relay_url(), &third_party_keys)
|
||||
.await
|
||||
.expect("connect third party");
|
||||
|
||||
let edit_event = EventBuilder::new(Kind::Custom(9002), "")
|
||||
.tags(vec![
|
||||
Tag::parse(["h", &channel_id]).unwrap(),
|
||||
Tag::parse(["name", "hijacked-name"]).unwrap(),
|
||||
])
|
||||
.sign_with_keys(&third_party_keys)
|
||||
.unwrap();
|
||||
|
||||
let ok = third_party_client
|
||||
.send_event(edit_event)
|
||||
.await
|
||||
.expect("send edit attempt");
|
||||
assert!(
|
||||
!ok.accepted,
|
||||
"third party should NOT be able to edit agent channel metadata, but was accepted"
|
||||
);
|
||||
|
||||
third_party_client.disconnect().await.ok();
|
||||
}
|
||||
|
||||
// ─── kind:9008 DELETE_GROUP ─────────────────────────────────────────────────
|
||||
|
||||
/// Owner can delete a channel owned by their agent via kind:9008,
|
||||
/// even when the owner is not a channel member.
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn test_owner_can_delete_agent_channel() {
|
||||
let owner_keys = Keys::generate();
|
||||
let agent_keys = Keys::generate();
|
||||
let channel_id = create_agent_owned_channel(&agent_keys).await;
|
||||
|
||||
// Establish NIP-OA ownership.
|
||||
let agent_client = connect_agent_with_owner(&agent_keys, &owner_keys).await;
|
||||
agent_client.disconnect().await.ok();
|
||||
|
||||
// Owner sends kind:9008 to delete the channel — owner is NOT a member.
|
||||
let mut owner_client = BuzzTestClient::connect(&relay_url(), &owner_keys)
|
||||
.await
|
||||
.expect("connect owner");
|
||||
|
||||
let delete_event = EventBuilder::new(Kind::Custom(9008), "")
|
||||
.tags(vec![Tag::parse(["h", &channel_id]).unwrap()])
|
||||
.sign_with_keys(&owner_keys)
|
||||
.unwrap();
|
||||
|
||||
let ok = owner_client
|
||||
.send_event(delete_event)
|
||||
.await
|
||||
.expect("send delete group");
|
||||
assert!(
|
||||
ok.accepted,
|
||||
"owner delete of agent channel rejected: {}",
|
||||
ok.message
|
||||
);
|
||||
|
||||
owner_client.disconnect().await.ok();
|
||||
}
|
||||
|
||||
/// Unrelated third party cannot delete an agent's channel.
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn test_third_party_cannot_delete_agent_channel() {
|
||||
let owner_keys = Keys::generate();
|
||||
let agent_keys = Keys::generate();
|
||||
let third_party_keys = Keys::generate();
|
||||
let channel_id = create_agent_owned_channel(&agent_keys).await;
|
||||
|
||||
let agent_client = connect_agent_with_owner(&agent_keys, &owner_keys).await;
|
||||
agent_client.disconnect().await.ok();
|
||||
|
||||
let mut third_party_client = BuzzTestClient::connect(&relay_url(), &third_party_keys)
|
||||
.await
|
||||
.expect("connect third party");
|
||||
|
||||
let delete_event = EventBuilder::new(Kind::Custom(9008), "")
|
||||
.tags(vec![Tag::parse(["h", &channel_id]).unwrap()])
|
||||
.sign_with_keys(&third_party_keys)
|
||||
.unwrap();
|
||||
|
||||
let ok = third_party_client
|
||||
.send_event(delete_event)
|
||||
.await
|
||||
.expect("send delete attempt");
|
||||
assert!(
|
||||
!ok.accepted,
|
||||
"third party should NOT be able to delete agent channel, but was accepted"
|
||||
);
|
||||
|
||||
third_party_client.disconnect().await.ok();
|
||||
}
|
||||
|
||||
/// Create a fresh **private** channel owned by `agent_keys`, return the channel UUID string.
|
||||
async fn create_private_agent_owned_channel(agent_keys: &Keys) -> String {
|
||||
let http = reqwest::Client::new();
|
||||
let channel_uuid = uuid::Uuid::new_v4();
|
||||
|
||||
let event = EventBuilder::new(Kind::Custom(9007), "")
|
||||
.tags(vec![
|
||||
Tag::parse(["h", &channel_uuid.to_string()]).unwrap(),
|
||||
Tag::parse([
|
||||
"name",
|
||||
&format!("haec-private-test-{}", channel_uuid.simple()),
|
||||
])
|
||||
.unwrap(),
|
||||
Tag::parse(["channel_type", "stream"]).unwrap(),
|
||||
Tag::parse(["visibility", "private"]).unwrap(),
|
||||
])
|
||||
.sign_with_keys(agent_keys)
|
||||
.unwrap();
|
||||
|
||||
let resp = http
|
||||
.post(format!("{}/events", relay_http_url()))
|
||||
.header("X-Pubkey", &agent_keys.public_key().to_hex())
|
||||
.header("Content-Type", "application/json")
|
||||
.body(serde_json::to_string(&event).unwrap())
|
||||
.send()
|
||||
.await
|
||||
.expect("submit create-private-channel event");
|
||||
assert!(
|
||||
resp.status().is_success(),
|
||||
"private channel creation failed: {}",
|
||||
resp.status()
|
||||
);
|
||||
let body: serde_json::Value = resp.json().await.unwrap();
|
||||
assert!(
|
||||
body["accepted"].as_bool().unwrap_or(false),
|
||||
"private channel creation not accepted: {body}"
|
||||
);
|
||||
channel_uuid.to_string()
|
||||
}
|
||||
|
||||
// ─── Private-channel coverage (Fix 1 regression guard) ──────────────────────
|
||||
//
|
||||
// These tests verify that the membership-gate bypass works on private channels —
|
||||
// i.e., a non-member owning human can act on private agent-owned content.
|
||||
// Previously all four predicates were unreachable for private channels because
|
||||
// check_channel_membership rejected non-members before the per-kind validators ran.
|
||||
|
||||
/// Owner can edit a message in a private agent-owned channel (non-member owner allowed).
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn test_owner_can_edit_agent_message_in_private_channel() {
|
||||
let owner_keys = Keys::generate();
|
||||
let agent_keys = Keys::generate();
|
||||
let channel_id = create_private_agent_owned_channel(&agent_keys).await;
|
||||
|
||||
// Establish NIP-OA ownership; agent sends a message.
|
||||
let mut agent_client = connect_agent_with_owner(&agent_keys, &owner_keys).await;
|
||||
let content = format!("private-agent-msg-{}", uuid::Uuid::new_v4());
|
||||
let ok = agent_client
|
||||
.send_text_message(&agent_keys, &channel_id, &content, 9)
|
||||
.await
|
||||
.expect("agent send message to private channel");
|
||||
assert!(
|
||||
ok.accepted,
|
||||
"agent message to private channel rejected: {}",
|
||||
ok.message
|
||||
);
|
||||
let msg_event_id = ok.event_id;
|
||||
agent_client.disconnect().await.ok();
|
||||
|
||||
// Owner (not a channel member) edits the agent's message.
|
||||
let mut owner_client = BuzzTestClient::connect(&relay_url(), &owner_keys)
|
||||
.await
|
||||
.expect("connect owner");
|
||||
let edit_event = EventBuilder::new(Kind::Custom(40003), "edited content")
|
||||
.tags(vec![
|
||||
Tag::parse(["e", &msg_event_id]).unwrap(),
|
||||
Tag::parse(["h", &channel_id]).unwrap(),
|
||||
])
|
||||
.sign_with_keys(&owner_keys)
|
||||
.unwrap();
|
||||
let ok = owner_client
|
||||
.send_event(edit_event)
|
||||
.await
|
||||
.expect("send edit");
|
||||
assert!(
|
||||
ok.accepted,
|
||||
"owner edit of agent message in private channel rejected (membership gate not bypassed): {}",
|
||||
ok.message
|
||||
);
|
||||
owner_client.disconnect().await.ok();
|
||||
}
|
||||
|
||||
/// Owner can delete a message in a private agent-owned channel (non-member owner allowed).
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn test_owner_can_delete_agent_message_in_private_channel() {
|
||||
let owner_keys = Keys::generate();
|
||||
let agent_keys = Keys::generate();
|
||||
let channel_id = create_private_agent_owned_channel(&agent_keys).await;
|
||||
|
||||
let mut agent_client = connect_agent_with_owner(&agent_keys, &owner_keys).await;
|
||||
let content = format!("private-agent-msg-{}", uuid::Uuid::new_v4());
|
||||
let ok = agent_client
|
||||
.send_text_message(&agent_keys, &channel_id, &content, 9)
|
||||
.await
|
||||
.expect("agent send message to private channel");
|
||||
assert!(
|
||||
ok.accepted,
|
||||
"agent message to private channel rejected: {}",
|
||||
ok.message
|
||||
);
|
||||
let msg_event_id = ok.event_id;
|
||||
agent_client.disconnect().await.ok();
|
||||
|
||||
let mut owner_client = BuzzTestClient::connect(&relay_url(), &owner_keys)
|
||||
.await
|
||||
.expect("connect owner");
|
||||
let delete_event = EventBuilder::new(Kind::Custom(9005), "")
|
||||
.tags(vec![
|
||||
Tag::parse(["e", &msg_event_id]).unwrap(),
|
||||
Tag::parse(["h", &channel_id]).unwrap(),
|
||||
])
|
||||
.sign_with_keys(&owner_keys)
|
||||
.unwrap();
|
||||
let ok = owner_client
|
||||
.send_event(delete_event)
|
||||
.await
|
||||
.expect("send delete");
|
||||
assert!(
|
||||
ok.accepted,
|
||||
"owner delete of agent message in private channel rejected (membership gate not bypassed): {}",
|
||||
ok.message
|
||||
);
|
||||
owner_client.disconnect().await.ok();
|
||||
}
|
||||
|
||||
/// Owner can edit metadata of a private agent-owned channel (non-member owner allowed).
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn test_owner_can_edit_metadata_of_private_agent_channel() {
|
||||
let owner_keys = Keys::generate();
|
||||
let agent_keys = Keys::generate();
|
||||
let channel_id = create_private_agent_owned_channel(&agent_keys).await;
|
||||
|
||||
let agent_client = connect_agent_with_owner(&agent_keys, &owner_keys).await;
|
||||
agent_client.disconnect().await.ok();
|
||||
|
||||
let mut owner_client = BuzzTestClient::connect(&relay_url(), &owner_keys)
|
||||
.await
|
||||
.expect("connect owner");
|
||||
let edit_event = EventBuilder::new(Kind::Custom(9002), "")
|
||||
.tags(vec![
|
||||
Tag::parse(["h", &channel_id]).unwrap(),
|
||||
Tag::parse(["name", "owner-renamed-private-channel"]).unwrap(),
|
||||
])
|
||||
.sign_with_keys(&owner_keys)
|
||||
.unwrap();
|
||||
let ok = owner_client
|
||||
.send_event(edit_event)
|
||||
.await
|
||||
.expect("send edit metadata");
|
||||
assert!(
|
||||
ok.accepted,
|
||||
"owner edit of private agent channel metadata rejected (membership gate not bypassed): {}",
|
||||
ok.message
|
||||
);
|
||||
owner_client.disconnect().await.ok();
|
||||
}
|
||||
|
||||
/// Owner can delete a private agent-owned channel (non-member owner allowed).
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn test_owner_can_delete_private_agent_channel() {
|
||||
let owner_keys = Keys::generate();
|
||||
let agent_keys = Keys::generate();
|
||||
let channel_id = create_private_agent_owned_channel(&agent_keys).await;
|
||||
|
||||
let agent_client = connect_agent_with_owner(&agent_keys, &owner_keys).await;
|
||||
agent_client.disconnect().await.ok();
|
||||
|
||||
let mut owner_client = BuzzTestClient::connect(&relay_url(), &owner_keys)
|
||||
.await
|
||||
.expect("connect owner");
|
||||
let delete_event = EventBuilder::new(Kind::Custom(9008), "")
|
||||
.tags(vec![Tag::parse(["h", &channel_id]).unwrap()])
|
||||
.sign_with_keys(&owner_keys)
|
||||
.unwrap();
|
||||
let ok = owner_client
|
||||
.send_event(delete_event)
|
||||
.await
|
||||
.expect("send delete group");
|
||||
assert!(
|
||||
ok.accepted,
|
||||
"owner delete of private agent channel rejected (membership gate not bypassed): {}",
|
||||
ok.message
|
||||
);
|
||||
owner_client.disconnect().await.ok();
|
||||
}
|
||||
|
||||
/// Agent itself can still delete its own channel (self-delete unchanged).
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn test_agent_can_self_delete_channel() {
|
||||
let owner_keys = Keys::generate();
|
||||
let agent_keys = Keys::generate();
|
||||
let channel_id = create_agent_owned_channel(&agent_keys).await;
|
||||
|
||||
let mut agent_client = connect_agent_with_owner(&agent_keys, &owner_keys).await;
|
||||
|
||||
let delete_event = EventBuilder::new(Kind::Custom(9008), "")
|
||||
.tags(vec![Tag::parse(["h", &channel_id]).unwrap()])
|
||||
.sign_with_keys(&agent_keys)
|
||||
.unwrap();
|
||||
|
||||
let ok = agent_client
|
||||
.send_event(delete_event)
|
||||
.await
|
||||
.expect("send self-delete group");
|
||||
assert!(
|
||||
ok.accepted,
|
||||
"agent self-delete of own channel rejected: {}",
|
||||
ok.message
|
||||
);
|
||||
|
||||
agent_client.disconnect().await.ok();
|
||||
}
|
||||
|
||||
// ─── Removed-author rejection tests (Option A regression guard) ─────────────
|
||||
//
|
||||
// These tests verify that removing a user from a private channel revokes their
|
||||
// ability to edit or delete their own historical messages. Before Option A,
|
||||
// adding 40003/9005 to skip_membership also widened the self-author fast-path,
|
||||
// allowing removed users to mutate private-channel history.
|
||||
|
||||
/// A user removed from a private channel CANNOT edit their own old messages.
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn test_removed_author_cannot_edit_own_message_in_private_channel() {
|
||||
let owner_keys = Keys::generate();
|
||||
let agent_keys = Keys::generate();
|
||||
let victim_keys = Keys::generate();
|
||||
let channel_id = create_private_agent_owned_channel(&agent_keys).await;
|
||||
|
||||
// Establish NIP-OA ownership; agent is now in the channel.
|
||||
let mut agent_client = connect_agent_with_owner(&agent_keys, &owner_keys).await;
|
||||
|
||||
// Agent adds victim to the private channel via kind:9000 (PUT_USER).
|
||||
let add_event = EventBuilder::new(Kind::Custom(9000), "")
|
||||
.tags(vec![
|
||||
Tag::parse(["h", &channel_id]).unwrap(),
|
||||
Tag::parse(["p", &victim_keys.public_key().to_hex()]).unwrap(),
|
||||
])
|
||||
.sign_with_keys(&agent_keys)
|
||||
.unwrap();
|
||||
let ok = agent_client
|
||||
.send_event(add_event)
|
||||
.await
|
||||
.expect("send PUT_USER");
|
||||
assert!(
|
||||
ok.accepted,
|
||||
"agent failed to add victim to private channel: {}",
|
||||
ok.message
|
||||
);
|
||||
|
||||
// Victim connects and sends a message while still a member.
|
||||
let mut victim_client = BuzzTestClient::connect(&relay_url(), &victim_keys)
|
||||
.await
|
||||
.expect("connect victim");
|
||||
let content = format!("victim-msg-{}", uuid::Uuid::new_v4());
|
||||
let ok = victim_client
|
||||
.send_text_message(&victim_keys, &channel_id, &content, 9)
|
||||
.await
|
||||
.expect("victim send message");
|
||||
assert!(
|
||||
ok.accepted,
|
||||
"victim message rejected while still a member: {}",
|
||||
ok.message
|
||||
);
|
||||
let msg_event_id = ok.event_id;
|
||||
|
||||
// Agent removes victim from the channel via kind:9001 (REMOVE_USER).
|
||||
let remove_event = EventBuilder::new(Kind::Custom(9001), "")
|
||||
.tags(vec![
|
||||
Tag::parse(["h", &channel_id]).unwrap(),
|
||||
Tag::parse(["p", &victim_keys.public_key().to_hex()]).unwrap(),
|
||||
])
|
||||
.sign_with_keys(&agent_keys)
|
||||
.unwrap();
|
||||
let ok = agent_client
|
||||
.send_event(remove_event)
|
||||
.await
|
||||
.expect("send REMOVE_USER");
|
||||
assert!(
|
||||
ok.accepted,
|
||||
"agent failed to remove victim from private channel: {}",
|
||||
ok.message
|
||||
);
|
||||
agent_client.disconnect().await.ok();
|
||||
|
||||
// Victim (now removed) attempts to edit their old message — must be rejected.
|
||||
let edit_event = EventBuilder::new(Kind::Custom(40003), "edited content after removal")
|
||||
.tags(vec![
|
||||
Tag::parse(["e", &msg_event_id]).unwrap(),
|
||||
Tag::parse(["h", &channel_id]).unwrap(),
|
||||
])
|
||||
.sign_with_keys(&victim_keys)
|
||||
.unwrap();
|
||||
let ok = victim_client
|
||||
.send_event(edit_event)
|
||||
.await
|
||||
.expect("send edit attempt");
|
||||
assert!(
|
||||
!ok.accepted,
|
||||
"removed author should NOT be able to edit old message in private channel, but was accepted"
|
||||
);
|
||||
victim_client.disconnect().await.ok();
|
||||
}
|
||||
|
||||
/// A user removed from a private channel CANNOT delete their own old messages.
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn test_removed_author_cannot_delete_own_message_in_private_channel() {
|
||||
let owner_keys = Keys::generate();
|
||||
let agent_keys = Keys::generate();
|
||||
let victim_keys = Keys::generate();
|
||||
let channel_id = create_private_agent_owned_channel(&agent_keys).await;
|
||||
|
||||
// Establish NIP-OA ownership; agent is now in the channel.
|
||||
let mut agent_client = connect_agent_with_owner(&agent_keys, &owner_keys).await;
|
||||
|
||||
// Agent adds victim to the private channel via kind:9000 (PUT_USER).
|
||||
let add_event = EventBuilder::new(Kind::Custom(9000), "")
|
||||
.tags(vec![
|
||||
Tag::parse(["h", &channel_id]).unwrap(),
|
||||
Tag::parse(["p", &victim_keys.public_key().to_hex()]).unwrap(),
|
||||
])
|
||||
.sign_with_keys(&agent_keys)
|
||||
.unwrap();
|
||||
let ok = agent_client
|
||||
.send_event(add_event)
|
||||
.await
|
||||
.expect("send PUT_USER");
|
||||
assert!(
|
||||
ok.accepted,
|
||||
"agent failed to add victim to private channel: {}",
|
||||
ok.message
|
||||
);
|
||||
|
||||
// Victim connects and sends a message while still a member.
|
||||
let mut victim_client = BuzzTestClient::connect(&relay_url(), &victim_keys)
|
||||
.await
|
||||
.expect("connect victim");
|
||||
let content = format!("victim-msg-{}", uuid::Uuid::new_v4());
|
||||
let ok = victim_client
|
||||
.send_text_message(&victim_keys, &channel_id, &content, 9)
|
||||
.await
|
||||
.expect("victim send message");
|
||||
assert!(
|
||||
ok.accepted,
|
||||
"victim message rejected while still a member: {}",
|
||||
ok.message
|
||||
);
|
||||
let msg_event_id = ok.event_id;
|
||||
|
||||
// Agent removes victim from the channel via kind:9001 (REMOVE_USER).
|
||||
let remove_event = EventBuilder::new(Kind::Custom(9001), "")
|
||||
.tags(vec![
|
||||
Tag::parse(["h", &channel_id]).unwrap(),
|
||||
Tag::parse(["p", &victim_keys.public_key().to_hex()]).unwrap(),
|
||||
])
|
||||
.sign_with_keys(&agent_keys)
|
||||
.unwrap();
|
||||
let ok = agent_client
|
||||
.send_event(remove_event)
|
||||
.await
|
||||
.expect("send REMOVE_USER");
|
||||
assert!(
|
||||
ok.accepted,
|
||||
"agent failed to remove victim from private channel: {}",
|
||||
ok.message
|
||||
);
|
||||
agent_client.disconnect().await.ok();
|
||||
|
||||
// Victim (now removed) attempts to delete their old message — must be rejected.
|
||||
let delete_event = EventBuilder::new(Kind::Custom(9005), "")
|
||||
.tags(vec![
|
||||
Tag::parse(["e", &msg_event_id]).unwrap(),
|
||||
Tag::parse(["h", &channel_id]).unwrap(),
|
||||
])
|
||||
.sign_with_keys(&victim_keys)
|
||||
.unwrap();
|
||||
let ok = victim_client
|
||||
.send_event(delete_event)
|
||||
.await
|
||||
.expect("send delete attempt");
|
||||
assert!(
|
||||
!ok.accepted,
|
||||
"removed author should NOT be able to delete old message in private channel, but was accepted"
|
||||
);
|
||||
victim_client.disconnect().await.ok();
|
||||
}
|
||||
@@ -0,0 +1,538 @@
|
||||
//! End-to-end tests for NIP-23 long-form content (kind:30023).
|
||||
//!
|
||||
//! These tests require a running relay instance. By default they are marked
|
||||
//! `#[ignore]` so that `cargo test` does not fail in CI when the relay is not
|
||||
//! available.
|
||||
//!
|
||||
//! # Running
|
||||
//!
|
||||
//! Start the relay, then run:
|
||||
//!
|
||||
//! ```text
|
||||
//! cargo test --test e2e_long_form -- --ignored
|
||||
//! ```
|
||||
//!
|
||||
//! Override the relay URL with the `RELAY_URL` environment variable:
|
||||
//!
|
||||
//! ```text
|
||||
//! RELAY_URL=ws://relay.example.com cargo test --test e2e_long_form -- --ignored
|
||||
//! ```
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use buzz_test_client::BuzzTestClient;
|
||||
use nostr::{Alphabet, EventBuilder, Filter, Keys, Kind, SingleLetterTag, Tag, Timestamp};
|
||||
|
||||
const KIND_LONG_FORM: u16 = 30023;
|
||||
|
||||
fn relay_url() -> String {
|
||||
std::env::var("RELAY_URL").unwrap_or_else(|_| "ws://localhost:3000".to_string())
|
||||
}
|
||||
|
||||
fn sub_id(name: &str) -> String {
|
||||
format!("e2e-{name}-{}", uuid::Uuid::new_v4())
|
||||
}
|
||||
|
||||
/// Build a kind:30023 event with standard NIP-23 tags.
|
||||
fn build_long_form_event(
|
||||
keys: &Keys,
|
||||
d_tag: &str,
|
||||
title: &str,
|
||||
content: &str,
|
||||
extra_tags: Vec<Tag>,
|
||||
) -> nostr::Event {
|
||||
let mut tags = vec![
|
||||
Tag::parse(["d", d_tag]).unwrap(),
|
||||
Tag::parse(["title", title]).unwrap(),
|
||||
];
|
||||
tags.extend(extra_tags);
|
||||
EventBuilder::new(Kind::Custom(KIND_LONG_FORM), content)
|
||||
.tags(tags)
|
||||
.sign_with_keys(keys)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
/// kind:30023 events are accepted by the relay.
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn test_long_form_accepted() {
|
||||
let url = relay_url();
|
||||
let keys = Keys::generate();
|
||||
let mut client = BuzzTestClient::connect(&url, &keys).await.expect("connect");
|
||||
|
||||
let event = build_long_form_event(
|
||||
&keys,
|
||||
"test-article-accept",
|
||||
"Test Article",
|
||||
"# Hello\n\nThis is a test article.",
|
||||
vec![],
|
||||
);
|
||||
|
||||
let ok = client.send_event(event).await.expect("send event");
|
||||
assert!(
|
||||
ok.accepted,
|
||||
"relay should accept kind:30023: {}",
|
||||
ok.message
|
||||
);
|
||||
|
||||
client.disconnect().await.expect("disconnect");
|
||||
}
|
||||
|
||||
/// kind:30023 events are retrievable via REQ with kinds filter.
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn test_long_form_retrievable() {
|
||||
let url = relay_url();
|
||||
let keys = Keys::generate();
|
||||
let mut client = BuzzTestClient::connect(&url, &keys).await.expect("connect");
|
||||
|
||||
let d_tag = format!("retrieve-{}", uuid::Uuid::new_v4().simple());
|
||||
let event = build_long_form_event(
|
||||
&keys,
|
||||
&d_tag,
|
||||
"Retrievable Article",
|
||||
"# Retrievable\n\nBody text.",
|
||||
vec![],
|
||||
);
|
||||
let event_id = event.id;
|
||||
|
||||
let ok = client.send_event(event).await.expect("send event");
|
||||
assert!(ok.accepted, "relay should accept: {}", ok.message);
|
||||
|
||||
// Query back by kind + author
|
||||
let sid = sub_id("retrieve");
|
||||
let filter = Filter::new()
|
||||
.kind(Kind::Custom(KIND_LONG_FORM))
|
||||
.author(keys.public_key());
|
||||
client
|
||||
.subscribe(&sid, vec![filter])
|
||||
.await
|
||||
.expect("subscribe");
|
||||
|
||||
let events = client
|
||||
.collect_until_eose(&sid, Duration::from_secs(5))
|
||||
.await
|
||||
.expect("collect");
|
||||
|
||||
assert!(
|
||||
events.iter().any(|e| e.id == event_id),
|
||||
"should find the published article in query results"
|
||||
);
|
||||
|
||||
client.disconnect().await.expect("disconnect");
|
||||
}
|
||||
|
||||
/// kind:30023 is stored globally (channel_id = NULL) — stray h-tags are ignored.
|
||||
/// An event with a stray h-tag should still be retrievable via a global query
|
||||
/// (no h-tag filter), proving it was stored as global.
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn test_long_form_stray_h_tag_ignored() {
|
||||
let url = relay_url();
|
||||
let keys = Keys::generate();
|
||||
let mut client = BuzzTestClient::connect(&url, &keys).await.expect("connect");
|
||||
|
||||
// Publish with a stray h-tag (a UUID that doesn't correspond to any channel).
|
||||
let fake_channel = uuid::Uuid::new_v4().to_string();
|
||||
let d_tag = format!("stray-h-{}", uuid::Uuid::new_v4().simple());
|
||||
let event = build_long_form_event(
|
||||
&keys,
|
||||
&d_tag,
|
||||
"Stray H-Tag Article",
|
||||
"Should be stored globally despite h-tag.",
|
||||
vec![Tag::parse(["h", &fake_channel]).unwrap()],
|
||||
);
|
||||
let event_id = event.id;
|
||||
|
||||
let ok = client.send_event(event).await.expect("send event");
|
||||
assert!(ok.accepted, "relay should accept: {}", ok.message);
|
||||
|
||||
// Query globally (no h-tag filter) — should find the article.
|
||||
let sid = sub_id("stray-h");
|
||||
let filter = Filter::new()
|
||||
.kind(Kind::Custom(KIND_LONG_FORM))
|
||||
.author(keys.public_key());
|
||||
client
|
||||
.subscribe(&sid, vec![filter])
|
||||
.await
|
||||
.expect("subscribe");
|
||||
|
||||
let events = client
|
||||
.collect_until_eose(&sid, Duration::from_secs(5))
|
||||
.await
|
||||
.expect("collect");
|
||||
|
||||
assert!(
|
||||
events.iter().any(|e| e.id == event_id),
|
||||
"article with stray h-tag should be retrievable via global query"
|
||||
);
|
||||
|
||||
// NOTE: Ideally, querying with #h=<fake_channel> should NOT return the
|
||||
// article since it's global. However, the raw h-tag remains on the stored
|
||||
// event (Nostr events are signed — tags can't be stripped without breaking
|
||||
// the signature), and the read-path filter matching in filter.rs treats
|
||||
// explicit h-tags as authoritative. This is a pre-existing limitation
|
||||
// affecting all global-only kinds (0, 1, 3, 30023) and should be fixed
|
||||
// in the filter layer as a follow-up.
|
||||
|
||||
client.disconnect().await.expect("disconnect");
|
||||
}
|
||||
|
||||
/// NIP-33 replacement: publishing a newer kind:30023 with the same d-tag replaces the old one.
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn test_long_form_nip33_replacement() {
|
||||
let url = relay_url();
|
||||
let keys = Keys::generate();
|
||||
let mut client = BuzzTestClient::connect(&url, &keys).await.expect("connect");
|
||||
|
||||
let d_tag = format!("replace-{}", uuid::Uuid::new_v4().simple());
|
||||
|
||||
// Publish v1
|
||||
let v1 = build_long_form_event(&keys, &d_tag, "Article v1", "Version 1 content.", vec![]);
|
||||
let ok1 = client.send_event(v1).await.expect("send v1");
|
||||
assert!(ok1.accepted, "v1 should be accepted: {}", ok1.message);
|
||||
|
||||
// Small delay to ensure different created_at timestamps
|
||||
tokio::time::sleep(Duration::from_secs(1)).await;
|
||||
|
||||
// Publish v2 with the same d-tag
|
||||
let v2 = build_long_form_event(
|
||||
&keys,
|
||||
&d_tag,
|
||||
"Article v2",
|
||||
"Version 2 content — updated.",
|
||||
vec![],
|
||||
);
|
||||
let v2_id = v2.id;
|
||||
let ok2 = client.send_event(v2).await.expect("send v2");
|
||||
assert!(ok2.accepted, "v2 should be accepted: {}", ok2.message);
|
||||
|
||||
// Query — should only get v2 (v1 replaced)
|
||||
let sid = sub_id("replace");
|
||||
let filter = Filter::new()
|
||||
.kind(Kind::Custom(KIND_LONG_FORM))
|
||||
.author(keys.public_key())
|
||||
.custom_tags(SingleLetterTag::lowercase(Alphabet::D), [d_tag.as_str()]);
|
||||
client
|
||||
.subscribe(&sid, vec![filter])
|
||||
.await
|
||||
.expect("subscribe");
|
||||
|
||||
let events = client
|
||||
.collect_until_eose(&sid, Duration::from_secs(5))
|
||||
.await
|
||||
.expect("collect");
|
||||
|
||||
assert_eq!(
|
||||
events.len(),
|
||||
1,
|
||||
"should have exactly one event after replacement"
|
||||
);
|
||||
assert_eq!(events[0].id, v2_id, "surviving event should be v2");
|
||||
assert!(
|
||||
events[0].content.contains("Version 2"),
|
||||
"content should be v2"
|
||||
);
|
||||
|
||||
client.disconnect().await.expect("disconnect");
|
||||
}
|
||||
|
||||
/// NIP-33 stale-write protection: an older event cannot replace a newer one.
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn test_long_form_stale_write_rejected() {
|
||||
let url = relay_url();
|
||||
let keys = Keys::generate();
|
||||
let mut client = BuzzTestClient::connect(&url, &keys).await.expect("connect");
|
||||
|
||||
let d_tag = format!("stale-{}", uuid::Uuid::new_v4().simple());
|
||||
|
||||
// Publish the "newer" event first (with a future-ish timestamp)
|
||||
let newer = {
|
||||
let tags = vec![
|
||||
Tag::parse(["d", &d_tag]).unwrap(),
|
||||
Tag::parse(["title", "Newer Article"]).unwrap(),
|
||||
];
|
||||
EventBuilder::new(Kind::Custom(KIND_LONG_FORM), "Newer content.")
|
||||
.tags(tags)
|
||||
.custom_created_at(Timestamp::from(nostr::Timestamp::now().as_secs() + 100))
|
||||
.sign_with_keys(&keys)
|
||||
.unwrap()
|
||||
};
|
||||
let newer_id = newer.id;
|
||||
let ok1 = client.send_event(newer).await.expect("send newer");
|
||||
assert!(ok1.accepted, "newer should be accepted: {}", ok1.message);
|
||||
|
||||
// Now try to publish an "older" event with the same d-tag but earlier timestamp
|
||||
let older = {
|
||||
let tags = vec![
|
||||
Tag::parse(["d", &d_tag]).unwrap(),
|
||||
Tag::parse(["title", "Older Article"]).unwrap(),
|
||||
];
|
||||
EventBuilder::new(Kind::Custom(KIND_LONG_FORM), "Older content.")
|
||||
.tags(tags)
|
||||
.custom_created_at(Timestamp::from(nostr::Timestamp::now().as_secs() - 100))
|
||||
.sign_with_keys(&keys)
|
||||
.unwrap()
|
||||
};
|
||||
let _ok2 = client.send_event(older).await.expect("send older");
|
||||
// Stale write may be rejected or accepted-as-duplicate — either way,
|
||||
// the older event must NOT replace the newer one.
|
||||
|
||||
// Query — should still have the newer event
|
||||
let sid = sub_id("stale");
|
||||
let filter = Filter::new()
|
||||
.kind(Kind::Custom(KIND_LONG_FORM))
|
||||
.author(keys.public_key())
|
||||
.custom_tags(SingleLetterTag::lowercase(Alphabet::D), [d_tag.as_str()]);
|
||||
client
|
||||
.subscribe(&sid, vec![filter])
|
||||
.await
|
||||
.expect("subscribe");
|
||||
|
||||
let events = client
|
||||
.collect_until_eose(&sid, Duration::from_secs(5))
|
||||
.await
|
||||
.expect("collect");
|
||||
|
||||
assert_eq!(events.len(), 1, "should have exactly one event");
|
||||
assert_eq!(
|
||||
events[0].id, newer_id,
|
||||
"surviving event should be the newer one"
|
||||
);
|
||||
assert!(
|
||||
events[0].content.contains("Newer"),
|
||||
"content should be from the newer event"
|
||||
);
|
||||
|
||||
client.disconnect().await.expect("disconnect");
|
||||
}
|
||||
|
||||
/// NIP-09 a-tag deletion: a kind:5 deletion targeting the addressable
|
||||
/// coordinate `30023:<pubkey>:<d-tag>` causes the live event row for that
|
||||
/// coordinate to be soft-deleted, so subsequent REQs no longer return it.
|
||||
///
|
||||
/// Regression test for issue #714 — before the fix,
|
||||
/// `handle_a_tag_deletion` only handled the workflow kind and silently
|
||||
/// no-op'd for kind:30023.
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn test_long_form_a_tag_deletion() {
|
||||
let url = relay_url();
|
||||
let keys = Keys::generate();
|
||||
let mut client = BuzzTestClient::connect(&url, &keys).await.expect("connect");
|
||||
|
||||
// Publish a note.
|
||||
let d_tag = format!("a-del-{}", uuid::Uuid::new_v4().simple());
|
||||
let note = build_long_form_event(&keys, &d_tag, "Doomed Article", "Body.", vec![]);
|
||||
let note_id = note.id;
|
||||
let ok = client.send_event(note).await.expect("send note");
|
||||
assert!(ok.accepted, "note should be accepted: {}", ok.message);
|
||||
|
||||
// Sanity check it's queryable before deletion.
|
||||
let sid_pre = sub_id("a-del-pre");
|
||||
let filter_pre = Filter::new()
|
||||
.kind(Kind::Custom(KIND_LONG_FORM))
|
||||
.author(keys.public_key())
|
||||
.custom_tag(SingleLetterTag::lowercase(Alphabet::D), d_tag.as_str());
|
||||
client
|
||||
.subscribe(&sid_pre, vec![filter_pre])
|
||||
.await
|
||||
.expect("subscribe pre");
|
||||
let pre = client
|
||||
.collect_until_eose(&sid_pre, Duration::from_secs(5))
|
||||
.await
|
||||
.expect("collect pre");
|
||||
assert!(
|
||||
pre.iter().any(|e| e.id == note_id),
|
||||
"note should be queryable before deletion"
|
||||
);
|
||||
|
||||
// Build the addressable coordinate and emit a kind:5 deletion targeting it.
|
||||
let a_coord = format!(
|
||||
"{}:{}:{}",
|
||||
KIND_LONG_FORM,
|
||||
keys.public_key().to_hex(),
|
||||
d_tag
|
||||
);
|
||||
let del = EventBuilder::new(Kind::EventDeletion, "")
|
||||
.tags(vec![Tag::parse(["a", &a_coord]).unwrap()])
|
||||
.sign_with_keys(&keys)
|
||||
.unwrap();
|
||||
let ok_del = client.send_event(del).await.expect("send deletion");
|
||||
assert!(
|
||||
ok_del.accepted,
|
||||
"a-tag deletion should be accepted: {}",
|
||||
ok_del.message
|
||||
);
|
||||
|
||||
// Query — should now be empty.
|
||||
let sid_post = sub_id("a-del-post");
|
||||
let filter_post = Filter::new()
|
||||
.kind(Kind::Custom(KIND_LONG_FORM))
|
||||
.author(keys.public_key())
|
||||
.custom_tag(SingleLetterTag::lowercase(Alphabet::D), d_tag.as_str());
|
||||
client
|
||||
.subscribe(&sid_post, vec![filter_post])
|
||||
.await
|
||||
.expect("subscribe post");
|
||||
let post = client
|
||||
.collect_until_eose(&sid_post, Duration::from_secs(5))
|
||||
.await
|
||||
.expect("collect post");
|
||||
assert!(
|
||||
post.is_empty(),
|
||||
"a-tag deletion should remove the note from REQ results (got {} events)",
|
||||
post.len()
|
||||
);
|
||||
|
||||
client.disconnect().await.expect("disconnect");
|
||||
}
|
||||
|
||||
/// A kind:5 carrying a malformed `e` tag alongside a valid `a` coordinate must
|
||||
/// NOT be routed as an addressable deletion — a malformed `e` makes the
|
||||
/// deletion ambiguous, not addressable-only. Regression guard for relay
|
||||
/// routing keyed on "no e tags present" rather than "no valid e-ids decoded":
|
||||
/// the note must survive.
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn test_long_form_malformed_e_plus_a_does_not_delete() {
|
||||
let url = relay_url();
|
||||
let keys = Keys::generate();
|
||||
let mut client = BuzzTestClient::connect(&url, &keys).await.expect("connect");
|
||||
|
||||
let d_tag = format!("mixed-del-{}", uuid::Uuid::new_v4().simple());
|
||||
let note = build_long_form_event(&keys, &d_tag, "Survivor", "Body.", vec![]);
|
||||
let note_id = note.id;
|
||||
let ok = client.send_event(note).await.expect("send note");
|
||||
assert!(ok.accepted, "note should be accepted: {}", ok.message);
|
||||
|
||||
// kind:5 with a *malformed* e tag (not 64 hex chars) plus a valid a coord.
|
||||
let a_coord = format!(
|
||||
"{}:{}:{}",
|
||||
KIND_LONG_FORM,
|
||||
keys.public_key().to_hex(),
|
||||
d_tag
|
||||
);
|
||||
let del = EventBuilder::new(Kind::EventDeletion, "")
|
||||
.tags(vec![
|
||||
Tag::parse(["e", "not-a-valid-event-id"]).unwrap(),
|
||||
Tag::parse(["a", &a_coord]).unwrap(),
|
||||
])
|
||||
.sign_with_keys(&keys)
|
||||
.unwrap();
|
||||
// Relay may accept-and-noop or reject; either is fine. The contract under
|
||||
// test is that the coordinate is NOT soft-deleted.
|
||||
let _ = client.send_event(del).await.expect("send mixed deletion");
|
||||
|
||||
let sid = sub_id("mixed-del-post");
|
||||
let filter = Filter::new()
|
||||
.kind(Kind::Custom(KIND_LONG_FORM))
|
||||
.author(keys.public_key())
|
||||
.custom_tag(SingleLetterTag::lowercase(Alphabet::D), d_tag.as_str());
|
||||
client
|
||||
.subscribe(&sid, vec![filter])
|
||||
.await
|
||||
.expect("subscribe");
|
||||
let post = client
|
||||
.collect_until_eose(&sid, Duration::from_secs(5))
|
||||
.await
|
||||
.expect("collect");
|
||||
assert!(
|
||||
post.iter().any(|e| e.id == note_id),
|
||||
"malformed-e + a must NOT soft-delete the coordinate; note should survive"
|
||||
);
|
||||
|
||||
client.disconnect().await.expect("disconnect");
|
||||
}
|
||||
|
||||
/// `notes set` re-publish preserves the original `published_at` while letting
|
||||
/// `created_at` advance. This is the contract that NIP-23 readers rely on to
|
||||
/// tell "when the author first wrote this" from "when they last updated it",
|
||||
/// and the carry-forward logic in `buzz-cli`'s `build_set_event` (unit-tested
|
||||
/// there) only works if the relay round-trips the tag faithfully.
|
||||
///
|
||||
/// The carry rule is duplicated inline here (rather than reaching into
|
||||
/// `buzz-cli`) so this e2e crate stays free of CLI deps; the rule's
|
||||
/// correctness is unit-tested in `commands::notes::tests`.
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn test_long_form_set_twice_preserves_published_at() {
|
||||
let url = relay_url();
|
||||
let keys = Keys::generate();
|
||||
let mut client = BuzzTestClient::connect(&url, &keys).await.expect("connect");
|
||||
|
||||
let d_tag = format!("preserve-pat-{}", uuid::Uuid::new_v4().simple());
|
||||
let original_published_at: u64 = 1_700_000_000;
|
||||
|
||||
// First publish: stamp `published_at` = original_published_at.
|
||||
let v1 = build_long_form_event(
|
||||
&keys,
|
||||
&d_tag,
|
||||
"First",
|
||||
"v1 body",
|
||||
vec![Tag::parse(["published_at", &original_published_at.to_string()]).unwrap()],
|
||||
);
|
||||
let ok1 = client.send_event(v1).await.expect("send v1");
|
||||
assert!(ok1.accepted, "v1 should be accepted: {}", ok1.message);
|
||||
|
||||
// Ensure created_at advances between writes.
|
||||
tokio::time::sleep(Duration::from_secs(1)).await;
|
||||
|
||||
// Re-publish carrying the original `published_at` forward — what
|
||||
// `notes set` does on update when `--title` (or nothing) changes.
|
||||
let v2 = EventBuilder::new(Kind::Custom(KIND_LONG_FORM), "v2 body")
|
||||
.tags(vec![
|
||||
Tag::parse(["d", &d_tag]).unwrap(),
|
||||
Tag::parse(["title", "First"]).unwrap(),
|
||||
Tag::parse(["published_at", &original_published_at.to_string()]).unwrap(),
|
||||
])
|
||||
.custom_created_at(Timestamp::now())
|
||||
.sign_with_keys(&keys)
|
||||
.unwrap();
|
||||
let v2_id = v2.id;
|
||||
let v2_created_at = v2.created_at.as_secs();
|
||||
let ok2 = client.send_event(v2).await.expect("send v2");
|
||||
assert!(ok2.accepted, "v2 should be accepted: {}", ok2.message);
|
||||
|
||||
// Re-fetch: there should be exactly one live event for (kind, author, d-tag),
|
||||
// and its `published_at` should still be the original — even though
|
||||
// `created_at` advanced.
|
||||
let sid = sub_id("preserve-pat");
|
||||
let filter = Filter::new()
|
||||
.kind(Kind::Custom(KIND_LONG_FORM))
|
||||
.author(keys.public_key())
|
||||
.custom_tag(SingleLetterTag::lowercase(Alphabet::D), d_tag.as_str());
|
||||
client
|
||||
.subscribe(&sid, vec![filter])
|
||||
.await
|
||||
.expect("subscribe");
|
||||
|
||||
let events = client
|
||||
.collect_until_eose(&sid, Duration::from_secs(5))
|
||||
.await
|
||||
.expect("collect");
|
||||
|
||||
assert_eq!(events.len(), 1, "exactly one live event after re-publish");
|
||||
let live = &events[0];
|
||||
assert_eq!(live.id, v2_id, "surviving event is v2");
|
||||
assert_eq!(
|
||||
live.created_at.as_secs(),
|
||||
v2_created_at,
|
||||
"created_at advanced to v2's timestamp"
|
||||
);
|
||||
let pa = live
|
||||
.tags
|
||||
.iter()
|
||||
.find(|t| t.as_slice().first().map(String::as_str) == Some("published_at"))
|
||||
.and_then(|t| t.as_slice().get(1).cloned())
|
||||
.and_then(|v| v.parse::<u64>().ok());
|
||||
assert_eq!(
|
||||
pa,
|
||||
Some(original_published_at),
|
||||
"published_at must be preserved across re-publish"
|
||||
);
|
||||
|
||||
client.disconnect().await.expect("disconnect");
|
||||
}
|
||||
@@ -0,0 +1,368 @@
|
||||
//! End-to-end tests for kind:30177 managed-agent events (NIP-AP).
|
||||
//!
|
||||
//! These tests verify the relay accepts and addresses managed-agent events the
|
||||
//! same way it does personas, and that content published as a projection-shaped
|
||||
//! body round-trips through the relay unchanged.
|
||||
//!
|
||||
//! - Accepts a valid managed-agent event and queries it back by NIP-33
|
||||
//! coordinate (the `d`-tag is the agent's 64-hex pubkey).
|
||||
//! - Round-trips the published content through the relay, confirming it returns
|
||||
//! exactly the projected fields and nothing more. The body is a hand-built
|
||||
//! secret-free literal; the secret-exclusion regression guard lives in the
|
||||
//! `agent_events.rs` unit test, not here (the e2e crate can't reach the
|
||||
//! desktop projection function).
|
||||
//! - Enforces NIP-33 replacement semantics (same d-tag, newer timestamp wins).
|
||||
//! - Honors a NIP-09 a-tag tombstone, removing the agent coordinate.
|
||||
//!
|
||||
//! # Running
|
||||
//!
|
||||
//! Start the relay, then run:
|
||||
//!
|
||||
//! ```text
|
||||
//! RELAY_URL=ws://localhost:3000 cargo test --test e2e_managed_agent -- --ignored
|
||||
//! ```
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use buzz_test_client::BuzzTestClient;
|
||||
use nostr::{Alphabet, EventBuilder, Filter, Keys, Kind, SingleLetterTag, Tag, Timestamp};
|
||||
|
||||
const AGENT_KIND: u16 = 30177;
|
||||
|
||||
fn relay_url() -> String {
|
||||
std::env::var("RELAY_URL").unwrap_or_else(|_| "ws://localhost:3000".to_string())
|
||||
}
|
||||
|
||||
fn sub_id(name: &str) -> String {
|
||||
format!("e2e-managed-agent-{name}-{}", uuid::Uuid::new_v4())
|
||||
}
|
||||
|
||||
/// The opt-IN allowlist projection a real `ManagedAgentRecord` produces (see
|
||||
/// `desktop/src-tauri/src/managed_agents/agent_events.rs`). Built inline here
|
||||
/// because the e2e crate does not depend on the desktop crate; the
|
||||
/// projection-function exclusion contract is unit-tested in that module. This
|
||||
/// is exactly the field set the desktop publishes for a definition-less
|
||||
/// record (a definition-linked one omits the definition quad — the slimmed
|
||||
/// NIP-AP shape; the relay accepts both, as the legacy-fat test below proves) —
|
||||
/// secrets, the backend blob, env vars, and runtime fields are absent by
|
||||
/// construction.
|
||||
fn agent_projection_content(name: &str) -> String {
|
||||
serde_json::json!({
|
||||
"name": name,
|
||||
"system_prompt": "You are a test agent.",
|
||||
"model": "claude-opus-4",
|
||||
"provider": "anthropic",
|
||||
"parallelism": 24,
|
||||
"respond_to": "allowlist",
|
||||
"respond_to_allowlist": ["79be667e"]
|
||||
})
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// A legacy "fat" definition-linked projection (pre-slimming shape): carries
|
||||
/// both `persona_id` and the definition quad. Old clients still publish this;
|
||||
/// the relay must keep accepting it.
|
||||
fn legacy_fat_agent_projection_content(name: &str) -> String {
|
||||
serde_json::json!({
|
||||
"name": name,
|
||||
"persona_id": "persona-1",
|
||||
"system_prompt": "You are a test agent.",
|
||||
"model": "claude-opus-4",
|
||||
"provider": "anthropic",
|
||||
"persona_source_version": "abc123",
|
||||
"parallelism": 24,
|
||||
"respond_to": "allowlist",
|
||||
"respond_to_allowlist": ["79be667e"]
|
||||
})
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// Build a managed-agent event whose `d`-tag is the agent's 64-hex pubkey,
|
||||
/// mirroring the desktop `build_agent_event` shape.
|
||||
fn agent_event(keys: &Keys, d_tag: &str, content: &str) -> nostr::Event {
|
||||
EventBuilder::new(Kind::Custom(AGENT_KIND), content)
|
||||
.tags(vec![Tag::parse(["d", d_tag]).unwrap()])
|
||||
.sign_with_keys(keys)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn agent_event_at(keys: &Keys, d_tag: &str, content: &str, created_at: u64) -> nostr::Event {
|
||||
EventBuilder::new(Kind::Custom(AGENT_KIND), content)
|
||||
.tags(vec![Tag::parse(["d", d_tag]).unwrap()])
|
||||
.custom_created_at(Timestamp::from(created_at))
|
||||
.sign_with_keys(keys)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
/// Build a NIP-09 a-tag-only deletion at the agent's NIP-33 coordinate,
|
||||
/// mirroring the desktop `build_agent_delete` shape (no `e`-tag).
|
||||
fn agent_delete_event(keys: &Keys, d_tag: &str) -> nostr::Event {
|
||||
let coord = format!("{AGENT_KIND}:{}:{d_tag}", keys.public_key().to_hex());
|
||||
EventBuilder::new(Kind::Custom(5), "")
|
||||
.tags(vec![Tag::parse(["a", coord.as_str()]).unwrap()])
|
||||
.sign_with_keys(keys)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
/// A synthetic agent `d`-tag: 64 lowercase hex chars, the agent-pubkey grammar.
|
||||
fn agent_d_tag() -> String {
|
||||
uuid::Uuid::new_v4().simple().to_string().repeat(2)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn test_managed_agent_publish_and_query() {
|
||||
let url = relay_url();
|
||||
let keys = Keys::generate();
|
||||
let d_tag = agent_d_tag();
|
||||
let content = agent_projection_content("Test Agent");
|
||||
|
||||
let mut client = BuzzTestClient::connect(&url, &keys).await.expect("connect");
|
||||
|
||||
let event = agent_event(&keys, &d_tag, &content);
|
||||
let ok = client.send_event(event).await.expect("send agent");
|
||||
assert!(
|
||||
ok.accepted,
|
||||
"relay rejected managed-agent event: {}",
|
||||
ok.message
|
||||
);
|
||||
|
||||
let sid = sub_id("query");
|
||||
let filter = Filter::new()
|
||||
.kind(Kind::Custom(AGENT_KIND))
|
||||
.author(keys.public_key())
|
||||
.custom_tags(SingleLetterTag::lowercase(Alphabet::D), [d_tag.as_str()]);
|
||||
|
||||
client
|
||||
.subscribe(&sid, vec![filter])
|
||||
.await
|
||||
.expect("subscribe");
|
||||
|
||||
let events = client
|
||||
.collect_until_eose(&sid, Duration::from_secs(5))
|
||||
.await
|
||||
.expect("collect events");
|
||||
|
||||
assert_eq!(events.len(), 1, "expected exactly one managed-agent event");
|
||||
let ev = &events[0];
|
||||
assert_eq!(ev.content, content);
|
||||
assert_eq!(ev.pubkey, keys.public_key());
|
||||
assert_eq!(ev.kind, Kind::Custom(AGENT_KIND));
|
||||
|
||||
client.disconnect().await.expect("disconnect");
|
||||
}
|
||||
|
||||
/// The relay keeps accepting the legacy "fat" definition-linked projection
|
||||
/// (pre-slimming shape) — old clients publish it during the transition.
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn test_legacy_fat_agent_event_still_accepted() {
|
||||
let url = relay_url();
|
||||
let keys = Keys::generate();
|
||||
let d_tag = agent_d_tag();
|
||||
let content = legacy_fat_agent_projection_content("Legacy Agent");
|
||||
|
||||
let mut client = BuzzTestClient::connect(&url, &keys).await.expect("connect");
|
||||
let event = agent_event(&keys, &d_tag, &content);
|
||||
let ok = client.send_event(event).await.expect("send legacy agent");
|
||||
assert!(
|
||||
ok.accepted,
|
||||
"relay rejected legacy fat managed-agent event: {}",
|
||||
ok.message
|
||||
);
|
||||
client.disconnect().await.expect("disconnect");
|
||||
}
|
||||
|
||||
/// The relay round-trips published content byte-for-byte: a projection-shaped
|
||||
/// body goes out, and the relay returns exactly those fields and nothing more.
|
||||
/// The body here is a hand-built secret-free literal (`agent_projection_content`),
|
||||
/// so this test does NOT exercise the desktop projection function — the e2e crate
|
||||
/// can't reach it. The secret-exclusion regression guard lives in the
|
||||
/// `agent_events.rs` unit test (`content_excludes_secrets_and_runtime_fields`),
|
||||
/// which feeds a fully-populated secret-bearing record through the real
|
||||
/// projection. This test confirms the relay neither adds nor drops fields.
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn test_managed_agent_round_trips_only_projected_fields() {
|
||||
let url = relay_url();
|
||||
let keys = Keys::generate();
|
||||
let d_tag = agent_d_tag();
|
||||
let content = agent_projection_content("Secret-Free Agent");
|
||||
|
||||
let mut client = BuzzTestClient::connect(&url, &keys).await.expect("connect");
|
||||
|
||||
let event = agent_event(&keys, &d_tag, &content);
|
||||
let ok = client.send_event(event).await.expect("send agent");
|
||||
assert!(
|
||||
ok.accepted,
|
||||
"relay rejected managed-agent event: {}",
|
||||
ok.message
|
||||
);
|
||||
|
||||
let sid = sub_id("secrets");
|
||||
let filter = Filter::new()
|
||||
.kind(Kind::Custom(AGENT_KIND))
|
||||
.author(keys.public_key())
|
||||
.custom_tags(SingleLetterTag::lowercase(Alphabet::D), [d_tag.as_str()]);
|
||||
|
||||
client
|
||||
.subscribe(&sid, vec![filter])
|
||||
.await
|
||||
.expect("subscribe");
|
||||
|
||||
let events = client
|
||||
.collect_until_eose(&sid, Duration::from_secs(5))
|
||||
.await
|
||||
.expect("collect events");
|
||||
|
||||
assert_eq!(events.len(), 1, "expected exactly one managed-agent event");
|
||||
let published = &events[0].content;
|
||||
|
||||
// The relay must not inject fields. These assertions confirm round-trip
|
||||
// fidelity for a projection-shaped body — they are NOT the secret guard
|
||||
// (the input literal is secret-free by construction; the real guard is the
|
||||
// `agent_events.rs` unit test over the projection function).
|
||||
for forbidden in [
|
||||
"private_key_nsec",
|
||||
"private_key",
|
||||
"nsec1",
|
||||
"auth_tag",
|
||||
"env_vars",
|
||||
"backend",
|
||||
"backend_agent_id",
|
||||
"provider_binary_path",
|
||||
] {
|
||||
assert!(
|
||||
!published.contains(forbidden),
|
||||
"published managed-agent content leaked `{forbidden}`: {published}"
|
||||
);
|
||||
}
|
||||
|
||||
// Runtime fields — must never appear.
|
||||
for forbidden in [
|
||||
"runtime_pid",
|
||||
"last_started_at",
|
||||
"last_stopped_at",
|
||||
"last_exit_code",
|
||||
"last_error",
|
||||
"relay_url",
|
||||
] {
|
||||
assert!(
|
||||
!published.contains(forbidden),
|
||||
"published managed-agent content leaked runtime field `{forbidden}`: {published}"
|
||||
);
|
||||
}
|
||||
|
||||
// Identity/config fields — must be present (proves we published real content).
|
||||
assert!(published.contains("Secret-Free Agent"), "missing name");
|
||||
assert!(published.contains("system_prompt"), "missing system_prompt");
|
||||
|
||||
client.disconnect().await.expect("disconnect");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn test_managed_agent_nip33_replacement_newer_wins() {
|
||||
let url = relay_url();
|
||||
let keys = Keys::generate();
|
||||
let d_tag = agent_d_tag();
|
||||
|
||||
let mut client = BuzzTestClient::connect(&url, &keys).await.expect("connect");
|
||||
|
||||
let now = Timestamp::now().as_secs();
|
||||
let old_content = agent_projection_content("Old Agent");
|
||||
let old_event = agent_event_at(&keys, &d_tag, &old_content, now - 100);
|
||||
let ok = client.send_event(old_event).await.expect("send old");
|
||||
assert!(ok.accepted, "relay rejected old event: {}", ok.message);
|
||||
|
||||
let new_content = agent_projection_content("New Agent");
|
||||
let new_event = agent_event_at(&keys, &d_tag, &new_content, now);
|
||||
let ok = client.send_event(new_event).await.expect("send new");
|
||||
assert!(ok.accepted, "relay rejected new event: {}", ok.message);
|
||||
|
||||
let sid = sub_id("replace");
|
||||
let filter = Filter::new()
|
||||
.kind(Kind::Custom(AGENT_KIND))
|
||||
.author(keys.public_key())
|
||||
.custom_tags(SingleLetterTag::lowercase(Alphabet::D), [d_tag.as_str()]);
|
||||
|
||||
client
|
||||
.subscribe(&sid, vec![filter])
|
||||
.await
|
||||
.expect("subscribe");
|
||||
|
||||
let events = client
|
||||
.collect_until_eose(&sid, Duration::from_secs(5))
|
||||
.await
|
||||
.expect("collect");
|
||||
|
||||
assert_eq!(events.len(), 1, "NIP-33: only newest event should remain");
|
||||
assert_eq!(
|
||||
events[0].content, new_content,
|
||||
"should be the newer version"
|
||||
);
|
||||
|
||||
client.disconnect().await.expect("disconnect");
|
||||
}
|
||||
|
||||
/// The a-tag tombstone is the only state-destroying op in the managed-agent
|
||||
/// flow. Publish an agent, confirm it is live, publish the a-tag-only tombstone
|
||||
/// at its coordinate, then assert the query returns it gone.
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn test_managed_agent_tombstone_deletes_coordinate() {
|
||||
let url = relay_url();
|
||||
let keys = Keys::generate();
|
||||
let d_tag = agent_d_tag();
|
||||
let content = agent_projection_content("Doomed Agent");
|
||||
|
||||
let mut client = BuzzTestClient::connect(&url, &keys).await.expect("connect");
|
||||
|
||||
let event = agent_event(&keys, &d_tag, &content);
|
||||
let ok = client.send_event(event).await.expect("send agent");
|
||||
assert!(
|
||||
ok.accepted,
|
||||
"relay rejected managed-agent event: {}",
|
||||
ok.message
|
||||
);
|
||||
|
||||
let filter = || {
|
||||
Filter::new()
|
||||
.kind(Kind::Custom(AGENT_KIND))
|
||||
.author(keys.public_key())
|
||||
.custom_tags(SingleLetterTag::lowercase(Alphabet::D), [d_tag.as_str()])
|
||||
};
|
||||
|
||||
let sid = sub_id("tombstone-pre");
|
||||
client
|
||||
.subscribe(&sid, vec![filter()])
|
||||
.await
|
||||
.expect("subscribe pre");
|
||||
let before = client
|
||||
.collect_until_eose(&sid, Duration::from_secs(5))
|
||||
.await
|
||||
.expect("collect pre");
|
||||
assert_eq!(before.len(), 1, "agent should be live before deletion");
|
||||
|
||||
let tombstone = agent_delete_event(&keys, &d_tag);
|
||||
let ok = client.send_event(tombstone).await.expect("send tombstone");
|
||||
assert!(ok.accepted, "relay rejected tombstone: {}", ok.message);
|
||||
|
||||
let sid = sub_id("tombstone-post");
|
||||
client
|
||||
.subscribe(&sid, vec![filter()])
|
||||
.await
|
||||
.expect("subscribe post");
|
||||
let after = client
|
||||
.collect_until_eose(&sid, Duration::from_secs(5))
|
||||
.await
|
||||
.expect("collect post");
|
||||
assert_eq!(
|
||||
after.len(),
|
||||
0,
|
||||
"tombstone should remove the agent coordinate, got {} event(s)",
|
||||
after.len()
|
||||
);
|
||||
|
||||
client.disconnect().await.expect("disconnect");
|
||||
}
|
||||
@@ -0,0 +1,460 @@
|
||||
//! End-to-end media upload tests (Blossom / NIP-96 style).
|
||||
//!
|
||||
//! Requires: relay running at localhost:3000, MinIO running at localhost:9000.
|
||||
//! All tests are `#[ignore]` so they don't run in CI by default.
|
||||
//!
|
||||
//! # Running
|
||||
//!
|
||||
//! ```text
|
||||
//! cargo test -p buzz-test-client --test e2e_media -- --ignored --nocapture
|
||||
//! ```
|
||||
//!
|
||||
//! Override the relay URL:
|
||||
//!
|
||||
//! ```text
|
||||
//! RELAY_HTTP_URL=http://localhost:3000 cargo test -p buzz-test-client --test e2e_media -- --ignored
|
||||
//! ```
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
|
||||
use nostr::{EventBuilder, JsonUtil, Keys, Kind, Tag, Timestamp};
|
||||
use reqwest::Client;
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
fn relay_http_url() -> String {
|
||||
std::env::var("RELAY_HTTP_URL").unwrap_or_else(|_| "http://localhost:3000".to_string())
|
||||
}
|
||||
|
||||
fn http_client() -> Client {
|
||||
Client::builder()
|
||||
.timeout(Duration::from_secs(15))
|
||||
.build()
|
||||
.expect("failed to build HTTP client")
|
||||
}
|
||||
|
||||
/// Sign a kind:24242 Blossom upload auth event for the given sha256.
|
||||
fn sign_blossom_auth(keys: &Keys, sha256: &str) -> nostr::Event {
|
||||
let now = Timestamp::now().as_secs();
|
||||
let exp_str = (now + 300).to_string();
|
||||
let tags = vec![
|
||||
Tag::parse(["t", "upload"]).expect("t tag"),
|
||||
Tag::parse(["x", sha256]).expect("x tag"),
|
||||
Tag::parse(["expiration", &exp_str]).expect("expiration tag"),
|
||||
];
|
||||
EventBuilder::new(Kind::from(24242), "Upload test")
|
||||
.tags(tags)
|
||||
.sign_with_keys(keys)
|
||||
.expect("sign blossom auth")
|
||||
}
|
||||
|
||||
/// Sign a kind:24242 Blossom *read* auth event for the given sha256.
|
||||
///
|
||||
/// Reads are authenticated unconditionally, so every successful GET/HEAD in this
|
||||
/// file has to present one of these. The `x` tag is hash-scoped and covers the
|
||||
/// derived paths too -- the relay matches on the sha256 before the extension, so
|
||||
/// one token serves `{sha}.jpg` and `{sha}.thumb.jpg` alike.
|
||||
fn sign_blossom_get_auth(keys: &Keys, sha256: &str) -> nostr::Event {
|
||||
let now = Timestamp::now().as_secs();
|
||||
let exp_str = (now + 300).to_string();
|
||||
let tags = vec![
|
||||
Tag::parse(["t", "get"]).expect("t tag"),
|
||||
Tag::parse(["x", sha256]).expect("x tag"),
|
||||
Tag::parse(["expiration", &exp_str]).expect("expiration tag"),
|
||||
];
|
||||
EventBuilder::new(Kind::from(24242), "Get test")
|
||||
.tags(tags)
|
||||
.sign_with_keys(keys)
|
||||
.expect("sign blossom get auth")
|
||||
}
|
||||
|
||||
/// Build `Authorization: Nostr <base64url(json)>` header value.
|
||||
fn blossom_auth_header(event: &nostr::Event) -> String {
|
||||
format!(
|
||||
"Nostr {}",
|
||||
URL_SAFE_NO_PAD.encode(event.as_json().as_bytes())
|
||||
)
|
||||
}
|
||||
|
||||
/// A valid 1×1 red JPEG (339 bytes). Used for fast upload tests.
|
||||
fn tiny_jpeg() -> Vec<u8> {
|
||||
vec![
|
||||
0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 0x4A, 0x46, 0x49, 0x46, 0x00, 0x01, 0x01, 0x00, 0x00,
|
||||
0x01, 0x00, 0x01, 0x00, 0x00, 0xFF, 0xDB, 0x00, 0x43, 0x00, 0x08, 0x06, 0x06, 0x07, 0x06,
|
||||
0x05, 0x08, 0x07, 0x07, 0x07, 0x09, 0x09, 0x08, 0x0A, 0x0C, 0x14, 0x0D, 0x0C, 0x0B, 0x0B,
|
||||
0x0C, 0x19, 0x12, 0x13, 0x0F, 0x14, 0x1D, 0x1A, 0x1F, 0x1E, 0x1D, 0x1A, 0x1C, 0x1C, 0x20,
|
||||
0x24, 0x2E, 0x27, 0x20, 0x22, 0x2C, 0x23, 0x1C, 0x1C, 0x28, 0x37, 0x29, 0x2C, 0x30, 0x31,
|
||||
0x34, 0x34, 0x34, 0x1F, 0x27, 0x39, 0x3D, 0x38, 0x32, 0x3C, 0x2E, 0x33, 0x34, 0x32, 0xFF,
|
||||
0xC0, 0x00, 0x0B, 0x08, 0x00, 0x01, 0x00, 0x01, 0x01, 0x01, 0x11, 0x00, 0xFF, 0xC4, 0x00,
|
||||
0x1F, 0x00, 0x00, 0x01, 0x05, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x00, 0x00, 0x00, 0x00,
|
||||
0x00, 0x00, 0x00, 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A, 0x0B,
|
||||
0xFF, 0xC4, 0x00, 0xB5, 0x10, 0x00, 0x02, 0x01, 0x03, 0x03, 0x02, 0x04, 0x03, 0x05, 0x05,
|
||||
0x04, 0x04, 0x00, 0x00, 0x01, 0x7D, 0x01, 0x02, 0x03, 0x00, 0x04, 0x11, 0x05, 0x12, 0x21,
|
||||
0x31, 0x41, 0x06, 0x13, 0x51, 0x61, 0x07, 0x22, 0x71, 0x14, 0x32, 0x81, 0x91, 0xA1, 0x08,
|
||||
0x23, 0x42, 0xB1, 0xC1, 0x15, 0x52, 0xD1, 0xF0, 0x24, 0x33, 0x62, 0x72, 0x82, 0x09, 0x0A,
|
||||
0x16, 0x17, 0x18, 0x19, 0x1A, 0x25, 0x26, 0x27, 0x28, 0x29, 0x2A, 0x34, 0x35, 0x36, 0x37,
|
||||
0x38, 0x39, 0x3A, 0x43, 0x44, 0x45, 0x46, 0x47, 0x48, 0x49, 0x4A, 0x53, 0x54, 0x55, 0x56,
|
||||
0x57, 0x58, 0x59, 0x5A, 0x63, 0x64, 0x65, 0x66, 0x67, 0x68, 0x69, 0x6A, 0x73, 0x74, 0x75,
|
||||
0x76, 0x77, 0x78, 0x79, 0x7A, 0x83, 0x84, 0x85, 0x86, 0x87, 0x88, 0x89, 0x8A, 0x92, 0x93,
|
||||
0x94, 0x95, 0x96, 0x97, 0x98, 0x99, 0x9A, 0xA2, 0xA3, 0xA4, 0xA5, 0xA6, 0xA7, 0xA8, 0xA9,
|
||||
0xAA, 0xB2, 0xB3, 0xB4, 0xB5, 0xB6, 0xB7, 0xB8, 0xB9, 0xBA, 0xC2, 0xC3, 0xC4, 0xC5, 0xC6,
|
||||
0xC7, 0xC8, 0xC9, 0xCA, 0xD2, 0xD3, 0xD4, 0xD5, 0xD6, 0xD7, 0xD8, 0xD9, 0xDA, 0xE1, 0xE2,
|
||||
0xE3, 0xE4, 0xE5, 0xE6, 0xE7, 0xE8, 0xE9, 0xEA, 0xF1, 0xF2, 0xF3, 0xF4, 0xF5, 0xF6, 0xF7,
|
||||
0xF8, 0xF9, 0xFA, 0xFF, 0xDA, 0x00, 0x08, 0x01, 0x01, 0x00, 0x00, 0x3F, 0x00, 0x7B, 0x94,
|
||||
0x11, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||
0xFF, 0xD9,
|
||||
]
|
||||
}
|
||||
|
||||
/// Upload a tiny JPEG, then GET it back and verify the bytes match.
|
||||
/// Also checks BlobDescriptor fields and thumbnail endpoint.
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn test_upload_and_get() {
|
||||
let client = http_client();
|
||||
let keys = Keys::generate();
|
||||
let jpeg = tiny_jpeg();
|
||||
let sha256 = hex::encode(Sha256::digest(&jpeg));
|
||||
|
||||
println!("sha256: {sha256}");
|
||||
println!("relay: {}", relay_http_url());
|
||||
|
||||
// Standards-compliant BUD-02 upload route.
|
||||
let auth = sign_blossom_auth(&keys, &sha256);
|
||||
let resp = client
|
||||
.put(format!("{}/upload", relay_http_url()))
|
||||
.header("Authorization", blossom_auth_header(&auth))
|
||||
.header("Content-Type", "image/jpeg")
|
||||
.header("X-SHA-256", &sha256)
|
||||
.body(jpeg.clone())
|
||||
.send()
|
||||
.await
|
||||
.expect("upload PUT failed");
|
||||
|
||||
let status = resp.status();
|
||||
let body_text = resp.text().await.unwrap_or_default();
|
||||
println!("PUT /upload → {status}: {body_text}");
|
||||
assert_eq!(status, 200, "upload should succeed");
|
||||
|
||||
// Parse BlobDescriptor
|
||||
let descriptor: serde_json::Value =
|
||||
serde_json::from_str(&body_text).expect("BlobDescriptor JSON");
|
||||
println!("BlobDescriptor: {descriptor:#}");
|
||||
|
||||
assert_eq!(
|
||||
descriptor["sha256"].as_str().unwrap(),
|
||||
sha256,
|
||||
"sha256 must match"
|
||||
);
|
||||
assert!(
|
||||
descriptor["url"].as_str().unwrap().contains(&sha256),
|
||||
"url must contain sha256"
|
||||
);
|
||||
assert!(
|
||||
descriptor["size"].as_u64().unwrap() > 0,
|
||||
"size must be positive"
|
||||
);
|
||||
assert!(
|
||||
descriptor["type"].as_str().is_some(),
|
||||
"mime type must be present"
|
||||
);
|
||||
// dim and blurhash are best-effort (image processing may not run on 1x1 JPEG)
|
||||
println!(
|
||||
"dim: {:?}, blurhash: {:?}",
|
||||
descriptor["dim"], descriptor["blurhash"]
|
||||
);
|
||||
|
||||
// Reads are authenticated, so mint one hash-scoped token for all three below.
|
||||
let read_auth = blossom_auth_header(&sign_blossom_get_auth(&keys, &sha256));
|
||||
|
||||
// GET /media/{sha256}.jpg — bytes must match
|
||||
let get_url = format!("{}/media/{sha256}.jpg", relay_http_url());
|
||||
let get_resp = client
|
||||
.get(&get_url)
|
||||
.header("Authorization", &read_auth)
|
||||
.send()
|
||||
.await
|
||||
.expect("GET /media/{sha256}.jpg failed");
|
||||
assert_eq!(get_resp.status(), 200, "GET should return 200");
|
||||
let returned_bytes = get_resp.bytes().await.unwrap();
|
||||
assert_eq!(
|
||||
returned_bytes.as_ref(),
|
||||
jpeg.as_slice(),
|
||||
"GET must return original bytes"
|
||||
);
|
||||
|
||||
// HEAD /media/{sha256}.jpg — must return 200 with content-type
|
||||
let head_resp = client
|
||||
.head(&get_url)
|
||||
.header("Authorization", &read_auth)
|
||||
.send()
|
||||
.await
|
||||
.expect("HEAD /media/{sha256}.jpg failed");
|
||||
assert_eq!(head_resp.status(), 200, "HEAD should return 200");
|
||||
assert!(
|
||||
head_resp.headers().get("content-type").is_some(),
|
||||
"HEAD must include content-type"
|
||||
);
|
||||
|
||||
// GET thumbnail — /media/{sha256}.thumb.jpg
|
||||
let thumb_url = format!("{}/media/{sha256}.thumb.jpg", relay_http_url());
|
||||
let thumb_resp = client
|
||||
.get(&thumb_url)
|
||||
.header("Authorization", &read_auth)
|
||||
.send()
|
||||
.await
|
||||
.expect("GET thumbnail failed");
|
||||
println!("GET thumbnail → {}", thumb_resp.status());
|
||||
// Thumbnail may be same as original for 1x1 images — just check 200
|
||||
assert_eq!(thumb_resp.status(), 200, "thumbnail should return 200");
|
||||
}
|
||||
|
||||
/// Idempotency: uploading the same file twice returns the same BlobDescriptor.
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn test_upload_idempotent() {
|
||||
let client = http_client();
|
||||
let keys = Keys::generate();
|
||||
let jpeg = tiny_jpeg();
|
||||
let sha256 = hex::encode(Sha256::digest(&jpeg));
|
||||
|
||||
let upload = |keys: &Keys| {
|
||||
let auth = sign_blossom_auth(keys, &sha256);
|
||||
client
|
||||
.put(format!("{}/media/upload", relay_http_url()))
|
||||
.header("Authorization", blossom_auth_header(&auth))
|
||||
.header("Content-Type", "image/jpeg")
|
||||
.header("X-SHA-256", sha256.clone())
|
||||
.body(jpeg.clone())
|
||||
.send()
|
||||
};
|
||||
|
||||
let r1: serde_json::Value = upload(&keys)
|
||||
.await
|
||||
.expect("first upload failed")
|
||||
.json()
|
||||
.await
|
||||
.expect("first descriptor parse");
|
||||
|
||||
// Second upload — different key, same content
|
||||
let keys2 = Keys::generate();
|
||||
let r2: serde_json::Value = upload(&keys2)
|
||||
.await
|
||||
.expect("second upload failed")
|
||||
.json()
|
||||
.await
|
||||
.expect("second descriptor parse");
|
||||
|
||||
assert_eq!(
|
||||
r1["sha256"], r2["sha256"],
|
||||
"sha256 must be identical on re-upload"
|
||||
);
|
||||
assert_eq!(r1["url"], r2["url"], "url must be identical on re-upload");
|
||||
}
|
||||
|
||||
/// Upload without an Authorization header must return 401.
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn test_upload_no_auth_returns_401() {
|
||||
let client = http_client();
|
||||
let jpeg = tiny_jpeg();
|
||||
|
||||
let resp = client
|
||||
.put(format!("{}/media/upload", relay_http_url()))
|
||||
.header("Content-Type", "image/jpeg")
|
||||
.body(jpeg)
|
||||
.send()
|
||||
.await
|
||||
.expect("request failed");
|
||||
|
||||
println!("no-auth → {}", resp.status());
|
||||
assert_eq!(resp.status(), 401, "upload without auth must be 401");
|
||||
}
|
||||
|
||||
/// Upload without X-SHA-256 header must return 401 (BUD-11: mandatory).
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn test_upload_missing_x_sha256_returns_401() {
|
||||
let client = http_client();
|
||||
let keys = Keys::generate();
|
||||
let jpeg = tiny_jpeg();
|
||||
let sha256 = hex::encode(Sha256::digest(&jpeg));
|
||||
|
||||
let auth = sign_blossom_auth(&keys, &sha256);
|
||||
let resp = client
|
||||
.put(format!("{}/media/upload", relay_http_url()))
|
||||
.header("Authorization", blossom_auth_header(&auth))
|
||||
.header("Content-Type", "image/jpeg")
|
||||
// Intentionally omit X-SHA-256
|
||||
.body(jpeg)
|
||||
.send()
|
||||
.await
|
||||
.expect("request failed");
|
||||
|
||||
println!("missing-x-sha256 → {}", resp.status());
|
||||
assert_eq!(resp.status(), 401, "upload without X-SHA-256 must be 401");
|
||||
}
|
||||
|
||||
/// Upload where the `x` tag sha256 doesn't match the actual body must return 401.
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn test_upload_hash_mismatch_returns_400() {
|
||||
let client = http_client();
|
||||
let keys = Keys::generate();
|
||||
let jpeg = tiny_jpeg();
|
||||
let wrong_hash = "f".repeat(64); // definitely not the real sha256
|
||||
|
||||
let auth = sign_blossom_auth(&keys, &wrong_hash);
|
||||
let resp = client
|
||||
.put(format!("{}/media/upload", relay_http_url()))
|
||||
.header("Authorization", blossom_auth_header(&auth))
|
||||
.header("Content-Type", "image/jpeg")
|
||||
.header("X-SHA-256", &wrong_hash)
|
||||
.body(jpeg)
|
||||
.send()
|
||||
.await
|
||||
.expect("request failed");
|
||||
|
||||
println!("hash-mismatch → {}", resp.status());
|
||||
assert_eq!(resp.status(), 401, "hash mismatch must be 401");
|
||||
}
|
||||
|
||||
/// GET an authenticated sha256 that was never uploaded must return 404.
|
||||
///
|
||||
/// The token has to be valid for the 404 to be reachable at all: authentication
|
||||
/// runs before the storage lookup, so a bare request is rejected with 401 and
|
||||
/// never distinguishes "missing" from "unauthorized" (see
|
||||
/// `test_unauthenticated_reads_are_rejected`).
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn test_get_nonexistent_returns_404() {
|
||||
let client = http_client();
|
||||
let keys = Keys::generate();
|
||||
let missing_sha256 = "0".repeat(64);
|
||||
let url = format!("{}/media/{missing_sha256}.jpg", relay_http_url());
|
||||
|
||||
let resp = client
|
||||
.get(&url)
|
||||
.header(
|
||||
"Authorization",
|
||||
blossom_auth_header(&sign_blossom_get_auth(&keys, &missing_sha256)),
|
||||
)
|
||||
.send()
|
||||
.await
|
||||
.expect("GET failed");
|
||||
println!("missing blob → {}", resp.status());
|
||||
assert_eq!(resp.status(), 404, "missing blob must be 404");
|
||||
}
|
||||
|
||||
/// Bare reads are rejected with 401 before any storage lookup.
|
||||
///
|
||||
/// This is the boundary PR #4610 made unconditional: there is no longer a config
|
||||
/// flag that lets an unauthenticated GET through, so the acceptance lane has to
|
||||
/// assert the rejection directly. Uses a never-uploaded hash deliberately -- a 401
|
||||
/// here rather than a 404 proves auth runs ahead of the storage lookup and that the
|
||||
/// endpoint does not leak blob existence to an unauthenticated caller.
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn test_unauthenticated_reads_are_rejected() {
|
||||
let client = http_client();
|
||||
let missing_sha256 = "0".repeat(64);
|
||||
let blob_url = format!("{}/media/{missing_sha256}.jpg", relay_http_url());
|
||||
let thumb_url = format!("{}/media/{missing_sha256}.thumb.jpg", relay_http_url());
|
||||
|
||||
let get_resp = client.get(&blob_url).send().await.expect("bare GET failed");
|
||||
println!("bare GET → {}", get_resp.status());
|
||||
assert_eq!(get_resp.status(), 401, "bare GET must be 401");
|
||||
|
||||
let head_resp = client
|
||||
.head(&blob_url)
|
||||
.send()
|
||||
.await
|
||||
.expect("bare HEAD failed");
|
||||
println!("bare HEAD → {}", head_resp.status());
|
||||
assert_eq!(head_resp.status(), 401, "bare HEAD must be 401");
|
||||
|
||||
let thumb_resp = client
|
||||
.get(&thumb_url)
|
||||
.send()
|
||||
.await
|
||||
.expect("bare thumbnail GET failed");
|
||||
println!("bare thumbnail GET → {}", thumb_resp.status());
|
||||
assert_eq!(thumb_resp.status(), 401, "bare thumbnail GET must be 401");
|
||||
}
|
||||
|
||||
/// Upload a real image from the filesystem (set TEST_IMAGE_PATH env var).
|
||||
/// Verifies the full round-trip: upload → BlobDescriptor → GET bytes match.
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn test_upload_real_image() {
|
||||
let image_path = match std::env::var("TEST_IMAGE_PATH") {
|
||||
Ok(p) => p,
|
||||
Err(_) => {
|
||||
println!("Skipping: TEST_IMAGE_PATH not set");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let client = http_client();
|
||||
let keys = Keys::generate();
|
||||
let bytes = std::fs::read(&image_path).expect("read image file");
|
||||
let sha256 = hex::encode(Sha256::digest(&bytes));
|
||||
let size = bytes.len();
|
||||
|
||||
println!("image: {image_path}");
|
||||
println!("size: {size} bytes");
|
||||
println!("sha256: {sha256}");
|
||||
|
||||
let auth = sign_blossom_auth(&keys, &sha256);
|
||||
let resp = client
|
||||
.put(format!("{}/media/upload", relay_http_url()))
|
||||
.header("Authorization", blossom_auth_header(&auth))
|
||||
.header("Content-Type", "image/jpeg")
|
||||
.header("X-SHA-256", &sha256)
|
||||
.body(bytes.clone())
|
||||
.send()
|
||||
.await
|
||||
.expect("upload PUT failed");
|
||||
|
||||
let status = resp.status();
|
||||
let body_text = resp.text().await.unwrap_or_default();
|
||||
println!("PUT /media/upload → {status}: {body_text}");
|
||||
assert_eq!(status, 200, "upload should succeed");
|
||||
|
||||
let descriptor: serde_json::Value =
|
||||
serde_json::from_str(&body_text).expect("BlobDescriptor JSON");
|
||||
println!("BlobDescriptor: {descriptor:#}");
|
||||
|
||||
assert_eq!(descriptor["sha256"].as_str().unwrap(), sha256);
|
||||
assert_eq!(descriptor["size"].as_u64().unwrap(), size as u64);
|
||||
assert!(descriptor["url"].as_str().unwrap().contains(&sha256));
|
||||
assert!(
|
||||
descriptor["dim"].as_str().is_some(),
|
||||
"real image should have dim"
|
||||
);
|
||||
assert!(
|
||||
descriptor["blurhash"].as_str().is_some(),
|
||||
"real image should have blurhash"
|
||||
);
|
||||
|
||||
// GET bytes back and verify
|
||||
let get_url = descriptor["url"].as_str().unwrap();
|
||||
let get_resp = client
|
||||
.get(get_url)
|
||||
.header(
|
||||
"Authorization",
|
||||
blossom_auth_header(&sign_blossom_get_auth(&keys, &sha256)),
|
||||
)
|
||||
.send()
|
||||
.await
|
||||
.expect("GET failed");
|
||||
assert_eq!(get_resp.status(), 200);
|
||||
let returned = get_resp.bytes().await.unwrap();
|
||||
assert_eq!(
|
||||
returned.as_ref(),
|
||||
bytes.as_slice(),
|
||||
"GET must return original bytes"
|
||||
);
|
||||
|
||||
println!("✅ Real image upload round-trip passed");
|
||||
}
|
||||
@@ -0,0 +1,738 @@
|
||||
//! Extended media upload integration tests — auth edge cases, content validation,
|
||||
//! multi-format uploads, WebSocket imeta validation.
|
||||
//!
|
||||
//! Run: cargo test -p buzz-test-client --test e2e_media_extended -- --ignored --nocapture
|
||||
|
||||
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
|
||||
use nostr::{EventBuilder, JsonUtil, Keys, Kind, Tag, Timestamp};
|
||||
use reqwest::Client;
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::time::Duration;
|
||||
|
||||
fn relay_http_url() -> String {
|
||||
std::env::var("RELAY_HTTP_URL").unwrap_or_else(|_| "http://localhost:3000".to_string())
|
||||
}
|
||||
|
||||
fn relay_ws_url() -> String {
|
||||
relay_http_url()
|
||||
.replace("http://", "ws://")
|
||||
.replace("https://", "wss://")
|
||||
}
|
||||
|
||||
fn http_client() -> Client {
|
||||
Client::builder()
|
||||
.timeout(Duration::from_secs(15))
|
||||
.build()
|
||||
.expect("http client")
|
||||
}
|
||||
|
||||
fn sign_blossom_auth(keys: &Keys, sha256: &str) -> nostr::Event {
|
||||
let now = Timestamp::now().as_secs();
|
||||
let tags = vec![
|
||||
Tag::parse(["t", "upload"]).unwrap(),
|
||||
Tag::parse(["x", sha256]).unwrap(),
|
||||
Tag::parse(["expiration", &(now + 300).to_string()]).unwrap(),
|
||||
];
|
||||
EventBuilder::new(Kind::from(24242), "Upload test")
|
||||
.tags(tags)
|
||||
.sign_with_keys(keys)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
/// Sign a kind:24242 Blossom *read* auth event. Reads are authenticated
|
||||
/// unconditionally, so round-trip GETs must present one of these.
|
||||
fn sign_blossom_get_auth(keys: &Keys, sha256: &str) -> nostr::Event {
|
||||
let now = Timestamp::now().as_secs();
|
||||
let tags = vec![
|
||||
Tag::parse(["t", "get"]).unwrap(),
|
||||
Tag::parse(["x", sha256]).unwrap(),
|
||||
Tag::parse(["expiration", &(now + 300).to_string()]).unwrap(),
|
||||
];
|
||||
EventBuilder::new(Kind::from(24242), "Get test")
|
||||
.tags(tags)
|
||||
.sign_with_keys(keys)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn blossom_auth_header(event: &nostr::Event) -> String {
|
||||
format!(
|
||||
"Nostr {}",
|
||||
URL_SAFE_NO_PAD.encode(event.as_json().as_bytes())
|
||||
)
|
||||
}
|
||||
|
||||
async fn upload(client: &Client, keys: &Keys, body: &[u8]) -> reqwest::Response {
|
||||
upload_to_path(client, keys, "/upload", body).await
|
||||
}
|
||||
|
||||
async fn upload_to_path(
|
||||
client: &Client,
|
||||
keys: &Keys,
|
||||
path: &str,
|
||||
body: &[u8],
|
||||
) -> reqwest::Response {
|
||||
let sha256 = hex::encode(Sha256::digest(body));
|
||||
let auth = sign_blossom_auth(keys, &sha256);
|
||||
client
|
||||
.put(format!("{}{path}", relay_http_url()))
|
||||
.header("Authorization", blossom_auth_header(&auth))
|
||||
.header("X-SHA-256", &sha256)
|
||||
.body(body.to_vec())
|
||||
.send()
|
||||
.await
|
||||
.expect("upload request")
|
||||
}
|
||||
|
||||
fn tiny_jpeg() -> Vec<u8> {
|
||||
vec![
|
||||
0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 0x4A, 0x46, 0x49, 0x46, 0x00, 0x01, 0x01, 0x00, 0x00,
|
||||
0x01, 0x00, 0x01, 0x00, 0x00, 0xFF, 0xDB, 0x00, 0x43, 0x00, 0x08, 0x06, 0x06, 0x07, 0x06,
|
||||
0x05, 0x08, 0x07, 0x07, 0x07, 0x09, 0x09, 0x08, 0x0A, 0x0C, 0x14, 0x0D, 0x0C, 0x0B, 0x0B,
|
||||
0x0C, 0x19, 0x12, 0x13, 0x0F, 0x14, 0x1D, 0x1A, 0x1F, 0x1E, 0x1D, 0x1A, 0x1C, 0x1C, 0x20,
|
||||
0x24, 0x2E, 0x27, 0x20, 0x22, 0x2C, 0x23, 0x1C, 0x1C, 0x28, 0x37, 0x29, 0x2C, 0x30, 0x31,
|
||||
0x34, 0x34, 0x34, 0x1F, 0x27, 0x39, 0x3D, 0x38, 0x32, 0x3C, 0x2E, 0x33, 0x34, 0x32, 0xFF,
|
||||
0xC0, 0x00, 0x0B, 0x08, 0x00, 0x01, 0x00, 0x01, 0x01, 0x01, 0x11, 0x00, 0xFF, 0xC4, 0x00,
|
||||
0x1F, 0x00, 0x00, 0x01, 0x05, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x00, 0x00, 0x00, 0x00,
|
||||
0x00, 0x00, 0x00, 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A, 0x0B,
|
||||
0xFF, 0xC4, 0x00, 0xB5, 0x10, 0x00, 0x02, 0x01, 0x03, 0x03, 0x02, 0x04, 0x03, 0x05, 0x05,
|
||||
0x04, 0x04, 0x00, 0x00, 0x01, 0x7D, 0x01, 0x02, 0x03, 0x00, 0x04, 0x11, 0x05, 0x12, 0x21,
|
||||
0x31, 0x41, 0x06, 0x13, 0x51, 0x61, 0x07, 0x22, 0x71, 0x14, 0x32, 0x81, 0x91, 0xA1, 0x08,
|
||||
0x23, 0x42, 0xB1, 0xC1, 0x15, 0x52, 0xD1, 0xF0, 0x24, 0x33, 0x62, 0x72, 0x82, 0x09, 0x0A,
|
||||
0x16, 0x17, 0x18, 0x19, 0x1A, 0x25, 0x26, 0x27, 0x28, 0x29, 0x2A, 0x34, 0x35, 0x36, 0x37,
|
||||
0x38, 0x39, 0x3A, 0x43, 0x44, 0x45, 0x46, 0x47, 0x48, 0x49, 0x4A, 0x53, 0x54, 0x55, 0x56,
|
||||
0x57, 0x58, 0x59, 0x5A, 0x63, 0x64, 0x65, 0x66, 0x67, 0x68, 0x69, 0x6A, 0x73, 0x74, 0x75,
|
||||
0x76, 0x77, 0x78, 0x79, 0x7A, 0x83, 0x84, 0x85, 0x86, 0x87, 0x88, 0x89, 0x8A, 0x92, 0x93,
|
||||
0x94, 0x95, 0x96, 0x97, 0x98, 0x99, 0x9A, 0xA2, 0xA3, 0xA4, 0xA5, 0xA6, 0xA7, 0xA8, 0xA9,
|
||||
0xAA, 0xB2, 0xB3, 0xB4, 0xB5, 0xB6, 0xB7, 0xB8, 0xB9, 0xBA, 0xC2, 0xC3, 0xC4, 0xC5, 0xC6,
|
||||
0xC7, 0xC8, 0xC9, 0xCA, 0xD2, 0xD3, 0xD4, 0xD5, 0xD6, 0xD7, 0xD8, 0xD9, 0xDA, 0xE1, 0xE2,
|
||||
0xE3, 0xE4, 0xE5, 0xE6, 0xE7, 0xE8, 0xE9, 0xEA, 0xF1, 0xF2, 0xF3, 0xF4, 0xF5, 0xF6, 0xF7,
|
||||
0xF8, 0xF9, 0xFA, 0xFF, 0xDA, 0x00, 0x08, 0x01, 0x01, 0x00, 0x00, 0x3F, 0x00, 0x7B, 0x94,
|
||||
0x11, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||
0xFF, 0xD9,
|
||||
]
|
||||
}
|
||||
|
||||
fn tiny_png() -> Vec<u8> {
|
||||
// Valid 2x2 red PNG generated by ffmpeg, with ffmpeg's pHYs chunk stripped:
|
||||
// `validate_png_metadata_free` rejects pHYs as an identity channel, so the
|
||||
// original fixture uploaded as 422 MetadataForbidden. IHDR/IDAT/IEND only.
|
||||
vec![
|
||||
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44,
|
||||
0x52, 0x00, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00, 0x02, 0x08, 0x02, 0x00, 0x00, 0x00, 0xfd,
|
||||
0xd4, 0x9a, 0x73, 0x00, 0x00, 0x00, 0x10, 0x49, 0x44, 0x41, 0x54, 0x78, 0x9c, 0x63, 0xfc,
|
||||
0xc3, 0x00, 0x02, 0x2c, 0x60, 0x92, 0x01, 0x00, 0x0d, 0x04, 0x01, 0x02, 0xbf, 0x50, 0x15,
|
||||
0xb3, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4e, 0x44, 0xae, 0x42, 0x60, 0x82,
|
||||
]
|
||||
}
|
||||
|
||||
fn tiny_gif() -> Vec<u8> {
|
||||
vec![
|
||||
0x47, 0x49, 0x46, 0x38, 0x39, 0x61, // GIF89a
|
||||
0x01, 0x00, 0x01, 0x00, 0x80, 0x00, 0x00, // LSD 1x1
|
||||
0xFF, 0xFF, 0xFF, 0x00, 0x00, 0x00, // GCT
|
||||
0x2C, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x01, 0x00, 0x00, // Image Descriptor
|
||||
0x02, 0x02, 0x4C, 0x01, 0x00, // Image Data
|
||||
0x3B, // Trailer
|
||||
]
|
||||
}
|
||||
|
||||
fn tiny_webp() -> Vec<u8> {
|
||||
// Valid 2x2 red lossy WebP generated by ffmpeg
|
||||
vec![
|
||||
0x52, 0x49, 0x46, 0x46, 0x3c, 0x00, 0x00, 0x00, 0x57, 0x45, 0x42, 0x50, 0x56, 0x50, 0x38,
|
||||
0x20, 0x30, 0x00, 0x00, 0x00, 0xd0, 0x01, 0x00, 0x9d, 0x01, 0x2a, 0x02, 0x00, 0x02, 0x00,
|
||||
0x02, 0x00, 0x34, 0x25, 0xa0, 0x02, 0x74, 0xba, 0x01, 0xf8, 0x00, 0x03, 0xb0, 0x00, 0xfe,
|
||||
0xf0, 0xe8, 0xf7, 0xff, 0x20, 0xb9, 0x61, 0x75, 0xc8, 0xd7, 0xff, 0x20, 0x3f, 0xe4, 0x07,
|
||||
0xfc, 0x80, 0xff, 0xf8, 0xf2, 0x00, 0x00, 0x00,
|
||||
]
|
||||
}
|
||||
|
||||
fn sign_custom_auth(keys: &Keys, kind: u16, content: &str, tags: Vec<Tag>) -> nostr::Event {
|
||||
EventBuilder::new(Kind::from(kind), content)
|
||||
.tags(tags)
|
||||
.sign_with_keys(keys)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
async fn upload_with_auth(
|
||||
client: &Client,
|
||||
auth_event: &nostr::Event,
|
||||
sha256: &str,
|
||||
body: &[u8],
|
||||
) -> reqwest::Response {
|
||||
client
|
||||
.put(format!("{}/upload", relay_http_url()))
|
||||
.header("Authorization", blossom_auth_header(auth_event))
|
||||
.header("X-SHA-256", sha256)
|
||||
.body(body.to_vec())
|
||||
.send()
|
||||
.await
|
||||
.expect("upload request")
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn test_upload_png_roundtrip() {
|
||||
let client = http_client();
|
||||
let keys = Keys::generate();
|
||||
let png = tiny_png();
|
||||
let resp = upload(&client, &keys, &png).await;
|
||||
assert_eq!(resp.status(), 200, "PNG upload should succeed");
|
||||
let desc: serde_json::Value = resp.json().await.unwrap();
|
||||
assert_eq!(desc["type"].as_str().unwrap(), "image/png");
|
||||
assert!(desc["url"].as_str().unwrap().ends_with(".png"));
|
||||
println!("✅ PNG upload: {}", desc["url"]);
|
||||
|
||||
// GET back — reads are authenticated, so scope a token to the uploaded hash.
|
||||
let sha256 = desc["sha256"].as_str().expect("descriptor sha256");
|
||||
let get = client
|
||||
.get(desc["url"].as_str().unwrap())
|
||||
.header(
|
||||
"Authorization",
|
||||
blossom_auth_header(&sign_blossom_get_auth(&keys, sha256)),
|
||||
)
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(get.status(), 200);
|
||||
assert_eq!(get.bytes().await.unwrap().as_ref(), png.as_slice());
|
||||
println!("✅ PNG GET roundtrip verified");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn test_upload_gif_roundtrip() {
|
||||
let client = http_client();
|
||||
let keys = Keys::generate();
|
||||
let gif = tiny_gif();
|
||||
let resp = upload(&client, &keys, &gif).await;
|
||||
assert_eq!(resp.status(), 200, "GIF upload should succeed");
|
||||
let desc: serde_json::Value = resp.json().await.unwrap();
|
||||
assert_eq!(desc["type"].as_str().unwrap(), "image/gif");
|
||||
assert!(desc["url"].as_str().unwrap().ends_with(".gif"));
|
||||
println!("✅ GIF upload: {}", desc["url"]);
|
||||
|
||||
let sha256 = desc["sha256"].as_str().expect("descriptor sha256");
|
||||
let get = client
|
||||
.get(desc["url"].as_str().unwrap())
|
||||
.header(
|
||||
"Authorization",
|
||||
blossom_auth_header(&sign_blossom_get_auth(&keys, sha256)),
|
||||
)
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(get.status(), 200);
|
||||
assert_eq!(get.bytes().await.unwrap().as_ref(), gif.as_slice());
|
||||
println!("✅ GIF GET roundtrip verified");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn test_upload_webp_roundtrip() {
|
||||
let client = http_client();
|
||||
let keys = Keys::generate();
|
||||
let webp = tiny_webp();
|
||||
let resp = upload(&client, &keys, &webp).await;
|
||||
let status = resp.status();
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
println!("WebP upload → {status}: {body}");
|
||||
assert_eq!(status, 200, "WebP upload should succeed");
|
||||
let desc: serde_json::Value = serde_json::from_str(&body).unwrap();
|
||||
assert_eq!(desc["type"].as_str().unwrap(), "image/webp");
|
||||
assert!(desc["url"].as_str().unwrap().ends_with(".webp"));
|
||||
println!("✅ WebP upload: {}", desc["url"]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn test_auth_wrong_kind() {
|
||||
let client = http_client();
|
||||
let keys = Keys::generate();
|
||||
let jpeg = tiny_jpeg();
|
||||
let sha256 = hex::encode(Sha256::digest(&jpeg));
|
||||
let now = Timestamp::now().as_secs();
|
||||
let auth = sign_custom_auth(
|
||||
&keys,
|
||||
27235,
|
||||
"Upload test",
|
||||
vec![
|
||||
Tag::parse(["t", "upload"]).unwrap(),
|
||||
Tag::parse(["x", &sha256]).unwrap(),
|
||||
Tag::parse(["expiration", &(now + 300).to_string()]).unwrap(),
|
||||
],
|
||||
);
|
||||
let resp = upload_with_auth(&client, &auth, &sha256, &jpeg).await;
|
||||
assert_eq!(resp.status(), 401, "wrong kind must be 401");
|
||||
println!("✅ Wrong kind → 401");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn test_auth_missing_t_tag() {
|
||||
let client = http_client();
|
||||
let keys = Keys::generate();
|
||||
let jpeg = tiny_jpeg();
|
||||
let sha256 = hex::encode(Sha256::digest(&jpeg));
|
||||
let now = Timestamp::now().as_secs();
|
||||
let auth = sign_custom_auth(
|
||||
&keys,
|
||||
24242,
|
||||
"Upload test",
|
||||
vec![
|
||||
Tag::parse(["x", &sha256]).unwrap(),
|
||||
Tag::parse(["expiration", &(now + 300).to_string()]).unwrap(),
|
||||
],
|
||||
);
|
||||
let resp = upload_with_auth(&client, &auth, &sha256, &jpeg).await;
|
||||
assert_eq!(resp.status(), 401, "missing t tag must be 401");
|
||||
println!("✅ Missing t tag → 401");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn test_auth_missing_expiration() {
|
||||
let client = http_client();
|
||||
let keys = Keys::generate();
|
||||
let jpeg = tiny_jpeg();
|
||||
let sha256 = hex::encode(Sha256::digest(&jpeg));
|
||||
let auth = sign_custom_auth(
|
||||
&keys,
|
||||
24242,
|
||||
"Upload test",
|
||||
vec![
|
||||
Tag::parse(["t", "upload"]).unwrap(),
|
||||
Tag::parse(["x", &sha256]).unwrap(),
|
||||
],
|
||||
);
|
||||
let resp = upload_with_auth(&client, &auth, &sha256, &jpeg).await;
|
||||
assert_eq!(resp.status(), 401, "missing expiration must be 401");
|
||||
println!("✅ Missing expiration → 401");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn test_auth_expired_token() {
|
||||
let client = http_client();
|
||||
let keys = Keys::generate();
|
||||
let jpeg = tiny_jpeg();
|
||||
let sha256 = hex::encode(Sha256::digest(&jpeg));
|
||||
let now = Timestamp::now().as_secs();
|
||||
let auth = sign_custom_auth(
|
||||
&keys,
|
||||
24242,
|
||||
"Upload test",
|
||||
vec![
|
||||
Tag::parse(["t", "upload"]).unwrap(),
|
||||
Tag::parse(["x", &sha256]).unwrap(),
|
||||
Tag::parse(["expiration", &(now - 60).to_string()]).unwrap(),
|
||||
],
|
||||
);
|
||||
let resp = upload_with_auth(&client, &auth, &sha256, &jpeg).await;
|
||||
assert_eq!(resp.status(), 401, "expired token must be 401");
|
||||
println!("✅ Expired token → 401");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn test_auth_empty_content() {
|
||||
let client = http_client();
|
||||
let keys = Keys::generate();
|
||||
let jpeg = tiny_jpeg();
|
||||
let sha256 = hex::encode(Sha256::digest(&jpeg));
|
||||
let now = Timestamp::now().as_secs();
|
||||
let auth = sign_custom_auth(
|
||||
&keys,
|
||||
24242,
|
||||
"",
|
||||
vec![
|
||||
Tag::parse(["t", "upload"]).unwrap(),
|
||||
Tag::parse(["x", &sha256]).unwrap(),
|
||||
Tag::parse(["expiration", &(now + 300).to_string()]).unwrap(),
|
||||
],
|
||||
);
|
||||
let resp = upload_with_auth(&client, &auth, &sha256, &jpeg).await;
|
||||
assert_eq!(resp.status(), 401, "empty content must be 401");
|
||||
println!("✅ Empty content → 401");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn test_auth_server_tag_mismatch() {
|
||||
let client = http_client();
|
||||
let keys = Keys::generate();
|
||||
let jpeg = tiny_jpeg();
|
||||
let sha256 = hex::encode(Sha256::digest(&jpeg));
|
||||
let now = Timestamp::now().as_secs();
|
||||
let auth = sign_custom_auth(
|
||||
&keys,
|
||||
24242,
|
||||
"Upload test",
|
||||
vec![
|
||||
Tag::parse(["t", "upload"]).unwrap(),
|
||||
Tag::parse(["x", &sha256]).unwrap(),
|
||||
Tag::parse(["expiration", &(now + 300).to_string()]).unwrap(),
|
||||
Tag::parse(["server", "evil.example.com"]).unwrap(),
|
||||
],
|
||||
);
|
||||
let resp = upload_with_auth(&client, &auth, &sha256, &jpeg).await;
|
||||
assert_eq!(resp.status(), 401, "server tag mismatch must be 401");
|
||||
println!("✅ Server tag mismatch → 401");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn test_auth_server_tag_correct() {
|
||||
let client = http_client();
|
||||
let keys = Keys::generate();
|
||||
let jpeg = tiny_jpeg();
|
||||
let sha256 = hex::encode(Sha256::digest(&jpeg));
|
||||
let now = Timestamp::now().as_secs();
|
||||
let auth = sign_custom_auth(
|
||||
&keys,
|
||||
24242,
|
||||
"Upload test",
|
||||
vec![
|
||||
Tag::parse(["t", "upload"]).unwrap(),
|
||||
Tag::parse(["x", &sha256]).unwrap(),
|
||||
Tag::parse(["expiration", &(now + 300).to_string()]).unwrap(),
|
||||
Tag::parse(["server", "localhost:3000"]).unwrap(),
|
||||
],
|
||||
);
|
||||
let resp = upload_with_auth(&client, &auth, &sha256, &jpeg).await;
|
||||
assert_eq!(resp.status(), 200, "correct server tag must be 200");
|
||||
println!("✅ Correct server tag → 200");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn test_upload_svg_accepted_as_text_xml() {
|
||||
// SVG with XML declaration is detected by `infer` as text/xml (not image/svg+xml),
|
||||
// which is not in the blocked list, so it routes through the generic file path.
|
||||
let client = http_client();
|
||||
let keys = Keys::generate();
|
||||
let svg = b"<?xml version=\"1.0\"?><svg xmlns=\"http://www.w3.org/2000/svg\"></svg>";
|
||||
let resp = upload(&client, &keys, svg).await;
|
||||
let status = resp.status().as_u16();
|
||||
assert_eq!(
|
||||
status, 200,
|
||||
"SVG (undetected) should succeed via file path, got {status}"
|
||||
);
|
||||
let desc: serde_json::Value = resp.json().await.unwrap();
|
||||
assert_eq!(desc["type"].as_str().unwrap(), "text/xml");
|
||||
println!("✅ SVG (XML declaration) → 200 as text/xml");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn test_upload_pdf_accepted() {
|
||||
// PDF is detected by `infer` and is not in the blocked list, so it
|
||||
// routes through the generic file path successfully.
|
||||
let client = http_client();
|
||||
let keys = Keys::generate();
|
||||
let pdf = b"%PDF-1.4 fake pdf content here for testing";
|
||||
let resp = upload(&client, &keys, pdf).await;
|
||||
let status = resp.status().as_u16();
|
||||
assert_eq!(
|
||||
status, 200,
|
||||
"PDF should succeed via file path, got {status}"
|
||||
);
|
||||
let desc: serde_json::Value = resp.json().await.unwrap();
|
||||
assert_eq!(desc["type"].as_str().unwrap(), "application/pdf");
|
||||
println!("✅ PDF → 200");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn test_legacy_media_route_rejects_non_media() {
|
||||
let client = http_client();
|
||||
let keys = Keys::generate();
|
||||
let pdf = b"%PDF-1.4 fake pdf content here for testing";
|
||||
let resp = upload_to_path(&client, &keys, "/media/upload", pdf).await;
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
reqwest::StatusCode::UNSUPPORTED_MEDIA_TYPE,
|
||||
"legacy media route must not accept generic attachments"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn test_legacy_media_route_still_accepts_canonical_media() {
|
||||
let client = http_client();
|
||||
let keys = Keys::generate();
|
||||
let resp = upload_to_path(&client, &keys, "/media/upload", &tiny_jpeg()).await;
|
||||
assert_eq!(resp.status(), reqwest::StatusCode::OK);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn test_standard_upload_rejects_recognized_audio() {
|
||||
let client = http_client();
|
||||
let keys = Keys::generate();
|
||||
let mp3 = b"ID3\x04\x00\x00\x00\x00\x00\x00";
|
||||
let resp = upload(&client, &keys, mp3).await;
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
reqwest::StatusCode::UNSUPPORTED_MEDIA_TYPE,
|
||||
"recognized audio must not bypass the location policy as an attachment"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn test_upload_zero_bytes_accepted() {
|
||||
// Empty body has no magic bytes — routes through the generic file path
|
||||
// as application/octet-stream.
|
||||
let client = http_client();
|
||||
let keys = Keys::generate();
|
||||
let resp = upload(&client, &keys, b"").await;
|
||||
let status = resp.status().as_u16();
|
||||
assert_eq!(
|
||||
status, 200,
|
||||
"zero bytes should succeed via file path, got {status}"
|
||||
);
|
||||
let desc: serde_json::Value = resp.json().await.unwrap();
|
||||
assert_eq!(desc["type"].as_str().unwrap(), "application/octet-stream");
|
||||
assert_eq!(desc["size"].as_u64().unwrap(), 0);
|
||||
println!("✅ Zero bytes → 200 as octet-stream");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn test_upload_random_bytes_accepted() {
|
||||
// Random bytes with no magic signature route through the generic file
|
||||
// path as application/octet-stream.
|
||||
let client = http_client();
|
||||
let keys = Keys::generate();
|
||||
let random: Vec<u8> = (0..1000).map(|i| (i * 37 % 256) as u8).collect();
|
||||
let resp = upload(&client, &keys, &random).await;
|
||||
let status = resp.status().as_u16();
|
||||
assert_eq!(
|
||||
status, 200,
|
||||
"random bytes should succeed via file path, got {status}"
|
||||
);
|
||||
let desc: serde_json::Value = resp.json().await.unwrap();
|
||||
assert_eq!(desc["type"].as_str().unwrap(), "application/octet-stream");
|
||||
println!("✅ Random bytes → 200 as octet-stream");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn test_concurrent_upload_same_file() {
|
||||
let client = http_client();
|
||||
let jpeg = tiny_jpeg();
|
||||
let keys1 = Keys::generate();
|
||||
let keys2 = Keys::generate();
|
||||
|
||||
let c1 = client.clone();
|
||||
let c2 = client.clone();
|
||||
let j1 = jpeg.clone();
|
||||
let j2 = jpeg.clone();
|
||||
|
||||
let (r1, r2) = tokio::join!(async { upload(&c1, &keys1, &j1).await }, async {
|
||||
upload(&c2, &keys2, &j2).await
|
||||
},);
|
||||
|
||||
assert_eq!(r1.status(), 200, "concurrent upload 1 must succeed");
|
||||
assert_eq!(r2.status(), 200, "concurrent upload 2 must succeed");
|
||||
|
||||
let d1: serde_json::Value = r1.json().await.unwrap();
|
||||
let d2: serde_json::Value = r2.json().await.unwrap();
|
||||
assert_eq!(d1["sha256"], d2["sha256"], "same content = same hash");
|
||||
assert_eq!(d1["url"], d2["url"], "same content = same URL");
|
||||
println!("✅ Concurrent upload: both succeeded, same sha256/url");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn test_ws_valid_imeta() {
|
||||
use buzz_test_client::BuzzTestClient;
|
||||
|
||||
let keys = Keys::generate();
|
||||
let pubkey_hex = keys.public_key().to_hex();
|
||||
let http = http_client();
|
||||
|
||||
// Create channel via signed kind:9007 event
|
||||
let channel_uuid = uuid::Uuid::new_v4();
|
||||
let channel_name = format!("ws-imeta-test-{}", channel_uuid);
|
||||
let create_event = EventBuilder::new(Kind::from(9007), "")
|
||||
.tags(vec![
|
||||
Tag::parse(["h", &channel_uuid.to_string()]).unwrap(),
|
||||
Tag::parse(["name", &channel_name]).unwrap(),
|
||||
Tag::parse(["channel_type", "stream"]).unwrap(),
|
||||
Tag::parse(["visibility", "open"]).unwrap(),
|
||||
])
|
||||
.sign_with_keys(&keys)
|
||||
.unwrap();
|
||||
let create_resp = http
|
||||
.post(format!("{}/events", relay_http_url()))
|
||||
.header("X-Pubkey", &pubkey_hex)
|
||||
.header("Content-Type", "application/json")
|
||||
.body(serde_json::to_string(&create_event).unwrap())
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(create_resp.status().is_success(), "channel creation failed");
|
||||
let channel_id = channel_uuid.to_string();
|
||||
println!("Channel: {channel_id}");
|
||||
|
||||
// Upload a JPEG to get a valid sha256
|
||||
let jpeg = tiny_jpeg();
|
||||
let sha256 = hex::encode(Sha256::digest(&jpeg));
|
||||
let resp = upload(&http, &keys, &jpeg).await;
|
||||
assert_eq!(resp.status(), 200);
|
||||
|
||||
// Connect via WebSocket
|
||||
let mut client = BuzzTestClient::connect(&relay_ws_url(), &keys)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Send event with valid imeta
|
||||
let event = EventBuilder::new(Kind::from(9), "image via ws")
|
||||
.tags(vec![
|
||||
Tag::parse(["h", &channel_id]).unwrap(),
|
||||
Tag::parse([
|
||||
"imeta",
|
||||
&format!("url http://localhost:3000/media/{sha256}.jpg"),
|
||||
"m image/jpeg",
|
||||
&format!("x {sha256}"),
|
||||
"size 347",
|
||||
])
|
||||
.unwrap(),
|
||||
])
|
||||
.sign_with_keys(&keys)
|
||||
.unwrap();
|
||||
|
||||
let ok = client.send_event(event).await.unwrap();
|
||||
assert!(
|
||||
ok.accepted,
|
||||
"valid imeta via WS must be accepted: {:?}",
|
||||
ok.message
|
||||
);
|
||||
println!("✅ WS valid imeta accepted");
|
||||
|
||||
client.disconnect().await.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn test_ws_invalid_imeta_external_url() {
|
||||
use buzz_test_client::BuzzTestClient;
|
||||
|
||||
let keys = Keys::generate();
|
||||
let pubkey_hex = keys.public_key().to_hex();
|
||||
let http = http_client();
|
||||
|
||||
let channel_uuid = uuid::Uuid::new_v4();
|
||||
let channel_name = format!("ws-imeta-bad-{}", channel_uuid);
|
||||
let create_event = EventBuilder::new(Kind::from(9007), "")
|
||||
.tags(vec![
|
||||
Tag::parse(["h", &channel_uuid.to_string()]).unwrap(),
|
||||
Tag::parse(["name", &channel_name]).unwrap(),
|
||||
Tag::parse(["channel_type", "stream"]).unwrap(),
|
||||
Tag::parse(["visibility", "open"]).unwrap(),
|
||||
])
|
||||
.sign_with_keys(&keys)
|
||||
.unwrap();
|
||||
let create_resp = http
|
||||
.post(format!("{}/events", relay_http_url()))
|
||||
.header("X-Pubkey", &pubkey_hex)
|
||||
.header("Content-Type", "application/json")
|
||||
.body(serde_json::to_string(&create_event).unwrap())
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(create_resp.status().is_success(), "channel creation failed");
|
||||
let channel_id = channel_uuid.to_string();
|
||||
|
||||
let sha = "a".repeat(64);
|
||||
let mut client = BuzzTestClient::connect(&relay_ws_url(), &keys)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let event = EventBuilder::new(Kind::from(9), "bad imeta")
|
||||
.tags(vec![
|
||||
Tag::parse(["h", &channel_id]).unwrap(),
|
||||
Tag::parse([
|
||||
"imeta",
|
||||
&format!("url https://evil.com/media/{sha}.jpg"),
|
||||
"m image/jpeg",
|
||||
&format!("x {sha}"),
|
||||
"size 347",
|
||||
])
|
||||
.unwrap(),
|
||||
])
|
||||
.sign_with_keys(&keys)
|
||||
.unwrap();
|
||||
|
||||
let ok = client.send_event(event).await.unwrap();
|
||||
assert!(!ok.accepted, "external URL imeta via WS must be rejected");
|
||||
assert!(
|
||||
ok.message.contains("invalid"),
|
||||
"rejection message must contain 'invalid': {:?}",
|
||||
ok.message
|
||||
);
|
||||
println!(
|
||||
"✅ WS invalid imeta (external URL) rejected: {:?}",
|
||||
ok.message
|
||||
);
|
||||
|
||||
client.disconnect().await.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn test_ws_invalid_imeta_missing_fields() {
|
||||
use buzz_test_client::BuzzTestClient;
|
||||
|
||||
let keys = Keys::generate();
|
||||
let pubkey_hex = keys.public_key().to_hex();
|
||||
let http = http_client();
|
||||
|
||||
let channel_uuid = uuid::Uuid::new_v4();
|
||||
let channel_name = format!("ws-imeta-miss-{}", channel_uuid);
|
||||
let create_event = EventBuilder::new(Kind::from(9007), "")
|
||||
.tags(vec![
|
||||
Tag::parse(["h", &channel_uuid.to_string()]).unwrap(),
|
||||
Tag::parse(["name", &channel_name]).unwrap(),
|
||||
Tag::parse(["channel_type", "stream"]).unwrap(),
|
||||
Tag::parse(["visibility", "open"]).unwrap(),
|
||||
])
|
||||
.sign_with_keys(&keys)
|
||||
.unwrap();
|
||||
let create_resp = http
|
||||
.post(format!("{}/events", relay_http_url()))
|
||||
.header("X-Pubkey", &pubkey_hex)
|
||||
.header("Content-Type", "application/json")
|
||||
.body(serde_json::to_string(&create_event).unwrap())
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(create_resp.status().is_success(), "channel creation failed");
|
||||
let channel_id = channel_uuid.to_string();
|
||||
|
||||
let sha = "b".repeat(64);
|
||||
let mut client = BuzzTestClient::connect(&relay_ws_url(), &keys)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Only url, missing m/x/size
|
||||
let event = EventBuilder::new(Kind::from(9), "incomplete imeta")
|
||||
.tags(vec![
|
||||
Tag::parse(["h", &channel_id]).unwrap(),
|
||||
Tag::parse([
|
||||
"imeta",
|
||||
&format!("url http://localhost:3000/media/{sha}.jpg"),
|
||||
])
|
||||
.unwrap(),
|
||||
])
|
||||
.sign_with_keys(&keys)
|
||||
.unwrap();
|
||||
|
||||
let ok = client.send_event(event).await.unwrap();
|
||||
assert!(!ok.accepted, "incomplete imeta via WS must be rejected");
|
||||
println!("✅ WS incomplete imeta rejected: {:?}", ok.message);
|
||||
|
||||
client.disconnect().await.unwrap();
|
||||
}
|
||||
@@ -0,0 +1,678 @@
|
||||
//! End-to-end video upload tests (Blossom protocol, MP4/H.264).
|
||||
//!
|
||||
//! Requires: relay running at localhost:3000, MinIO running at localhost:9000.
|
||||
//! All tests are `#[ignore]` so they don't run in CI by default.
|
||||
//!
|
||||
//! # Running
|
||||
//!
|
||||
//! ```text
|
||||
//! cargo test -p buzz-test-client --test e2e_media_video -- --ignored --nocapture
|
||||
//! ```
|
||||
|
||||
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
|
||||
use nostr::{EventBuilder, JsonUtil, Keys, Kind, Tag, Timestamp};
|
||||
use reqwest::{Client, StatusCode};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::time::Duration;
|
||||
|
||||
fn relay_http_url() -> String {
|
||||
std::env::var("RELAY_HTTP_URL").unwrap_or_else(|_| "http://localhost:3000".to_string())
|
||||
}
|
||||
|
||||
fn http_client() -> Client {
|
||||
Client::builder()
|
||||
.timeout(Duration::from_secs(30))
|
||||
.build()
|
||||
.expect("failed to build HTTP client")
|
||||
}
|
||||
|
||||
fn sign_blossom_auth(keys: &Keys, sha256: &str) -> nostr::Event {
|
||||
let now = Timestamp::now().as_secs();
|
||||
let exp_str = (now + 300).to_string();
|
||||
let tags = vec![
|
||||
Tag::parse(["t", "upload"]).expect("t tag"),
|
||||
Tag::parse(["x", sha256]).expect("x tag"),
|
||||
Tag::parse(["expiration", &exp_str]).expect("expiration tag"),
|
||||
];
|
||||
EventBuilder::new(Kind::from(24242), "Upload test")
|
||||
.tags(tags)
|
||||
.sign_with_keys(keys)
|
||||
.expect("sign blossom auth")
|
||||
}
|
||||
|
||||
/// Sign a kind:24242 Blossom *read* auth event. Reads are authenticated
|
||||
/// unconditionally, so blob and range GETs must present one of these -- without it
|
||||
/// the 206 and 416 range behaviour below would never be reached.
|
||||
fn sign_blossom_get_auth(keys: &Keys, sha256: &str) -> nostr::Event {
|
||||
let now = Timestamp::now().as_secs();
|
||||
let exp_str = (now + 300).to_string();
|
||||
let tags = vec![
|
||||
Tag::parse(["t", "get"]).expect("t tag"),
|
||||
Tag::parse(["x", sha256]).expect("x tag"),
|
||||
Tag::parse(["expiration", &exp_str]).expect("expiration tag"),
|
||||
];
|
||||
EventBuilder::new(Kind::from(24242), "Get test")
|
||||
.tags(tags)
|
||||
.sign_with_keys(keys)
|
||||
.expect("sign blossom get auth")
|
||||
}
|
||||
|
||||
fn blossom_auth_header(event: &nostr::Event) -> String {
|
||||
format!(
|
||||
"Nostr {}",
|
||||
URL_SAFE_NO_PAD.encode(event.as_json().as_bytes())
|
||||
)
|
||||
}
|
||||
|
||||
/// Build a minimal but structurally valid fast-start MP4 (H.264, 1s, 320×240).
|
||||
///
|
||||
/// Layout: ftyp | moov(mvhd + trak(tkhd + mdia(mdhd + hdlr + minf(vmhd + dinf + stbl)))) | mdat
|
||||
/// This is enough for `infer` to detect video/mp4 and for the `mp4` crate to parse.
|
||||
fn build_test_mp4() -> Vec<u8> {
|
||||
fn box_wrap(fourcc: &[u8; 4], payload: &[u8]) -> Vec<u8> {
|
||||
let size = (8 + payload.len()) as u32;
|
||||
let mut b = Vec::new();
|
||||
b.extend_from_slice(&size.to_be_bytes());
|
||||
b.extend_from_slice(fourcc);
|
||||
b.extend_from_slice(payload);
|
||||
b
|
||||
}
|
||||
|
||||
// ftyp
|
||||
let ftyp = {
|
||||
let mut b = Vec::new();
|
||||
b.extend_from_slice(&20u32.to_be_bytes());
|
||||
b.extend_from_slice(b"ftyp");
|
||||
b.extend_from_slice(b"isom");
|
||||
b.extend_from_slice(&0u32.to_be_bytes());
|
||||
b.extend_from_slice(b"isom");
|
||||
b
|
||||
};
|
||||
|
||||
// mvhd (version 0, timescale=1000, duration=1000ms)
|
||||
let mvhd_payload = {
|
||||
let mut b = vec![0u8; 4]; // version=0, flags=0
|
||||
b.extend_from_slice(&0u32.to_be_bytes()); // creation_time
|
||||
b.extend_from_slice(&0u32.to_be_bytes()); // modification_time
|
||||
b.extend_from_slice(&1000u32.to_be_bytes()); // timescale
|
||||
b.extend_from_slice(&1000u32.to_be_bytes()); // duration
|
||||
b.extend_from_slice(&0x00010000u32.to_be_bytes()); // rate
|
||||
b.extend_from_slice(&0x0100u16.to_be_bytes()); // volume
|
||||
b.extend_from_slice(&[0u8; 10]); // reserved
|
||||
// identity matrix (9 × u32)
|
||||
for &v in &[0x00010000u32, 0, 0, 0, 0x00010000, 0, 0, 0, 0x40000000] {
|
||||
b.extend_from_slice(&v.to_be_bytes());
|
||||
}
|
||||
b.extend_from_slice(&[0u8; 24]); // pre_defined
|
||||
b.extend_from_slice(&2u32.to_be_bytes()); // next_track_id
|
||||
b
|
||||
};
|
||||
let mvhd = box_wrap(b"mvhd", &mvhd_payload);
|
||||
|
||||
// tkhd
|
||||
let tkhd_payload = {
|
||||
let mut b = vec![0u8, 0, 0, 3]; // version=0, flags=3
|
||||
b.extend_from_slice(&0u32.to_be_bytes()); // creation
|
||||
b.extend_from_slice(&0u32.to_be_bytes()); // modification
|
||||
b.extend_from_slice(&1u32.to_be_bytes()); // track_id
|
||||
b.extend_from_slice(&0u32.to_be_bytes()); // reserved
|
||||
b.extend_from_slice(&1000u32.to_be_bytes()); // duration
|
||||
b.extend_from_slice(&[0u8; 8]); // reserved
|
||||
b.extend_from_slice(&0i16.to_be_bytes()); // layer
|
||||
b.extend_from_slice(&0i16.to_be_bytes()); // alternate_group
|
||||
b.extend_from_slice(&0u16.to_be_bytes()); // volume
|
||||
b.extend_from_slice(&0u16.to_be_bytes()); // reserved
|
||||
for &v in &[0x00010000u32, 0, 0, 0, 0x00010000, 0, 0, 0, 0x40000000] {
|
||||
b.extend_from_slice(&v.to_be_bytes());
|
||||
}
|
||||
b.extend_from_slice(&(320u32 << 16).to_be_bytes()); // width 16.16
|
||||
b.extend_from_slice(&(240u32 << 16).to_be_bytes()); // height 16.16
|
||||
b
|
||||
};
|
||||
let tkhd = box_wrap(b"tkhd", &tkhd_payload);
|
||||
|
||||
// mdhd
|
||||
let mdhd_payload = {
|
||||
let mut b = vec![0u8; 4];
|
||||
b.extend_from_slice(&0u32.to_be_bytes());
|
||||
b.extend_from_slice(&0u32.to_be_bytes());
|
||||
b.extend_from_slice(&1000u32.to_be_bytes()); // timescale
|
||||
b.extend_from_slice(&1000u32.to_be_bytes()); // duration
|
||||
b.extend_from_slice(&0u16.to_be_bytes()); // language
|
||||
b.extend_from_slice(&0u16.to_be_bytes()); // pre_defined
|
||||
b
|
||||
};
|
||||
let mdhd = box_wrap(b"mdhd", &mdhd_payload);
|
||||
|
||||
// hdlr (video)
|
||||
let hdlr_payload = {
|
||||
let mut b = vec![0u8; 4];
|
||||
b.extend_from_slice(&0u32.to_be_bytes()); // pre_defined
|
||||
b.extend_from_slice(b"vide");
|
||||
b.extend_from_slice(&[0u8; 12]); // reserved
|
||||
b.extend_from_slice(b"VideoHandler\0");
|
||||
b
|
||||
};
|
||||
let hdlr = box_wrap(b"hdlr", &hdlr_payload);
|
||||
|
||||
// vmhd
|
||||
let vmhd_payload = {
|
||||
let mut b = vec![0u8, 0, 0, 1]; // flags=1
|
||||
b.extend_from_slice(&0u16.to_be_bytes());
|
||||
b.extend_from_slice(&[0u8; 6]);
|
||||
b
|
||||
};
|
||||
let vmhd = box_wrap(b"vmhd", &vmhd_payload);
|
||||
|
||||
// dinf -> dref -> url
|
||||
let url_box = box_wrap(b"url ", &[0, 0, 0, 1]);
|
||||
let dref_payload = {
|
||||
let mut b = vec![0u8; 4];
|
||||
b.extend_from_slice(&1u32.to_be_bytes());
|
||||
b.extend_from_slice(&url_box);
|
||||
b
|
||||
};
|
||||
let dref = box_wrap(b"dref", &dref_payload);
|
||||
let dinf = box_wrap(b"dinf", &dref);
|
||||
|
||||
// stsd -> avc1 (H.264)
|
||||
let avc1_entry = {
|
||||
let mut b = vec![0u8; 6]; // reserved
|
||||
b.extend_from_slice(&1u16.to_be_bytes()); // data_ref_idx
|
||||
b.extend_from_slice(&[0u8; 2]); // pre_defined
|
||||
b.extend_from_slice(&[0u8; 2]); // reserved
|
||||
b.extend_from_slice(&[0u8; 12]); // pre_defined
|
||||
b.extend_from_slice(&320u16.to_be_bytes()); // width
|
||||
b.extend_from_slice(&240u16.to_be_bytes()); // height
|
||||
b.extend_from_slice(&0x00480000u32.to_be_bytes()); // horiz_res
|
||||
b.extend_from_slice(&0x00480000u32.to_be_bytes()); // vert_res
|
||||
b.extend_from_slice(&0u32.to_be_bytes()); // reserved
|
||||
b.extend_from_slice(&1u16.to_be_bytes()); // frame_count
|
||||
b.extend_from_slice(&[0u8; 32]); // compressorname
|
||||
b.extend_from_slice(&0x0018u16.to_be_bytes()); // depth
|
||||
b.extend_from_slice(&(-1i16).to_be_bytes()); // pre_defined
|
||||
// avcC
|
||||
let avcc = vec![
|
||||
0x01, 0x42, 0x00, 0x1E, 0xFF, 0xE1, 0x00, 0x00, 0x01, 0x00, 0x00,
|
||||
];
|
||||
b.extend_from_slice(&box_wrap(b"avcC", &avcc));
|
||||
b
|
||||
};
|
||||
let avc1 = box_wrap(b"avc1", &avc1_entry);
|
||||
let stsd_payload = {
|
||||
let mut b = vec![0u8; 4];
|
||||
b.extend_from_slice(&1u32.to_be_bytes());
|
||||
b.extend_from_slice(&avc1);
|
||||
b
|
||||
};
|
||||
let stsd = box_wrap(b"stsd", &stsd_payload);
|
||||
|
||||
// Minimal sample tables
|
||||
let stts = box_wrap(b"stts", &{
|
||||
let mut b = vec![0u8; 4];
|
||||
b.extend_from_slice(&1u32.to_be_bytes());
|
||||
b.extend_from_slice(&1u32.to_be_bytes());
|
||||
b.extend_from_slice(&1000u32.to_be_bytes());
|
||||
b
|
||||
});
|
||||
let stsc = box_wrap(b"stsc", &{
|
||||
let mut b = vec![0u8; 4];
|
||||
b.extend_from_slice(&1u32.to_be_bytes());
|
||||
b.extend_from_slice(&1u32.to_be_bytes());
|
||||
b.extend_from_slice(&1u32.to_be_bytes());
|
||||
b.extend_from_slice(&1u32.to_be_bytes());
|
||||
b
|
||||
});
|
||||
let stsz = box_wrap(b"stsz", &{
|
||||
let mut b = vec![0u8; 4];
|
||||
b.extend_from_slice(&0u32.to_be_bytes());
|
||||
b.extend_from_slice(&1u32.to_be_bytes());
|
||||
b.extend_from_slice(&0u32.to_be_bytes());
|
||||
b
|
||||
});
|
||||
let stco = box_wrap(b"stco", &{
|
||||
let mut b = vec![0u8; 4];
|
||||
b.extend_from_slice(&1u32.to_be_bytes());
|
||||
b.extend_from_slice(&28u32.to_be_bytes());
|
||||
b
|
||||
});
|
||||
|
||||
let stbl_payload = [&stsd[..], &stts, &stsc, &stsz, &stco].concat();
|
||||
let stbl = box_wrap(b"stbl", &stbl_payload);
|
||||
let minf_payload = [&vmhd[..], &dinf, &stbl].concat();
|
||||
let minf = box_wrap(b"minf", &minf_payload);
|
||||
let mdia_payload = [&mdhd[..], &hdlr, &minf].concat();
|
||||
let mdia = box_wrap(b"mdia", &mdia_payload);
|
||||
let trak_payload = [&tkhd[..], &mdia].concat();
|
||||
let trak = box_wrap(b"trak", &trak_payload);
|
||||
let moov_payload = [&mvhd[..], &trak].concat();
|
||||
let moov = box_wrap(b"moov", &moov_payload);
|
||||
let mdat = box_wrap(b"mdat", &[]);
|
||||
|
||||
[ftyp, moov, mdat].concat()
|
||||
}
|
||||
|
||||
/// Upload a valid MP4 video via Blossom, verify the BlobDescriptor includes
|
||||
/// video-specific fields (duration, dim) and the blob is retrievable.
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn test_video_upload_and_get() {
|
||||
let client = http_client();
|
||||
let keys = Keys::generate();
|
||||
let mp4 = build_test_mp4();
|
||||
let sha256 = hex::encode(Sha256::digest(&mp4));
|
||||
|
||||
let auth = sign_blossom_auth(&keys, &sha256);
|
||||
let url = format!("{}/upload", relay_http_url());
|
||||
|
||||
let resp = client
|
||||
.put(&url)
|
||||
.header("Authorization", blossom_auth_header(&auth))
|
||||
.header("X-SHA-256", &sha256)
|
||||
.header("Content-Type", "video/mp4")
|
||||
.body(mp4.clone())
|
||||
.send()
|
||||
.await
|
||||
.expect("upload request");
|
||||
|
||||
assert_eq!(resp.status(), StatusCode::OK, "upload should succeed");
|
||||
|
||||
let desc: serde_json::Value = resp.json().await.expect("json body");
|
||||
assert_eq!(desc["sha256"].as_str().unwrap(), sha256);
|
||||
assert_eq!(desc["type"].as_str().unwrap(), "video/mp4");
|
||||
assert!(desc["size"].as_u64().unwrap() > 0);
|
||||
// Video descriptor should have duration
|
||||
assert!(
|
||||
desc.get("duration").is_some(),
|
||||
"video descriptor should include duration"
|
||||
);
|
||||
|
||||
// GET the blob back
|
||||
let get_url = desc["url"].as_str().unwrap();
|
||||
let get_resp = client
|
||||
.get(get_url)
|
||||
.header(
|
||||
"Authorization",
|
||||
blossom_auth_header(&sign_blossom_get_auth(&keys, &sha256)),
|
||||
)
|
||||
.send()
|
||||
.await
|
||||
.expect("GET blob");
|
||||
assert_eq!(get_resp.status(), StatusCode::OK);
|
||||
let body = get_resp.bytes().await.expect("body bytes");
|
||||
assert_eq!(body.len(), mp4.len());
|
||||
}
|
||||
|
||||
/// The relay ignores the Content-Type header and sniffs magic bytes. MP4
|
||||
/// uploaded with a spoofed image/jpeg header is detected as video/mp4 and
|
||||
/// accepted through the streaming media validator, including on the legacy
|
||||
/// compatibility route.
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn test_video_content_type_header_ignored() {
|
||||
let client = http_client();
|
||||
let keys = Keys::generate();
|
||||
let mp4 = build_test_mp4();
|
||||
let sha256 = hex::encode(Sha256::digest(&mp4));
|
||||
|
||||
let auth = sign_blossom_auth(&keys, &sha256);
|
||||
let url = format!("{}/media/upload", relay_http_url());
|
||||
|
||||
// Upload MP4 bytes but claim it's image/jpeg
|
||||
let resp = client
|
||||
.put(&url)
|
||||
.header("Authorization", blossom_auth_header(&auth))
|
||||
.header("X-SHA-256", &sha256)
|
||||
.header("Content-Type", "image/jpeg")
|
||||
.body(mp4)
|
||||
.send()
|
||||
.await
|
||||
.expect("upload request");
|
||||
|
||||
assert_eq!(
|
||||
resp.status().as_u16(),
|
||||
200,
|
||||
"MP4 with spoofed Content-Type should be accepted, got {}",
|
||||
resp.status()
|
||||
);
|
||||
let desc: serde_json::Value = resp.json().await.unwrap();
|
||||
assert_eq!(desc["type"].as_str().unwrap(), "video/mp4");
|
||||
println!(
|
||||
"✅ MP4 with spoofed Content-Type → 200 as video/mp4 (header ignored, magic bytes used)"
|
||||
);
|
||||
}
|
||||
|
||||
/// Range request on a video blob should return 206 Partial Content.
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn test_video_range_request_206() {
|
||||
let client = http_client();
|
||||
let keys = Keys::generate();
|
||||
let mp4 = build_test_mp4();
|
||||
let sha256 = hex::encode(Sha256::digest(&mp4));
|
||||
|
||||
// Upload first
|
||||
let auth = sign_blossom_auth(&keys, &sha256);
|
||||
let url = format!("{}/media/upload", relay_http_url());
|
||||
let resp = client
|
||||
.put(&url)
|
||||
.header("Authorization", blossom_auth_header(&auth))
|
||||
.header("X-SHA-256", &sha256)
|
||||
.header("Content-Type", "video/mp4")
|
||||
.body(mp4.clone())
|
||||
.send()
|
||||
.await
|
||||
.expect("upload");
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
let desc: serde_json::Value = resp.json().await.unwrap();
|
||||
let blob_url = desc["url"].as_str().unwrap();
|
||||
|
||||
// Range request: first 100 bytes
|
||||
let range_resp = client
|
||||
.get(blob_url)
|
||||
.header(
|
||||
"Authorization",
|
||||
blossom_auth_header(&sign_blossom_get_auth(&keys, &sha256)),
|
||||
)
|
||||
.header("Range", "bytes=0-99")
|
||||
.send()
|
||||
.await
|
||||
.expect("range GET");
|
||||
|
||||
assert_eq!(range_resp.status(), StatusCode::PARTIAL_CONTENT);
|
||||
assert!(range_resp.headers().get("content-range").is_some());
|
||||
assert!(range_resp
|
||||
.headers()
|
||||
.get("accept-ranges")
|
||||
.is_some_and(|v| v == "bytes"));
|
||||
let body = range_resp.bytes().await.unwrap();
|
||||
assert_eq!(body.len(), 100);
|
||||
assert_eq!(&body[..], &mp4[..100]);
|
||||
}
|
||||
|
||||
/// Unsatisfiable range request should return 416.
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn test_video_range_request_416() {
|
||||
let client = http_client();
|
||||
let keys = Keys::generate();
|
||||
let mp4 = build_test_mp4();
|
||||
let sha256 = hex::encode(Sha256::digest(&mp4));
|
||||
|
||||
// Upload first
|
||||
let auth = sign_blossom_auth(&keys, &sha256);
|
||||
let url = format!("{}/media/upload", relay_http_url());
|
||||
let resp = client
|
||||
.put(&url)
|
||||
.header("Authorization", blossom_auth_header(&auth))
|
||||
.header("X-SHA-256", &sha256)
|
||||
.header("Content-Type", "video/mp4")
|
||||
.body(mp4.clone())
|
||||
.send()
|
||||
.await
|
||||
.expect("upload");
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
let desc: serde_json::Value = resp.json().await.unwrap();
|
||||
let blob_url = desc["url"].as_str().unwrap();
|
||||
|
||||
// Request a range beyond the file size
|
||||
let range_resp = client
|
||||
.get(blob_url)
|
||||
.header(
|
||||
"Authorization",
|
||||
blossom_auth_header(&sign_blossom_get_auth(&keys, &sha256)),
|
||||
)
|
||||
.header(
|
||||
"Range",
|
||||
format!("bytes={}-{}", mp4.len() + 1000, mp4.len() + 2000),
|
||||
)
|
||||
.send()
|
||||
.await
|
||||
.expect("range GET");
|
||||
|
||||
assert_eq!(
|
||||
range_resp.status(),
|
||||
StatusCode::RANGE_NOT_SATISFIABLE,
|
||||
"out-of-range request should return 416"
|
||||
);
|
||||
}
|
||||
|
||||
/// Upload without auth should return 401.
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn test_video_upload_no_auth_returns_401() {
|
||||
let client = http_client();
|
||||
let mp4 = build_test_mp4();
|
||||
let url = format!("{}/media/upload", relay_http_url());
|
||||
|
||||
let resp = client
|
||||
.put(&url)
|
||||
.header("Content-Type", "video/mp4")
|
||||
.body(mp4)
|
||||
.send()
|
||||
.await
|
||||
.expect("upload request");
|
||||
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
StatusCode::UNAUTHORIZED,
|
||||
"upload without auth should return 401"
|
||||
);
|
||||
}
|
||||
|
||||
fn relay_ws_url() -> String {
|
||||
relay_http_url()
|
||||
.replace("http://", "ws://")
|
||||
.replace("https://", "wss://")
|
||||
}
|
||||
|
||||
/// Minimal valid JPEG (1x1 pixel) — used as a poster frame blob.
|
||||
fn tiny_jpeg() -> Vec<u8> {
|
||||
vec![
|
||||
0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 0x4A, 0x46, 0x49, 0x46, 0x00, 0x01, 0x01, 0x00, 0x00,
|
||||
0x01, 0x00, 0x01, 0x00, 0x00, 0xFF, 0xDB, 0x00, 0x43, 0x00, 0x08, 0x06, 0x06, 0x07, 0x06,
|
||||
0x05, 0x08, 0x07, 0x07, 0x07, 0x09, 0x09, 0x08, 0x0A, 0x0C, 0x14, 0x0D, 0x0C, 0x0B, 0x0B,
|
||||
0x0C, 0x19, 0x12, 0x13, 0x0F, 0x14, 0x1D, 0x1A, 0x1F, 0x1E, 0x1D, 0x1A, 0x1C, 0x1C, 0x20,
|
||||
0x24, 0x2E, 0x27, 0x20, 0x22, 0x2C, 0x23, 0x1C, 0x1C, 0x28, 0x37, 0x29, 0x2C, 0x30, 0x31,
|
||||
0x34, 0x34, 0x34, 0x1F, 0x27, 0x39, 0x3D, 0x38, 0x32, 0x3C, 0x2E, 0x33, 0x34, 0x32, 0xFF,
|
||||
0xC0, 0x00, 0x0B, 0x08, 0x00, 0x01, 0x00, 0x01, 0x01, 0x01, 0x11, 0x00, 0xFF, 0xC4, 0x00,
|
||||
0x1F, 0x00, 0x00, 0x01, 0x05, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x00, 0x00, 0x00, 0x00,
|
||||
0x00, 0x00, 0x00, 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A, 0x0B,
|
||||
0xFF, 0xC4, 0x00, 0xB5, 0x10, 0x00, 0x02, 0x01, 0x03, 0x03, 0x02, 0x04, 0x03, 0x05, 0x05,
|
||||
0x04, 0x04, 0x00, 0x00, 0x01, 0x7D, 0x01, 0x02, 0x03, 0x00, 0x04, 0x11, 0x05, 0x12, 0x21,
|
||||
0x31, 0x41, 0x06, 0x13, 0x51, 0x61, 0x07, 0x22, 0x71, 0x14, 0x32, 0x81, 0x91, 0xA1, 0x08,
|
||||
0x23, 0x42, 0xB1, 0xC1, 0x15, 0x52, 0xD1, 0xF0, 0x24, 0x33, 0x62, 0x72, 0x82, 0x09, 0x0A,
|
||||
0x16, 0x17, 0x18, 0x19, 0x1A, 0x25, 0x26, 0x27, 0x28, 0x29, 0x2A, 0x34, 0x35, 0x36, 0x37,
|
||||
0x38, 0x39, 0x3A, 0x43, 0x44, 0x45, 0x46, 0x47, 0x48, 0x49, 0x4A, 0x53, 0x54, 0x55, 0x56,
|
||||
0x57, 0x58, 0x59, 0x5A, 0x63, 0x64, 0x65, 0x66, 0x67, 0x68, 0x69, 0x6A, 0x73, 0x74, 0x75,
|
||||
0x76, 0x77, 0x78, 0x79, 0x7A, 0x83, 0x84, 0x85, 0x86, 0x87, 0x88, 0x89, 0x8A, 0x92, 0x93,
|
||||
0x94, 0x95, 0x96, 0x97, 0x98, 0x99, 0x9A, 0xA2, 0xA3, 0xA4, 0xA5, 0xA6, 0xA7, 0xA8, 0xA9,
|
||||
0xAA, 0xB2, 0xB3, 0xB4, 0xB5, 0xB6, 0xB7, 0xB8, 0xB9, 0xBA, 0xC2, 0xC3, 0xC4, 0xC5, 0xC6,
|
||||
0xC7, 0xC8, 0xC9, 0xCA, 0xD2, 0xD3, 0xD4, 0xD5, 0xD6, 0xD7, 0xD8, 0xD9, 0xDA, 0xE1, 0xE2,
|
||||
0xE3, 0xE4, 0xE5, 0xE6, 0xE7, 0xE8, 0xE9, 0xEA, 0xF1, 0xF2, 0xF3, 0xF4, 0xF5, 0xF6, 0xF7,
|
||||
0xF8, 0xF9, 0xFA, 0xFF, 0xDA, 0x00, 0x08, 0x01, 0x01, 0x00, 0x00, 0x3F, 0x00, 0x7B, 0x94,
|
||||
0x11, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||
0xFF, 0xD9,
|
||||
]
|
||||
}
|
||||
|
||||
/// Upload a video + poster, then send a message with imeta `image` field
|
||||
/// referencing the poster. The relay must accept the event.
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn test_video_poster_imeta_accepted_via_ws() {
|
||||
use buzz_test_client::BuzzTestClient;
|
||||
|
||||
let client = http_client();
|
||||
let keys = Keys::generate();
|
||||
let pubkey_hex = keys.public_key().to_hex();
|
||||
|
||||
// 1. Create a channel
|
||||
let channel_uuid = uuid::Uuid::new_v4();
|
||||
let channel_id = channel_uuid.to_string();
|
||||
let create_event = EventBuilder::new(Kind::from(9007), "")
|
||||
.tags(vec![
|
||||
Tag::parse(["h", &channel_id]).unwrap(),
|
||||
Tag::parse(["name", &format!("video-poster-test-{channel_id}")]).unwrap(),
|
||||
Tag::parse(["channel_type", "stream"]).unwrap(),
|
||||
Tag::parse(["visibility", "open"]).unwrap(),
|
||||
])
|
||||
.sign_with_keys(&keys)
|
||||
.unwrap();
|
||||
let resp = client
|
||||
.post(format!("{}/events", relay_http_url()))
|
||||
.header("X-Pubkey", &pubkey_hex)
|
||||
.header("Content-Type", "application/json")
|
||||
.body(serde_json::to_string(&create_event).unwrap())
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(resp.status().is_success(), "channel creation failed");
|
||||
|
||||
// 2. Upload video
|
||||
let mp4 = build_test_mp4();
|
||||
let video_sha = hex::encode(Sha256::digest(&mp4));
|
||||
let video_auth = sign_blossom_auth(&keys, &video_sha);
|
||||
let video_resp = client
|
||||
.put(format!("{}/media/upload", relay_http_url()))
|
||||
.header("Authorization", blossom_auth_header(&video_auth))
|
||||
.header("X-SHA-256", &video_sha)
|
||||
.header("Content-Type", "video/mp4")
|
||||
.body(mp4.clone())
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(video_resp.status(), StatusCode::OK, "video upload failed");
|
||||
let video_desc: serde_json::Value = video_resp.json().await.unwrap();
|
||||
let video_size = video_desc["size"].as_u64().unwrap();
|
||||
|
||||
// 3. Upload poster (tiny JPEG)
|
||||
let poster = tiny_jpeg();
|
||||
let poster_sha = hex::encode(Sha256::digest(&poster));
|
||||
let poster_auth = sign_blossom_auth(&keys, &poster_sha);
|
||||
let poster_resp = client
|
||||
.put(format!("{}/media/upload", relay_http_url()))
|
||||
.header("Authorization", blossom_auth_header(&poster_auth))
|
||||
.header("X-SHA-256", &poster_sha)
|
||||
.body(poster.to_vec())
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(poster_resp.status(), StatusCode::OK, "poster upload failed");
|
||||
|
||||
// 4. Send message with imeta referencing both video and poster
|
||||
let mut ws = BuzzTestClient::connect(&relay_ws_url(), &keys)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let base = relay_http_url();
|
||||
let event = EventBuilder::new(
|
||||
Kind::from(9),
|
||||
format!(""),
|
||||
)
|
||||
.tags(vec![
|
||||
Tag::parse(["h", &channel_id]).unwrap(),
|
||||
Tag::parse([
|
||||
"imeta",
|
||||
&format!("url {base}/media/{video_sha}.mp4"),
|
||||
"m video/mp4",
|
||||
&format!("x {video_sha}"),
|
||||
&format!("size {video_size}"),
|
||||
&format!("image {base}/media/{poster_sha}.jpg"),
|
||||
])
|
||||
.unwrap(),
|
||||
])
|
||||
.sign_with_keys(&keys)
|
||||
.unwrap();
|
||||
|
||||
let ok = ws.send_event(event).await.unwrap();
|
||||
assert!(
|
||||
ok.accepted,
|
||||
"video+poster imeta must be accepted: {:?}",
|
||||
ok.message
|
||||
);
|
||||
|
||||
ws.disconnect().await.unwrap();
|
||||
}
|
||||
|
||||
/// Send a message with imeta `image` pointing to the video URL (not an image).
|
||||
/// The relay must reject this — poster must be an image file, not video.
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn test_video_poster_imeta_rejects_video_as_poster() {
|
||||
use buzz_test_client::BuzzTestClient;
|
||||
|
||||
let client = http_client();
|
||||
let keys = Keys::generate();
|
||||
let pubkey_hex = keys.public_key().to_hex();
|
||||
|
||||
// 1. Create channel
|
||||
let channel_uuid = uuid::Uuid::new_v4();
|
||||
let channel_id = channel_uuid.to_string();
|
||||
let create_event = EventBuilder::new(Kind::from(9007), "")
|
||||
.tags(vec![
|
||||
Tag::parse(["h", &channel_id]).unwrap(),
|
||||
Tag::parse(["name", &format!("poster-reject-test-{channel_id}")]).unwrap(),
|
||||
Tag::parse(["channel_type", "stream"]).unwrap(),
|
||||
Tag::parse(["visibility", "open"]).unwrap(),
|
||||
])
|
||||
.sign_with_keys(&keys)
|
||||
.unwrap();
|
||||
let resp = client
|
||||
.post(format!("{}/events", relay_http_url()))
|
||||
.header("X-Pubkey", &pubkey_hex)
|
||||
.header("Content-Type", "application/json")
|
||||
.body(serde_json::to_string(&create_event).unwrap())
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(resp.status().is_success());
|
||||
|
||||
// 2. Upload video
|
||||
let mp4 = build_test_mp4();
|
||||
let video_sha = hex::encode(Sha256::digest(&mp4));
|
||||
let video_auth = sign_blossom_auth(&keys, &video_sha);
|
||||
let video_resp = client
|
||||
.put(format!("{}/media/upload", relay_http_url()))
|
||||
.header("Authorization", blossom_auth_header(&video_auth))
|
||||
.header("X-SHA-256", &video_sha)
|
||||
.header("Content-Type", "video/mp4")
|
||||
.body(mp4.clone())
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(video_resp.status(), StatusCode::OK);
|
||||
let video_desc: serde_json::Value = video_resp.json().await.unwrap();
|
||||
let video_size = video_desc["size"].as_u64().unwrap();
|
||||
|
||||
// 3. Send message with imeta `image` pointing to the VIDEO (not an image)
|
||||
let mut ws = BuzzTestClient::connect(&relay_ws_url(), &keys)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let base = relay_http_url();
|
||||
let event = EventBuilder::new(Kind::from(9), "bad poster")
|
||||
.tags(vec![
|
||||
Tag::parse(["h", &channel_id]).unwrap(),
|
||||
Tag::parse([
|
||||
"imeta",
|
||||
&format!("url {base}/media/{video_sha}.mp4"),
|
||||
"m video/mp4",
|
||||
&format!("x {video_sha}"),
|
||||
&format!("size {video_size}"),
|
||||
// BAD: image field points to the video itself (.mp4 extension)
|
||||
&format!("image {base}/media/{video_sha}.mp4"),
|
||||
])
|
||||
.unwrap(),
|
||||
])
|
||||
.sign_with_keys(&keys)
|
||||
.unwrap();
|
||||
|
||||
let ok = ws.send_event(event).await.unwrap();
|
||||
assert!(
|
||||
!ok.accepted,
|
||||
"video URL as poster must be rejected, but was accepted"
|
||||
);
|
||||
|
||||
ws.disconnect().await.unwrap();
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
//! End-to-end acceptance tests for Buzz shared compute.
|
||||
//!
|
||||
//! These tests require a membership-gated buzz-relay and a mesh-enabled desktop
|
||||
//! publishing its client-signed discovery note. Live-inference rows additionally
|
||||
//! require two desktop mesh nodes (serve + client).
|
||||
//! All tests are `#[ignore]` by default — they need infra CI does not host
|
||||
//! (native llama, multi-node, model download). The deterministic trust
|
||||
//! invariants are unit-tested in the desktop mesh module; this file is the
|
||||
//! opt-in full-stack acceptance layer.
|
||||
//!
|
||||
//! # Running (manual / runbook)
|
||||
//!
|
||||
//! ```text
|
||||
//! # 1. prepare the matching native runtime with `scripts/ensure-mesh-native-runtime.sh`
|
||||
//! # 2. start the normal membership-gated relay and a mesh-enabled desktop
|
||||
//! # 3. have that desktop publish status, then run the trust assertions:
|
||||
//! RELAY_URL=ws://localhost:3000 \
|
||||
//! cargo test --test e2e_mesh_llm trust -- --ignored --nocapture
|
||||
//! # 4. run the live A->B inference row (needs 2 mesh nodes + a small model):
|
||||
//! # point at B's local OpenAI endpoint; without it the test SKIPS (no silent pass):
|
||||
//! MESH_OPENAI_BASE=http://127.0.0.1:9337/v1 \
|
||||
//! cargo test --test e2e_mesh_llm live_agent_completes -- --ignored --nocapture
|
||||
//! # MEMBER_NSEC is the publishing desktop identity; STRANGER_NSEC is not a member:
|
||||
//! MEMBER_NSEC=nsec1... STRANGER_NSEC=nsec1... \
|
||||
//! cargo test --test e2e_mesh_llm trust -- --ignored --nocapture
|
||||
//! ```
|
||||
//!
|
||||
//! ## Acceptance matrix (= the demo, as a test)
|
||||
//! | # | Assertion | This file | Also covered by |
|
||||
//! |---|-----------|-----------|-----------------|
|
||||
//! | 1 | member reads its kind:30003 owner-bound status, no secrets | `trust_member_reads_mesh_status` | desktop discovery units |
|
||||
//! | 2 | non-member REQ for kind:30003 returns nothing | `trust_nonmember_read_denied` | relay membership tests |
|
||||
//! | 3 | untrusted Mesh owner cannot infer with a leaked join token | hardware harness | `mesh_admission_smoke` |
|
||||
//! | 4 | B's agent completes a chat against A's model over mesh | `live_agent_completes_chat_over_mesh` | runbook |
|
||||
//! | 5 | dropped member → typed auth failure reaches lastError | runbook (desktop harness) | buzz-agent `-32001` unit |
|
||||
//! | 6 | split: model too big → 2 serve nodes → chat completes | `live_split_model_completes` | runbook |
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use buzz_test_client::BuzzTestClient;
|
||||
use nostr::{Alphabet, Filter, Keys, Kind, SingleLetterTag};
|
||||
|
||||
/// NIP-51 bookmark set used for client-owned Mesh discovery notes.
|
||||
const KIND_BUZZ_MESH_MEMBER_STATUS: u16 = 30003;
|
||||
const MESH_STATUS_D_TAG_PREFIX: &str = "buzz-mesh-member-status:";
|
||||
const MESH_STATUS_TYPE: &str = "buzz-mesh-status";
|
||||
|
||||
fn relay_url() -> String {
|
||||
std::env::var("RELAY_URL").unwrap_or_else(|_| "ws://localhost:3000".to_string())
|
||||
}
|
||||
|
||||
/// Load a relay identity from an env-provided nsec. Returns `None` (and prints
|
||||
/// why) when the fixture is absent, so the caller skips rather than running
|
||||
/// against a `Keys::generate()` identity whose membership is undefined —
|
||||
/// asserting "member sees status" against a random key is the bug Perci caught.
|
||||
fn keys_from_env(var: &str) -> Option<Keys> {
|
||||
match std::env::var(var) {
|
||||
Ok(nsec) if !nsec.trim().is_empty() => match Keys::parse(nsec.trim()) {
|
||||
Ok(keys) => Some(keys),
|
||||
Err(e) => panic!("{var} is set but not a valid nsec/hex secret key: {e}"),
|
||||
},
|
||||
_ => {
|
||||
eprintln!(
|
||||
"SKIP: {var} not set — provision a relay {} identity and re-run (see module docs)",
|
||||
if var.contains("MEMBER") {
|
||||
"member"
|
||||
} else {
|
||||
"non-member"
|
||||
}
|
||||
);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn sub_id(name: &str) -> String {
|
||||
format!("e2e-mesh-{name}-{}", uuid::Uuid::new_v4().simple())
|
||||
}
|
||||
|
||||
fn mesh_status_filter() -> Filter {
|
||||
Filter::new()
|
||||
.kind(Kind::Custom(KIND_BUZZ_MESH_MEMBER_STATUS))
|
||||
.custom_tag(SingleLetterTag::lowercase(Alphabet::K), MESH_STATUS_TYPE)
|
||||
}
|
||||
|
||||
/// Assertion 1: an authenticated relay member can read its own client-signed
|
||||
/// kind:30003 status. The content carries an owner binding and EndpointAddr dial
|
||||
/// pointers, but no secret keys or local paths.
|
||||
///
|
||||
/// Requires a mesh-enabled relay that has published at least one status event.
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn trust_member_reads_mesh_status() {
|
||||
let url = relay_url();
|
||||
let Some(member) = keys_from_env("MEMBER_NSEC") else {
|
||||
return;
|
||||
};
|
||||
let mut client = BuzzTestClient::connect(&url, &member)
|
||||
.await
|
||||
.expect("member connect+auth");
|
||||
|
||||
let sid = sub_id("member-read");
|
||||
client
|
||||
.subscribe(&sid, vec![mesh_status_filter()])
|
||||
.await
|
||||
.expect("subscribe");
|
||||
let events = client
|
||||
.collect_until_eose(&sid, Duration::from_secs(10))
|
||||
.await
|
||||
.expect("collect");
|
||||
|
||||
let status = events
|
||||
.iter()
|
||||
.find(|e| {
|
||||
e.kind == Kind::Custom(KIND_BUZZ_MESH_MEMBER_STATUS) && e.pubkey == member.public_key()
|
||||
})
|
||||
.expect("the publishing member must see its kind:30003 mesh status event");
|
||||
|
||||
assert_eq!(
|
||||
status.pubkey,
|
||||
member.public_key(),
|
||||
"status must be signed by the desktop member, not the relay"
|
||||
);
|
||||
assert!(status.tags.iter().any(|tag| {
|
||||
let values = tag.as_slice();
|
||||
values.first().map(String::as_str) == Some("d")
|
||||
&& values
|
||||
.get(1)
|
||||
.is_some_and(|value| value.starts_with(MESH_STATUS_D_TAG_PREFIX))
|
||||
}));
|
||||
|
||||
let content: serde_json::Value =
|
||||
serde_json::from_str(&status.content).expect("content is JSON");
|
||||
for field in ["ownerId", "ownerVerifyingKey", "ownerBindingSig"] {
|
||||
assert!(
|
||||
content[field]
|
||||
.as_str()
|
||||
.is_some_and(|value| !value.trim().is_empty()),
|
||||
"status carries {field}"
|
||||
);
|
||||
}
|
||||
|
||||
// Dial pointer present (EndpointAddr is connectivity, not a secret).
|
||||
let targets = content["serveTargets"]
|
||||
.as_array()
|
||||
.expect("serveTargets array");
|
||||
if let Some(t) = targets.first() {
|
||||
assert!(
|
||||
t.get("endpointAddr").is_some(),
|
||||
"serve target carries its EndpointAddr dial pointer"
|
||||
);
|
||||
}
|
||||
|
||||
// No secrets / no local-machine leakage in the published projection.
|
||||
let raw = status.content.to_lowercase();
|
||||
for forbidden in [
|
||||
"nsec",
|
||||
"secret",
|
||||
"/users/",
|
||||
"/home/",
|
||||
"runtime_dir",
|
||||
"local_path",
|
||||
] {
|
||||
assert!(
|
||||
!raw.contains(forbidden),
|
||||
"published status must not leak `{forbidden}`"
|
||||
);
|
||||
}
|
||||
|
||||
client.disconnect().await.ok();
|
||||
}
|
||||
|
||||
/// Assertion 2: a valid Nostr identity that is NOT a relay member gets nothing
|
||||
/// back for a kind:30003 mesh-status REQ — membership gates the read.
|
||||
///
|
||||
/// Requires a relay with `BUZZ_REQUIRE_RELAY_MEMBERSHIP=true` and a published
|
||||
/// status event that members can see (paired with assertion 1).
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn trust_nonmember_read_denied() {
|
||||
let url = relay_url();
|
||||
let Some(stranger) = keys_from_env("STRANGER_NSEC") else {
|
||||
return;
|
||||
};
|
||||
let mut client = match BuzzTestClient::connect(&url, &stranger).await {
|
||||
Ok(c) => c,
|
||||
// A closed relay may refuse NIP-42 auth for a non-member outright —
|
||||
// that is also a valid "denied" outcome.
|
||||
Err(_) => return,
|
||||
};
|
||||
|
||||
let sid = sub_id("stranger-read");
|
||||
client
|
||||
.subscribe(&sid, vec![mesh_status_filter()])
|
||||
.await
|
||||
.expect("subscribe");
|
||||
let events = client
|
||||
.collect_until_eose(&sid, Duration::from_secs(10))
|
||||
.await
|
||||
.expect("collect");
|
||||
|
||||
let leaked = events
|
||||
.iter()
|
||||
.any(|e| e.kind == Kind::Custom(KIND_BUZZ_MESH_MEMBER_STATUS));
|
||||
assert!(
|
||||
!leaked,
|
||||
"non-member must NOT receive kind:30003 mesh status"
|
||||
);
|
||||
|
||||
client.disconnect().await.ok();
|
||||
}
|
||||
|
||||
/// Assertion 4 (the headline demo): with desktop A serving a model and desktop
|
||||
/// B running a mesh client + a launched buzz-agent pointed at B's local
|
||||
/// `:9337/v1`, a chat completion returns a non-empty response routed over the
|
||||
/// mesh to A's GPU.
|
||||
///
|
||||
/// This needs two live mesh nodes + a small served model — runbook only, never
|
||||
/// in default CI. Left as a documented, compiling placeholder so the acceptance
|
||||
/// matrix is executable code, not prose; wire the live harness when M1 lands.
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn live_agent_completes_chat_over_mesh() {
|
||||
// RUNBOOK (M1 hardware): see module docs.
|
||||
// A: Share compute → serve a small model. B: mesh client up on :9337.
|
||||
// Point this test at B's local OpenAI endpoint via MESH_OPENAI_BASE
|
||||
// (e.g. http://127.0.0.1:9337/v1). When set, we drive a real completion
|
||||
// over the mesh and assert non-empty output — no endpoint, no silent pass.
|
||||
let Ok(base) = std::env::var("MESH_OPENAI_BASE") else {
|
||||
eprintln!(
|
||||
"SKIP: MESH_OPENAI_BASE not set — needs a live mesh client endpoint (see module docs)"
|
||||
);
|
||||
return;
|
||||
};
|
||||
let base = base.trim_end_matches('/').to_string();
|
||||
let http = reqwest::Client::new();
|
||||
|
||||
// Resolve the served model id (the node assigns its own, not our ref).
|
||||
let models: serde_json::Value = http
|
||||
.get(format!("{base}/models"))
|
||||
.send()
|
||||
.await
|
||||
.expect("GET /models")
|
||||
.json()
|
||||
.await
|
||||
.expect("/models JSON");
|
||||
let model_id = models["data"][0]["id"]
|
||||
.as_str()
|
||||
.expect("at least one model served over the mesh")
|
||||
.to_string();
|
||||
|
||||
let resp: serde_json::Value = http
|
||||
.post(format!("{base}/chat/completions"))
|
||||
.json(&serde_json::json!({
|
||||
"model": model_id,
|
||||
"messages": [{"role": "user", "content": "Reply with exactly one word: PONG"}],
|
||||
"max_tokens": 512,
|
||||
"temperature": 0.0,
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.expect("POST /chat/completions over mesh")
|
||||
.json()
|
||||
.await
|
||||
.expect("completion JSON");
|
||||
|
||||
let content = resp["choices"][0]["message"]["content"]
|
||||
.as_str()
|
||||
.expect("completion has message content");
|
||||
assert!(
|
||||
!content.trim().is_empty(),
|
||||
"chat completion over the mesh must return non-empty content"
|
||||
);
|
||||
}
|
||||
|
||||
/// Assertion 6 (split): a model too large for one node + two serve nodes in the
|
||||
/// same mesh → mesh auto-splits → the same chat (assertion 4) completes via the
|
||||
/// split route. Auto-split is mesh runtime behavior (no Buzz code); this row
|
||||
/// only verifies two serve desktops in one mesh produce a working split.
|
||||
///
|
||||
/// Runbook only — needs a known too-large-for-one-node fixture + 2 serve nodes.
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn live_split_model_completes() {
|
||||
// RUNBOOK: A + C both serve the oversized model into the same mesh; B's
|
||||
// agent completes a chat; mesh elects a split topology (>=2 stage participants).
|
||||
// Genuinely multi-node — cannot be automated single-process. Skips in CI;
|
||||
// run manually with a real split harness.
|
||||
println!("SKIP: live_split_model_completes is a manual runbook test — needs 2 serve nodes (see module docs)");
|
||||
return;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,491 @@
|
||||
//! End-to-end tests for kind:30621 multi-repo project events (NIP-MP).
|
||||
//!
|
||||
//! The ingest unit tests in `buzz-relay` pin the envelope contract in isolation.
|
||||
//! These tests cover the three behaviors that only exist once an event reaches
|
||||
//! storage, plus proof that the envelope validator is actually wired into the
|
||||
//! live write path:
|
||||
//! - a valid cross-owner project round-trips through its NIP-33 coordinate;
|
||||
//! - replacement is keyed by `(pubkey, 30621, d)` — newer wins for one author,
|
||||
//! and two authors sharing a `d` hold two independent projects (this is what
|
||||
//! makes owner-only editing free rather than a relay permission check);
|
||||
//! - a NIP-09 `a`-tag tombstone removes the project coordinate and leaves every
|
||||
//! referenced kind:30617 announcement untouched, because membership is an
|
||||
//! assertion about repositories and never authority over them;
|
||||
//! - malformed envelopes are refused by the relay, not merely by the validator.
|
||||
//!
|
||||
//! See `docs/nips/NIP-MP.md` for the normative contract.
|
||||
//!
|
||||
//! # Running
|
||||
//!
|
||||
//! Start the relay, then run:
|
||||
//!
|
||||
//! ```text
|
||||
//! RELAY_URL=ws://localhost:3000 cargo test -p buzz-test-client --test e2e_project -- --ignored
|
||||
//! ```
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use buzz_test_client::BuzzTestClient;
|
||||
use nostr::{Alphabet, EventBuilder, Filter, Keys, Kind, SingleLetterTag, Tag, Timestamp};
|
||||
|
||||
const PROJECT_KIND: u16 = 30621;
|
||||
const REPO_ANNOUNCEMENT_KIND: u16 = 30617;
|
||||
|
||||
fn relay_url() -> String {
|
||||
std::env::var("RELAY_URL").unwrap_or_else(|_| "ws://localhost:3000".to_string())
|
||||
}
|
||||
|
||||
fn sub_id(name: &str) -> String {
|
||||
format!("e2e-project-{name}-{}", uuid::Uuid::new_v4())
|
||||
}
|
||||
|
||||
/// A short unique suffix so concurrent runs never collide on a `d` tag.
|
||||
fn unique(prefix: &str) -> String {
|
||||
format!("{prefix}-{}", &uuid::Uuid::new_v4().to_string()[..8])
|
||||
}
|
||||
|
||||
fn member_coord(owner: &Keys, repo_d: &str) -> String {
|
||||
format!(
|
||||
"{REPO_ANNOUNCEMENT_KIND}:{}:{repo_d}",
|
||||
owner.public_key().to_hex()
|
||||
)
|
||||
}
|
||||
|
||||
/// Build a project event. `members` are canonical `30617:<owner>:<d>`
|
||||
/// coordinates; `created_at` defaults to now when `None`.
|
||||
fn project_event(
|
||||
keys: &Keys,
|
||||
d_tag: &str,
|
||||
name: &str,
|
||||
members: &[String],
|
||||
created_at: Option<u64>,
|
||||
) -> nostr::Event {
|
||||
let mut tags = vec![
|
||||
Tag::parse(["d", d_tag]).unwrap(),
|
||||
Tag::parse(["name", name]).unwrap(),
|
||||
];
|
||||
tags.extend(
|
||||
members
|
||||
.iter()
|
||||
.map(|m| Tag::parse(["a", m.as_str()]).unwrap()),
|
||||
);
|
||||
let builder = EventBuilder::new(Kind::Custom(PROJECT_KIND), "").tags(tags);
|
||||
match created_at {
|
||||
Some(ts) => builder.custom_created_at(Timestamp::from(ts)),
|
||||
None => builder,
|
||||
}
|
||||
.sign_with_keys(keys)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
/// Announce a repository so a project has a real coordinate to reference.
|
||||
fn repo_announcement(keys: &Keys, repo_d: &str) -> nostr::Event {
|
||||
EventBuilder::new(Kind::Custom(REPO_ANNOUNCEMENT_KIND), "")
|
||||
.tags(vec![
|
||||
Tag::parse(["d", repo_d]).unwrap(),
|
||||
Tag::parse(["name", repo_d]).unwrap(),
|
||||
])
|
||||
.sign_with_keys(keys)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
/// A NIP-09 `a`-tag-only deletion at a NIP-33 coordinate. No `e` tag, so the
|
||||
/// relay takes the coordinate-delete path rather than the event-id path.
|
||||
/// `created_at` defaults to now when `None`.
|
||||
fn coordinate_delete(keys: &Keys, kind: u16, d_tag: &str, created_at: Option<u64>) -> nostr::Event {
|
||||
let coord = format!("{kind}:{}:{d_tag}", keys.public_key().to_hex());
|
||||
let builder =
|
||||
EventBuilder::new(Kind::Custom(5), "")
|
||||
.tags(vec![Tag::parse(["a", coord.as_str()]).unwrap()]);
|
||||
match created_at {
|
||||
Some(ts) => builder.custom_created_at(Timestamp::from(ts)),
|
||||
None => builder,
|
||||
}
|
||||
.sign_with_keys(keys)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn addressable_filter(kind: u16, author: &Keys, d_tag: &str) -> Filter {
|
||||
Filter::new()
|
||||
.kind(Kind::Custom(kind))
|
||||
.author(author.public_key())
|
||||
.custom_tags(SingleLetterTag::lowercase(Alphabet::D), [d_tag])
|
||||
}
|
||||
|
||||
/// Subscribe with `filter` and drain to EOSE.
|
||||
async fn query(client: &mut BuzzTestClient, name: &str, filter: Filter) -> Vec<nostr::Event> {
|
||||
let sid = sub_id(name);
|
||||
client
|
||||
.subscribe(&sid, vec![filter])
|
||||
.await
|
||||
.expect("subscribe");
|
||||
client
|
||||
.collect_until_eose(&sid, Duration::from_secs(5))
|
||||
.await
|
||||
.expect("collect events")
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn test_project_publish_and_query_returns_cross_owner_members() {
|
||||
let url = relay_url();
|
||||
let owner = Keys::generate();
|
||||
let other = Keys::generate();
|
||||
let d_tag = unique("project");
|
||||
|
||||
let members = vec![
|
||||
member_coord(&owner, "buzz"),
|
||||
member_coord(&other, "buzz-infra"),
|
||||
];
|
||||
|
||||
let mut client = BuzzTestClient::connect(&url, &owner)
|
||||
.await
|
||||
.expect("connect");
|
||||
|
||||
let event = project_event(&owner, &d_tag, "Platform", &members, None);
|
||||
let ok = client.send_event(event).await.expect("send project");
|
||||
assert!(ok.accepted, "relay rejected project event: {}", ok.message);
|
||||
|
||||
let events = query(
|
||||
&mut client,
|
||||
"query",
|
||||
addressable_filter(PROJECT_KIND, &owner, &d_tag),
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(events.len(), 1, "expected exactly one project event");
|
||||
let stored: Vec<&str> = events[0]
|
||||
.tags
|
||||
.iter()
|
||||
.filter_map(|t| {
|
||||
let parts = t.as_slice();
|
||||
(parts.first().map(|s| s.as_str()) == Some("a")).then(|| parts[1].as_str())
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(
|
||||
stored, members,
|
||||
"both members must survive the round trip, including the one owned by another pubkey"
|
||||
);
|
||||
|
||||
client.disconnect().await.expect("disconnect");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn test_project_replacement_keeps_only_newest_for_same_author_and_d() {
|
||||
let url = relay_url();
|
||||
let owner = Keys::generate();
|
||||
let d_tag = unique("project-replace");
|
||||
let now = Timestamp::now().as_secs();
|
||||
|
||||
let mut client = BuzzTestClient::connect(&url, &owner)
|
||||
.await
|
||||
.expect("connect");
|
||||
|
||||
let first = project_event(&owner, &d_tag, "Old", &[], Some(now - 100));
|
||||
let ok = client.send_event(first).await.expect("send old");
|
||||
assert!(ok.accepted, "relay rejected old project: {}", ok.message);
|
||||
|
||||
let members = vec![member_coord(&owner, "buzz")];
|
||||
let second = project_event(&owner, &d_tag, "New", &members, Some(now));
|
||||
let ok = client.send_event(second).await.expect("send new");
|
||||
assert!(ok.accepted, "relay rejected new project: {}", ok.message);
|
||||
|
||||
let events = query(
|
||||
&mut client,
|
||||
"replace",
|
||||
addressable_filter(PROJECT_KIND, &owner, &d_tag),
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(
|
||||
events.len(),
|
||||
1,
|
||||
"NIP-33: only the newest head should remain"
|
||||
);
|
||||
let name = events[0]
|
||||
.tags
|
||||
.iter()
|
||||
.find_map(|t| {
|
||||
let parts = t.as_slice();
|
||||
(parts.first().map(|s| s.as_str()) == Some("name")).then(|| parts[1].as_str())
|
||||
})
|
||||
.expect("name tag");
|
||||
assert_eq!(name, "New", "the newer head must win");
|
||||
|
||||
client.disconnect().await.expect("disconnect");
|
||||
}
|
||||
|
||||
/// Owner-only editing is a property of the addressable model, not a relay
|
||||
/// permission check: two authors publishing the same `d` occupy two coordinates,
|
||||
/// so neither can overwrite the other. This is the test that would fail if the
|
||||
/// kind were ever classified as plain-replaceable or keyed on `d` alone.
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn test_project_same_d_under_two_authors_are_independent() {
|
||||
let url = relay_url();
|
||||
let alice = Keys::generate();
|
||||
let bob = Keys::generate();
|
||||
let d_tag = unique("project-shared-d");
|
||||
|
||||
let mut alice_client = BuzzTestClient::connect(&url, &alice)
|
||||
.await
|
||||
.expect("connect");
|
||||
let ok = alice_client
|
||||
.send_event(project_event(&alice, &d_tag, "Alice", &[], None))
|
||||
.await
|
||||
.expect("send alice");
|
||||
assert!(
|
||||
ok.accepted,
|
||||
"relay rejected alice's project: {}",
|
||||
ok.message
|
||||
);
|
||||
|
||||
let mut bob_client = BuzzTestClient::connect(&url, &bob).await.expect("connect");
|
||||
let ok = bob_client
|
||||
.send_event(project_event(&bob, &d_tag, "Bob", &[], None))
|
||||
.await
|
||||
.expect("send bob");
|
||||
assert!(ok.accepted, "relay rejected bob's project: {}", ok.message);
|
||||
|
||||
for (label, keys, expected_name) in [("alice", &alice, "Alice"), ("bob", &bob, "Bob")] {
|
||||
let events = query(
|
||||
&mut alice_client,
|
||||
label,
|
||||
addressable_filter(PROJECT_KIND, keys, &d_tag),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
events.len(),
|
||||
1,
|
||||
"{label} should still hold their own project at the shared `d`"
|
||||
);
|
||||
let name = events[0]
|
||||
.tags
|
||||
.iter()
|
||||
.find_map(|t| {
|
||||
let parts = t.as_slice();
|
||||
(parts.first().map(|s| s.as_str()) == Some("name")).then(|| parts[1].as_str())
|
||||
})
|
||||
.expect("name tag");
|
||||
assert_eq!(name, expected_name, "{label}'s project was overwritten");
|
||||
}
|
||||
|
||||
alice_client.disconnect().await.expect("disconnect");
|
||||
bob_client.disconnect().await.expect("disconnect");
|
||||
}
|
||||
|
||||
/// Deleting a project must delete only the grouping. A project is metadata about
|
||||
/// repositories; if a tombstone at the project coordinate cascaded to the
|
||||
/// referenced kind:30617s, adding a repo to someone's project would become a way
|
||||
/// to destroy it.
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn test_project_tombstone_deletes_coordinate_and_spares_members() {
|
||||
let url = relay_url();
|
||||
let owner = Keys::generate();
|
||||
let repo_d = unique("repo");
|
||||
let project_d = unique("project-tombstone");
|
||||
|
||||
let mut client = BuzzTestClient::connect(&url, &owner)
|
||||
.await
|
||||
.expect("connect");
|
||||
|
||||
let ok = client
|
||||
.send_event(repo_announcement(&owner, &repo_d))
|
||||
.await
|
||||
.expect("send announcement");
|
||||
assert!(ok.accepted, "relay rejected announcement: {}", ok.message);
|
||||
|
||||
let members = vec![member_coord(&owner, &repo_d)];
|
||||
let ok = client
|
||||
.send_event(project_event(&owner, &project_d, "Doomed", &members, None))
|
||||
.await
|
||||
.expect("send project");
|
||||
assert!(ok.accepted, "relay rejected project: {}", ok.message);
|
||||
|
||||
let before = query(
|
||||
&mut client,
|
||||
"tombstone-pre",
|
||||
addressable_filter(PROJECT_KIND, &owner, &project_d),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(before.len(), 1, "project should be live before deletion");
|
||||
|
||||
let ok = client
|
||||
.send_event(coordinate_delete(&owner, PROJECT_KIND, &project_d, None))
|
||||
.await
|
||||
.expect("send tombstone");
|
||||
assert!(ok.accepted, "relay rejected tombstone: {}", ok.message);
|
||||
|
||||
let after = query(
|
||||
&mut client,
|
||||
"tombstone-post",
|
||||
addressable_filter(PROJECT_KIND, &owner, &project_d),
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
after.is_empty(),
|
||||
"tombstone should remove the project coordinate, got {} event(s)",
|
||||
after.len()
|
||||
);
|
||||
|
||||
let repo = query(
|
||||
&mut client,
|
||||
"member-after",
|
||||
addressable_filter(REPO_ANNOUNCEMENT_KIND, &owner, &repo_d),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
repo.len(),
|
||||
1,
|
||||
"deleting a project must not touch the repositories it referenced"
|
||||
);
|
||||
|
||||
client.disconnect().await.expect("disconnect");
|
||||
}
|
||||
|
||||
/// NIP-09 scopes an `a`-tag deletion to versions at or before the deletion's own
|
||||
/// `created_at`. A tombstone signed between V1 and V2 — delayed in transit or
|
||||
/// replayed by a third party — must therefore retire V1 only and leave the newer
|
||||
/// V2 head live. Before the timestamp predicate landed in
|
||||
/// `soft_delete_by_coordinate`, the coordinate delete was timestamp-blind and
|
||||
/// this sequence silently destroyed V2.
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn test_stale_tombstone_between_versions_leaves_newer_project_live() {
|
||||
let url = relay_url();
|
||||
let owner = Keys::generate();
|
||||
let project_d = unique("project-stale-tombstone");
|
||||
let now = Timestamp::now().as_secs();
|
||||
|
||||
let mut client = BuzzTestClient::connect(&url, &owner)
|
||||
.await
|
||||
.expect("connect");
|
||||
|
||||
let ok = client
|
||||
.send_event(project_event(
|
||||
&owner,
|
||||
&project_d,
|
||||
"V1",
|
||||
&[],
|
||||
Some(now - 100),
|
||||
))
|
||||
.await
|
||||
.expect("send v1");
|
||||
assert!(ok.accepted, "relay rejected V1: {}", ok.message);
|
||||
|
||||
let ok = client
|
||||
.send_event(project_event(&owner, &project_d, "V2", &[], Some(now)))
|
||||
.await
|
||||
.expect("send v2");
|
||||
assert!(ok.accepted, "relay rejected V2: {}", ok.message);
|
||||
|
||||
// Timestamped strictly between V1 and V2: valid for V1, stale for V2.
|
||||
let ok = client
|
||||
.send_event(coordinate_delete(
|
||||
&owner,
|
||||
PROJECT_KIND,
|
||||
&project_d,
|
||||
Some(now - 50),
|
||||
))
|
||||
.await
|
||||
.expect("send stale tombstone");
|
||||
assert!(
|
||||
ok.accepted,
|
||||
"a well-formed tombstone is still an acceptable event: {}",
|
||||
ok.message
|
||||
);
|
||||
|
||||
let after = query(
|
||||
&mut client,
|
||||
"stale-tombstone",
|
||||
addressable_filter(PROJECT_KIND, &owner, &project_d),
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(
|
||||
after.len(),
|
||||
1,
|
||||
"a tombstone older than the live head must not delete it, got {} event(s)",
|
||||
after.len()
|
||||
);
|
||||
let name = after[0]
|
||||
.tags
|
||||
.iter()
|
||||
.find_map(|t| {
|
||||
let parts = t.as_slice();
|
||||
(parts.first().map(|s| s.as_str()) == Some("name")).then(|| parts[1].as_str())
|
||||
})
|
||||
.expect("surviving head must carry its name tag");
|
||||
assert_eq!(name, "V2", "the surviving head must be the newer version");
|
||||
|
||||
client.disconnect().await.expect("disconnect");
|
||||
}
|
||||
|
||||
/// Proves the envelope validator is reachable from the live write path — a unit
|
||||
/// test of `validate_project_envelope` cannot show that ingest calls it.
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn test_project_malformed_envelope_rejected_by_relay() {
|
||||
let url = relay_url();
|
||||
let owner = Keys::generate();
|
||||
let mut client = BuzzTestClient::connect(&url, &owner)
|
||||
.await
|
||||
.expect("connect");
|
||||
|
||||
let duplicate = member_coord(&owner, "buzz");
|
||||
// Each case pairs a malformed event with the substring its rejection must
|
||||
// carry, so a refusal for an unrelated reason cannot satisfy the assertion.
|
||||
let cases: Vec<(&str, nostr::Event, &str)> = vec![
|
||||
(
|
||||
"duplicate member coordinate",
|
||||
project_event(
|
||||
&owner,
|
||||
&unique("project-dup"),
|
||||
"Dup",
|
||||
&[duplicate.clone(), duplicate],
|
||||
None,
|
||||
),
|
||||
"duplicate member coordinate",
|
||||
),
|
||||
(
|
||||
"member coordinate naming the wrong kind",
|
||||
project_event(
|
||||
&owner,
|
||||
&unique("project-badkind"),
|
||||
"Bad kind",
|
||||
&[format!("30618:{}:buzz", owner.public_key().to_hex())],
|
||||
None,
|
||||
),
|
||||
"member `a` tag must be",
|
||||
),
|
||||
(
|
||||
"member coordinate with an uppercase-hex owner",
|
||||
project_event(
|
||||
&owner,
|
||||
&unique("project-upper"),
|
||||
"Uppercase",
|
||||
&[format!("{REPO_ANNOUNCEMENT_KIND}:{}:buzz", "A".repeat(64))],
|
||||
None,
|
||||
),
|
||||
"member `a` tag must be",
|
||||
),
|
||||
];
|
||||
|
||||
for (label, event, expected) in cases {
|
||||
let ok = client.send_event(event).await.expect("send");
|
||||
assert!(
|
||||
!ok.accepted,
|
||||
"relay must reject a project with a {label}, got OK: {}",
|
||||
ok.message
|
||||
);
|
||||
assert!(
|
||||
ok.message.contains(expected),
|
||||
"rejection for {label} must name the rule that fired, got: {}",
|
||||
ok.message
|
||||
);
|
||||
}
|
||||
|
||||
client.disconnect().await.expect("disconnect");
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,212 @@
|
||||
//! End-to-end tests for kind:30176 team events (NIP-AP).
|
||||
//!
|
||||
//! These tests verify the relay accepts and addresses team events the same way
|
||||
//! it does personas:
|
||||
//! - Accepts a valid team event and queries it back by NIP-33 coordinate.
|
||||
//! - Enforces NIP-33 replacement semantics (same d-tag, newer timestamp wins).
|
||||
//! - Honors a NIP-09 a-tag tombstone, removing the team coordinate.
|
||||
//!
|
||||
//! The team `d`-tag is the team's stable id (a slug-like string), not a 64-hex
|
||||
//! pubkey — it exercises the generic parameterized-replaceable path that
|
||||
//! enforces only `D_TAG_MAX_LEN`, distinct from the persona slug envelope.
|
||||
//!
|
||||
//! # Running
|
||||
//!
|
||||
//! Start the relay, then run:
|
||||
//!
|
||||
//! ```text
|
||||
//! RELAY_URL=ws://localhost:3000 cargo test --test e2e_team -- --ignored
|
||||
//! ```
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use buzz_test_client::BuzzTestClient;
|
||||
use nostr::{Alphabet, EventBuilder, Filter, Keys, Kind, SingleLetterTag, Tag, Timestamp};
|
||||
|
||||
const TEAM_KIND: u16 = 30176;
|
||||
|
||||
fn relay_url() -> String {
|
||||
std::env::var("RELAY_URL").unwrap_or_else(|_| "ws://localhost:3000".to_string())
|
||||
}
|
||||
|
||||
fn sub_id(name: &str) -> String {
|
||||
format!("e2e-team-{name}-{}", uuid::Uuid::new_v4())
|
||||
}
|
||||
|
||||
/// Build a team event whose `d`-tag is the team id, mirroring the desktop
|
||||
/// `build_team_event` shape.
|
||||
fn team_event(keys: &Keys, d_tag: &str, content: &str) -> nostr::Event {
|
||||
EventBuilder::new(Kind::Custom(TEAM_KIND), content)
|
||||
.tags(vec![Tag::parse(["d", d_tag]).unwrap()])
|
||||
.sign_with_keys(keys)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn team_event_at(keys: &Keys, d_tag: &str, content: &str, created_at: u64) -> nostr::Event {
|
||||
EventBuilder::new(Kind::Custom(TEAM_KIND), content)
|
||||
.tags(vec![Tag::parse(["d", d_tag]).unwrap()])
|
||||
.custom_created_at(Timestamp::from(created_at))
|
||||
.sign_with_keys(keys)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
/// Build a NIP-09 a-tag-only deletion at the team's NIP-33 coordinate,
|
||||
/// mirroring the desktop `build_team_delete` shape (no `e`-tag, so the relay
|
||||
/// takes the coordinate-delete path rather than the event-id path).
|
||||
fn team_delete_event(keys: &Keys, d_tag: &str) -> nostr::Event {
|
||||
let coord = format!("{TEAM_KIND}:{}:{d_tag}", keys.public_key().to_hex());
|
||||
EventBuilder::new(Kind::Custom(5), "")
|
||||
.tags(vec![Tag::parse(["a", coord.as_str()]).unwrap()])
|
||||
.sign_with_keys(keys)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn test_team_publish_and_query() {
|
||||
let url = relay_url();
|
||||
let keys = Keys::generate();
|
||||
let d_tag = format!("team-{}", &uuid::Uuid::new_v4().to_string()[..8]);
|
||||
|
||||
let content = serde_json::json!({
|
||||
"name": "Test Team",
|
||||
"description": "A test team for E2E validation",
|
||||
"persona_ids": ["p1", "p2"]
|
||||
})
|
||||
.to_string();
|
||||
|
||||
let mut client = BuzzTestClient::connect(&url, &keys).await.expect("connect");
|
||||
|
||||
let event = team_event(&keys, &d_tag, &content);
|
||||
let ok = client.send_event(event).await.expect("send team");
|
||||
assert!(ok.accepted, "relay rejected team event: {}", ok.message);
|
||||
|
||||
let sid = sub_id("query");
|
||||
let filter = Filter::new()
|
||||
.kind(Kind::Custom(TEAM_KIND))
|
||||
.author(keys.public_key())
|
||||
.custom_tags(SingleLetterTag::lowercase(Alphabet::D), [d_tag.as_str()]);
|
||||
|
||||
client
|
||||
.subscribe(&sid, vec![filter])
|
||||
.await
|
||||
.expect("subscribe");
|
||||
|
||||
let events = client
|
||||
.collect_until_eose(&sid, Duration::from_secs(5))
|
||||
.await
|
||||
.expect("collect events");
|
||||
|
||||
assert_eq!(events.len(), 1, "expected exactly one team event");
|
||||
let ev = &events[0];
|
||||
assert_eq!(ev.content, content);
|
||||
assert_eq!(ev.pubkey, keys.public_key());
|
||||
assert_eq!(ev.kind, Kind::Custom(TEAM_KIND));
|
||||
|
||||
client.disconnect().await.expect("disconnect");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn test_team_nip33_replacement_newer_wins() {
|
||||
let url = relay_url();
|
||||
let keys = Keys::generate();
|
||||
let d_tag = format!("team-replace-{}", &uuid::Uuid::new_v4().to_string()[..8]);
|
||||
|
||||
let mut client = BuzzTestClient::connect(&url, &keys).await.expect("connect");
|
||||
|
||||
let now = Timestamp::now().as_secs();
|
||||
let old_content = r#"{"name":"Old Team","persona_ids":["p1"]}"#;
|
||||
let old_event = team_event_at(&keys, &d_tag, old_content, now - 100);
|
||||
let ok = client.send_event(old_event).await.expect("send old");
|
||||
assert!(ok.accepted, "relay rejected old event: {}", ok.message);
|
||||
|
||||
let new_content = r#"{"name":"New Team","persona_ids":["p1","p2"]}"#;
|
||||
let new_event = team_event_at(&keys, &d_tag, new_content, now);
|
||||
let ok = client.send_event(new_event).await.expect("send new");
|
||||
assert!(ok.accepted, "relay rejected new event: {}", ok.message);
|
||||
|
||||
let sid = sub_id("replace");
|
||||
let filter = Filter::new()
|
||||
.kind(Kind::Custom(TEAM_KIND))
|
||||
.author(keys.public_key())
|
||||
.custom_tags(SingleLetterTag::lowercase(Alphabet::D), [d_tag.as_str()]);
|
||||
|
||||
client
|
||||
.subscribe(&sid, vec![filter])
|
||||
.await
|
||||
.expect("subscribe");
|
||||
|
||||
let events = client
|
||||
.collect_until_eose(&sid, Duration::from_secs(5))
|
||||
.await
|
||||
.expect("collect");
|
||||
|
||||
assert_eq!(events.len(), 1, "NIP-33: only newest event should remain");
|
||||
assert_eq!(
|
||||
events[0].content, new_content,
|
||||
"should be the newer version"
|
||||
);
|
||||
|
||||
client.disconnect().await.expect("disconnect");
|
||||
}
|
||||
|
||||
/// The a-tag tombstone is the only state-destroying op in the team flow: it
|
||||
/// removes the team for every client and across reboots. This proves the relay
|
||||
/// acts on it — publish a team, confirm it is live, publish the a-tag-only
|
||||
/// tombstone at its coordinate, then assert the query returns it gone.
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn test_team_tombstone_deletes_coordinate() {
|
||||
let url = relay_url();
|
||||
let keys = Keys::generate();
|
||||
let d_tag = format!("team-tombstone-{}", &uuid::Uuid::new_v4().to_string()[..8]);
|
||||
|
||||
let content = r#"{"name":"Doomed Team","persona_ids":["p1"]}"#;
|
||||
|
||||
let mut client = BuzzTestClient::connect(&url, &keys).await.expect("connect");
|
||||
|
||||
let event = team_event(&keys, &d_tag, content);
|
||||
let ok = client.send_event(event).await.expect("send team");
|
||||
assert!(ok.accepted, "relay rejected team event: {}", ok.message);
|
||||
|
||||
let filter = || {
|
||||
Filter::new()
|
||||
.kind(Kind::Custom(TEAM_KIND))
|
||||
.author(keys.public_key())
|
||||
.custom_tags(SingleLetterTag::lowercase(Alphabet::D), [d_tag.as_str()])
|
||||
};
|
||||
|
||||
let sid = sub_id("tombstone-pre");
|
||||
client
|
||||
.subscribe(&sid, vec![filter()])
|
||||
.await
|
||||
.expect("subscribe pre");
|
||||
let before = client
|
||||
.collect_until_eose(&sid, Duration::from_secs(5))
|
||||
.await
|
||||
.expect("collect pre");
|
||||
assert_eq!(before.len(), 1, "team should be live before deletion");
|
||||
|
||||
let tombstone = team_delete_event(&keys, &d_tag);
|
||||
let ok = client.send_event(tombstone).await.expect("send tombstone");
|
||||
assert!(ok.accepted, "relay rejected tombstone: {}", ok.message);
|
||||
|
||||
let sid = sub_id("tombstone-post");
|
||||
client
|
||||
.subscribe(&sid, vec![filter()])
|
||||
.await
|
||||
.expect("subscribe post");
|
||||
let after = client
|
||||
.collect_until_eose(&sid, Duration::from_secs(5))
|
||||
.await
|
||||
.expect("collect post");
|
||||
assert_eq!(
|
||||
after.len(),
|
||||
0,
|
||||
"tombstone should remove the team coordinate, got {} event(s)",
|
||||
after.len()
|
||||
);
|
||||
|
||||
client.disconnect().await.expect("disconnect");
|
||||
}
|
||||
@@ -0,0 +1,484 @@
|
||||
//! End-to-end tests for kind:30178 team-catalog events (NIP-AP).
|
||||
//!
|
||||
//! Kind 30178 is the shareable projection of a team. It joins kind:30175 in
|
||||
//! `SHARED_GATED_KINDS`, so these tests assert the wire behaviour of that gate
|
||||
//! at every read chokepoint (REQ, `ids` lookup, COUNT, live fan-out) plus the
|
||||
//! ingest envelope rules that make the gate sound:
|
||||
//! - Exactly one non-empty, bounded `d` tag — the team's stable local id, which
|
||||
//! may contain a colon (`builtin-team:welcome`) unlike a persona slug.
|
||||
//! - `shared`, if present, is exactly `["shared", "true"]`.
|
||||
//!
|
||||
//! # Running
|
||||
//!
|
||||
//! Start the relay, then run:
|
||||
//!
|
||||
//! ```text
|
||||
//! RELAY_URL=ws://localhost:3000 cargo test --test e2e_team_catalog -- --ignored
|
||||
//! ```
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use buzz_test_client::{BuzzTestClient, RelayMessage};
|
||||
use nostr::{Alphabet, EventBuilder, Filter, Keys, Kind, SingleLetterTag, Tag, Timestamp};
|
||||
|
||||
const TEAM_CATALOG_KIND: u16 = 30178;
|
||||
|
||||
fn relay_url() -> String {
|
||||
std::env::var("RELAY_URL").unwrap_or_else(|_| "ws://localhost:3000".to_string())
|
||||
}
|
||||
|
||||
fn sub_id(name: &str) -> String {
|
||||
format!("e2e-team-catalog-{name}-{}", uuid::Uuid::new_v4())
|
||||
}
|
||||
|
||||
fn catalog_content(name: &str) -> String {
|
||||
serde_json::json!({ "v": 1, "name": name, "members": [] }).to_string()
|
||||
}
|
||||
|
||||
/// Build a kind:30178 event, optionally carrying the `["shared","true"]` opt-in.
|
||||
fn catalog_event(keys: &Keys, d_tag: &str, shared: bool) -> nostr::Event {
|
||||
catalog_event_at(keys, d_tag, shared, Timestamp::now().as_secs())
|
||||
}
|
||||
|
||||
/// Same as [`catalog_event`] with an explicit `created_at`, so NIP-33 head
|
||||
/// ordering is deterministic instead of resolved by event-id tie-break.
|
||||
fn catalog_event_at(keys: &Keys, d_tag: &str, shared: bool, created_at: u64) -> nostr::Event {
|
||||
let mut tags = vec![Tag::parse(["d", d_tag]).unwrap()];
|
||||
if shared {
|
||||
tags.push(Tag::parse(["shared", "true"]).unwrap());
|
||||
}
|
||||
EventBuilder::new(
|
||||
Kind::Custom(TEAM_CATALOG_KIND),
|
||||
catalog_content("Test Team"),
|
||||
)
|
||||
.tags(tags)
|
||||
.custom_created_at(Timestamp::from(created_at))
|
||||
.sign_with_keys(keys)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn author_filter(author: &Keys) -> Filter {
|
||||
Filter::new()
|
||||
.kind(Kind::Custom(TEAM_CATALOG_KIND))
|
||||
.author(author.public_key())
|
||||
}
|
||||
|
||||
fn coordinate_filter(author: &Keys, d_tag: &str) -> Filter {
|
||||
author_filter(author).custom_tags(SingleLetterTag::lowercase(Alphabet::D), [d_tag])
|
||||
}
|
||||
|
||||
fn d_tag_of(event: &nostr::Event) -> Option<&str> {
|
||||
event.tags.iter().find_map(|t| {
|
||||
let parts = t.as_slice();
|
||||
if parts.first().map(|p| p.as_str()) != Some("d") {
|
||||
return None;
|
||||
}
|
||||
Some(parts.get(1)?.as_str())
|
||||
})
|
||||
}
|
||||
|
||||
/// The author's own unshared projection round-trips at its NIP-33 coordinate.
|
||||
///
|
||||
/// The `d` tag is a UUID, matching the desktop team id — proof the envelope does
|
||||
/// NOT apply the persona slug grammar.
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn test_team_catalog_publish_and_query_own_unshared() {
|
||||
let url = relay_url();
|
||||
let keys = Keys::generate();
|
||||
let d_tag = uuid::Uuid::new_v4().to_string();
|
||||
|
||||
let mut client = BuzzTestClient::connect(&url, &keys).await.expect("connect");
|
||||
let event = catalog_event(&keys, &d_tag, false);
|
||||
let event_id = event.id;
|
||||
let ok = client.send_event(event).await.expect("send catalog");
|
||||
assert!(ok.accepted, "relay rejected catalog event: {}", ok.message);
|
||||
|
||||
let sid = sub_id("own-unshared");
|
||||
client
|
||||
.subscribe(&sid, vec![coordinate_filter(&keys, &d_tag)])
|
||||
.await
|
||||
.expect("subscribe");
|
||||
let events = client
|
||||
.collect_until_eose(&sid, Duration::from_secs(5))
|
||||
.await
|
||||
.expect("collect");
|
||||
|
||||
assert_eq!(events.len(), 1, "author must see own unshared projection");
|
||||
assert_eq!(events[0].id, event_id);
|
||||
|
||||
client.disconnect().await.expect("disconnect");
|
||||
}
|
||||
|
||||
/// A built-in team id (`builtin-team:welcome`) is accepted as the `d` tag.
|
||||
///
|
||||
/// The colon is illegal in a persona slug; rewriting the id to fit would break
|
||||
/// NIP-33 addressing against the team's own kind:30176 head.
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn test_team_catalog_accepts_builtin_colon_d_tag() {
|
||||
let url = relay_url();
|
||||
let keys = Keys::generate();
|
||||
let d_tag = format!("builtin-team:{}", &uuid::Uuid::new_v4().to_string()[..8]);
|
||||
|
||||
let mut client = BuzzTestClient::connect(&url, &keys).await.expect("connect");
|
||||
let ok = client
|
||||
.send_event(catalog_event(&keys, &d_tag, true))
|
||||
.await
|
||||
.expect("send catalog");
|
||||
assert!(
|
||||
ok.accepted,
|
||||
"relay rejected colon-bearing team id: {}",
|
||||
ok.message
|
||||
);
|
||||
|
||||
client.disconnect().await.expect("disconnect");
|
||||
}
|
||||
|
||||
/// Ingest refuses an empty `d` tag: generic NIP-33 storage maps it to the empty
|
||||
/// coordinate, collapsing every team into one `(pubkey, 30178, "")` slot.
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn test_team_catalog_rejects_empty_d_tag() {
|
||||
let url = relay_url();
|
||||
let keys = Keys::generate();
|
||||
|
||||
let mut client = BuzzTestClient::connect(&url, &keys).await.expect("connect");
|
||||
let ok = client
|
||||
.send_event(catalog_event(&keys, "", false))
|
||||
.await
|
||||
.expect("send catalog");
|
||||
assert!(!ok.accepted, "empty d-tag must be rejected");
|
||||
assert!(
|
||||
ok.message.contains("invalid:"),
|
||||
"expected an `invalid:` refusal, got: {}",
|
||||
ok.message
|
||||
);
|
||||
|
||||
client.disconnect().await.expect("disconnect");
|
||||
}
|
||||
|
||||
/// Ingest refuses a valueless `["d"]` tag alongside a valued one. Counting only
|
||||
/// tags that carry a value would see exactly one `d` here and accept the event;
|
||||
/// a NIP-33 consumer that reads `["d"]` as an empty-valued first `d` tag would
|
||||
/// then address the event at `""` where this relay addresses it at the team id.
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn test_team_catalog_rejects_valueless_plus_valued_d_tags() {
|
||||
let url = relay_url();
|
||||
let keys = Keys::generate();
|
||||
let d_tag = uuid::Uuid::new_v4().to_string();
|
||||
|
||||
let event = EventBuilder::new(
|
||||
Kind::Custom(TEAM_CATALOG_KIND),
|
||||
catalog_content("Two d tags"),
|
||||
)
|
||||
.tags(vec![
|
||||
Tag::parse(["d"]).unwrap(),
|
||||
Tag::parse(["d", d_tag.as_str()]).unwrap(),
|
||||
])
|
||||
.sign_with_keys(&keys)
|
||||
.unwrap();
|
||||
|
||||
let mut client = BuzzTestClient::connect(&url, &keys).await.expect("connect");
|
||||
let ok = client.send_event(event).await.expect("send catalog");
|
||||
assert!(
|
||||
!ok.accepted,
|
||||
"a valueless `d` tag must count toward the exactly-one rule"
|
||||
);
|
||||
assert!(
|
||||
ok.message.contains("invalid:"),
|
||||
"expected an `invalid:` refusal, got: {}",
|
||||
ok.message
|
||||
);
|
||||
|
||||
client.disconnect().await.expect("disconnect");
|
||||
}
|
||||
|
||||
/// Ingest refuses a malformed `shared` tag. A three-element tag would satisfy
|
||||
/// the SQL containment clause `tags @> '[["shared","true"]]'` as a superset
|
||||
/// while the in-process gate reads it as unshared — the two layers must agree,
|
||||
/// so such an event can never be stored.
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn test_team_catalog_rejects_three_element_shared_tag() {
|
||||
let url = relay_url();
|
||||
let keys = Keys::generate();
|
||||
let d_tag = uuid::Uuid::new_v4().to_string();
|
||||
|
||||
let event = EventBuilder::new(
|
||||
Kind::Custom(TEAM_CATALOG_KIND),
|
||||
catalog_content("Malformed"),
|
||||
)
|
||||
.tags(vec![
|
||||
Tag::parse(["d", d_tag.as_str()]).unwrap(),
|
||||
Tag::parse(["shared", "true", "extra"]).unwrap(),
|
||||
])
|
||||
.sign_with_keys(&keys)
|
||||
.unwrap();
|
||||
|
||||
let mut client = BuzzTestClient::connect(&url, &keys).await.expect("connect");
|
||||
let ok = client.send_event(event).await.expect("send catalog");
|
||||
assert!(!ok.accepted, "three-element shared tag must be rejected");
|
||||
assert!(
|
||||
ok.message.contains("invalid:"),
|
||||
"expected an `invalid:` refusal, got: {}",
|
||||
ok.message
|
||||
);
|
||||
|
||||
client.disconnect().await.expect("disconnect");
|
||||
}
|
||||
|
||||
/// REQ historical delivery: a foreign reader receives only shared projections,
|
||||
/// while the author receives both of their own.
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn test_team_catalog_foreign_sees_only_shared() {
|
||||
let url = relay_url();
|
||||
let author_keys = Keys::generate();
|
||||
let foreign_keys = Keys::generate();
|
||||
|
||||
let d_unshared = format!("priv-{}", uuid::Uuid::new_v4());
|
||||
let d_shared = format!("pub-{}", uuid::Uuid::new_v4());
|
||||
|
||||
let mut author = BuzzTestClient::connect(&url, &author_keys)
|
||||
.await
|
||||
.expect("connect author");
|
||||
let shared_event = catalog_event(&author_keys, &d_shared, true);
|
||||
let shared_id = shared_event.id;
|
||||
let ok = author
|
||||
.send_event(catalog_event(&author_keys, &d_unshared, false))
|
||||
.await
|
||||
.expect("send unshared");
|
||||
assert!(ok.accepted, "unshared ingest rejected: {}", ok.message);
|
||||
let ok = author.send_event(shared_event).await.expect("send shared");
|
||||
assert!(ok.accepted, "shared ingest rejected: {}", ok.message);
|
||||
|
||||
let mut foreign = BuzzTestClient::connect(&url, &foreign_keys)
|
||||
.await
|
||||
.expect("connect foreign");
|
||||
let sid = sub_id("fg-all");
|
||||
foreign
|
||||
.subscribe(&sid, vec![author_filter(&author_keys)])
|
||||
.await
|
||||
.expect("subscribe");
|
||||
let events = foreign
|
||||
.collect_until_eose(&sid, Duration::from_secs(5))
|
||||
.await
|
||||
.expect("collect");
|
||||
|
||||
assert!(
|
||||
!events
|
||||
.iter()
|
||||
.any(|e| d_tag_of(e) == Some(d_unshared.as_str())),
|
||||
"foreign reader must NOT see the unshared projection"
|
||||
);
|
||||
assert!(
|
||||
events.iter().any(|e| e.id == shared_id),
|
||||
"foreign reader must see the shared projection"
|
||||
);
|
||||
|
||||
let sid_author = sub_id("auth-all");
|
||||
author
|
||||
.subscribe(&sid_author, vec![author_filter(&author_keys)])
|
||||
.await
|
||||
.expect("subscribe author");
|
||||
let author_events = author
|
||||
.collect_until_eose(&sid_author, Duration::from_secs(5))
|
||||
.await
|
||||
.expect("collect author");
|
||||
assert!(
|
||||
author_events.len() >= 2,
|
||||
"author must see both own projections, got {}",
|
||||
author_events.len()
|
||||
);
|
||||
|
||||
author.disconnect().await.expect("disconnect author");
|
||||
foreign.disconnect().await.expect("disconnect foreign");
|
||||
}
|
||||
|
||||
/// Knowing an event id does NOT grant access: `{ids:[unshared]}` returns nothing
|
||||
/// to a foreign reader.
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn test_team_catalog_ids_lookup_unshared_returns_nothing_to_foreign() {
|
||||
let url = relay_url();
|
||||
let author_keys = Keys::generate();
|
||||
let foreign_keys = Keys::generate();
|
||||
|
||||
let event = catalog_event(&author_keys, &uuid::Uuid::new_v4().to_string(), false);
|
||||
let event_id = event.id;
|
||||
|
||||
let mut author = BuzzTestClient::connect(&url, &author_keys)
|
||||
.await
|
||||
.expect("connect author");
|
||||
let ok = author.send_event(event).await.expect("send");
|
||||
assert!(ok.accepted, "ingest rejected: {}", ok.message);
|
||||
author.disconnect().await.expect("disconnect author");
|
||||
|
||||
let mut foreign = BuzzTestClient::connect(&url, &foreign_keys)
|
||||
.await
|
||||
.expect("connect foreign");
|
||||
let sid = sub_id("ids-unshared");
|
||||
foreign
|
||||
.subscribe(&sid, vec![Filter::new().id(event_id)])
|
||||
.await
|
||||
.expect("subscribe");
|
||||
let events = foreign
|
||||
.collect_until_eose(&sid, Duration::from_secs(5))
|
||||
.await
|
||||
.expect("collect");
|
||||
|
||||
assert!(
|
||||
events.is_empty(),
|
||||
"ids-lookup of an unshared projection must return nothing, got {:?}",
|
||||
events.iter().map(|e| e.id).collect::<Vec<_>>()
|
||||
);
|
||||
|
||||
foreign.disconnect().await.expect("disconnect foreign");
|
||||
}
|
||||
|
||||
/// COUNT must take the per-event fallback for kind:30178 so the aggregate does
|
||||
/// not leak the existence of unshared projections.
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn test_team_catalog_count_excludes_foreign_unshared() {
|
||||
let url = relay_url();
|
||||
let author_keys = Keys::generate();
|
||||
let foreign_keys = Keys::generate();
|
||||
|
||||
let mut author = BuzzTestClient::connect(&url, &author_keys)
|
||||
.await
|
||||
.expect("connect author");
|
||||
let ok = author
|
||||
.send_event(catalog_event(
|
||||
&author_keys,
|
||||
&uuid::Uuid::new_v4().to_string(),
|
||||
false,
|
||||
))
|
||||
.await
|
||||
.expect("send unshared");
|
||||
assert!(ok.accepted, "unshared rejected: {}", ok.message);
|
||||
let ok = author
|
||||
.send_event(catalog_event(
|
||||
&author_keys,
|
||||
&uuid::Uuid::new_v4().to_string(),
|
||||
true,
|
||||
))
|
||||
.await
|
||||
.expect("send shared");
|
||||
assert!(ok.accepted, "shared rejected: {}", ok.message);
|
||||
author.disconnect().await.expect("disconnect author");
|
||||
|
||||
let mut foreign = BuzzTestClient::connect(&url, &foreign_keys)
|
||||
.await
|
||||
.expect("connect foreign");
|
||||
let sid = sub_id("count");
|
||||
let count_msg = serde_json::json!(["COUNT", sid, author_filter(&author_keys)]);
|
||||
foreign.send_raw(&count_msg).await.expect("send COUNT");
|
||||
|
||||
let count = match foreign.recv_event(Duration::from_secs(5)).await {
|
||||
Ok(RelayMessage::Count { count, .. }) => count,
|
||||
Ok(RelayMessage::Closed { message, .. }) => panic!("COUNT closed unexpectedly: {message}"),
|
||||
Ok(other) => panic!("unexpected relay message for COUNT: {other:?}"),
|
||||
Err(e) => panic!("unexpected error for COUNT: {e}"),
|
||||
};
|
||||
assert_eq!(
|
||||
count, 1,
|
||||
"foreign COUNT must see only the shared projection, got {count}"
|
||||
);
|
||||
|
||||
foreign.disconnect().await.expect("disconnect foreign");
|
||||
}
|
||||
|
||||
/// Live fan-out honours the gate, and unsharing (a NIP-33 replacement that drops
|
||||
/// the `shared` tag) retracts the projection from foreign readers.
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn test_team_catalog_live_fanout_and_unshare_retracts() {
|
||||
let url = relay_url();
|
||||
let author_keys = Keys::generate();
|
||||
let foreign_keys = Keys::generate();
|
||||
|
||||
let d_tag = uuid::Uuid::new_v4().to_string();
|
||||
let now = Timestamp::now().as_secs();
|
||||
let (t0, t1, t2) = (now.saturating_sub(2), now.saturating_sub(1), now);
|
||||
|
||||
// Subscribe BEFORE publishing, scoped to this author so parallel tests
|
||||
// publishing their own 30178s cannot trip the leak assertion.
|
||||
let mut foreign = BuzzTestClient::connect(&url, &foreign_keys)
|
||||
.await
|
||||
.expect("connect foreign");
|
||||
let sid = sub_id("fanout");
|
||||
foreign
|
||||
.subscribe(&sid, vec![author_filter(&author_keys)])
|
||||
.await
|
||||
.expect("subscribe");
|
||||
let _ = foreign
|
||||
.collect_until_eose(&sid, Duration::from_secs(5))
|
||||
.await
|
||||
.expect("drain eose");
|
||||
|
||||
let mut author = BuzzTestClient::connect(&url, &author_keys)
|
||||
.await
|
||||
.expect("connect author");
|
||||
|
||||
// Unshared publish must NOT reach the foreign connection.
|
||||
let ok = author
|
||||
.send_event(catalog_event_at(&author_keys, &d_tag, false, t0))
|
||||
.await
|
||||
.expect("send unshared");
|
||||
assert!(ok.accepted, "unshared rejected: {}", ok.message);
|
||||
match foreign.recv_event(Duration::from_millis(750)).await {
|
||||
Err(buzz_test_client::TestClientError::Timeout) => {}
|
||||
Ok(RelayMessage::Event { event, .. }) if event.kind == Kind::Custom(TEAM_CATALOG_KIND) => {
|
||||
panic!("unshared projection leaked to foreign live subscription");
|
||||
}
|
||||
Ok(_) => {}
|
||||
Err(e) => panic!("unexpected error awaiting fan-out: {e}"),
|
||||
}
|
||||
|
||||
// Shared replacement MUST reach it.
|
||||
let shared_event = catalog_event_at(&author_keys, &d_tag, true, t1);
|
||||
let shared_id = shared_event.id;
|
||||
let ok = author.send_event(shared_event).await.expect("send shared");
|
||||
assert!(ok.accepted, "shared rejected: {}", ok.message);
|
||||
let delivered = loop {
|
||||
match foreign.recv_event(Duration::from_secs(5)).await {
|
||||
Ok(RelayMessage::Event { event, .. }) if event.id == shared_id => break true,
|
||||
Ok(_) => continue,
|
||||
Err(buzz_test_client::TestClientError::Timeout) => break false,
|
||||
Err(e) => panic!("unexpected error awaiting shared fan-out: {e}"),
|
||||
}
|
||||
};
|
||||
assert!(
|
||||
delivered,
|
||||
"shared projection must fan out to foreign readers"
|
||||
);
|
||||
|
||||
// Unshare: replace at the same coordinate without the tag. Subsequent
|
||||
// foreign REQs must return nothing.
|
||||
let ok = author
|
||||
.send_event(catalog_event_at(&author_keys, &d_tag, false, t2))
|
||||
.await
|
||||
.expect("send unshare");
|
||||
assert!(ok.accepted, "unshare rejected: {}", ok.message);
|
||||
|
||||
let sid_post = sub_id("post-unshare");
|
||||
foreign
|
||||
.subscribe(&sid_post, vec![coordinate_filter(&author_keys, &d_tag)])
|
||||
.await
|
||||
.expect("subscribe post");
|
||||
let after = foreign
|
||||
.collect_until_eose(&sid_post, Duration::from_secs(5))
|
||||
.await
|
||||
.expect("collect post");
|
||||
assert!(
|
||||
after.is_empty(),
|
||||
"unsharing must retract the projection from foreign readers, got {} event(s)",
|
||||
after.len()
|
||||
);
|
||||
|
||||
author.disconnect().await.expect("disconnect author");
|
||||
foreign.disconnect().await.expect("disconnect foreign");
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
//! End-to-end tests for NIP-38 user statuses (kind:30315).
|
||||
//!
|
||||
//! These tests require a running relay instance. By default they are marked
|
||||
//! `#[ignore]` so that `cargo test` does not fail in CI when the relay is not
|
||||
//! available.
|
||||
//!
|
||||
//! # Running
|
||||
//!
|
||||
//! Start the relay, then run:
|
||||
//!
|
||||
//! ```text
|
||||
//! cargo test --test e2e_user_status -- --ignored
|
||||
//! ```
|
||||
//!
|
||||
//! Override the relay URL with the `RELAY_URL` environment variable:
|
||||
//!
|
||||
//! ```text
|
||||
//! RELAY_URL=ws://relay.example.com cargo test --test e2e_user_status -- --ignored
|
||||
//! ```
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use buzz_test_client::BuzzTestClient;
|
||||
use nostr::{Alphabet, EventBuilder, Filter, Keys, Kind, SingleLetterTag, Tag, Timestamp};
|
||||
|
||||
const KIND_USER_STATUS: u16 = 30315;
|
||||
|
||||
fn relay_url() -> String {
|
||||
std::env::var("RELAY_URL").unwrap_or_else(|_| "ws://localhost:3000".to_string())
|
||||
}
|
||||
|
||||
fn sub_id(name: &str) -> String {
|
||||
format!("e2e-{name}-{}", uuid::Uuid::new_v4())
|
||||
}
|
||||
|
||||
/// Build a kind:30315 event with a d-tag and content.
|
||||
fn build_user_status_event(
|
||||
keys: &Keys,
|
||||
d_tag: &str,
|
||||
content: &str,
|
||||
extra_tags: Vec<Tag>,
|
||||
) -> nostr::Event {
|
||||
let mut tags = vec![Tag::parse(["d", d_tag]).unwrap()];
|
||||
tags.extend(extra_tags);
|
||||
EventBuilder::new(Kind::Custom(KIND_USER_STATUS), content)
|
||||
.tags(tags)
|
||||
.sign_with_keys(keys)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
/// kind:30315 events are accepted by the relay.
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn test_user_status_accepted() {
|
||||
let url = relay_url();
|
||||
let keys = Keys::generate();
|
||||
let mut client = BuzzTestClient::connect(&url, &keys).await.expect("connect");
|
||||
|
||||
let event = build_user_status_event(&keys, "general", "Working on NIP-38 support", vec![]);
|
||||
|
||||
let ok = client.send_event(event).await.expect("send event");
|
||||
assert!(
|
||||
ok.accepted,
|
||||
"relay should accept kind:30315: {}",
|
||||
ok.message
|
||||
);
|
||||
|
||||
client.disconnect().await.expect("disconnect");
|
||||
}
|
||||
|
||||
/// kind:30315 events are retrievable via REQ with kinds filter.
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn test_user_status_retrievable() {
|
||||
let url = relay_url();
|
||||
let keys = Keys::generate();
|
||||
let mut client = BuzzTestClient::connect(&url, &keys).await.expect("connect");
|
||||
|
||||
let d_tag = format!("retrieve-{}", uuid::Uuid::new_v4().simple());
|
||||
let event = build_user_status_event(&keys, &d_tag, "Currently online", vec![]);
|
||||
let event_id = event.id;
|
||||
|
||||
let ok = client.send_event(event).await.expect("send event");
|
||||
assert!(ok.accepted, "relay should accept: {}", ok.message);
|
||||
|
||||
// Query back by kind + author
|
||||
let sid = sub_id("retrieve");
|
||||
let filter = Filter::new()
|
||||
.kind(Kind::Custom(KIND_USER_STATUS))
|
||||
.author(keys.public_key());
|
||||
client
|
||||
.subscribe(&sid, vec![filter])
|
||||
.await
|
||||
.expect("subscribe");
|
||||
|
||||
let events = client
|
||||
.collect_until_eose(&sid, Duration::from_secs(5))
|
||||
.await
|
||||
.expect("collect");
|
||||
|
||||
assert!(
|
||||
events.iter().any(|e| e.id == event_id),
|
||||
"should find the published user status in query results"
|
||||
);
|
||||
|
||||
client.disconnect().await.expect("disconnect");
|
||||
}
|
||||
|
||||
/// NIP-33 replacement: publishing a newer kind:30315 with the same d-tag replaces the old one.
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn test_user_status_nip33_replacement() {
|
||||
let url = relay_url();
|
||||
let keys = Keys::generate();
|
||||
let mut client = BuzzTestClient::connect(&url, &keys).await.expect("connect");
|
||||
|
||||
let d_tag = format!("replace-{}", uuid::Uuid::new_v4().simple());
|
||||
|
||||
// Publish v1
|
||||
let v1 = build_user_status_event(&keys, &d_tag, "Status v1", vec![]);
|
||||
let ok1 = client.send_event(v1).await.expect("send v1");
|
||||
assert!(ok1.accepted, "v1 should be accepted: {}", ok1.message);
|
||||
|
||||
// Small delay to ensure different created_at timestamps
|
||||
tokio::time::sleep(Duration::from_secs(1)).await;
|
||||
|
||||
// Publish v2 with the same d-tag
|
||||
let v2 = build_user_status_event(&keys, &d_tag, "Status v2 — updated", vec![]);
|
||||
let v2_id = v2.id;
|
||||
let ok2 = client.send_event(v2).await.expect("send v2");
|
||||
assert!(ok2.accepted, "v2 should be accepted: {}", ok2.message);
|
||||
|
||||
// Query — should only get v2 (v1 replaced)
|
||||
let sid = sub_id("replace");
|
||||
let filter = Filter::new()
|
||||
.kind(Kind::Custom(KIND_USER_STATUS))
|
||||
.author(keys.public_key())
|
||||
.custom_tags(SingleLetterTag::lowercase(Alphabet::D), [d_tag.as_str()]);
|
||||
client
|
||||
.subscribe(&sid, vec![filter])
|
||||
.await
|
||||
.expect("subscribe");
|
||||
|
||||
let events = client
|
||||
.collect_until_eose(&sid, Duration::from_secs(5))
|
||||
.await
|
||||
.expect("collect");
|
||||
|
||||
assert_eq!(
|
||||
events.len(),
|
||||
1,
|
||||
"should have exactly one event after replacement"
|
||||
);
|
||||
assert_eq!(events[0].id, v2_id, "surviving event should be v2");
|
||||
assert!(events[0].content.contains("v2"), "content should be v2");
|
||||
|
||||
client.disconnect().await.expect("disconnect");
|
||||
}
|
||||
|
||||
/// NIP-38: multiple d-tags coexist — "general" and "music" are independent status slots.
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn test_user_status_multiple_d_tags_coexist() {
|
||||
let url = relay_url();
|
||||
let keys = Keys::generate();
|
||||
let mut client = BuzzTestClient::connect(&url, &keys).await.expect("connect");
|
||||
|
||||
let general_d = format!("general-{}", uuid::Uuid::new_v4().simple());
|
||||
let music_d = format!("music-{}", uuid::Uuid::new_v4().simple());
|
||||
|
||||
// Publish general status
|
||||
let general = build_user_status_event(&keys, &general_d, "Working on code", vec![]);
|
||||
let general_id = general.id;
|
||||
let ok1 = client.send_event(general).await.expect("send general");
|
||||
assert!(ok1.accepted, "general should be accepted: {}", ok1.message);
|
||||
|
||||
// Publish music status
|
||||
let music = build_user_status_event(&keys, &music_d, "Listening to jazz", vec![]);
|
||||
let music_id = music.id;
|
||||
let ok2 = client.send_event(music).await.expect("send music");
|
||||
assert!(ok2.accepted, "music should be accepted: {}", ok2.message);
|
||||
|
||||
// Query by kind + author — both should be returned
|
||||
let sid = sub_id("multi-dtag");
|
||||
let filter = Filter::new()
|
||||
.kind(Kind::Custom(KIND_USER_STATUS))
|
||||
.author(keys.public_key());
|
||||
client
|
||||
.subscribe(&sid, vec![filter])
|
||||
.await
|
||||
.expect("subscribe");
|
||||
|
||||
let events = client
|
||||
.collect_until_eose(&sid, Duration::from_secs(5))
|
||||
.await
|
||||
.expect("collect");
|
||||
|
||||
assert!(
|
||||
events.iter().any(|e| e.id == general_id),
|
||||
"general status should be present"
|
||||
);
|
||||
assert!(
|
||||
events.iter().any(|e| e.id == music_id),
|
||||
"music status should be present"
|
||||
);
|
||||
|
||||
client.disconnect().await.expect("disconnect");
|
||||
}
|
||||
|
||||
/// NIP-33 stale-write protection: an older event cannot replace a newer one.
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn test_user_status_stale_write_rejected() {
|
||||
let url = relay_url();
|
||||
let keys = Keys::generate();
|
||||
let mut client = BuzzTestClient::connect(&url, &keys).await.expect("connect");
|
||||
|
||||
let d_tag = format!("stale-{}", uuid::Uuid::new_v4().simple());
|
||||
|
||||
// Publish the "newer" event first (with a future-ish timestamp)
|
||||
let newer = {
|
||||
let tags = vec![Tag::parse(["d", &d_tag]).unwrap()];
|
||||
EventBuilder::new(Kind::Custom(KIND_USER_STATUS), "Newer status")
|
||||
.tags(tags)
|
||||
.custom_created_at(Timestamp::from(nostr::Timestamp::now().as_secs() + 100))
|
||||
.sign_with_keys(&keys)
|
||||
.unwrap()
|
||||
};
|
||||
let newer_id = newer.id;
|
||||
let ok1 = client.send_event(newer).await.expect("send newer");
|
||||
assert!(ok1.accepted, "newer should be accepted: {}", ok1.message);
|
||||
|
||||
// Now try to publish an "older" event with the same d-tag but earlier timestamp
|
||||
let older = {
|
||||
let tags = vec![Tag::parse(["d", &d_tag]).unwrap()];
|
||||
EventBuilder::new(Kind::Custom(KIND_USER_STATUS), "Older status")
|
||||
.tags(tags)
|
||||
.custom_created_at(Timestamp::from(nostr::Timestamp::now().as_secs() - 100))
|
||||
.sign_with_keys(&keys)
|
||||
.unwrap()
|
||||
};
|
||||
let _ok2 = client.send_event(older).await.expect("send older");
|
||||
// Stale write may be rejected or accepted-as-duplicate — either way,
|
||||
// the older event must NOT replace the newer one.
|
||||
|
||||
// Query — should still have the newer event
|
||||
let sid = sub_id("stale");
|
||||
let filter = Filter::new()
|
||||
.kind(Kind::Custom(KIND_USER_STATUS))
|
||||
.author(keys.public_key())
|
||||
.custom_tags(SingleLetterTag::lowercase(Alphabet::D), [d_tag.as_str()]);
|
||||
client
|
||||
.subscribe(&sid, vec![filter])
|
||||
.await
|
||||
.expect("subscribe");
|
||||
|
||||
let events = client
|
||||
.collect_until_eose(&sid, Duration::from_secs(5))
|
||||
.await
|
||||
.expect("collect");
|
||||
|
||||
assert_eq!(events.len(), 1, "should have exactly one event");
|
||||
assert_eq!(
|
||||
events[0].id, newer_id,
|
||||
"surviving event should be the newer one"
|
||||
);
|
||||
assert!(
|
||||
events[0].content.contains("Newer"),
|
||||
"content should be from the newer event"
|
||||
);
|
||||
|
||||
client.disconnect().await.expect("disconnect");
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
//! Live two-host proof for the NIP-42 host-binding fix.
|
||||
//!
|
||||
//! Sibling of the NIP-98 row 44 obligation: AUTH event's `relay` tag must match
|
||||
//! the per-tenant host of the connection it arrives on, not the deployment-wide
|
||||
//! `config.relay_url`.
|
||||
//!
|
||||
//! Requires a running multi-tenant relay with TWO seeded communities. Bring-up:
|
||||
//!
|
||||
//! ```sh
|
||||
//! # Compose up infra, schema, then seed:
|
||||
//! INSERT INTO communities (id, host) VALUES
|
||||
//! ('11111111-1111-4111-8111-111111111111', 'a.localhost:3100'),
|
||||
//! ('22222222-2222-4222-8222-222222222222', 'b.localhost:3100');
|
||||
//! # Run one binary, BUZZ_HEALTH_PORT=8180 BUZZ_METRICS_PORT=9202,
|
||||
//! # BUZZ_RECONCILE_CHANNELS=false, BUZZ_GIT_CONFORMANCE_PROBE=false
|
||||
//! ```
|
||||
//!
|
||||
//! Each test is `#[ignore]` so it only runs explicitly:
|
||||
//! `cargo test -p buzz-test-client --test nip42_host_binding_live -- --ignored --test-threads=1`
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use futures_util::{SinkExt, StreamExt};
|
||||
use nostr::{EventBuilder, Keys, RelayUrl};
|
||||
use serde_json::{json, Value};
|
||||
use tokio_tungstenite::{connect_async, tungstenite::Message};
|
||||
|
||||
const HOST_A: &str = "ws://a.localhost:3100";
|
||||
const HOST_B: &str = "ws://b.localhost:3100";
|
||||
|
||||
/// Connect, wait for AUTH challenge, send a kind:22242 with `relay_tag_url`,
|
||||
/// return the OK response's accepted flag and message.
|
||||
async fn do_auth_with_relay_tag(
|
||||
connect_url: &str,
|
||||
relay_tag_url: &str,
|
||||
) -> Result<(bool, String), String> {
|
||||
let (mut ws, _) = connect_async(connect_url)
|
||||
.await
|
||||
.map_err(|e| format!("connect: {e}"))?;
|
||||
|
||||
// Wait for AUTH challenge.
|
||||
let challenge = loop {
|
||||
let msg = tokio::time::timeout(Duration::from_secs(5), ws.next())
|
||||
.await
|
||||
.map_err(|_| "timeout waiting for AUTH challenge".to_string())?
|
||||
.ok_or_else(|| "ws closed before challenge".to_string())?
|
||||
.map_err(|e| format!("ws read: {e}"))?;
|
||||
let text = match msg {
|
||||
Message::Text(t) => t,
|
||||
Message::Binary(_) | Message::Ping(_) | Message::Pong(_) => continue,
|
||||
other => return Err(format!("unexpected ws frame: {other:?}")),
|
||||
};
|
||||
let v: Value = serde_json::from_str(&text).map_err(|e| format!("json: {e}"))?;
|
||||
if v.get(0).and_then(|s| s.as_str()) == Some("AUTH") {
|
||||
break v
|
||||
.get(1)
|
||||
.and_then(|s| s.as_str())
|
||||
.ok_or_else(|| "AUTH msg missing challenge".to_string())?
|
||||
.to_string();
|
||||
}
|
||||
// ignore NOTICE etc.
|
||||
};
|
||||
|
||||
// Sign a NIP-42 AUTH event with chosen relay tag.
|
||||
let keys = Keys::generate();
|
||||
let parsed: RelayUrl = relay_tag_url
|
||||
.parse()
|
||||
.map_err(|e| format!("parse relay tag url {relay_tag_url}: {e}"))?;
|
||||
let event = EventBuilder::auth(&challenge, parsed)
|
||||
.sign_with_keys(&keys)
|
||||
.map_err(|e| format!("sign: {e}"))?;
|
||||
let event_id_hex = event.id.to_hex();
|
||||
|
||||
let send = json!(["AUTH", event]);
|
||||
ws.send(Message::Text(send.to_string().into()))
|
||||
.await
|
||||
.map_err(|e| format!("ws send: {e}"))?;
|
||||
|
||||
// Wait for OK with matching event id.
|
||||
loop {
|
||||
let msg = tokio::time::timeout(Duration::from_secs(5), ws.next())
|
||||
.await
|
||||
.map_err(|_| "timeout waiting for OK".to_string())?
|
||||
.ok_or_else(|| "ws closed before OK".to_string())?
|
||||
.map_err(|e| format!("ws read OK: {e}"))?;
|
||||
let text = match msg {
|
||||
Message::Text(t) => t,
|
||||
_ => continue,
|
||||
};
|
||||
let v: Value = serde_json::from_str(&text).map_err(|e| format!("json OK: {e}"))?;
|
||||
if v.get(0).and_then(|s| s.as_str()) == Some("OK")
|
||||
&& v.get(1).and_then(|s| s.as_str()) == Some(&event_id_hex)
|
||||
{
|
||||
let accepted = v.get(2).and_then(|s| s.as_bool()).unwrap_or(false);
|
||||
let message = v.get(3).and_then(|s| s.as_str()).unwrap_or("").to_string();
|
||||
return Ok((accepted, message));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// AUTH event signed for host A's URL on a connection to host A → ACCEPT.
|
||||
#[tokio::test]
|
||||
#[ignore = "requires two-host multi-tenant relay"]
|
||||
async fn nip42_matching_host_accepted_a() {
|
||||
let (accepted, msg) = do_auth_with_relay_tag(HOST_A, HOST_A)
|
||||
.await
|
||||
.expect("auth flow on host A");
|
||||
assert!(
|
||||
accepted,
|
||||
"matching-host AUTH on host A must be ACCEPTED; relay said: {msg}"
|
||||
);
|
||||
}
|
||||
|
||||
/// AUTH event signed for host B's URL on a connection to host B → ACCEPT.
|
||||
#[tokio::test]
|
||||
#[ignore = "requires two-host multi-tenant relay"]
|
||||
async fn nip42_matching_host_accepted_b() {
|
||||
let (accepted, msg) = do_auth_with_relay_tag(HOST_B, HOST_B)
|
||||
.await
|
||||
.expect("auth flow on host B");
|
||||
assert!(
|
||||
accepted,
|
||||
"matching-host AUTH on host B must be ACCEPTED; relay said: {msg}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Cross-host attack on the B-bound connection: forge AUTH with `relay` tag
|
||||
/// pointing at host A. Pre-fix this passed (verified against
|
||||
/// `state.config.relay_url`). Post-fix the per-tenant host check rejects it.
|
||||
#[tokio::test]
|
||||
#[ignore = "requires two-host multi-tenant relay"]
|
||||
async fn nip42_cross_host_rejected_a_relay_tag_on_b_connection() {
|
||||
let (accepted, msg) = do_auth_with_relay_tag(HOST_B, HOST_A)
|
||||
.await
|
||||
.expect("auth flow with cross-host relay tag");
|
||||
assert!(
|
||||
!accepted,
|
||||
"cross-host AUTH (relay-tag=A on connection=B) must be REJECTED; relay said: {msg}"
|
||||
);
|
||||
// Must be the host-binding rejection, not some other error.
|
||||
assert!(
|
||||
msg.contains("auth-required") || msg.contains("verification"),
|
||||
"rejection must be the NIP-42 verification-failure signal; relay said: {msg}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Mirror of the cross-host test in the opposite direction: B-relay-tag on
|
||||
/// A-connection → REJECT.
|
||||
#[tokio::test]
|
||||
#[ignore = "requires two-host multi-tenant relay"]
|
||||
async fn nip42_cross_host_rejected_b_relay_tag_on_a_connection() {
|
||||
let (accepted, msg) = do_auth_with_relay_tag(HOST_A, HOST_B)
|
||||
.await
|
||||
.expect("auth flow with mirror cross-host relay tag");
|
||||
assert!(
|
||||
!accepted,
|
||||
"cross-host AUTH (relay-tag=B on connection=A) must be REJECTED; relay said: {msg}"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,377 @@
|
||||
//! Regression test for the NIP-43 relay-admin durable-ban bypass
|
||||
//! (BUZZ-SEC-007 class, reported 2026-07-27).
|
||||
//!
|
||||
//! `ingest_event` exempts relay-admin kinds 9030-9033 from its durable
|
||||
//! write-path restriction gate so a *timed out* admin keeps its administrative
|
||||
//! capability. That exemption was ban-blind, so a **banned** admin could still
|
||||
//! add/remove relay members and change the workspace icon via signed NIP-98
|
||||
//! `POST /events`. The ban is now enforced inside
|
||||
//! `relay_admin::handle_relay_admin_event`; this test pins both halves of that
|
||||
//! contract — bans refused, timeouts still admitted.
|
||||
//!
|
||||
//! Requires a running relay and its Postgres. Ignored by default:
|
||||
//! REPRO_RELAY_HTTP=http://localhost:3999 REPRO_HOST=localhost:3999 \
|
||||
//! DATABASE_URL=postgres://buzz:buzz_dev@localhost:5432/buzz_relay_admin_regression \
|
||||
//! cargo test -p buzz-test-client --test regression_relay_admin_ban_gate \
|
||||
//! -- --ignored --nocapture
|
||||
|
||||
use base64::engine::general_purpose::STANDARD as BASE64;
|
||||
use base64::Engine;
|
||||
use nostr::{EventBuilder, Keys, Kind, Tag};
|
||||
use sha2::{Digest, Sha256};
|
||||
use uuid::Uuid;
|
||||
|
||||
fn http_base() -> String {
|
||||
std::env::var("REPRO_RELAY_HTTP").unwrap_or_else(|_| "http://localhost:3999".into())
|
||||
}
|
||||
fn host() -> String {
|
||||
std::env::var("REPRO_HOST").unwrap_or_else(|_| "localhost:3999".into())
|
||||
}
|
||||
fn db_url() -> String {
|
||||
std::env::var("DATABASE_URL").expect("DATABASE_URL required")
|
||||
}
|
||||
|
||||
fn sha256_hex(b: &[u8]) -> String {
|
||||
hex::encode(Sha256::digest(b))
|
||||
}
|
||||
|
||||
fn nip98(keys: &Keys, url: &str, body: &str) -> String {
|
||||
let ev = EventBuilder::new(Kind::Custom(27_235), "")
|
||||
.tags(vec![
|
||||
Tag::parse(["u", url]).unwrap(),
|
||||
Tag::parse(["method", "POST"]).unwrap(),
|
||||
Tag::parse(["payload", &sha256_hex(body.as_bytes())]).unwrap(),
|
||||
Tag::parse(["nonce", &Uuid::new_v4().to_string()]).unwrap(),
|
||||
])
|
||||
.sign_with_keys(keys)
|
||||
.unwrap();
|
||||
format!(
|
||||
"Nostr {}",
|
||||
BASE64.encode(serde_json::to_string(&ev).unwrap())
|
||||
)
|
||||
}
|
||||
|
||||
async fn post_event(keys: &Keys, event: &nostr::Event) -> (u16, String) {
|
||||
let body = serde_json::to_string(event).unwrap();
|
||||
let signed_url = format!("http://{}/events", host());
|
||||
let r = reqwest::Client::new()
|
||||
.post(format!("{}/events", http_base()))
|
||||
.header("Host", host())
|
||||
.header("Content-Type", "application/json")
|
||||
.header("Authorization", nip98(keys, &signed_url, &body))
|
||||
.body(body)
|
||||
.send()
|
||||
.await
|
||||
.expect("POST /events");
|
||||
let status = r.status().as_u16();
|
||||
(status, r.text().await.unwrap_or_default())
|
||||
}
|
||||
|
||||
fn signed(keys: &Keys, kind: u16, tags: Vec<Tag>) -> nostr::Event {
|
||||
EventBuilder::new(Kind::Custom(kind), "")
|
||||
.tags(tags)
|
||||
.sign_with_keys(keys)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
async fn pool() -> sqlx::Pool<sqlx::Postgres> {
|
||||
sqlx::postgres::PgPoolOptions::new()
|
||||
.max_connections(2)
|
||||
.connect(&db_url())
|
||||
.await
|
||||
.expect("connect Postgres")
|
||||
}
|
||||
|
||||
async fn community_id(p: &sqlx::Pool<sqlx::Postgres>) -> Uuid {
|
||||
let id = Uuid::new_v4();
|
||||
sqlx::query(
|
||||
"INSERT INTO communities (id, host) VALUES ($1, $2) ON CONFLICT (lower(host)) DO NOTHING",
|
||||
)
|
||||
.bind(id)
|
||||
.bind(host())
|
||||
.execute(p)
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query_scalar("SELECT id FROM communities WHERE lower(host) = lower($1)")
|
||||
.bind(host())
|
||||
.fetch_one(p)
|
||||
.await
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
async fn seed(p: &sqlx::Pool<sqlx::Postgres>, cid: Uuid, keys: &Keys, role: &str) {
|
||||
sqlx::query("INSERT INTO users (community_id, pubkey) VALUES ($1, $2) ON CONFLICT DO NOTHING")
|
||||
.bind(cid)
|
||||
.bind(keys.public_key().to_bytes().to_vec())
|
||||
.execute(p)
|
||||
.await
|
||||
.ok();
|
||||
sqlx::query(
|
||||
"INSERT INTO relay_members (community_id, pubkey, role, added_by) VALUES ($1,$2,$3,NULL) \
|
||||
ON CONFLICT (community_id, pubkey) DO UPDATE SET role = $3, updated_at = now()",
|
||||
)
|
||||
.bind(cid)
|
||||
.bind(keys.public_key().to_hex())
|
||||
.bind(role)
|
||||
.execute(p)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
/// Post-fix regression bar.
|
||||
///
|
||||
/// Asserts the full contract rather than just "the exploit stopped":
|
||||
/// - banned admin: 403 + exact `blocked:` prefix on 9030/9031/9033, and a
|
||||
/// banned *owner* likewise on 9032 (owner-only kind), covering all four
|
||||
/// exempt kinds,
|
||||
/// - no roster, role, or icon mutation from any of those attempts,
|
||||
/// - a *timed-out* admin still reaches relay-admin authorization (the ingest
|
||||
/// exemption's whole purpose — the fix must not silently widen to timeouts),
|
||||
/// - an unrestricted admin's behaviour is unchanged, mutation included.
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn banned_admin_is_refused_but_timed_out_admin_still_administers() {
|
||||
let p = pool().await;
|
||||
let cid = community_id(&p).await;
|
||||
|
||||
let owner = Keys::generate();
|
||||
let banned_owner = Keys::generate();
|
||||
let banned_admin = Keys::generate();
|
||||
let timed_out_admin = Keys::generate();
|
||||
let good_admin = Keys::generate();
|
||||
let victim = Keys::generate();
|
||||
let victim2 = Keys::generate();
|
||||
let victim3 = Keys::generate();
|
||||
let role_target = Keys::generate();
|
||||
// Retained (not generated inline) so the 9030 attempt can be checked for
|
||||
// absence afterward — a planted member is the mutation that attempt buys.
|
||||
let planted = Keys::generate();
|
||||
for (k, r) in [
|
||||
(&owner, "owner"),
|
||||
(&banned_owner, "owner"),
|
||||
(&banned_admin, "admin"),
|
||||
(&timed_out_admin, "admin"),
|
||||
(&good_admin, "admin"),
|
||||
(&victim, "member"),
|
||||
(&victim2, "member"),
|
||||
(&victim3, "member"),
|
||||
(&role_target, "member"),
|
||||
] {
|
||||
seed(&p, cid, k, r).await;
|
||||
}
|
||||
|
||||
// Owner bans one admin and times out another, through the real 9040/9042
|
||||
// command path.
|
||||
let (s, _) = post_event(
|
||||
&owner,
|
||||
&signed(
|
||||
&owner,
|
||||
9040,
|
||||
vec![Tag::parse(["p", &banned_admin.public_key().to_hex()]).unwrap()],
|
||||
),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(s, 200, "ban must land");
|
||||
let expiry = (chrono_now() + 3600).to_string();
|
||||
let (s, b) = post_event(
|
||||
&owner,
|
||||
&signed(
|
||||
&owner,
|
||||
9042,
|
||||
vec![
|
||||
Tag::parse(["p", &timed_out_admin.public_key().to_hex()]).unwrap(),
|
||||
Tag::parse(["expiration", &expiry]).unwrap(),
|
||||
],
|
||||
),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(s, 200, "timeout must land: {b}");
|
||||
|
||||
// 9032 is owner-only, so its banned case needs a banned *owner*. Whether
|
||||
// one owner may 9040 another is a moderation-policy question independent of
|
||||
// this fix, so the ban row is seeded directly to keep the test pinned to
|
||||
// the admission gate.
|
||||
sqlx::query(
|
||||
"INSERT INTO community_bans (community_id, pubkey, banned, actor_pubkey) \
|
||||
VALUES ($1,$2,true,$3) \
|
||||
ON CONFLICT (community_id, pubkey) DO UPDATE SET banned = true",
|
||||
)
|
||||
.bind(cid)
|
||||
.bind(banned_owner.public_key().to_bytes().to_vec())
|
||||
.bind(owner.public_key().to_bytes().to_vec())
|
||||
.execute(&p)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// ── Banned actors: every relay-admin kind must be 403 + `blocked:`. ──
|
||||
for (actor, kind, tags, label) in [
|
||||
(
|
||||
&banned_admin,
|
||||
9031u16,
|
||||
vec![Tag::parse(["p", &victim.public_key().to_hex()]).unwrap()],
|
||||
"9031 remove",
|
||||
),
|
||||
(
|
||||
&banned_admin,
|
||||
9030u16,
|
||||
vec![
|
||||
Tag::parse(["p", &planted.public_key().to_hex()]).unwrap(),
|
||||
Tag::parse(["role", "member"]).unwrap(),
|
||||
],
|
||||
"9030 add",
|
||||
),
|
||||
(
|
||||
&banned_admin,
|
||||
9033u16,
|
||||
vec![Tag::parse(["icon", "https://evil.example/pwned.png"]).unwrap()],
|
||||
"9033 icon",
|
||||
),
|
||||
(
|
||||
&banned_owner,
|
||||
9032u16,
|
||||
vec![
|
||||
Tag::parse(["p", &role_target.public_key().to_hex()]).unwrap(),
|
||||
Tag::parse(["role", "admin"]).unwrap(),
|
||||
],
|
||||
"9032 change role",
|
||||
),
|
||||
] {
|
||||
let (st, body) = post_event(actor, &signed(actor, kind, tags)).await;
|
||||
println!("[banned] {label} -> {st} {body}");
|
||||
assert_eq!(
|
||||
st, 403,
|
||||
"{label}: banned actor must get 403, got {st} {body}"
|
||||
);
|
||||
let msg: serde_json::Value = serde_json::from_str(&body).unwrap_or_default();
|
||||
let text = msg.get("error").and_then(|v| v.as_str()).unwrap_or(&body);
|
||||
assert_eq!(
|
||||
text, "blocked: you are banned from this community",
|
||||
"{label}: must carry the exact `blocked:` wire contract"
|
||||
);
|
||||
}
|
||||
|
||||
// No mutation from any banned attempt.
|
||||
let role_of = |k: &Keys| {
|
||||
let hex = k.public_key().to_hex();
|
||||
let p = p.clone();
|
||||
async move {
|
||||
sqlx::query_scalar::<_, String>(
|
||||
"SELECT role FROM relay_members WHERE community_id=$1 AND pubkey=$2",
|
||||
)
|
||||
.bind(cid)
|
||||
.bind(hex)
|
||||
.fetch_optional(&p)
|
||||
.await
|
||||
.unwrap()
|
||||
}
|
||||
};
|
||||
assert_eq!(
|
||||
role_of(&victim).await.as_deref(),
|
||||
Some("member"),
|
||||
"9031: banned admin must not remove a member"
|
||||
);
|
||||
assert_eq!(
|
||||
role_of(&planted).await,
|
||||
None,
|
||||
"9030: banned admin must not plant a new member"
|
||||
);
|
||||
assert_eq!(
|
||||
role_of(&role_target).await.as_deref(),
|
||||
Some("member"),
|
||||
"9032: banned owner must not change a member's role"
|
||||
);
|
||||
let icon: Option<String> = sqlx::query_scalar("SELECT icon FROM communities WHERE id=$1")
|
||||
.bind(cid)
|
||||
.fetch_one(&p)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(
|
||||
icon.is_none(),
|
||||
"9033: banned admin must not change the workspace icon, got {icon:?}"
|
||||
);
|
||||
|
||||
// ── Timed-out admin: still administers (ingest exemption preserved). ──
|
||||
let (ts, tb) = post_event(
|
||||
&timed_out_admin,
|
||||
&signed(
|
||||
&timed_out_admin,
|
||||
9031,
|
||||
vec![Tag::parse(["p", &victim2.public_key().to_hex()]).unwrap()],
|
||||
),
|
||||
)
|
||||
.await;
|
||||
println!("[timed-out] 9031 remove -> {ts} {tb}");
|
||||
assert_eq!(
|
||||
ts, 200,
|
||||
"timed-out admin must still administer the roster: {tb}"
|
||||
);
|
||||
assert_eq!(
|
||||
role_of(&victim2).await,
|
||||
None,
|
||||
"timed-out admin's removal must take effect"
|
||||
);
|
||||
|
||||
// Control: the same timed-out admin is still write-blocked for content.
|
||||
let (cs, cb) = post_event(
|
||||
&timed_out_admin,
|
||||
&EventBuilder::new(Kind::Custom(9), "x")
|
||||
.tags(vec![Tag::parse(["h", &Uuid::new_v4().to_string()]).unwrap()])
|
||||
.sign_with_keys(&timed_out_admin)
|
||||
.unwrap(),
|
||||
)
|
||||
.await;
|
||||
println!("[timed-out] control kind:9 -> {cs} {cb}");
|
||||
assert_ne!(
|
||||
cs, 200,
|
||||
"timed-out admin must still be write-blocked for content"
|
||||
);
|
||||
|
||||
// ── Unrestricted admin: behaviour unchanged, mutation included. ──
|
||||
let (gs, gb) = post_event(
|
||||
&good_admin,
|
||||
&signed(
|
||||
&good_admin,
|
||||
9031,
|
||||
vec![Tag::parse(["p", &victim3.public_key().to_hex()]).unwrap()],
|
||||
),
|
||||
)
|
||||
.await;
|
||||
println!("[clean] 9031 remove -> {gs} {gb}");
|
||||
assert_eq!(gs, 200, "unrestricted admin must be unaffected: {gb}");
|
||||
assert_eq!(
|
||||
role_of(&victim3).await,
|
||||
None,
|
||||
"unrestricted admin's removal must actually take effect"
|
||||
);
|
||||
|
||||
// ── Unchanged rejection contract: non-admin still gets `invalid:`/400. ──
|
||||
let nobody = Keys::generate();
|
||||
seed(&p, cid, &nobody, "member").await;
|
||||
let (ns, nb) = post_event(
|
||||
&nobody,
|
||||
&signed(
|
||||
&nobody,
|
||||
9031,
|
||||
vec![Tag::parse(["p", &victim.public_key().to_hex()]).unwrap()],
|
||||
),
|
||||
)
|
||||
.await;
|
||||
println!("[non-admin] 9031 -> {ns} {nb}");
|
||||
assert_eq!(
|
||||
ns, 400,
|
||||
"a plain member's 9031 must stay a 400 validation reject"
|
||||
);
|
||||
assert!(
|
||||
nb.contains("invalid: actor not authorized"),
|
||||
"non-admin rejection must keep its `invalid:` prefix, got {nb}"
|
||||
);
|
||||
|
||||
println!("\nALL INVARIANTS HELD");
|
||||
}
|
||||
|
||||
fn chrono_now() -> i64 {
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_secs() as i64)
|
||||
.unwrap_or(0)
|
||||
}
|
||||
Reference in New Issue
Block a user