import { afterEach, describe, expect, it, vi } from 'vitest' import { blobUrl, bskyProfileUrl, buildAtUri, isBlobRef, normalizeAtUri, parseAtUri, profileAvatarUrl, resolveDid, } from './atproto' afterEach(() => { vi.unstubAllGlobals() }) describe('parseAtUri', () => { it('parses a DID-authority at-uri', () => { expect( parseAtUri('at://did:plc:abc123/site.standard.publication/self'), ).toEqual({ did: 'did:plc:abc123', collection: 'site.standard.publication', rkey: 'self', }) }) it('accepts did:web authorities', () => { expect(parseAtUri('at://did:web:example.com/co.ll/rk')).toEqual({ did: 'did:web:example.com', collection: 'co.ll', rkey: 'rk', }) }) it('tolerates surrounding whitespace', () => { expect(parseAtUri(' at://did:plc:abc/c.o/r \n')).not.toBeNull() }) it('rejects handle authorities (normalizeAtUri handles those)', () => { expect(parseAtUri('at://example.com/site.standard.publication/self')).toBeNull() }) it('rejects malformed uris', () => { expect(parseAtUri('https://example.com/x')).toBeNull() expect(parseAtUri('at://did:plc:abc')).toBeNull() expect(parseAtUri('at://did:plc:abc/collection-only')).toBeNull() expect(parseAtUri('at://did:plc:abc/c.o/r/extra')).toBeNull() expect(parseAtUri('')).toBeNull() }) it('round-trips with buildAtUri', () => { const uri = buildAtUri('did:plc:abc', 'site.standard.document', '3kabc') expect(parseAtUri(uri)).toEqual({ did: 'did:plc:abc', collection: 'site.standard.document', rkey: '3kabc', }) }) }) describe('bskyProfileUrl', () => { it('builds a profile URL from a handle', () => { expect(bskyProfileUrl('alice.example.com')).toBe( 'https://bsky.app/profile/alice.example.com', ) }) it('accepts a DID', () => { expect(bskyProfileUrl('did:plc:abc123')).toBe( 'https://bsky.app/profile/did:plc:abc123', ) }) it('does not double a leading "@"', () => { expect(bskyProfileUrl('@alice.example.com')).toBe( 'https://bsky.app/profile/alice.example.com', ) }) // The handle comes from a DID document's alsoKnownAs, i.e. from whoever // controls the DID. it('escapes path separators, query and fragment out of the handle', () => { expect(bskyProfileUrl('alice.example.com/../settings')).toBe( 'https://bsky.app/profile/alice.example.com%2F..%2Fsettings', ) expect(bskyProfileUrl('alice.example.com?a=b#c')).toBe( 'https://bsky.app/profile/alice.example.com%3Fa%3Db%23c', ) expect(new URL(bskyProfileUrl('x/../../y')).pathname).toBe('/profile/x%2F..%2F..%2Fy') }) }) describe('resolveDid', () => { // resolveDid memoizes per DID for an hour, so every case uses its own DID. const docFor = (serviceEndpoint: string) => ({ alsoKnownAs: ['at://alice.example.com'], service: [ { id: '#atproto_pds', type: 'AtprotoPersonalDataServer', serviceEndpoint }, ], }) const stubDoc = (serviceEndpoint: string) => { // Typed params so callers can read the requested url back off mock.calls. const spy = vi.fn( async (_url: string, _init?: RequestInit) => new Response(JSON.stringify(docFor(serviceEndpoint))), ) vi.stubGlobal('fetch', spy) return spy } it('returns the https endpoint and the unverified handle', async () => { stubDoc('https://pds.example.com/') await expect(resolveDid('did:plc:ok1')).resolves.toEqual({ pds: 'https://pds.example.com', handle: 'alice.example.com', }) }) // A DID document is published by whoever owns the DID, and the extension // holds host permissions for cleartext http, so an http endpoint would // otherwise be fetched — and rendered as an for avatars. it('rejects an http:// service endpoint', async () => { stubDoc('http://pds.example.com') await expect(resolveDid('did:plc:downgrade1')).rejects.toThrow(/non-https/) }) it('rejects non-http schemes and malformed endpoints', async () => { stubDoc('wss://pds.example.com') await expect(resolveDid('did:plc:scheme1')).rejects.toThrow(/non-https/) stubDoc('javascript:alert(1)') await expect(resolveDid('did:plc:scheme2')).rejects.toThrow(/non-https/) stubDoc('data:text/html,x') await expect(resolveDid('did:plc:scheme3')).rejects.toThrow(/non-https/) stubDoc('pds.example.com') await expect(resolveDid('did:plc:scheme4')).rejects.toThrow(/Malformed PDS endpoint/) }) it('keeps only the origin and path of the endpoint', async () => { stubDoc('https://user:pw@pds.example.com/pds/?a=b#c') await expect(resolveDid('did:plc:trim1')).resolves.toMatchObject({ pds: 'https://pds.example.com/pds', }) }) it('caches a resolved DID instead of refetching', async () => { const spy = stubDoc('https://pds.example.com') await resolveDid('did:plc:cache1') await resolveDid('did:plc:cache1') expect(spy).toHaveBeenCalledTimes(1) }) it('fetches did.json from the did:web host, port included', async () => { const spy = stubDoc('https://pds.example.com') await resolveDid('did:web:pds.example.com%3A8443') // The url only: requests now carry an init object (timeout signal, // credentials) that this assertion has no opinion about. expect(spy.mock.calls[0]?.[0]).toBe('https://pds.example.com:8443/.well-known/did.json') }) // decodeURIComponent runs before the host reaches the URL, so %2F and %40 // would otherwise point the fetch at a host the DID does not name. it('rejects did:web hosts that decode into something other than a host', async () => { const spy = vi.fn() vi.stubGlobal('fetch', spy) await expect(resolveDid('did:web:evil.example%2F..%2Fx')).rejects.toThrow(/did:web host/) await expect(resolveDid('did:web:evil.example%40pds.example.com')).rejects.toThrow( /did:web host/, ) await expect(resolveDid('did:web:example.com:user:alice')).rejects.toThrow(/did:web host/) await expect(resolveDid('did:web:pds.example.com%zz')).rejects.toThrow(/Malformed did:web/) expect(spy).not.toHaveBeenCalled() }) it('rejects unsupported DID methods without a fetch', async () => { const spy = vi.fn() vi.stubGlobal('fetch', spy) await expect(resolveDid('did:key:z6Mk')).rejects.toThrow(/Unsupported DID method/) expect(spy).not.toHaveBeenCalled() }) }) describe('normalizeAtUri', () => { it('passes DID-authority uris through without any network call', async () => { const fetchSpy = vi.fn() vi.stubGlobal('fetch', fetchSpy) await expect( normalizeAtUri('at://did:plc:abc/site.standard.publication/self'), ).resolves.toBe('at://did:plc:abc/site.standard.publication/self') expect(fetchSpy).not.toHaveBeenCalled() }) it('resolves handle authorities to DID form', async () => { vi.stubGlobal( 'fetch', vi.fn(async (url: string) => { expect(url).toContain('com.atproto.identity.resolveHandle') expect(url).toContain('handle=norm-a.example.com') return new Response(JSON.stringify({ did: 'did:plc:resolved1' })) }), ) await expect( normalizeAtUri('at://norm-a.example.com/site.standard.publication/self'), ).resolves.toBe('at://did:plc:resolved1/site.standard.publication/self') }) it('returns null when the handle does not resolve', async () => { vi.stubGlobal( 'fetch', vi.fn(async () => new Response('nope', { status: 400, statusText: 'Bad Request' })), ) await expect( normalizeAtUri('at://norm-b.example.com/site.standard.publication/self'), ).resolves.toBeNull() }) it('returns null for malformed uris without any network call', async () => { const fetchSpy = vi.fn() vi.stubGlobal('fetch', fetchSpy) await expect(normalizeAtUri('not-an-at-uri')).resolves.toBeNull() await expect(normalizeAtUri('at://only.authority')).resolves.toBeNull() expect(fetchSpy).not.toHaveBeenCalled() }) }) describe('profileAvatarUrl', () => { it('builds a getBlob url from the profile avatar blob', async () => { vi.stubGlobal( 'fetch', vi.fn(async (url: string) => { expect(url).toContain('com.atproto.repo.getRecord') expect(url).toContain('collection=app.bsky.actor.profile') expect(url).toContain('rkey=self') return new Response( JSON.stringify({ uri: 'at://did:plc:me/app.bsky.actor.profile/self', cid: 'bafyreicid', value: { avatar: { $type: 'blob', ref: { $link: 'bafkreiava' }, mimeType: 'image/jpeg', size: 1 }, }, }), ) }), ) await expect(profileAvatarUrl('https://pds.example.com', 'did:plc:me')).resolves.toBe( 'https://pds.example.com/xrpc/com.atproto.sync.getBlob?did=did%3Aplc%3Ame&cid=bafkreiava', ) }) it('resolves undefined when the profile has no avatar', async () => { vi.stubGlobal( 'fetch', vi.fn(async () => new Response( JSON.stringify({ uri: 'at://x/app.bsky.actor.profile/self', cid: 'c', value: {} }), ), ), ) await expect(profileAvatarUrl('https://pds.example.com', 'did:plc:me')).resolves.toBeUndefined() }) it('resolves undefined instead of throwing when there is no profile record', async () => { vi.stubGlobal( 'fetch', vi.fn(async () => new Response('nope', { status: 400, statusText: 'Bad Request' })), ) await expect(profileAvatarUrl('https://pds.example.com', 'did:plc:me')).resolves.toBeUndefined() }) }) describe('isBlobRef', () => { it('accepts a real blob ref', () => { expect( isBlobRef({ $type: 'blob', ref: { $link: 'bafkreicid1' }, mimeType: 'image/png', size: 1 }), ).toBe(true) }) it('rejects the shapes publishers actually put in blob fields', () => { // Publication records in the wild carry a path string for `icon`. expect(isBlobRef('/assets/images/favicon/apple-touch-icon.png')).toBe(false) expect(isBlobRef(undefined)).toBe(false) expect(isBlobRef(null)).toBe(false) expect(isBlobRef({})).toBe(false) expect(isBlobRef({ ref: {} })).toBe(false) expect(isBlobRef({ ref: { $link: 42 } })).toBe(false) }) }) describe('blobUrl', () => { it('builds an https getBlob url on the pds, escaping the did', () => { expect( blobUrl('https://pds.example.com', 'did:plc:abc123', { $type: 'blob', ref: { $link: 'bafkreicid1' }, mimeType: 'image/png', size: 1234, }), ).toBe( 'https://pds.example.com/xrpc/com.atproto.sync.getBlob?did=did%3Aplc%3Aabc123&cid=bafkreicid1', ) }) })