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:
Michal
2026-06-16 21:55:56 +01:00
parent 0e952dbf68
commit 2cceeb7093
15 changed files with 583 additions and 15 deletions

View File

@@ -0,0 +1,72 @@
import { Command } from 'commander';
import type { ApiClient } from '../api-client.js';
export interface PasswdPromptDeps {
password(message: string): Promise<string>;
}
export interface PasswdCommandDeps {
client: ApiClient;
log: (...args: string[]) => void;
prompt: PasswdPromptDeps;
}
interface Me {
id: string;
email: string;
}
interface UserView {
id: string;
email: string;
}
async function defaultPassword(message: string): Promise<string> {
const { default: inquirer } = await import('inquirer');
const { answer } = await inquirer.prompt([{ type: 'password', name: 'answer', message, mask: '*' }]);
return answer as string;
}
function validateNew(newPassword: string, confirm: string): void {
if (newPassword !== confirm) {
throw new Error('Passwords do not match');
}
if (newPassword.length < 8) {
throw new Error('Password must be at least 8 characters');
}
}
export function createPasswdCommand(deps?: Partial<PasswdCommandDeps>): Command {
const log = deps?.log ?? ((...args: string[]): void => { console.log(...args); });
const prompt: PasswdPromptDeps = deps?.prompt ?? { password: defaultPassword };
return new Command('passwd')
.description('Change a user password (your own when called without an argument)')
.argument('[user]', 'email or id of the user whose password to change (defaults to yourself)')
.action(async (target: string | undefined) => {
const client = deps?.client;
if (!client) throw new Error('passwd: no API client configured');
const me = await client.get<Me>('/api/v1/auth/me');
const isSelf = target === undefined || target === me.email || target === me.id;
if (isSelf) {
// Self-service: prove identity with the current password, then set the new one.
const currentPassword = await prompt.password('Current password');
const newPassword = await prompt.password('New password');
const confirm = await prompt.password('Retype new password');
validateNew(newPassword, confirm);
await client.post('/api/v1/users/me/password', { currentPassword, newPassword });
log(`Password updated for ${me.email}.`);
return;
}
// Admin reset of another user — requires edit:users.
const targetUser = await client.get<UserView>(`/api/v1/users/${encodeURIComponent(target as string)}`);
const newPassword = await prompt.password(`New password for ${targetUser.email}`);
const confirm = await prompt.password('Retype new password');
validateNew(newPassword, confirm);
await client.put(`/api/v1/users/${targetUser.id}/password`, { newPassword });
log(`Password reset for ${targetUser.email}.`);
});
}

View File

