d3ro-voice/apps/mobile-rn/__tests__/identity-linking.test.ts
2026-08-29 18:33:45 +09:00

131 lines
4.4 KiB
TypeScript

import {
IdentityManagementService,
MANAGED_OAUTH_PROVIDERS,
} from '../src/features/auth/identity-linking'
jest.mock('../src/lib/supabase', () => ({ supabase: { auth: {} } }))
jest.mock('../src/lib/auth-redirect', () => ({ AUTH_REDIRECT_URL: 'd3ro-voice://auth-callback' }))
const USER_ID = '11111111-1111-4111-8111-111111111111'
function identity(provider: string, identityId = `${provider}-identity`) {
return {
id: USER_ID,
user_id: USER_ID,
identity_id: identityId,
provider,
}
}
function createAuth(overrides: Record<string, unknown> = {}) {
const getUser = jest.fn(async () => ({
data: { user: { id: USER_ID, app_metadata: { provider: 'email' } } },
error: null,
}))
const getUserIdentities = jest.fn(async () => ({
data: { identities: [identity('email'), identity('google')] },
error: null,
}))
const linkIdentity = jest.fn(async () => ({
data: { provider: 'github', url: 'https://provider.example/authorize' },
error: null,
}))
const unlinkIdentity = jest.fn(async () => ({ data: {}, error: null }))
return {
getUser,
getUserIdentities,
linkIdentity,
unlinkIdentity,
...overrides,
}
}
describe('manual identity linking safety boundary', () => {
it('only exposes the three mobile capability providers', () => {
expect(MANAGED_OAUTH_PROVIDERS).toEqual(['google', 'github', 'apple'])
})
it('uses the canonical PKCE callback and blocks duplicate provider linking', async () => {
const auth = createAuth()
const service = new IdentityManagementService(auth as never)
await expect(service.createLinkUrl('github', USER_ID)).resolves.toEqual({
ok: true,
value: { url: 'https://provider.example/authorize' },
})
expect(auth.linkIdentity).toHaveBeenCalledWith({
provider: 'github',
options: { redirectTo: 'd3ro-voice://auth-callback', skipBrowserRedirect: true },
})
await expect(service.createLinkUrl('google', USER_ID)).resolves.toEqual({
ok: false,
code: 'already_linked',
})
expect(auth.linkIdentity).toHaveBeenCalledTimes(1)
})
it('fails closed if the account changes during the provider URL request', async () => {
const getUser = jest
.fn()
.mockResolvedValueOnce({ data: { user: { id: USER_ID, app_metadata: { provider: 'email' } } }, error: null })
.mockResolvedValueOnce({ data: { user: { id: USER_ID, app_metadata: { provider: 'email' } } }, error: null })
.mockResolvedValueOnce({ data: { user: { id: 'other-account', app_metadata: { provider: 'email' } } }, error: null })
const auth = createAuth({ getUser })
const service = new IdentityManagementService(auth as never)
await expect(service.createLinkUrl('github', USER_ID)).resolves.toEqual({
ok: false,
code: 'account_changed',
})
})
it('never unlinks email, the last identity, or the provider used for the current login', async () => {
const auth = createAuth()
const service = new IdentityManagementService(auth as never)
await expect(service.unlink('email-identity', USER_ID)).resolves.toEqual({
ok: false,
code: 'email_identity_protected',
})
expect(auth.unlinkIdentity).not.toHaveBeenCalled()
auth.getUserIdentities.mockResolvedValueOnce({
data: { identities: [identity('google')] },
error: null,
})
await expect(service.unlink('google-identity', USER_ID)).resolves.toEqual({
ok: false,
code: 'last_identity_protected',
})
auth.getUser.mockImplementation(async () => ({
data: { user: { id: USER_ID, app_metadata: { provider: 'google' } } },
error: null,
}))
auth.getUserIdentities.mockResolvedValueOnce({
data: { identities: [identity('email'), identity('google')] },
error: null,
})
await expect(service.unlink('google-identity', USER_ID)).resolves.toEqual({
ok: false,
code: 'last_login_protected',
})
expect(auth.unlinkIdentity).not.toHaveBeenCalled()
})
it('maps session failures to a sanitized reauthentication requirement', async () => {
const auth = createAuth({
getUser: jest.fn(async () => ({
data: { user: null },
error: Object.assign(new Error('JWT expired with provider internals'), { code: 'jwt_expired' }),
})),
})
const service = new IdentityManagementService(auth as never)
await expect(service.list(USER_ID)).resolves.toEqual({
ok: false,
code: 'reauthentication_required',
})
})
})