Mirror of https://github.com/roostorg/coop
github.com/roostorg/coop
1import { type Kysely } from 'kysely';
2
3import { makeTestWithFixture } from '../../test/utils.js';
4import UserManagementService from './userManagementService.js';
5import type { UserManagementPg } from './index.js';
6
7// Mock dependencies
8const mockDb = {
9 selectFrom: jest.fn(),
10 insertInto: jest.fn(),
11 deleteFrom: jest.fn(),
12} as unknown as Kysely<UserManagementPg>;
13
14const mockSendEmail = jest.fn();
15
16const mockConfigService = {
17 uiUrl: 'http://localhost:3000',
18};
19
20describe('UserManagementService', () => {
21 const testWithFixtures = makeTestWithFixture(() => {
22 const sut = new UserManagementService(
23 mockDb,
24 mockSendEmail,
25 mockConfigService,
26 );
27 return { sut };
28 });
29
30 beforeEach(() => {
31 jest.clearAllMocks();
32 });
33
34 describe('#generatePasswordResetTokenForUser', () => {
35 testWithFixtures(
36 'should generate token and send email for valid user in same org',
37 async ({ sut }) => {
38 const userId = 'user-123';
39 const orgId = 'org-456';
40 const email = 'test@example.com';
41
42 // Mock user lookup
43 const mockSelect = {
44 select: jest.fn().mockReturnThis(),
45 where: jest.fn().mockReturnThis(),
46 executeTakeFirst: jest.fn().mockResolvedValue({
47 email,
48 orgId,
49 }),
50 };
51
52 // Mock token insertion
53 const mockInsert = {
54 values: jest.fn().mockReturnThis(),
55 execute: jest.fn().mockResolvedValue([]),
56 };
57
58 // Mock delete
59 const mockDelete = {
60 where: jest.fn().mockReturnThis(),
61 execute: jest.fn().mockResolvedValue([]),
62 };
63
64 (mockDb.selectFrom as jest.Mock).mockReturnValue(mockSelect);
65 (mockDb.insertInto as jest.Mock).mockReturnValue(mockInsert);
66 (mockDb.deleteFrom as jest.Mock).mockReturnValue(mockDelete);
67
68 const token = await sut.generatePasswordResetTokenForUser({
69 userId,
70 invokerOrgId: orgId,
71 });
72
73 // Verify token was generated (64 char hex string)
74 expect(token).toMatch(/^[a-f0-9]{64}$/);
75
76 // Verify email was sent
77 expect(mockSendEmail).toHaveBeenCalledWith(
78 expect.objectContaining({
79 to: email,
80 subject: '[Coop] Reset your password',
81 html: expect.stringContaining('password reset'),
82 }),
83 );
84
85 // Verify token was stored in database
86 expect(mockDb.insertInto).toHaveBeenCalledWith(
87 'user_management_service.password_reset_tokens',
88 );
89 },
90 );
91
92 testWithFixtures(
93 'should throw UnauthorizedError when user is in different org',
94 async ({ sut }) => {
95 const userId = 'user-123';
96 const userOrgId = 'org-456';
97 const adminOrgId = 'org-789'; // Different org!
98
99 // Mock user lookup
100 const mockSelect = {
101 select: jest.fn().mockReturnThis(),
102 where: jest.fn().mockReturnThis(),
103 executeTakeFirst: jest.fn().mockResolvedValue({
104 email: 'test@example.com',
105 orgId: userOrgId,
106 }),
107 };
108
109 (mockDb.selectFrom as jest.Mock).mockReturnValue(mockSelect);
110
111 await expect(
112 sut.generatePasswordResetTokenForUser({
113 userId,
114 invokerOrgId: adminOrgId,
115 }),
116 ).rejects.toThrow(
117 expect.objectContaining({
118 message: expect.stringContaining(
119 'can only reset passwords for users in your organization',
120 ),
121 }),
122 );
123
124 // Verify email was NOT sent
125 expect(mockSendEmail).not.toHaveBeenCalled();
126 },
127 );
128
129 testWithFixtures(
130 'should throw NotFoundError when user does not exist',
131 async ({ sut }) => {
132 const userId = 'nonexistent-user';
133 const orgId = 'org-456';
134
135 // Mock user lookup returning null
136 const mockSelect = {
137 select: jest.fn().mockReturnThis(),
138 where: jest.fn().mockReturnThis(),
139 executeTakeFirst: jest.fn().mockResolvedValue(null),
140 };
141
142 (mockDb.selectFrom as jest.Mock).mockReturnValue(mockSelect);
143
144 await expect(
145 sut.generatePasswordResetTokenForUser({
146 userId,
147 invokerOrgId: orgId,
148 }),
149 ).rejects.toThrow(
150 expect.objectContaining({
151 message: expect.stringContaining('User not found'),
152 }),
153 );
154
155 // Verify email was NOT sent
156 expect(mockSendEmail).not.toHaveBeenCalled();
157 },
158 );
159
160 testWithFixtures(
161 'should continue if email sending fails (email errors are caught internally)',
162 async ({ sut }) => {
163 const userId = 'user-123';
164 const orgId = 'org-456';
165 const email = 'test@example.com';
166
167 // Mock user lookup
168 const mockSelect = {
169 select: jest.fn().mockReturnThis(),
170 where: jest.fn().mockReturnThis(),
171 executeTakeFirst: jest.fn().mockResolvedValue({
172 email,
173 orgId,
174 }),
175 };
176
177 // Mock token insertion
178 const mockInsert = {
179 values: jest.fn().mockReturnThis(),
180 execute: jest.fn().mockResolvedValue([]),
181 };
182
183 // Mock delete
184 const mockDelete = {
185 where: jest.fn().mockReturnThis(),
186 execute: jest.fn().mockResolvedValue([]),
187 };
188
189 // Mock email sending to fail (but it's caught internally by sendEmail)
190 mockSendEmail.mockResolvedValue(undefined); // sendEmail catches errors internally
191
192 (mockDb.selectFrom as jest.Mock).mockReturnValue(mockSelect);
193 (mockDb.insertInto as jest.Mock).mockReturnValue(mockInsert);
194 (mockDb.deleteFrom as jest.Mock).mockReturnValue(mockDelete);
195
196 // Should still return token - email service handles its own errors
197 const token = await sut.generatePasswordResetTokenForUser({
198 userId,
199 invokerOrgId: orgId,
200 });
201
202 expect(token).toMatch(/^[a-f0-9]{64}$/);
203 expect(mockSendEmail).toHaveBeenCalled();
204 },
205 );
206 });
207});