Files
mcpctl/src/mcpd/tests/users-password.test.ts

155 lines
6.0 KiB
TypeScript
Raw Normal View History

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<typeof makeDeps>;
async function createApp(deps: Deps): Promise<FastifyInstance> {
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<typeof registerUserRoutes>[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();
});
});