mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 08:48:11 +00:00
420 lines
14 KiB
TypeScript
420 lines
14 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';
|
|
|
|
function buildPrismaMock() {
|
|
return {
|
|
faydaVerificationSession: {
|
|
create: jest.fn(),
|
|
findUnique: jest.fn(),
|
|
update: jest.fn(),
|
|
updateMany: jest.fn(),
|
|
},
|
|
bookingSeat: {
|
|
updateMany: jest.fn(),
|
|
},
|
|
passenger: { create: jest.fn() },
|
|
loyaltyAccount: { create: jest.fn() },
|
|
walletAccount: { create: jest.fn() },
|
|
userPreferences: { create: jest.fn() },
|
|
verifaydaVerification: { create: jest.fn() },
|
|
};
|
|
}
|
|
|
|
function buildDataSourceMock() {
|
|
return { query: jest.fn().mockResolvedValue([]) };
|
|
}
|
|
|
|
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: 'http://localhost:4000/fayda/verification/complete',
|
|
webRedirectUri: 'http://localhost:5174/fayda/verification/complete',
|
|
privateJwk: { kty: 'RSA', n: '', e: '', d: '' },
|
|
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, client-callback)', () => {
|
|
let prisma: ReturnType<typeof buildPrismaMock>;
|
|
let dataSource: ReturnType<typeof buildDataSourceMock>;
|
|
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();
|
|
dataSource = buildDataSourceMock();
|
|
const cfg = buildConfig({ privateJwk: realPrivateJwk as FaydaConfig['privateJwk'] });
|
|
service = new VerifaydaService(
|
|
buildConfigService(cfg),
|
|
prisma as unknown as PrismaService,
|
|
dataSource as any,
|
|
);
|
|
(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: 'VERIFY',
|
|
userId: 'user-1',
|
|
});
|
|
|
|
const created = prisma.faydaVerificationSession.create.mock.calls[0][0].data;
|
|
expect(created.purpose).toBe('VERIFY');
|
|
expect(created.platform).toBe('WEB');
|
|
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('code_challenge_method')).toBe('S256');
|
|
// Default platform is WEB → webRedirectUri.
|
|
expect(parsed.searchParams.get('redirect_uri')).toBe(
|
|
'http://localhost:5174/fayda/verification/complete',
|
|
);
|
|
expect(parsed.searchParams.get('state')).toBe(created.state);
|
|
});
|
|
|
|
it('sends the MOBILE redirect_uri (base redirectUri) for MOBILE sessions', async () => {
|
|
prisma.faydaVerificationSession.create.mockResolvedValue({});
|
|
|
|
const url = await service.startVerification({
|
|
purpose: 'LOGIN',
|
|
platform: 'MOBILE',
|
|
});
|
|
|
|
const created = prisma.faydaVerificationSession.create.mock.calls[0][0].data;
|
|
expect(created.platform).toBe('MOBILE');
|
|
expect(new URL(url).searchParams.get('redirect_uri')).toBe(
|
|
'http://localhost:4000/fayda/verification/complete',
|
|
);
|
|
});
|
|
|
|
it('sends the WEB redirect_uri (webRedirectUri) for WEB sessions', async () => {
|
|
prisma.faydaVerificationSession.create.mockResolvedValue({});
|
|
|
|
const url = await service.startVerification({
|
|
purpose: 'VERIFY',
|
|
platform: 'WEB',
|
|
});
|
|
|
|
expect(new URL(url).searchParams.get('redirect_uri')).toBe(
|
|
'http://localhost:5174/fayda/verification/complete',
|
|
);
|
|
});
|
|
|
|
it('throws ServiceUnavailable when fayda integration is disabled', async () => {
|
|
const disabledService = new VerifaydaService(
|
|
buildConfigService(buildConfig({ enabled: false })),
|
|
prisma as unknown as PrismaService,
|
|
buildDataSourceMock() as any,
|
|
);
|
|
await expect(
|
|
disabledService.startVerification({ purpose: 'VERIFY' }),
|
|
).rejects.toMatchObject({ status: 503 });
|
|
});
|
|
});
|
|
|
|
describe('completeVerification — validation', () => {
|
|
function pendingSession(overrides: Partial<any> = {}) {
|
|
return {
|
|
id: 'session-1',
|
|
state: 'state-abc',
|
|
codeVerifier: 'verifier-xyz',
|
|
purpose: 'VERIFY',
|
|
platform: 'WEB',
|
|
status: 'PENDING',
|
|
errorCode: null,
|
|
errorDescription: null,
|
|
iamUserId: null,
|
|
expiresAt: new Date(Date.now() + 60_000),
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
it('throws and marks failed when callback carries an error', async () => {
|
|
prisma.faydaVerificationSession.updateMany.mockResolvedValue({ count: 1 });
|
|
await expect(
|
|
service.completeVerification({
|
|
error: 'access_denied',
|
|
error_description: 'user cancelled',
|
|
state: 'state-abc',
|
|
}),
|
|
).rejects.toMatchObject({ status: 400 });
|
|
expect(prisma.faydaVerificationSession.updateMany).toHaveBeenCalled();
|
|
});
|
|
|
|
it('throws FAYDA_MISSING_PARAMETERS when code/state absent', async () => {
|
|
await expect(service.completeVerification({})).rejects.toMatchObject({
|
|
status: 400,
|
|
});
|
|
});
|
|
|
|
it('throws FAYDA_INVALID_STATE for unknown state', async () => {
|
|
prisma.faydaVerificationSession.findUnique.mockResolvedValue(null);
|
|
await expect(
|
|
service.completeVerification({ code: 'c', state: 'bogus' }),
|
|
).rejects.toMatchObject({ status: 400 });
|
|
});
|
|
|
|
it('throws FAYDA_INVALID_STATE for a non-pending session', async () => {
|
|
prisma.faydaVerificationSession.findUnique.mockResolvedValue(
|
|
pendingSession({ status: 'COMPLETED' }),
|
|
);
|
|
await expect(
|
|
service.completeVerification({ code: 'c', state: 'state-abc' }),
|
|
).rejects.toMatchObject({ status: 400 });
|
|
});
|
|
|
|
it('throws FAYDA_SESSION_EXPIRED and marks failed for an expired session', async () => {
|
|
prisma.faydaVerificationSession.findUnique.mockResolvedValue(
|
|
pendingSession({ expiresAt: new Date(Date.now() - 1000) }),
|
|
);
|
|
prisma.faydaVerificationSession.updateMany.mockResolvedValue({ count: 1 });
|
|
await expect(
|
|
service.completeVerification({ code: 'c', state: 'state-abc' }),
|
|
).rejects.toMatchObject({ status: 400 });
|
|
expect(prisma.faydaVerificationSession.updateMany).toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('completeVerification — VERIFY', () => {
|
|
function pendingSession(overrides: Partial<any> = {}) {
|
|
return {
|
|
id: 'session-1',
|
|
state: 'state-abc',
|
|
codeVerifier: 'verifier-xyz',
|
|
purpose: 'VERIFY',
|
|
platform: 'WEB',
|
|
status: 'PENDING',
|
|
iamUserId: 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('returns the verified identity attributes and writes no domain rows', async () => {
|
|
prisma.faydaVerificationSession.findUnique.mockResolvedValue(pendingSession());
|
|
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-1',
|
|
name: 'Test User',
|
|
email: 'test@example.com',
|
|
phone_number: '+251911000000',
|
|
birthdate: '1990-05-01',
|
|
gender: 'Male',
|
|
}),
|
|
},
|
|
);
|
|
|
|
const result = await service.completeVerification({
|
|
code: 'authcode',
|
|
state: 'state-abc',
|
|
});
|
|
|
|
expect(result).toMatchObject({
|
|
purpose: 'VERIFY',
|
|
verified: true,
|
|
fullName: 'Test User',
|
|
email: 'test@example.com',
|
|
phoneNumber: '+251911000000',
|
|
birthdate: '1990-05-01',
|
|
gender: 'Male',
|
|
});
|
|
expect(result.token).toBeUndefined();
|
|
expect(result.user).toBeUndefined();
|
|
expect(prisma.bookingSeat.updateMany).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('throws 502 when the token endpoint returns 4xx', async () => {
|
|
prisma.faydaVerificationSession.findUnique.mockResolvedValue(pendingSession());
|
|
prisma.faydaVerificationSession.updateMany.mockResolvedValue({ count: 1 });
|
|
mockFetchSequence({
|
|
ok: false,
|
|
status: 400,
|
|
text: async () => '{"error":"invalid_assertion"}',
|
|
});
|
|
|
|
await expect(
|
|
service.completeVerification({ code: 'authcode', state: 'state-abc' }),
|
|
).rejects.toMatchObject({ status: 502 });
|
|
});
|
|
|
|
it('throws 502 when userinfo is an unsupported format', async () => {
|
|
prisma.faydaVerificationSession.findUnique.mockResolvedValue(pendingSession());
|
|
prisma.faydaVerificationSession.updateMany.mockResolvedValue({ count: 1 });
|
|
mockFetchSequence(
|
|
{ json: async () => ({ access_token: 'tok', token_type: 'Bearer' }) },
|
|
{
|
|
headers: new Headers({ 'content-type': 'text/plain' }),
|
|
text: async () => 'not-a-jwt-not-a-json',
|
|
},
|
|
);
|
|
|
|
await expect(
|
|
service.completeVerification({ code: 'authcode', state: 'state-abc' }),
|
|
).rejects.toMatchObject({ status: 502 });
|
|
});
|
|
|
|
it('falls back to localized name (name#en) when name is missing', async () => {
|
|
prisma.faydaVerificationSession.findUnique.mockResolvedValue(pendingSession());
|
|
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-4',
|
|
'name#en': 'English Name',
|
|
'name#am': 'Amharic Name',
|
|
}),
|
|
},
|
|
);
|
|
|
|
const result = await service.completeVerification({
|
|
code: 'c',
|
|
state: 'state-abc',
|
|
});
|
|
expect(result.fullName).toBe('English Name');
|
|
});
|
|
});
|
|
|
|
describe('completeVerification — LOGIN', () => {
|
|
function loginSession(overrides: Partial<any> = {}) {
|
|
return {
|
|
id: 'login-session',
|
|
state: 'state-login',
|
|
codeVerifier: 'verifier-xyz',
|
|
purpose: 'LOGIN',
|
|
platform: 'WEB',
|
|
status: 'PENDING',
|
|
iamUserId: null,
|
|
expiresAt: new Date(Date.now() + 60_000),
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function mockLoginFetch(userInfo: Record<string, unknown>) {
|
|
const queue = [
|
|
{
|
|
ok: true,
|
|
status: 200,
|
|
json: async () => ({ access_token: 'tok', token_type: 'Bearer' }),
|
|
text: async () => '',
|
|
headers: new Headers({ 'content-type': 'application/json' }),
|
|
},
|
|
{
|
|
ok: true,
|
|
status: 200,
|
|
json: async () => ({}),
|
|
text: async () => JSON.stringify(userInfo),
|
|
headers: new Headers({ 'content-type': 'application/json' }),
|
|
},
|
|
];
|
|
(global as any).fetch = jest.fn(() => Promise.resolve(queue.shift()));
|
|
}
|
|
|
|
beforeEach(() => {
|
|
prisma.faydaVerificationSession.findUnique.mockResolvedValue(loginSession());
|
|
});
|
|
|
|
it('always rejects with FAYDA_LOGIN_MIGRATED_TO_IAM (401)', async () => {
|
|
mockLoginFetch({ sub: 'login-sub-1', name: 'New Person', email: 'new@example.com' });
|
|
prisma.faydaVerificationSession.updateMany.mockResolvedValue({ count: 1 });
|
|
|
|
await expect(
|
|
service.completeVerification({ code: 'c', state: 'state-login' }),
|
|
).rejects.toMatchObject({
|
|
status: 401,
|
|
response: expect.objectContaining({ code: 'FAYDA_LOGIN_MIGRATED_TO_IAM' }),
|
|
});
|
|
});
|
|
|
|
it('does not touch the database for LOGIN purpose', async () => {
|
|
mockLoginFetch({ sub: 'login-sub-2', name: 'Person' });
|
|
prisma.faydaVerificationSession.updateMany.mockResolvedValue({ count: 1 });
|
|
|
|
await expect(
|
|
service.completeVerification({ code: 'c', state: 'state-login' }),
|
|
).rejects.toMatchObject({ status: 401 });
|
|
expect(dataSource.query).not.toHaveBeenCalled();
|
|
expect(prisma.passenger.create).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('getVerificationStatus', () => {
|
|
it('returns verified=true when IAM user metadata has the flag', async () => {
|
|
dataSource.query.mockResolvedValueOnce([{
|
|
metadata: { faydaVerified: true, faydaVerifiedAt: '2026-01-01T00:00:00.000Z' },
|
|
name: { en: 'Test User', am: 'ቴስት ዩዘር' },
|
|
}]);
|
|
const result = await service.getVerificationStatus('iam-user-1');
|
|
expect(result).toEqual({
|
|
verified: true,
|
|
verifiedAt: new Date('2026-01-01T00:00:00Z'),
|
|
fullName: 'Test User',
|
|
});
|
|
});
|
|
|
|
it('returns verified=false when IAM user is missing or unverified', async () => {
|
|
dataSource.query.mockResolvedValueOnce([]);
|
|
const result = await service.getVerificationStatus('iam-user-x');
|
|
expect(result).toEqual({ verified: false });
|
|
});
|
|
});
|
|
});
|