A minimal AT Protocol Personal Data Server written in JavaScript.
at main 2329 lines 81 kB view raw
1/** 2 * E2E tests for PDS - runs against local wrangler dev, Node.js, or Deno server 3 * Uses Vitest and fetch 4 */ 5 6import { spawn } from 'node:child_process'; 7import { randomBytes } from 'node:crypto'; 8import { afterAll, beforeAll, describe, expect, it } from 'vitest'; 9import WebSocket from 'ws'; 10import { DpopClient } from './helpers/dpop.js'; 11import { 12 startNodeServer, 13 stopNodeServer, 14 USE_LOCAL_INFRA, 15} from './helpers/node-server.js'; 16import { startDenoServer, stopDenoServer } from './helpers/deno-server.js'; 17import { getOAuthTokenWithScope, setBaseUrl } from './helpers/oauth.js'; 18import { ensureDockerServices, RELAY_URL } from './helpers/docker-services.js'; 19import { createTestIdentity } from './helpers/identity.js'; 20 21const PLATFORM = process.env.PLATFORM || 'cloudflare'; 22const BASE = 23 PLATFORM === 'node' || PLATFORM === 'deno' 24 ? 'http://localhost:3000' 25 : 'http://localhost:8787'; 26 27// Configure oauth helper to use the same BASE 28setBaseUrl(BASE); 29 30// Generate random base32-lower string (a-z, 2-7) for valid did:plc format 31/** @param {number} length */ 32function randomBase32(length) { 33 const chars = 'abcdefghijklmnopqrstuvwxyz234567'; 34 const bytes = randomBytes(length); 35 return Array.from(bytes) 36 .map((b) => chars[b % 32]) 37 .join(''); 38} 39 40// DID, private key, and handle - set during test setup 41let DID = `did:plc:${randomBase32(24)}`; 42let PRIVATE_KEY_HEX = randomBytes(32).toString('hex'); 43let HANDLE = 'test.local'; 44const PASSWORD = 'test-password'; 45 46/** 47 * Wait for server to be ready 48 */ 49async function waitForServer(maxAttempts = 30) { 50 for (let i = 0; i < maxAttempts; i++) { 51 try { 52 const res = await fetch(`${BASE}/`); 53 if (res.ok) return; 54 } catch { 55 // Server not ready yet 56 } 57 await new Promise((r) => setTimeout(r, 500)); 58 } 59 throw new Error('Server failed to start'); 60} 61 62/** 63 * Make JSON request helper (with retry for flaky wrangler dev 5xx errors) 64 * @param {string} path 65 * @param {any} body 66 * @param {Record<string, string>} [headers] 67 * @returns {Promise<{status: number, data: any}>} 68 */ 69async function jsonPost(path, body, headers = {}) { 70 /** @type {{status: number, data: any}|undefined} */ 71 let result; 72 for (let attempt = 0; attempt < 3; attempt++) { 73 const res = await fetch(`${BASE}${path}`, { 74 method: 'POST', 75 headers: { 'Content-Type': 'application/json', ...headers }, 76 body: JSON.stringify(body), 77 }); 78 // Retry on 5xx errors (wrangler dev flakiness) 79 if (res.status >= 500 && attempt < 2) { 80 await new Promise((r) => setTimeout(r, 100 * (attempt + 1))); 81 continue; 82 } 83 const text = await res.text(); 84 let data = null; 85 try { 86 data = text ? JSON.parse(text) : null; 87 } catch { 88 // Not JSON 89 } 90 result = { status: res.status, data }; 91 break; 92 } 93 if (!result) throw new Error('All retry attempts failed'); 94 return result; 95} 96 97/** 98 * Make form-encoded POST (with retry for flaky wrangler dev 5xx errors) 99 * @param {string} path 100 * @param {Record<string, string>} params 101 * @param {Record<string, string>} [headers] 102 * @returns {Promise<{status: number, data: any}>} 103 */ 104async function formPost(path, params, headers = {}) { 105 /** @type {{status: number, data: any}|undefined} */ 106 let result; 107 for (let attempt = 0; attempt < 3; attempt++) { 108 const res = await fetch(`${BASE}${path}`, { 109 method: 'POST', 110 headers: { 111 'Content-Type': 'application/x-www-form-urlencoded', 112 ...headers, 113 }, 114 body: new URLSearchParams(params).toString(), 115 }); 116 // Retry on 5xx errors (wrangler dev flakiness) 117 if (res.status >= 500 && attempt < 2) { 118 await new Promise((r) => setTimeout(r, 100 * (attempt + 1))); 119 continue; 120 } 121 const text = await res.text(); 122 let data = null; 123 try { 124 data = JSON.parse(text); 125 } catch { 126 data = text; 127 } 128 result = { status: res.status, data }; 129 break; 130 } 131 if (!result) throw new Error('All retry attempts failed'); 132 return result; 133} 134 135describe('E2E Tests', () => { 136 /** @type {import('node:child_process').ChildProcess|null} */ 137 let wrangler = null; 138 /** @type {{close: () => Promise<void>}|null} */ 139 let nodeServer = null; 140 /** @type {{close: () => Promise<void>}|null} */ 141 let denoServer = null; 142 /** @type {string} */ 143 let token = ''; 144 /** @type {string} */ 145 let refreshToken = ''; 146 /** @type {string} */ 147 let testRkey = ''; 148 149 beforeAll(async () => { 150 // Start docker services if enabled (PLC, relay) 151 if (USE_LOCAL_INFRA) { 152 await ensureDockerServices(); 153 } 154 155 if (PLATFORM === 'node') { 156 // Start Node.js server 157 nodeServer = await startNodeServer(); 158 } else if (PLATFORM === 'deno') { 159 // Start Deno server 160 denoServer = await startDenoServer(); 161 } else { 162 // Clear wrangler state for clean slate (like docker reset for node) 163 const { rmSync } = await import('node:fs'); 164 try { 165 rmSync('.wrangler/state', { recursive: true, force: true }); 166 } catch { 167 // Directory may not exist 168 } 169 170 // Start wrangler 171 wrangler = spawn( 172 'npx', 173 [ 174 'wrangler', 175 'dev', 176 '--port', 177 '8787', 178 '--persist-to', 179 '.wrangler/state', 180 ], 181 { 182 stdio: 'pipe', 183 cwd: process.cwd(), 184 }, 185 ); 186 } 187 188 await waitForServer(); 189 190 // Initialize PDS - use proper PLC registration when local infra is available 191 if (USE_LOCAL_INFRA) { 192 // Create identity registered with local PLC 193 const identity = await createTestIdentity({ 194 pdsUrl: 'https://host.docker.internal:3443', 195 handle: 'test', 196 }); 197 DID = identity.did; 198 PRIVATE_KEY_HEX = identity.privateKeyHex; 199 HANDLE = identity.handle; 200 console.log(`Created identity: ${DID} with handle ${HANDLE}`); 201 202 const res = await fetch(`${BASE}/init?did=${DID}`, { 203 method: 'POST', 204 headers: { 'Content-Type': 'application/json' }, 205 body: JSON.stringify({ 206 did: DID, 207 privateKey: PRIVATE_KEY_HEX, 208 handle: HANDLE, 209 }), 210 }); 211 expect(res.ok).toBeTruthy(); 212 } else { 213 // Simple initialization without PLC for non-relay tests 214 HANDLE = 'test.local'; 215 const res = await fetch(`${BASE}/init?did=${DID}`, { 216 method: 'POST', 217 headers: { 'Content-Type': 'application/json' }, 218 body: JSON.stringify({ 219 did: DID, 220 privateKey: PRIVATE_KEY_HEX, 221 handle: HANDLE, 222 }), 223 }); 224 expect(res.ok).toBeTruthy(); 225 } 226 }, 120000); 227 228 afterAll(async () => { 229 if (PLATFORM === 'node') { 230 if (nodeServer) await stopNodeServer(nodeServer); 231 } else if (PLATFORM === 'deno') { 232 if (denoServer) await stopDenoServer(denoServer); 233 } else { 234 if (wrangler) wrangler.kill(); 235 } 236 }, 120000); // Match beforeAll timeout 237 238 describe('Server endpoints', () => { 239 it('root returns ASCII art', async () => { 240 const res = await fetch(`${BASE}/`); 241 const text = await res.text(); 242 expect(text.includes('PDS')).toBeTruthy(); // Root should contain PDS; 243 }); 244 245 it('describeServer returns DID and availableUserDomains', async () => { 246 const res = await fetch(`${BASE}/xrpc/com.atproto.server.describeServer`); 247 const data = await res.json(); 248 expect(data.did).toBeTruthy(); 249 expect(Array.isArray(data.availableUserDomains)).toBeTruthy(); 250 expect(data.availableUserDomains.length).toBeGreaterThan(0); 251 expect(data.availableUserDomains[0].startsWith('.')).toBeTruthy(); 252 expect(data.inviteCodeRequired).toBe(false); 253 expect(data.phoneVerificationRequired).toBe(false); 254 expect(data.links).toBeDefined(); 255 expect(data.contact).toBeDefined(); 256 }); 257 258 it('resolveHandle returns DID', async () => { 259 const res = await fetch( 260 `${BASE}/xrpc/com.atproto.identity.resolveHandle?handle=${HANDLE}`, 261 ); 262 const data = await res.json(); 263 expect(data.did).toBeTruthy(); 264 }); 265 }); 266 267 describe('CORS headers', () => { 268 it('OPTIONS preflight returns CORS headers', async () => { 269 const res = await fetch( 270 `${BASE}/xrpc/com.atproto.server.describeServer`, 271 { 272 method: 'OPTIONS', 273 }, 274 ); 275 expect(res.status).toBe(200); 276 expect(res.headers.get('Access-Control-Allow-Origin')).toBe('*'); 277 expect(res.headers.get('Access-Control-Allow-Methods')).toBe( 278 'GET, POST, OPTIONS', 279 ); 280 expect(res.headers.get('Access-Control-Allow-Headers')).toContain( 281 'Content-Type', 282 ); 283 expect(res.headers.get('Access-Control-Allow-Headers')).toContain( 284 'Authorization', 285 ); 286 expect(res.headers.get('Access-Control-Allow-Headers')).toContain('DPoP'); 287 }); 288 289 it('GET requests include CORS headers', async () => { 290 const res = await fetch(`${BASE}/xrpc/com.atproto.server.describeServer`); 291 expect(res.headers.get('Access-Control-Allow-Origin')).toBe('*'); 292 }); 293 294 it('POST requests include CORS headers', async () => { 295 const res = await fetch(`${BASE}/xrpc/com.atproto.server.createSession`, { 296 method: 'POST', 297 headers: { 'Content-Type': 'application/json' }, 298 body: JSON.stringify({ identifier: 'test', password: 'wrong' }), 299 }); 300 // Even failed requests should have CORS headers 301 expect(res.headers.get('Access-Control-Allow-Origin')).toBe('*'); 302 }); 303 304 it('error responses include CORS headers', async () => { 305 const res = await fetch(`${BASE}/xrpc/nonexistent.endpoint`); 306 expect(res.status).toBe(404); 307 expect(res.headers.get('Access-Control-Allow-Origin')).toBe('*'); 308 }); 309 310 it('.well-known endpoints include CORS headers', async () => { 311 const res = await fetch(`${BASE}/.well-known/atproto-did`); 312 expect(res.headers.get('Access-Control-Allow-Origin')).toBe('*'); 313 }); 314 315 it('OAuth endpoints include CORS headers', async () => { 316 const res = await fetch(`${BASE}/.well-known/oauth-authorization-server`); 317 expect(res.headers.get('Access-Control-Allow-Origin')).toBe('*'); 318 }); 319 }); 320 321 describe('Authentication', () => { 322 it('createSession returns tokens', async () => { 323 const { status, data } = await jsonPost( 324 '/xrpc/com.atproto.server.createSession', 325 { 326 identifier: DID, 327 password: PASSWORD, 328 }, 329 ); 330 expect(status).toBe(200); 331 expect(data.accessJwt).toBeTruthy(); 332 expect(data.refreshJwt).toBeTruthy(); 333 token = data.accessJwt; 334 refreshToken = data.refreshJwt; 335 }); 336 337 it('getSession with valid token', async () => { 338 const res = await fetch(`${BASE}/xrpc/com.atproto.server.getSession`, { 339 headers: { Authorization: `Bearer ${token}` }, 340 }); 341 const data = await res.json(); 342 expect(data.did).toBeTruthy(); 343 }); 344 345 it('refreshSession returns new tokens', async () => { 346 const res = await fetch( 347 `${BASE}/xrpc/com.atproto.server.refreshSession`, 348 { 349 method: 'POST', 350 headers: { Authorization: `Bearer ${refreshToken}` }, 351 }, 352 ); 353 const data = await res.json(); 354 expect(data.accessJwt).toBeTruthy(); 355 expect(data.refreshJwt).toBeTruthy(); 356 token = data.accessJwt; // Use new token 357 }); 358 359 it('refreshSession rejects access token', async () => { 360 const res = await fetch( 361 `${BASE}/xrpc/com.atproto.server.refreshSession`, 362 { 363 method: 'POST', 364 headers: { Authorization: `Bearer ${token}` }, 365 }, 366 ); 367 expect(res.status).toBe(400); 368 }); 369 370 it('refreshSession rejects missing auth', async () => { 371 const res = await fetch( 372 `${BASE}/xrpc/com.atproto.server.refreshSession`, 373 { 374 method: 'POST', 375 }, 376 ); 377 expect(res.status).toBe(401); 378 }); 379 380 it('createRecord rejects without auth', async () => { 381 const { status } = await jsonPost('/xrpc/com.atproto.repo.createRecord', { 382 repo: 'x', 383 collection: 'x', 384 record: {}, 385 }); 386 expect(status).toBe(401); 387 }); 388 389 it('getPreferences works', async () => { 390 const res = await fetch(`${BASE}/xrpc/app.bsky.actor.getPreferences`, { 391 headers: { Authorization: `Bearer ${token}` }, 392 }); 393 const data = await res.json(); 394 expect(data.preferences).toBeTruthy(); 395 }); 396 397 it('putPreferences works', async () => { 398 const { status } = await jsonPost( 399 '/xrpc/app.bsky.actor.putPreferences', 400 { preferences: [{ $type: 'app.bsky.actor.defs#savedFeedsPrefV2' }] }, 401 { Authorization: `Bearer ${token}` }, 402 ); 403 expect(status).toBe(200); 404 }); 405 }); 406 407 describe('Record operations', () => { 408 it('createRecord with auth', async () => { 409 const { status, data } = await jsonPost( 410 '/xrpc/com.atproto.repo.createRecord', 411 { 412 repo: DID, 413 collection: 'app.bsky.feed.post', 414 record: { text: 'test', createdAt: new Date().toISOString() }, 415 }, 416 { Authorization: `Bearer ${token}` }, 417 ); 418 expect(status).toBe(200); 419 expect(data.uri).toBeTruthy(); 420 testRkey = data.uri.split('/').pop(); 421 }); 422 423 it('getRecord returns record', async () => { 424 const res = await fetch( 425 `${BASE}/xrpc/com.atproto.repo.getRecord?repo=${DID}&collection=app.bsky.feed.post&rkey=${testRkey}`, 426 ); 427 const data = await res.json(); 428 expect(data.value?.text).toBeTruthy(); 429 }); 430 431 it('putRecord updates record', async () => { 432 const { status, data } = await jsonPost( 433 '/xrpc/com.atproto.repo.putRecord', 434 { 435 repo: DID, 436 collection: 'app.bsky.feed.post', 437 rkey: testRkey, 438 record: { text: 'updated', createdAt: new Date().toISOString() }, 439 }, 440 { Authorization: `Bearer ${token}` }, 441 ); 442 expect(status).toBe(200); 443 expect(data.uri).toBeTruthy(); 444 }); 445 446 it('listRecords returns records', async () => { 447 const res = await fetch( 448 `${BASE}/xrpc/com.atproto.repo.listRecords?repo=${DID}&collection=app.bsky.feed.post`, 449 ); 450 const data = await res.json(); 451 expect(data.records?.length > 0).toBeTruthy(); 452 }); 453 454 it('describeRepo returns did', async () => { 455 const res = await fetch( 456 `${BASE}/xrpc/com.atproto.repo.describeRepo?repo=${DID}`, 457 ); 458 const data = await res.json(); 459 expect(data.did).toBeTruthy(); 460 }); 461 462 it('applyWrites create returns proper format', async () => { 463 const { status, data } = await jsonPost( 464 '/xrpc/com.atproto.repo.applyWrites', 465 { 466 repo: DID, 467 writes: [ 468 { 469 $type: 'com.atproto.repo.applyWrites#create', 470 collection: 'app.bsky.feed.post', 471 rkey: 'applytest', 472 value: { text: 'batch', createdAt: new Date().toISOString() }, 473 }, 474 ], 475 }, 476 { Authorization: `Bearer ${token}` }, 477 ); 478 expect(status).toBe(200); 479 // Verify commit info 480 expect(data.commit).toBeTruthy(); 481 expect(data.commit.cid).toBeTruthy(); 482 expect(data.commit.rev).toBeTruthy(); 483 // Verify results format 484 expect(data.results).toBeTruthy(); 485 expect(data.results.length).toBe(1); 486 expect(data.results[0].$type).toBe( 487 'com.atproto.repo.applyWrites#createResult', 488 ); 489 expect(data.results[0].uri).toBeTruthy(); 490 expect(data.results[0].cid).toBeTruthy(); 491 // Node has lexiconResolver configured with post schema, others don't 492 expect(data.results[0].validationStatus).toBe( 493 PLATFORM === 'node' ? 'valid' : 'unknown', 494 ); 495 }); 496 497 it('applyWrites returns unknown for unregistered collection', async () => { 498 const { status, data } = await jsonPost( 499 '/xrpc/com.atproto.repo.applyWrites', 500 { 501 repo: DID, 502 writes: [ 503 { 504 $type: 'com.atproto.repo.applyWrites#create', 505 collection: 'com.example.unknown', 506 rkey: 'unknowntest', 507 value: { foo: 'bar' }, 508 }, 509 ], 510 }, 511 { Authorization: `Bearer ${token}` }, 512 ); 513 expect(status).toBe(200); 514 // Unknown collection should always return 'unknown' validationStatus 515 expect(data.results[0].validationStatus).toBe('unknown'); 516 517 // Cleanup 518 await jsonPost( 519 '/xrpc/com.atproto.repo.applyWrites', 520 { 521 repo: DID, 522 writes: [ 523 { 524 $type: 'com.atproto.repo.applyWrites#delete', 525 collection: 'com.example.unknown', 526 rkey: 'unknowntest', 527 }, 528 ], 529 }, 530 { Authorization: `Bearer ${token}` }, 531 ); 532 }); 533 534 it('createRecord validates app.bsky.feed.like via live lexicon resolution', async () => { 535 // This test verifies live lexicon resolution: DNS -> DID -> PDS -> lexicon fetch 536 // Uses app.bsky.feed.like which is NOT in the static schemas - must resolve live 537 const { status, data } = await jsonPost( 538 '/xrpc/com.atproto.repo.createRecord', 539 { 540 repo: DID, 541 collection: 'app.bsky.feed.like', 542 record: { 543 $type: 'app.bsky.feed.like', 544 subject: { 545 uri: 'at://did:plc:test/app.bsky.feed.post/abc123', 546 cid: 'bafyreig2fjxi3a2vwkanxj4jna4mlwabm4zkeakslfoqxxktsq3nqkgzoi', 547 }, 548 createdAt: new Date().toISOString(), 549 }, 550 }, 551 { Authorization: `Bearer ${token}` }, 552 ); 553 expect(status).toBe(200); 554 // Node has full network access for live DNS resolution 555 // Cloudflare/Deno in test env may have limited network, returns 'unknown' 556 expect(data.validationStatus).toBe( 557 PLATFORM === 'node' ? 'valid' : 'unknown', 558 ); 559 560 // Cleanup 561 const rkey = data.uri.split('/').pop(); 562 await jsonPost( 563 '/xrpc/com.atproto.repo.deleteRecord', 564 { repo: DID, collection: 'app.bsky.feed.like', rkey }, 565 { Authorization: `Bearer ${token}` }, 566 ); 567 }); 568 569 it('applyWrites delete returns proper format', async () => { 570 const { status, data } = await jsonPost( 571 '/xrpc/com.atproto.repo.applyWrites', 572 { 573 repo: DID, 574 writes: [ 575 { 576 $type: 'com.atproto.repo.applyWrites#delete', 577 collection: 'app.bsky.feed.post', 578 rkey: 'applytest', 579 }, 580 ], 581 }, 582 { Authorization: `Bearer ${token}` }, 583 ); 584 expect(status).toBe(200); 585 // Verify commit info 586 expect(data.commit).toBeTruthy(); 587 expect(data.commit.cid).toBeTruthy(); 588 expect(data.commit.rev).toBeTruthy(); 589 // Verify results format 590 expect(data.results).toBeTruthy(); 591 expect(data.results.length).toBe(1); 592 expect(data.results[0].$type).toBe( 593 'com.atproto.repo.applyWrites#deleteResult', 594 ); 595 }); 596 597 it('applyWrites rejects unknown operation type', async () => { 598 const { status, data } = await jsonPost( 599 '/xrpc/com.atproto.repo.applyWrites', 600 { 601 repo: DID, 602 writes: [ 603 { 604 $type: 'com.atproto.repo.applyWrites#unknownOp', 605 collection: 'app.bsky.feed.post', 606 rkey: 'test', 607 }, 608 ], 609 }, 610 { Authorization: `Bearer ${token}` }, 611 ); 612 expect(status).toBe(400); 613 expect(data.error).toBe('InvalidRequest'); 614 expect(data.message).toContain('Unknown write operation type'); 615 }); 616 }); 617 618 describe('Sync endpoints', () => { 619 it('getLatestCommit returns cid', async () => { 620 const res = await fetch( 621 `${BASE}/xrpc/com.atproto.sync.getLatestCommit?did=${DID}`, 622 ); 623 const data = await res.json(); 624 expect(data.cid).toBeTruthy(); 625 }); 626 627 it('getRepoStatus returns did', async () => { 628 const res = await fetch( 629 `${BASE}/xrpc/com.atproto.sync.getRepoStatus?did=${DID}`, 630 ); 631 const data = await res.json(); 632 expect(data.did).toBeTruthy(); 633 }); 634 635 it('getRepo returns CAR', async () => { 636 const res = await fetch( 637 `${BASE}/xrpc/com.atproto.sync.getRepo?did=${DID}`, 638 ); 639 const data = await res.arrayBuffer(); 640 expect(data.byteLength > 100).toBeTruthy(); 641 }); 642 643 it('getRecord returns record CAR', async () => { 644 const res = await fetch( 645 `${BASE}/xrpc/com.atproto.sync.getRecord?did=${DID}&collection=app.bsky.feed.post&rkey=${testRkey}`, 646 ); 647 const data = await res.arrayBuffer(); 648 expect(data.byteLength > 50).toBeTruthy(); 649 }); 650 651 it('listRepos returns repos', async () => { 652 const res = await fetch(`${BASE}/xrpc/com.atproto.sync.listRepos`); 653 const data = await res.json(); 654 expect(data.repos?.length > 0).toBeTruthy(); 655 }); 656 }); 657 658 describe('Error handling', () => { 659 it('invalid password rejected (401)', async () => { 660 const { status } = await jsonPost( 661 '/xrpc/com.atproto.server.createSession', 662 { 663 identifier: DID, 664 password: 'wrong-password', 665 }, 666 ); 667 expect(status).toBe(401); 668 }); 669 670 it('wrong repo rejected (403)', async () => { 671 const { status } = await jsonPost( 672 '/xrpc/com.atproto.repo.createRecord', 673 { 674 repo: 'did:plc:z72i7hdynmk6r22z27h6tvur', 675 collection: 'app.bsky.feed.post', 676 record: { text: 'x', createdAt: '2024-01-01T00:00:00Z' }, 677 }, 678 { Authorization: `Bearer ${token}` }, 679 ); 680 expect(status).toBe(403); 681 }); 682 683 it('non-existent record errors', async () => { 684 const res = await fetch( 685 `${BASE}/xrpc/com.atproto.repo.getRecord?repo=${DID}&collection=app.bsky.feed.post&rkey=nonexistent`, 686 ); 687 expect([400, 404].includes(res.status)).toBeTruthy(); 688 }); 689 }); 690 691 describe('Blob endpoints', () => { 692 /** @type {string} */ 693 let blobCid = ''; 694 /** @type {string} */ 695 let blobPostRkey = ''; 696 697 // Create minimal PNG 698 const pngBytes = new Uint8Array([ 699 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 700 0x49, 0x48, 0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 701 0x08, 0x06, 0x00, 0x00, 0x00, 0x1f, 0x15, 0xc4, 0x89, 0x00, 0x00, 0x00, 702 0x0a, 0x49, 0x44, 0x41, 0x54, 0x78, 0x9c, 0x63, 0x00, 0x01, 0x00, 0x00, 703 0x05, 0x00, 0x01, 0x0d, 0x0a, 0x2d, 0xb4, 0x00, 0x00, 0x00, 0x00, 0x49, 704 0x45, 0x4e, 0x44, 0xae, 0x42, 0x60, 0x82, 705 ]); 706 707 it('uploadBlob rejects without auth', async () => { 708 const res = await fetch(`${BASE}/xrpc/com.atproto.repo.uploadBlob`, { 709 method: 'POST', 710 headers: { 'Content-Type': 'image/png' }, 711 body: pngBytes, 712 }); 713 expect(res.status).toBe(401); 714 }); 715 716 it('uploadBlob returns CID', async () => { 717 const res = await fetch(`${BASE}/xrpc/com.atproto.repo.uploadBlob`, { 718 method: 'POST', 719 headers: { 720 'Content-Type': 'image/png', 721 Authorization: `Bearer ${token}`, 722 }, 723 body: pngBytes, 724 }); 725 const data = await res.json(); 726 expect(data.blob?.ref?.$link).toBeTruthy(); 727 expect(data.blob?.mimeType).toBe('image/png'); 728 blobCid = data.blob.ref.$link; 729 }); 730 731 it('listBlobs includes uploaded blob', async () => { 732 const res = await fetch( 733 `${BASE}/xrpc/com.atproto.sync.listBlobs?did=${DID}`, 734 ); 735 const data = await res.json(); 736 expect(data.cids?.includes(blobCid)).toBeTruthy(); 737 }); 738 739 it('getBlob retrieves data', async () => { 740 const res = await fetch( 741 `${BASE}/xrpc/com.atproto.sync.getBlob?did=${DID}&cid=${blobCid}`, 742 ); 743 expect(res.ok).toBeTruthy(); 744 expect(res.headers.get('content-type')).toBe('image/png'); 745 expect(res.headers.get('x-content-type-options')).toBe('nosniff'); 746 }); 747 748 it('getBlob rejects wrong DID', async () => { 749 const res = await fetch( 750 `${BASE}/xrpc/com.atproto.sync.getBlob?did=did:plc:wrongdid&cid=${blobCid}`, 751 ); 752 expect(res.status).toBe(400); 753 }); 754 755 it('getBlob rejects invalid CID', async () => { 756 const res = await fetch( 757 `${BASE}/xrpc/com.atproto.sync.getBlob?did=${DID}&cid=invalid`, 758 ); 759 expect(res.status).toBe(400); 760 }); 761 762 it('getBlob 404 for missing blob', async () => { 763 const res = await fetch( 764 `${BASE}/xrpc/com.atproto.sync.getBlob?did=${DID}&cid=bafkreiaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa`, 765 ); 766 expect(res.status).toBe(404); 767 }); 768 769 it('createRecord with blob ref', async () => { 770 const { status, data } = await jsonPost( 771 '/xrpc/com.atproto.repo.createRecord', 772 { 773 repo: DID, 774 collection: 'app.bsky.feed.post', 775 record: { 776 text: 'post with image', 777 createdAt: new Date().toISOString(), 778 embed: { 779 $type: 'app.bsky.embed.images', 780 images: [ 781 { 782 image: { 783 $type: 'blob', 784 ref: { $link: blobCid }, 785 mimeType: 'image/png', 786 size: pngBytes.length, 787 }, 788 alt: 'test', 789 }, 790 ], 791 }, 792 }, 793 }, 794 { Authorization: `Bearer ${token}` }, 795 ); 796 expect(status).toBe(200); 797 blobPostRkey = data.uri.split('/').pop(); 798 }); 799 800 it('blob persists after record creation', async () => { 801 const res = await fetch( 802 `${BASE}/xrpc/com.atproto.sync.listBlobs?did=${DID}`, 803 ); 804 const data = await res.json(); 805 expect(data.cids?.includes(blobCid)).toBeTruthy(); 806 }); 807 808 it('deleteRecord with blob cleans up', async () => { 809 const { status } = await jsonPost( 810 '/xrpc/com.atproto.repo.deleteRecord', 811 { repo: DID, collection: 'app.bsky.feed.post', rkey: blobPostRkey }, 812 { Authorization: `Bearer ${token}` }, 813 ); 814 expect(status).toBe(200); 815 816 const res = await fetch( 817 `${BASE}/xrpc/com.atproto.sync.listBlobs?did=${DID}`, 818 ); 819 const data = await res.json(); 820 expect(data.cids?.length).toBe(0); 821 }); 822 }); 823 824 describe('OAuth endpoints', () => { 825 it('AS metadata', async () => { 826 const res = await fetch(`${BASE}/.well-known/oauth-authorization-server`); 827 const data = await res.json(); 828 expect(data.issuer).toBe(BASE); 829 expect(data.authorization_endpoint).toBe(`${BASE}/oauth/authorize`); 830 expect(data.token_endpoint).toBe(`${BASE}/oauth/token`); 831 expect(data.pushed_authorization_request_endpoint).toBe( 832 `${BASE}/oauth/par`, 833 ); 834 expect(data.revocation_endpoint).toBe(`${BASE}/oauth/revoke`); 835 expect(data.jwks_uri).toBe(`${BASE}/oauth/jwks`); 836 expect(data.scopes_supported).toEqual(['atproto']); 837 expect(data.dpop_signing_alg_values_supported).toEqual(['ES256']); 838 expect(data.require_pushed_authorization_requests).toBe(false); 839 expect(data.client_id_metadata_document_supported).toBe(true); 840 expect(data.protected_resources).toEqual([BASE]); 841 }); 842 843 it('PR metadata', async () => { 844 const res = await fetch(`${BASE}/.well-known/oauth-protected-resource`); 845 const data = await res.json(); 846 expect(data.resource).toBe(BASE); 847 expect(data.authorization_servers).toEqual([BASE]); 848 }); 849 850 it('JWKS endpoint', async () => { 851 const res = await fetch(`${BASE}/oauth/jwks`); 852 const data = await res.json(); 853 expect(data.keys?.length > 0).toBeTruthy(); 854 const key = data.keys[0]; 855 expect(key.kty).toBe('EC'); 856 expect(key.crv).toBe('P-256'); 857 expect(key.alg).toBe('ES256'); 858 expect(key.use).toBe('sig'); 859 expect(key.x && key.y).toBeTruthy(); 860 expect(!key.d).toBeTruthy(); 861 }); 862 863 it('PAR rejects missing DPoP', async () => { 864 const { status, data } = await formPost('/oauth/par', { 865 client_id: 'http://localhost:3000', 866 redirect_uri: 'http://localhost:3000/callback', 867 response_type: 'code', 868 scope: 'atproto', 869 code_challenge: 'test', 870 code_challenge_method: 'S256', 871 }); 872 expect(status).toBe(400); 873 expect(data.error).toBe('invalid_dpop_proof'); 874 }); 875 876 it('token rejects missing DPoP', async () => { 877 const { status, data } = await formPost('/oauth/token', { 878 grant_type: 'authorization_code', 879 code: 'fake', 880 client_id: 'http://localhost:3000', 881 }); 882 expect(status).toBe(400); 883 expect(data.error).toBe('invalid_dpop_proof'); 884 }); 885 886 it('revoke returns 200 for invalid token', async () => { 887 const { status } = await formPost('/oauth/revoke', { 888 token: 'nonexistent', 889 client_id: 'http://localhost:3000', 890 }); 891 expect(status).toBe(200); 892 }); 893 }); 894 895 describe('OAuth flow with DPoP', () => { 896 it('full PAR -> authorize -> token flow', async () => { 897 const dpop = await DpopClient.create(); 898 const clientId = 'http://localhost:3000'; 899 const redirectUri = 'http://localhost:3000/callback'; 900 const codeVerifier = randomBytes(32).toString('base64url'); 901 902 // Generate code_challenge from verifier (S256) 903 const challengeBuffer = await crypto.subtle.digest( 904 'SHA-256', 905 new TextEncoder().encode(codeVerifier), 906 ); 907 const codeChallenge = Buffer.from(challengeBuffer).toString('base64url'); 908 909 // Step 1: PAR request 910 const parProof = await dpop.createProof('POST', `${BASE}/oauth/par`); 911 const parRes = await fetch(`${BASE}/oauth/par`, { 912 method: 'POST', 913 headers: { 914 'Content-Type': 'application/x-www-form-urlencoded', 915 DPoP: parProof, 916 }, 917 body: new URLSearchParams({ 918 client_id: clientId, 919 redirect_uri: redirectUri, 920 response_type: 'code', 921 scope: 'atproto', 922 code_challenge: codeChallenge, 923 code_challenge_method: 'S256', 924 state: 'test-state', 925 login_hint: DID, 926 }).toString(), 927 }); 928 929 expect(parRes.status).toBe(200); 930 const parData = await parRes.json(); 931 expect(parData.request_uri).toBeTruthy(); 932 expect(parData.expires_in > 0).toBeTruthy(); 933 934 // Step 2: Authorization (simulate user consent by POSTing to authorize) 935 const authRes = await fetch(`${BASE}/oauth/authorize`, { 936 method: 'POST', 937 headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, 938 body: new URLSearchParams({ 939 request_uri: parData.request_uri, 940 client_id: clientId, 941 password: PASSWORD, 942 }).toString(), 943 redirect: 'manual', 944 }); 945 946 expect(authRes.status).toBe(302); 947 const location = authRes.headers.get('location'); 948 expect(location).toBeTruthy(); 949 950 const redirectUrl = new URL(location || ''); 951 const authCode = redirectUrl.searchParams.get('code'); 952 expect(authCode).toBeTruthy(); 953 expect(redirectUrl.searchParams.get('state')).toBe('test-state'); 954 expect(redirectUrl.searchParams.get('iss')).toBe(BASE); 955 956 // Step 3: Token exchange 957 const tokenProof = await dpop.createProof('POST', `${BASE}/oauth/token`); 958 const tokenRes = await fetch(`${BASE}/oauth/token`, { 959 method: 'POST', 960 headers: { 961 'Content-Type': 'application/x-www-form-urlencoded', 962 DPoP: tokenProof, 963 }, 964 body: new URLSearchParams({ 965 grant_type: 'authorization_code', 966 code: authCode || '', 967 client_id: clientId, 968 redirect_uri: redirectUri, 969 code_verifier: codeVerifier, 970 }).toString(), 971 }); 972 973 expect(tokenRes.status).toBe(200); 974 const tokenData = await tokenRes.json(); 975 expect(tokenData.access_token).toBeTruthy(); 976 expect(tokenData.refresh_token).toBeTruthy(); 977 expect(tokenData.token_type).toBe('DPoP'); 978 expect(tokenData.scope).toBe('atproto'); 979 expect(tokenData.sub).toBeTruthy(); 980 981 // Step 4: Use access token with DPoP for protected endpoint 982 const resourceProof = await dpop.createProof( 983 'GET', 984 `${BASE}/xrpc/com.atproto.server.getSession`, 985 tokenData.access_token, 986 ); 987 const sessionRes = await fetch( 988 `${BASE}/xrpc/com.atproto.server.getSession`, 989 { 990 headers: { 991 Authorization: `DPoP ${tokenData.access_token}`, 992 DPoP: resourceProof, 993 }, 994 }, 995 ); 996 997 expect(sessionRes.status).toBe(200); 998 const sessionData = await sessionRes.json(); 999 expect(sessionData.did).toBeTruthy(); 1000 1001 // Step 5: Refresh token 1002 const refreshProof = await dpop.createProof( 1003 'POST', 1004 `${BASE}/oauth/token`, 1005 ); 1006 const refreshRes = await fetch(`${BASE}/oauth/token`, { 1007 method: 'POST', 1008 headers: { 1009 'Content-Type': 'application/x-www-form-urlencoded', 1010 DPoP: refreshProof, 1011 }, 1012 body: new URLSearchParams({ 1013 grant_type: 'refresh_token', 1014 refresh_token: tokenData.refresh_token, 1015 client_id: clientId, 1016 }).toString(), 1017 }); 1018 1019 expect(refreshRes.status).toBe(200); 1020 const refreshData = await refreshRes.json(); 1021 expect(refreshData.access_token).toBeTruthy(); 1022 expect(refreshData.refresh_token).toBeTruthy(); 1023 1024 // Step 6: Revoke token 1025 const revokeRes = await fetch(`${BASE}/oauth/revoke`, { 1026 method: 'POST', 1027 headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, 1028 body: new URLSearchParams({ 1029 token: refreshData.refresh_token, 1030 client_id: clientId, 1031 }).toString(), 1032 }); 1033 expect(revokeRes.status).toBe(200); 1034 }); 1035 1036 it('DPoP key mismatch rejected', async () => { 1037 const dpop1 = await DpopClient.create(); 1038 const dpop2 = await DpopClient.create(); 1039 const clientId = 'http://localhost:3000'; 1040 const redirectUri = 'http://localhost:3000/callback'; 1041 const codeVerifier = randomBytes(32).toString('base64url'); 1042 const challengeBuffer = await crypto.subtle.digest( 1043 'SHA-256', 1044 new TextEncoder().encode(codeVerifier), 1045 ); 1046 const codeChallenge = Buffer.from(challengeBuffer).toString('base64url'); 1047 1048 // PAR with first key 1049 const parProof = await dpop1.createProof('POST', `${BASE}/oauth/par`); 1050 const parRes = await fetch(`${BASE}/oauth/par`, { 1051 method: 'POST', 1052 headers: { 1053 'Content-Type': 'application/x-www-form-urlencoded', 1054 DPoP: parProof, 1055 }, 1056 body: new URLSearchParams({ 1057 client_id: clientId, 1058 redirect_uri: redirectUri, 1059 response_type: 'code', 1060 scope: 'atproto', 1061 code_challenge: codeChallenge, 1062 code_challenge_method: 'S256', 1063 login_hint: DID, 1064 }).toString(), 1065 }); 1066 const parData = await parRes.json(); 1067 1068 // Authorize 1069 const authRes = await fetch(`${BASE}/oauth/authorize`, { 1070 method: 'POST', 1071 headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, 1072 body: new URLSearchParams({ 1073 request_uri: parData.request_uri, 1074 client_id: clientId, 1075 password: PASSWORD, 1076 }).toString(), 1077 redirect: 'manual', 1078 }); 1079 const location = authRes.headers.get('location'); 1080 const authCode = new URL(location || '').searchParams.get('code'); 1081 1082 // Token with DIFFERENT key should fail 1083 const tokenProof = await dpop2.createProof('POST', `${BASE}/oauth/token`); 1084 const tokenRes = await fetch(`${BASE}/oauth/token`, { 1085 method: 'POST', 1086 headers: { 1087 'Content-Type': 'application/x-www-form-urlencoded', 1088 DPoP: tokenProof, 1089 }, 1090 body: new URLSearchParams({ 1091 grant_type: 'authorization_code', 1092 code: authCode || '', 1093 client_id: clientId, 1094 redirect_uri: redirectUri, 1095 code_verifier: codeVerifier, 1096 }).toString(), 1097 }); 1098 1099 expect(tokenRes.status).toBe(400); 1100 const tokenData = await tokenRes.json(); 1101 expect(tokenData.error).toBe('invalid_dpop_proof'); 1102 }); 1103 1104 it('fragment response_mode returns code in fragment', async () => { 1105 const dpop = await DpopClient.create(); 1106 const clientId = 'http://localhost:3000'; 1107 const redirectUri = 'http://localhost:3000/callback'; 1108 const codeVerifier = randomBytes(32).toString('base64url'); 1109 const challengeBuffer = await crypto.subtle.digest( 1110 'SHA-256', 1111 new TextEncoder().encode(codeVerifier), 1112 ); 1113 const codeChallenge = Buffer.from(challengeBuffer).toString('base64url'); 1114 1115 // PAR with response_mode=fragment 1116 const parProof = await dpop.createProof('POST', `${BASE}/oauth/par`); 1117 const parRes = await fetch(`${BASE}/oauth/par`, { 1118 method: 'POST', 1119 headers: { 1120 'Content-Type': 'application/x-www-form-urlencoded', 1121 DPoP: parProof, 1122 }, 1123 body: new URLSearchParams({ 1124 client_id: clientId, 1125 redirect_uri: redirectUri, 1126 response_type: 'code', 1127 response_mode: 'fragment', 1128 scope: 'atproto', 1129 code_challenge: codeChallenge, 1130 code_challenge_method: 'S256', 1131 login_hint: DID, 1132 }).toString(), 1133 }); 1134 const parData = await parRes.json(); 1135 expect(parData.request_uri).toBeTruthy(); 1136 1137 // Authorize 1138 const authRes = await fetch(`${BASE}/oauth/authorize`, { 1139 method: 'POST', 1140 headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, 1141 body: new URLSearchParams({ 1142 request_uri: parData.request_uri, 1143 client_id: clientId, 1144 password: PASSWORD, 1145 }).toString(), 1146 redirect: 'manual', 1147 }); 1148 1149 expect(authRes.status).toBe(302); 1150 const location = authRes.headers.get('location'); 1151 expect(location).toBeTruthy(); 1152 // For fragment mode, code should be in hash fragment 1153 expect(location?.includes('#')).toBeTruthy(); // Should use fragment; 1154 const url = new URL(location || ''); 1155 const fragment = new URLSearchParams(url.hash.slice(1)); 1156 expect(fragment.get('code')).toBeTruthy(); // Code should be in fragment; 1157 expect(fragment.get('iss')).toBeTruthy(); // Issuer should be in fragment; 1158 }); 1159 1160 it('PKCE failure - wrong code_verifier rejected', async () => { 1161 const dpop = await DpopClient.create(); 1162 const clientId = 'http://localhost:3000'; 1163 const redirectUri = 'http://localhost:3000/callback'; 1164 const codeVerifier = randomBytes(32).toString('base64url'); 1165 const wrongVerifier = randomBytes(32).toString('base64url'); 1166 const challengeBuffer = await crypto.subtle.digest( 1167 'SHA-256', 1168 new TextEncoder().encode(codeVerifier), 1169 ); 1170 const codeChallenge = Buffer.from(challengeBuffer).toString('base64url'); 1171 1172 // PAR 1173 const parProof = await dpop.createProof('POST', `${BASE}/oauth/par`); 1174 const parRes = await fetch(`${BASE}/oauth/par`, { 1175 method: 'POST', 1176 headers: { 1177 'Content-Type': 'application/x-www-form-urlencoded', 1178 DPoP: parProof, 1179 }, 1180 body: new URLSearchParams({ 1181 client_id: clientId, 1182 redirect_uri: redirectUri, 1183 response_type: 'code', 1184 scope: 'atproto', 1185 code_challenge: codeChallenge, 1186 code_challenge_method: 'S256', 1187 login_hint: DID, 1188 }).toString(), 1189 }); 1190 const parData = await parRes.json(); 1191 1192 // Authorize 1193 const authRes = await fetch(`${BASE}/oauth/authorize`, { 1194 method: 'POST', 1195 headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, 1196 body: new URLSearchParams({ 1197 request_uri: parData.request_uri, 1198 client_id: clientId, 1199 password: PASSWORD, 1200 }).toString(), 1201 redirect: 'manual', 1202 }); 1203 const location = authRes.headers.get('location'); 1204 const authCode = new URL(location || '').searchParams.get('code'); 1205 1206 // Token with WRONG code_verifier should fail 1207 const tokenProof = await dpop.createProof('POST', `${BASE}/oauth/token`); 1208 const tokenRes = await fetch(`${BASE}/oauth/token`, { 1209 method: 'POST', 1210 headers: { 1211 'Content-Type': 'application/x-www-form-urlencoded', 1212 DPoP: tokenProof, 1213 }, 1214 body: new URLSearchParams({ 1215 grant_type: 'authorization_code', 1216 code: authCode || '', 1217 client_id: clientId, 1218 redirect_uri: redirectUri, 1219 code_verifier: wrongVerifier, 1220 }).toString(), 1221 }); 1222 1223 expect(tokenRes.status).toBe(400); 1224 const tokenData = await tokenRes.json(); 1225 expect(tokenData.error).toBe('invalid_grant'); 1226 expect(tokenData.message?.includes('code_verifier')).toBeTruthy(); 1227 }); 1228 1229 it('redirect_uri mismatch rejected', async () => { 1230 const dpop = await DpopClient.create(); 1231 const clientId = 'http://localhost:3000'; 1232 const codeVerifier = randomBytes(32).toString('base64url'); 1233 const challengeBuffer = await crypto.subtle.digest( 1234 'SHA-256', 1235 new TextEncoder().encode(codeVerifier), 1236 ); 1237 const codeChallenge = Buffer.from(challengeBuffer).toString('base64url'); 1238 1239 // PAR with unregistered redirect_uri 1240 const parProof = await dpop.createProof('POST', `${BASE}/oauth/par`); 1241 const parRes = await fetch(`${BASE}/oauth/par`, { 1242 method: 'POST', 1243 headers: { 1244 'Content-Type': 'application/x-www-form-urlencoded', 1245 DPoP: parProof, 1246 }, 1247 body: new URLSearchParams({ 1248 client_id: clientId, 1249 redirect_uri: 'http://attacker.com/callback', 1250 response_type: 'code', 1251 scope: 'atproto', 1252 code_challenge: codeChallenge, 1253 code_challenge_method: 'S256', 1254 login_hint: DID, 1255 }).toString(), 1256 }); 1257 1258 expect(parRes.status).toBe(400); 1259 const parData = await parRes.json(); 1260 expect(parData.error).toBe('invalid_request'); 1261 expect(parData.message?.includes('redirect_uri')).toBeTruthy(); 1262 }); 1263 1264 it('DPoP jti replay rejected', async () => { 1265 const dpop = await DpopClient.create(); 1266 const clientId = 'http://localhost:3000'; 1267 const redirectUri = 'http://localhost:3000/callback'; 1268 const codeVerifier = randomBytes(32).toString('base64url'); 1269 const challengeBuffer = await crypto.subtle.digest( 1270 'SHA-256', 1271 new TextEncoder().encode(codeVerifier), 1272 ); 1273 const codeChallenge = Buffer.from(challengeBuffer).toString('base64url'); 1274 1275 // Create a single DPoP proof 1276 const parProof = await dpop.createProof('POST', `${BASE}/oauth/par`); 1277 1278 // First request should succeed 1279 const parRes1 = await fetch(`${BASE}/oauth/par`, { 1280 method: 'POST', 1281 headers: { 1282 'Content-Type': 'application/x-www-form-urlencoded', 1283 DPoP: parProof, 1284 }, 1285 body: new URLSearchParams({ 1286 client_id: clientId, 1287 redirect_uri: redirectUri, 1288 response_type: 'code', 1289 scope: 'atproto', 1290 code_challenge: codeChallenge, 1291 code_challenge_method: 'S256', 1292 login_hint: DID, 1293 }).toString(), 1294 }); 1295 expect(parRes1.status).toBe(200); 1296 1297 // Second request with SAME proof should be rejected 1298 const parRes2 = await fetch(`${BASE}/oauth/par`, { 1299 method: 'POST', 1300 headers: { 1301 'Content-Type': 'application/x-www-form-urlencoded', 1302 DPoP: parProof, 1303 }, 1304 body: new URLSearchParams({ 1305 client_id: clientId, 1306 redirect_uri: redirectUri, 1307 response_type: 'code', 1308 scope: 'atproto', 1309 code_challenge: codeChallenge, 1310 code_challenge_method: 'S256', 1311 login_hint: DID, 1312 }).toString(), 1313 }); 1314 1315 expect(parRes2.status).toBe(400); 1316 const data = await parRes2.json(); 1317 expect(data.error).toBe('invalid_dpop_proof'); 1318 expect(data.message?.includes('replay')).toBeTruthy(); 1319 }); 1320 }); 1321 1322 describe('Scope Enforcement', () => { 1323 it('createRecord denied with insufficient scope', async () => { 1324 // Get token that only allows creating likes, not posts 1325 const { accessToken, dpop } = await getOAuthTokenWithScope( 1326 'repo:app.bsky.feed.like?action=create', 1327 DID, 1328 PASSWORD, 1329 ); 1330 1331 const proof = await dpop.createProof( 1332 'POST', 1333 `${BASE}/xrpc/com.atproto.repo.createRecord`, 1334 accessToken, 1335 ); 1336 1337 const res = await fetch(`${BASE}/xrpc/com.atproto.repo.createRecord`, { 1338 method: 'POST', 1339 headers: { 1340 'Content-Type': 'application/json', 1341 Authorization: `DPoP ${accessToken}`, 1342 DPoP: proof, 1343 }, 1344 body: JSON.stringify({ 1345 repo: DID, 1346 collection: 'app.bsky.feed.post', // Not allowed by scope 1347 record: { text: 'test', createdAt: new Date().toISOString() }, 1348 }), 1349 }); 1350 1351 expect(res.status).toBe(403); 1352 const body = await res.json(); 1353 expect(body.message?.includes('Missing required scope')).toBeTruthy(); // Error should mention missing scope 1354 }); 1355 1356 it('createRecord allowed with matching scope', async () => { 1357 // Get token that allows creating posts 1358 const { accessToken, dpop } = await getOAuthTokenWithScope( 1359 'repo:app.bsky.feed.post?action=create', 1360 DID, 1361 PASSWORD, 1362 ); 1363 1364 const proof = await dpop.createProof( 1365 'POST', 1366 `${BASE}/xrpc/com.atproto.repo.createRecord`, 1367 accessToken, 1368 ); 1369 1370 const res = await fetch(`${BASE}/xrpc/com.atproto.repo.createRecord`, { 1371 method: 'POST', 1372 headers: { 1373 'Content-Type': 'application/json', 1374 Authorization: `DPoP ${accessToken}`, 1375 DPoP: proof, 1376 }, 1377 body: JSON.stringify({ 1378 repo: DID, 1379 collection: 'app.bsky.feed.post', 1380 record: { text: 'scope test', createdAt: new Date().toISOString() }, 1381 }), 1382 }); 1383 1384 expect(res.status).toBe(200); 1385 const body = await res.json(); 1386 expect(body.uri).toBeTruthy(); 1387 1388 // Note: We don't clean up here because our token only has create scope 1389 // The record will be cleaned up by subsequent tests with full-access tokens 1390 }); 1391 1392 it('createRecord allowed with wildcard collection scope', async () => { 1393 // Get token that allows creating any record type 1394 const { accessToken, dpop } = await getOAuthTokenWithScope( 1395 'repo:*?action=create', 1396 DID, 1397 PASSWORD, 1398 ); 1399 1400 const proof = await dpop.createProof( 1401 'POST', 1402 `${BASE}/xrpc/com.atproto.repo.createRecord`, 1403 accessToken, 1404 ); 1405 1406 const res = await fetch(`${BASE}/xrpc/com.atproto.repo.createRecord`, { 1407 method: 'POST', 1408 headers: { 1409 'Content-Type': 'application/json', 1410 Authorization: `DPoP ${accessToken}`, 1411 DPoP: proof, 1412 }, 1413 body: JSON.stringify({ 1414 repo: DID, 1415 collection: 'app.bsky.feed.post', 1416 record: { 1417 text: 'wildcard scope test', 1418 createdAt: new Date().toISOString(), 1419 }, 1420 }), 1421 }); 1422 1423 expect(res.status).toBe(200); 1424 }); 1425 1426 it('deleteRecord denied without delete scope', async () => { 1427 // Get token that only has create scope 1428 const { accessToken, dpop } = await getOAuthTokenWithScope( 1429 'repo:app.bsky.feed.post?action=create', 1430 DID, 1431 PASSWORD, 1432 ); 1433 1434 const proof = await dpop.createProof( 1435 'POST', 1436 `${BASE}/xrpc/com.atproto.repo.deleteRecord`, 1437 accessToken, 1438 ); 1439 1440 const res = await fetch(`${BASE}/xrpc/com.atproto.repo.deleteRecord`, { 1441 method: 'POST', 1442 headers: { 1443 'Content-Type': 'application/json', 1444 Authorization: `DPoP ${accessToken}`, 1445 DPoP: proof, 1446 }, 1447 body: JSON.stringify({ 1448 repo: DID, 1449 collection: 'app.bsky.feed.post', 1450 rkey: 'nonexistent', // Doesn't matter, should fail on scope first 1451 }), 1452 }); 1453 1454 expect(res.status).toBe(403); 1455 }); 1456 1457 it('uploadBlob denied with mismatched MIME scope', async () => { 1458 // Get token that only allows image uploads 1459 const { accessToken, dpop } = await getOAuthTokenWithScope( 1460 'blob:image/*', 1461 DID, 1462 PASSWORD, 1463 ); 1464 1465 const proof = await dpop.createProof( 1466 'POST', 1467 `${BASE}/xrpc/com.atproto.repo.uploadBlob`, 1468 accessToken, 1469 ); 1470 1471 // Try to upload a video (not allowed by scope) 1472 const res = await fetch(`${BASE}/xrpc/com.atproto.repo.uploadBlob`, { 1473 method: 'POST', 1474 headers: { 1475 'Content-Type': 'video/mp4', 1476 Authorization: `DPoP ${accessToken}`, 1477 DPoP: proof, 1478 }, 1479 body: new Uint8Array([0x00, 0x00, 0x00, 0x18, 0x66, 0x74, 0x79, 0x70]), // Fake MP4 header 1480 }); 1481 1482 expect(res.status).toBe(403); 1483 const body = await res.json(); 1484 expect(body.message?.includes('Missing required scope')).toBeTruthy(); // Error should mention missing scope 1485 }); 1486 1487 it('uploadBlob allowed with matching MIME scope', async () => { 1488 // Get token that allows image uploads 1489 const { accessToken, dpop } = await getOAuthTokenWithScope( 1490 'blob:image/*', 1491 DID, 1492 PASSWORD, 1493 ); 1494 1495 const proof = await dpop.createProof( 1496 'POST', 1497 `${BASE}/xrpc/com.atproto.repo.uploadBlob`, 1498 accessToken, 1499 ); 1500 1501 // Minimal PNG 1502 const pngBytes = new Uint8Array([ 1503 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 1504 0x49, 0x48, 0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 1505 0x08, 0x06, 0x00, 0x00, 0x00, 0x1f, 0x15, 0xc4, 0x89, 0x00, 0x00, 0x00, 1506 0x0a, 0x49, 0x44, 0x41, 0x54, 0x78, 0x9c, 0x63, 0x00, 0x01, 0x00, 0x00, 1507 0x05, 0x00, 0x01, 0x0d, 0x0a, 0x2d, 0xb4, 0x00, 0x00, 0x00, 0x00, 0x49, 1508 0x45, 0x4e, 0x44, 0xae, 0x42, 0x60, 0x82, 1509 ]); 1510 1511 const res = await fetch(`${BASE}/xrpc/com.atproto.repo.uploadBlob`, { 1512 method: 'POST', 1513 headers: { 1514 'Content-Type': 'image/png', 1515 Authorization: `DPoP ${accessToken}`, 1516 DPoP: proof, 1517 }, 1518 body: pngBytes, 1519 }); 1520 1521 expect(res.status).toBe(200); 1522 }); 1523 1524 it('transition:generic grants full access', async () => { 1525 // Get token with transition:generic scope (full access) 1526 const { accessToken, dpop } = await getOAuthTokenWithScope( 1527 'transition:generic', 1528 DID, 1529 PASSWORD, 1530 ); 1531 1532 const proof = await dpop.createProof( 1533 'POST', 1534 `${BASE}/xrpc/com.atproto.repo.createRecord`, 1535 accessToken, 1536 ); 1537 1538 const res = await fetch(`${BASE}/xrpc/com.atproto.repo.createRecord`, { 1539 method: 'POST', 1540 headers: { 1541 'Content-Type': 'application/json', 1542 Authorization: `DPoP ${accessToken}`, 1543 DPoP: proof, 1544 }, 1545 body: JSON.stringify({ 1546 repo: DID, 1547 collection: 'app.bsky.feed.post', 1548 record: { 1549 text: 'transition scope test', 1550 createdAt: new Date().toISOString(), 1551 }, 1552 }), 1553 }); 1554 1555 expect(res.status).toBe(200); 1556 }); 1557 }); 1558 1559 describe('Consent page display', () => { 1560 it('consent page shows permissions table for granular scopes', async () => { 1561 const dpop = await DpopClient.create(); 1562 const clientId = 'http://localhost:3000'; 1563 const redirectUri = 'http://localhost:3000/callback'; 1564 const codeVerifier = randomBytes(32).toString('base64url'); 1565 1566 const challengeBuffer = await crypto.subtle.digest( 1567 'SHA-256', 1568 new TextEncoder().encode(codeVerifier), 1569 ); 1570 const codeChallenge = Buffer.from(challengeBuffer).toString('base64url'); 1571 1572 // PAR request with granular scopes 1573 const parProof = await dpop.createProof('POST', `${BASE}/oauth/par`); 1574 const parRes = await fetch(`${BASE}/oauth/par`, { 1575 method: 'POST', 1576 headers: { 1577 'Content-Type': 'application/x-www-form-urlencoded', 1578 DPoP: parProof, 1579 }, 1580 body: new URLSearchParams({ 1581 client_id: clientId, 1582 redirect_uri: redirectUri, 1583 response_type: 'code', 1584 scope: 1585 'atproto repo:app.bsky.feed.post?action=create&action=update blob:image/*', 1586 code_challenge: codeChallenge, 1587 code_challenge_method: 'S256', 1588 state: 'test-state', 1589 login_hint: DID, 1590 }).toString(), 1591 }); 1592 1593 expect(parRes.status).toBe(200); 1594 const { request_uri } = await parRes.json(); 1595 1596 // GET the authorize page 1597 const authorizeRes = await fetch( 1598 `${BASE}/oauth/authorize?client_id=${encodeURIComponent(clientId)}&request_uri=${encodeURIComponent(request_uri)}`, 1599 ); 1600 1601 const html = await authorizeRes.text(); 1602 1603 // Verify permissions table is rendered 1604 expect(html.includes('Repository permissions:')).toBeTruthy(); // Should show repo permissions section 1605 expect(html.includes('app.bsky.feed.post')).toBeTruthy(); // Should show collection name 1606 expect(html.includes('Upload permissions:')).toBeTruthy(); // Should show upload permissions section 1607 expect(html.includes('image/*')).toBeTruthy(); // Should show blob MIME type; 1608 }); 1609 1610 it('consent page shows identity message for atproto-only scope', async () => { 1611 const dpop = await DpopClient.create(); 1612 const clientId = 'http://localhost:3000'; 1613 const redirectUri = 'http://localhost:3000/callback'; 1614 const codeVerifier = randomBytes(32).toString('base64url'); 1615 1616 const challengeBuffer = await crypto.subtle.digest( 1617 'SHA-256', 1618 new TextEncoder().encode(codeVerifier), 1619 ); 1620 const codeChallenge = Buffer.from(challengeBuffer).toString('base64url'); 1621 1622 // PAR request with atproto only (identity-only) 1623 const parProof = await dpop.createProof('POST', `${BASE}/oauth/par`); 1624 const parRes = await fetch(`${BASE}/oauth/par`, { 1625 method: 'POST', 1626 headers: { 1627 'Content-Type': 'application/x-www-form-urlencoded', 1628 DPoP: parProof, 1629 }, 1630 body: new URLSearchParams({ 1631 client_id: clientId, 1632 redirect_uri: redirectUri, 1633 response_type: 'code', 1634 scope: 'atproto', 1635 code_challenge: codeChallenge, 1636 code_challenge_method: 'S256', 1637 state: 'test-state', 1638 login_hint: DID, 1639 }).toString(), 1640 }); 1641 1642 expect(parRes.status).toBe(200); 1643 const { request_uri } = await parRes.json(); 1644 1645 // GET the authorize page 1646 const authorizeRes = await fetch( 1647 `${BASE}/oauth/authorize?client_id=${encodeURIComponent(clientId)}&request_uri=${encodeURIComponent(request_uri)}`, 1648 ); 1649 1650 const html = await authorizeRes.text(); 1651 1652 // Verify identity-only message 1653 expect(html.includes('wants to uniquely identify you')).toBeTruthy(); // Should show identity-only message 1654 expect(!html.includes('Repository permissions:')).toBeTruthy(); // Should NOT show permissions table 1655 }); 1656 1657 it('consent page shows warning for transition:generic scope', async () => { 1658 const dpop = await DpopClient.create(); 1659 const clientId = 'http://localhost:3000'; 1660 const redirectUri = 'http://localhost:3000/callback'; 1661 const codeVerifier = randomBytes(32).toString('base64url'); 1662 1663 const challengeBuffer = await crypto.subtle.digest( 1664 'SHA-256', 1665 new TextEncoder().encode(codeVerifier), 1666 ); 1667 const codeChallenge = Buffer.from(challengeBuffer).toString('base64url'); 1668 1669 // PAR request with transition:generic (full access) 1670 const parProof = await dpop.createProof('POST', `${BASE}/oauth/par`); 1671 const parRes = await fetch(`${BASE}/oauth/par`, { 1672 method: 'POST', 1673 headers: { 1674 'Content-Type': 'application/x-www-form-urlencoded', 1675 DPoP: parProof, 1676 }, 1677 body: new URLSearchParams({ 1678 client_id: clientId, 1679 redirect_uri: redirectUri, 1680 response_type: 'code', 1681 scope: 'atproto transition:generic', 1682 code_challenge: codeChallenge, 1683 code_challenge_method: 'S256', 1684 state: 'test-state', 1685 login_hint: DID, 1686 }).toString(), 1687 }); 1688 1689 expect(parRes.status).toBe(200); 1690 const { request_uri } = await parRes.json(); 1691 1692 // GET the authorize page 1693 const authorizeRes = await fetch( 1694 `${BASE}/oauth/authorize?client_id=${encodeURIComponent(clientId)}&request_uri=${encodeURIComponent(request_uri)}`, 1695 ); 1696 1697 const html = await authorizeRes.text(); 1698 1699 // Verify warning banner 1700 expect(html.includes('Full repository access requested')).toBeTruthy(); // Should show full access warning 1701 }); 1702 1703 it('supports direct authorization without PAR', async () => { 1704 const clientId = 'http://localhost:3000'; 1705 const redirectUri = 'http://localhost:3000/callback'; 1706 const codeVerifier = 'test-verifier-for-direct-auth-flow-min-43-chars!!'; 1707 const challengeBuffer = await crypto.subtle.digest( 1708 'SHA-256', 1709 new TextEncoder().encode(codeVerifier), 1710 ); 1711 const codeChallenge = Buffer.from(challengeBuffer).toString('base64url'); 1712 const state = 'test-direct-auth-state'; 1713 1714 // Step 1: GET authorize with direct parameters (no PAR) 1715 const authorizeUrl = new URL(`${BASE}/oauth/authorize`); 1716 authorizeUrl.searchParams.set('client_id', clientId); 1717 authorizeUrl.searchParams.set('redirect_uri', redirectUri); 1718 authorizeUrl.searchParams.set('response_type', 'code'); 1719 authorizeUrl.searchParams.set('scope', 'atproto'); 1720 authorizeUrl.searchParams.set('code_challenge', codeChallenge); 1721 authorizeUrl.searchParams.set('code_challenge_method', 'S256'); 1722 authorizeUrl.searchParams.set('state', state); 1723 authorizeUrl.searchParams.set('login_hint', DID); 1724 1725 const getRes = await fetch(authorizeUrl.toString()); 1726 expect(getRes.status).toBe(200); 1727 1728 const html = await getRes.text(); 1729 expect(html.includes('Authorize')).toBeTruthy(); // Should show consent page; 1730 expect(html.includes('request_uri')).toBeTruthy(); // Should include request_uri in form 1731 }); 1732 1733 it('completes full direct authorization flow', async () => { 1734 const clientId = 'http://localhost:3000'; 1735 const redirectUri = 'http://localhost:3000/callback'; 1736 const codeVerifier = 'test-verifier-for-direct-auth-flow-min-43-chars!!'; 1737 const challengeBuffer = await crypto.subtle.digest( 1738 'SHA-256', 1739 new TextEncoder().encode(codeVerifier), 1740 ); 1741 const codeChallenge = Buffer.from(challengeBuffer).toString('base64url'); 1742 const state = 'test-direct-auth-state'; 1743 1744 // Step 1: GET authorize with direct parameters 1745 const authorizeUrl = new URL(`${BASE}/oauth/authorize`); 1746 authorizeUrl.searchParams.set('client_id', clientId); 1747 authorizeUrl.searchParams.set('redirect_uri', redirectUri); 1748 authorizeUrl.searchParams.set('response_type', 'code'); 1749 authorizeUrl.searchParams.set('scope', 'atproto'); 1750 authorizeUrl.searchParams.set('code_challenge', codeChallenge); 1751 authorizeUrl.searchParams.set('code_challenge_method', 'S256'); 1752 authorizeUrl.searchParams.set('state', state); 1753 authorizeUrl.searchParams.set('login_hint', DID); 1754 1755 const getRes = await fetch(authorizeUrl.toString()); 1756 expect(getRes.status).toBe(200); 1757 const html = await getRes.text(); 1758 1759 // Extract request_uri from the form 1760 const requestUriMatch = html.match(/name="request_uri" value="([^"]+)"/); 1761 expect(requestUriMatch).toBeTruthy(); 1762 const requestUri = requestUriMatch ? requestUriMatch[1] : ''; 1763 1764 // Step 2: POST to authorize (user approval) 1765 const authRes = await fetch(`${BASE}/oauth/authorize`, { 1766 method: 'POST', 1767 headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, 1768 body: new URLSearchParams({ 1769 request_uri: requestUri, 1770 client_id: clientId, 1771 password: PASSWORD, 1772 }).toString(), 1773 redirect: 'manual', 1774 }); 1775 1776 expect(authRes.status).toBe(302); 1777 const location = authRes.headers.get('location'); 1778 expect(location).toBeTruthy(); 1779 const locationUrl = new URL(location || ''); 1780 const code = locationUrl.searchParams.get('code'); 1781 expect(code).toBeTruthy(); 1782 expect(locationUrl.searchParams.get('state')).toBe(state); 1783 1784 // Step 3: Exchange code for tokens 1785 const dpop = await DpopClient.create(); 1786 const dpopProof = await dpop.createProof('POST', `${BASE}/oauth/token`); 1787 1788 const tokenRes = await fetch(`${BASE}/oauth/token`, { 1789 method: 'POST', 1790 headers: { 1791 'Content-Type': 'application/x-www-form-urlencoded', 1792 DPoP: dpopProof, 1793 }, 1794 body: new URLSearchParams({ 1795 grant_type: 'authorization_code', 1796 code: code || '', 1797 redirect_uri: redirectUri, 1798 client_id: clientId, 1799 code_verifier: codeVerifier, 1800 }).toString(), 1801 }); 1802 1803 expect(tokenRes.status).toBe(200); 1804 const tokenData = await tokenRes.json(); 1805 expect(tokenData.access_token).toBeTruthy(); 1806 expect(tokenData.token_type).toBe('DPoP'); 1807 }); 1808 1809 it('consent page shows profile card when login_hint is provided', async () => { 1810 const clientId = 'http://localhost:3000'; 1811 const redirectUri = 'http://localhost:3000/callback'; 1812 const codeVerifier = 'test-verifier-for-profile-card-test-min-43-chars!!'; 1813 const challengeBuffer = await crypto.subtle.digest( 1814 'SHA-256', 1815 new TextEncoder().encode(codeVerifier), 1816 ); 1817 const codeChallenge = Buffer.from(challengeBuffer).toString('base64url'); 1818 1819 const authorizeUrl = new URL(`${BASE}/oauth/authorize`); 1820 authorizeUrl.searchParams.set('client_id', clientId); 1821 authorizeUrl.searchParams.set('redirect_uri', redirectUri); 1822 authorizeUrl.searchParams.set('response_type', 'code'); 1823 authorizeUrl.searchParams.set('scope', 'atproto'); 1824 authorizeUrl.searchParams.set('code_challenge', codeChallenge); 1825 authorizeUrl.searchParams.set('code_challenge_method', 'S256'); 1826 authorizeUrl.searchParams.set('state', 'test-state'); 1827 authorizeUrl.searchParams.set('login_hint', 'test.handle.example'); 1828 1829 const res = await fetch(authorizeUrl.toString()); 1830 const html = await res.text(); 1831 1832 expect(html.includes('profile-card')).toBeTruthy(); // Should include profile card element 1833 expect(html.includes('@test.handle.example')).toBeTruthy(); // Should show handle with @ prefix 1834 expect(html.includes('app.bsky.actor.getProfile')).toBeTruthy(); // Should include profile fetch script 1835 }); 1836 1837 it('consent page does not show profile card when login_hint is omitted', async () => { 1838 const clientId = 'http://localhost:3000'; 1839 const redirectUri = 'http://localhost:3000/callback'; 1840 const codeVerifier = 'test-verifier-for-no-profile-test-min-43-chars!!'; 1841 const challengeBuffer = await crypto.subtle.digest( 1842 'SHA-256', 1843 new TextEncoder().encode(codeVerifier), 1844 ); 1845 const codeChallenge = Buffer.from(challengeBuffer).toString('base64url'); 1846 1847 const authorizeUrl = new URL(`${BASE}/oauth/authorize`); 1848 authorizeUrl.searchParams.set('client_id', clientId); 1849 authorizeUrl.searchParams.set('redirect_uri', redirectUri); 1850 authorizeUrl.searchParams.set('response_type', 'code'); 1851 authorizeUrl.searchParams.set('scope', 'atproto'); 1852 authorizeUrl.searchParams.set('code_challenge', codeChallenge); 1853 authorizeUrl.searchParams.set('code_challenge_method', 'S256'); 1854 authorizeUrl.searchParams.set('state', 'test-state'); 1855 // No login_hint parameter 1856 1857 const res = await fetch(authorizeUrl.toString()); 1858 const html = await res.text(); 1859 1860 // Check for the actual element (id="profile-card"), not the CSS class selector 1861 expect(!html.includes('id="profile-card"')).toBeTruthy(); // Should NOT include profile card element 1862 expect(!html.includes('app.bsky.actor.getProfile')).toBeTruthy(); // Should NOT include profile fetch script 1863 }); 1864 1865 it('consent page escapes dangerous characters in login_hint', async () => { 1866 const clientId = 'http://localhost:3000'; 1867 const redirectUri = 'http://localhost:3000/callback'; 1868 const codeVerifier = 'test-verifier-for-xss-test-minimum-43-chars!!!!!'; 1869 const challengeBuffer = await crypto.subtle.digest( 1870 'SHA-256', 1871 new TextEncoder().encode(codeVerifier), 1872 ); 1873 const codeChallenge = Buffer.from(challengeBuffer).toString('base64url'); 1874 1875 // Attempt XSS via login_hint with double quotes to break out of JSON.stringify 1876 const maliciousHint = 'user");alert("xss'; 1877 1878 const authorizeUrl = new URL(`${BASE}/oauth/authorize`); 1879 authorizeUrl.searchParams.set('client_id', clientId); 1880 authorizeUrl.searchParams.set('redirect_uri', redirectUri); 1881 authorizeUrl.searchParams.set('response_type', 'code'); 1882 authorizeUrl.searchParams.set('scope', 'atproto'); 1883 authorizeUrl.searchParams.set('code_challenge', codeChallenge); 1884 authorizeUrl.searchParams.set('code_challenge_method', 'S256'); 1885 authorizeUrl.searchParams.set('state', 'test-state'); 1886 authorizeUrl.searchParams.set('login_hint', maliciousHint); 1887 1888 const res = await fetch(authorizeUrl.toString()); 1889 const html = await res.text(); 1890 1891 // JSON.stringify escapes double quotes, so the payload should be escaped 1892 // The raw ");alert(" should NOT appear - it should be escaped as \");alert(\" 1893 expect( 1894 !html.includes('").toBeTruthy();alert("'), 1895 'Should escape double quotes to prevent XSS breakout', 1896 ); 1897 // Verify the escaped version is present (backslash before the quote) 1898 expect(html.includes('\\"')).toBeTruthy(); // Should contain escaped characters from JSON.stringify 1899 }); 1900 }); 1901 1902 describe('Foreign DID proxying', () => { 1903 it('proxies to AppView when atproto-proxy header present', async () => { 1904 // Use a known public DID (bsky.app official account) 1905 // We expect 200 (record exists) or 400 (record deleted/not found) from AppView 1906 // A 502 would indicate proxy failure, 404 would indicate local handling 1907 const res = await fetch( 1908 `${BASE}/xrpc/com.atproto.repo.getRecord?repo=did:plc:z72i7hdynmk6r22z27h6tvur&collection=app.bsky.feed.post&rkey=3juzlwllznd24`, 1909 { 1910 headers: { 1911 'atproto-proxy': 'did:web:api.bsky.app#bsky_appview', 1912 }, 1913 }, 1914 ); 1915 // AppView returns 200 (found) or 400 (RecordNotFound), not 404 or 502 1916 expect( 1917 res.status === 200 || res.status === 400, 1918 `Expected 200 or 400 from AppView, got ${res.status}`, 1919 ).toBeTruthy(); 1920 // Verify we got a JSON response (not an error page) 1921 const contentType = res.headers.get('content-type'); 1922 expect(contentType?.includes('application/json')).toBeTruthy(); // Should return JSON 1923 }); 1924 1925 it('handles foreign repo locally without header (returns not found)', async () => { 1926 // Foreign DID without atproto-proxy header is handled locally 1927 // This returns an error since the foreign DID doesn't exist on this PDS 1928 const res = await fetch( 1929 `${BASE}/xrpc/com.atproto.repo.getRecord?repo=did:plc:z72i7hdynmk6r22z27h6tvur&collection=app.bsky.feed.post&rkey=3juzlwllznd24`, 1930 ); 1931 // Local PDS returns 404 for non-existent record/DID 1932 expect(res.status).toBe(404); 1933 }); 1934 1935 it('returns error for unknown proxy service', async () => { 1936 const res = await fetch( 1937 `${BASE}/xrpc/com.atproto.repo.getRecord?repo=did:plc:test&collection=test&rkey=test`, 1938 { 1939 headers: { 1940 'atproto-proxy': 'did:web:unknown.service#unknown', 1941 }, 1942 }, 1943 ); 1944 expect(res.status).toBe(400); 1945 const data = await res.json(); 1946 expect(data.message.includes('Unknown proxy service')).toBeTruthy(); 1947 }); 1948 1949 it('returns error for malformed atproto-proxy header', async () => { 1950 // Header without fragment separator 1951 const res1 = await fetch( 1952 `${BASE}/xrpc/com.atproto.repo.getRecord?repo=did:plc:test&collection=test&rkey=test`, 1953 { 1954 headers: { 1955 'atproto-proxy': 'did:web:api.bsky.app', // missing #serviceId 1956 }, 1957 }, 1958 ); 1959 expect(res1.status).toBe(400); 1960 const data1 = await res1.json(); 1961 expect( 1962 data1.message.includes('Malformed atproto-proxy header'), 1963 ).toBeTruthy(); 1964 1965 // Header with only fragment 1966 const res2 = await fetch( 1967 `${BASE}/xrpc/com.atproto.repo.getRecord?repo=did:plc:test&collection=test&rkey=test`, 1968 { 1969 headers: { 1970 'atproto-proxy': '#bsky_appview', // missing DID 1971 }, 1972 }, 1973 ); 1974 expect(res2.status).toBe(400); 1975 const data2 = await res2.json(); 1976 expect( 1977 data2.message.includes('Malformed atproto-proxy header'), 1978 ).toBeTruthy(); 1979 }); 1980 1981 it('returns local record for local DID without proxy header', async () => { 1982 // Create a record first 1983 const { data: created } = await jsonPost( 1984 '/xrpc/com.atproto.repo.createRecord', 1985 { 1986 repo: DID, 1987 collection: 'app.bsky.feed.post', 1988 record: { 1989 $type: 'app.bsky.feed.post', 1990 text: 'Test post for local DID test', 1991 createdAt: new Date().toISOString(), 1992 }, 1993 }, 1994 { Authorization: `Bearer ${token}` }, 1995 ); 1996 1997 // Fetch without proxy header - should get local record 1998 const rkey = created.uri.split('/').pop(); 1999 const res = await fetch( 2000 `${BASE}/xrpc/com.atproto.repo.getRecord?repo=${DID}&collection=app.bsky.feed.post&rkey=${rkey}`, 2001 ); 2002 expect(res.status).toBe(200); 2003 const data = await res.json(); 2004 expect( 2005 data.value.text.includes('Test post for local DID test'), 2006 ).toBeTruthy(); 2007 2008 // Cleanup - verify success to ensure test isolation 2009 const { status: cleanupStatus } = await jsonPost( 2010 '/xrpc/com.atproto.repo.deleteRecord', 2011 { repo: DID, collection: 'app.bsky.feed.post', rkey }, 2012 { Authorization: `Bearer ${token}` }, 2013 ); 2014 expect(cleanupStatus).toBe(200); 2015 }); 2016 2017 it('describeRepo handles foreign DID locally', async () => { 2018 // Without proxy header, foreign DID is handled locally (returns error) 2019 const res = await fetch( 2020 `${BASE}/xrpc/com.atproto.repo.describeRepo?repo=did:plc:z72i7hdynmk6r22z27h6tvur`, 2021 ); 2022 // Local PDS returns 404 for non-existent DID 2023 expect(res.status).toBe(404); 2024 }); 2025 2026 it('listRecords handles foreign DID locally', async () => { 2027 // Without proxy header, foreign DID is handled locally 2028 // listRecords returns 200 with empty records for non-existent collection 2029 const res = await fetch( 2030 `${BASE}/xrpc/com.atproto.repo.listRecords?repo=did:plc:z72i7hdynmk6r22z27h6tvur&collection=app.bsky.feed.post&limit=1`, 2031 ); 2032 // Local PDS returns 200 with empty records (or 404 for completely unknown DID) 2033 expect( 2034 res.status === 200 || res.status === 404, 2035 `Expected 200 or 404, got ${res.status}`, 2036 ).toBeTruthy(); 2037 }); 2038 }); 2039 2040 describe('subscribeRepos WebSocket', () => { 2041 it('rejects non-WebSocket requests with 426', async () => { 2042 const res = await fetch(`${BASE}/xrpc/com.atproto.sync.subscribeRepos`); 2043 expect(res.status).toBe(426); 2044 }); 2045 2046 it('connects via WebSocket', async () => { 2047 const wsUrl = BASE.replace('http', 'ws'); 2048 const ws = new WebSocket(`${wsUrl}/xrpc/com.atproto.sync.subscribeRepos`); 2049 2050 await /** @type {Promise<void>} */ ( 2051 new Promise((resolve, reject) => { 2052 ws.on('open', () => { 2053 ws.close(); 2054 resolve(); 2055 }); 2056 ws.on('error', reject); 2057 setTimeout(() => reject(new Error('WebSocket timeout')), 5000); 2058 }) 2059 ); 2060 }); 2061 2062 it('receives events after cursor', async () => { 2063 // Get a fresh token for this test 2064 const sessionRes = await jsonPost( 2065 '/xrpc/com.atproto.server.createSession', 2066 { identifier: DID, password: PASSWORD }, 2067 ); 2068 expect(sessionRes.status).toBe(200); 2069 const testToken = sessionRes.data.accessJwt; 2070 2071 // First create a record to generate an event 2072 const { status } = await jsonPost( 2073 '/xrpc/com.atproto.repo.createRecord', 2074 { 2075 repo: DID, 2076 collection: 'app.bsky.feed.post', 2077 record: { text: 'ws test', createdAt: new Date().toISOString() }, 2078 }, 2079 { Authorization: `Bearer ${testToken}` }, 2080 ); 2081 expect(status).toBe(200); 2082 2083 // Get current commit to use as cursor baseline 2084 const commitRes = await fetch( 2085 `${BASE}/xrpc/com.atproto.sync.getLatestCommit?did=${DID}`, 2086 ); 2087 const { rev } = await commitRes.json(); 2088 expect(rev).toBeTruthy(); 2089 2090 // Connect with cursor=0 to get all events 2091 const wsUrl = BASE.replace('http', 'ws'); 2092 const ws = new WebSocket( 2093 `${wsUrl}/xrpc/com.atproto.sync.subscribeRepos?cursor=0`, 2094 ); 2095 2096 /** @type {unknown[]} */ 2097 const events = []; 2098 await /** @type {Promise<void>} */ ( 2099 new Promise((resolve, reject) => { 2100 ws.on('message', (/** @type {unknown} */ data) => { 2101 events.push(data); 2102 // Close after receiving at least one event 2103 if (events.length >= 1) { 2104 ws.close(); 2105 resolve(); 2106 } 2107 }); 2108 ws.on('error', reject); 2109 ws.on('open', () => { 2110 // Give it time to receive events, then close 2111 setTimeout(() => { 2112 ws.close(); 2113 resolve(); 2114 }, 2000); 2115 }); 2116 setTimeout(() => reject(new Error('WebSocket timeout')), 10000); 2117 }) 2118 ); 2119 2120 // Should have received at least one event 2121 expect(events.length).toBeGreaterThan(0); 2122 // Events should be binary (Uint8Array/Buffer) 2123 expect( 2124 Buffer.isBuffer(events[0]) || events[0] instanceof Uint8Array, 2125 ).toBeTruthy(); 2126 }); 2127 2128 it('receives real-time events when record is created after connecting', async () => { 2129 // Get a fresh token for this test 2130 const sessionRes = await jsonPost( 2131 '/xrpc/com.atproto.server.createSession', 2132 { identifier: DID, password: PASSWORD }, 2133 ); 2134 expect(sessionRes.status).toBe(200); 2135 const testToken = sessionRes.data.accessJwt; 2136 2137 // Connect to WebSocket first (no cursor - only real-time events) 2138 const wsUrl = BASE.replace('http', 'ws'); 2139 const ws = new WebSocket(`${wsUrl}/xrpc/com.atproto.sync.subscribeRepos`); 2140 2141 /** @type {unknown[]} */ 2142 const events = []; 2143 let wsReady = false; 2144 2145 const eventPromise = /** @type {Promise<void>} */ ( 2146 new Promise((resolve, reject) => { 2147 ws.on('message', (/** @type {unknown} */ data) => { 2148 events.push(data); 2149 // Got an event, test passes 2150 ws.close(); 2151 resolve(); 2152 }); 2153 ws.on('error', reject); 2154 ws.on('open', () => { 2155 wsReady = true; 2156 }); 2157 // Timeout after waiting for event 2158 setTimeout(() => { 2159 ws.close(); 2160 reject(new Error('No real-time event received within timeout')); 2161 }, 10000); 2162 }) 2163 ); 2164 2165 // Wait for WebSocket to be ready 2166 for (let i = 0; i < 50 && !wsReady; i++) { 2167 await new Promise((r) => setTimeout(r, 100)); 2168 } 2169 expect(wsReady).toBe(true); 2170 2171 // Small delay to ensure subscription is active 2172 await new Promise((r) => setTimeout(r, 200)); 2173 2174 // Create a record AFTER WebSocket is connected 2175 const { status, data } = await jsonPost( 2176 '/xrpc/com.atproto.repo.createRecord', 2177 { 2178 repo: DID, 2179 collection: 'app.bsky.feed.post', 2180 record: { 2181 text: `real-time ws test ${Date.now()}`, 2182 createdAt: new Date().toISOString(), 2183 }, 2184 }, 2185 { Authorization: `Bearer ${testToken}` }, 2186 ); 2187 expect(status).toBe(200); 2188 2189 // Wait for the event to be received 2190 await eventPromise; 2191 2192 // Should have received the real-time event 2193 expect(events.length).toBeGreaterThan(0); 2194 // Event should be binary 2195 expect( 2196 Buffer.isBuffer(events[0]) || events[0] instanceof Uint8Array, 2197 ).toBeTruthy(); 2198 2199 // Cleanup 2200 const rkey = data.uri.split('/').pop(); 2201 await jsonPost( 2202 '/xrpc/com.atproto.repo.deleteRecord', 2203 { repo: DID, collection: 'app.bsky.feed.post', rkey }, 2204 { Authorization: `Bearer ${testToken}` }, 2205 ); 2206 }); 2207 }); 2208 2209 describe('Relay sync (requires docker)', () => { 2210 // Skip entire suite if docker infrastructure is disabled or using cloudflare 2211 // Cloudflare wrangler isn't accessible from Docker through Caddy 2212 const skipRelaySyncTests = 2213 !USE_LOCAL_INFRA || (PLATFORM !== 'node' && PLATFORM !== 'deno'); 2214 2215 beforeAll(() => { 2216 if (skipRelaySyncTests) { 2217 console.log( 2218 'Skipping relay sync tests (requires USE_LOCAL_INFRA=true and PLATFORM=node or deno)', 2219 ); 2220 } 2221 }); 2222 2223 it('relay receives repo after PDS commits', async () => { 2224 if (skipRelaySyncTests) return; 2225 2226 // Create a record (triggers notifyRelay internally) 2227 const { status } = await jsonPost( 2228 '/xrpc/com.atproto.repo.createRecord', 2229 { 2230 repo: DID, 2231 collection: 'app.bsky.feed.post', 2232 record: { 2233 text: 'relay sync test', 2234 createdAt: new Date().toISOString(), 2235 }, 2236 }, 2237 { Authorization: `Bearer ${token}` }, 2238 ); 2239 expect(status).toBe(200); 2240 2241 // Poll relay until it has synced the repo (or timeout) 2242 let synced = false; 2243 for (let i = 0; i < 30; i++) { 2244 try { 2245 const res = await fetch( 2246 `${RELAY_URL}/xrpc/com.atproto.sync.getLatestCommit?did=${DID}`, 2247 ); 2248 if (res.ok) { 2249 const data = await res.json(); 2250 if (data.rev && data.rev.length > 0) { 2251 synced = true; 2252 console.log(`Relay synced after ${i + 1} attempts:`, data); 2253 break; 2254 } 2255 } 2256 } catch { 2257 // Relay not ready or error 2258 } 2259 await new Promise((r) => setTimeout(r, 500)); 2260 } 2261 2262 expect(synced, 'Relay should have synced the repo').toBe(true); 2263 }); 2264 2265 it('relay firehose emits commit events', async () => { 2266 if (skipRelaySyncTests) return; 2267 2268 // Connect to relay firehose 2269 const wsUrl = RELAY_URL.replace('http', 'ws'); 2270 const ws = new WebSocket( 2271 `${wsUrl}/xrpc/com.atproto.sync.subscribeRepos?cursor=0`, 2272 ); 2273 2274 /** @type {unknown[]} */ 2275 const events = []; 2276 await /** @type {Promise<void>} */ ( 2277 new Promise((resolve, reject) => { 2278 ws.on('message', (/** @type {unknown} */ data) => { 2279 events.push(data); 2280 if (events.length >= 1) { 2281 ws.close(); 2282 resolve(); 2283 } 2284 }); 2285 ws.on('error', (/** @type {Error} */ err) => { 2286 console.error('Relay WebSocket error:', err); 2287 reject(err); 2288 }); 2289 ws.on('open', () => { 2290 // Create a record to generate an event 2291 jsonPost( 2292 '/xrpc/com.atproto.repo.createRecord', 2293 { 2294 repo: DID, 2295 collection: 'app.bsky.feed.post', 2296 record: { 2297 text: 'firehose test', 2298 createdAt: new Date().toISOString(), 2299 }, 2300 }, 2301 { Authorization: `Bearer ${token}` }, 2302 ); 2303 2304 // Give time for events, then close 2305 setTimeout(() => { 2306 ws.close(); 2307 resolve(); 2308 }, 5000); 2309 }); 2310 setTimeout(() => reject(new Error('Relay firehose timeout')), 15000); 2311 }) 2312 ); 2313 2314 console.log(`Received ${events.length} events from relay firehose`); 2315 expect(events.length).toBeGreaterThan(0); 2316 }); 2317 }); 2318 2319 describe('Cleanup', () => { 2320 it('deleteRecord (cleanup)', async () => { 2321 const { status } = await jsonPost( 2322 '/xrpc/com.atproto.repo.deleteRecord', 2323 { repo: DID, collection: 'app.bsky.feed.post', rkey: testRkey }, 2324 { Authorization: `Bearer ${token}` }, 2325 ); 2326 expect(status).toBe(200); 2327 }); 2328 }); 2329});