import { type Kysely } from 'kysely'; import { makeTestWithFixture } from '../../test/utils.js'; import type { UserManagementPg } from './index.js'; import UserManagementService from './userManagementService.js'; // Mock dependencies const mockDb = { selectFrom: jest.fn(), insertInto: jest.fn(), updateTable: jest.fn(), deleteFrom: jest.fn(), transaction: jest.fn(), } as unknown as Kysely; const mockSendEmail = jest.fn(); const mockConfigService = { uiUrl: 'http://localhost:3000', }; describe('UserManagementService', () => { const testWithFixtures = makeTestWithFixture(() => { const sut = new UserManagementService( mockDb, mockSendEmail, mockConfigService, ); return { sut }; }); beforeEach(() => { jest.clearAllMocks(); }); describe('#generatePasswordResetTokenForUser', () => { testWithFixtures( 'should generate token and send email for valid user in same org', async ({ sut }) => { const userId = 'user-123'; const orgId = 'org-456'; const email = 'test@example.com'; // Mock user lookup const mockSelect = { select: jest.fn().mockReturnThis(), where: jest.fn().mockReturnThis(), executeTakeFirst: jest.fn().mockResolvedValue({ email, orgId, }), }; // Mock token insertion const mockInsert = { values: jest.fn().mockReturnThis(), execute: jest.fn().mockResolvedValue([]), }; // Mock delete const mockDelete = { where: jest.fn().mockReturnThis(), execute: jest.fn().mockResolvedValue([]), }; (mockDb.selectFrom as jest.Mock).mockReturnValue(mockSelect); (mockDb.insertInto as jest.Mock).mockReturnValue(mockInsert); (mockDb.deleteFrom as jest.Mock).mockReturnValue(mockDelete); const token = await sut.generatePasswordResetTokenForUser({ userId, invokerOrgId: orgId, }); // Verify token was generated (64 char hex string) expect(token).toMatch(/^[a-f0-9]{64}$/); // Verify email was sent expect(mockSendEmail).toHaveBeenCalledWith( expect.objectContaining({ to: email, subject: '[Coop] Reset your password', html: expect.stringContaining('password reset'), }), ); // Verify token was stored in database expect(mockDb.insertInto).toHaveBeenCalledWith( 'user_management_service.password_reset_tokens', ); }, ); testWithFixtures( 'should throw UnauthorizedError when user is in different org', async ({ sut }) => { const userId = 'user-123'; const userOrgId = 'org-456'; const adminOrgId = 'org-789'; // Different org! // Mock user lookup const mockSelect = { select: jest.fn().mockReturnThis(), where: jest.fn().mockReturnThis(), executeTakeFirst: jest.fn().mockResolvedValue({ email: 'test@example.com', orgId: userOrgId, }), }; (mockDb.selectFrom as jest.Mock).mockReturnValue(mockSelect); await expect( sut.generatePasswordResetTokenForUser({ userId, invokerOrgId: adminOrgId, }), ).rejects.toThrow( expect.objectContaining({ message: expect.stringContaining( 'can only reset passwords for users in your organization', ), }), ); // Verify email was NOT sent expect(mockSendEmail).not.toHaveBeenCalled(); }, ); testWithFixtures( 'should throw NotFoundError when user does not exist', async ({ sut }) => { const userId = 'nonexistent-user'; const orgId = 'org-456'; // Mock user lookup returning null const mockSelect = { select: jest.fn().mockReturnThis(), where: jest.fn().mockReturnThis(), executeTakeFirst: jest.fn().mockResolvedValue(null), }; (mockDb.selectFrom as jest.Mock).mockReturnValue(mockSelect); await expect( sut.generatePasswordResetTokenForUser({ userId, invokerOrgId: orgId, }), ).rejects.toThrow( expect.objectContaining({ message: expect.stringContaining('User not found'), }), ); // Verify email was NOT sent expect(mockSendEmail).not.toHaveBeenCalled(); }, ); testWithFixtures( 'should continue if email sending fails (email errors are caught internally)', async ({ sut }) => { const userId = 'user-123'; const orgId = 'org-456'; const email = 'test@example.com'; // Mock user lookup const mockSelect = { select: jest.fn().mockReturnThis(), where: jest.fn().mockReturnThis(), executeTakeFirst: jest.fn().mockResolvedValue({ email, orgId, }), }; // Mock token insertion const mockInsert = { values: jest.fn().mockReturnThis(), execute: jest.fn().mockResolvedValue([]), }; // Mock delete const mockDelete = { where: jest.fn().mockReturnThis(), execute: jest.fn().mockResolvedValue([]), }; // Mock email sending to fail (but it's caught internally by sendEmail) mockSendEmail.mockResolvedValue(undefined); // sendEmail catches errors internally (mockDb.selectFrom as jest.Mock).mockReturnValue(mockSelect); (mockDb.insertInto as jest.Mock).mockReturnValue(mockInsert); (mockDb.deleteFrom as jest.Mock).mockReturnValue(mockDelete); // Should still return token - email service handles its own errors const token = await sut.generatePasswordResetTokenForUser({ userId, invokerOrgId: orgId, }); expect(token).toMatch(/^[a-f0-9]{64}$/); expect(mockSendEmail).toHaveBeenCalled(); }, ); }); describe('#resetPasswordForToken', () => { testWithFixtures( 'invalidates all sessions for the user after resetting the password', async ({ sut }) => { const userId = 'user-123'; // Valid, non-expired reset token. const mockSelect = { selectAll: jest.fn().mockReturnThis(), where: jest.fn().mockReturnThis(), executeTakeFirst: jest.fn().mockResolvedValue({ hashed_token: 'hashed', user_id: userId, org_id: 'org-456', created_at: new Date(), }), }; const mockUpdate = { set: jest.fn().mockReturnThis(), where: jest.fn().mockReturnThis(), execute: jest.fn().mockResolvedValue([]), }; const mockDelete = { where: jest.fn().mockReturnThis(), execute: jest.fn().mockResolvedValue([]), }; (mockDb.selectFrom as jest.Mock).mockReturnValue(mockSelect); (mockDb.updateTable as jest.Mock).mockReturnValue(mockUpdate); (mockDb.deleteFrom as jest.Mock).mockReturnValue(mockDelete); // Steps 2-4 run inside makeKyselyTransactionWithRetry, which calls // `pgQuery.transaction().execute(cb)`. Run the callback against mockDb. (mockDb.transaction as jest.Mock).mockReturnValue({ execute: (cb: (trx: typeof mockDb) => unknown) => cb(mockDb), }); await sut.resetPasswordForToken({ token: 'plaintext-token', newPassword: 'new-password', }); // The password row is updated... expect(mockDb.updateTable).toHaveBeenCalledWith('public.users'); // ...and the user's sessions are deleted so a phished session can't // outlive the reset. expect(mockDb.deleteFrom).toHaveBeenCalledWith('public.session'); }, ); }); });