···7373 .ok_or(DbLookupError::NotFound)
7474}
75757676+pub fn parse_repeated_query_param(query: Option<&str>, key: &str) -> Vec<String> {
7777+ query
7878+ .map(|q| {
7979+ let mut values = Vec::new();
8080+ for pair in q.split('&') {
8181+ if let Some((k, v)) = pair.split_once('=')
8282+ && k == key
8383+ && let Ok(decoded) = urlencoding::decode(v)
8484+ {
8585+ let decoded = decoded.into_owned();
8686+ if decoded.contains(',') {
8787+ for part in decoded.split(',') {
8888+ let trimmed = part.trim();
8989+ if !trimmed.is_empty() {
9090+ values.push(trimmed.to_string());
9191+ }
9292+ }
9393+ } else if !decoded.is_empty() {
9494+ values.push(decoded);
9595+ }
9696+ }
9797+ }
9898+ values
9999+ })
100100+ .unwrap_or_default()
101101+}
102102+76103pub fn extract_client_ip(headers: &HeaderMap) -> String {
77104 if let Some(forwarded) = headers.get("x-forwarded-for")
78105 && let Ok(value) = forwarded.to_str()
···91118#[cfg(test)]
92119mod tests {
93120 use super::*;
121121+122122+ #[test]
123123+ fn test_parse_repeated_query_param_repeated() {
124124+ let query = "did=test&cids=a&cids=b&cids=c";
125125+ let result = parse_repeated_query_param(Some(query), "cids");
126126+ assert_eq!(result, vec!["a", "b", "c"]);
127127+ }
128128+129129+ #[test]
130130+ fn test_parse_repeated_query_param_comma_separated() {
131131+ let query = "did=test&cids=a,b,c";
132132+ let result = parse_repeated_query_param(Some(query), "cids");
133133+ assert_eq!(result, vec!["a", "b", "c"]);
134134+ }
135135+136136+ #[test]
137137+ fn test_parse_repeated_query_param_mixed() {
138138+ let query = "did=test&cids=a,b&cids=c";
139139+ let result = parse_repeated_query_param(Some(query), "cids");
140140+ assert_eq!(result, vec!["a", "b", "c"]);
141141+ }
142142+143143+ #[test]
144144+ fn test_parse_repeated_query_param_single() {
145145+ let query = "did=test&cids=a";
146146+ let result = parse_repeated_query_param(Some(query), "cids");
147147+ assert_eq!(result, vec!["a"]);
148148+ }
149149+150150+ #[test]
151151+ fn test_parse_repeated_query_param_empty() {
152152+ let query = "did=test";
153153+ let result = parse_repeated_query_param(Some(query), "cids");
154154+ assert!(result.is_empty());
155155+ }
156156+157157+ #[test]
158158+ fn test_parse_repeated_query_param_url_encoded() {
159159+ let query = "did=test&cids=bafyreib%2Btest";
160160+ let result = parse_repeated_query_param(Some(query), "cids");
161161+ assert_eq!(result, vec!["bafyreib+test"]);
162162+ }
9416395164 #[test]
96165 fn test_generate_token_code() {
+194-5
tests/firehose_validation.rs
···200200 app_port()
201201 );
202202 let (mut ws_stream, _) = connect_async(&url).await.expect("Failed to connect");
203203+ tokio::time::sleep(std::time::Duration::from_millis(100)).await;
203204204205 let post_text = "Testing firehose validation!";
205206 let post_payload = json!({
···224225 assert_eq!(res.status(), StatusCode::OK);
225226226227 let mut frame_opt: Option<(FrameHeader, CommitFrame)> = None;
227227- let timeout = tokio::time::timeout(std::time::Duration::from_secs(5), async {
228228+ let timeout = tokio::time::timeout(std::time::Duration::from_secs(10), async {
228229 loop {
229230 let msg = ws_stream.next().await.unwrap().unwrap();
230231 let raw_bytes = match msg {
···392393 app_port()
393394 );
394395 let (mut ws_stream, _) = connect_async(&url).await.expect("Failed to connect");
396396+ tokio::time::sleep(std::time::Duration::from_millis(100)).await;
395397396398 let update_payload = json!({
397399 "repo": did,
···415417 assert_eq!(res.status(), StatusCode::OK);
416418417419 let mut frame_opt: Option<CommitFrame> = None;
418418- let timeout = tokio::time::timeout(std::time::Duration::from_secs(15), async {
420420+ let timeout = tokio::time::timeout(std::time::Duration::from_secs(20), async {
419421 loop {
420422 let msg = match ws_stream.next().await {
421423 Some(Ok(m)) => m,
···472474 app_port()
473475 );
474476 let (mut ws_stream, _) = connect_async(&url).await.expect("Failed to connect");
477477+ tokio::time::sleep(std::time::Duration::from_millis(100)).await;
475478476479 let post_payload = json!({
477480 "repo": did,
···494497 .expect("Failed to create first post");
495498496499 let mut first_frame_opt: Option<CommitFrame> = None;
497497- let timeout = tokio::time::timeout(std::time::Duration::from_secs(5), async {
500500+ let timeout = tokio::time::timeout(std::time::Duration::from_secs(10), async {
498501 loop {
499502 let msg = ws_stream.next().await.unwrap().unwrap();
500503 let raw_bytes = match msg {
···544547 .expect("Failed to create second post");
545548546549 let mut second_frame_opt: Option<CommitFrame> = None;
547547- let timeout = tokio::time::timeout(std::time::Duration::from_secs(5), async {
550550+ let timeout = tokio::time::timeout(std::time::Duration::from_secs(10), async {
548551 loop {
549552 let msg = ws_stream.next().await.unwrap().unwrap();
550553 let raw_bytes = match msg {
···593596 app_port()
594597 );
595598 let (mut ws_stream, _) = connect_async(&url).await.expect("Failed to connect");
599599+ tokio::time::sleep(std::time::Duration::from_millis(100)).await;
596600597601 let post_payload = json!({
598602 "repo": did,
···615619 .expect("Failed to create post");
616620617621 let mut raw_bytes_opt: Option<Vec<u8>> = None;
618618- let timeout = tokio::time::timeout(std::time::Duration::from_secs(5), async {
622622+ let timeout = tokio::time::timeout(std::time::Duration::from_secs(10), async {
619623 loop {
620624 let msg = ws_stream.next().await.unwrap().unwrap();
621625 let raw = match msg {
···661665662666 ws_stream.send(tungstenite::Message::Close(None)).await.ok();
663667}
668668+669669+#[derive(Debug, Deserialize)]
670670+struct ErrorFrameHeader {
671671+ op: i64,
672672+}
673673+674674+#[derive(Debug, Deserialize)]
675675+struct ErrorFrameBody {
676676+ error: String,
677677+ #[allow(dead_code)]
678678+ message: Option<String>,
679679+}
680680+681681+#[derive(Debug, Deserialize)]
682682+struct InfoFrameHeader {
683683+ #[allow(dead_code)]
684684+ op: i64,
685685+ t: String,
686686+}
687687+688688+#[derive(Debug, Deserialize)]
689689+struct InfoFrameBody {
690690+ name: String,
691691+ #[allow(dead_code)]
692692+ message: Option<String>,
693693+}
694694+695695+fn parse_error_frame(bytes: &[u8]) -> Result<(ErrorFrameHeader, ErrorFrameBody), String> {
696696+ let header_len = find_cbor_map_end(bytes)?;
697697+ let header: ErrorFrameHeader = serde_ipld_dagcbor::from_slice(&bytes[..header_len])
698698+ .map_err(|e| format!("Failed to parse error header: {:?}", e))?;
699699+700700+ if header.op != -1 {
701701+ return Err(format!("Not an error frame, op: {}", header.op));
702702+ }
703703+704704+ let remaining = &bytes[header_len..];
705705+ let body: ErrorFrameBody = serde_ipld_dagcbor::from_slice(remaining)
706706+ .map_err(|e| format!("Failed to parse error body: {:?}", e))?;
707707+708708+ Ok((header, body))
709709+}
710710+711711+fn parse_info_frame(bytes: &[u8]) -> Result<(InfoFrameHeader, InfoFrameBody), String> {
712712+ let header_len = find_cbor_map_end(bytes)?;
713713+ let header: InfoFrameHeader = serde_ipld_dagcbor::from_slice(&bytes[..header_len])
714714+ .map_err(|e| format!("Failed to parse info header: {:?}", e))?;
715715+716716+ if header.t != "#info" {
717717+ return Err(format!("Not an info frame, t: {}", header.t));
718718+ }
719719+720720+ let remaining = &bytes[header_len..];
721721+ let body: InfoFrameBody = serde_ipld_dagcbor::from_slice(remaining)
722722+ .map_err(|e| format!("Failed to parse info body: {:?}", e))?;
723723+724724+ Ok((header, body))
725725+}
726726+727727+#[tokio::test]
728728+async fn test_firehose_future_cursor_error() {
729729+ let _ = base_url().await;
730730+731731+ let future_cursor = 9999999999i64;
732732+ let url = format!(
733733+ "ws://127.0.0.1:{}/xrpc/com.atproto.sync.subscribeRepos?cursor={}",
734734+ app_port(),
735735+ future_cursor
736736+ );
737737+738738+ let (mut ws_stream, _) = connect_async(&url).await.expect("Failed to connect");
739739+740740+ let timeout = tokio::time::timeout(std::time::Duration::from_secs(10), async {
741741+ loop {
742742+ match ws_stream.next().await {
743743+ Some(Ok(tungstenite::Message::Binary(bin))) => {
744744+ if let Ok((header, body)) = parse_error_frame(&bin) {
745745+ println!("Received error frame: {:?} {:?}", header, body);
746746+ assert_eq!(header.op, -1, "Error frame op should be -1");
747747+ assert_eq!(body.error, "FutureCursor", "Error should be FutureCursor");
748748+ return true;
749749+ }
750750+ }
751751+ Some(Ok(tungstenite::Message::Close(_))) => {
752752+ println!("Connection closed");
753753+ return false;
754754+ }
755755+ None => {
756756+ println!("Stream ended");
757757+ return false;
758758+ }
759759+ _ => continue,
760760+ }
761761+ }
762762+ })
763763+ .await;
764764+765765+ match timeout {
766766+ Ok(received_error) => {
767767+ assert!(
768768+ received_error,
769769+ "Should have received FutureCursor error frame before connection closed"
770770+ );
771771+ }
772772+ Err(_) => {
773773+ panic!(
774774+ "Timed out waiting for FutureCursor error - connection should close quickly with error"
775775+ );
776776+ }
777777+ }
778778+}
779779+780780+#[tokio::test]
781781+async fn test_firehose_outdated_cursor_info() {
782782+ let client = client();
783783+ let (token, did) = create_account_and_login(&client).await;
784784+785785+ let post_payload = json!({
786786+ "repo": did,
787787+ "collection": "app.bsky.feed.post",
788788+ "record": {
789789+ "$type": "app.bsky.feed.post",
790790+ "text": "Post for outdated cursor test",
791791+ "createdAt": chrono::Utc::now().to_rfc3339(),
792792+ }
793793+ });
794794+ let _ = client
795795+ .post(format!(
796796+ "{}/xrpc/com.atproto.repo.createRecord",
797797+ base_url().await
798798+ ))
799799+ .bearer_auth(&token)
800800+ .json(&post_payload)
801801+ .send()
802802+ .await
803803+ .expect("Failed to create post");
804804+805805+ tokio::time::sleep(std::time::Duration::from_millis(100)).await;
806806+807807+ let outdated_cursor = 1i64;
808808+ let url = format!(
809809+ "ws://127.0.0.1:{}/xrpc/com.atproto.sync.subscribeRepos?cursor={}",
810810+ app_port(),
811811+ outdated_cursor
812812+ );
813813+814814+ let (mut ws_stream, _) = connect_async(&url).await.expect("Failed to connect");
815815+816816+ let mut found_info = false;
817817+ let mut found_commit = false;
818818+819819+ let timeout = tokio::time::timeout(std::time::Duration::from_secs(15), async {
820820+ loop {
821821+ match ws_stream.next().await {
822822+ Some(Ok(tungstenite::Message::Binary(bin))) => {
823823+ if let Ok((header, body)) = parse_info_frame(&bin) {
824824+ println!("Received info frame: {:?} {:?}", header, body);
825825+ if body.name == "OutdatedCursor" {
826826+ found_info = true;
827827+ println!("Found OutdatedCursor info frame!");
828828+ }
829829+ } else if let Ok((_, frame)) = parse_frame(&bin) {
830830+ if frame.repo == did {
831831+ found_commit = true;
832832+ println!("Found commit for our DID");
833833+ }
834834+ }
835835+ if found_commit {
836836+ break;
837837+ }
838838+ }
839839+ Some(Ok(tungstenite::Message::Close(_))) => break,
840840+ None => break,
841841+ _ => continue,
842842+ }
843843+ }
844844+ })
845845+ .await;
846846+847847+ assert!(timeout.is_ok(), "Timed out");
848848+ assert!(
849849+ found_commit,
850850+ "Should have received commits even with outdated cursor"
851851+ );
852852+}
+38
tests/helpers/mod.rs
···214214 body["cid"].as_str().unwrap().to_string(),
215215 )
216216}
217217+218218+#[allow(dead_code)]
219219+pub async fn set_account_takedown(did: &str, takedown_ref: Option<&str>) {
220220+ let conn_str = get_db_connection_string().await;
221221+ let pool = sqlx::postgres::PgPoolOptions::new()
222222+ .max_connections(2)
223223+ .connect(&conn_str)
224224+ .await
225225+ .expect("Failed to connect to test database");
226226+ sqlx::query!(
227227+ "UPDATE users SET takedown_ref = $1 WHERE did = $2",
228228+ takedown_ref,
229229+ did
230230+ )
231231+ .execute(&pool)
232232+ .await
233233+ .expect("Failed to update takedown_ref");
234234+}
235235+236236+#[allow(dead_code)]
237237+pub async fn set_account_deactivated(did: &str, deactivated: bool) {
238238+ let conn_str = get_db_connection_string().await;
239239+ let pool = sqlx::postgres::PgPoolOptions::new()
240240+ .max_connections(2)
241241+ .connect(&conn_str)
242242+ .await
243243+ .expect("Failed to connect to test database");
244244+ let deactivated_at: Option<chrono::DateTime<Utc>> =
245245+ if deactivated { Some(Utc::now()) } else { None };
246246+ sqlx::query!(
247247+ "UPDATE users SET deactivated_at = $1 WHERE did = $2",
248248+ deactivated_at,
249249+ did
250250+ )
251251+ .execute(&pool)
252252+ .await
253253+ .expect("Failed to update deactivated_at");
254254+}
+1-1
tests/sync_blob.rs
···5050 .send()
5151 .await
5252 .expect("Failed to send request");
5353- assert_eq!(res.status(), StatusCode::NOT_FOUND);
5353+ assert_eq!(res.status(), StatusCode::BAD_REQUEST);
5454 let body: Value = res.json().await.expect("Response was not valid JSON");
5555 assert_eq!(body["error"], "RepoNotFound");
5656}
+443
tests/sync_conformance.rs
···11+mod common;
22+mod helpers;
33+44+use common::*;
55+use helpers::*;
66+use reqwest::StatusCode;
77+use serde_json::Value;
88+99+#[tokio::test]
1010+async fn test_get_repo_takendown_returns_error() {
1111+ let client = client();
1212+ let (_, did) = create_account_and_login(&client).await;
1313+1414+ set_account_takedown(&did, Some("test-takedown-ref")).await;
1515+1616+ let res = client
1717+ .get(format!(
1818+ "{}/xrpc/com.atproto.sync.getRepo",
1919+ base_url().await
2020+ ))
2121+ .query(&[("did", did.as_str())])
2222+ .send()
2323+ .await
2424+ .expect("Failed to send request");
2525+2626+ assert_eq!(res.status(), StatusCode::BAD_REQUEST);
2727+ let body: Value = res.json().await.expect("Response was not valid JSON");
2828+ assert_eq!(body["error"], "RepoTakendown");
2929+}
3030+3131+#[tokio::test]
3232+async fn test_get_repo_deactivated_returns_error() {
3333+ let client = client();
3434+ let (_, did) = create_account_and_login(&client).await;
3535+3636+ set_account_deactivated(&did, true).await;
3737+3838+ let res = client
3939+ .get(format!(
4040+ "{}/xrpc/com.atproto.sync.getRepo",
4141+ base_url().await
4242+ ))
4343+ .query(&[("did", did.as_str())])
4444+ .send()
4545+ .await
4646+ .expect("Failed to send request");
4747+4848+ assert_eq!(res.status(), StatusCode::BAD_REQUEST);
4949+ let body: Value = res.json().await.expect("Response was not valid JSON");
5050+ assert_eq!(body["error"], "RepoDeactivated");
5151+}
5252+5353+#[tokio::test]
5454+async fn test_get_latest_commit_takendown_returns_error() {
5555+ let client = client();
5656+ let (_, did) = create_account_and_login(&client).await;
5757+5858+ set_account_takedown(&did, Some("test-takedown-ref")).await;
5959+6060+ let res = client
6161+ .get(format!(
6262+ "{}/xrpc/com.atproto.sync.getLatestCommit",
6363+ base_url().await
6464+ ))
6565+ .query(&[("did", did.as_str())])
6666+ .send()
6767+ .await
6868+ .expect("Failed to send request");
6969+7070+ assert_eq!(res.status(), StatusCode::BAD_REQUEST);
7171+ let body: Value = res.json().await.expect("Response was not valid JSON");
7272+ assert_eq!(body["error"], "RepoTakendown");
7373+}
7474+7575+#[tokio::test]
7676+async fn test_get_blocks_takendown_returns_error() {
7777+ let client = client();
7878+ let (_, did) = create_account_and_login(&client).await;
7979+8080+ let commit_res = client
8181+ .get(format!(
8282+ "{}/xrpc/com.atproto.sync.getLatestCommit",
8383+ base_url().await
8484+ ))
8585+ .query(&[("did", did.as_str())])
8686+ .send()
8787+ .await
8888+ .expect("Failed to get commit");
8989+ let commit_body: Value = commit_res.json().await.unwrap();
9090+ let cid = commit_body["cid"].as_str().unwrap();
9191+9292+ set_account_takedown(&did, Some("test-takedown-ref")).await;
9393+9494+ let res = client
9595+ .get(format!(
9696+ "{}/xrpc/com.atproto.sync.getBlocks",
9797+ base_url().await
9898+ ))
9999+ .query(&[("did", did.as_str()), ("cids", cid)])
100100+ .send()
101101+ .await
102102+ .expect("Failed to send request");
103103+104104+ assert_eq!(res.status(), StatusCode::BAD_REQUEST);
105105+ let body: Value = res.json().await.expect("Response was not valid JSON");
106106+ assert_eq!(body["error"], "RepoTakendown");
107107+}
108108+109109+#[tokio::test]
110110+async fn test_get_repo_status_shows_takendown_status() {
111111+ let client = client();
112112+ let (_, did) = create_account_and_login(&client).await;
113113+114114+ set_account_takedown(&did, Some("test-takedown-ref")).await;
115115+116116+ let res = client
117117+ .get(format!(
118118+ "{}/xrpc/com.atproto.sync.getRepoStatus",
119119+ base_url().await
120120+ ))
121121+ .query(&[("did", did.as_str())])
122122+ .send()
123123+ .await
124124+ .expect("Failed to send request");
125125+126126+ assert_eq!(res.status(), StatusCode::OK);
127127+ let body: Value = res.json().await.expect("Response was not valid JSON");
128128+ assert_eq!(body["active"], false);
129129+ assert_eq!(body["status"], "takendown");
130130+ assert!(body.get("rev").is_none() || body["rev"].is_null());
131131+}
132132+133133+#[tokio::test]
134134+async fn test_get_repo_status_shows_deactivated_status() {
135135+ let client = client();
136136+ let (_, did) = create_account_and_login(&client).await;
137137+138138+ set_account_deactivated(&did, true).await;
139139+140140+ let res = client
141141+ .get(format!(
142142+ "{}/xrpc/com.atproto.sync.getRepoStatus",
143143+ base_url().await
144144+ ))
145145+ .query(&[("did", did.as_str())])
146146+ .send()
147147+ .await
148148+ .expect("Failed to send request");
149149+150150+ assert_eq!(res.status(), StatusCode::OK);
151151+ let body: Value = res.json().await.expect("Response was not valid JSON");
152152+ assert_eq!(body["active"], false);
153153+ assert_eq!(body["status"], "deactivated");
154154+}
155155+156156+#[tokio::test]
157157+async fn test_list_repos_shows_status_field() {
158158+ let client = client();
159159+ let (_, did) = create_account_and_login(&client).await;
160160+161161+ set_account_takedown(&did, Some("test-takedown-ref")).await;
162162+163163+ let res = client
164164+ .get(format!(
165165+ "{}/xrpc/com.atproto.sync.listRepos",
166166+ base_url().await
167167+ ))
168168+ .send()
169169+ .await
170170+ .expect("Failed to send request");
171171+172172+ assert_eq!(res.status(), StatusCode::OK);
173173+ let body: Value = res.json().await.expect("Response was not valid JSON");
174174+ let repos = body["repos"].as_array().unwrap();
175175+176176+ let takendown_repo = repos.iter().find(|r| r["did"] == did);
177177+ assert!(takendown_repo.is_some(), "Takendown repo should be in list");
178178+ let repo = takendown_repo.unwrap();
179179+ assert_eq!(repo["active"], false);
180180+ assert_eq!(repo["status"], "takendown");
181181+}
182182+183183+#[tokio::test]
184184+async fn test_get_blob_takendown_returns_error() {
185185+ let client = client();
186186+ let (jwt, did) = create_account_and_login(&client).await;
187187+188188+ let blob_res = client
189189+ .post(format!(
190190+ "{}/xrpc/com.atproto.repo.uploadBlob",
191191+ base_url().await
192192+ ))
193193+ .header("Content-Type", "image/png")
194194+ .bearer_auth(&jwt)
195195+ .body(vec![0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A])
196196+ .send()
197197+ .await
198198+ .expect("Failed to upload blob");
199199+ let blob_body: Value = blob_res.json().await.unwrap();
200200+ let cid = blob_body["blob"]["ref"]["$link"].as_str().unwrap();
201201+202202+ set_account_takedown(&did, Some("test-takedown-ref")).await;
203203+204204+ let res = client
205205+ .get(format!(
206206+ "{}/xrpc/com.atproto.sync.getBlob",
207207+ base_url().await
208208+ ))
209209+ .query(&[("did", did.as_str()), ("cid", cid)])
210210+ .send()
211211+ .await
212212+ .expect("Failed to send request");
213213+214214+ assert_eq!(res.status(), StatusCode::BAD_REQUEST);
215215+ let body: Value = res.json().await.expect("Response was not valid JSON");
216216+ assert_eq!(body["error"], "RepoTakendown");
217217+}
218218+219219+#[tokio::test]
220220+async fn test_get_blob_has_security_headers() {
221221+ let client = client();
222222+ let (jwt, did) = create_account_and_login(&client).await;
223223+224224+ let blob_res = client
225225+ .post(format!(
226226+ "{}/xrpc/com.atproto.repo.uploadBlob",
227227+ base_url().await
228228+ ))
229229+ .header("Content-Type", "image/png")
230230+ .bearer_auth(&jwt)
231231+ .body(vec![0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A])
232232+ .send()
233233+ .await
234234+ .expect("Failed to upload blob");
235235+ let blob_body: Value = blob_res.json().await.unwrap();
236236+ let cid = blob_body["blob"]["ref"]["$link"].as_str().unwrap();
237237+238238+ let res = client
239239+ .get(format!(
240240+ "{}/xrpc/com.atproto.sync.getBlob",
241241+ base_url().await
242242+ ))
243243+ .query(&[("did", did.as_str()), ("cid", cid)])
244244+ .send()
245245+ .await
246246+ .expect("Failed to send request");
247247+248248+ assert_eq!(res.status(), StatusCode::OK);
249249+250250+ let headers = res.headers();
251251+ assert_eq!(
252252+ headers
253253+ .get("x-content-type-options")
254254+ .map(|v| v.to_str().unwrap()),
255255+ Some("nosniff"),
256256+ "Missing x-content-type-options: nosniff header"
257257+ );
258258+ assert_eq!(
259259+ headers
260260+ .get("content-security-policy")
261261+ .map(|v| v.to_str().unwrap()),
262262+ Some("default-src 'none'; sandbox"),
263263+ "Missing content-security-policy header"
264264+ );
265265+ assert!(
266266+ headers.get("content-length").is_some(),
267267+ "Missing content-length header"
268268+ );
269269+}
270270+271271+#[tokio::test]
272272+async fn test_get_blocks_missing_cids_returns_error() {
273273+ let client = client();
274274+ let (_, did) = create_account_and_login(&client).await;
275275+276276+ let fake_cid = "bafyreif2pall7dybz7vecqka3zo24irdwabwdi4wc55jznaq75q7eaavvu";
277277+278278+ let res = client
279279+ .get(format!(
280280+ "{}/xrpc/com.atproto.sync.getBlocks",
281281+ base_url().await
282282+ ))
283283+ .query(&[("did", did.as_str()), ("cids", fake_cid)])
284284+ .send()
285285+ .await
286286+ .expect("Failed to send request");
287287+288288+ assert_eq!(res.status(), StatusCode::BAD_REQUEST);
289289+ let body: Value = res.json().await.expect("Response was not valid JSON");
290290+ assert_eq!(body["error"], "InvalidRequest");
291291+ assert!(
292292+ body["message"]
293293+ .as_str()
294294+ .unwrap()
295295+ .contains("Could not find blocks"),
296296+ "Error message should mention missing blocks"
297297+ );
298298+}
299299+300300+#[tokio::test]
301301+async fn test_get_blocks_accepts_array_format() {
302302+ let client = client();
303303+ let (_, did) = create_account_and_login(&client).await;
304304+305305+ let commit_res = client
306306+ .get(format!(
307307+ "{}/xrpc/com.atproto.sync.getLatestCommit",
308308+ base_url().await
309309+ ))
310310+ .query(&[("did", did.as_str())])
311311+ .send()
312312+ .await
313313+ .expect("Failed to get commit");
314314+ let commit_body: Value = commit_res.json().await.unwrap();
315315+ let cid = commit_body["cid"].as_str().unwrap();
316316+317317+ let url = format!(
318318+ "{}/xrpc/com.atproto.sync.getBlocks?did={}&cids={}&cids={}",
319319+ base_url().await,
320320+ did,
321321+ cid,
322322+ cid
323323+ );
324324+ let res = client
325325+ .get(&url)
326326+ .send()
327327+ .await
328328+ .expect("Failed to send request");
329329+330330+ assert_eq!(res.status(), StatusCode::OK);
331331+ let content_type = res.headers().get("content-type").unwrap().to_str().unwrap();
332332+ assert!(
333333+ content_type.contains("application/vnd.ipld.car"),
334334+ "Response should be a CAR file"
335335+ );
336336+}
337337+338338+#[tokio::test]
339339+async fn test_get_repo_since_returns_partial() {
340340+ let client = client();
341341+ let (jwt, did) = create_account_and_login(&client).await;
342342+343343+ let initial_commit_res = client
344344+ .get(format!(
345345+ "{}/xrpc/com.atproto.sync.getLatestCommit",
346346+ base_url().await
347347+ ))
348348+ .query(&[("did", did.as_str())])
349349+ .send()
350350+ .await
351351+ .expect("Failed to get initial commit");
352352+ let initial_body: Value = initial_commit_res.json().await.unwrap();
353353+ let initial_rev = initial_body["rev"].as_str().unwrap();
354354+355355+ let full_repo_res = client
356356+ .get(format!(
357357+ "{}/xrpc/com.atproto.sync.getRepo",
358358+ base_url().await
359359+ ))
360360+ .query(&[("did", did.as_str())])
361361+ .send()
362362+ .await
363363+ .expect("Failed to get full repo");
364364+ assert_eq!(full_repo_res.status(), StatusCode::OK);
365365+ let full_repo_bytes = full_repo_res.bytes().await.unwrap();
366366+ let full_repo_size = full_repo_bytes.len();
367367+368368+ create_post(&client, &did, &jwt, "Test post for since param").await;
369369+370370+ let partial_repo_res = client
371371+ .get(format!(
372372+ "{}/xrpc/com.atproto.sync.getRepo",
373373+ base_url().await
374374+ ))
375375+ .query(&[("did", did.as_str()), ("since", initial_rev)])
376376+ .send()
377377+ .await
378378+ .expect("Failed to get partial repo");
379379+ assert_eq!(partial_repo_res.status(), StatusCode::OK);
380380+ let partial_repo_bytes = partial_repo_res.bytes().await.unwrap();
381381+ let partial_repo_size = partial_repo_bytes.len();
382382+383383+ assert!(
384384+ partial_repo_size < full_repo_size,
385385+ "Partial export (since={}) should be smaller than full export: {} vs {}",
386386+ initial_rev,
387387+ partial_repo_size,
388388+ full_repo_size
389389+ );
390390+}
391391+392392+#[tokio::test]
393393+async fn test_list_blobs_takendown_returns_error() {
394394+ let client = client();
395395+ let (_, did) = create_account_and_login(&client).await;
396396+397397+ set_account_takedown(&did, Some("test-takedown-ref")).await;
398398+399399+ let res = client
400400+ .get(format!(
401401+ "{}/xrpc/com.atproto.sync.listBlobs",
402402+ base_url().await
403403+ ))
404404+ .query(&[("did", did.as_str())])
405405+ .send()
406406+ .await
407407+ .expect("Failed to send request");
408408+409409+ assert_eq!(res.status(), StatusCode::BAD_REQUEST);
410410+ let body: Value = res.json().await.expect("Response was not valid JSON");
411411+ assert_eq!(body["error"], "RepoTakendown");
412412+}
413413+414414+#[tokio::test]
415415+async fn test_get_record_takendown_returns_error() {
416416+ let client = client();
417417+ let (jwt, did) = create_account_and_login(&client).await;
418418+419419+ let (uri, _cid) = create_post(&client, &did, &jwt, "Test post").await;
420420+ let parts: Vec<&str> = uri.split('/').collect();
421421+ let collection = parts[parts.len() - 2];
422422+ let rkey = parts[parts.len() - 1];
423423+424424+ set_account_takedown(&did, Some("test-takedown-ref")).await;
425425+426426+ let res = client
427427+ .get(format!(
428428+ "{}/xrpc/com.atproto.sync.getRecord",
429429+ base_url().await
430430+ ))
431431+ .query(&[
432432+ ("did", did.as_str()),
433433+ ("collection", collection),
434434+ ("rkey", rkey),
435435+ ])
436436+ .send()
437437+ .await
438438+ .expect("Failed to send request");
439439+440440+ assert_eq!(res.status(), StatusCode::BAD_REQUEST);
441441+ let body: Value = res.json().await.expect("Response was not valid JSON");
442442+ assert_eq!(body["error"], "RepoTakendown");
443443+}
+1-1
tests/sync_deprecated.rs
···138138 "CAR file should have at least header length"
139139 );
140140 for i in 0..4 {
141141- tokio::time::sleep(std::time::Duration::from_millis(50)).await;
141141+ tokio::time::sleep(std::time::Duration::from_millis(100)).await;
142142 create_post(&client, &did, &jwt, &format!("Checkout post {}", i)).await;
143143 }
144144 let multi_res = client
+40-27
tests/sync_repo.rs
···3939 .send()
4040 .await
4141 .expect("Failed to send request");
4242- assert_eq!(res.status(), StatusCode::NOT_FOUND);
4242+ assert_eq!(res.status(), StatusCode::BAD_REQUEST);
4343 let body: Value = res.json().await.expect("Response was not valid JSON");
4444 assert_eq!(body["error"], "RepoNotFound");
4545}
···106106#[tokio::test]
107107async fn test_list_repos_pagination() {
108108 let client = client();
109109- let _ = create_account_and_login(&client).await;
110110- let _ = create_account_and_login(&client).await;
111111- let _ = create_account_and_login(&client).await;
112112- let params = [("limit", "1")];
113113- let res = client
114114- .get(format!(
115115- "{}/xrpc/com.atproto.sync.listRepos",
116116- base_url().await
117117- ))
118118- .query(¶ms)
119119- .send()
120120- .await
121121- .expect("Failed to send request");
122122- assert_eq!(res.status(), StatusCode::OK);
123123- let body: Value = res.json().await.expect("Response was not valid JSON");
124124- let repos = body["repos"].as_array().unwrap();
125125- assert_eq!(repos.len(), 1);
126126- if let Some(cursor) = body["cursor"].as_str() {
127127- let params = [("limit", "1"), ("cursor", cursor)];
109109+ let (_, did1) = create_account_and_login(&client).await;
110110+ let (_, did2) = create_account_and_login(&client).await;
111111+ let (_, did3) = create_account_and_login(&client).await;
112112+ let our_dids: std::collections::HashSet<String> = [did1, did2, did3].into_iter().collect();
113113+ let mut all_dids_seen: std::collections::HashSet<String> = std::collections::HashSet::new();
114114+ let mut cursor: Option<String> = None;
115115+ let mut page_count = 0;
116116+ let max_pages = 100;
117117+ loop {
118118+ let mut params: Vec<(&str, String)> = vec![("limit".into(), "10".into())];
119119+ if let Some(ref c) = cursor {
120120+ params.push(("cursor", c.clone()));
121121+ }
128122 let res = client
129123 .get(format!(
130124 "{}/xrpc/com.atproto.sync.listRepos",
···136130 .expect("Failed to send request");
137131 assert_eq!(res.status(), StatusCode::OK);
138132 let body: Value = res.json().await.expect("Response was not valid JSON");
139139- let repos2 = body["repos"].as_array().unwrap();
140140- assert_eq!(repos2.len(), 1);
141141- assert_ne!(repos[0]["did"], repos2[0]["did"]);
133133+ let repos = body["repos"].as_array().unwrap();
134134+ for repo in repos {
135135+ let did = repo["did"].as_str().unwrap().to_string();
136136+ assert!(
137137+ !all_dids_seen.contains(&did),
138138+ "Pagination returned duplicate DID: {}",
139139+ did
140140+ );
141141+ all_dids_seen.insert(did);
142142+ }
143143+ cursor = body["cursor"].as_str().map(String::from);
144144+ page_count += 1;
145145+ if cursor.is_none() || page_count >= max_pages {
146146+ break;
147147+ }
148148+ }
149149+ for did in &our_dids {
150150+ assert!(
151151+ all_dids_seen.contains(did),
152152+ "Our created DID {} was not found in paginated results",
153153+ did
154154+ );
142155 }
143156}
144157···176189 .send()
177190 .await
178191 .expect("Failed to send request");
179179- assert_eq!(res.status(), StatusCode::NOT_FOUND);
192192+ assert_eq!(res.status(), StatusCode::BAD_REQUEST);
180193 let body: Value = res.json().await.expect("Response was not valid JSON");
181194 assert_eq!(body["error"], "RepoNotFound");
182195}
···270283 .send()
271284 .await
272285 .expect("Failed to send request");
273273- assert_eq!(res.status(), StatusCode::NOT_FOUND);
286286+ assert_eq!(res.status(), StatusCode::BAD_REQUEST);
274287 let body: Value = res.json().await.expect("Response was not valid JSON");
275288 assert_eq!(body["error"], "RepoNotFound");
276289}
···397410 .send()
398411 .await
399412 .expect("Failed to send request");
400400- assert_eq!(res.status(), StatusCode::NOT_FOUND);
413413+ assert_eq!(res.status(), StatusCode::BAD_REQUEST);
401414}
402415403416#[tokio::test]
···536549 .expect("Failed to create profile");
537550 assert_eq!(profile_res.status(), StatusCode::OK);
538551 for i in 0..3 {
539539- tokio::time::sleep(std::time::Duration::from_millis(50)).await;
552552+ tokio::time::sleep(std::time::Duration::from_millis(100)).await;
540553 create_post(&client, &did, &jwt, &format!("Export test post {}", i)).await;
541554 }
542555 let blob_data = b"blob data for sync export test";