@@ -25,6 +25,7 @@ import { createMigrateCommand } from './commands/migrate.js';
import { createRotateCommand } from './commands/rotate.js';
import { createReviewCommand } from './commands/review.js';
import { createSkillsCommand } from './commands/skills.js';
import { createPasswdCommand } from './commands/passwd.js';
import { ApiClient, ApiError } from './api-client.js';
import { loadConfig } from './config/index.js';
import { loadCredentials } from './auth/index.js';
@@ -257,6 +258,11 @@ export function createProgram(): Command {
log: (...args) => console.log(...args),
}));
program.addCommand(createPasswdCommand({
client,
log: (...args) => console.log(...args),
}));
program.addCommand(createBackupCommand({
client,
log: (...args) => console.log(...args),

View File

@@ -0,0 +1,33 @@
/**
* Self-service password change is modelled as a real, admin-revocable RBAC
* permission — NOT an exception to the RBAC path. Every new user gets a
* personal RbacDefinition (`self-<userId>`) granting the `set-own-password`
* operation, gated at creation time by the `allowSelfPasswordChange` system
* setting. Admins disable it for an individual by deleting that definition,
* or for future users by flipping the setting.
*/
import type { RbacDefinitionService } from '../services/rbac-definition.service.js';
/** Operation that gates `POST /api/v1/users/me/password`. */
export const SET_OWN_PASSWORD_OPERATION = 'set-own-password';
/** Name of a user's personal RbacDefinition (currently just self password). */
export function selfRbacName(userId: string): string {
return `self-${userId}`;
}
/**
* Grant a user the default self-service password-change permission.
* Idempotent (upsert by name) — safe to call on every creation/bootstrap.
*/
export async function grantSelfPasswordPermission(
rbac: RbacDefinitionService,
user: { id: string; email: string },
): Promise<void> {
await rbac.upsertByName({
name: selfRbacName(user.id),
subjects: [{ kind: 'User', name: user.email }],
roleBindings: [{ role: 'run', action: SET_OWN_PASSWORD_OPERATION }],
});
}

View File

@@ -0,0 +1,47 @@
/**
* Global mcpd settings, stored as JSON in a well-known Secret's `data` field.
*
* We deliberately reuse the Secret object instead of adding schema columns:
* Secrets already have CRUD, RBAC, and backup/restore, so settings stay
* admin-editable through the same tooling with no migration per flag.
* See the `backup-ssh` secret for the original precedent.
*/
import type { PrismaClient } from '@prisma/client';
/** Well-known secret holding global mcpd settings as `data` JSON. */
export const SYSTEM_SETTINGS_SECRET = 'mcpctl-system-settings';
/** Settings key: whether newly-created users get self password-change permission. */
export const SETTING_ALLOW_SELF_PASSWORD = 'allowSelfPasswordChange';
/** Coerce a stored JSON value (boolean, or "true"/"false"/"0" string) to boolean. */
function toBool(v: unknown, dflt: boolean): boolean {
if (typeof v === 'boolean') return v;
if (typeof v === 'string') {
const s = v.trim().toLowerCase();
if (s === 'false' || s === '0' || s === '') return false;
return true;
}
return dflt;
}
/**
* Whether new users should receive the `set-own-password` permission by default.
*
* Defaults to TRUE — a fresh install allows self-service password changes.
* An admin disables it by setting `allowSelfPasswordChange: false` in the
* `mcpctl-system-settings` secret. Missing secret / missing key / read error
* all fall back to the permissive default.
*/
export async function getAllowSelfPasswordChange(prisma: PrismaClient): Promise<boolean> {
try {
const secret = await prisma.secret.findUnique({ where: { name: SYSTEM_SETTINGS_SECRET } });
if (!secret) return true;
const data = (secret.data ?? {}) as Record<string, unknown>;
if (!(SETTING_ALLOW_SELF_PASSWORD in data)) return true;
return toBool(data[SETTING_ALLOW_SELF_PASSWORD], true);
} catch {
return true;
}
}

View File

@@ -54,6 +54,7 @@ import { PersonalityService } from './services/personality.service.js';
import { registerPersonalityRoutes } from './routes/personalities.js';
import { registerWebUi } from './routes/web-ui.js';
import { bootstrapSystemProject } from './bootstrap/system-project.js';
import { SET_OWN_PASSWORD_OPERATION } from './bootstrap/self-password-permission.js';
import { bootstrapSystemSkills } from './bootstrap/system-skills.js';
import {
McpServerService,
@@ -130,6 +131,13 @@ function mapUrlToPermission(method: string, url: string): PermissionCheck {
const segment = match[1] as string;
// Self-service password change — gated by the `set-own-password` operation
// (a default, admin-revocable permission), NOT the broad edit:users that an
// admin reset of another user needs. Must precede the generic users mapping.
if (url.startsWith('/api/v1/users/me/password')) {
return { kind: 'operation', operation: SET_OWN_PASSWORD_OPERATION };
}
// Operations (non-resource endpoints)
if (segment === 'backup') return { kind: 'operation', operation: 'backup' };
if (segment === 'restore') return { kind: 'operation', operation: 'restore' };
@@ -414,6 +422,12 @@ async function main(): Promise<void> {
mcptokens: mcpTokenRepo,
llms: llmRepo,
agents: agentRepo,
// Resolve user CUID → email so name-scoped `edit:users:<email>` bindings
// match on PUT /api/v1/users/:id/password (admin reset).
users: { findById: async (id: string) => {
const u = await userRepo.findById(id);
return u ? { name: u.email } : null;
} },
};
// Migrate legacy 'admin' role → granular roles
@@ -701,7 +715,7 @@ async function main(): Promise<void> {
authDeps,
});
registerRbacRoutes(app, rbacDefinitionService);
registerUserRoutes(app, userService);
registerUserRoutes(app, { userService, rbacDefinitionService, prisma });
registerGroupRoutes(app, groupService);
registerMcpTokenRoutes(app, { tokenService: mcpTokenService, projectRepo });
registerPromptRoutes(app, promptService, projectRepo, agentRepo);

View File

@@ -6,9 +6,11 @@ export type SafeUser = Omit<User, 'passwordHash'>;
export interface IUserRepository {
findAll(): Promise<SafeUser[]>;
findById(id: string): Promise<SafeUser | null>;
/** Like findById but includes the passwordHash — used by password verification. */
findByIdWithHash(id: string): Promise<User | null>;
findByEmail(email: string, includeHash?: boolean): Promise<SafeUser | null> | Promise<User | null>;
create(data: { email: string; passwordHash: string; name?: string; role?: string }): Promise<SafeUser>;
update(id: string, data: { name?: string; role?: string }): Promise<SafeUser>;
update(id: string, data: { name?: string; role?: string; passwordHash?: string }): Promise<SafeUser>;
delete(id: string): Promise<void>;
count(): Promise<number>;
}
@@ -43,6 +45,10 @@ export class UserRepository implements IUserRepository {
});
}
async findByIdWithHash(id: string): Promise<User | null> {
return this.prisma.user.findUnique({ where: { id } });
}
async findByEmail(email: string, includeHash?: boolean): Promise<User | SafeUser | null> {
if (includeHash === true) {
return this.prisma.user.findUnique({ where: { email } });
@@ -67,10 +73,11 @@ export class UserRepository implements IUserRepository {
});
}
async update(id: string, data: { name?: string; role?: string }): Promise<SafeUser> {
async update(id: string, data: { name?: string; role?: string; passwordHash?: string }): Promise<SafeUser> {
const updateData: Record<string, unknown> = {};
if (data.name !== undefined) updateData['name'] = data.name;
if (data.role !== undefined) updateData['role'] = data.role;
if (data.passwordHash !== undefined) updateData['passwordHash'] = data.passwordHash;
return this.prisma.user.update({
where: { id },
data: updateData,

View File

@@ -6,6 +6,7 @@ import type { RbacDefinitionService } from '../services/rbac-definition.service.
import type { RbacService } from '../services/rbac.service.js';
import { createAuthMiddleware } from '../middleware/auth.js';
import { createRbacMiddleware } from '../middleware/rbac.js';
import { grantSelfPasswordPermission } from '../bootstrap/self-password-permission.js';
export interface AuthRouteDeps {
authService: AuthService;
@@ -37,12 +38,18 @@ export function registerAuthRoutes(app: FastifyInstance, deps: AuthRouteDeps): v
const { email, password, name } = request.body as { email: string; password: string; name?: string };
// Create the first admin user
await deps.userService.create({
const adminUser = await deps.userService.create({
email,
password,
...(name !== undefined ? { name } : {}),
});
// Fresh install: the admin is also a user, so give them the default
// self-service password-change permission (default-on; see
// grantSelfPasswordPermission). Their bootstrap-admin edit:* already
// covers resetting OTHER users.
await grantSelfPasswordPermission(deps.rbacDefinitionService, adminUser);
// Create "admin" group and add the first user to it
await deps.groupService.create({
name: 'admin',

View File

@@ -1,10 +1,20 @@
import type { FastifyInstance } from 'fastify';
import type { PrismaClient } from '@prisma/client';
import type { UserService } from '../services/user.service.js';
import type { RbacDefinitionService } from '../services/rbac-definition.service.js';
import { ChangeOwnPasswordSchema, ResetPasswordSchema } from '../validation/user.schema.js';
import { getAllowSelfPasswordChange } from '../bootstrap/system-settings.js';
import { grantSelfPasswordPermission } from '../bootstrap/self-password-permission.js';
export interface UserRouteDeps {
userService: UserService;
rbacDefinitionService: RbacDefinitionService;
prisma: PrismaClient;
}
export function registerUserRoutes(app: FastifyInstance, deps: UserRouteDeps): void {
const { userService: service, rbacDefinitionService, prisma } = deps;
export function registerUserRoutes(
app: FastifyInstance,
service: UserService,
): void {
app.get('/api/v1/users', async () => {
return service.list();
});
@@ -20,12 +30,58 @@ export function registerUserRoutes(
app.post('/api/v1/users', async (request, reply) => {
const user = await service.create(request.body);
// Seed the default, admin-revocable self password-change permission,
// gated by the system setting. Never fail user creation if seeding fails.
try {
if (await getAllowSelfPasswordChange(prisma)) {
await grantSelfPasswordPermission(rbacDefinitionService, user);
}
} catch (err) {
request.log.warn({ err, userId: user.id }, 'failed to seed self password permission');
}
reply.code(201);
return user;
});
app.delete<{ Params: { id: string } }>('/api/v1/users/:id', async (_request, reply) => {
await service.delete(_request.params.id);
// ── Self-service password change ──
// Gated by the `set-own-password` operation (global RBAC hook). Requires the
// current password as proof — a user who forgot it must use an admin reset.
app.post('/api/v1/users/me/password', async (request, reply) => {
if (request.userId === undefined) {
reply.code(401);
return { error: 'Authentication required' };
}
const { currentPassword, newPassword } = ChangeOwnPasswordSchema.parse(request.body);
const ok = await service.verifyPassword(request.userId, currentPassword);
if (!ok) {
reply.code(401);
return { error: 'Current password is incorrect' };
}
await service.setPassword(request.userId, newPassword);
return { success: true };
});
// ── Admin reset of another user's password ──
// Gated by edit:users (admins have edit:*). No current password required.
app.put<{ Params: { id: string } }>('/api/v1/users/:id/password', async (request) => {
const idOrEmail = request.params.id;
const target = idOrEmail.includes('@')
? await service.getByEmail(idOrEmail)
: await service.getById(idOrEmail);
const { newPassword } = ResetPasswordSchema.parse(request.body);
await service.setPassword(target.id, newPassword);
return { success: true };
});
app.delete<{ Params: { id: string } }>('/api/v1/users/:id', async (request, reply) => {
// A user cannot delete their own account through the API — self bindings
// grant password change, not account deletion. Admins delete others.
if (request.userId !== undefined && request.userId === request.params.id) {
reply.code(403);
return { error: 'You cannot delete your own account' };
}
await service.delete(request.params.id);
reply.code(204);
return undefined;
});
}

View File

@@ -1,6 +1,6 @@
import bcrypt from 'bcrypt';
import type { IUserRepository, SafeUser } from '../repositories/user.repository.js';
import { CreateUserSchema } from '../validation/user.schema.js';
import { CreateUserSchema, PasswordSchema } from '../validation/user.schema.js';
import { NotFoundError, ConflictError } from './mcp-server.service.js';
const SALT_ROUNDS = 10;
@@ -54,6 +54,27 @@ export class UserService {
await this.userRepo.delete(id);
}
/** Verify a plaintext password against the stored hash for a user id. */
async verifyPassword(id: string, password: string): Promise<boolean> {
const user = await this.userRepo.findByIdWithHash(id);
if (user === null) {
throw new NotFoundError(`User not found: ${id}`);
}
// Locked accounts (e.g. system user with `!locked`) never verify.
if (!user.passwordHash || user.passwordHash.startsWith('!') || user.passwordHash.startsWith('__')) {
return false;
}
return bcrypt.compare(password, user.passwordHash);
}
/** Hash + store a new password for a user. Throws NotFoundError if absent. */
async setPassword(id: string, newPassword: string): Promise<void> {
PasswordSchema.parse(newPassword);
await this.getById(id); // 404 if missing
const passwordHash = await bcrypt.hash(newPassword, SALT_ROUNDS);
await this.userRepo.update(id, { passwordHash });
}
async count(): Promise<number> {
return this.userRepo.count();
}

View File

@@ -1,14 +1,28 @@
import { z } from 'zod';
/** Shared password rules — reused by create, self-change, and admin-reset paths. */
export const PasswordSchema = z.string().min(8).max(128);
export const CreateUserSchema = z.object({
email: z.string().email(),
password: z.string().min(8).max(128),
password: PasswordSchema,
name: z.string().max(100).optional(),
});
export const UpdateUserSchema = z.object({
name: z.string().max(100).optional(),
password: z.string().min(8).max(128).optional(),
password: PasswordSchema.optional(),
});
/** Self-service change: requires the current password as proof. */
export const ChangeOwnPasswordSchema = z.object({
currentPassword: z.string().min(1),
newPassword: PasswordSchema,
});
/** Admin reset of another user: no current password needed. */
export const ResetPasswordSchema = z.object({
newPassword: PasswordSchema,
});
export type CreateUserInput = z.infer<typeof CreateUserSchema>;

View File

@@ -92,6 +92,7 @@ interface MockDeps {
getByName: ReturnType<typeof vi.fn>;
update: ReturnType<typeof vi.fn>;
delete: ReturnType<typeof vi.fn>;
upsertByName: ReturnType<typeof vi.fn>;
};
rbacService: {
canAccess: ReturnType<typeof vi.fn>;
@@ -131,6 +132,7 @@ function createMockDeps(): MockDeps {
getByName: vi.fn(async () => null),
update: vi.fn(async () => makeRbacDef()),
delete: vi.fn(async () => {}),
upsertByName: vi.fn(async () => makeRbacDef()),
},
rbacService: {
canAccess: vi.fn(async () => false),
@@ -223,6 +225,13 @@ describe('Auth Bootstrap', () => {
// Verify auto-login was called
expect(deps.authService.login).toHaveBeenCalledWith('admin@example.com', 'securepass123');
// Verify the admin also got the default self password-change permission
expect(deps.rbacDefinitionService.upsertByName).toHaveBeenCalledWith({
name: 'self-user-1',
subjects: [{ kind: 'User', name: 'admin@example.com' }],
roleBindings: [{ role: 'run', action: 'set-own-password' }],
});
});
it('passes name when provided', async () => {

View 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();
});
});

View File

@@ -0,0 +1,124 @@
/**
* Smoke tests: `mcpctl passwd` end-to-end against live mcpd.
*
* Exercises the full password-change contract:
* 1. Admin creates a throwaway user (gets the default self-permission seeded).
* 2. That user self-changes their password (POST /users/me/password) with the
* correct current password → succeeds; new password logs in, old does not.
* 3. Wrong current password → 401, password unchanged.
* 4. Admin resets the user's password (PUT /users/:id/password) → new one logs in.
* 5. Cleanup: delete the throwaway user.
*
* Target: $MCPD_URL (default https://mcpctl.ad.itaz.eu). Admin token is read
* from the local mcpctl credentials file (the box running smoke tests is logged
* in as admin). If /healthz fails or no admin token is found, the suite skips.
*
* Run with: pnpm test:smoke
*/
import { describe, it, expect, beforeAll } from 'vitest';
import http from 'node:http';
import https from 'node:https';
import { readFileSync } from 'node:fs';
import { homedir } from 'node:os';
import { join } from 'node:path';
const MCPD_URL = process.env.MCPD_URL ?? 'https://mcpctl.ad.itaz.eu';
const STAMP = Date.now().toString(36);
const EMAIL = `smoke-passwd-${STAMP}@example.com`;
const P1 = `Smoke-${STAMP}-aaa1`;
const P2 = `Smoke-${STAMP}-bbb2`;
const P3 = `Smoke-${STAMP}-ccc3`;
interface Resp { status: number; body: string; json: <T = unknown>() => T }
function request(method: string, url: string, token?: string, payload?: unknown): Promise<Resp> {
return new Promise((resolve, reject) => {
const u = new URL(url);
const driver = u.protocol === 'https:' ? https : http;
const data = payload === undefined ? undefined : JSON.stringify(payload);
const headers: Record<string, string> = {};
if (token) headers['Authorization'] = `Bearer ${token}`;
if (data) { headers['Content-Type'] = 'application/json'; headers['Content-Length'] = String(Buffer.byteLength(data)); }
const req = driver.request(url, { method, headers, timeout: 15_000 }, (res) => {
const chunks: Buffer[] = [];
res.on('data', (c: Buffer) => chunks.push(c));
res.on('end', () => {
const body = Buffer.concat(chunks).toString('utf-8');
resolve({ status: res.statusCode ?? 0, body, json: <T,>() => JSON.parse(body) as T });
});
});
req.on('error', reject);
req.on('timeout', () => { req.destroy(); reject(new Error('timeout')); });
if (data) req.write(data);
req.end();
});
}
async function healthz(): Promise<boolean> {
try {
const res = await request('GET', `${MCPD_URL.replace(/\/$/, '')}/healthz`);
return res.status === 200;
} catch { return false; }
}
function adminToken(): string | undefined {
try {
const raw = readFileSync(join(homedir(), '.config', 'mcpctl', 'credentials'), 'utf-8');
return (JSON.parse(raw) as { token?: string }).token;
} catch { return undefined; }
}
async function login(email: string, password: string): Promise<Resp> {
return request('POST', `${MCPD_URL}/api/v1/auth/login`, undefined, { email, password });
}
describe('passwd smoke', () => {
let token: string | undefined;
let ready = false;
let userId: string | undefined;
beforeAll(async () => {
token = adminToken();
const up = await healthz();
ready = up && token !== undefined;
if (!ready) {
console.warn(`\n ○ passwd smoke: skipped — ${MCPD_URL}/healthz up=${up}, adminToken=${token !== undefined}.\n`);
return;
}
// Create the throwaway user (admin).
const res = await request('POST', `${MCPD_URL}/api/v1/users`, token, { email: EMAIL, password: P1 });
expect([201, 409]).toContain(res.status);
const me = await request('GET', `${MCPD_URL}/api/v1/users/${encodeURIComponent(EMAIL)}`, token);
userId = me.json<{ id: string }>().id;
});
it('self-change succeeds with correct current password; new logs in, old does not', async () => {
if (!ready) return;
const userTok = (await login(EMAIL, P1)).json<{ token: string }>().token;
const chg = await request('POST', `${MCPD_URL}/api/v1/users/me/password`, userTok, { currentPassword: P1, newPassword: P2 });
expect(chg.status).toBe(200);
expect((await login(EMAIL, P2)).status).toBe(200);
expect((await login(EMAIL, P1)).status).toBe(401);
});
it('self-change with wrong current password is rejected (401)', async () => {
if (!ready) return;
const userTok = (await login(EMAIL, P2)).json<{ token: string }>().token;
const chg = await request('POST', `${MCPD_URL}/api/v1/users/me/password`, userTok, { currentPassword: 'definitely-wrong', newPassword: P3 });
expect(chg.status).toBe(401);
expect((await login(EMAIL, P2)).status).toBe(200); // unchanged
});
it('admin reset sets a new password without the current one', async () => {
if (!ready || !userId) return;
const reset = await request('PUT', `${MCPD_URL}/api/v1/users/${userId}/password`, token, { newPassword: P3 });
expect(reset.status).toBe(200);
expect((await login(EMAIL, P3)).status).toBe(200);
});
it('cleanup: delete the throwaway user', async () => {
if (!ready || !userId) return;
const del = await request('DELETE', `${MCPD_URL}/api/v1/users/${userId}`, token);
expect([204, 404]).toContain(del.status);
});
});