Something went wrong. Try again.
Mirrored from GitHub github.com/roostorg/coop
Something went wrong. Try again.
8.0 kB · 261 lines
TypeScript
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262import { type Kysely } from 'kysely';
import { makeTestWithFixture } from '../../test/utils.js';import type { UserManagementPg } from './index.js';import UserManagementService from './userManagementService.js';
// Mock dependenciesconst mockDb = { selectFrom: jest.fn(), insertInto: jest.fn(), updateTable: jest.fn(), deleteFrom: jest.fn(), transaction: jest.fn(),} as unknown as Kysely<UserManagementPg>;
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'); }, ); });});