Files
edr-platform/apps/edr-passenger-api/src/modules/verifayda/verifayda.service.spec.ts
2026-05-25 17:22:49 +03:00

386 lines
13 KiB
TypeScript

import { ConfigService } from '@nestjs/config';
import { exportJWK, generateKeyPair, type JWK } from 'jose';
import { PrismaService } from '../../common/prisma.service';
import { FaydaConfig } from '../../config/fayda.config';
import { VerifaydaService } from './verifayda.service';
type AnyFn = (...args: any[]) => any;
function mockFn<T extends AnyFn>(impl?: T): jest.Mock {
return impl ? jest.fn(impl) : jest.fn();
}
function buildPrismaMock() {
return {
faydaVerificationSession: {
create: jest.fn(),
findUnique: jest.fn(),
update: jest.fn(),
updateMany: jest.fn(),
},
bookingSeat: {
updateMany: jest.fn(),
},
user: {
findUnique: jest.fn(),
findFirst: jest.fn(),
update: jest.fn(),
},
verifaydaVerification: { create: jest.fn() },
};
}
function buildConfig(overrides?: Partial<FaydaConfig>): FaydaConfig {
return {
enabled: true,
clientId: 'edr-test-client',
authorizationEndpoint: 'https://esignet.test/authorize',
tokenEndpoint: 'https://esignet.test/token',
userInfoEndpoint: 'https://esignet.test/userinfo',
redirectUri: 'https://api.edr.test/api/v1/fayda/verification/callback',
privateJwk: { kty: 'RSA', n: '', e: '', d: '' },
successRedirectUrl: 'https://passenger.edr.test/verify/success',
failureRedirectUrl: 'https://passenger.edr.test/verify/failure',
scope: 'openid profile email',
acrValues: 'mosip:idp:acr:generated-code',
claimsLocales: 'en am',
sessionTtlMinutes: 10,
...overrides,
};
}
function buildConfigService(faydaConfig: FaydaConfig): jest.Mocked<ConfigService> {
return {
get: jest.fn((key: string, defaultValue?: unknown) => {
if (key === 'fayda') return faydaConfig;
if (key === 'VERIFAYDA_ENABLED') return false;
return defaultValue;
}),
} as unknown as jest.Mocked<ConfigService>;
}
describe('VerifaydaService (OIDC)', () => {
let prisma: ReturnType<typeof buildPrismaMock>;
let service: VerifaydaService;
let realPrivateJwk: JWK;
beforeAll(async () => {
const kp = await generateKeyPair('RS256', { extractable: true });
realPrivateJwk = await exportJWK(kp.privateKey);
realPrivateJwk.kty = 'RSA';
});
beforeEach(() => {
prisma = buildPrismaMock();
const cfg = buildConfig({ privateJwk: realPrivateJwk as FaydaConfig['privateJwk'] });
service = new VerifaydaService(
buildConfigService(cfg),
prisma as unknown as PrismaService,
);
(global as any).fetch = jest.fn();
});
afterEach(() => {
jest.restoreAllMocks();
});
describe('startVerification', () => {
it('persists a session and returns a fully-formed authorize URL', async () => {
prisma.faydaVerificationSession.create.mockResolvedValue({});
const url = await service.startVerification({
purpose: 'PURCHASE',
userId: 'user-1',
saveToAccount: true,
});
expect(prisma.faydaVerificationSession.create).toHaveBeenCalledTimes(1);
const created = prisma.faydaVerificationSession.create.mock.calls[0][0].data;
expect(created.purpose).toBe('PURCHASE');
expect(created.saveToAccount).toBe(true);
expect(created.userId).toBe('user-1');
expect(typeof created.state).toBe('string');
expect(typeof created.codeVerifier).toBe('string');
const parsed = new URL(url);
expect(parsed.origin + parsed.pathname).toBe(
'https://esignet.test/authorize',
);
expect(parsed.searchParams.get('client_id')).toBe('edr-test-client');
expect(parsed.searchParams.get('response_type')).toBe('code');
expect(parsed.searchParams.get('code_challenge_method')).toBe('S256');
expect(parsed.searchParams.get('state')).toBe(created.state);
});
it('throws ServiceUnavailable when fayda integration is disabled', async () => {
const disabledCfg = buildConfig({ enabled: false });
const disabledService = new VerifaydaService(
buildConfigService(disabledCfg),
prisma as unknown as PrismaService,
);
await expect(
disabledService.startVerification({ purpose: 'PURCHASE' }),
).rejects.toMatchObject({ status: 503 });
});
});
describe('handleCallback', () => {
function pendingSession(overrides: Partial<any> = {}) {
return {
id: 'session-1',
state: 'state-abc',
codeVerifier: 'verifier-xyz',
purpose: 'PURCHASE',
saveToAccount: false,
status: 'PENDING',
errorCode: null,
errorDescription: null,
userId: null,
bookingId: null,
expiresAt: new Date(Date.now() + 60_000),
...overrides,
};
}
function mockFetchSequence(...responses: Array<Partial<Response>>) {
const queue = responses.map((r) => ({
ok: true,
status: 200,
text: async () => '',
json: async () => ({}),
headers: new Headers({ 'content-type': 'application/json' }),
...r,
}));
(global as any).fetch = jest.fn(() => Promise.resolve(queue.shift()));
}
it('redirects to failure URL when callback carries an error', async () => {
prisma.faydaVerificationSession.findUnique.mockResolvedValue(null);
const url = await service.handleCallback({
error: 'access_denied',
error_description: 'user cancelled',
state: 'state-abc',
});
expect(url).toContain('https://passenger.edr.test/verify/failure');
expect(url).toContain('reason=access_denied');
expect(prisma.faydaVerificationSession.updateMany).toHaveBeenCalled();
});
it('redirects to failure URL when state is unknown', async () => {
prisma.faydaVerificationSession.findUnique.mockResolvedValue(null);
const url = await service.handleCallback({
code: 'authcode',
state: 'bogus',
});
expect(url).toContain('reason=invalid_state');
});
it('redirects to failure URL when session is not pending', async () => {
prisma.faydaVerificationSession.findUnique.mockResolvedValue(
pendingSession({ status: 'COMPLETED' }),
);
const url = await service.handleCallback({
code: 'authcode',
state: 'state-abc',
});
expect(url).toContain('reason=invalid_state');
});
it('redirects to failure URL when session is expired', async () => {
prisma.faydaVerificationSession.findUnique.mockResolvedValue(
pendingSession({ expiresAt: new Date(Date.now() - 1000) }),
);
const url = await service.handleCallback({
code: 'authcode',
state: 'state-abc',
});
expect(url).toContain('reason=session_expired');
});
it('happy path: exchanges code, fetches userinfo, updates booking seat', async () => {
prisma.faydaVerificationSession.findUnique.mockResolvedValue(
pendingSession({ bookingId: 'booking-1' }),
);
prisma.faydaVerificationSession.update.mockResolvedValue({});
prisma.bookingSeat.updateMany.mockResolvedValue({ count: 1 });
mockFetchSequence(
{
json: async () => ({ access_token: 'tok', token_type: 'Bearer' }),
},
{
headers: new Headers({ 'content-type': 'application/json' }),
text: async () =>
JSON.stringify({
sub: 'fayda-sub-1',
name: 'Test User',
phone_number: '+251911000000',
email: 'test@example.com',
}),
},
);
const url = await service.handleCallback({
code: 'authcode',
state: 'state-abc',
});
expect(url).toBe('https://passenger.edr.test/verify/success');
expect(prisma.bookingSeat.updateMany).toHaveBeenCalledWith({
where: { bookingId: 'booking-1' },
data: expect.objectContaining({
faydaSub: 'fayda-sub-1',
faydaVerifiedName: 'Test User',
}),
});
expect(prisma.faydaVerificationSession.update).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
status: 'COMPLETED',
codeVerifier: '',
}),
}),
);
});
it('saves to User when saveToAccount=true and no conflict', async () => {
prisma.faydaVerificationSession.findUnique.mockResolvedValue(
pendingSession({ userId: 'user-1', saveToAccount: true }),
);
prisma.user.findFirst.mockResolvedValue(null);
prisma.user.update.mockResolvedValue({});
prisma.faydaVerificationSession.update.mockResolvedValue({});
mockFetchSequence(
{ json: async () => ({ access_token: 'tok', token_type: 'Bearer' }) },
{
headers: new Headers({ 'content-type': 'application/json' }),
text: async () =>
JSON.stringify({ sub: 'fayda-sub-2', name: 'Test User' }),
},
);
const url = await service.handleCallback({
code: 'authcode',
state: 'state-abc',
});
expect(url).toBe('https://passenger.edr.test/verify/success');
expect(prisma.user.update).toHaveBeenCalledWith({
where: { id: 'user-1' },
data: expect.objectContaining({
faydaVerified: true,
faydaSub: 'fayda-sub-2',
}),
});
});
it('rejects with identity_conflict when faydaSub is on another user', async () => {
prisma.faydaVerificationSession.findUnique.mockResolvedValue(
pendingSession({ userId: 'user-1', saveToAccount: true }),
);
prisma.user.findFirst.mockResolvedValue({ id: 'other-user' });
mockFetchSequence(
{ json: async () => ({ access_token: 'tok', token_type: 'Bearer' }) },
{
headers: new Headers({ 'content-type': 'application/json' }),
text: async () =>
JSON.stringify({ sub: 'fayda-sub-3', name: 'Test User' }),
},
);
const url = await service.handleCallback({
code: 'authcode',
state: 'state-abc',
});
expect(url).toContain('reason=identity_conflict');
expect(prisma.user.update).not.toHaveBeenCalled();
});
it('reports token_exchange_failed when token endpoint returns 4xx', async () => {
prisma.faydaVerificationSession.findUnique.mockResolvedValue(pendingSession());
mockFetchSequence({
ok: false,
status: 400,
text: async () => '{"error":"invalid_assertion"}',
});
const url = await service.handleCallback({
code: 'authcode',
state: 'state-abc',
});
expect(url).toContain('reason=token_exchange_failed');
});
it('reports userinfo_failed when userinfo response is unsupported', async () => {
prisma.faydaVerificationSession.findUnique.mockResolvedValue(pendingSession());
mockFetchSequence(
{ json: async () => ({ access_token: 'tok', token_type: 'Bearer' }) },
{
headers: new Headers({ 'content-type': 'text/plain' }),
text: async () => 'not-a-jwt-not-a-json',
},
);
const url = await service.handleCallback({
code: 'authcode',
state: 'state-abc',
});
expect(url).toContain('reason=userinfo_failed');
});
it('falls back to localized name (name#en) when name is missing', async () => {
prisma.faydaVerificationSession.findUnique.mockResolvedValue(
pendingSession({ bookingId: 'booking-2' }),
);
prisma.faydaVerificationSession.update.mockResolvedValue({});
prisma.bookingSeat.updateMany.mockResolvedValue({ count: 1 });
mockFetchSequence(
{ json: async () => ({ access_token: 'tok', token_type: 'Bearer' }) },
{
headers: new Headers({ 'content-type': 'application/json' }),
text: async () =>
JSON.stringify({
sub: 'fayda-sub-4',
'name#en': 'English Name',
'name#am': 'Amharic Name',
}),
},
);
await service.handleCallback({ code: 'c', state: 'state-abc' });
const seatCall = prisma.bookingSeat.updateMany.mock.calls[0][0];
expect(seatCall.data.faydaVerifiedName).toBe('English Name');
});
});
describe('getVerificationStatus', () => {
it('returns verified=true when User row has the flag', async () => {
prisma.user.findUnique.mockResolvedValue({
faydaVerified: true,
faydaVerifiedAt: new Date('2026-01-01T00:00:00Z'),
fullName: 'Test User',
});
const result = await service.getVerificationStatus('user-1');
expect(result).toEqual({
verified: true,
verifiedAt: new Date('2026-01-01T00:00:00Z'),
fullName: 'Test User',
});
});
it('returns verified=false when User row is missing or unverified', async () => {
prisma.user.findUnique.mockResolvedValue(null);
const result = await service.getVerificationStatus('user-x');
expect(result).toEqual({ verified: false });
});
});
});