import { AtpAgent } from '@atproto/api' import { SeedClient, TestNetworkNoAppView } from '@atproto/dev-env' import { IdResolver } from '@atproto/identity' import { AtIdentifierString, DidString } from '@atproto/syntax' import { AppContext } from '../src' import basicSeed from './seeds/basic' // outside of suite so they can be used in mock let alice: DidString let bob: DidString jest.mock('node:dns/promises', () => { return { resolveTxt: (domain: string) => { if (domain === '_atproto.alice.external') { return [[`did=${alice}`]] } if (domain === '_atproto.bob.external') { return [[`did=${bob}`]] } return [] }, } }) describe('handles', () => { let network: TestNetworkNoAppView let agent: AtpAgent let sc: SeedClient let ctx: AppContext let idResolver: IdResolver const newHandle = 'alice2.test' const tryHandle = async (handle: string) => { await agent.com.atproto.identity.updateHandle( { handle }, { headers: sc.getHeaders(alice), encoding: 'application/json' }, ) } beforeAll(async () => { network = await TestNetworkNoAppView.create({ dbPostgresSchema: 'handles', }) // @ts-expect-error Error due to circular dependency with the dev-env package ctx = network.pds.ctx idResolver = new IdResolver({ plcUrl: ctx.cfg.identity.plcUrl }) agent = network.pds.getAgent() sc = network.getSeedClient() await basicSeed(sc) alice = sc.dids.alice bob = sc.dids.bob }) afterAll(async () => { await network.close() }) const getHandleFromDb = async ( did: AtIdentifierString, ): Promise => { const res = await ctx.accountManager.getAccount(did) return res?.handle ?? undefined } it('resolves handles', async () => { const res = await agent.api.com.atproto.identity.resolveHandle({ handle: 'alice.test', }) expect(res.data.did).toBe(alice) }) it('resolves non-normalize handles', async () => { const res = await agent.api.com.atproto.identity.resolveHandle({ handle: 'aLicE.tEst', }) expect(res.data.did).toBe(alice) }) it('allows a user to change their handle', async () => { await agent.api.com.atproto.identity.updateHandle( { handle: newHandle }, { headers: sc.getHeaders(alice), encoding: 'application/json' }, ) const attemptOld = agent.api.com.atproto.identity.resolveHandle({ handle: 'alice.test', }) await expect(attemptOld).rejects.toThrow('Unable to resolve handle') const attemptNew = await agent.api.com.atproto.identity.resolveHandle({ handle: newHandle, }) expect(attemptNew.data.did).toBe(alice) }) it('updates their did document', async () => { const data = await idResolver.did.resolveAtprotoData(alice) expect(data.handle).toBe(newHandle) }) it('allows a user to login with their new handle', async () => { const res = await agent.api.com.atproto.server.createSession({ identifier: newHandle, password: sc.accounts[alice].password, }) sc.accounts[alice].accessJwt = res.data.accessJwt sc.accounts[alice].refreshJwt = res.data.refreshJwt }) it('does not allow taking a handle that already exists', async () => { const attempt = agent.api.com.atproto.identity.updateHandle( { handle: 'Bob.test' }, { headers: sc.getHeaders(alice), encoding: 'application/json' }, ) await expect(attempt).rejects.toThrow('Handle already taken: bob.test') }) it('handle updates are idempotent', async () => { await agent.api.com.atproto.identity.updateHandle( { handle: 'Bob.test' }, { headers: sc.getHeaders(bob), encoding: 'application/json' }, ) }) it('if handle update fails, it does not update their did document', async () => { const data = await idResolver.did.resolveAtprotoData(alice) expect(data.handle).toBe(newHandle) }) it('disallows handles that do not resolve to a DID', async () => { await expect(tryHandle('john.bsky.io')).rejects.toThrow( 'External handle did not resolve to DID', ) }) it('validates input through lexicon schema', async () => { for (const invalidHandle of [ 'did:john', 'jo_hn.test', 'jo!hn.test', 'jo%hn.test', 'jo&hn.test', 'jo*hn.test', 'jo|hn.test', 'jo:hn.test', 'jo/hn.test', ]) { await expect(tryHandle(invalidHandle)).rejects.toMatchObject({ error: 'InvalidRequest', message: expect.stringContaining('handle'), }) } }) it('applies PDS specific handle length constraints', async () => { await expect(tryHandle('j.test')).rejects.toMatchObject({ error: 'InvalidHandle', message: 'Handle too short', }) await expect( tryHandle('jayromy-johnber12345678910.test'), ).rejects.toMatchObject({ error: 'InvalidHandle', message: 'Handle too long', }) }) it('disallows reserved handles', async () => { await expect(tryHandle('about.test')).rejects.toMatchObject({ error: 'HandleNotAvailable', message: 'Reserved handle', }) await expect(tryHandle('atp.test')).rejects.toMatchObject({ error: 'HandleNotAvailable', message: 'Reserved handle', }) }) it('allows updating to a dns handles', async () => { await agent.api.com.atproto.identity.updateHandle( { handle: 'alice.external', }, { headers: sc.getHeaders(alice), encoding: 'application/json' }, ) const dbHandle = await getHandleFromDb(alice) expect(dbHandle).toBe('alice.external') const data = await idResolver.did.resolveAtprotoData(alice) expect(data.handle).toBe('alice.external') }) it('does not allow updating to an invalid dns handle', async () => { const attempt = agent.api.com.atproto.identity.updateHandle( { handle: 'bob.external', }, { headers: sc.getHeaders(alice), encoding: 'application/json' }, ) await expect(attempt).rejects.toThrow( 'External handle did not resolve to DID', ) const attempt2 = agent.api.com.atproto.identity.updateHandle( { handle: 'noexist.external', }, { headers: sc.getHeaders(alice), encoding: 'application/json' }, ) await expect(attempt2).rejects.toThrow( 'External handle did not resolve to DID', ) const dbHandle = await getHandleFromDb(alice) expect(dbHandle).toBe('alice.external') }) it('allows admin overrules of service domains', async () => { await agent.api.com.atproto.admin.updateAccountHandle( { did: bob, handle: 'bob-alt.test', }, { headers: network.pds.adminAuthHeaders(), encoding: 'application/json', }, ) const dbHandle = await getHandleFromDb(bob) expect(dbHandle).toBe('bob-alt.test') }) it('allows admin override of reserved domains', async () => { await agent.api.com.atproto.admin.updateAccountHandle( { did: bob, handle: 'dril.test', }, { headers: network.pds.adminAuthHeaders(), encoding: 'application/json', }, ) const dbHandle = await getHandleFromDb(bob) expect(dbHandle).toBe('dril.test') }) it('requires admin auth', async () => { const attempt = agent.api.com.atproto.admin.updateAccountHandle( { did: bob, handle: 'bob-alt.test', }, { headers: sc.getHeaders(bob), encoding: 'application/json', }, ) await expect(attempt).rejects.toThrow('Authentication Required') const attempt2 = agent.api.com.atproto.admin.updateAccountHandle({ did: bob, handle: 'bob-alt.test', }) await expect(attempt2).rejects.toThrow('Authentication Required') }) })