this repo has no description
1mod common;
2mod helpers;
3
4use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
5use chrono::Utc;
6use common::{base_url, client};
7use helpers::verify_new_account;
8use reqwest::{StatusCode, redirect};
9use serde_json::{Value, json};
10use sha2::{Digest, Sha256};
11use wiremock::matchers::{method, path};
12use wiremock::{Mock, MockServer, ResponseTemplate};
13
14fn generate_pkce() -> (String, String) {
15 let verifier_bytes: [u8; 32] = rand::random();
16 let code_verifier = URL_SAFE_NO_PAD.encode(verifier_bytes);
17 let mut hasher = Sha256::new();
18 hasher.update(code_verifier.as_bytes());
19 let hash = hasher.finalize();
20 let code_challenge = URL_SAFE_NO_PAD.encode(&hash);
21 (code_verifier, code_challenge)
22}
23
24fn no_redirect_client() -> reqwest::Client {
25 reqwest::Client::builder()
26 .redirect(redirect::Policy::none())
27 .build()
28 .unwrap()
29}
30
31async fn setup_mock_client_metadata(redirect_uri: &str) -> MockServer {
32 let mock_server = MockServer::start().await;
33 let client_id = mock_server.uri();
34 let metadata = json!({
35 "client_id": client_id,
36 "client_name": "Test OAuth Client",
37 "redirect_uris": [redirect_uri],
38 "grant_types": ["authorization_code", "refresh_token"],
39 "response_types": ["code"],
40 "token_endpoint_auth_method": "none",
41 "dpop_bound_access_tokens": false
42 });
43 Mock::given(method("GET"))
44 .and(path("/"))
45 .respond_with(ResponseTemplate::new(200).set_body_json(metadata))
46 .mount(&mock_server)
47 .await;
48 mock_server
49}
50
51struct OAuthSession {
52 access_token: String,
53 refresh_token: String,
54 did: String,
55 client_id: String,
56}
57
58async fn create_user_and_oauth_session(
59 handle_prefix: &str,
60 redirect_uri: &str,
61) -> (OAuthSession, MockServer) {
62 let url = base_url().await;
63 let http_client = client();
64 let ts = Utc::now().timestamp_millis();
65 let handle = format!("{}-{}", handle_prefix, ts);
66 let email = format!("{}-{}@example.com", handle_prefix, ts);
67 let password = format!("{}-password", handle_prefix);
68 let create_res = http_client
69 .post(format!("{}/xrpc/com.atproto.server.createAccount", url))
70 .json(&json!({
71 "handle": handle,
72 "email": email,
73 "password": password
74 }))
75 .send()
76 .await
77 .expect("Account creation failed");
78 assert_eq!(create_res.status(), StatusCode::OK);
79 let account: Value = create_res.json().await.unwrap();
80 let user_did = account["did"].as_str().unwrap().to_string();
81 let _ = verify_new_account(&http_client, &user_did).await;
82 let mock_client = setup_mock_client_metadata(redirect_uri).await;
83 let client_id = mock_client.uri();
84 let (code_verifier, code_challenge) = generate_pkce();
85 let par_res = http_client
86 .post(format!("{}/oauth/par", url))
87 .form(&[
88 ("response_type", "code"),
89 ("client_id", &client_id),
90 ("redirect_uri", redirect_uri),
91 ("code_challenge", &code_challenge),
92 ("code_challenge_method", "S256"),
93 ("scope", "atproto"),
94 ])
95 .send()
96 .await
97 .expect("PAR failed");
98 assert!(
99 par_res.status() == StatusCode::OK || par_res.status() == StatusCode::CREATED,
100 "PAR should succeed with 200 or 201, got {}",
101 par_res.status()
102 );
103 let par_body: Value = par_res.json().await.unwrap();
104 let request_uri = par_body["request_uri"].as_str().unwrap();
105 let auth_client = no_redirect_client();
106 let auth_res = auth_client
107 .post(format!("{}/oauth/authorize", url))
108 .form(&[
109 ("request_uri", request_uri),
110 ("username", &handle),
111 ("password", &password),
112 ("remember_device", "false"),
113 ])
114 .send()
115 .await
116 .expect("Authorize failed");
117 let location = auth_res
118 .headers()
119 .get("location")
120 .unwrap()
121 .to_str()
122 .unwrap();
123 let code = location
124 .split("code=")
125 .nth(1)
126 .unwrap()
127 .split('&')
128 .next()
129 .unwrap();
130 let token_res = http_client
131 .post(format!("{}/oauth/token", url))
132 .form(&[
133 ("grant_type", "authorization_code"),
134 ("code", code),
135 ("redirect_uri", redirect_uri),
136 ("code_verifier", &code_verifier),
137 ("client_id", &client_id),
138 ])
139 .send()
140 .await
141 .expect("Token request failed");
142 assert_eq!(token_res.status(), StatusCode::OK);
143 let token_body: Value = token_res.json().await.unwrap();
144 let session = OAuthSession {
145 access_token: token_body["access_token"].as_str().unwrap().to_string(),
146 refresh_token: token_body["refresh_token"].as_str().unwrap().to_string(),
147 did: user_did,
148 client_id,
149 };
150 (session, mock_client)
151}
152
153#[tokio::test]
154async fn test_oauth_token_can_create_and_read_records() {
155 let url = base_url().await;
156 let http_client = client();
157 let (session, _mock) =
158 create_user_and_oauth_session("oauth-records", "https://example.com/callback").await;
159 let collection = "app.bsky.feed.post";
160 let post_text = "Hello from OAuth! This post was created with an OAuth access token.";
161 let create_res = http_client
162 .post(format!("{}/xrpc/com.atproto.repo.createRecord", url))
163 .bearer_auth(&session.access_token)
164 .json(&json!({
165 "repo": session.did,
166 "collection": collection,
167 "record": {
168 "$type": collection,
169 "text": post_text,
170 "createdAt": Utc::now().to_rfc3339()
171 }
172 }))
173 .send()
174 .await
175 .expect("createRecord failed");
176 assert_eq!(
177 create_res.status(),
178 StatusCode::OK,
179 "Should create record with OAuth token"
180 );
181 let create_body: Value = create_res.json().await.unwrap();
182 let uri = create_body["uri"].as_str().unwrap();
183 let rkey = uri.split('/').last().unwrap();
184 let get_res = http_client
185 .get(format!("{}/xrpc/com.atproto.repo.getRecord", url))
186 .bearer_auth(&session.access_token)
187 .query(&[
188 ("repo", session.did.as_str()),
189 ("collection", collection),
190 ("rkey", rkey),
191 ])
192 .send()
193 .await
194 .expect("getRecord failed");
195 assert_eq!(
196 get_res.status(),
197 StatusCode::OK,
198 "Should read record with OAuth token"
199 );
200 let get_body: Value = get_res.json().await.unwrap();
201 assert_eq!(get_body["value"]["text"], post_text);
202}
203
204#[tokio::test]
205async fn test_oauth_token_can_upload_blob() {
206 let url = base_url().await;
207 let http_client = client();
208 let (session, _mock) =
209 create_user_and_oauth_session("oauth-blob", "https://example.com/callback").await;
210 let blob_data = b"This is test blob data uploaded via OAuth";
211 let upload_res = http_client
212 .post(format!("{}/xrpc/com.atproto.repo.uploadBlob", url))
213 .bearer_auth(&session.access_token)
214 .header("Content-Type", "text/plain")
215 .body(blob_data.to_vec())
216 .send()
217 .await
218 .expect("uploadBlob failed");
219 assert_eq!(
220 upload_res.status(),
221 StatusCode::OK,
222 "Should upload blob with OAuth token"
223 );
224 let upload_body: Value = upload_res.json().await.unwrap();
225 assert!(upload_body["blob"]["ref"]["$link"].is_string());
226 assert_eq!(upload_body["blob"]["mimeType"], "text/plain");
227}
228
229#[tokio::test]
230async fn test_oauth_token_can_describe_repo() {
231 let url = base_url().await;
232 let http_client = client();
233 let (session, _mock) =
234 create_user_and_oauth_session("oauth-describe", "https://example.com/callback").await;
235 let describe_res = http_client
236 .get(format!("{}/xrpc/com.atproto.repo.describeRepo", url))
237 .bearer_auth(&session.access_token)
238 .query(&[("repo", session.did.as_str())])
239 .send()
240 .await
241 .expect("describeRepo failed");
242 assert_eq!(
243 describe_res.status(),
244 StatusCode::OK,
245 "Should describe repo with OAuth token"
246 );
247 let describe_body: Value = describe_res.json().await.unwrap();
248 assert_eq!(describe_body["did"], session.did);
249 assert!(describe_body["handle"].is_string());
250}
251
252#[tokio::test]
253async fn test_oauth_full_post_lifecycle_create_edit_delete() {
254 let url = base_url().await;
255 let http_client = client();
256 let (session, _mock) =
257 create_user_and_oauth_session("oauth-lifecycle", "https://example.com/callback").await;
258 let collection = "app.bsky.feed.post";
259 let original_text = "Original post content";
260 let create_res = http_client
261 .post(format!("{}/xrpc/com.atproto.repo.createRecord", url))
262 .bearer_auth(&session.access_token)
263 .json(&json!({
264 "repo": session.did,
265 "collection": collection,
266 "record": {
267 "$type": collection,
268 "text": original_text,
269 "createdAt": Utc::now().to_rfc3339()
270 }
271 }))
272 .send()
273 .await
274 .unwrap();
275 assert_eq!(create_res.status(), StatusCode::OK);
276 let create_body: Value = create_res.json().await.unwrap();
277 let uri = create_body["uri"].as_str().unwrap();
278 let rkey = uri.split('/').last().unwrap();
279 let updated_text = "Updated post content via OAuth putRecord";
280 let put_res = http_client
281 .post(format!("{}/xrpc/com.atproto.repo.putRecord", url))
282 .bearer_auth(&session.access_token)
283 .json(&json!({
284 "repo": session.did,
285 "collection": collection,
286 "rkey": rkey,
287 "record": {
288 "$type": collection,
289 "text": updated_text,
290 "createdAt": Utc::now().to_rfc3339()
291 }
292 }))
293 .send()
294 .await
295 .unwrap();
296 assert_eq!(
297 put_res.status(),
298 StatusCode::OK,
299 "Should update record with OAuth token"
300 );
301 let get_res = http_client
302 .get(format!("{}/xrpc/com.atproto.repo.getRecord", url))
303 .bearer_auth(&session.access_token)
304 .query(&[
305 ("repo", session.did.as_str()),
306 ("collection", collection),
307 ("rkey", rkey),
308 ])
309 .send()
310 .await
311 .unwrap();
312 let get_body: Value = get_res.json().await.unwrap();
313 assert_eq!(
314 get_body["value"]["text"], updated_text,
315 "Record should have updated text"
316 );
317 let delete_res = http_client
318 .post(format!("{}/xrpc/com.atproto.repo.deleteRecord", url))
319 .bearer_auth(&session.access_token)
320 .json(&json!({
321 "repo": session.did,
322 "collection": collection,
323 "rkey": rkey
324 }))
325 .send()
326 .await
327 .unwrap();
328 assert_eq!(
329 delete_res.status(),
330 StatusCode::OK,
331 "Should delete record with OAuth token"
332 );
333 let get_deleted_res = http_client
334 .get(format!("{}/xrpc/com.atproto.repo.getRecord", url))
335 .bearer_auth(&session.access_token)
336 .query(&[
337 ("repo", session.did.as_str()),
338 ("collection", collection),
339 ("rkey", rkey),
340 ])
341 .send()
342 .await
343 .unwrap();
344 assert!(
345 get_deleted_res.status() == StatusCode::BAD_REQUEST
346 || get_deleted_res.status() == StatusCode::NOT_FOUND,
347 "Deleted record should not be found, got {}",
348 get_deleted_res.status()
349 );
350}
351
352#[tokio::test]
353async fn test_oauth_batch_operations_apply_writes() {
354 let url = base_url().await;
355 let http_client = client();
356 let (session, _mock) =
357 create_user_and_oauth_session("oauth-batch", "https://example.com/callback").await;
358 let collection = "app.bsky.feed.post";
359 let now = Utc::now().to_rfc3339();
360 let apply_res = http_client
361 .post(format!("{}/xrpc/com.atproto.repo.applyWrites", url))
362 .bearer_auth(&session.access_token)
363 .json(&json!({
364 "repo": session.did,
365 "writes": [
366 {
367 "$type": "com.atproto.repo.applyWrites#create",
368 "collection": collection,
369 "rkey": "batch1",
370 "value": {
371 "$type": collection,
372 "text": "Batch post 1",
373 "createdAt": now
374 }
375 },
376 {
377 "$type": "com.atproto.repo.applyWrites#create",
378 "collection": collection,
379 "rkey": "batch2",
380 "value": {
381 "$type": collection,
382 "text": "Batch post 2",
383 "createdAt": now
384 }
385 },
386 {
387 "$type": "com.atproto.repo.applyWrites#create",
388 "collection": collection,
389 "rkey": "batch3",
390 "value": {
391 "$type": collection,
392 "text": "Batch post 3",
393 "createdAt": now
394 }
395 }
396 ]
397 }))
398 .send()
399 .await
400 .unwrap();
401 assert_eq!(
402 apply_res.status(),
403 StatusCode::OK,
404 "Should apply batch writes with OAuth token"
405 );
406 let list_res = http_client
407 .get(format!("{}/xrpc/com.atproto.repo.listRecords", url))
408 .bearer_auth(&session.access_token)
409 .query(&[("repo", session.did.as_str()), ("collection", collection)])
410 .send()
411 .await
412 .unwrap();
413 assert_eq!(list_res.status(), StatusCode::OK);
414 let list_body: Value = list_res.json().await.unwrap();
415 let records = list_body["records"].as_array().unwrap();
416 assert!(
417 records.len() >= 3,
418 "Should have at least 3 records from batch"
419 );
420}
421
422#[tokio::test]
423async fn test_oauth_token_refresh_maintains_access() {
424 let url = base_url().await;
425 let http_client = client();
426 let (session, _mock) =
427 create_user_and_oauth_session("oauth-refresh-access", "https://example.com/callback").await;
428 let collection = "app.bsky.feed.post";
429 let create_res = http_client
430 .post(format!("{}/xrpc/com.atproto.repo.createRecord", url))
431 .bearer_auth(&session.access_token)
432 .json(&json!({
433 "repo": session.did,
434 "collection": collection,
435 "record": {
436 "$type": collection,
437 "text": "Post before refresh",
438 "createdAt": Utc::now().to_rfc3339()
439 }
440 }))
441 .send()
442 .await
443 .unwrap();
444 assert_eq!(
445 create_res.status(),
446 StatusCode::OK,
447 "Original token should work"
448 );
449 let refresh_res = http_client
450 .post(format!("{}/oauth/token", url))
451 .form(&[
452 ("grant_type", "refresh_token"),
453 ("refresh_token", &session.refresh_token),
454 ("client_id", &session.client_id),
455 ])
456 .send()
457 .await
458 .unwrap();
459 assert_eq!(refresh_res.status(), StatusCode::OK);
460 let refresh_body: Value = refresh_res.json().await.unwrap();
461 let new_access_token = refresh_body["access_token"].as_str().unwrap();
462 assert_ne!(
463 new_access_token, session.access_token,
464 "New token should be different"
465 );
466 let create_res2 = http_client
467 .post(format!("{}/xrpc/com.atproto.repo.createRecord", url))
468 .bearer_auth(new_access_token)
469 .json(&json!({
470 "repo": session.did,
471 "collection": collection,
472 "record": {
473 "$type": collection,
474 "text": "Post after refresh with new token",
475 "createdAt": Utc::now().to_rfc3339()
476 }
477 }))
478 .send()
479 .await
480 .unwrap();
481 assert_eq!(
482 create_res2.status(),
483 StatusCode::OK,
484 "New token should work for creating records"
485 );
486 let list_res = http_client
487 .get(format!("{}/xrpc/com.atproto.repo.listRecords", url))
488 .bearer_auth(new_access_token)
489 .query(&[("repo", session.did.as_str()), ("collection", collection)])
490 .send()
491 .await
492 .unwrap();
493 assert_eq!(
494 list_res.status(),
495 StatusCode::OK,
496 "New token should work for listing records"
497 );
498 let list_body: Value = list_res.json().await.unwrap();
499 let records = list_body["records"].as_array().unwrap();
500 assert_eq!(records.len(), 2, "Should have both posts");
501}
502
503#[tokio::test]
504async fn test_oauth_revoked_token_cannot_access_resources() {
505 let url = base_url().await;
506 let http_client = client();
507 let (session, _mock) =
508 create_user_and_oauth_session("oauth-revoke-access", "https://example.com/callback").await;
509 let collection = "app.bsky.feed.post";
510 let create_res = http_client
511 .post(format!("{}/xrpc/com.atproto.repo.createRecord", url))
512 .bearer_auth(&session.access_token)
513 .json(&json!({
514 "repo": session.did,
515 "collection": collection,
516 "record": {
517 "$type": collection,
518 "text": "Post before revocation",
519 "createdAt": Utc::now().to_rfc3339()
520 }
521 }))
522 .send()
523 .await
524 .unwrap();
525 assert_eq!(
526 create_res.status(),
527 StatusCode::OK,
528 "Token should work before revocation"
529 );
530 let revoke_res = http_client
531 .post(format!("{}/oauth/revoke", url))
532 .form(&[("token", session.refresh_token.as_str())])
533 .send()
534 .await
535 .unwrap();
536 assert_eq!(
537 revoke_res.status(),
538 StatusCode::OK,
539 "Revocation should succeed"
540 );
541 let refresh_res = http_client
542 .post(format!("{}/oauth/token", url))
543 .form(&[
544 ("grant_type", "refresh_token"),
545 ("refresh_token", &session.refresh_token),
546 ("client_id", &session.client_id),
547 ])
548 .send()
549 .await
550 .unwrap();
551 assert_eq!(
552 refresh_res.status(),
553 StatusCode::BAD_REQUEST,
554 "Revoked refresh token should not work"
555 );
556}
557
558#[tokio::test]
559async fn test_oauth_multiple_clients_same_user() {
560 let url = base_url().await;
561 let http_client = client();
562 let ts = Utc::now().timestamp_millis();
563 let handle = format!("multi-client-{}", ts);
564 let email = format!("multi-client-{}@example.com", ts);
565 let password = "multi-client-password";
566 let create_res = http_client
567 .post(format!("{}/xrpc/com.atproto.server.createAccount", url))
568 .json(&json!({
569 "handle": handle,
570 "email": email,
571 "password": password
572 }))
573 .send()
574 .await
575 .unwrap();
576 assert_eq!(create_res.status(), StatusCode::OK);
577 let account: Value = create_res.json().await.unwrap();
578 let user_did = account["did"].as_str().unwrap();
579 let _ = verify_new_account(&http_client, user_did).await;
580 let mock_client1 = setup_mock_client_metadata("https://client1.example.com/callback").await;
581 let client1_id = mock_client1.uri();
582 let mock_client2 = setup_mock_client_metadata("https://client2.example.com/callback").await;
583 let client2_id = mock_client2.uri();
584 let (verifier1, challenge1) = generate_pkce();
585 let par_res1 = http_client
586 .post(format!("{}/oauth/par", url))
587 .form(&[
588 ("response_type", "code"),
589 ("client_id", &client1_id),
590 ("redirect_uri", "https://client1.example.com/callback"),
591 ("code_challenge", &challenge1),
592 ("code_challenge_method", "S256"),
593 ])
594 .send()
595 .await
596 .unwrap();
597 let par_body1: Value = par_res1.json().await.unwrap();
598 let request_uri1 = par_body1["request_uri"].as_str().unwrap();
599 let auth_client = no_redirect_client();
600 let auth_res1 = auth_client
601 .post(format!("{}/oauth/authorize", url))
602 .form(&[
603 ("request_uri", request_uri1),
604 ("username", &handle),
605 ("password", password),
606 ("remember_device", "false"),
607 ])
608 .send()
609 .await
610 .unwrap();
611 let location1 = auth_res1
612 .headers()
613 .get("location")
614 .unwrap()
615 .to_str()
616 .unwrap();
617 let code1 = location1
618 .split("code=")
619 .nth(1)
620 .unwrap()
621 .split('&')
622 .next()
623 .unwrap();
624 let token_res1 = http_client
625 .post(format!("{}/oauth/token", url))
626 .form(&[
627 ("grant_type", "authorization_code"),
628 ("code", code1),
629 ("redirect_uri", "https://client1.example.com/callback"),
630 ("code_verifier", &verifier1),
631 ("client_id", &client1_id),
632 ])
633 .send()
634 .await
635 .unwrap();
636 let token_body1: Value = token_res1.json().await.unwrap();
637 let token1 = token_body1["access_token"].as_str().unwrap();
638 let (verifier2, challenge2) = generate_pkce();
639 let par_res2 = http_client
640 .post(format!("{}/oauth/par", url))
641 .form(&[
642 ("response_type", "code"),
643 ("client_id", &client2_id),
644 ("redirect_uri", "https://client2.example.com/callback"),
645 ("code_challenge", &challenge2),
646 ("code_challenge_method", "S256"),
647 ])
648 .send()
649 .await
650 .unwrap();
651 let par_body2: Value = par_res2.json().await.unwrap();
652 let request_uri2 = par_body2["request_uri"].as_str().unwrap();
653 let auth_res2 = auth_client
654 .post(format!("{}/oauth/authorize", url))
655 .form(&[
656 ("request_uri", request_uri2),
657 ("username", &handle),
658 ("password", password),
659 ("remember_device", "false"),
660 ])
661 .send()
662 .await
663 .unwrap();
664 let location2 = auth_res2
665 .headers()
666 .get("location")
667 .unwrap()
668 .to_str()
669 .unwrap();
670 let code2 = location2
671 .split("code=")
672 .nth(1)
673 .unwrap()
674 .split('&')
675 .next()
676 .unwrap();
677 let token_res2 = http_client
678 .post(format!("{}/oauth/token", url))
679 .form(&[
680 ("grant_type", "authorization_code"),
681 ("code", code2),
682 ("redirect_uri", "https://client2.example.com/callback"),
683 ("code_verifier", &verifier2),
684 ("client_id", &client2_id),
685 ])
686 .send()
687 .await
688 .unwrap();
689 let token_body2: Value = token_res2.json().await.unwrap();
690 let token2 = token_body2["access_token"].as_str().unwrap();
691 assert_ne!(
692 token1, token2,
693 "Different clients should get different tokens"
694 );
695 let collection = "app.bsky.feed.post";
696 let create_res1 = http_client
697 .post(format!("{}/xrpc/com.atproto.repo.createRecord", url))
698 .bearer_auth(token1)
699 .json(&json!({
700 "repo": user_did,
701 "collection": collection,
702 "record": {
703 "$type": collection,
704 "text": "Post from client 1",
705 "createdAt": Utc::now().to_rfc3339()
706 }
707 }))
708 .send()
709 .await
710 .unwrap();
711 assert_eq!(
712 create_res1.status(),
713 StatusCode::OK,
714 "Client 1 token should work"
715 );
716 let create_res2 = http_client
717 .post(format!("{}/xrpc/com.atproto.repo.createRecord", url))
718 .bearer_auth(token2)
719 .json(&json!({
720 "repo": user_did,
721 "collection": collection,
722 "record": {
723 "$type": collection,
724 "text": "Post from client 2",
725 "createdAt": Utc::now().to_rfc3339()
726 }
727 }))
728 .send()
729 .await
730 .unwrap();
731 assert_eq!(
732 create_res2.status(),
733 StatusCode::OK,
734 "Client 2 token should work"
735 );
736 let list_res = http_client
737 .get(format!("{}/xrpc/com.atproto.repo.listRecords", url))
738 .bearer_auth(token1)
739 .query(&[("repo", user_did), ("collection", collection)])
740 .send()
741 .await
742 .unwrap();
743 let list_body: Value = list_res.json().await.unwrap();
744 let records = list_body["records"].as_array().unwrap();
745 assert_eq!(
746 records.len(),
747 2,
748 "Both posts should be visible to either client"
749 );
750}
751
752#[tokio::test]
753async fn test_oauth_social_interactions_follow_like_repost() {
754 let url = base_url().await;
755 let http_client = client();
756 let (alice, _mock_alice) =
757 create_user_and_oauth_session("alice-social", "https://alice-app.example.com/callback")
758 .await;
759 let (bob, _mock_bob) =
760 create_user_and_oauth_session("bob-social", "https://bob-app.example.com/callback").await;
761 let post_collection = "app.bsky.feed.post";
762 let post_res = http_client
763 .post(format!("{}/xrpc/com.atproto.repo.createRecord", url))
764 .bearer_auth(&alice.access_token)
765 .json(&json!({
766 "repo": alice.did,
767 "collection": post_collection,
768 "record": {
769 "$type": post_collection,
770 "text": "Hello from Alice! Looking for friends.",
771 "createdAt": Utc::now().to_rfc3339()
772 }
773 }))
774 .send()
775 .await
776 .unwrap();
777 assert_eq!(post_res.status(), StatusCode::OK);
778 let post_body: Value = post_res.json().await.unwrap();
779 let post_uri = post_body["uri"].as_str().unwrap();
780 let post_cid = post_body["cid"].as_str().unwrap();
781 let follow_collection = "app.bsky.graph.follow";
782 let follow_res = http_client
783 .post(format!("{}/xrpc/com.atproto.repo.createRecord", url))
784 .bearer_auth(&bob.access_token)
785 .json(&json!({
786 "repo": bob.did,
787 "collection": follow_collection,
788 "record": {
789 "$type": follow_collection,
790 "subject": alice.did,
791 "createdAt": Utc::now().to_rfc3339()
792 }
793 }))
794 .send()
795 .await
796 .unwrap();
797 assert_eq!(
798 follow_res.status(),
799 StatusCode::OK,
800 "Bob should be able to follow Alice via OAuth"
801 );
802 let like_collection = "app.bsky.feed.like";
803 let like_res = http_client
804 .post(format!("{}/xrpc/com.atproto.repo.createRecord", url))
805 .bearer_auth(&bob.access_token)
806 .json(&json!({
807 "repo": bob.did,
808 "collection": like_collection,
809 "record": {
810 "$type": like_collection,
811 "subject": {
812 "uri": post_uri,
813 "cid": post_cid
814 },
815 "createdAt": Utc::now().to_rfc3339()
816 }
817 }))
818 .send()
819 .await
820 .unwrap();
821 assert_eq!(
822 like_res.status(),
823 StatusCode::OK,
824 "Bob should be able to like Alice's post via OAuth"
825 );
826 let repost_collection = "app.bsky.feed.repost";
827 let repost_res = http_client
828 .post(format!("{}/xrpc/com.atproto.repo.createRecord", url))
829 .bearer_auth(&bob.access_token)
830 .json(&json!({
831 "repo": bob.did,
832 "collection": repost_collection,
833 "record": {
834 "$type": repost_collection,
835 "subject": {
836 "uri": post_uri,
837 "cid": post_cid
838 },
839 "createdAt": Utc::now().to_rfc3339()
840 }
841 }))
842 .send()
843 .await
844 .unwrap();
845 assert_eq!(
846 repost_res.status(),
847 StatusCode::OK,
848 "Bob should be able to repost Alice's post via OAuth"
849 );
850 let bob_follows = http_client
851 .get(format!("{}/xrpc/com.atproto.repo.listRecords", url))
852 .bearer_auth(&bob.access_token)
853 .query(&[
854 ("repo", bob.did.as_str()),
855 ("collection", follow_collection),
856 ])
857 .send()
858 .await
859 .unwrap();
860 let follows_body: Value = bob_follows.json().await.unwrap();
861 let follows = follows_body["records"].as_array().unwrap();
862 assert_eq!(follows.len(), 1, "Bob should have 1 follow");
863 assert_eq!(follows[0]["value"]["subject"], alice.did);
864 let bob_likes = http_client
865 .get(format!("{}/xrpc/com.atproto.repo.listRecords", url))
866 .bearer_auth(&bob.access_token)
867 .query(&[("repo", bob.did.as_str()), ("collection", like_collection)])
868 .send()
869 .await
870 .unwrap();
871 let likes_body: Value = bob_likes.json().await.unwrap();
872 let likes = likes_body["records"].as_array().unwrap();
873 assert_eq!(likes.len(), 1, "Bob should have 1 like");
874}
875
876#[tokio::test]
877async fn test_oauth_cannot_modify_other_users_repo() {
878 let url = base_url().await;
879 let http_client = client();
880 let (alice, _mock_alice) =
881 create_user_and_oauth_session("alice-boundary", "https://alice.example.com/callback").await;
882 let (bob, _mock_bob) =
883 create_user_and_oauth_session("bob-boundary", "https://bob.example.com/callback").await;
884 let collection = "app.bsky.feed.post";
885 let malicious_res = http_client
886 .post(format!("{}/xrpc/com.atproto.repo.createRecord", url))
887 .bearer_auth(&bob.access_token)
888 .json(&json!({
889 "repo": alice.did,
890 "collection": collection,
891 "record": {
892 "$type": collection,
893 "text": "Bob trying to post as Alice!",
894 "createdAt": Utc::now().to_rfc3339()
895 }
896 }))
897 .send()
898 .await
899 .unwrap();
900 assert_ne!(
901 malicious_res.status(),
902 StatusCode::OK,
903 "Bob should NOT be able to create records in Alice's repo"
904 );
905 let alice_posts = http_client
906 .get(format!("{}/xrpc/com.atproto.repo.listRecords", url))
907 .bearer_auth(&alice.access_token)
908 .query(&[("repo", alice.did.as_str()), ("collection", collection)])
909 .send()
910 .await
911 .unwrap();
912 let posts_body: Value = alice_posts.json().await.unwrap();
913 let posts = posts_body["records"].as_array().unwrap();
914 assert_eq!(posts.len(), 0, "Alice's repo should have no posts from Bob");
915}
916
917#[tokio::test]
918async fn test_oauth_session_isolation_between_users() {
919 let url = base_url().await;
920 let http_client = client();
921 let (alice, _mock_alice) =
922 create_user_and_oauth_session("alice-isolation", "https://alice.example.com/callback")
923 .await;
924 let (bob, _mock_bob) =
925 create_user_and_oauth_session("bob-isolation", "https://bob.example.com/callback").await;
926 let collection = "app.bsky.feed.post";
927 let alice_post = http_client
928 .post(format!("{}/xrpc/com.atproto.repo.createRecord", url))
929 .bearer_auth(&alice.access_token)
930 .json(&json!({
931 "repo": alice.did,
932 "collection": collection,
933 "record": {
934 "$type": collection,
935 "text": "Alice's private thoughts",
936 "createdAt": Utc::now().to_rfc3339()
937 }
938 }))
939 .send()
940 .await
941 .unwrap();
942 assert_eq!(alice_post.status(), StatusCode::OK);
943 let bob_post = http_client
944 .post(format!("{}/xrpc/com.atproto.repo.createRecord", url))
945 .bearer_auth(&bob.access_token)
946 .json(&json!({
947 "repo": bob.did,
948 "collection": collection,
949 "record": {
950 "$type": collection,
951 "text": "Bob's different thoughts",
952 "createdAt": Utc::now().to_rfc3339()
953 }
954 }))
955 .send()
956 .await
957 .unwrap();
958 assert_eq!(bob_post.status(), StatusCode::OK);
959 let alice_list = http_client
960 .get(format!("{}/xrpc/com.atproto.repo.listRecords", url))
961 .bearer_auth(&alice.access_token)
962 .query(&[("repo", alice.did.as_str()), ("collection", collection)])
963 .send()
964 .await
965 .unwrap();
966 let alice_records: Value = alice_list.json().await.unwrap();
967 let alice_posts = alice_records["records"].as_array().unwrap();
968 assert_eq!(alice_posts.len(), 1);
969 assert_eq!(alice_posts[0]["value"]["text"], "Alice's private thoughts");
970 let bob_list = http_client
971 .get(format!("{}/xrpc/com.atproto.repo.listRecords", url))
972 .bearer_auth(&bob.access_token)
973 .query(&[("repo", bob.did.as_str()), ("collection", collection)])
974 .send()
975 .await
976 .unwrap();
977 let bob_records: Value = bob_list.json().await.unwrap();
978 let bob_posts = bob_records["records"].as_array().unwrap();
979 assert_eq!(bob_posts.len(), 1);
980 assert_eq!(bob_posts[0]["value"]["text"], "Bob's different thoughts");
981}
982
983#[tokio::test]
984async fn test_oauth_token_works_with_sync_endpoints() {
985 let url = base_url().await;
986 let http_client = client();
987 let (session, _mock) =
988 create_user_and_oauth_session("oauth-sync", "https://example.com/callback").await;
989 let collection = "app.bsky.feed.post";
990 http_client
991 .post(format!("{}/xrpc/com.atproto.repo.createRecord", url))
992 .bearer_auth(&session.access_token)
993 .json(&json!({
994 "repo": session.did,
995 "collection": collection,
996 "record": {
997 "$type": collection,
998 "text": "Post to sync",
999 "createdAt": Utc::now().to_rfc3339()
1000 }
1001 }))
1002 .send()
1003 .await
1004 .unwrap();
1005 let latest_commit = http_client
1006 .get(format!("{}/xrpc/com.atproto.sync.getLatestCommit", url))
1007 .query(&[("did", session.did.as_str())])
1008 .send()
1009 .await
1010 .unwrap();
1011 assert_eq!(latest_commit.status(), StatusCode::OK);
1012 let commit_body: Value = latest_commit.json().await.unwrap();
1013 assert!(commit_body["cid"].is_string());
1014 assert!(commit_body["rev"].is_string());
1015 let repo_status = http_client
1016 .get(format!("{}/xrpc/com.atproto.sync.getRepoStatus", url))
1017 .query(&[("did", session.did.as_str())])
1018 .send()
1019 .await
1020 .unwrap();
1021 assert_eq!(repo_status.status(), StatusCode::OK);
1022 let status_body: Value = repo_status.json().await.unwrap();
1023 assert_eq!(status_body["did"], session.did);
1024 assert!(status_body["active"].as_bool().unwrap());
1025}