refactor(iam): remove remaining prisma.user refs from passenger side align RegisterDto to IAM body

This commit is contained in:
Abubeker Yasin
2026-06-09 11:53:46 +03:00
parent 1d35453338
commit 5ae6d9600a
6 changed files with 296 additions and 374 deletions

View File

@@ -15,12 +15,6 @@ function buildPrismaMock() {
bookingSeat: {
updateMany: jest.fn(),
},
user: {
findUnique: jest.fn(),
findFirst: jest.fn(),
create: jest.fn(),
update: jest.fn(),
},
passenger: { create: jest.fn() },
loyaltyAccount: { create: jest.fn() },
walletAccount: { create: jest.fn() },
@@ -29,6 +23,10 @@ function buildPrismaMock() {
};
}
function buildDataSourceMock() {
return { query: jest.fn().mockResolvedValue([]) };
}
function buildConfig(overrides?: Partial<FaydaConfig>): FaydaConfig {
return {
enabled: true,
@@ -58,6 +56,7 @@ function buildConfigService(faydaConfig: FaydaConfig): jest.Mocked<ConfigService
describe('VerifaydaService (OIDC, client-callback)', () => {
let prisma: ReturnType<typeof buildPrismaMock>;
let dataSource: ReturnType<typeof buildDataSourceMock>;
let service: VerifaydaService;
let realPrivateJwk: JWK;
@@ -69,11 +68,12 @@ describe('VerifaydaService (OIDC, client-callback)', () => {
beforeEach(() => {
prisma = buildPrismaMock();
dataSource = buildDataSourceMock();
const cfg = buildConfig({ privateJwk: realPrivateJwk as FaydaConfig['privateJwk'] });
service = new VerifaydaService(
buildConfigService(cfg),
prisma as unknown as PrismaService,
{ query: jest.fn().mockResolvedValue([]) } as any,
dataSource as any,
);
(global as any).fetch = jest.fn();
});
@@ -127,7 +127,7 @@ describe('VerifaydaService (OIDC, client-callback)', () => {
const disabledService = new VerifaydaService(
buildConfigService(buildConfig({ enabled: false })),
prisma as unknown as PrismaService,
{ query: jest.fn().mockResolvedValue([]) } as any,
buildDataSourceMock() as any,
);
await expect(
disabledService.startVerification({ purpose: 'PURCHASE' }),
@@ -147,7 +147,7 @@ describe('VerifaydaService (OIDC, client-callback)', () => {
status: 'PENDING',
errorCode: null,
errorDescription: null,
userId: null,
iamUserId: null,
bookingId: null,
expiresAt: new Date(Date.now() + 60_000),
...overrides,
@@ -210,7 +210,7 @@ describe('VerifaydaService (OIDC, client-callback)', () => {
platform: 'WEB',
saveToAccount: false,
status: 'PENDING',
userId: null,
iamUserId: null,
bookingId: null,
expiresAt: new Date(Date.now() + 60_000),
...overrides,
@@ -262,12 +262,15 @@ describe('VerifaydaService (OIDC, client-callback)', () => {
});
});
it('saves to the User account when saveToAccount=true and no conflict', async () => {
it('saves to the IAM user account when saveToAccount=true and no conflict', async () => {
prisma.faydaVerificationSession.findUnique.mockResolvedValue(
pendingSession({ userId: 'user-1', saveToAccount: true }),
pendingSession({ iamUserId: 'iam-user-1', saveToAccount: true }),
);
prisma.user.findFirst.mockResolvedValue(null);
prisma.user.update.mockResolvedValue({});
// First dataSource.query = conflict check returns [] (no conflict)
// Second dataSource.query = UPDATE call returns []
dataSource.query
.mockResolvedValueOnce([])
.mockResolvedValueOnce([]);
prisma.faydaVerificationSession.update.mockResolvedValue({});
mockFetchSequence(
@@ -285,17 +288,24 @@ describe('VerifaydaService (OIDC, client-callback)', () => {
});
expect(result.verified).toBe(true);
expect(prisma.user.update).toHaveBeenCalledWith({
where: { id: 'user-1' },
data: expect.objectContaining({ faydaVerified: true, faydaSub: 'fayda-sub-2' }),
});
// conflict check: SELECT id FROM iam.users WHERE metadata->>'faydaSub' = ...
expect(dataSource.query).toHaveBeenCalledWith(
expect.stringContaining(`metadata->>'faydaSub'`),
['fayda-sub-2', 'iam-user-1'],
);
// update: SET metadata = COALESCE(metadata, '{}') || ...
expect(dataSource.query).toHaveBeenCalledWith(
expect.stringContaining('UPDATE iam.users SET metadata'),
expect.arrayContaining(['iam-user-1']),
);
});
it('throws identity_conflict (409) when faydaSub belongs to another user', async () => {
it('throws identity_conflict (409) when faydaSub belongs to another IAM user', async () => {
prisma.faydaVerificationSession.findUnique.mockResolvedValue(
pendingSession({ userId: 'user-1', saveToAccount: true }),
pendingSession({ iamUserId: 'iam-user-1', saveToAccount: true }),
);
prisma.user.findFirst.mockResolvedValue({ id: 'other-user' });
// conflict check returns a conflicting row
dataSource.query.mockResolvedValueOnce([{ id: 'other-iam-user' }]);
prisma.faydaVerificationSession.updateMany.mockResolvedValue({ count: 1 });
mockFetchSequence(
@@ -310,7 +320,8 @@ describe('VerifaydaService (OIDC, client-callback)', () => {
await expect(
service.completeVerification({ code: 'authcode', state: 'state-abc' }),
).rejects.toMatchObject({ status: 409 });
expect(prisma.user.update).not.toHaveBeenCalled();
// UPDATE must not have been called
expect(dataSource.query).toHaveBeenCalledTimes(1);
});
it('throws 502 when the token endpoint returns 4xx', async () => {
@@ -384,7 +395,7 @@ describe('VerifaydaService (OIDC, client-callback)', () => {
platform: 'WEB',
saveToAccount: false,
status: 'PENDING',
userId: null,
iamUserId: null,
bookingId: null,
expiresAt: new Date(Date.now() + 60_000),
...overrides,
@@ -411,131 +422,41 @@ describe('VerifaydaService (OIDC, client-callback)', () => {
(global as any).fetch = jest.fn(() => Promise.resolve(queue.shift()));
}
/** user.findUnique answers the faydaSub lookup and the issueLoginToken id lookup. */
function mockUserFindUnique(bySub: any, fullUser: any) {
prisma.user.findUnique.mockImplementation(async (args: any) => {
if (args?.where?.faydaSub !== undefined) return bySub;
if (args?.where?.id !== undefined) return fullUser;
return null;
});
}
beforeEach(() => {
prisma.faydaVerificationSession.findUnique.mockResolvedValue(loginSession());
});
it('creates a new user but rejects legacy local token issuance', async () => {
const fullUser = {
id: 'new-user',
email: 'new@example.com',
role: 'PASSENGER',
passenger: { id: 'p-new' },
agent: null,
};
mockUserFindUnique(null, fullUser);
prisma.user.findFirst.mockResolvedValue(null);
prisma.user.create.mockResolvedValue({ id: 'new-user' });
prisma.passenger.create.mockResolvedValue({ id: 'p-new' });
prisma.loyaltyAccount.create.mockResolvedValue({});
prisma.walletAccount.create.mockResolvedValue({});
prisma.userPreferences.create.mockResolvedValue({});
prisma.faydaVerificationSession.update.mockResolvedValue({});
it('always rejects with FAYDA_LOGIN_MIGRATED_TO_IAM (401)', async () => {
mockLoginFetch({ sub: 'login-sub-1', name: 'New Person', email: 'new@example.com' });
await expect(
service.completeVerification({
code: 'c',
state: 'state-login',
}),
).rejects.toMatchObject({ status: 401 });
expect(prisma.user.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
faydaSub: 'login-sub-1',
faydaVerified: true,
email: 'new@example.com',
}),
}),
);
expect(prisma.passenger.create).toHaveBeenCalled();
});
it('resolves an existing linked user but rejects legacy local token issuance', async () => {
const fullUser = {
id: 'known-user',
email: 'k@example.com',
role: 'PASSENGER',
passenger: { id: 'p-k' },
agent: null,
};
mockUserFindUnique({ id: 'known-user' }, fullUser);
prisma.faydaVerificationSession.update.mockResolvedValue({});
mockLoginFetch({ sub: 'login-sub-2', name: 'Known' });
await expect(
service.completeVerification({
code: 'c',
state: 'state-login',
}),
).rejects.toMatchObject({ status: 401 });
expect(prisma.user.create).not.toHaveBeenCalled();
});
it('links Fayda to an existing account matched by email but rejects legacy local token issuance', async () => {
const fullUser = {
id: 'acc-1',
email: 'match@example.com',
role: 'PASSENGER',
passenger: { id: 'p-1' },
agent: null,
};
mockUserFindUnique(null, fullUser);
prisma.user.findFirst.mockResolvedValue({ id: 'acc-1', faydaSub: null });
prisma.user.update.mockResolvedValue({});
prisma.faydaVerificationSession.update.mockResolvedValue({});
mockLoginFetch({ sub: 'login-sub-3', email: 'match@example.com' });
await expect(
service.completeVerification({
code: 'c',
state: 'state-login',
}),
).rejects.toMatchObject({ status: 401 });
expect(prisma.user.update).toHaveBeenCalledWith(
expect.objectContaining({
where: { id: 'acc-1' },
data: expect.objectContaining({ faydaSub: 'login-sub-3' }),
}),
);
expect(prisma.user.create).not.toHaveBeenCalled();
});
it('throws identity_conflict (409) when matched account has a different faydaSub', async () => {
mockUserFindUnique(null, null);
prisma.user.findFirst.mockResolvedValue({ id: 'acc-2', faydaSub: 'someone-else' });
prisma.faydaVerificationSession.updateMany.mockResolvedValue({ count: 1 });
mockLoginFetch({ sub: 'login-sub-4', email: 'match@example.com' });
await expect(
service.completeVerification({ code: 'c', state: 'state-login' }),
).rejects.toMatchObject({ status: 409 });
expect(prisma.user.update).not.toHaveBeenCalled();
expect(prisma.user.create).not.toHaveBeenCalled();
).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 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');
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'),
@@ -543,9 +464,9 @@ describe('VerifaydaService (OIDC, client-callback)', () => {
});
});
it('returns verified=false when User row is missing or unverified', async () => {
prisma.user.findUnique.mockResolvedValue(null);
const result = await service.getVerificationStatus('user-x');
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 });
});
});

View File

@@ -250,50 +250,25 @@ export class VerifaydaService {
}
}
/** Loads a user (+ relations) and mints the same JWT shape as `/auth/login`. */
private async issueLoginToken(
userId: string,
_userId: string,
): Promise<{ token: string; user: FaydaUserSummary }> {
const user = await this.prisma.user.findUnique({
where: { id: userId },
include: { passenger: true, agent: true },
});
if (!user) {
// Should not happen — we just resolved/created this user.
throw new UnauthorizedException({
code: 'FAYDA_LOGIN_FAILED',
message: 'Could not load the verified user',
});
}
const summary: FaydaUserSummary = {
id: user.id,
email: user.email,
role: user.role,
passengerId: user.passenger?.id,
agentId: user.agent?.id,
};
this.logger.warn(
`Legacy passenger Fayda login reached for user ${user.id}; use IAM /v1/auth Fayda login to issue tokens.`,
);
throw new UnauthorizedException({
code: 'FAYDA_LOGIN_MIGRATED_TO_IAM',
message: 'Fayda login tokens are issued by the IAM package auth endpoints.',
user: summary,
});
}
async getVerificationStatus(iamUserId: string): Promise<VerificationStatusDto> {
const passenger = await this.prisma.passenger.findUnique({
where: { iamUserId },
include: { user: { select: { faydaVerified: true, faydaVerifiedAt: true, fullName: true } } },
});
return {
verified: passenger?.user?.faydaVerified ?? false,
verifiedAt: passenger?.user?.faydaVerifiedAt ?? undefined,
fullName: passenger?.user?.fullName ?? undefined,
};
const rows = await this.dataSource.query<{ metadata: Record<string, any> | null; name: { en: string; am: string } | null }[]>(
`SELECT metadata, name FROM iam.users WHERE id = $1 LIMIT 1`,
[iamUserId],
);
const iam = rows[0] ?? null;
const faydaVerified = iam?.metadata?.faydaVerified === true || iam?.metadata?.faydaVerified === 'true';
const faydaVerifiedAt = iam?.metadata?.faydaVerifiedAt ? new Date(iam.metadata.faydaVerifiedAt) : undefined;
const fullName = iam?.name?.en ?? iam?.name?.am ?? undefined;
return { verified: faydaVerified, verifiedAt: faydaVerifiedAt, fullName };
}
// ==========================================================================