9dfa06ffee
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>
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();
|
|
}
|