import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import Fastify, { type FastifyInstance } from 'fastify'; import { registerUserRoutes } from '../src/routes/users.js'; import { errorHandler } from '../src/middleware/error-handler.js'; /** * Unit tests for the password endpoints. RBAC/auth is enforced by global hooks * in main.ts (not registerUserRoutes), so here we set request.userId via a test * preHandler from the `x-test-user` header to drive the route logic directly. */ let app: FastifyInstance; function makeUser(over?: Partial<{ id: string; email: string }>) { return { id: 'user-1', email: 'me@example.com', name: null, role: 'user', provider: 'local', externalId: null, version: 1, createdAt: new Date(), updatedAt: new Date(), ...over }; } function makeDeps() { return { userService: { list: vi.fn(async () => []), getById: vi.fn(async (id: string) => makeUser({ id })), getByEmail: vi.fn(async (email: string) => makeUser({ email, id: 'user-2' })), create: vi.fn(async () => makeUser({ id: 'new-user' })), delete: vi.fn(async () => {}), verifyPassword: vi.fn(async () => true), setPassword: vi.fn(async () => {}), }, rbacDefinitionService: { upsertByName: vi.fn(async () => ({})), }, prisma: { secret: { findUnique: vi.fn(async () => null) }, // no settings secret → default ON }, }; } type Deps = ReturnType; async function createApp(deps: Deps): Promise { app = Fastify({ logger: false }); app.setErrorHandler(errorHandler); // Emulate the global auth hook: set userId from a test header when present. app.addHook('preHandler', async (request) => { const u = request.headers['x-test-user']; if (typeof u === 'string') request.userId = u; }); registerUserRoutes(app, deps as unknown as Parameters[1]); await app.ready(); return app; } afterEach(async () => { await app?.close(); }); describe('POST /api/v1/users/me/password (self-service)', () => { let deps: Deps; beforeEach(() => { deps = makeDeps(); }); it('changes password when current password verifies', async () => { await createApp(deps); const res = await app.inject({ method: 'POST', url: '/api/v1/users/me/password', headers: { 'x-test-user': 'user-1' }, payload: { currentPassword: 'oldpass12', newPassword: 'newpass345' }, }); expect(res.statusCode).toBe(200); expect(res.json()).toEqual({ success: true }); expect(deps.userService.verifyPassword).toHaveBeenCalledWith('user-1', 'oldpass12'); expect(deps.userService.setPassword).toHaveBeenCalledWith('user-1', 'newpass345'); }); it('rejects with 401 when current password is wrong', async () => { deps.userService.verifyPassword.mockResolvedValueOnce(false); await createApp(deps); const res = await app.inject({ method: 'POST', url: '/api/v1/users/me/password', headers: { 'x-test-user': 'user-1' }, payload: { currentPassword: 'wrong', newPassword: 'newpass345' }, }); expect(res.statusCode).toBe(401); expect(deps.userService.setPassword).not.toHaveBeenCalled(); }); it('rejects with 401 when unauthenticated', async () => { await createApp(deps); const res = await app.inject({ method: 'POST', url: '/api/v1/users/me/password', payload: { currentPassword: 'x', newPassword: 'newpass345' }, }); expect(res.statusCode).toBe(401); }); it('rejects with 400 when new password too short', async () => { await createApp(deps); const res = await app.inject({ method: 'POST', url: '/api/v1/users/me/password', headers: { 'x-test-user': 'user-1' }, payload: { currentPassword: 'oldpass12', newPassword: 'short' }, }); expect(res.statusCode).toBe(400); expect(deps.userService.setPassword).not.toHaveBeenCalled(); }); }); describe('PUT /api/v1/users/:id/password (admin reset)', () => { let deps: Deps; beforeEach(() => { deps = makeDeps(); }); it('resets by id without requiring a current password', async () => { await createApp(deps); const res = await app.inject({ method: 'PUT', url: '/api/v1/users/user-9/password', payload: { newPassword: 'resetpass99' }, }); expect(res.statusCode).toBe(200); expect(deps.userService.setPassword).toHaveBeenCalledWith('user-9', 'resetpass99'); expect(deps.userService.verifyPassword).not.toHaveBeenCalled(); }); it('resolves an email target to its id before resetting', async () => { await createApp(deps); const res = await app.inject({ method: 'PUT', url: `/api/v1/users/${encodeURIComponent('other@example.com')}/password`, payload: { newPassword: 'resetpass99' }, }); expect(res.statusCode).toBe(200); expect(deps.userService.getByEmail).toHaveBeenCalledWith('other@example.com'); expect(deps.userService.setPassword).toHaveBeenCalledWith('user-2', 'resetpass99'); }); }); describe('POST /api/v1/users — self-permission seeding', () => { let deps: Deps; beforeEach(() => { deps = makeDeps(); }); it('seeds the self password permission when the setting is ON (default)', async () => { await createApp(deps); const res = await app.inject({ method: 'POST', url: '/api/v1/users', payload: { email: 'x@y.com', password: 'password12' } }); expect(res.statusCode).toBe(201); expect(deps.rbacDefinitionService.upsertByName).toHaveBeenCalledWith({ name: 'self-new-user', subjects: [{ kind: 'User', name: 'me@example.com' }], roleBindings: [{ role: 'run', action: 'set-own-password' }], }); }); it('does NOT seed when the setting is disabled', async () => { deps.prisma.secret.findUnique.mockResolvedValueOnce({ name: 'mcpctl-system-settings', data: { allowSelfPasswordChange: false } } as never); await createApp(deps); const res = await app.inject({ method: 'POST', url: '/api/v1/users', payload: { email: 'x@y.com', password: 'password12' } }); expect(res.statusCode).toBe(201); expect(deps.rbacDefinitionService.upsertByName).not.toHaveBeenCalled(); }); });