mirror of
https://github.com/block/buzz.git
synced 2026-10-31 22:41:12 +00:00
This change requires a valid signed Blossom authorization request and current relay membership for every media GET and HEAD request. It removes the unauthenticated compatibility path and updates desktop reads to send the required authorization. This blocks anonymous retrieval and access after relay-membership revocation. It does not yet bind a blob to its originating channel, so someone removed from a private channel can still read a known blob while remaining a relay member. That channel-ACL follow-up remains required before closing the full finding. ## Testing - `git diff --check origin/main...codex/security-media-read-auth` - Rebased onto `origin/main` at `5c98932` - Full CI pending Originating Buzz thread: `buzz://message?channel=3928fe05-df61-4b5d-b9c7-d623b9b10ea1&id=3c6c02312f763fbe0d2bfc33a6c1a362f91d0354f3d18b039cf7a0558c1439d1` --------- Signed-off-by: Jordan Mecom <jm@squareup.com> Signed-off-by: Alex Rosenzweig <arosenzweig@squareup.com> Signed-off-by: Eli Foster <efoster@squareup.com> Co-authored-by: Eli Foster <efoster@squareup.com> Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
739 lines
26 KiB
Rust
739 lines
26 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_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();
|
|
}
|