Files
buzz/crates/buzz-test-client/tests/e2e_media_extended.rs
Wes bba3e06386 Fix macOS attachment picker lifecycle and allow inert HTML downloads (#5569)
## Problem

Canceling the native macOS file chooser leaves the composer's temporary,
detached `<input type="file">` without a `change` event or an explicit
cleanup path. Opening Finder again immediately creates a second detached
input while WebKit may still be unwinding the first picker. The newly
selected files can therefore fail to reach the upload pipeline. Drag and
drop is unaffected because it bypasses this picker lifecycle.

This does **not** add an automatic retry mechanism. “Retry” means the
user's next attachment attempt after canceling or after a prior
selection.

## Fix

- give each composer hook one hidden, body-mounted file input for its
lifetime instead of creating a detached one per click
- reset and reconfigure that input before every open, replace its
handler rather than stacking handlers, and remove it cleanly on unmount
- preserve normal selection, cancel then reopen, selecting the same file
again, and multi-select behavior
- accept canonical `text/html` attachments while continuing to serve and
render them strictly as inert downloads
- keep XHTML, SVG, JavaScript, and executable MIME types blocked

The picker change fixes the ownership/lifecycle bug at its source; it
does not retry failed uploads, add delays, or mask errors.

## Testing

- mandatory pre-push gate: branch-skew, desktop typecheck/tests/check,
Rust tests, and desktop Tauri checks passed on
`ea5a97adf957803935b28d63d32f9f332cf65287`
- `cargo test -p buzz-media --lib` (110 passed)
- `pnpm --dir desktop typecheck`
- focused Biome check for the three picker files
- picker Playwright regression: cancel/no selection then reopen, select
the same file again, and multiple selection (run on the source commit
before integration)
- HTML live-relay response regression added as ignored E2E because it
requires the S3-backed relay harness

## Manual verification

Playwright models cancellation with Chromium's
`FileChooser.setFiles([])`; it cannot exercise the native macOS Finder
panel/WebKit presentation lifecycle. Before merge, manually verify in
the built macOS app:

1. select a PNG normally
2. cancel, then immediately reopen and select a PNG
3. select the same PNG on a subsequent attempt
4. multi-select two PNGs

---------

Signed-off-by: Wes <wesbillman@users.noreply.github.com>
Co-authored-by: Princess Donut <68157ebd23b3897c1991015c3038658ea916200c67d3a54620b0754d1b92f6e0@buzz.block.builderlab.xyz>
Co-authored-by: Mongo <5c25403eab7271f9f94ddd4f2b270e8cac2c92e2c830c51877cca6ec974ffb3f@buzz.block.builderlab.xyz>
Co-authored-by: Carl <c7ebe626f000404285d3686e1dc74cc07cc60a9754a150041ba132e14bd3e2ec@buzz.block.builderlab.xyz>
2026-08-11 09:51:42 -07:00

805 lines
28 KiB
Rust

//! 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_html_served_as_inert_attachment() {
// HTML is accepted on the generic file path and MUST be served as an inert
// download: the security property the whole feature relies on is that the
// relay returns `Content-Disposition: attachment` + `X-Content-Type-Options:
// nosniff` + `Content-Security-Policy: default-src 'none'` so the payload can
// never execute or render as active content. This response-level regression
// pins that end to end (upload → GET), not just the deny-list membership.
let client = http_client();
let keys = Keys::generate();
// Exactly the shape `infer` classifies as text/html (leading recognised tag).
let html = b"<!DOCTYPE html><html><body><script>alert(1)</script></body></html>";
let resp = upload(&client, &keys, html).await;
let status = resp.status().as_u16();
assert_eq!(
status, 200,
"HTML should upload via file path, got {status}"
);
let desc: serde_json::Value = resp.json().await.unwrap();
assert_eq!(desc["type"].as_str().unwrap(), "text/html");
let url = desc["url"].as_str().unwrap();
assert!(
url.ends_with(".html"),
"served URL must carry the .html extension, got {url}"
);
let sha256 = desc["sha256"].as_str().unwrap();
let get_resp = client
.get(url)
.header(
"Authorization",
blossom_auth_header(&sign_blossom_get_auth(&keys, sha256)),
)
.send()
.await
.expect("GET request");
assert_eq!(get_resp.status(), 200, "HTML GET roundtrip should succeed");
let header = |name: &str| {
get_resp
.headers()
.get(name)
.and_then(|v| v.to_str().ok())
.unwrap_or("")
.to_string()
};
assert_eq!(header("content-type"), "text/html");
assert_eq!(
header("content-disposition"),
"attachment",
"HTML must be forced to download, never rendered inline"
);
assert_eq!(
header("x-content-type-options"),
"nosniff",
"nosniff must prevent MIME re-sniffing to an executable type"
);
assert_eq!(
header("content-security-policy"),
"default-src 'none'",
"restrictive CSP must neutralise any active content"
);
println!("✅ HTML → 200, served as inert attachment (disposition+nosniff+CSP)");
}
#[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();
}