155 lines
6.0 KiB
TypeScript
155 lines
6.0 KiB
TypeScript
|
|
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();
|
||
|
|
});
|
||
|
|
});
|