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:
124
src/mcplocal/tests/smoke/passwd.smoke.test.ts
Normal file
124
src/mcplocal/tests/smoke/passwd.smoke.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user