131 lines
4.4 KiB
TypeScript
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',
|
|
})
|
|
})
|
|
})
|