feat(passwd): mcpctl passwd + RBAC-gated password change
Restores the lost `mcpctl passwd` command and builds the backend it needs. Backend (mcpd): - POST /api/v1/users/me/password — self-service change, requires current password. Gated by a new `set-own-password` operation. - PUT /api/v1/users/:id/password — admin reset of another user, gated by edit:users (admins have edit:*). Added users name-resolver for CUID→email. - UserService.setPassword/verifyPassword; UserRepository.update accepts passwordHash + findByIdWithHash. RBAC, no exceptions: self password change is a default, admin-revocable permission. Every new user gets a `self-<id>` RbacDefinition granting `set-own-password`, seeded on create + bootstrap, gated by the `allowSelfPasswordChange` system setting (stored in the mcpctl-system-settings secret, default ON; admins disable globally or revoke per-user). CLI: src/cli/src/commands/passwd.ts (self vs admin paths) + completions. Tests: users-password route tests (8), auth-bootstrap grant assertion, passwd live smoke test. Full suite 2214 passing; zero new lint errors. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
154
src/mcpd/tests/users-password.test.ts
Normal file
154
src/mcpd/tests/users-password.test.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user