import { beforeEach, describe, expect, it } from 'vitest'; import { SCOPE_ACCESS } from '../packages/core/src/app-password.js'; import { createAccessJwt } from '../packages/core/src/auth.js'; import { renderConsentPage } from '../packages/core/src/oauth.js'; import { PersonalDataServer } from '../packages/core/src/pds.js'; import { parseScopesForDisplay, ScopePermissions, } from '../packages/core/src/scope.js'; const DID = 'did:plc:testemailscope'; const JWT_SECRET = 'test-secret'; const ACCOUNT_PASSWORD = 'account-password'; const EMAIL = 'alice@example.com'; describe('email scope semantics', () => { describe('ScopePermissions.hasEmailAccess', () => { it('grants on transition:email', () => { expect( new ScopePermissions('atproto transition:email').hasEmailAccess(), ).toBe(true); }); it('grants on granular account:email, bare or parameterized', () => { expect(new ScopePermissions('account:email').hasEmailAccess()).toBe(true); expect( new ScopePermissions('account:email?action=read').hasEmailAccess(), ).toBe(true); }); it('is not implied by full access', () => { // The reference PDS keeps email separate: broad repo access alone does // not expose the address. expect(new ScopePermissions('atproto').hasEmailAccess()).toBe(false); expect(new ScopePermissions('transition:generic').hasEmailAccess()).toBe( false, ); expect(new ScopePermissions(SCOPE_ACCESS).hasEmailAccess()).toBe(false); }); it('is false for an empty scope', () => { expect(new ScopePermissions('').hasEmailAccess()).toBe(false); }); }); describe('parseScopesForDisplay', () => { it('flags hasEmail when the email scope is present', () => { expect(parseScopesForDisplay('atproto transition:email').hasEmail).toBe( true, ); expect(parseScopesForDisplay('account:email').hasEmail).toBe(true); }); it('leaves hasEmail false otherwise', () => { expect(parseScopesForDisplay('atproto transition:generic').hasEmail).toBe( false, ); }); }); describe('consent page', () => { const base = { clientName: 'Test App', clientId: 'https://app.example.com/client-metadata.json', requestUri: 'urn:ietf:params:oauth:request_uri:abc', }; it('tells the user their email will be visible', () => { const html = renderConsentPage({ ...base, scope: 'atproto transition:email', }); expect(html).toContain('email address'); }); it('mentions email even alongside full repository access', () => { const html = renderConsentPage({ ...base, scope: 'atproto transition:generic transition:email', }); expect(html).toContain('email address'); expect(html).toContain('Full repository access'); }); it('says nothing about email when the scope is not requested', () => { const html = renderConsentPage({ ...base, scope: 'atproto' }); expect(html).not.toContain('email address'); }); }); }); /** * @param {import('../packages/core/src/ports.js').EmailState} emailState */ function createPds(emailState) { /** @type {Map} */ const byName = new Map(); return new PersonalDataServer({ actorStorage: /** @type {any} */ ({ async getHandle() { return 'alice.test'; }, async getDid() { return DID; }, async getEmail() { return emailState; }, async setEmail() {}, }), sharedStorage: /** @type {any} */ ({ async putAppPassword( /** @type {string} */ did, /** @type {string} */ name, /** @type {string} */ hash, /** @type {boolean} */ privileged, /** @type {string} */ createdAt, ) { if (byName.has(`${did}:${name}`)) return false; byName.set(`${did}:${name}`, { name, hash, privileged, createdAt }); return true; }, async getAppPasswordByHash( /** @type {string} */ did, /** @type {string} */ hash, ) { for (const [key, v] of byName) { if (key.startsWith(`${did}:`) && v.hash === hash) { return { name: v.name, privileged: v.privileged, createdAt: v.createdAt, }; } } return null; }, }), blobs: /** @type {any} */ ({}), jwtSecret: JWT_SECRET, hostname: 'pds.example.com', password: ACCOUNT_PASSWORD, }); } /** * @param {PersonalDataServer} pds * @param {string} jwt */ async function getSession(pds, jwt) { const response = await pds.fetch( new Request('https://pds.example.com/xrpc/com.atproto.server.getSession', { headers: { authorization: `Bearer ${jwt}` }, }), ); return { status: response.status, body: await response.json() }; } describe('email in session responses', () => { /** @type {PersonalDataServer} */ let pds; beforeEach(() => { pds = createPds({ address: EMAIL, verified: true, pending: null }); }); it('createSession returns the email to the account-password session', async () => { const res = await pds.fetch( new Request( 'https://pds.example.com/xrpc/com.atproto.server.createSession', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ identifier: DID, password: ACCOUNT_PASSWORD }), }, ), ); const body = await res.json(); expect(body.email).toBe(EMAIL); expect(body.emailConfirmed).toBe(true); }); it('getSession exposes email to the account-password session', async () => { const jwt = await createAccessJwt(DID, JWT_SECRET, 7200, SCOPE_ACCESS); const { body } = await getSession(pds, jwt); expect(body.email).toBe(EMAIL); expect(body.emailConfirmed).toBe(true); }); it('reports an unverified address as unconfirmed', async () => { pds = createPds({ address: EMAIL, verified: false, pending: null }); const jwt = await createAccessJwt(DID, JWT_SECRET, 7200, SCOPE_ACCESS); const { body } = await getSession(pds, jwt); expect(body.email).toBe(EMAIL); expect(body.emailConfirmed).toBe(false); }); it('exposes email to an OAuth session holding transition:email', async () => { const jwt = await createAccessJwt( DID, JWT_SECRET, 7200, 'atproto transition:email', ); const { body } = await getSession(pds, jwt); expect(body.email).toBe(EMAIL); }); it('withholds email from an OAuth session without the email scope', async () => { const jwt = await createAccessJwt(DID, JWT_SECRET, 7200, 'atproto'); const { body } = await getSession(pds, jwt); expect(body.email).toBeUndefined(); expect(body.emailConfirmed).toBeUndefined(); // Identity is still returned. expect(body.did).toBe(DID); }); it('never exposes email to an app-password session', async () => { // Register an app password, then log in with it for a restricted session. const accountJwt = await createAccessJwt( DID, JWT_SECRET, 7200, SCOPE_ACCESS, ); const created = await pds.fetch( new Request( 'https://pds.example.com/xrpc/com.atproto.server.createAppPassword', { method: 'POST', headers: { 'content-type': 'application/json', authorization: `Bearer ${accountJwt}`, }, body: JSON.stringify({ name: 'my-client' }), }, ), ); const { password } = await created.json(); const session = await pds.fetch( new Request( 'https://pds.example.com/xrpc/com.atproto.server.createSession', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ identifier: DID, password }), }, ), ); const sessionBody = await session.json(); expect(sessionBody.email).toBeUndefined(); const { body } = await getSession(pds, sessionBody.accessJwt); expect(body.email).toBeUndefined(); expect(body.emailConfirmed).toBeUndefined(); }); it('omits email fields when the account has no address', async () => { pds = createPds({ address: null, verified: false, pending: null }); const jwt = await createAccessJwt(DID, JWT_SECRET, 7200, SCOPE_ACCESS); const { body } = await getSession(pds, jwt); expect(body.email).toBeUndefined(); expect(body.emailConfirmed).toBeUndefined(); }); });