mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
refactor(auth): auth, passkey, profile, token and admin services refuse with DomainError instead of returning error envelopes
This commit is contained in:
@@ -29,15 +29,6 @@ import { NotificationsService } from '../notifications/notifications.service';
|
|||||||
import type { User } from '../../types';
|
import type { User } from '../../types';
|
||||||
import { ManagedForbidden } from '../common/managed';
|
import { ManagedForbidden } from '../common/managed';
|
||||||
|
|
||||||
/** Throw the legacy {error,status} envelope when a service call reports failure. */
|
|
||||||
function ok<T>(result: T): Exclude<T, { error: string }> {
|
|
||||||
if (result && typeof result === 'object' && 'error' in (result as Record<string, unknown>)) {
|
|
||||||
const r = result as unknown as { error: string; status?: number };
|
|
||||||
throw new HttpException({ error: r.error }, r.status ?? 400);
|
|
||||||
}
|
|
||||||
return result as Exclude<T, { error: string }>;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* /api/admin — admin-only control surface (users, stats, permissions, audit log,
|
* /api/admin — admin-only control surface (users, stats, permissions, audit log,
|
||||||
* OIDC settings, invites, feature toggles, packing templates, addons, MCP/OAuth
|
* OIDC settings, invites, feature toggles, packing templates, addons, MCP/OAuth
|
||||||
@@ -76,14 +67,14 @@ export class AdminController {
|
|||||||
@Post('users')
|
@Post('users')
|
||||||
@HttpCode(201)
|
@HttpCode(201)
|
||||||
async createUser(@CurrentUser() user: User, @Body() body: AdminUserCreateDto, @Req() req: Request) {
|
async createUser(@CurrentUser() user: User, @Body() body: AdminUserCreateDto, @Req() req: Request) {
|
||||||
const result = ok(await this.admin.createUser(body as Parameters<AdminService['createUser']>[0]));
|
const result = await this.admin.createUser(body as Parameters<AdminService['createUser']>[0]);
|
||||||
await this.audit.writeAudit({ userId: user.id, action: 'admin.user_create', resource: String(result.insertedId), ip: getClientIp(req), details: result.auditDetails });
|
await this.audit.writeAudit({ userId: user.id, action: 'admin.user_create', resource: String(result.insertedId), ip: getClientIp(req), details: result.auditDetails });
|
||||||
return { user: result.user };
|
return { user: result.user };
|
||||||
}
|
}
|
||||||
|
|
||||||
@Put('users/:id')
|
@Put('users/:id')
|
||||||
async updateUser(@CurrentUser() user: User, @Param('id') id: string, @Body() body: AdminUserUpdateDto, @Req() req: Request) {
|
async updateUser(@CurrentUser() user: User, @Param('id') id: string, @Body() body: AdminUserUpdateDto, @Req() req: Request) {
|
||||||
const result = ok(await this.admin.updateUser(id, body));
|
const result = await this.admin.updateUser(id, body);
|
||||||
await this.audit.writeAudit({ userId: user.id, action: 'admin.user_update', resource: String(id), ip: getClientIp(req), details: { targetUser: result.previousEmail, fields: result.changed } });
|
await this.audit.writeAudit({ userId: user.id, action: 'admin.user_update', resource: String(id), ip: getClientIp(req), details: { targetUser: result.previousEmail, fields: result.changed } });
|
||||||
logInfo(`Admin ${user.email} edited user ${result.previousEmail} (fields: ${result.changed.join(', ')})`);
|
logInfo(`Admin ${user.email} edited user ${result.previousEmail} (fields: ${result.changed.join(', ')})`);
|
||||||
return { user: result.user };
|
return { user: result.user };
|
||||||
@@ -91,7 +82,7 @@ export class AdminController {
|
|||||||
|
|
||||||
@Delete('users/:id')
|
@Delete('users/:id')
|
||||||
async deleteUser(@CurrentUser() user: User, @Param('id') id: string, @Req() req: Request) {
|
async deleteUser(@CurrentUser() user: User, @Param('id') id: string, @Req() req: Request) {
|
||||||
const result = ok(await this.admin.deleteUser(id, user.id));
|
const result = await this.admin.deleteUser(id, user.id);
|
||||||
await this.audit.writeAudit({ userId: user.id, action: 'admin.user_delete', resource: String(id), ip: getClientIp(req), details: { targetUser: result.email } });
|
await this.audit.writeAudit({ userId: user.id, action: 'admin.user_delete', resource: String(id), ip: getClientIp(req), details: { targetUser: result.email } });
|
||||||
logInfo(`Admin ${user.email} deleted user ${result.email}`);
|
logInfo(`Admin ${user.email} deleted user ${result.email}`);
|
||||||
return { success: true };
|
return { success: true };
|
||||||
@@ -99,14 +90,14 @@ export class AdminController {
|
|||||||
|
|
||||||
@Delete('users/:id/passkeys')
|
@Delete('users/:id/passkeys')
|
||||||
async resetUserPasskeys(@CurrentUser() user: User, @Param('id') id: string, @Req() req: Request) {
|
async resetUserPasskeys(@CurrentUser() user: User, @Param('id') id: string, @Req() req: Request) {
|
||||||
const result = ok(await this.admin.resetUserPasskeys(id));
|
const result = await this.admin.resetUserPasskeys(id);
|
||||||
await this.audit.writeAudit({ userId: user.id, action: 'admin.user_passkeys_reset', resource: String(id), ip: getClientIp(req), details: { targetUser: result.email, deleted: result.deleted } });
|
await this.audit.writeAudit({ userId: user.id, action: 'admin.user_passkeys_reset', resource: String(id), ip: getClientIp(req), details: { targetUser: result.email, deleted: result.deleted } });
|
||||||
return { success: true, deleted: result.deleted };
|
return { success: true, deleted: result.deleted };
|
||||||
}
|
}
|
||||||
|
|
||||||
@Delete('users/:id/mfa')
|
@Delete('users/:id/mfa')
|
||||||
async resetUserMfa(@CurrentUser() user: User, @Param('id') id: string, @Req() req: Request) {
|
async resetUserMfa(@CurrentUser() user: User, @Param('id') id: string, @Req() req: Request) {
|
||||||
const result = ok(await this.admin.resetUserMfa(id, user.id));
|
const result = await this.admin.resetUserMfa(id, user.id);
|
||||||
await this.audit.writeAudit({ userId: user.id, action: 'admin.user_mfa_reset', resource: String(id), ip: getClientIp(req), details: { targetUser: result.email } });
|
await this.audit.writeAudit({ userId: user.id, action: 'admin.user_mfa_reset', resource: String(id), ip: getClientIp(req), details: { targetUser: result.email } });
|
||||||
return { success: true };
|
return { success: true };
|
||||||
}
|
}
|
||||||
@@ -132,9 +123,6 @@ export class AdminController {
|
|||||||
@HttpCode(200)
|
@HttpCode(200)
|
||||||
async saveDemoBaseline(@CurrentUser() user: User, @Req() req: Request) {
|
async saveDemoBaseline(@CurrentUser() user: User, @Req() req: Request) {
|
||||||
const result = await this.admin.saveDemoBaseline();
|
const result = await this.admin.saveDemoBaseline();
|
||||||
if (result.error) {
|
|
||||||
throw new HttpException({ error: result.error }, result.status!);
|
|
||||||
}
|
|
||||||
await this.audit.writeAudit({ userId: user.id, action: 'admin.demo_baseline_save', ip: getClientIp(req) });
|
await this.audit.writeAudit({ userId: user.id, action: 'admin.demo_baseline_save', ip: getClientIp(req) });
|
||||||
return { success: true, message: result.message };
|
return { success: true, message: result.message };
|
||||||
}
|
}
|
||||||
@@ -175,7 +163,7 @@ export class AdminController {
|
|||||||
|
|
||||||
@Delete('invites/:id')
|
@Delete('invites/:id')
|
||||||
async deleteInvite(@CurrentUser() user: User, @Param('id') id: string, @Req() req: Request) {
|
async deleteInvite(@CurrentUser() user: User, @Param('id') id: string, @Req() req: Request) {
|
||||||
ok(await this.invites.deleteInvite(id));
|
await this.invites.deleteInvite(id);
|
||||||
await this.audit.writeAudit({ userId: user.id, action: 'admin.invite_delete', resource: String(id), ip: getClientIp(req) });
|
await this.audit.writeAudit({ userId: user.id, action: 'admin.invite_delete', resource: String(id), ip: getClientIp(req) });
|
||||||
return { success: true };
|
return { success: true };
|
||||||
}
|
}
|
||||||
@@ -290,7 +278,7 @@ export class AdminController {
|
|||||||
|
|
||||||
@Put('addons/:id')
|
@Put('addons/:id')
|
||||||
async updateAddon(@CurrentUser() user: User, @Param('id') id: string, @Body() body: AdminAddonUpdateDto, @Req() req: Request) {
|
async updateAddon(@CurrentUser() user: User, @Param('id') id: string, @Body() body: AdminAddonUpdateDto, @Req() req: Request) {
|
||||||
const result = await ok(await this.admin.updateAddon(id, body));
|
const result = await this.admin.updateAddon(id, body);
|
||||||
await this.audit.writeAudit({ userId: user.id, action: 'admin.addon_update', resource: String(id), ip: getClientIp(req), details: result.auditDetails });
|
await this.audit.writeAudit({ userId: user.id, action: 'admin.addon_update', resource: String(id), ip: getClientIp(req), details: result.auditDetails });
|
||||||
// Sessions only need re-creating when the registered MCP surface can
|
// Sessions only need re-creating when the registered MCP surface can
|
||||||
// actually change — an enabled-flip of an MCP-relevant addon. Config-only
|
// actually change — an enabled-flip of an MCP-relevant addon. Config-only
|
||||||
@@ -316,7 +304,7 @@ export class AdminController {
|
|||||||
|
|
||||||
@Delete('mcp-tokens/:id')
|
@Delete('mcp-tokens/:id')
|
||||||
async deleteMcpToken(@CurrentUser() user: User, @Param('id') id: string, @Req() req: Request) {
|
async deleteMcpToken(@CurrentUser() user: User, @Param('id') id: string, @Req() req: Request) {
|
||||||
ok(await this.tokens.adminDeleteMcpToken(id));
|
await this.tokens.adminDeleteMcpToken(id);
|
||||||
await this.audit.writeAudit({ userId: user.id, action: 'admin.mcp_token_delete', resource: String(id), ip: getClientIp(req) });
|
await this.audit.writeAudit({ userId: user.id, action: 'admin.mcp_token_delete', resource: String(id), ip: getClientIp(req) });
|
||||||
return { success: true };
|
return { success: true };
|
||||||
}
|
}
|
||||||
@@ -326,7 +314,7 @@ export class AdminController {
|
|||||||
|
|
||||||
@Delete('oauth-sessions/:id')
|
@Delete('oauth-sessions/:id')
|
||||||
async revokeOAuthSession(@CurrentUser() user: User, @Param('id') id: string, @Req() req: Request) {
|
async revokeOAuthSession(@CurrentUser() user: User, @Param('id') id: string, @Req() req: Request) {
|
||||||
ok(await this.oauth.adminRevokeOAuthSession(id));
|
await this.oauth.adminRevokeOAuthSession(id);
|
||||||
await this.audit.writeAudit({ userId: user.id, action: 'admin.oauth_session_revoke', resource: String(id), ip: getClientIp(req) });
|
await this.audit.writeAudit({ userId: user.id, action: 'admin.oauth_session_revoke', resource: String(id), ip: getClientIp(req) });
|
||||||
return { success: true };
|
return { success: true };
|
||||||
}
|
}
|
||||||
@@ -336,10 +324,7 @@ export class AdminController {
|
|||||||
@Post('rotate-jwt-secret')
|
@Post('rotate-jwt-secret')
|
||||||
@HttpCode(200)
|
@HttpCode(200)
|
||||||
async rotateJwtSecret(@CurrentUser() user: User, @Req() req: Request) {
|
async rotateJwtSecret(@CurrentUser() user: User, @Req() req: Request) {
|
||||||
const result = this.admin.rotateJwtSecret();
|
this.admin.rotateJwtSecret();
|
||||||
if (result.error) {
|
|
||||||
throw new HttpException({ error: result.error }, result.status!);
|
|
||||||
}
|
|
||||||
await this.audit.writeAudit({ userId: user.id, action: 'admin.rotate_jwt_secret', ip: getClientIp(req) });
|
await this.audit.writeAudit({ userId: user.id, action: 'admin.rotate_jwt_secret', ip: getClientIp(req) });
|
||||||
return { success: true };
|
return { success: true };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { DomainError } from '../common/domain-error';
|
||||||
import { Inject, Injectable } from '@nestjs/common';
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
import { InjectRepository } from '@mikro-orm/nestjs';
|
import { InjectRepository } from '@mikro-orm/nestjs';
|
||||||
import bcrypt from 'bcryptjs';
|
import bcrypt from 'bcryptjs';
|
||||||
@@ -167,22 +168,22 @@ export class AdminService {
|
|||||||
const password = data.password?.trim();
|
const password = data.password?.trim();
|
||||||
|
|
||||||
if (!username || !email || !password) {
|
if (!username || !email || !password) {
|
||||||
return { error: 'Username, email and password are required', status: 400 };
|
throw new DomainError(400, 'Username, email and password are required');
|
||||||
}
|
}
|
||||||
|
|
||||||
const pwCheck = validatePassword(password);
|
const pwCheck = validatePassword(password);
|
||||||
if (!pwCheck.ok) return { error: pwCheck.reason, status: 400 };
|
if (!pwCheck.ok) throw new DomainError(400, pwCheck.reason);
|
||||||
|
|
||||||
if (data.role && !['user', 'admin'].includes(data.role)) {
|
if (data.role && !['user', 'admin'].includes(data.role)) {
|
||||||
return { error: 'Invalid role', status: 400 };
|
throw new DomainError(400, 'Invalid role');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Guests (#1362) live in a reserved synthetic namespace; never let one block a real account.
|
// Guests (#1362) live in a reserved synthetic namespace; never let one block a real account.
|
||||||
const existingUsername = await this.users.findIdByUsernameExact(username);
|
const existingUsername = await this.users.findIdByUsernameExact(username);
|
||||||
if (existingUsername) return { error: 'Username already taken', status: 409 };
|
if (existingUsername) throw new DomainError(409, 'Username already taken');
|
||||||
|
|
||||||
const existingEmail = await this.users.findIdByEmailCI(email);
|
const existingEmail = await this.users.findIdByEmailCI(email);
|
||||||
if (existingEmail) return { error: 'Email already taken', status: 409 };
|
if (existingEmail) throw new DomainError(409, 'Email already taken');
|
||||||
|
|
||||||
const passwordHash = bcrypt.hashSync(password, BCRYPT_COST);
|
const passwordHash = bcrypt.hashSync(password, BCRYPT_COST);
|
||||||
|
|
||||||
@@ -193,7 +194,7 @@ export class AdminService {
|
|||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// Another write took the name or address between the check and the insert.
|
// Another write took the name or address between the check and the insert.
|
||||||
if (err instanceof UserIdentityTakenError) return { error: identityTakenMessage(err), status: 409 };
|
if (err instanceof UserIdentityTakenError) throw new DomainError(409, identityTakenMessage(err));
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -213,29 +214,29 @@ export class AdminService {
|
|||||||
const { role, password } = data;
|
const { role, password } = data;
|
||||||
const user = await this.users.findById(userId);
|
const user = await this.users.findById(userId);
|
||||||
|
|
||||||
if (!user) return { error: 'User not found', status: 404 };
|
if (!user) throw new DomainError(404, 'User not found');
|
||||||
|
|
||||||
if (role && !['user', 'admin'].includes(role)) {
|
if (role && !['user', 'admin'].includes(role)) {
|
||||||
return { error: 'Invalid role', status: 400 };
|
throw new DomainError(400, 'Invalid role');
|
||||||
}
|
}
|
||||||
|
|
||||||
// An empty string used to fall through `username || null` into COALESCE and
|
// An empty string used to fall through `username || null` into COALESCE and
|
||||||
// silently mean "leave unchanged". Say so instead of pretending it worked.
|
// silently mean "leave unchanged". Say so instead of pretending it worked.
|
||||||
if (username === '') return { error: 'Username cannot be empty', status: 400 };
|
if (username === '') throw new DomainError(400, 'Username cannot be empty');
|
||||||
if (email === '') return { error: 'Email cannot be empty', status: 400 };
|
if (email === '') throw new DomainError(400, 'Email cannot be empty');
|
||||||
|
|
||||||
if (username && username !== user.username) {
|
if (username && username !== user.username) {
|
||||||
const conflict = await this.users.findIdByUsernameExactExcluding(username, userId);
|
const conflict = await this.users.findIdByUsernameExactExcluding(username, userId);
|
||||||
if (conflict) return { error: 'Username already taken', status: 409 };
|
if (conflict) throw new DomainError(409, 'Username already taken');
|
||||||
}
|
}
|
||||||
if (email && email !== user.email) {
|
if (email && email !== user.email) {
|
||||||
const conflict = await this.users.findIdByEmailCI(email, userId);
|
const conflict = await this.users.findIdByEmailCI(email, userId);
|
||||||
if (conflict) return { error: 'Email already taken', status: 409 };
|
if (conflict) throw new DomainError(409, 'Email already taken');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (password) {
|
if (password) {
|
||||||
const pwCheck = validatePassword(password);
|
const pwCheck = validatePassword(password);
|
||||||
if (!pwCheck.ok) return { error: pwCheck.reason, status: 400 };
|
if (!pwCheck.ok) throw new DomainError(400, pwCheck.reason);
|
||||||
}
|
}
|
||||||
const passwordHash = password ? bcrypt.hashSync(password, BCRYPT_COST) : null;
|
const passwordHash = password ? bcrypt.hashSync(password, BCRYPT_COST) : null;
|
||||||
|
|
||||||
@@ -248,7 +249,7 @@ export class AdminService {
|
|||||||
const currentRole = await this.users.getRole(userId);
|
const currentRole = await this.users.getRole(userId);
|
||||||
if (currentRole === 'admin') {
|
if (currentRole === 'admin') {
|
||||||
const adminCount = await this.users.countAdmins();
|
const adminCount = await this.users.countAdmins();
|
||||||
if (adminCount <= 1) return { error: 'Cannot remove the last admin', status: 400 };
|
if (adminCount <= 1) throw new DomainError(400, 'Cannot remove the last admin');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -291,7 +292,7 @@ export class AdminService {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof UserIdentityTakenError) return { error: identityTakenMessage(err), status: 409 };
|
if (err instanceof UserIdentityTakenError) throw new DomainError(409, identityTakenMessage(err));
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -318,11 +319,11 @@ export class AdminService {
|
|||||||
// SECURITY: plain JS comparison, deliberately kept exactly here — never
|
// SECURITY: plain JS comparison, deliberately kept exactly here — never
|
||||||
// folded into a repository method's WHERE clause (R4).
|
// folded into a repository method's WHERE clause (R4).
|
||||||
if (Number.parseInt(id) === currentUserId) {
|
if (Number.parseInt(id) === currentUserId) {
|
||||||
return { error: 'Cannot delete own account', status: 400 };
|
throw new DomainError(400, 'Cannot delete own account');
|
||||||
}
|
}
|
||||||
|
|
||||||
const userToDel = await this.users.findIdAndEmail(Number(id));
|
const userToDel = await this.users.findIdAndEmail(Number(id));
|
||||||
if (!userToDel) return { error: 'User not found', status: 404 };
|
if (!userToDel) throw new DomainError(404, 'User not found');
|
||||||
|
|
||||||
await this.userCleanup.deleteUserCompletely(userToDel.id);
|
await this.userCleanup.deleteUserCompletely(userToDel.id);
|
||||||
await emitUserDeleted(userToDel.id); // let plugins erase their own per-user data
|
await emitUserDeleted(userToDel.id); // let plugins erase their own per-user data
|
||||||
@@ -344,15 +345,15 @@ export class AdminService {
|
|||||||
* making that reachable from here would turn a stolen admin session into a
|
* making that reachable from here would turn a stolen admin session into a
|
||||||
* way to strip the second factor off the very account it came from.
|
* way to strip the second factor off the very account it came from.
|
||||||
*/
|
*/
|
||||||
async resetUserMfa(id: string, actingUserId: number): Promise<{ error?: string; status?: number; success?: boolean; email?: string }> {
|
async resetUserMfa(id: string, actingUserId: number): Promise<{ success?: boolean; email?: string }> {
|
||||||
const targetId = Number(id);
|
const targetId = Number(id);
|
||||||
// SECURITY: plain JS comparison, deliberately kept exactly here — never
|
// SECURITY: plain JS comparison, deliberately kept exactly here — never
|
||||||
// folded into a repository method's WHERE clause (R4).
|
// folded into a repository method's WHERE clause (R4).
|
||||||
if (targetId === actingUserId) {
|
if (targetId === actingUserId) {
|
||||||
return { error: 'Use Settings to change your own two-factor setup', status: 400 };
|
throw new DomainError(400, 'Use Settings to change your own two-factor setup');
|
||||||
}
|
}
|
||||||
const target = await this.users.findIdEmailMfaEnabled(targetId);
|
const target = await this.users.findIdEmailMfaEnabled(targetId);
|
||||||
if (!target) return { error: 'User not found', status: 404 };
|
if (!target) throw new DomainError(404, 'User not found');
|
||||||
|
|
||||||
// Same three columns disableMfa clears, so an admin reset and a self-service
|
// Same three columns disableMfa clears, so an admin reset and a self-service
|
||||||
// disable leave the account in exactly one state rather than two; the
|
// disable leave the account in exactly one state rather than two; the
|
||||||
@@ -426,9 +427,9 @@ export class AdminService {
|
|||||||
|
|
||||||
// ── Demo Baseline ──────────────────────────────────────────────────────────
|
// ── Demo Baseline ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
async saveDemoBaseline(): Promise<{ error?: string; status?: number; message?: string }> {
|
async saveDemoBaseline(): Promise<{ message?: string }> {
|
||||||
if (!readEnv().demo.enabled) {
|
if (!readEnv().demo.enabled) {
|
||||||
return { error: 'Not found', status: 404 };
|
throw new DomainError(404, 'Not found');
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
// Lazy require: demo-reset is a demo-only module.
|
// Lazy require: demo-reset is a demo-only module.
|
||||||
@@ -437,7 +438,7 @@ export class AdminService {
|
|||||||
return { message: 'Demo baseline saved. Hourly resets will restore to this state.' };
|
return { message: 'Demo baseline saved. Hourly resets will restore to this state.' };
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
return { error: 'Failed to save baseline', status: 500 };
|
throw new DomainError(500, 'Failed to save baseline');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -676,28 +677,28 @@ export class AdminService {
|
|||||||
const addon = await this.addonsRepo.findById(id);
|
const addon = await this.addonsRepo.findById(id);
|
||||||
const provider = await this.photoProviders.findById(id);
|
const provider = await this.photoProviders.findById(id);
|
||||||
const docProvider = await this.documentProviders.findById(id);
|
const docProvider = await this.documentProviders.findById(id);
|
||||||
if (!addon && !provider && !docProvider) return { error: 'Addon not found', status: 404 };
|
if (!addon && !provider && !docProvider) throw new DomainError(404, 'Addon not found');
|
||||||
|
|
||||||
// The whole addon, not just its config: on a centrally administered install
|
// The whole addon, not just its config: on a centrally administered install
|
||||||
// the operator owns the endpoint, the model and the per-document cost, so
|
// the operator owns the endpoint, the model and the per-document cost, so
|
||||||
// there is nothing here for an instance admin to set — including whether it
|
// there is nothing here for an instance admin to set — including whether it
|
||||||
// runs at all. listAddons hides the row; this closes the route behind it.
|
// runs at all. listAddons hides the row; this closes the route behind it.
|
||||||
if (readEnv().managed.enabled && id === ADDON_IDS.LLM_PARSING) {
|
if (readEnv().managed.enabled && id === ADDON_IDS.LLM_PARSING) {
|
||||||
return { error: MANAGED_FORBIDDEN_ERROR.error, status: 403 };
|
throw new DomainError(403, MANAGED_FORBIDDEN_ERROR.error);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Photo providers are Journey's shelf rows — their whole UI lives inside
|
// Photo providers are Journey's shelf rows — their whole UI lives inside
|
||||||
// journeys, so enabling one under a disabled journey addon would only
|
// journeys, so enabling one under a disabled journey addon would only
|
||||||
// advertise an integration nothing can reach.
|
// advertise an integration nothing can reach.
|
||||||
if (provider && data.enabled === true && !(await this.addons.isAddonEnabled(ADDON_IDS.JOURNEY))) {
|
if (provider && data.enabled === true && !(await this.addons.isAddonEnabled(ADDON_IDS.JOURNEY))) {
|
||||||
return { error: 'Enable the Journey addon first', status: 409 };
|
throw new DomainError(409, 'Enable the Journey addon first');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Same rule one shelf down: a document provider only exists to serve the
|
// Same rule one shelf down: a document provider only exists to serve the
|
||||||
// file manager, so switching one on under a disabled Documents addon would
|
// file manager, so switching one on under a disabled Documents addon would
|
||||||
// advertise a sync nothing can reach.
|
// advertise a sync nothing can reach.
|
||||||
if (docProvider && data.enabled === true && !(await this.addons.isAddonEnabled(ADDON_IDS.DOCUMENTS))) {
|
if (docProvider && data.enabled === true && !(await this.addons.isAddonEnabled(ADDON_IDS.DOCUMENTS))) {
|
||||||
return { error: 'Enable the Documents addon first', status: 409 };
|
throw new DomainError(409, 'Enable the Documents addon first');
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.uow.transactional(async () => {
|
await this.uow.transactional(async () => {
|
||||||
@@ -784,17 +785,16 @@ export class AdminService {
|
|||||||
|
|
||||||
// ── JWT Rotation ───────────────────────────────────────────────────────────
|
// ── JWT Rotation ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
rotateJwtSecret(): { error?: string; status?: number } {
|
rotateJwtSecret(): void {
|
||||||
const newSecret = crypto.randomBytes(32).toString('hex');
|
const newSecret = crypto.randomBytes(32).toString('hex');
|
||||||
const { dataDir, jwtSecretFile: secretFile } = this.dataPaths;
|
const { dataDir, jwtSecretFile: secretFile } = this.dataPaths;
|
||||||
try {
|
try {
|
||||||
if (!fs.existsSync(dataDir)) fs.mkdirSync(dataDir, { recursive: true });
|
if (!fs.existsSync(dataDir)) fs.mkdirSync(dataDir, { recursive: true });
|
||||||
fs.writeFileSync(secretFile, newSecret, { mode: 0o600 });
|
fs.writeFileSync(secretFile, newSecret, { mode: 0o600 });
|
||||||
} catch {
|
} catch {
|
||||||
return { error: 'Failed to persist new JWT secret to disk', status: 500 };
|
throw new DomainError(500, 'Failed to persist new JWT secret to disk');
|
||||||
}
|
}
|
||||||
updateJwtSecret(newSecret);
|
updateJwtSecret(newSecret);
|
||||||
return {};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
invalidateMcpSessions() { invalidateMcpSessions(); }
|
invalidateMcpSessions() { invalidateMcpSessions(); }
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { Public } from '../auth-core/public.decorator';
|
|||||||
import { MfaExempt } from '../auth-core/mfa-policy.guard';
|
import { MfaExempt } from '../auth-core/mfa-policy.guard';
|
||||||
import { extractToken, verifiedSessionClaims } from '../auth-core/jwt-verify';
|
import { extractToken, verifiedSessionClaims } from '../auth-core/jwt-verify';
|
||||||
import { SessionsService, sessionClientFrom } from '../sessions/sessions.service';
|
import { SessionsService, sessionClientFrom } from '../sessions/sessions.service';
|
||||||
|
import { catchDomainError, DomainError } from '../common/domain-error';
|
||||||
|
|
||||||
const WINDOW = 15 * 60 * 1000;
|
const WINDOW = 15 * 60 * 1000;
|
||||||
const LOGIN_MIN_LATENCY_MS = 350;
|
const LOGIN_MIN_LATENCY_MS = 350;
|
||||||
@@ -60,9 +61,6 @@ export class AuthPublicController {
|
|||||||
@HttpCode(200)
|
@HttpCode(200)
|
||||||
async demoLogin(@Req() req: Request, @Res({ passthrough: true }) res: Response) {
|
async demoLogin(@Req() req: Request, @Res({ passthrough: true }) res: Response) {
|
||||||
const result = await this.auth.demoLogin(sessionClientFrom(req));
|
const result = await this.auth.demoLogin(sessionClientFrom(req));
|
||||||
if (result.error) {
|
|
||||||
throw new HttpException({ error: result.error }, result.status!);
|
|
||||||
}
|
|
||||||
this.auth.setAuthCookie(res, result.token!, req);
|
this.auth.setAuthCookie(res, result.token!, req);
|
||||||
return { token: result.token, user: result.user };
|
return { token: result.token, user: result.user };
|
||||||
}
|
}
|
||||||
@@ -72,9 +70,6 @@ export class AuthPublicController {
|
|||||||
async invite(@Param('token') token: string, @Req() req: Request) {
|
async invite(@Param('token') token: string, @Req() req: Request) {
|
||||||
await this.limit('login', req, 10);
|
await this.limit('login', req, 10);
|
||||||
const result = await this.auth.validateInviteToken(token);
|
const result = await this.auth.validateInviteToken(token);
|
||||||
if (result.error) {
|
|
||||||
throw new HttpException({ error: result.error }, result.status!);
|
|
||||||
}
|
|
||||||
return { valid: result.valid, max_uses: result.max_uses, used_count: result.used_count, expires_at: result.expires_at };
|
return { valid: result.valid, max_uses: result.max_uses, used_count: result.used_count, expires_at: result.expires_at };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,9 +79,6 @@ export class AuthPublicController {
|
|||||||
async register(@Body() body: RegisterDto, @Req() req: Request, @Res({ passthrough: true }) res: Response) {
|
async register(@Body() body: RegisterDto, @Req() req: Request, @Res({ passthrough: true }) res: Response) {
|
||||||
await this.limit('login', req, 10);
|
await this.limit('login', req, 10);
|
||||||
const result = await this.auth.registerUser(body, sessionClientFrom(req));
|
const result = await this.auth.registerUser(body, sessionClientFrom(req));
|
||||||
if (result.error) {
|
|
||||||
throw new HttpException({ error: result.error }, result.status!);
|
|
||||||
}
|
|
||||||
await this.audit.writeAudit({ userId: result.auditUserId!, action: 'user.register', ip: getClientIp(req), details: result.auditDetails });
|
await this.audit.writeAudit({ userId: result.auditUserId!, action: 'user.register', ip: getClientIp(req), details: result.auditDetails });
|
||||||
this.auth.setAuthCookie(res, result.token!, req);
|
this.auth.setAuthCookie(res, result.token!, req);
|
||||||
return { token: result.token, user: result.user };
|
return { token: result.token, user: result.user };
|
||||||
@@ -156,10 +148,10 @@ export class AuthPublicController {
|
|||||||
// a dedicated bucket) — without it reset tokens could be guessed unthrottled.
|
// a dedicated bucket) — without it reset tokens could be guessed unthrottled.
|
||||||
await this.limit('reset', req, 5);
|
await this.limit('reset', req, 5);
|
||||||
const ip = getClientIp(req);
|
const ip = getClientIp(req);
|
||||||
const result = await this.auth.resetPassword(body);
|
const result = await catchDomainError(() => this.auth.resetPassword(body));
|
||||||
if (result.error) {
|
if (result instanceof DomainError) {
|
||||||
await this.audit.writeAudit({ userId: null, action: 'user.password_reset_fail', ip, details: { reason: result.error } });
|
await this.audit.writeAudit({ userId: null, action: 'user.password_reset_fail', ip, details: { reason: result.publicMessage } });
|
||||||
throw new HttpException({ error: result.error }, result.status!);
|
throw result;
|
||||||
}
|
}
|
||||||
if (result.mfa_required) {
|
if (result.mfa_required) {
|
||||||
return { mfa_required: true };
|
return { mfa_required: true };
|
||||||
@@ -174,9 +166,6 @@ export class AuthPublicController {
|
|||||||
async verifyMfaLogin(@Body() body: MfaVerifyLoginDto, @Req() req: Request, @Res({ passthrough: true }) res: Response) {
|
async verifyMfaLogin(@Body() body: MfaVerifyLoginDto, @Req() req: Request, @Res({ passthrough: true }) res: Response) {
|
||||||
await this.limit('mfa', req, 5);
|
await this.limit('mfa', req, 5);
|
||||||
const result = await this.auth.verifyMfaLogin(body, sessionClientFrom(req));
|
const result = await this.auth.verifyMfaLogin(body, sessionClientFrom(req));
|
||||||
if (result.error) {
|
|
||||||
throw new HttpException({ error: result.error }, result.status!);
|
|
||||||
}
|
|
||||||
await this.audit.writeAudit({ userId: result.auditUserId!, action: 'user.login', ip: getClientIp(req), details: { mfa: true } });
|
await this.audit.writeAudit({ userId: result.auditUserId!, action: 'user.login', ip: getClientIp(req), details: { mfa: true } });
|
||||||
this.auth.setAuthCookie(res, result.token!, req, result.remember);
|
this.auth.setAuthCookie(res, result.token!, req, result.remember);
|
||||||
return { token: result.token, user: result.user };
|
return { token: result.token, user: result.user };
|
||||||
|
|||||||
@@ -119,9 +119,6 @@ export class AuthController {
|
|||||||
const remember = decodeSessionClaims(cookie)?.remember;
|
const remember = decodeSessionClaims(cookie)?.remember;
|
||||||
const cookieSession = typeof cookie === 'string' && cookie.length > 0;
|
const cookieSession = typeof cookie === 'string' && cookie.length > 0;
|
||||||
const result = await this.auth.changePassword(user.id, user.email, body, remember, sessionClientFrom(req), cookieSession);
|
const result = await this.auth.changePassword(user.id, user.email, body, remember, sessionClientFrom(req), cookieSession);
|
||||||
if (result.error) {
|
|
||||||
throw new HttpException({ error: result.error }, result.status!);
|
|
||||||
}
|
|
||||||
// Refresh this device's cookie with the new password_version so the user
|
// Refresh this device's cookie with the new password_version so the user
|
||||||
// stays logged in here while all other sessions are invalidated.
|
// stays logged in here while all other sessions are invalidated.
|
||||||
if (result.token) this.auth.setAuthCookie(res, result.token, req, remember);
|
if (result.token) this.auth.setAuthCookie(res, result.token, req, remember);
|
||||||
@@ -131,10 +128,7 @@ export class AuthController {
|
|||||||
|
|
||||||
@Delete('me')
|
@Delete('me')
|
||||||
async deleteAccount(@CurrentUser() user: User, @Req() req: Request) {
|
async deleteAccount(@CurrentUser() user: User, @Req() req: Request) {
|
||||||
const result = await this.auth.deleteAccount(user.id, user.email, user.role);
|
await this.auth.deleteAccount(user.id, user.email, user.role);
|
||||||
if (result.error) {
|
|
||||||
throw new HttpException({ error: result.error }, result.status!);
|
|
||||||
}
|
|
||||||
await this.audit.writeAudit({ userId: user.id, action: 'user.account_delete', ip: getClientIp(req) });
|
await this.audit.writeAudit({ userId: user.id, action: 'user.account_delete', ip: getClientIp(req) });
|
||||||
return { success: true };
|
return { success: true };
|
||||||
}
|
}
|
||||||
@@ -180,9 +174,6 @@ export class AuthController {
|
|||||||
@Put('me/settings')
|
@Put('me/settings')
|
||||||
async updateSettings(@CurrentUser() user: User, @Body() body: SettingsUpdateDto, @Req() req: Request) {
|
async updateSettings(@CurrentUser() user: User, @Body() body: SettingsUpdateDto, @Req() req: Request) {
|
||||||
const result = await this.profile.updateSettings(user.id, body);
|
const result = await this.profile.updateSettings(user.id, body);
|
||||||
if (result.error) {
|
|
||||||
throw new HttpException({ error: result.error }, result.status!);
|
|
||||||
}
|
|
||||||
await this.auditApiKeys(user.id, result.changedKeys ?? [], req);
|
await this.auditApiKeys(user.id, result.changedKeys ?? [], req);
|
||||||
return { success: result.success, user: result.user };
|
return { success: result.success, user: result.user };
|
||||||
}
|
}
|
||||||
@@ -190,9 +181,6 @@ export class AuthController {
|
|||||||
@Get('me/settings')
|
@Get('me/settings')
|
||||||
async getSettings(@CurrentUser() user: User) {
|
async getSettings(@CurrentUser() user: User) {
|
||||||
const result = await this.profile.getSettings(user.id);
|
const result = await this.profile.getSettings(user.id);
|
||||||
if (result.error) {
|
|
||||||
throw new HttpException({ error: result.error }, result.status!);
|
|
||||||
}
|
|
||||||
return { settings: result.settings };
|
return { settings: result.settings };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -226,9 +214,6 @@ export class AuthController {
|
|||||||
@Get('validate-keys')
|
@Get('validate-keys')
|
||||||
async validateKeys(@CurrentUser() user: User) {
|
async validateKeys(@CurrentUser() user: User) {
|
||||||
const result = await this.profile.validateKeys(user.id);
|
const result = await this.profile.validateKeys(user.id);
|
||||||
if (result.error) {
|
|
||||||
throw new HttpException({ error: result.error }, result.status!);
|
|
||||||
}
|
|
||||||
return { maps: result.maps, weather: result.weather, maps_details: result.maps_details };
|
return { maps: result.maps, weather: result.weather, maps_details: result.maps_details };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -236,9 +221,6 @@ export class AuthController {
|
|||||||
@MfaExempt('the setup screen reads the policy it is asking the user to satisfy')
|
@MfaExempt('the setup screen reads the policy it is asking the user to satisfy')
|
||||||
async getAppSettings(@CurrentUser() user: User) {
|
async getAppSettings(@CurrentUser() user: User) {
|
||||||
const result = await this.auth.getAppSettings(user.id);
|
const result = await this.auth.getAppSettings(user.id);
|
||||||
if (result.error) {
|
|
||||||
throw new HttpException({ error: result.error }, result.status!);
|
|
||||||
}
|
|
||||||
return result.data;
|
return result.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -246,9 +228,6 @@ export class AuthController {
|
|||||||
@MfaExempt('an admin locked out by their own policy must still be able to lift it')
|
@MfaExempt('an admin locked out by their own policy must still be able to lift it')
|
||||||
async updateAppSettings(@CurrentUser() user: User, @Body() body: AppSettingsUpdateDto, @Req() req: Request) {
|
async updateAppSettings(@CurrentUser() user: User, @Body() body: AppSettingsUpdateDto, @Req() req: Request) {
|
||||||
const result = await this.auth.updateAppSettings(user.id, body);
|
const result = await this.auth.updateAppSettings(user.id, body);
|
||||||
if (result.error) {
|
|
||||||
throw new HttpException({ error: result.error }, result.status!);
|
|
||||||
}
|
|
||||||
await this.audit.writeAudit({ userId: user.id, action: 'settings.app_update', ip: getClientIp(req), details: result.auditSummary, debugDetails: result.auditDebugDetails });
|
await this.audit.writeAudit({ userId: user.id, action: 'settings.app_update', ip: getClientIp(req), details: result.auditSummary, debugDetails: result.auditDebugDetails });
|
||||||
// Named so the settings tab can say which fields the operator holds rather
|
// Named so the settings tab can say which fields the operator holds rather
|
||||||
// than showing a saved value that silently did not save.
|
// than showing a saved value that silently did not save.
|
||||||
@@ -264,9 +243,6 @@ export class AuthController {
|
|||||||
@HttpCode(200)
|
@HttpCode(200)
|
||||||
async mfaSetup(@CurrentUser() user: User) {
|
async mfaSetup(@CurrentUser() user: User) {
|
||||||
const result = await this.auth.setupMfa(user.id, user.email);
|
const result = await this.auth.setupMfa(user.id, user.email);
|
||||||
if (result.error) {
|
|
||||||
throw new HttpException({ error: result.error }, result.status!);
|
|
||||||
}
|
|
||||||
try {
|
try {
|
||||||
const qr_svg = await result.qrPromise!;
|
const qr_svg = await result.qrPromise!;
|
||||||
return { secret: result.secret, otpauth_url: result.otpauth_url, qr_svg };
|
return { secret: result.secret, otpauth_url: result.otpauth_url, qr_svg };
|
||||||
@@ -282,9 +258,6 @@ export class AuthController {
|
|||||||
async mfaEnable(@CurrentUser() user: User, @Body() body: MfaEnableDto, @Req() req: Request) {
|
async mfaEnable(@CurrentUser() user: User, @Body() body: MfaEnableDto, @Req() req: Request) {
|
||||||
await this.limit('mfa', req, 5);
|
await this.limit('mfa', req, 5);
|
||||||
const result = await this.auth.enableMfa(user.id, body.code);
|
const result = await this.auth.enableMfa(user.id, body.code);
|
||||||
if (result.error) {
|
|
||||||
throw new HttpException({ error: result.error }, result.status!);
|
|
||||||
}
|
|
||||||
await this.audit.writeAudit({ userId: user.id, action: 'user.mfa_enable', ip: getClientIp(req) });
|
await this.audit.writeAudit({ userId: user.id, action: 'user.mfa_enable', ip: getClientIp(req) });
|
||||||
return { success: true, mfa_enabled: result.mfa_enabled, backup_codes: result.backup_codes };
|
return { success: true, mfa_enabled: result.mfa_enabled, backup_codes: result.backup_codes };
|
||||||
}
|
}
|
||||||
@@ -294,9 +267,6 @@ export class AuthController {
|
|||||||
async mfaDisable(@CurrentUser() user: User, @Body() body: MfaDisableDto, @Req() req: Request) {
|
async mfaDisable(@CurrentUser() user: User, @Body() body: MfaDisableDto, @Req() req: Request) {
|
||||||
await this.limit('login', req, 5);
|
await this.limit('login', req, 5);
|
||||||
const result = await this.auth.disableMfa(user.id, user.email, body, currentSessionId(req));
|
const result = await this.auth.disableMfa(user.id, user.email, body, currentSessionId(req));
|
||||||
if (result.error) {
|
|
||||||
throw new HttpException({ error: result.error }, result.status!);
|
|
||||||
}
|
|
||||||
await this.audit.writeAudit({ userId: user.id, action: 'user.mfa_disable', ip: getClientIp(req) });
|
await this.audit.writeAudit({ userId: user.id, action: 'user.mfa_disable', ip: getClientIp(req) });
|
||||||
return { success: true, mfa_enabled: result.mfa_enabled };
|
return { success: true, mfa_enabled: result.mfa_enabled };
|
||||||
}
|
}
|
||||||
@@ -312,18 +282,12 @@ export class AuthController {
|
|||||||
async createMcpToken(@CurrentUser() user: User, @Body() body: McpTokenCreateDto, @Req() req: Request) {
|
async createMcpToken(@CurrentUser() user: User, @Body() body: McpTokenCreateDto, @Req() req: Request) {
|
||||||
await this.limit('login', req, 5);
|
await this.limit('login', req, 5);
|
||||||
const result = await this.tokens.createMcpToken(user.id, body.name);
|
const result = await this.tokens.createMcpToken(user.id, body.name);
|
||||||
if (result.error) {
|
|
||||||
throw new HttpException({ error: result.error }, result.status!);
|
|
||||||
}
|
|
||||||
return { token: result.token };
|
return { token: result.token };
|
||||||
}
|
}
|
||||||
|
|
||||||
@Delete('mcp-tokens/:id')
|
@Delete('mcp-tokens/:id')
|
||||||
async deleteMcpToken(@CurrentUser() user: User, @Param('id') id: string) {
|
async deleteMcpToken(@CurrentUser() user: User, @Param('id') id: string) {
|
||||||
const result = await this.tokens.deleteMcpToken(user.id, id);
|
await this.tokens.deleteMcpToken(user.id, id);
|
||||||
if (result.error) {
|
|
||||||
throw new HttpException({ error: result.error }, result.status!);
|
|
||||||
}
|
|
||||||
return { success: true };
|
return { success: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -352,18 +316,12 @@ export class AuthController {
|
|||||||
// before this field existed does. Narrowing stays opt-in so the change
|
// before this field existed does. Narrowing stays opt-in so the change
|
||||||
// cannot break an integration that is already running.
|
// cannot break an integration that is already running.
|
||||||
const result = await this.tokens.createApiToken(user.id, body.name, body.scopes);
|
const result = await this.tokens.createApiToken(user.id, body.name, body.scopes);
|
||||||
if (result.error) {
|
|
||||||
throw new HttpException({ error: result.error }, result.status!);
|
|
||||||
}
|
|
||||||
return { token: result.token };
|
return { token: result.token };
|
||||||
}
|
}
|
||||||
|
|
||||||
@Delete('api-tokens/:id')
|
@Delete('api-tokens/:id')
|
||||||
async deleteApiToken(@CurrentUser() user: User, @Param('id') id: string) {
|
async deleteApiToken(@CurrentUser() user: User, @Param('id') id: string) {
|
||||||
const result = await this.tokens.deleteApiToken(user.id, id);
|
await this.tokens.deleteApiToken(user.id, id);
|
||||||
if (result.error) {
|
|
||||||
throw new HttpException({ error: result.error }, result.status!);
|
|
||||||
}
|
|
||||||
return { success: true };
|
return { success: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -377,9 +335,6 @@ export class AuthController {
|
|||||||
// 503-ing every other user's ws and download tokens.
|
// 503-ing every other user's ws and download tokens.
|
||||||
await this.limitUser('ws_token', user.id, 120);
|
await this.limitUser('ws_token', user.id, 120);
|
||||||
const result = await this.tokens.createWsToken(user.id);
|
const result = await this.tokens.createWsToken(user.id);
|
||||||
if (result.error) {
|
|
||||||
throw new HttpException({ error: result.error }, result.status!);
|
|
||||||
}
|
|
||||||
return { token: result.token };
|
return { token: result.token };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { DomainError } from '../common/domain-error';
|
||||||
import { Injectable, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
|
import { Injectable, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
|
||||||
import bcrypt from 'bcryptjs';
|
import bcrypt from 'bcryptjs';
|
||||||
import jwt from 'jsonwebtoken';
|
import jwt from 'jsonwebtoken';
|
||||||
@@ -129,7 +130,6 @@ setInterval(() => {
|
|||||||
}, 5 * 60 * 1000).unref?.();
|
}, 5 * 60 * 1000).unref?.();
|
||||||
|
|
||||||
export interface ResetPasswordOutcome {
|
export interface ResetPasswordOutcome {
|
||||||
error?: string;
|
|
||||||
status?: number;
|
status?: number;
|
||||||
success?: boolean;
|
success?: boolean;
|
||||||
/** When true the client must collect a TOTP/backup code and call again. */
|
/** When true the client must collect a TOTP/backup code and call again. */
|
||||||
@@ -410,28 +410,28 @@ export class AuthService {
|
|||||||
// Auth: register, login, demo
|
// Auth: register, login, demo
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
async demoLogin(client?: SessionClient): Promise<{ error?: string; status?: number; token?: string; user?: Record<string, unknown> }> {
|
async demoLogin(client?: SessionClient): Promise<{ token?: string; user?: Record<string, unknown> }> {
|
||||||
if (!readEnv().demo.enabled) {
|
if (!readEnv().demo.enabled) {
|
||||||
return { error: 'Not found', status: 404 };
|
throw new DomainError(404, 'Not found');
|
||||||
}
|
}
|
||||||
const user = await this.usersRepo.findByEmailExact(DEMO_EMAIL_PRIMARY);
|
const user = await this.usersRepo.findByEmailExact(DEMO_EMAIL_PRIMARY);
|
||||||
if (!user) return { error: 'Demo user not found', status: 500 };
|
if (!user) throw new DomainError(500, 'Demo user not found');
|
||||||
const token = await this.generateToken(user, undefined, client);
|
const token = await this.generateToken(user, undefined, client);
|
||||||
const safe = stripUserForClient(toClientUser(user)) as Record<string, unknown>;
|
const safe = stripUserForClient(toClientUser(user)) as Record<string, unknown>;
|
||||||
return { token, user: { ...safe, avatar_url: avatarUrl(user) } };
|
return { token, user: { ...safe, avatar_url: avatarUrl(user) } };
|
||||||
}
|
}
|
||||||
|
|
||||||
async validateInviteToken(token: string): Promise<{ error?: string; status?: number; valid?: boolean; max_uses?: number; used_count?: number; expires_at?: string | null }> {
|
async validateInviteToken(token: string): Promise<{ valid?: boolean; max_uses?: number; used_count?: number; expires_at?: string | null }> {
|
||||||
const invite = await this.inviteTokens.findByToken(token);
|
const invite = await this.inviteTokens.findByToken(token);
|
||||||
if (!invite) return { error: 'Invalid invite link', status: 404 };
|
if (!invite) throw new DomainError(404, 'Invalid invite link');
|
||||||
if (invite.max_uses > 0 && invite.used_count >= invite.max_uses) return { error: 'Invite link has been fully used', status: 410 };
|
if (invite.max_uses > 0 && invite.used_count >= invite.max_uses) throw new DomainError(410, 'Invite link has been fully used');
|
||||||
if (invite.expires_at && new Date(invite.expires_at) < new Date()) return { error: 'Invite link has expired', status: 410 };
|
if (invite.expires_at && new Date(invite.expires_at) < new Date()) throw new DomainError(410, 'Invite link has expired');
|
||||||
// A nullable column stays null on the wire: `?? undefined` would make
|
// A nullable column stays null on the wire: `?? undefined` would make
|
||||||
// JSON.stringify drop the key for a never-expiring invite.
|
// JSON.stringify drop the key for a never-expiring invite.
|
||||||
return { valid: true, max_uses: invite.max_uses, used_count: invite.used_count, expires_at: invite.expires_at };
|
return { valid: true, max_uses: invite.max_uses, used_count: invite.used_count, expires_at: invite.expires_at };
|
||||||
}
|
}
|
||||||
|
|
||||||
async registerUser(rawBody: unknown, client?: SessionClient): Promise<{ error?: string; status?: number; token?: string; user?: Record<string, unknown>; auditUserId?: number; auditDetails?: Record<string, unknown> }> {
|
async registerUser(rawBody: unknown, client?: SessionClient): Promise<{ token?: string; user?: Record<string, unknown>; auditUserId?: number; auditDetails?: Record<string, unknown> }> {
|
||||||
const body = rawBody as { username?: string; email?: string; password?: string; invite_token?: string };
|
const body = rawBody as { username?: string; email?: string; password?: string; invite_token?: string };
|
||||||
const username = typeof body.username === 'string' ? body.username.trim() : '';
|
const username = typeof body.username === 'string' ? body.username.trim() : '';
|
||||||
const email = typeof body.email === 'string' ? body.email.trim() : '';
|
const email = typeof body.email === 'string' ? body.email.trim() : '';
|
||||||
@@ -442,27 +442,27 @@ export class AuthService {
|
|||||||
let validInvite: Awaited<ReturnType<InviteTokensRepository['findByToken']>> = null;
|
let validInvite: Awaited<ReturnType<InviteTokensRepository['findByToken']>> = null;
|
||||||
if (invite_token) {
|
if (invite_token) {
|
||||||
validInvite = await this.inviteTokens.findByToken(invite_token);
|
validInvite = await this.inviteTokens.findByToken(invite_token);
|
||||||
if (!validInvite) return { error: 'Invalid invite link', status: 400 };
|
if (!validInvite) throw new DomainError(400, 'Invalid invite link');
|
||||||
if (validInvite.max_uses > 0 && validInvite.used_count >= validInvite.max_uses) return { error: 'Invite link has been fully used', status: 410 };
|
if (validInvite.max_uses > 0 && validInvite.used_count >= validInvite.max_uses) throw new DomainError(410, 'Invite link has been fully used');
|
||||||
if (validInvite.expires_at && new Date(validInvite.expires_at) < new Date()) return { error: 'Invite link has expired', status: 410 };
|
if (validInvite.expires_at && new Date(validInvite.expires_at) < new Date()) throw new DomainError(410, 'Invite link has expired');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (userCount > 0 && !validInvite) {
|
if (userCount > 0 && !validInvite) {
|
||||||
const toggles = await this.resolveAuthToggles();
|
const toggles = await this.resolveAuthToggles();
|
||||||
if (!toggles.password_registration) {
|
if (!toggles.password_registration) {
|
||||||
return { error: 'Password registration is disabled. Contact your administrator.', status: 403 };
|
throw new DomainError(403, 'Password registration is disabled. Contact your administrator.');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!username || !email || !password) {
|
if (!username || !email || !password) {
|
||||||
return { error: 'Username, email and password are required', status: 400 };
|
throw new DomainError(400, 'Username, email and password are required');
|
||||||
}
|
}
|
||||||
|
|
||||||
const pwCheck = validatePassword(password);
|
const pwCheck = validatePassword(password);
|
||||||
if (!pwCheck.ok) return { error: pwCheck.reason, status: 400 };
|
if (!pwCheck.ok) throw new DomainError(400, pwCheck.reason);
|
||||||
|
|
||||||
if (!EMAIL_REGEX.test(email)) {
|
if (!EMAIL_REGEX.test(email)) {
|
||||||
return { error: 'Invalid email format', status: 400 };
|
throw new DomainError(400, 'Invalid email format');
|
||||||
}
|
}
|
||||||
|
|
||||||
const password_hash = bcrypt.hashSync(password, BCRYPT_COST);
|
const password_hash = bcrypt.hashSync(password, BCRYPT_COST);
|
||||||
@@ -476,7 +476,7 @@ export class AuthService {
|
|||||||
// Ignore guests (#1362): their synthetic username/email must never block a real signup.
|
// Ignore guests (#1362): their synthetic username/email must never block a real signup.
|
||||||
const existingUserId = await this.usersRepo.findIdByEmailOrUsernameCI(email, username);
|
const existingUserId = await this.usersRepo.findIdByEmailOrUsernameCI(email, username);
|
||||||
if (existingUserId !== null) {
|
if (existingUserId !== null) {
|
||||||
return { error: 'Registration failed. Please try different credentials.', status: 409 };
|
throw new DomainError(409, 'Registration failed. Please try different credentials.');
|
||||||
}
|
}
|
||||||
const isFirstUser = (await this.usersRepo.countNonGuest()) === 0;
|
const isFirstUser = (await this.usersRepo.countNonGuest()) === 0;
|
||||||
const role = isFirstUser ? 'admin' : 'user';
|
const role = isFirstUser ? 'admin' : 'user';
|
||||||
@@ -509,10 +509,11 @@ export class AuthService {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
// The check above runs in the transaction, but a concurrent signup can
|
// The check above runs in the transaction, but a concurrent signup can
|
||||||
// still win the insert; the database's unique indexes refuse the second.
|
// still win the insert; the database's unique indexes refuse the second.
|
||||||
|
if (err instanceof DomainError) throw err;
|
||||||
if (err instanceof UserIdentityTakenError) {
|
if (err instanceof UserIdentityTakenError) {
|
||||||
return { error: 'Registration failed. Please try different credentials.', status: 409 };
|
throw new DomainError(409, 'Registration failed. Please try different credentials.');
|
||||||
}
|
}
|
||||||
return { error: 'Error creating user', status: 500 };
|
throw new DomainError(500, 'Error creating user');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -623,25 +624,25 @@ export class AuthService {
|
|||||||
remember?: boolean,
|
remember?: boolean,
|
||||||
client?: SessionClient,
|
client?: SessionClient,
|
||||||
issueSession = true,
|
issueSession = true,
|
||||||
): Promise<{ error?: string; status?: number; success?: boolean; token?: string }> {
|
): Promise<{ success?: boolean; token?: string }> {
|
||||||
const body = rawBody as { current_password?: string; new_password?: string };
|
const body = rawBody as { current_password?: string; new_password?: string };
|
||||||
if (await this.isOidcOnlyMode()) {
|
if (await this.isOidcOnlyMode()) {
|
||||||
return { error: 'Password authentication is disabled.', status: 403 };
|
throw new DomainError(403, 'Password authentication is disabled.');
|
||||||
}
|
}
|
||||||
if (readEnv().demo.enabled && isDemoEmail(userEmail)) {
|
if (readEnv().demo.enabled && isDemoEmail(userEmail)) {
|
||||||
return { error: 'Password change is disabled in demo mode.', status: 403 };
|
throw new DomainError(403, 'Password change is disabled in demo mode.');
|
||||||
}
|
}
|
||||||
|
|
||||||
const { current_password, new_password } = body;
|
const { current_password, new_password } = body;
|
||||||
if (!current_password) return { error: 'Current password is required', status: 400 };
|
if (!current_password) throw new DomainError(400, 'Current password is required');
|
||||||
if (!new_password) return { error: 'New password is required', status: 400 };
|
if (!new_password) throw new DomainError(400, 'New password is required');
|
||||||
|
|
||||||
const pwCheck = validatePassword(new_password);
|
const pwCheck = validatePassword(new_password);
|
||||||
if (!pwCheck.ok) return { error: pwCheck.reason, status: 400 };
|
if (!pwCheck.ok) throw new DomainError(400, pwCheck.reason);
|
||||||
|
|
||||||
const user = await this.usersRepo.getPasswordHashAndVersion(userId);
|
const user = await this.usersRepo.getPasswordHashAndVersion(userId);
|
||||||
if (!user || !bcrypt.compareSync(current_password, user.password_hash)) {
|
if (!user || !bcrypt.compareSync(current_password, user.password_hash)) {
|
||||||
return { error: 'Current password is incorrect', status: 401 };
|
throw new DomainError(401, 'Current password is incorrect');
|
||||||
}
|
}
|
||||||
|
|
||||||
const hash = bcrypt.hashSync(new_password, BCRYPT_COST);
|
const hash = bcrypt.hashSync(new_password, BCRYPT_COST);
|
||||||
@@ -671,14 +672,14 @@ export class AuthService {
|
|||||||
return { success: true, token };
|
return { success: true, token };
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteAccount(userId: number, userEmail: string, userRole: string): Promise<{ error?: string; status?: number; success?: boolean }> {
|
async deleteAccount(userId: number, userEmail: string, userRole: string): Promise<{ success?: boolean }> {
|
||||||
if (readEnv().demo.enabled && isDemoEmail(userEmail)) {
|
if (readEnv().demo.enabled && isDemoEmail(userEmail)) {
|
||||||
return { error: 'Account deletion is disabled in demo mode.', status: 403 };
|
throw new DomainError(403, 'Account deletion is disabled in demo mode.');
|
||||||
}
|
}
|
||||||
if (userRole === 'admin') {
|
if (userRole === 'admin') {
|
||||||
const adminCount = await this.usersRepo.countAdmins();
|
const adminCount = await this.usersRepo.countAdmins();
|
||||||
if (adminCount <= 1) {
|
if (adminCount <= 1) {
|
||||||
return { error: 'Cannot delete the last admin account', status: 400 };
|
throw new DomainError(400, 'Cannot delete the last admin account');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
await this.userCleanup.deleteUserCompletely(userId);
|
await this.userCleanup.deleteUserCompletely(userId);
|
||||||
@@ -700,9 +701,9 @@ export class AuthService {
|
|||||||
// work, not with a move.
|
// work, not with a move.
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
async getAppSettings(userId: number): Promise<{ error?: string; status?: number; data?: Record<string, string> }> {
|
async getAppSettings(userId: number): Promise<{ data?: Record<string, string> }> {
|
||||||
const role = await this.usersRepo.getRole(userId);
|
const role = await this.usersRepo.getRole(userId);
|
||||||
if (role !== 'admin') return { error: 'Admin access required', status: 403 };
|
if (role !== 'admin') throw new DomainError(403, 'Admin access required');
|
||||||
|
|
||||||
const result: Record<string, string> = {};
|
const result: Record<string, string> = {};
|
||||||
for (const key of ADMIN_FORM_SETTING_KEYS) {
|
for (const key of ADMIN_FORM_SETTING_KEYS) {
|
||||||
@@ -716,8 +717,6 @@ export class AuthService {
|
|||||||
userId: number,
|
userId: number,
|
||||||
rawBody: unknown
|
rawBody: unknown
|
||||||
): Promise<{
|
): Promise<{
|
||||||
error?: string;
|
|
||||||
status?: number;
|
|
||||||
success?: boolean;
|
success?: boolean;
|
||||||
auditSummary?: Record<string, unknown>;
|
auditSummary?: Record<string, unknown>;
|
||||||
auditDebugDetails?: Record<string, unknown>;
|
auditDebugDetails?: Record<string, unknown>;
|
||||||
@@ -726,7 +725,7 @@ export class AuthService {
|
|||||||
}> {
|
}> {
|
||||||
const body = rawBody as Record<string, unknown>;
|
const body = rawBody as Record<string, unknown>;
|
||||||
const role = await this.usersRepo.getRole(userId);
|
const role = await this.usersRepo.getRole(userId);
|
||||||
if (role !== 'admin') return { error: 'Admin access required', status: 403 };
|
if (role !== 'admin') throw new DomainError(403, 'Admin access required');
|
||||||
|
|
||||||
const { require_mfa } = body;
|
const { require_mfa } = body;
|
||||||
if (require_mfa === true || require_mfa === 'true') {
|
if (require_mfa === true || require_mfa === 'true') {
|
||||||
@@ -735,10 +734,7 @@ export class AuthService {
|
|||||||
// their own account with a passkey may enable it too (not only TOTP).
|
// their own account with a passkey may enable it too (not only TOTP).
|
||||||
const adminHasPasskey = await this.webauthnCredentials.hasAny(userId);
|
const adminHasPasskey = await this.webauthnCredentials.hasAny(userId);
|
||||||
if (!(adminMfa?.mfa_enabled === 1) && !adminHasPasskey) {
|
if (!(adminMfa?.mfa_enabled === 1) && !adminHasPasskey) {
|
||||||
return {
|
throw new DomainError(400, 'Secure your own account with two-factor authentication or a passkey before requiring it for all users.');
|
||||||
error: 'Secure your own account with two-factor authentication or a passkey before requiring it for all users.',
|
|
||||||
status: 400,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -749,7 +745,7 @@ export class AuthService {
|
|||||||
const nextPasswordLogin = body.password_login !== undefined ? (String(body.password_login) === 'true') : current.password_login;
|
const nextPasswordLogin = body.password_login !== undefined ? (String(body.password_login) === 'true') : current.password_login;
|
||||||
const nextOidcLogin = body.oidc_login !== undefined ? (String(body.oidc_login) === 'true') : current.oidc_login;
|
const nextOidcLogin = body.oidc_login !== undefined ? (String(body.oidc_login) === 'true') : current.oidc_login;
|
||||||
if (!nextPasswordLogin && (!nextOidcLogin || !oidcConfigured)) {
|
if (!nextPasswordLogin && (!nextOidcLogin || !oidcConfigured)) {
|
||||||
return { error: 'Cannot disable all login methods. At least one must remain enabled.', status: 400 };
|
throw new DomainError(400, 'Cannot disable all login methods. At least one must remain enabled.');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -807,13 +803,13 @@ export class AuthService {
|
|||||||
// MFA
|
// MFA
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
async setupMfa(userId: number, userEmail: string): Promise<{ error?: string; status?: number; secret?: string; otpauth_url?: string; qrPromise?: Promise<string> }> {
|
async setupMfa(userId: number, userEmail: string): Promise<{ secret?: string; otpauth_url?: string; qrPromise?: Promise<string> }> {
|
||||||
if (readEnv().demo.enabled && isDemoEmail(userEmail)) {
|
if (readEnv().demo.enabled && isDemoEmail(userEmail)) {
|
||||||
return { error: 'MFA is not available in demo mode.', status: 403 };
|
throw new DomainError(403, 'MFA is not available in demo mode.');
|
||||||
}
|
}
|
||||||
const row = await this.usersRepo.getMfaEnabled(userId);
|
const row = await this.usersRepo.getMfaEnabled(userId);
|
||||||
if (row?.mfa_enabled) {
|
if (row?.mfa_enabled) {
|
||||||
return { error: 'MFA is already enabled', status: 400 };
|
throw new DomainError(400, 'MFA is already enabled');
|
||||||
}
|
}
|
||||||
let secret: string, otpauth_url: string;
|
let secret: string, otpauth_url: string;
|
||||||
try {
|
try {
|
||||||
@@ -822,24 +818,24 @@ export class AuthService {
|
|||||||
otpauth_url = authenticator.keyuri(userEmail, 'TREK', secret);
|
otpauth_url = authenticator.keyuri(userEmail, 'TREK', secret);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[MFA] Setup error:', err);
|
console.error('[MFA] Setup error:', err);
|
||||||
return { error: 'MFA setup failed', status: 500 };
|
throw new DomainError(500, 'MFA setup failed');
|
||||||
}
|
}
|
||||||
return { secret, otpauth_url, qrPromise: QRCode.toString(otpauth_url, { type: 'svg', width: 250 }) };
|
return { secret, otpauth_url, qrPromise: QRCode.toString(otpauth_url, { type: 'svg', width: 250 }) };
|
||||||
}
|
}
|
||||||
|
|
||||||
async enableMfa(userId: number, rawCode: unknown): Promise<{ error?: string; status?: number; success?: boolean; mfa_enabled?: boolean; backup_codes?: string[] }> {
|
async enableMfa(userId: number, rawCode: unknown): Promise<{ success?: boolean; mfa_enabled?: boolean; backup_codes?: string[] }> {
|
||||||
const code = rawCode as string | undefined;
|
const code = rawCode as string | undefined;
|
||||||
if (!code) {
|
if (!code) {
|
||||||
return { error: 'Verification code is required', status: 400 };
|
throw new DomainError(400, 'Verification code is required');
|
||||||
}
|
}
|
||||||
const pending = this.getPendingMfaSecret(userId);
|
const pending = this.getPendingMfaSecret(userId);
|
||||||
if (!pending) {
|
if (!pending) {
|
||||||
return { error: 'No MFA setup in progress. Start the setup again.', status: 400 };
|
throw new DomainError(400, 'No MFA setup in progress. Start the setup again.');
|
||||||
}
|
}
|
||||||
const tokenStr = String(code).replace(/\s/g, '');
|
const tokenStr = String(code).replace(/\s/g, '');
|
||||||
const ok = authenticator.verify({ token: tokenStr, secret: pending });
|
const ok = authenticator.verify({ token: tokenStr, secret: pending });
|
||||||
if (!ok) {
|
if (!ok) {
|
||||||
return { error: 'Invalid verification code', status: 401 };
|
throw new DomainError(401, 'Invalid verification code');
|
||||||
}
|
}
|
||||||
const backupCodes = generateBackupCodes();
|
const backupCodes = generateBackupCodes();
|
||||||
const backupHashes = backupCodes.map(hashBackupCodeBcrypt);
|
const backupHashes = backupCodes.map(hashBackupCodeBcrypt);
|
||||||
@@ -854,31 +850,31 @@ export class AuthService {
|
|||||||
userEmail: string,
|
userEmail: string,
|
||||||
rawBody: unknown,
|
rawBody: unknown,
|
||||||
currentSessionId?: string,
|
currentSessionId?: string,
|
||||||
): Promise<{ error?: string; status?: number; success?: boolean; mfa_enabled?: boolean }> {
|
): Promise<{ success?: boolean; mfa_enabled?: boolean }> {
|
||||||
const body = rawBody as { password?: string; code?: string };
|
const body = rawBody as { password?: string; code?: string };
|
||||||
if (readEnv().demo.enabled && isDemoEmail(userEmail)) {
|
if (readEnv().demo.enabled && isDemoEmail(userEmail)) {
|
||||||
return { error: 'MFA cannot be changed in demo mode.', status: 403 };
|
throw new DomainError(403, 'MFA cannot be changed in demo mode.');
|
||||||
}
|
}
|
||||||
const policy = await readAppSetting(this.appSettings, 'require_mfa');
|
const policy = await readAppSetting(this.appSettings, 'require_mfa');
|
||||||
if (policy === 'true') {
|
if (policy === 'true') {
|
||||||
return { error: 'Two-factor authentication cannot be disabled while it is required for all users.', status: 403 };
|
throw new DomainError(403, 'Two-factor authentication cannot be disabled while it is required for all users.');
|
||||||
}
|
}
|
||||||
const { password, code } = body;
|
const { password, code } = body;
|
||||||
if (!password || !code) {
|
if (!password || !code) {
|
||||||
return { error: 'Password and authenticator code are required', status: 400 };
|
throw new DomainError(400, 'Password and authenticator code are required');
|
||||||
}
|
}
|
||||||
const user = await this.usersRepo.findById(userId);
|
const user = await this.usersRepo.findById(userId);
|
||||||
if (!user?.mfa_enabled || !user.mfa_secret) {
|
if (!user?.mfa_enabled || !user.mfa_secret) {
|
||||||
return { error: 'MFA is not enabled', status: 400 };
|
throw new DomainError(400, 'MFA is not enabled');
|
||||||
}
|
}
|
||||||
if (!user.password_hash || !bcrypt.compareSync(password, user.password_hash)) {
|
if (!user.password_hash || !bcrypt.compareSync(password, user.password_hash)) {
|
||||||
return { error: 'Incorrect password', status: 401 };
|
throw new DomainError(401, 'Incorrect password');
|
||||||
}
|
}
|
||||||
const secret = decryptMfaSecret(user.mfa_secret);
|
const secret = decryptMfaSecret(user.mfa_secret);
|
||||||
const tokenStr = String(code).replace(/\s/g, '');
|
const tokenStr = String(code).replace(/\s/g, '');
|
||||||
const ok = authenticator.verify({ token: tokenStr, secret });
|
const ok = authenticator.verify({ token: tokenStr, secret });
|
||||||
if (!ok) {
|
if (!ok) {
|
||||||
return { error: 'Invalid verification code', status: 401 };
|
throw new DomainError(401, 'Invalid verification code');
|
||||||
}
|
}
|
||||||
// Every other session ends with the second factor; this one just proved both.
|
// Every other session ends with the second factor; this one just proved both.
|
||||||
await this.uow.transactional(async () => {
|
await this.uow.transactional(async () => {
|
||||||
@@ -890,8 +886,6 @@ export class AuthService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async verifyMfaLogin(rawBody: unknown, client?: SessionClient): Promise<{
|
async verifyMfaLogin(rawBody: unknown, client?: SessionClient): Promise<{
|
||||||
error?: string;
|
|
||||||
status?: number;
|
|
||||||
token?: string;
|
token?: string;
|
||||||
user?: Record<string, unknown>;
|
user?: Record<string, unknown>;
|
||||||
remember?: boolean;
|
remember?: boolean;
|
||||||
@@ -901,16 +895,16 @@ export class AuthService {
|
|||||||
const { mfa_token, code, remember_me } = body;
|
const { mfa_token, code, remember_me } = body;
|
||||||
const remember = remember_me === true;
|
const remember = remember_me === true;
|
||||||
if (!mfa_token || !code) {
|
if (!mfa_token || !code) {
|
||||||
return { error: 'Verification token and code are required', status: 400 };
|
throw new DomainError(400, 'Verification token and code are required');
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const decoded = jwt.verify(mfa_token, JWT_SECRET, { algorithms: ['HS256'] }) as { id: number; purpose?: string };
|
const decoded = jwt.verify(mfa_token, JWT_SECRET, { algorithms: ['HS256'] }) as { id: number; purpose?: string };
|
||||||
if (decoded.purpose !== 'mfa_login') {
|
if (decoded.purpose !== 'mfa_login') {
|
||||||
return { error: 'Invalid verification token', status: 401 };
|
throw new DomainError(401, 'Invalid verification token');
|
||||||
}
|
}
|
||||||
const user = await this.usersRepo.findById(decoded.id);
|
const user = await this.usersRepo.findById(decoded.id);
|
||||||
if (!user || user.mfa_enabled !== 1 || !user.mfa_secret) {
|
if (!user || user.mfa_enabled !== 1 || !user.mfa_secret) {
|
||||||
return { error: 'Invalid session', status: 401 };
|
throw new DomainError(401, 'Invalid session');
|
||||||
}
|
}
|
||||||
const secret = decryptMfaSecret(user.mfa_secret);
|
const secret = decryptMfaSecret(user.mfa_secret);
|
||||||
const tokenStr = String(code).trim();
|
const tokenStr = String(code).trim();
|
||||||
@@ -921,7 +915,7 @@ export class AuthService {
|
|||||||
// any store older than the bcrypt migration keeps working.
|
// any store older than the bcrypt migration keeps working.
|
||||||
const idx = hashes.findIndex((h) => matchBackupCode(tokenStr, h));
|
const idx = hashes.findIndex((h) => matchBackupCode(tokenStr, h));
|
||||||
if (idx === -1) {
|
if (idx === -1) {
|
||||||
return { error: 'Invalid verification code', status: 401 };
|
throw new DomainError(401, 'Invalid verification code');
|
||||||
}
|
}
|
||||||
hashes.splice(idx, 1);
|
hashes.splice(idx, 1);
|
||||||
// Consume the backup code and record the login atomically — the code
|
// Consume the backup code and record the login atomically — the code
|
||||||
@@ -941,8 +935,9 @@ export class AuthService {
|
|||||||
remember,
|
remember,
|
||||||
auditUserId: Number(user.id),
|
auditUserId: Number(user.id),
|
||||||
};
|
};
|
||||||
} catch {
|
} catch (err) {
|
||||||
return { error: 'Invalid or expired verification token', status: 401 };
|
if (err instanceof DomainError) throw err;
|
||||||
|
throw new DomainError(401, 'Invalid or expired verification token');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1021,28 +1016,28 @@ export class AuthService {
|
|||||||
const body = rawBody as { token?: string; new_password?: string; mfa_code?: string };
|
const body = rawBody as { token?: string; new_password?: string; mfa_code?: string };
|
||||||
const { token, new_password, mfa_code } = body;
|
const { token, new_password, mfa_code } = body;
|
||||||
if (!token || typeof token !== 'string') {
|
if (!token || typeof token !== 'string') {
|
||||||
return { error: 'Reset token is required', status: 400 };
|
throw new DomainError(400, 'Reset token is required');
|
||||||
}
|
}
|
||||||
if (!new_password || typeof new_password !== 'string') {
|
if (!new_password || typeof new_password !== 'string') {
|
||||||
return { error: 'New password is required', status: 400 };
|
throw new DomainError(400, 'New password is required');
|
||||||
}
|
}
|
||||||
// Check the policy BEFORE touching the token so an invalid password
|
// Check the policy BEFORE touching the token so an invalid password
|
||||||
// does not burn the user's one-time link.
|
// does not burn the user's one-time link.
|
||||||
const pwCheck = validatePassword(new_password);
|
const pwCheck = validatePassword(new_password);
|
||||||
if (!pwCheck.ok) return { error: pwCheck.reason!, status: 400 };
|
if (!pwCheck.ok) throw new DomainError(400, pwCheck.reason!);
|
||||||
|
|
||||||
const tokenHash = hashResetToken(token);
|
const tokenHash = hashResetToken(token);
|
||||||
const row = await this.passwordResetTokens.findByTokenHash(tokenHash);
|
const row = await this.passwordResetTokens.findByTokenHash(tokenHash);
|
||||||
|
|
||||||
if (!row) return { error: 'Invalid or expired reset link', status: 400 };
|
if (!row) throw new DomainError(400, 'Invalid or expired reset link');
|
||||||
if (row.consumed_at) return { error: 'This reset link has already been used', status: 400 };
|
if (row.consumed_at) throw new DomainError(400, 'This reset link has already been used');
|
||||||
if ((parseDbTimestamp(row.expires_at)?.getTime() ?? Number.NaN) < Date.now()) {
|
if ((parseDbTimestamp(row.expires_at)?.getTime() ?? Number.NaN) < Date.now()) {
|
||||||
return { error: 'Reset link has expired. Please request a new one.', status: 400 };
|
throw new DomainError(400, 'Reset link has expired. Please request a new one.');
|
||||||
}
|
}
|
||||||
|
|
||||||
const user = await this.usersRepo.findResetTarget(row.user_id);
|
const user = await this.usersRepo.findResetTarget(row.user_id);
|
||||||
|
|
||||||
if (!user) return { error: 'Invalid or expired reset link', status: 400 };
|
if (!user) throw new DomainError(400, 'Invalid or expired reset link');
|
||||||
|
|
||||||
// MFA gate. If enabled, require a valid TOTP or backup code.
|
// MFA gate. If enabled, require a valid TOTP or backup code.
|
||||||
const mfaOn = user.mfa_enabled === 1;
|
const mfaOn = user.mfa_enabled === 1;
|
||||||
@@ -1050,7 +1045,7 @@ export class AuthService {
|
|||||||
if (mfaOn) {
|
if (mfaOn) {
|
||||||
if (!user.mfa_secret) {
|
if (!user.mfa_secret) {
|
||||||
// Data inconsistency — fail closed.
|
// Data inconsistency — fail closed.
|
||||||
return { error: 'MFA is enabled but not configured. Contact your administrator.', status: 500 };
|
throw new DomainError(500, 'MFA is enabled but not configured. Contact your administrator.');
|
||||||
}
|
}
|
||||||
const supplied = typeof mfa_code === 'string' ? mfa_code.trim() : '';
|
const supplied = typeof mfa_code === 'string' ? mfa_code.trim() : '';
|
||||||
if (!supplied) return { mfa_required: true, status: 200 };
|
if (!supplied) return { mfa_required: true, status: 200 };
|
||||||
@@ -1060,7 +1055,7 @@ export class AuthService {
|
|||||||
if (!okTotp) {
|
if (!okTotp) {
|
||||||
const hashes = parseBackupCodeHashes(user.mfa_backup_codes);
|
const hashes = parseBackupCodeHashes(user.mfa_backup_codes);
|
||||||
const idx = hashes.findIndex((h) => matchBackupCode(supplied, h));
|
const idx = hashes.findIndex((h) => matchBackupCode(supplied, h));
|
||||||
if (idx === -1) return { error: 'Invalid MFA code', status: 401 };
|
if (idx === -1) throw new DomainError(401, 'Invalid MFA code');
|
||||||
backupCodeConsumedIndex = idx;
|
backupCodeConsumedIndex = idx;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,7 +53,6 @@ export class PasskeyController {
|
|||||||
// The Origin header is only a pre-ceremony sanity input (see PasskeyService)
|
// The Origin header is only a pre-ceremony sanity input (see PasskeyService)
|
||||||
// — the RP ID itself is never derived from request headers.
|
// — the RP ID itself is never derived from request headers.
|
||||||
const result = await this.passkeys.passkeyRegisterOptions(user.id, body?.password, req.headers.origin);
|
const result = await this.passkeys.passkeyRegisterOptions(user.id, body?.password, req.headers.origin);
|
||||||
if (result.error) throw new HttpException({ error: result.error }, result.status!);
|
|
||||||
return result.options;
|
return result.options;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -63,7 +62,6 @@ export class PasskeyController {
|
|||||||
@UseGuards(PasskeyEnabledGuard, JwtAuthGuard)
|
@UseGuards(PasskeyEnabledGuard, JwtAuthGuard)
|
||||||
async registerVerify(@CurrentUser() user: User, @Body() body: PasskeyRegisterVerifyDto, @Req() req: Request) {
|
async registerVerify(@CurrentUser() user: User, @Body() body: PasskeyRegisterVerifyDto, @Req() req: Request) {
|
||||||
const result = await this.passkeys.passkeyRegisterVerify(user.id, body);
|
const result = await this.passkeys.passkeyRegisterVerify(user.id, body);
|
||||||
if (result.error) throw new HttpException({ error: result.error }, result.status!);
|
|
||||||
await this.audit.writeAudit({ userId: user.id, action: 'user.passkey_register', ip: getClientIp(req) });
|
await this.audit.writeAudit({ userId: user.id, action: 'user.passkey_register', ip: getClientIp(req) });
|
||||||
return { success: true, credential: result.credential };
|
return { success: true, credential: result.credential };
|
||||||
}
|
}
|
||||||
@@ -76,7 +74,6 @@ export class PasskeyController {
|
|||||||
async loginOptions(@Req() req: Request) {
|
async loginOptions(@Req() req: Request) {
|
||||||
await this.limit('login', req, 10);
|
await this.limit('login', req, 10);
|
||||||
const result = await this.passkeys.passkeyLoginOptions(req.headers.origin);
|
const result = await this.passkeys.passkeyLoginOptions(req.headers.origin);
|
||||||
if (result.error) throw new HttpException({ error: result.error }, result.status!);
|
|
||||||
return result.options;
|
return result.options;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -112,8 +109,7 @@ export class PasskeyController {
|
|||||||
@Patch('credentials/:id')
|
@Patch('credentials/:id')
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
async rename(@CurrentUser() user: User, @Param('id') id: string, @Body() body: PasskeyRenameDto) {
|
async rename(@CurrentUser() user: User, @Param('id') id: string, @Body() body: PasskeyRenameDto) {
|
||||||
const result = await this.passkeys.renamePasskey(user.id, id, body?.name);
|
await this.passkeys.renamePasskey(user.id, id, body?.name);
|
||||||
if (result.error) throw new HttpException({ error: result.error }, result.status!);
|
|
||||||
return { success: true };
|
return { success: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -121,8 +117,7 @@ export class PasskeyController {
|
|||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
async remove(@CurrentUser() user: User, @Param('id') id: string, @Body() body: PasskeyDeleteDto, @Req() req: Request) {
|
async remove(@CurrentUser() user: User, @Param('id') id: string, @Body() body: PasskeyDeleteDto, @Req() req: Request) {
|
||||||
await this.limit('login', req, 5);
|
await this.limit('login', req, 5);
|
||||||
const result = await this.passkeys.deletePasskey(user.id, id, body?.password);
|
await this.passkeys.deletePasskey(user.id, id, body?.password);
|
||||||
if (result.error) throw new HttpException({ error: result.error }, result.status!);
|
|
||||||
await this.audit.writeAudit({ userId: user.id, action: 'user.passkey_delete', resource: String(id), ip: getClientIp(req) });
|
await this.audit.writeAudit({ userId: user.id, action: 'user.passkey_delete', resource: String(id), ip: getClientIp(req) });
|
||||||
return { success: true };
|
return { success: true };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { DomainError } from '../common/domain-error';
|
||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
import { InjectRepository } from '@mikro-orm/nestjs';
|
import { InjectRepository } from '@mikro-orm/nestjs';
|
||||||
import bcrypt from 'bcryptjs';
|
import bcrypt from 'bcryptjs';
|
||||||
@@ -41,6 +42,9 @@ const NOT_CONFIGURED = { error: 'Passkey login is not configured for this server
|
|||||||
// used to tell "no such credential" apart from "bad signature" (CWE-203).
|
// used to tell "no such credential" apart from "bad signature" (CWE-203).
|
||||||
const AUTH_FAILED = { error: 'Authentication failed', status: 401 } as const;
|
const AUTH_FAILED = { error: 'Authentication failed', status: 401 } as const;
|
||||||
|
|
||||||
|
/** One of the canned `{ error, status }` pairs above, raised. */
|
||||||
|
const refusal = (r: { error: string; status: number }) => new DomainError(r.status, r.error);
|
||||||
|
|
||||||
// Reference-compared sentinel (oidc invite_exhausted precedent): thrown inside
|
// Reference-compared sentinel (oidc invite_exhausted precedent): thrown inside
|
||||||
// the register transaction to keep the duplicate 409 distinct from the generic
|
// the register transaction to keep the duplicate 409 distinct from the generic
|
||||||
// insert-failure 400 without string-matching SQLite errors.
|
// insert-failure 400 without string-matching SQLite errors.
|
||||||
@@ -224,19 +228,19 @@ export class PasskeyService {
|
|||||||
userId: number,
|
userId: number,
|
||||||
password: string | undefined,
|
password: string | undefined,
|
||||||
requestOrigin?: string,
|
requestOrigin?: string,
|
||||||
): Promise<{ error?: string; status?: number; options?: Awaited<ReturnType<typeof generateRegistrationOptions>> }> {
|
): Promise<{ options?: Awaited<ReturnType<typeof generateRegistrationOptions>> }> {
|
||||||
const cfg = await this.webauthn.resolve();
|
const cfg = await this.webauthn.resolve();
|
||||||
if (!cfg) return { ...NOT_CONFIGURED };
|
if (!cfg) throw refusal(NOT_CONFIGURED);
|
||||||
if (this.originCannotVerify(cfg, requestOrigin)) return { ...NOT_CONFIGURED };
|
if (this.originCannotVerify(cfg, requestOrigin)) throw refusal(NOT_CONFIGURED);
|
||||||
|
|
||||||
const user = await this.users.findById(userId);
|
const user = await this.users.findById(userId);
|
||||||
if (!user) return { error: 'User not found', status: 404 };
|
if (!user) throw new DomainError(404, 'User not found');
|
||||||
|
|
||||||
// Re-authentication: a hijacked session must not be able to silently plant an
|
// Re-authentication: a hijacked session must not be able to silently plant an
|
||||||
// attacker-controlled passkey. Require the current password (parity with the
|
// attacker-controlled passkey. Require the current password (parity with the
|
||||||
// change-password / disable-MFA step-up).
|
// change-password / disable-MFA step-up).
|
||||||
if (!password || !user.password_hash || !bcrypt.compareSync(password, user.password_hash)) {
|
if (!password || !user.password_hash || !bcrypt.compareSync(password, user.password_hash)) {
|
||||||
return { error: 'Incorrect password', status: 401 };
|
throw new DomainError(401, 'Incorrect password');
|
||||||
}
|
}
|
||||||
|
|
||||||
const existing = await this.webauthnCredentials.listExcludeCredentials(userId);
|
const existing = await this.webauthnCredentials.listExcludeCredentials(userId);
|
||||||
@@ -264,20 +268,20 @@ export class PasskeyService {
|
|||||||
async passkeyRegisterVerify(
|
async passkeyRegisterVerify(
|
||||||
userId: number,
|
userId: number,
|
||||||
body: { attestationResponse?: unknown; name?: unknown },
|
body: { attestationResponse?: unknown; name?: unknown },
|
||||||
): Promise<{ error?: string; status?: number; success?: boolean; credential?: unknown }> {
|
): Promise<{ success?: boolean; credential?: unknown }> {
|
||||||
const cfg = await this.webauthn.resolve();
|
const cfg = await this.webauthn.resolve();
|
||||||
if (!cfg) return { ...NOT_CONFIGURED };
|
if (!cfg) throw refusal(NOT_CONFIGURED);
|
||||||
|
|
||||||
const resp = body?.attestationResponse;
|
const resp = body?.attestationResponse;
|
||||||
if (!resp) return { error: 'Invalid registration response', status: 400 };
|
if (!resp) throw new DomainError(400, 'Invalid registration response');
|
||||||
|
|
||||||
const challenge = challengeFromResponse(resp);
|
const challenge = challengeFromResponse(resp);
|
||||||
if (!challenge) return { error: 'Invalid registration response', status: 400 };
|
if (!challenge) throw new DomainError(400, 'Invalid registration response');
|
||||||
|
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const claimed = await this.claimChallenge(challenge, 'registration', now);
|
const claimed = await this.claimChallenge(challenge, 'registration', now);
|
||||||
if (!claimed || claimed.user_id !== userId) {
|
if (!claimed || claimed.user_id !== userId) {
|
||||||
return { error: 'Registration challenge expired. Please try again.', status: 400 };
|
throw new DomainError(400, 'Registration challenge expired. Please try again.');
|
||||||
}
|
}
|
||||||
|
|
||||||
const expectedOrigin = this.expectedOrigins(cfg, resp);
|
const expectedOrigin = this.expectedOrigins(cfg, resp);
|
||||||
@@ -296,11 +300,11 @@ export class PasskeyService {
|
|||||||
this.logger.warn(
|
this.logger.warn(
|
||||||
`Passkey registration rejected (expectedRPID=${cfg.rpID}, expectedOrigin=[${expectedOrigin.join(', ')}]): ${err instanceof Error ? err.message : String(err)}`,
|
`Passkey registration rejected (expectedRPID=${cfg.rpID}, expectedOrigin=[${expectedOrigin.join(', ')}]): ${err instanceof Error ? err.message : String(err)}`,
|
||||||
);
|
);
|
||||||
return { error: 'Could not register this passkey.', status: 400 };
|
throw new DomainError(400, 'Could not register this passkey.');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!verification.verified || !verification.registrationInfo) {
|
if (!verification.verified || !verification.registrationInfo) {
|
||||||
return { error: 'Could not register this passkey.', status: 400 };
|
throw new DomainError(400, 'Could not register this passkey.');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Persist ONLY the values the verifier vouches for — never anything parsed
|
// Persist ONLY the values the verifier vouches for — never anything parsed
|
||||||
@@ -329,9 +333,9 @@ export class PasskeyService {
|
|||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err === DUPLICATE_CREDENTIAL) {
|
if (err === DUPLICATE_CREDENTIAL) {
|
||||||
return { error: 'This passkey is already registered.', status: 409 };
|
throw new DomainError(409, 'This passkey is already registered.');
|
||||||
}
|
}
|
||||||
return { error: 'Could not register this passkey.', status: 400 };
|
throw new DomainError(400, 'Could not register this passkey.');
|
||||||
}
|
}
|
||||||
|
|
||||||
const created = (await this.webauthnCredentials.findCreatedCredential(credential.id))!;
|
const created = (await this.webauthnCredentials.findCreatedCredential(credential.id))!;
|
||||||
@@ -343,13 +347,11 @@ export class PasskeyService {
|
|||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
async passkeyLoginOptions(requestOrigin?: string): Promise<{
|
async passkeyLoginOptions(requestOrigin?: string): Promise<{
|
||||||
error?: string;
|
|
||||||
status?: number;
|
|
||||||
options?: Awaited<ReturnType<typeof generateAuthenticationOptions>>;
|
options?: Awaited<ReturnType<typeof generateAuthenticationOptions>>;
|
||||||
}> {
|
}> {
|
||||||
const cfg = await this.webauthn.resolve();
|
const cfg = await this.webauthn.resolve();
|
||||||
if (!cfg) return { ...NOT_CONFIGURED };
|
if (!cfg) throw refusal(NOT_CONFIGURED);
|
||||||
if (this.originCannotVerify(cfg, requestOrigin)) return { ...NOT_CONFIGURED };
|
if (this.originCannotVerify(cfg, requestOrigin)) throw refusal(NOT_CONFIGURED);
|
||||||
|
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
await this.purgeExpiredChallenges(now);
|
await this.purgeExpiredChallenges(now);
|
||||||
@@ -452,9 +454,9 @@ export class PasskeyService {
|
|||||||
return rows.map((r) => ({ ...r, backed_up: r.backed_up === 1 }));
|
return rows.map((r) => ({ ...r, backed_up: r.backed_up === 1 }));
|
||||||
}
|
}
|
||||||
|
|
||||||
async renamePasskey(userId: number, id: string, name: unknown): Promise<{ error?: string; status?: number; success?: boolean }> {
|
async renamePasskey(userId: number, id: string, name: unknown): Promise<{ success?: boolean }> {
|
||||||
const cleanName = sanitizeName(name);
|
const cleanName = sanitizeName(name);
|
||||||
if (!cleanName) return { error: 'Name is required', status: 400 };
|
if (!cleanName) throw new DomainError(400, 'Name is required');
|
||||||
// Convert, VALIDATE, and answer the legacy not-found before the
|
// Convert, VALIDATE, and answer the legacy not-found before the
|
||||||
// repository call (program rule 15): the legacy `UPDATE ... WHERE id = ?
|
// repository call (program rule 15): the legacy `UPDATE ... WHERE id = ?
|
||||||
// AND user_id = ?` bound `Number(id)` as a plain parameter — a
|
// AND user_id = ?` bound `Number(id)` as a plain parameter — a
|
||||||
@@ -466,10 +468,10 @@ export class PasskeyService {
|
|||||||
// reference and throws — a 500 where the legacy 404'd (Plan 3b Task 3
|
// reference and throws — a 500 where the legacy 404'd (Plan 3b Task 3
|
||||||
// review, F1).
|
// review, F1).
|
||||||
const rowId = toRowId(id);
|
const rowId = toRowId(id);
|
||||||
if (rowId === null) return { error: 'Passkey not found', status: 404 };
|
if (rowId === null) throw new DomainError(404, 'Passkey not found');
|
||||||
// Ownership enforced in SQL (404 on miss, never a 403 that leaks existence).
|
// Ownership enforced in SQL (404 on miss, never a 403 that leaks existence).
|
||||||
const changes = await this.webauthnCredentials.renameOwned(rowId, userId, cleanName);
|
const changes = await this.webauthnCredentials.renameOwned(rowId, userId, cleanName);
|
||||||
if (changes === 0) return { error: 'Passkey not found', status: 404 };
|
if (changes === 0) throw new DomainError(404, 'Passkey not found');
|
||||||
return { success: true };
|
return { success: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -477,27 +479,27 @@ export class PasskeyService {
|
|||||||
userId: number,
|
userId: number,
|
||||||
id: string,
|
id: string,
|
||||||
password: string | undefined,
|
password: string | undefined,
|
||||||
): Promise<{ error?: string; status?: number; success?: boolean }> {
|
): Promise<{ success?: boolean }> {
|
||||||
// Re-auth before removing a credential (a hijacked session must not be able to
|
// Re-auth before removing a credential (a hijacked session must not be able to
|
||||||
// strip the victim's passkeys). Deleting is always allowed because every
|
// strip the victim's passkeys). Deleting is always allowed because every
|
||||||
// account keeps a usable password as recovery fallback — losing all passkeys
|
// account keeps a usable password as recovery fallback — losing all passkeys
|
||||||
// can never lock anyone out.
|
// can never lock anyone out.
|
||||||
const passwordHash = await this.users.getPasswordHash(userId);
|
const passwordHash = await this.users.getPasswordHash(userId);
|
||||||
if (!passwordHash || !password || !bcrypt.compareSync(password, passwordHash)) {
|
if (!passwordHash || !password || !bcrypt.compareSync(password, passwordHash)) {
|
||||||
return { error: 'Incorrect password', status: 401 };
|
throw new DomainError(401, 'Incorrect password');
|
||||||
}
|
}
|
||||||
// Same guard as `renamePasskey` (F1) — placed after the password check
|
// Same guard as `renamePasskey` (F1) — placed after the password check
|
||||||
// to match the legacy statement order exactly (a wrong password still
|
// to match the legacy statement order exactly (a wrong password still
|
||||||
// answers 401 before a bad id is ever considered).
|
// answers 401 before a bad id is ever considered).
|
||||||
const rowId = toRowId(id);
|
const rowId = toRowId(id);
|
||||||
if (rowId === null) return { error: 'Passkey not found', status: 404 };
|
if (rowId === null) throw new DomainError(404, 'Passkey not found');
|
||||||
const changes = await this.webauthnCredentials.deleteOwned(rowId, userId);
|
const changes = await this.webauthnCredentials.deleteOwned(rowId, userId);
|
||||||
if (changes === 0) return { error: 'Passkey not found', status: 404 };
|
if (changes === 0) throw new DomainError(404, 'Passkey not found');
|
||||||
return { success: true };
|
return { success: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Admin: clear all of a user's passkeys (e.g. on suspected compromise). */
|
/** Admin: clear all of a user's passkeys (e.g. on suspected compromise). */
|
||||||
async adminResetPasskeys(targetUserId: number): Promise<{ error?: string; status?: number; success?: boolean; deleted?: number; email?: string }> {
|
async adminResetPasskeys(targetUserId: number): Promise<{ success?: boolean; deleted?: number; email?: string }> {
|
||||||
// `AdminService.resetUserPasskeys` converts the route param with a bare
|
// `AdminService.resetUserPasskeys` converts the route param with a bare
|
||||||
// `Number(id)` before calling in — a non-numeric id arrives here as
|
// `Number(id)` before calling in — a non-numeric id arrives here as
|
||||||
// `NaN`, still typed `number` at the JS level. Same F1 guard: validate
|
// `NaN`, still typed `number` at the JS level. Same F1 guard: validate
|
||||||
@@ -505,9 +507,9 @@ export class PasskeyService {
|
|||||||
// `SELECT id, email FROM users WHERE id = ?` bound `Number(id)` too and
|
// `SELECT id, email FROM users WHERE id = ?` bound `Number(id)` too and
|
||||||
// simply matched no row).
|
// simply matched no row).
|
||||||
const rowId = toRowId(targetUserId);
|
const rowId = toRowId(targetUserId);
|
||||||
if (rowId === null) return { error: 'User not found', status: 404 };
|
if (rowId === null) throw new DomainError(404, 'User not found');
|
||||||
const target = await this.users.findIdAndEmail(rowId);
|
const target = await this.users.findIdAndEmail(rowId);
|
||||||
if (!target) return { error: 'User not found', status: 404 };
|
if (!target) throw new DomainError(404, 'User not found');
|
||||||
const deleted = await this.webauthnCredentials.deleteAllForUser(rowId);
|
const deleted = await this.webauthnCredentials.deleteAllForUser(rowId);
|
||||||
return { success: true, deleted, email: target.email };
|
return { success: true, deleted, email: target.email };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { DomainError } from '../common/domain-error';
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { InjectRepository } from '@mikro-orm/nestjs';
|
import { InjectRepository } from '@mikro-orm/nestjs';
|
||||||
import crypto from 'crypto';
|
import crypto from 'crypto';
|
||||||
@@ -101,7 +102,7 @@ export class RegistrationInvitesService {
|
|||||||
const numericId = toRowId(id);
|
const numericId = toRowId(id);
|
||||||
// RI6 — the 404 check.
|
// RI6 — the 404 check.
|
||||||
if (numericId === null || (await this.inviteTokens.findIdById(numericId)) === null) {
|
if (numericId === null || (await this.inviteTokens.findIdById(numericId)) === null) {
|
||||||
return { error: 'Invite not found', status: 404 };
|
throw new DomainError(404, 'Invite not found');
|
||||||
}
|
}
|
||||||
// RI7.
|
// RI7.
|
||||||
await this.inviteTokens.deleteById(numericId);
|
await this.inviteTokens.deleteById(numericId);
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { DomainError } from '../common/domain-error';
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { InjectRepository } from '@mikro-orm/nestjs';
|
import { InjectRepository } from '@mikro-orm/nestjs';
|
||||||
import { readEnv, getAppUrl } from '../../app-config';
|
import { readEnv, getAppUrl } from '../../app-config';
|
||||||
@@ -184,29 +185,29 @@ export class UserProfileService {
|
|||||||
async updateSettings(
|
async updateSettings(
|
||||||
userId: number,
|
userId: number,
|
||||||
rawBody: unknown
|
rawBody: unknown
|
||||||
): Promise<{ error?: string; status?: number; success?: boolean; user?: Record<string, unknown>; changedKeys?: string[] }> {
|
): Promise<{ success?: boolean; user?: Record<string, unknown>; changedKeys?: string[] }> {
|
||||||
const body = rawBody as { maps_api_key?: string; openweather_api_key?: string; unsplash_api_key?: string; amap_api_key?: string; username?: string; email?: string };
|
const body = rawBody as { maps_api_key?: string; openweather_api_key?: string; unsplash_api_key?: string; amap_api_key?: string; username?: string; email?: string };
|
||||||
const { maps_api_key, openweather_api_key, unsplash_api_key, amap_api_key, username, email } = body;
|
const { maps_api_key, openweather_api_key, unsplash_api_key, amap_api_key, username, email } = body;
|
||||||
|
|
||||||
if (username !== undefined) {
|
if (username !== undefined) {
|
||||||
const trimmed = username.trim();
|
const trimmed = username.trim();
|
||||||
if (!trimmed || trimmed.length < 2 || trimmed.length > 50) {
|
if (!trimmed || trimmed.length < 2 || trimmed.length > 50) {
|
||||||
return { error: 'Username must be between 2 and 50 characters', status: 400 };
|
throw new DomainError(400, 'Username must be between 2 and 50 characters');
|
||||||
}
|
}
|
||||||
if (!/^[a-zA-Z0-9_.-]+$/.test(trimmed)) {
|
if (!/^[a-zA-Z0-9_.-]+$/.test(trimmed)) {
|
||||||
return { error: 'Username can only contain letters, numbers, underscores, dots and hyphens', status: 400 };
|
throw new DomainError(400, 'Username can only contain letters, numbers, underscores, dots and hyphens');
|
||||||
}
|
}
|
||||||
const conflict = await this.usersRepo.findIdByUsernameCI(trimmed, userId);
|
const conflict = await this.usersRepo.findIdByUsernameCI(trimmed, userId);
|
||||||
if (conflict) return { error: 'Username already taken', status: 409 };
|
if (conflict) throw new DomainError(409, 'Username already taken');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (email !== undefined) {
|
if (email !== undefined) {
|
||||||
const trimmed = email.trim();
|
const trimmed = email.trim();
|
||||||
if (!trimmed || !EMAIL_REGEX.test(trimmed)) {
|
if (!trimmed || !EMAIL_REGEX.test(trimmed)) {
|
||||||
return { error: 'Invalid email format', status: 400 };
|
throw new DomainError(400, 'Invalid email format');
|
||||||
}
|
}
|
||||||
const conflict = await this.usersRepo.findIdByEmailCI(trimmed, userId);
|
const conflict = await this.usersRepo.findIdByEmailCI(trimmed, userId);
|
||||||
if (conflict) return { error: 'Email already taken', status: 409 };
|
if (conflict) throw new DomainError(409, 'Email already taken');
|
||||||
}
|
}
|
||||||
|
|
||||||
// The name and email half of this body stays the user's own in every mode;
|
// The name and email half of this body stays the user's own in every mode;
|
||||||
@@ -241,7 +242,7 @@ export class UserProfileService {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
// Another write took the name or address between the check above and this one.
|
// Another write took the name or address between the check above and this one.
|
||||||
if (err instanceof UserIdentityTakenError) {
|
if (err instanceof UserIdentityTakenError) {
|
||||||
return { error: err.field === 'email' ? 'Email already taken' : 'Username already taken', status: 409 };
|
throw new DomainError(409, err.field === 'email' ? 'Email already taken' : 'Username already taken');
|
||||||
}
|
}
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
@@ -258,9 +259,9 @@ export class UserProfileService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async getSettings(userId: number): Promise<{ error?: string; status?: number; settings?: Record<string, unknown> }> {
|
async getSettings(userId: number): Promise<{ settings?: Record<string, unknown> }> {
|
||||||
const user = await this.usersRepo.getApiKeyColumns(userId);
|
const user = await this.usersRepo.getApiKeyColumns(userId);
|
||||||
if (user?.role !== 'admin') return { error: 'Admin access required', status: 403 };
|
if (user?.role !== 'admin') throw new DomainError(403, 'Admin access required');
|
||||||
|
|
||||||
// The one endpoint in the codebase that hands back a stored key in the
|
// The one endpoint in the codebase that hands back a stored key in the
|
||||||
// clear. That is fine when the admin pasted it in themselves and wrong when
|
// clear. That is fine when the admin pasted it in themselves and wrong when
|
||||||
@@ -348,9 +349,9 @@ export class UserProfileService {
|
|||||||
// Key validation
|
// Key validation
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
async validateKeys(userId: number): Promise<{ error?: string; status?: number; maps: boolean; weather: boolean; maps_details: null | { ok: boolean; status: number | null; status_text: string | null; error_message: string | null; error_status: string | null; error_raw: string | null } }> {
|
async validateKeys(userId: number): Promise<{ maps: boolean; weather: boolean; maps_details: null | { ok: boolean; status: number | null; status_text: string | null; error_message: string | null; error_status: string | null; error_raw: string | null } }> {
|
||||||
const user = await this.usersRepo.getRoleAndWeatherKey(userId);
|
const user = await this.usersRepo.getRoleAndWeatherKey(userId);
|
||||||
if (user?.role !== 'admin') return { error: 'Admin access required', status: 403, maps: false, weather: false, maps_details: null };
|
if (user?.role !== 'admin') throw new DomainError(403, 'Admin access required');
|
||||||
|
|
||||||
const result: {
|
const result: {
|
||||||
maps: boolean;
|
maps: boolean;
|
||||||
|
|||||||
@@ -79,14 +79,13 @@ export const conflict = (message: string, details?: Record<string, unknown>) =>
|
|||||||
* Runs `fn` and returns the DomainError it raised instead of throwing it; any
|
* Runs `fn` and returns the DomainError it raised instead of throwing it; any
|
||||||
* other error propagates. For the few callers that must act on a refusal before
|
* other error propagates. For the few callers that must act on a refusal before
|
||||||
* passing it on (an audit row, a constant-time pad) and for service tests.
|
* passing it on (an audit row, a constant-time pad) and for service tests.
|
||||||
|
* Test the result with `instanceof DomainError`.
|
||||||
*/
|
*/
|
||||||
export async function catchDomainError<T>(
|
export async function catchDomainError<T>(fn: () => Promise<T> | T): Promise<T | DomainError> {
|
||||||
fn: () => Promise<T> | T,
|
|
||||||
): Promise<{ ok: true; value: T } | { ok: false; error: DomainError }> {
|
|
||||||
try {
|
try {
|
||||||
return { ok: true, value: await fn() };
|
return await fn();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof DomainError) return { ok: false, error: err };
|
if (err instanceof DomainError) return err;
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { DomainError } from '../common/domain-error';
|
||||||
import crypto, { randomBytes, randomUUID } from 'crypto';
|
import crypto, { randomBytes, randomUUID } from 'crypto';
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { InjectRepository } from '@mikro-orm/nestjs';
|
import { InjectRepository } from '@mikro-orm/nestjs';
|
||||||
@@ -745,10 +746,10 @@ export class OauthService {
|
|||||||
|
|
||||||
async adminRevokeOAuthSession(id: string) {
|
async adminRevokeOAuthSession(id: string) {
|
||||||
const tokenId = toRowId(id);
|
const tokenId = toRowId(id);
|
||||||
if (tokenId === null) return { error: 'Session not found', status: 404 };
|
if (tokenId === null) throw new DomainError(404, 'Session not found');
|
||||||
|
|
||||||
const row = await this.tokens.findById(tokenId);
|
const row = await this.tokens.findById(tokenId);
|
||||||
if (!row) return { error: 'Session not found', status: 404 };
|
if (!row) throw new DomainError(404, 'Session not found');
|
||||||
await this.tokens.revokeById(tokenId);
|
await this.tokens.revokeById(tokenId);
|
||||||
revokeUserSessionsForClient(row.user_id, row.client_id);
|
revokeUserSessionsForClient(row.user_id, row.client_id);
|
||||||
return {};
|
return {};
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { DomainError } from '../common/domain-error';
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { InjectRepository } from '@mikro-orm/nestjs';
|
import { InjectRepository } from '@mikro-orm/nestjs';
|
||||||
import { randomBytes, createHash } from 'crypto';
|
import { randomBytes, createHash } from 'crypto';
|
||||||
@@ -108,13 +109,13 @@ export class TokenService {
|
|||||||
return await this.createToken(userId, rawName, 'api', scopes);
|
return await this.createToken(userId, rawName, 'api', scopes);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async createToken(userId: number, rawName: unknown, kind: TokenKind, scopes?: readonly string[]): Promise<{ error?: string; status?: number; token?: Record<string, unknown> }> {
|
private async createToken(userId: number, rawName: unknown, kind: TokenKind, scopes?: readonly string[]): Promise<{ token?: Record<string, unknown> }> {
|
||||||
const name = rawName as string | undefined;
|
const name = rawName as string | undefined;
|
||||||
if (!name?.trim()) return { error: 'Token name is required', status: 400 };
|
if (!name?.trim()) throw new DomainError(400, 'Token name is required');
|
||||||
if (name.trim().length > 100) return { error: 'Token name must be 100 characters or less', status: 400 };
|
if (name.trim().length > 100) throw new DomainError(400, 'Token name must be 100 characters or less');
|
||||||
|
|
||||||
const tokenCount = await this.tokens.countByUserAndKind(userId, kind);
|
const tokenCount = await this.tokens.countByUserAndKind(userId, kind);
|
||||||
if (tokenCount >= 10) return { error: 'Maximum of 10 tokens per user reached', status: 400 };
|
if (tokenCount >= 10) throw new DomainError(400, 'Maximum of 10 tokens per user reached');
|
||||||
|
|
||||||
const rawToken = 'trek_' + randomBytes(24).toString('hex');
|
const rawToken = 'trek_' + randomBytes(24).toString('hex');
|
||||||
const tokenHash = createHash('sha256').update(rawToken).digest('hex');
|
const tokenHash = createHash('sha256').update(rawToken).digest('hex');
|
||||||
@@ -162,7 +163,7 @@ export class TokenService {
|
|||||||
* happily delete a token the MCP panel manages, and the user would find a key
|
* happily delete a token the MCP panel manages, and the user would find a key
|
||||||
* missing from a screen they never opened.
|
* missing from a screen they never opened.
|
||||||
*/
|
*/
|
||||||
private async deleteToken(userId: number, tokenId: string, kind: TokenKind): Promise<{ error?: string; status?: number; success?: boolean }> {
|
private async deleteToken(userId: number, tokenId: string, kind: TokenKind): Promise<{ success?: boolean }> {
|
||||||
// Convert, VALIDATE, and answer the legacy not-found before any
|
// Convert, VALIDATE, and answer the legacy not-found before any
|
||||||
// repository call (program rule 15): the legacy statement bound
|
// repository call (program rule 15): the legacy statement bound
|
||||||
// `tokenId` straight into `WHERE id = ?` and let SQLite's affinity rules
|
// `tokenId` straight into `WHERE id = ?` and let SQLite's affinity rules
|
||||||
@@ -170,9 +171,9 @@ export class TokenService {
|
|||||||
// leniency, so a bare `Number()` turned a 404 into a 500 (Plan 3b Task 2
|
// leniency, so a bare `Number()` turned a 404 into a 500 (Plan 3b Task 2
|
||||||
// review, F1).
|
// review, F1).
|
||||||
const id = toRowId(tokenId);
|
const id = toRowId(tokenId);
|
||||||
if (id === null) return { error: 'Token not found', status: 404 };
|
if (id === null) throw new DomainError(404, 'Token not found');
|
||||||
const token = await this.tokens.findOwnedByKind(id, userId, kind);
|
const token = await this.tokens.findOwnedByKind(id, userId, kind);
|
||||||
if (!token) return { error: 'Token not found', status: 404 };
|
if (!token) throw new DomainError(404, 'Token not found');
|
||||||
await this.tokens.deleteById(id);
|
await this.tokens.deleteById(id);
|
||||||
// Best-effort, like the changePassword/resetPassword revocations: a session
|
// Best-effort, like the changePassword/resetPassword revocations: a session
|
||||||
// sweep failure must not turn a successful token delete into a 500.
|
// sweep failure must not turn a successful token delete into a 500.
|
||||||
@@ -184,12 +185,12 @@ export class TokenService {
|
|||||||
// Ephemeral tokens
|
// Ephemeral tokens
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
async createWsToken(userId: number): Promise<{ error?: string; status?: number; token?: string }> {
|
async createWsToken(userId: number): Promise<{ token?: string }> {
|
||||||
// Bind the ws-token to the user's current password_version so a token minted
|
// Bind the ws-token to the user's current password_version so a token minted
|
||||||
// before a password reset is rejected on connect (defence-in-depth session gate).
|
// before a password reset is rejected on connect (defence-in-depth session gate).
|
||||||
const pv = (await this.users.getPasswordVersion(userId)) ?? 0;
|
const pv = (await this.users.getPasswordVersion(userId)) ?? 0;
|
||||||
const token = this.ephemeral.create(userId, 'ws', { pv });
|
const token = this.ephemeral.create(userId, 'ws', { pv });
|
||||||
if (!token) return { error: 'Service unavailable', status: 503 };
|
if (!token) throw new DomainError(503, 'Service unavailable');
|
||||||
return { token };
|
return { token };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -222,9 +223,9 @@ export class TokenService {
|
|||||||
// Same guard as `deleteToken` above — convert, VALIDATE, answer the
|
// Same guard as `deleteToken` above — convert, VALIDATE, answer the
|
||||||
// legacy 404 before any repository call (F1).
|
// legacy 404 before any repository call (F1).
|
||||||
const numericId = toRowId(id);
|
const numericId = toRowId(id);
|
||||||
if (numericId === null) return { error: 'Token not found', status: 404 };
|
if (numericId === null) throw new DomainError(404, 'Token not found');
|
||||||
const token = await this.tokens.findBasic(numericId);
|
const token = await this.tokens.findBasic(numericId);
|
||||||
if (!token) return { error: 'Token not found', status: 404 };
|
if (!token) throw new DomainError(404, 'Token not found');
|
||||||
await this.tokens.deleteById(numericId);
|
await this.tokens.deleteById(numericId);
|
||||||
revokeUserSessions(token.user_id);
|
revokeUserSessions(token.user_id);
|
||||||
return {};
|
return {};
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { DomainError } from '../../src/nest/common/domain-error';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Awaits a service call and hands a `DomainError` refusal back as the
|
||||||
|
* `{ error, status }` result the service used to return, so a test can assert a
|
||||||
|
* refusal and a success through one shape. Any other rejection propagates.
|
||||||
|
*/
|
||||||
|
export async function asLegacyResult<T>(call: Promise<T> | T): Promise<T & { error?: string; status?: number }> {
|
||||||
|
try {
|
||||||
|
return (await call) as T & { error?: string; status?: number };
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof DomainError) {
|
||||||
|
return { ...err.details, error: err.publicMessage, status: err.getStatus() } as T & {
|
||||||
|
error?: string;
|
||||||
|
status?: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { DomainError } from '../../../src/nest/common/domain-error';
|
||||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||||
import { HttpException, NotFoundException } from '@nestjs/common';
|
import { HttpException, NotFoundException } from '@nestjs/common';
|
||||||
import type { Request } from 'express';
|
import type { Request } from 'express';
|
||||||
@@ -88,7 +89,7 @@ afterEach(() => { delete process.env.NODE_ENV; });
|
|||||||
describe('AdminController users', () => {
|
describe('AdminController users', () => {
|
||||||
it('lists, creates (201 + audit), maps an error', async () => {
|
it('lists, creates (201 + audit), maps an error', async () => {
|
||||||
expect(await adminCtl(svc({ listUsers: vi.fn().mockReturnValue([{ id: 1 }]) } as Partial<AdminService>)).listUsers()).toEqual({ users: [{ id: 1 }] });
|
expect(await adminCtl(svc({ listUsers: vi.fn().mockReturnValue([{ id: 1 }]) } as Partial<AdminService>)).listUsers()).toEqual({ users: [{ id: 1 }] });
|
||||||
expect(await thrown(() => adminCtl(svc({ createUser: vi.fn().mockReturnValue({ error: 'Email taken', status: 409 }) } as Partial<AdminService>)).createUser(user, {}, req))).toEqual({ status: 409, body: { error: 'Email taken' } });
|
expect(await thrown(() => adminCtl(svc({ createUser: vi.fn().mockRejectedValue(new DomainError(409, 'Email taken')) } as Partial<AdminService>)).createUser(user, {}, req))).toEqual({ status: 409, body: { error: 'Email taken' } });
|
||||||
const c = adminCtl(svc({ createUser: vi.fn().mockReturnValue({ user: { id: 2 }, insertedId: 2, auditDetails: {} }) } as Partial<AdminService>));
|
const c = adminCtl(svc({ createUser: vi.fn().mockReturnValue({ user: { id: 2 }, insertedId: 2, auditDetails: {} }) } as Partial<AdminService>));
|
||||||
expect(await c.createUser(user, { email: 'a@b.c' }, req)).toEqual({ user: { id: 2 } });
|
expect(await c.createUser(user, { email: 'a@b.c' }, req)).toEqual({ user: { id: 2 } });
|
||||||
expect(writeAudit).toHaveBeenCalledWith(expect.objectContaining({ action: 'admin.user_create' }));
|
expect(writeAudit).toHaveBeenCalledWith(expect.objectContaining({ action: 'admin.user_create' }));
|
||||||
@@ -96,7 +97,7 @@ describe('AdminController users', () => {
|
|||||||
|
|
||||||
it('update + delete audit and map errors', async () => {
|
it('update + delete audit and map errors', async () => {
|
||||||
expect(await adminCtl(svc({ updateUser: vi.fn().mockReturnValue({ user: { id: 2 }, previousEmail: 'a@b.c', changed: ['role'] }) } as Partial<AdminService>)).updateUser(user, '2', {}, req)).toEqual({ user: { id: 2 } });
|
expect(await adminCtl(svc({ updateUser: vi.fn().mockReturnValue({ user: { id: 2 }, previousEmail: 'a@b.c', changed: ['role'] }) } as Partial<AdminService>)).updateUser(user, '2', {}, req)).toEqual({ user: { id: 2 } });
|
||||||
expect(await thrown(() => adminCtl(svc({ deleteUser: vi.fn().mockReturnValue({ error: 'Cannot delete self', status: 400 }) } as Partial<AdminService>)).deleteUser(user, '1', req))).toEqual({ status: 400, body: { error: 'Cannot delete self' } });
|
expect(await thrown(() => adminCtl(svc({ deleteUser: vi.fn().mockRejectedValue(new DomainError(400, 'Cannot delete self')) } as Partial<AdminService>)).deleteUser(user, '1', req))).toEqual({ status: 400, body: { error: 'Cannot delete self' } });
|
||||||
expect(await adminCtl(svc({ deleteUser: vi.fn().mockReturnValue({ email: 'a@b.c' }) } as Partial<AdminService>)).deleteUser(user, '2', req)).toEqual({ success: true });
|
expect(await adminCtl(svc({ deleteUser: vi.fn().mockReturnValue({ email: 'a@b.c' }) } as Partial<AdminService>)).deleteUser(user, '2', req)).toEqual({ success: true });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -114,7 +115,7 @@ describe('AdminController permissions + oidc + misc', () => {
|
|||||||
|
|
||||||
|
|
||||||
it('save-demo-baseline maps error, else returns message', async () => {
|
it('save-demo-baseline maps error, else returns message', async () => {
|
||||||
expect(await thrown(() => adminCtl(svc({ saveDemoBaseline: vi.fn().mockReturnValue({ error: 'not demo', status: 400 }) } as Partial<AdminService>)).saveDemoBaseline(user, req))).toEqual({ status: 400, body: { error: 'not demo' } });
|
expect(await thrown(() => adminCtl(svc({ saveDemoBaseline: vi.fn().mockRejectedValue(new DomainError(400, 'not demo')) } as Partial<AdminService>)).saveDemoBaseline(user, req))).toEqual({ status: 400, body: { error: 'not demo' } });
|
||||||
expect(await adminCtl(svc({ saveDemoBaseline: vi.fn().mockReturnValue({ message: 'saved' }) } as Partial<AdminService>)).saveDemoBaseline(user, req)).toEqual({ success: true, message: 'saved' });
|
expect(await adminCtl(svc({ saveDemoBaseline: vi.fn().mockReturnValue({ message: 'saved' }) } as Partial<AdminService>)).saveDemoBaseline(user, req)).toEqual({ success: true, message: 'saved' });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -127,7 +128,7 @@ describe('AdminController invites', () => {
|
|||||||
const c = adminCtl(svc(), undefined, undefined, {}, { createInvite: vi.fn().mockReturnValue({ invite: { id: 5 }, inviteId: 5, uses: 1, expiresInDays: 7 }) });
|
const c = adminCtl(svc(), undefined, undefined, {}, { createInvite: vi.fn().mockReturnValue({ invite: { id: 5 }, inviteId: 5, uses: 1, expiresInDays: 7 }) });
|
||||||
expect(await c.createInvite(user, {}, req)).toEqual({ invite: { id: 5 } });
|
expect(await c.createInvite(user, {}, req)).toEqual({ invite: { id: 5 } });
|
||||||
expect(writeAudit).toHaveBeenCalledWith(expect.objectContaining({ action: 'admin.invite_create' }));
|
expect(writeAudit).toHaveBeenCalledWith(expect.objectContaining({ action: 'admin.invite_create' }));
|
||||||
expect(await thrown(() => adminCtl(svc(), undefined, undefined, {}, { deleteInvite: vi.fn().mockReturnValue({ error: 'not found', status: 404 }) }).deleteInvite(user, '5', req))).toEqual({ status: 404, body: { error: 'not found' } });
|
expect(await thrown(() => adminCtl(svc(), undefined, undefined, {}, { deleteInvite: vi.fn().mockRejectedValue(new DomainError(404, 'not found')) }).deleteInvite(user, '5', req))).toEqual({ status: 404, body: { error: 'not found' } });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
@@ -157,7 +158,7 @@ describe('AdminController addons + sessions + jwt + defaults', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('addon update maps the journey-dependency refusal to its status, before any audit', async () => {
|
it('addon update maps the journey-dependency refusal to its status, before any audit', async () => {
|
||||||
const c = adminCtl(svc({ updateAddon: vi.fn().mockReturnValue({ error: 'Enable the Journey addon first', status: 409 }) } as Partial<AdminService>));
|
const c = adminCtl(svc({ updateAddon: vi.fn().mockRejectedValue(new DomainError(409, 'Enable the Journey addon first')) } as Partial<AdminService>));
|
||||||
const err = await c.updateAddon(user, 'immich', { enabled: true }, req).then(
|
const err = await c.updateAddon(user, 'immich', { enabled: true }, req).then(
|
||||||
() => null,
|
() => null,
|
||||||
(e: unknown) => e as HttpException,
|
(e: unknown) => e as HttpException,
|
||||||
@@ -170,20 +171,12 @@ describe('AdminController addons + sessions + jwt + defaults', () => {
|
|||||||
|
|
||||||
it('oauth-sessions revoke audits; rotate-jwt maps error', async () => {
|
it('oauth-sessions revoke audits; rotate-jwt maps error', async () => {
|
||||||
expect(await adminCtl(svc(), undefined, undefined, {}, {}, { adminRevokeOAuthSession: vi.fn().mockReturnValue({}) }).revokeOAuthSession(user, '3', req)).toEqual({ success: true });
|
expect(await adminCtl(svc(), undefined, undefined, {}, {}, { adminRevokeOAuthSession: vi.fn().mockReturnValue({}) }).revokeOAuthSession(user, '3', req)).toEqual({ success: true });
|
||||||
expect(await thrown(() => adminCtl(svc({ rotateJwtSecret: vi.fn().mockReturnValue({ error: 'locked', status: 409 }) } as Partial<AdminService>)).rotateJwtSecret(user, req))).toEqual({ status: 409, body: { error: 'locked' } });
|
expect(await thrown(() => adminCtl(svc({ rotateJwtSecret: vi.fn(() => { throw new DomainError(409, 'locked'); }) } as Partial<AdminService>)).rotateJwtSecret(user, req))).toEqual({ status: 409, body: { error: 'locked' } });
|
||||||
expect(await adminCtl(svc({ rotateJwtSecret: vi.fn().mockReturnValue({}) } as Partial<AdminService>)).rotateJwtSecret(user, req)).toEqual({ success: true });
|
expect(await adminCtl(svc({ rotateJwtSecret: vi.fn().mockReturnValue({}) } as Partial<AdminService>)).rotateJwtSecret(user, req)).toEqual({ success: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('AdminController error envelope fallbacks', () => {
|
|
||||||
it('ok() defaults to 400 when the error envelope omits a status', async () => {
|
|
||||||
expect(await thrown(() => adminCtl(svc({ createUser: vi.fn().mockReturnValue({ error: 'boom' }) } as Partial<AdminService>)).createUser(user, {}, req))).toEqual({ status: 400, body: { error: 'boom' } });
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('AdminController read-only getters', () => {
|
describe('AdminController read-only getters', () => {
|
||||||
it('return service values verbatim', async () => {
|
it('return service values verbatim', async () => {
|
||||||
expect(await adminCtl(svc({ resetUserPasskeys: vi.fn().mockReturnValue({ email: 'a@b.c', deleted: 2 }) } as Partial<AdminService>)).resetUserPasskeys(user, '4', req)).toEqual({ success: true, deleted: 2 });
|
expect(await adminCtl(svc({ resetUserPasskeys: vi.fn().mockReturnValue({ email: 'a@b.c', deleted: 2 }) } as Partial<AdminService>)).resetUserPasskeys(user, '4', req)).toEqual({ success: true, deleted: 2 });
|
||||||
@@ -213,8 +206,8 @@ describe('AdminController read-only getters', () => {
|
|||||||
describe('AdminController tokens + sessions', () => {
|
describe('AdminController tokens + sessions', () => {
|
||||||
it('mcp token + oauth session deletes return success and map errors', async () => {
|
it('mcp token + oauth session deletes return success and map errors', async () => {
|
||||||
expect(await adminCtl(svc(), undefined, undefined, { adminDeleteMcpToken: vi.fn().mockReturnValue({}) }).deleteMcpToken(user, '2', req)).toEqual({ success: true });
|
expect(await adminCtl(svc(), undefined, undefined, { adminDeleteMcpToken: vi.fn().mockReturnValue({}) }).deleteMcpToken(user, '2', req)).toEqual({ success: true });
|
||||||
expect(await thrown(() => adminCtl(svc(), undefined, undefined, { adminDeleteMcpToken: vi.fn().mockReturnValue({ error: 'no token', status: 404 }) }).deleteMcpToken(user, '9', req))).toEqual({ status: 404, body: { error: 'no token' } });
|
expect(await thrown(() => adminCtl(svc(), undefined, undefined, { adminDeleteMcpToken: vi.fn().mockRejectedValue(new DomainError(404, 'no token')) }).deleteMcpToken(user, '9', req))).toEqual({ status: 404, body: { error: 'no token' } });
|
||||||
expect(await thrown(() => adminCtl(svc(), undefined, undefined, {}, {}, { adminRevokeOAuthSession: vi.fn().mockReturnValue({ error: 'no session', status: 404 }) }).revokeOAuthSession(user, '9', req))).toEqual({ status: 404, body: { error: 'no session' } });
|
expect(await thrown(() => adminCtl(svc(), undefined, undefined, {}, {}, { adminRevokeOAuthSession: vi.fn().mockRejectedValue(new DomainError(404, 'no session')) }).revokeOAuthSession(user, '9', req))).toEqual({ status: 404, body: { error: 'no session' } });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
* integration tests don't exercise. VCJOB-001 pins the version-check cron path
|
* integration tests don't exercise. VCJOB-001 pins the version-check cron path
|
||||||
* (it replaced ADMIN-BR-001 when the old admin bridge died with the cron move).
|
* (it replaced ADMIN-BR-001 when the old admin bridge died with the cron move).
|
||||||
*/
|
*/
|
||||||
|
import { asLegacyResult } from '../../helpers/domain-error';
|
||||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterEach, afterAll } from 'vitest';
|
import { describe, it, expect, vi, beforeAll, beforeEach, afterEach, afterAll } from 'vitest';
|
||||||
import { UsersRepository } from '../../../src/db/repositories/Users.repository';
|
import { UsersRepository } from '../../../src/db/repositories/Users.repository';
|
||||||
import { ADDON_IDS, MCP_GATED_ADDON_IDS } from '../../../src/addons';
|
import { ADDON_IDS, MCP_GATED_ADDON_IDS } from '../../../src/addons';
|
||||||
@@ -196,18 +197,18 @@ beforeAll(async () => {
|
|||||||
// Legacy free-function names bound to the service, so the moved cases below read
|
// Legacy free-function names bound to the service, so the moved cases below read
|
||||||
// exactly as they did before the fold.
|
// exactly as they did before the fold.
|
||||||
const listUsers = () => svc.listUsers();
|
const listUsers = () => svc.listUsers();
|
||||||
const svcCreateUser = (d: Parameters<AdminService['createUser']>[0]) => svc.createUser(d);
|
const svcCreateUser = (d: Parameters<AdminService['createUser']>[0]) => asLegacyResult(svc.createUser(d));
|
||||||
const updateUser = (id: string, d: Parameters<AdminService['updateUser']>[1]) => svc.updateUser(id, d);
|
const updateUser = (id: string, d: Parameters<AdminService['updateUser']>[1]) => asLegacyResult(svc.updateUser(id, d));
|
||||||
const deleteUser = (id: string, actingId: number) => svc.deleteUser(id, actingId);
|
const deleteUser = (id: string, actingId: number) => asLegacyResult(svc.deleteUser(id, actingId));
|
||||||
const getStats = () => svc.getStats();
|
const getStats = () => svc.getStats();
|
||||||
const getPermissions = () => svc.getPermissions();
|
const getPermissions = () => svc.getPermissions();
|
||||||
const savePermissions = (p: Record<string, string>) => svc.savePermissions(p);
|
const savePermissions = (p: Record<string, string>) => svc.savePermissions(p);
|
||||||
const getAuditLog = (q: { limit?: string; offset?: string }) => svc.getAuditLog(q);
|
const getAuditLog = (q: { limit?: string; offset?: string }) => svc.getAuditLog(q);
|
||||||
const saveDemoBaseline = () => svc.saveDemoBaseline();
|
const saveDemoBaseline = () => asLegacyResult(svc.saveDemoBaseline());
|
||||||
const getGithubReleases = (perPage?: string, page?: string) => svc.getGithubReleases(perPage, page);
|
const getGithubReleases = (perPage?: string, page?: string) => svc.getGithubReleases(perPage, page);
|
||||||
const checkVersion = () => svc.checkVersion();
|
const checkVersion = () => svc.checkVersion();
|
||||||
const listAddons = () => svc.listAddons();
|
const listAddons = () => svc.listAddons();
|
||||||
const updateAddon = (id: string, d: Parameters<AdminService['updateAddon']>[1]) => svc.updateAddon(id, d);
|
const updateAddon = (id: string, d: Parameters<AdminService['updateAddon']>[1]) => asLegacyResult(svc.updateAddon(id, d));
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
resetTestDb(testDb);
|
resetTestDb(testDb);
|
||||||
@@ -831,7 +832,7 @@ describe('resetUserMfa', () => {
|
|||||||
await auth.generateToken({ id: user.id });
|
await auth.generateToken({ id: user.id });
|
||||||
await auth.generateToken({ id: admin.user.id });
|
await auth.generateToken({ id: admin.user.id });
|
||||||
|
|
||||||
expect((await svc.resetUserMfa(String(user.id), admin.user.id)) as { success?: boolean }).toMatchObject({ success: true });
|
expect((await asLegacyResult(svc.resetUserMfa(String(user.id), admin.user.id))) as { success?: boolean }).toMatchObject({ success: true });
|
||||||
expect(await liveSessions(user.id)).toBe(0);
|
expect(await liveSessions(user.id)).toBe(0);
|
||||||
expect(await liveSessions(admin.user.id)).toBe(1);
|
expect(await liveSessions(admin.user.id)).toBe(1);
|
||||||
});
|
});
|
||||||
@@ -843,7 +844,7 @@ describe('resetUserMfa', () => {
|
|||||||
const admin = createAdmin(testDb);
|
const admin = createAdmin(testDb);
|
||||||
const { user } = createUserWithMfa(testDb);
|
const { user } = createUserWithMfa(testDb);
|
||||||
|
|
||||||
const result = (await svc.resetUserMfa(String(user.id), admin.user.id)) as { success?: boolean; email?: string };
|
const result = (await asLegacyResult(svc.resetUserMfa(String(user.id), admin.user.id))) as { success?: boolean; email?: string };
|
||||||
|
|
||||||
expect(result.success).toBe(true);
|
expect(result.success).toBe(true);
|
||||||
expect(result.email).toBe(user.email);
|
expect(result.email).toBe(user.email);
|
||||||
@@ -859,7 +860,7 @@ describe('resetUserMfa', () => {
|
|||||||
// self-service path in Settings asks for the current password.
|
// self-service path in Settings asks for the current password.
|
||||||
const admin = createAdmin(testDb);
|
const admin = createAdmin(testDb);
|
||||||
|
|
||||||
const result = (await svc.resetUserMfa(String(admin.user.id), admin.user.id)) as { error?: string; status?: number };
|
const result = (await asLegacyResult(svc.resetUserMfa(String(admin.user.id), admin.user.id))) as { error?: string; status?: number };
|
||||||
|
|
||||||
expect(result.status).toBe(400);
|
expect(result.status).toBe(400);
|
||||||
expect(result.error).toMatch(/your own/i);
|
expect(result.error).toMatch(/your own/i);
|
||||||
@@ -868,7 +869,7 @@ describe('resetUserMfa', () => {
|
|||||||
it('ADMIN-SVC-085 — 404 for a user that is not there', async () => {
|
it('ADMIN-SVC-085 — 404 for a user that is not there', async () => {
|
||||||
const admin = createAdmin(testDb);
|
const admin = createAdmin(testDb);
|
||||||
|
|
||||||
expect((await svc.resetUserMfa('99999', admin.user.id)) as { status?: number }).toMatchObject({ status: 404 });
|
expect((await asLegacyResult(svc.resetUserMfa('99999', admin.user.id))) as { status?: number }).toMatchObject({ status: 404 });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { PasskeyEnabledGuard } from '../../../../src/nest/auth/passkey-enabled.g
|
|||||||
import { PasskeyController } from '../../../../src/nest/auth/passkey.controller';
|
import { PasskeyController } from '../../../../src/nest/auth/passkey.controller';
|
||||||
import type { PasskeyService } from '../../../../src/nest/auth/passkey.service';
|
import type { PasskeyService } from '../../../../src/nest/auth/passkey.service';
|
||||||
import { setAuthCookie } from '../../../../src/nest/common/cookie';
|
import { setAuthCookie } from '../../../../src/nest/common/cookie';
|
||||||
|
import { DomainError } from '../../../../src/nest/common/domain-error';
|
||||||
import { RateLimitService } from '../../../../src/nest/common/rate-limit.service';
|
import { RateLimitService } from '../../../../src/nest/common/rate-limit.service';
|
||||||
import type { User } from '../../../../src/types';
|
import type { User } from '../../../../src/types';
|
||||||
import type { EntityManager } from '@mikro-orm/core';
|
import type { EntityManager } from '@mikro-orm/core';
|
||||||
@@ -240,7 +241,7 @@ describe('PasskeyController', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
it('register/options maps a service error, else returns the options', async () => {
|
it('register/options maps a service error, else returns the options', async () => {
|
||||||
passkey.passkeyRegisterOptions.mockResolvedValue({ error: 'Incorrect password', status: 401 });
|
passkey.passkeyRegisterOptions.mockRejectedValue(new DomainError(401, 'Incorrect password'));
|
||||||
expect(await thrownAsync(() => pc(rl()).registerOptions(user, { password: 'x' }, req))).toEqual({
|
expect(await thrownAsync(() => pc(rl()).registerOptions(user, { password: 'x' }, req))).toEqual({
|
||||||
status: 401,
|
status: 401,
|
||||||
body: { error: 'Incorrect password' },
|
body: { error: 'Incorrect password' },
|
||||||
@@ -250,7 +251,7 @@ describe('PasskeyController', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('register/verify maps a service error, else audits and returns the credential', async () => {
|
it('register/verify maps a service error, else audits and returns the credential', async () => {
|
||||||
passkey.passkeyRegisterVerify.mockResolvedValue({ error: 'Verification failed', status: 400 } as never);
|
passkey.passkeyRegisterVerify.mockRejectedValue(new DomainError(400, 'Verification failed'));
|
||||||
expect(await thrownAsync(() => pc(rl()).registerVerify(user, {}, req))).toEqual({
|
expect(await thrownAsync(() => pc(rl()).registerVerify(user, {}, req))).toEqual({
|
||||||
status: 400,
|
status: 400,
|
||||||
body: { error: 'Verification failed' },
|
body: { error: 'Verification failed' },
|
||||||
@@ -261,7 +262,7 @@ describe('PasskeyController', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('login/options maps a service error, else returns the options', async () => {
|
it('login/options maps a service error, else returns the options', async () => {
|
||||||
passkey.passkeyLoginOptions.mockResolvedValue({ error: 'Not configured', status: 503 } as never);
|
passkey.passkeyLoginOptions.mockRejectedValue(new DomainError(503, 'Not configured'));
|
||||||
expect(await thrownAsync(() => pc(rl()).loginOptions(req))).toEqual({
|
expect(await thrownAsync(() => pc(rl()).loginOptions(req))).toEqual({
|
||||||
status: 503,
|
status: 503,
|
||||||
body: { error: 'Not configured' },
|
body: { error: 'Not configured' },
|
||||||
@@ -297,7 +298,7 @@ describe('PasskeyController', () => {
|
|||||||
passkey.listPasskeys.mockResolvedValue([{ id: 'a' }]);
|
passkey.listPasskeys.mockResolvedValue([{ id: 'a' }]);
|
||||||
expect(await pc(rl()).list(user)).toEqual({ credentials: [{ id: 'a' }] });
|
expect(await pc(rl()).list(user)).toEqual({ credentials: [{ id: 'a' }] });
|
||||||
|
|
||||||
passkey.renamePasskey.mockResolvedValue({ error: 'Not found', status: 404 });
|
passkey.renamePasskey.mockRejectedValue(new DomainError(404, 'Not found'));
|
||||||
expect(await thrownAsync(() => pc(rl()).rename(user, 'cid', { name: 'x' }))).toEqual({
|
expect(await thrownAsync(() => pc(rl()).rename(user, 'cid', { name: 'x' }))).toEqual({
|
||||||
status: 404,
|
status: 404,
|
||||||
body: { error: 'Not found' },
|
body: { error: 'Not found' },
|
||||||
@@ -305,7 +306,7 @@ describe('PasskeyController', () => {
|
|||||||
passkey.renamePasskey.mockResolvedValue({ success: true });
|
passkey.renamePasskey.mockResolvedValue({ success: true });
|
||||||
expect(await pc(rl()).rename(user, 'cid', { name: 'x' })).toEqual({ success: true });
|
expect(await pc(rl()).rename(user, 'cid', { name: 'x' })).toEqual({ success: true });
|
||||||
|
|
||||||
passkey.deletePasskey.mockResolvedValue({ error: 'Incorrect password', status: 401 });
|
passkey.deletePasskey.mockRejectedValue(new DomainError(401, 'Incorrect password'));
|
||||||
expect(await thrownAsync(() => pc(rl()).remove(user, 'cid', { password: 'x' }, req))).toEqual({
|
expect(await thrownAsync(() => pc(rl()).remove(user, 'cid', { password: 'x' }, req))).toEqual({
|
||||||
status: 401,
|
status: 401,
|
||||||
body: { error: 'Incorrect password' },
|
body: { error: 'Incorrect password' },
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { DomainError } from '../../../src/nest/common/domain-error';
|
||||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||||
import { RuntimeEnvService } from '../../../src/nest/app-config/runtime-env.service';
|
import { RuntimeEnvService } from '../../../src/nest/app-config/runtime-env.service';
|
||||||
import { HttpException } from '@nestjs/common';
|
import { HttpException } from '@nestjs/common';
|
||||||
@@ -90,7 +91,7 @@ describe('RateLimitService', () => {
|
|||||||
|
|
||||||
describe('AuthPublicController', () => {
|
describe('AuthPublicController', () => {
|
||||||
it('demo-login maps error, else sets the cookie + returns token/user', async () => {
|
it('demo-login maps error, else sets the cookie + returns token/user', async () => {
|
||||||
expect(await thrownAsync(() => apc(asvc({ demoLogin: vi.fn().mockReturnValue({ error: 'Demo disabled', status: 403 }) } as Partial<AuthService>), rl()).demoLogin(req, res))).toEqual({ status: 403, body: { error: 'Demo disabled' } });
|
expect(await thrownAsync(() => apc(asvc({ demoLogin: vi.fn().mockRejectedValue(new DomainError(403, 'Demo disabled')) } as Partial<AuthService>), rl()).demoLogin(req, res))).toEqual({ status: 403, body: { error: 'Demo disabled' } });
|
||||||
const setAuthCookie = vi.fn();
|
const setAuthCookie = vi.fn();
|
||||||
const demoLogin = vi.fn().mockReturnValue({ token: 'tk', user });
|
const demoLogin = vi.fn().mockReturnValue({ token: 'tk', user });
|
||||||
const c = apc(asvc({ demoLogin, setAuthCookie } as Partial<AuthService>), rl());
|
const c = apc(asvc({ demoLogin, setAuthCookie } as Partial<AuthService>), rl());
|
||||||
@@ -101,7 +102,7 @@ describe('AuthPublicController', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('register audits + sets cookie; maps error', async () => {
|
it('register audits + sets cookie; maps error', async () => {
|
||||||
expect(await thrownAsync(() => apc(asvc({ registerUser: vi.fn().mockReturnValue({ error: 'Email taken', status: 409 }) } as Partial<AuthService>), rl()).register(anyBody(), req, res))).toEqual({ status: 409, body: { error: 'Email taken' } });
|
expect(await thrownAsync(() => apc(asvc({ registerUser: vi.fn().mockRejectedValue(new DomainError(409, 'Email taken')) } as Partial<AuthService>), rl()).register(anyBody(), req, res))).toEqual({ status: 409, body: { error: 'Email taken' } });
|
||||||
const setAuthCookie = vi.fn();
|
const setAuthCookie = vi.fn();
|
||||||
const c = apc(asvc({ registerUser: vi.fn().mockReturnValue({ token: 'tk', user, auditUserId: 1, auditDetails: {} }), setAuthCookie } as Partial<AuthService>), rl());
|
const c = apc(asvc({ registerUser: vi.fn().mockReturnValue({ token: 'tk', user, auditUserId: 1, auditDetails: {} }), setAuthCookie } as Partial<AuthService>), rl());
|
||||||
expect(await c.register({ email: 'a@b.c', password: 'p' }, req, res)).toEqual({ token: 'tk', user });
|
expect(await c.register({ email: 'a@b.c', password: 'p' }, req, res)).toEqual({ token: 'tk', user });
|
||||||
@@ -136,7 +137,7 @@ describe('AuthPublicController', () => {
|
|||||||
}, 10000);
|
}, 10000);
|
||||||
|
|
||||||
it('reset-password: error audits a fail, mfa branch, success', async () => {
|
it('reset-password: error audits a fail, mfa branch, success', async () => {
|
||||||
expect(await thrownAsync(() => apc(asvc({ resetPassword: vi.fn().mockReturnValue({ error: 'Invalid token', status: 400 }) } as Partial<AuthService>), rl()).resetPassword(anyBody(), req))).toEqual({ status: 400, body: { error: 'Invalid token' } });
|
expect(await thrownAsync(() => apc(asvc({ resetPassword: vi.fn().mockRejectedValue(new DomainError(400, 'Invalid token')) } as Partial<AuthService>), rl()).resetPassword(anyBody(), req))).toEqual({ status: 400, body: { error: 'Invalid token' } });
|
||||||
expect(writeAudit).toHaveBeenCalledWith(expect.objectContaining({ action: 'user.password_reset_fail' }));
|
expect(writeAudit).toHaveBeenCalledWith(expect.objectContaining({ action: 'user.password_reset_fail' }));
|
||||||
expect(await apc(asvc({ resetPassword: vi.fn().mockReturnValue({ mfa_required: true }) } as Partial<AuthService>), rl()).resetPassword(anyBody(), req)).toEqual({ mfa_required: true });
|
expect(await apc(asvc({ resetPassword: vi.fn().mockReturnValue({ mfa_required: true }) } as Partial<AuthService>), rl()).resetPassword(anyBody(), req)).toEqual({ mfa_required: true });
|
||||||
expect(await apc(asvc({ resetPassword: vi.fn().mockReturnValue({ userId: 1 }) } as Partial<AuthService>), rl()).resetPassword(anyBody(), req)).toEqual({ success: true });
|
expect(await apc(asvc({ resetPassword: vi.fn().mockReturnValue({ userId: 1 }) } as Partial<AuthService>), rl()).resetPassword(anyBody(), req)).toEqual({ success: true });
|
||||||
@@ -152,7 +153,7 @@ describe('AuthPublicController', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('invite maps a service error', async () => {
|
it('invite maps a service error', async () => {
|
||||||
const c = apc(asvc({ validateInviteToken: vi.fn().mockReturnValue({ error: 'Expired', status: 410 }) } as Partial<AuthService>), rl());
|
const c = apc(asvc({ validateInviteToken: vi.fn().mockRejectedValue(new DomainError(410, 'Expired')) } as Partial<AuthService>), rl());
|
||||||
expect(await thrownAsync(() => c.invite('tok', req))).toEqual({ status: 410, body: { error: 'Expired' } });
|
expect(await thrownAsync(() => c.invite('tok', req))).toEqual({ status: 410, body: { error: 'Expired' } });
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -194,7 +195,7 @@ describe('AuthPublicController', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('mfa/verify-login maps a service error', async () => {
|
it('mfa/verify-login maps a service error', async () => {
|
||||||
const c = apc(asvc({ verifyMfaLogin: vi.fn().mockReturnValue({ error: 'Bad code', status: 401 }) } as Partial<AuthService>), rl());
|
const c = apc(asvc({ verifyMfaLogin: vi.fn().mockRejectedValue(new DomainError(401, 'Bad code')) } as Partial<AuthService>), rl());
|
||||||
expect(await thrownAsync(() => c.verifyMfaLogin(anyBody(), req, res))).toEqual({ status: 401, body: { error: 'Bad code' } });
|
expect(await thrownAsync(() => c.verifyMfaLogin(anyBody(), req, res))).toEqual({ status: 401, body: { error: 'Bad code' } });
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -259,7 +260,7 @@ describe('AuthController (authenticated)', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('change-password maps error, else audits', async () => {
|
it('change-password maps error, else audits', async () => {
|
||||||
expect(await thrownAsync(() => ac(asvc({ changePassword: vi.fn().mockReturnValue({ error: 'Wrong', status: 400 }) } as Partial<AuthService>), rl()).changePassword(user, anyBody(), req, res))).toEqual({ status: 400, body: { error: 'Wrong' } });
|
expect(await thrownAsync(() => ac(asvc({ changePassword: vi.fn().mockRejectedValue(new DomainError(400, 'Wrong')) } as Partial<AuthService>), rl()).changePassword(user, anyBody(), req, res))).toEqual({ status: 400, body: { error: 'Wrong' } });
|
||||||
expect(await ac(asvc({ changePassword: vi.fn().mockReturnValue({}) } as Partial<AuthService>), rl()).changePassword(user, anyBody(), req, res)).toEqual({ success: true });
|
expect(await ac(asvc({ changePassword: vi.fn().mockReturnValue({}) } as Partial<AuthService>), rl()).changePassword(user, anyBody(), req, res)).toEqual({ success: true });
|
||||||
expect(writeAudit).toHaveBeenCalledWith(expect.objectContaining({ action: 'user.password_change' }));
|
expect(writeAudit).toHaveBeenCalledWith(expect.objectContaining({ action: 'user.password_change' }));
|
||||||
});
|
});
|
||||||
@@ -367,7 +368,7 @@ describe('AuthController (authenticated)', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('delete-account maps error, else audits and succeeds', async () => {
|
it('delete-account maps error, else audits and succeeds', async () => {
|
||||||
expect(await thrownAsync(() => ac(asvc({ deleteAccount: vi.fn().mockReturnValue({ error: 'Last admin', status: 403 }) } as Partial<AuthService>), rl()).deleteAccount(user, req))).toEqual({ status: 403, body: { error: 'Last admin' } });
|
expect(await thrownAsync(() => ac(asvc({ deleteAccount: vi.fn().mockRejectedValue(new DomainError(403, 'Last admin')) } as Partial<AuthService>), rl()).deleteAccount(user, req))).toEqual({ status: 403, body: { error: 'Last admin' } });
|
||||||
expect(await ac(asvc({ deleteAccount: vi.fn().mockReturnValue({}) } as Partial<AuthService>), rl()).deleteAccount(user, req)).toEqual({ success: true });
|
expect(await ac(asvc({ deleteAccount: vi.fn().mockReturnValue({}) } as Partial<AuthService>), rl()).deleteAccount(user, req)).toEqual({ success: true });
|
||||||
expect(writeAudit).toHaveBeenCalledWith(expect.objectContaining({ action: 'user.account_delete' }));
|
expect(writeAudit).toHaveBeenCalledWith(expect.objectContaining({ action: 'user.account_delete' }));
|
||||||
});
|
});
|
||||||
@@ -418,9 +419,9 @@ describe('AuthController (authenticated)', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('update-settings + get-settings map errors, else return their payloads', async () => {
|
it('update-settings + get-settings map errors, else return their payloads', async () => {
|
||||||
expect(await thrownAsync(() => ac(asvc({}), rl(), {}, { updateSettings: vi.fn().mockReturnValue({ error: 'Bad', status: 400 }) }).updateSettings(user, {}, req))).toEqual({ status: 400, body: { error: 'Bad' } });
|
expect(await thrownAsync(() => ac(asvc({}), rl(), {}, { updateSettings: vi.fn().mockRejectedValue(new DomainError(400, 'Bad')) }).updateSettings(user, {}, req))).toEqual({ status: 400, body: { error: 'Bad' } });
|
||||||
expect(await ac(asvc({}), rl(), {}, { updateSettings: vi.fn().mockReturnValue({ success: true, user: { id: 1 } }) }).updateSettings(user, {}, req)).toEqual({ success: true, user: { id: 1 } });
|
expect(await ac(asvc({}), rl(), {}, { updateSettings: vi.fn().mockReturnValue({ success: true, user: { id: 1 } }) }).updateSettings(user, {}, req)).toEqual({ success: true, user: { id: 1 } });
|
||||||
expect(await thrownAsync(() => ac(asvc({}), rl(), {}, { getSettings: vi.fn().mockReturnValue({ error: 'Nope', status: 404 }) }).getSettings(user))).toEqual({ status: 404, body: { error: 'Nope' } });
|
expect(await thrownAsync(() => ac(asvc({}), rl(), {}, { getSettings: vi.fn().mockRejectedValue(new DomainError(404, 'Nope')) }).getSettings(user))).toEqual({ status: 404, body: { error: 'Nope' } });
|
||||||
expect(await ac(asvc({}), rl(), {}, { getSettings: vi.fn().mockReturnValue({ settings: { theme: 'dark' } }) }).getSettings(user)).toEqual({ settings: { theme: 'dark' } });
|
expect(await ac(asvc({}), rl(), {}, { getSettings: vi.fn().mockReturnValue({ settings: { theme: 'dark' } }) }).getSettings(user)).toEqual({ settings: { theme: 'dark' } });
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -435,27 +436,27 @@ describe('AuthController (authenticated)', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('validate-keys maps error, else returns the maps/weather payload', async () => {
|
it('validate-keys maps error, else returns the maps/weather payload', async () => {
|
||||||
expect(await thrownAsync(() => ac(asvc({}), rl(), {}, { validateKeys: vi.fn().mockResolvedValue({ error: 'fail', status: 502 }) }).validateKeys(user))).toEqual({ status: 502, body: { error: 'fail' } });
|
expect(await thrownAsync(() => ac(asvc({}), rl(), {}, { validateKeys: vi.fn().mockRejectedValue(new DomainError(502, 'fail')) }).validateKeys(user))).toEqual({ status: 502, body: { error: 'fail' } });
|
||||||
const ok = ac(asvc({}), rl(), {}, { validateKeys: vi.fn().mockResolvedValue({ maps: true, weather: false, maps_details: { ok: 1 } }) });
|
const ok = ac(asvc({}), rl(), {}, { validateKeys: vi.fn().mockResolvedValue({ maps: true, weather: false, maps_details: { ok: 1 } }) });
|
||||||
expect(await ok.validateKeys(user)).toEqual({ maps: true, weather: false, maps_details: { ok: 1 } });
|
expect(await ok.validateKeys(user)).toEqual({ maps: true, weather: false, maps_details: { ok: 1 } });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('app-settings get maps error, else returns data; put maps error, else audits', async () => {
|
it('app-settings get maps error, else returns data; put maps error, else audits', async () => {
|
||||||
expect(await thrownAsync(() => ac(asvc({ getAppSettings: vi.fn().mockReturnValue({ error: 'denied', status: 403 }) } as Partial<AuthService>), rl()).getAppSettings(user))).toEqual({ status: 403, body: { error: 'denied' } });
|
expect(await thrownAsync(() => ac(asvc({ getAppSettings: vi.fn().mockRejectedValue(new DomainError(403, 'denied')) } as Partial<AuthService>), rl()).getAppSettings(user))).toEqual({ status: 403, body: { error: 'denied' } });
|
||||||
expect(await ac(asvc({ getAppSettings: vi.fn().mockReturnValue({ data: { x: 1 } }) } as Partial<AuthService>), rl()).getAppSettings(user)).toEqual({ x: 1 });
|
expect(await ac(asvc({ getAppSettings: vi.fn().mockReturnValue({ data: { x: 1 } }) } as Partial<AuthService>), rl()).getAppSettings(user)).toEqual({ x: 1 });
|
||||||
expect(await thrownAsync(() => ac(asvc({ updateAppSettings: vi.fn().mockReturnValue({ error: 'bad', status: 400 }) } as Partial<AuthService>), rl()).updateAppSettings(user, {}, req))).toEqual({ status: 400, body: { error: 'bad' } });
|
expect(await thrownAsync(() => ac(asvc({ updateAppSettings: vi.fn().mockRejectedValue(new DomainError(400, 'bad')) } as Partial<AuthService>), rl()).updateAppSettings(user, {}, req))).toEqual({ status: 400, body: { error: 'bad' } });
|
||||||
expect(await ac(asvc({ updateAppSettings: vi.fn().mockReturnValue({ auditSummary: 's', auditDebugDetails: 'd' }) } as Partial<AuthService>), rl()).updateAppSettings(user, {}, req)).toEqual({ success: true });
|
expect(await ac(asvc({ updateAppSettings: vi.fn().mockReturnValue({ auditSummary: 's', auditDebugDetails: 'd' }) } as Partial<AuthService>), rl()).updateAppSettings(user, {}, req)).toEqual({ success: true });
|
||||||
expect(writeAudit).toHaveBeenCalledWith(expect.objectContaining({ action: 'settings.app_update' }));
|
expect(writeAudit).toHaveBeenCalledWith(expect.objectContaining({ action: 'settings.app_update' }));
|
||||||
});
|
});
|
||||||
|
|
||||||
it('mfa/setup maps a service error before ever awaiting the QR promise', async () => {
|
it('mfa/setup maps a service error before ever awaiting the QR promise', async () => {
|
||||||
const c = ac(asvc({ setupMfa: vi.fn().mockReturnValue({ error: 'already on', status: 409 }) } as Partial<AuthService>), rl());
|
const c = ac(asvc({ setupMfa: vi.fn().mockRejectedValue(new DomainError(409, 'already on')) } as Partial<AuthService>), rl());
|
||||||
expect(await thrownAsync(() => c.mfaSetup(user))).toEqual({ status: 409, body: { error: 'already on' } });
|
expect(await thrownAsync(() => c.mfaSetup(user))).toEqual({ status: 409, body: { error: 'already on' } });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('mfa/enable + mfa/disable map errors', async () => {
|
it('mfa/enable + mfa/disable map errors', async () => {
|
||||||
expect(await thrownAsync(() => ac(asvc({ enableMfa: vi.fn().mockReturnValue({ error: 'Invalid code', status: 400 }) } as Partial<AuthService>), rl()).mfaEnable(user, { code: 'x' }, req))).toEqual({ status: 400, body: { error: 'Invalid code' } });
|
expect(await thrownAsync(() => ac(asvc({ enableMfa: vi.fn().mockRejectedValue(new DomainError(400, 'Invalid code')) } as Partial<AuthService>), rl()).mfaEnable(user, { code: 'x' }, req))).toEqual({ status: 400, body: { error: 'Invalid code' } });
|
||||||
expect(await thrownAsync(() => ac(asvc({ disableMfa: vi.fn().mockReturnValue({ error: 'Wrong', status: 401 }) } as Partial<AuthService>), rl()).mfaDisable(user, anyBody(), req))).toEqual({ status: 401, body: { error: 'Wrong' } });
|
expect(await thrownAsync(() => ac(asvc({ disableMfa: vi.fn().mockRejectedValue(new DomainError(401, 'Wrong')) } as Partial<AuthService>), rl()).mfaDisable(user, anyBody(), req))).toEqual({ status: 401, body: { error: 'Wrong' } });
|
||||||
const disableMfa = vi.fn().mockReturnValue({ mfa_enabled: false });
|
const disableMfa = vi.fn().mockReturnValue({ mfa_enabled: false });
|
||||||
const ok = ac(asvc({ disableMfa } as Partial<AuthService>), rl());
|
const ok = ac(asvc({ disableMfa } as Partial<AuthService>), rl());
|
||||||
expect(await ok.mfaDisable(user, anyBody(), req)).toEqual({ success: true, mfa_enabled: false });
|
expect(await ok.mfaDisable(user, anyBody(), req)).toEqual({ success: true, mfa_enabled: false });
|
||||||
@@ -470,8 +471,8 @@ describe('AuthController (authenticated)', () => {
|
|||||||
|
|
||||||
it('mcp-tokens list + create error + delete error/success', async () => {
|
it('mcp-tokens list + create error + delete error/success', async () => {
|
||||||
expect(await ac(asvc({}), rl(), { listMcpTokens: vi.fn().mockReturnValue([{ id: 't' }]) }).listMcpTokens(user)).toEqual({ tokens: [{ id: 't' }] });
|
expect(await ac(asvc({}), rl(), { listMcpTokens: vi.fn().mockReturnValue([{ id: 't' }]) }).listMcpTokens(user)).toEqual({ tokens: [{ id: 't' }] });
|
||||||
expect(await thrownAsync(() => ac(asvc({}), rl(), { createMcpToken: vi.fn().mockReturnValue({ error: 'Name taken', status: 409 }) }).createMcpToken(user, { name: 'x' }, req))).toEqual({ status: 409, body: { error: 'Name taken' } });
|
expect(await thrownAsync(() => ac(asvc({}), rl(), { createMcpToken: vi.fn().mockRejectedValue(new DomainError(409, 'Name taken')) }).createMcpToken(user, { name: 'x' }, req))).toEqual({ status: 409, body: { error: 'Name taken' } });
|
||||||
expect(await thrownAsync(() => ac(asvc({}), rl(), { deleteMcpToken: vi.fn().mockReturnValue({ error: 'Not found', status: 404 }) }).deleteMcpToken(user, 'tid'))).toEqual({ status: 404, body: { error: 'Not found' } });
|
expect(await thrownAsync(() => ac(asvc({}), rl(), { deleteMcpToken: vi.fn().mockRejectedValue(new DomainError(404, 'Not found')) }).deleteMcpToken(user, 'tid'))).toEqual({ status: 404, body: { error: 'Not found' } });
|
||||||
expect(await ac(asvc({}), rl(), { deleteMcpToken: vi.fn().mockReturnValue({}) }).deleteMcpToken(user, 'tid')).toEqual({ success: true });
|
expect(await ac(asvc({}), rl(), { deleteMcpToken: vi.fn().mockReturnValue({}) }).deleteMcpToken(user, 'tid')).toEqual({ success: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -481,8 +482,8 @@ describe('AuthController (authenticated)', () => {
|
|||||||
it('api-tokens list + create success/error + delete error/success', async () => {
|
it('api-tokens list + create success/error + delete error/success', async () => {
|
||||||
expect(await ac(asvc({}), rl(), { listApiTokens: vi.fn().mockReturnValue([{ id: 'a' }]) }).listApiTokens(user)).toEqual({ tokens: [{ id: 'a' }] });
|
expect(await ac(asvc({}), rl(), { listApiTokens: vi.fn().mockReturnValue([{ id: 'a' }]) }).listApiTokens(user)).toEqual({ tokens: [{ id: 'a' }] });
|
||||||
expect(await ac(asvc({}), rl(), { createApiToken: vi.fn().mockReturnValue({ token: 'trek_x' }) }).createApiToken(user, { name: 'Homepage' }, req)).toEqual({ token: 'trek_x' });
|
expect(await ac(asvc({}), rl(), { createApiToken: vi.fn().mockReturnValue({ token: 'trek_x' }) }).createApiToken(user, { name: 'Homepage' }, req)).toEqual({ token: 'trek_x' });
|
||||||
expect(await thrownAsync(() => ac(asvc({}), rl(), { createApiToken: vi.fn().mockReturnValue({ error: 'Name taken', status: 409 }) }).createApiToken(user, { name: 'x' }, req))).toEqual({ status: 409, body: { error: 'Name taken' } });
|
expect(await thrownAsync(() => ac(asvc({}), rl(), { createApiToken: vi.fn().mockRejectedValue(new DomainError(409, 'Name taken')) }).createApiToken(user, { name: 'x' }, req))).toEqual({ status: 409, body: { error: 'Name taken' } });
|
||||||
expect(await thrownAsync(() => ac(asvc({}), rl(), { deleteApiToken: vi.fn().mockReturnValue({ error: 'Not found', status: 404 }) }).deleteApiToken(user, 'tid'))).toEqual({ status: 404, body: { error: 'Not found' } });
|
expect(await thrownAsync(() => ac(asvc({}), rl(), { deleteApiToken: vi.fn().mockRejectedValue(new DomainError(404, 'Not found')) }).deleteApiToken(user, 'tid'))).toEqual({ status: 404, body: { error: 'Not found' } });
|
||||||
expect(await ac(asvc({}), rl(), { deleteApiToken: vi.fn().mockReturnValue({}) }).deleteApiToken(user, 'tid')).toEqual({ success: true });
|
expect(await ac(asvc({}), rl(), { deleteApiToken: vi.fn().mockReturnValue({}) }).deleteApiToken(user, 'tid')).toEqual({ success: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -499,7 +500,7 @@ describe('AuthController (authenticated)', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('ws-token maps error, else returns the token', async () => {
|
it('ws-token maps error, else returns the token', async () => {
|
||||||
expect(await thrownAsync(() => ac(asvc({}), rl(), { createWsToken: vi.fn().mockReturnValue({ error: 'down', status: 503 }) }).wsToken(user))).toEqual({ status: 503, body: { error: 'down' } });
|
expect(await thrownAsync(() => ac(asvc({}), rl(), { createWsToken: vi.fn().mockRejectedValue(new DomainError(503, 'down')) }).wsToken(user))).toEqual({ status: 503, body: { error: 'down' } });
|
||||||
expect(await ac(asvc({}), rl(), { createWsToken: vi.fn().mockReturnValue({ token: 'ws' }) }).wsToken(user)).toEqual({ token: 'ws' });
|
expect(await ac(asvc({}), rl(), { createWsToken: vi.fn().mockReturnValue({ token: 'ws' }) }).wsToken(user)).toEqual({ token: 'ws' });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ vi.mock('../../../src/mcp/sessionManager', () => ({ revokeUserSessions: vi.fn()
|
|||||||
// Imports (after mocks)
|
// Imports (after mocks)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
import { asLegacyResult } from '../../helpers/domain-error';
|
||||||
import { db as testDb } from '../../../src/db/database';
|
import { db as testDb } from '../../../src/db/database';
|
||||||
import { describe, it, expect, beforeAll, beforeEach, afterEach, afterAll, vi } from 'vitest';
|
import { describe, it, expect, beforeAll, beforeEach, afterEach, afterAll, vi } from 'vitest';
|
||||||
import { UsersRepository } from '../../../src/db/repositories/Users.repository';
|
import { UsersRepository } from '../../../src/db/repositories/Users.repository';
|
||||||
@@ -205,7 +206,7 @@ describe('requestPasswordReset — OIDC/SSO accounts', () => {
|
|||||||
describe('getAppSettings', () => {
|
describe('getAppSettings', () => {
|
||||||
it('AUTH-DB-013: returns 403 for non-admin', async () => {
|
it('AUTH-DB-013: returns 403 for non-admin', async () => {
|
||||||
const { user } = createUser(testDb);
|
const { user } = createUser(testDb);
|
||||||
const result = await svc.getAppSettings(user.id);
|
const result = await asLegacyResult(svc.getAppSettings(user.id));
|
||||||
expect(result.status).toBe(403);
|
expect(result.status).toBe(403);
|
||||||
expect(result.error).toMatch(/admin/i);
|
expect(result.error).toMatch(/admin/i);
|
||||||
});
|
});
|
||||||
@@ -213,7 +214,7 @@ describe('getAppSettings', () => {
|
|||||||
it('AUTH-DB-014: returns settings object for admin with known key allow_registration', async () => {
|
it('AUTH-DB-014: returns settings object for admin with known key allow_registration', async () => {
|
||||||
const { user } = createAdmin(testDb);
|
const { user } = createAdmin(testDb);
|
||||||
await appSetting('allow_registration', 'true');
|
await appSetting('allow_registration', 'true');
|
||||||
const result = await svc.getAppSettings(user.id);
|
const result = await asLegacyResult(svc.getAppSettings(user.id));
|
||||||
expect(result.status).toBeUndefined();
|
expect(result.status).toBeUndefined();
|
||||||
expect(result.data).toBeDefined();
|
expect(result.data).toBeDefined();
|
||||||
expect(result.data).toHaveProperty('allow_registration', 'true');
|
expect(result.data).toHaveProperty('allow_registration', 'true');
|
||||||
@@ -332,7 +333,7 @@ describe('setupMfa', () => {
|
|||||||
it('AUTH-DB-022: returns 403 in demo mode for demo@nomad.app', async () => {
|
it('AUTH-DB-022: returns 403 in demo mode for demo@nomad.app', async () => {
|
||||||
vi.stubEnv('DEMO_MODE', 'true');
|
vi.stubEnv('DEMO_MODE', 'true');
|
||||||
const { user } = createUser(testDb, { email: 'demo@nomad.app' });
|
const { user } = createUser(testDb, { email: 'demo@nomad.app' });
|
||||||
const result = await svc.setupMfa(user.id, 'demo@nomad.app');
|
const result = await asLegacyResult(svc.setupMfa(user.id, 'demo@nomad.app'));
|
||||||
expect(result.status).toBe(403);
|
expect(result.status).toBe(403);
|
||||||
expect(result.error).toMatch(/demo mode/i);
|
expect(result.error).toMatch(/demo mode/i);
|
||||||
vi.unstubAllEnvs();
|
vi.unstubAllEnvs();
|
||||||
@@ -341,14 +342,14 @@ describe('setupMfa', () => {
|
|||||||
it('AUTH-DB-023: returns 400 when MFA is already enabled', async () => {
|
it('AUTH-DB-023: returns 400 when MFA is already enabled', async () => {
|
||||||
const { user } = createUser(testDb);
|
const { user } = createUser(testDb);
|
||||||
await updateRows(orm, Users, { id: user.id }, { mfa_enabled: 1 });
|
await updateRows(orm, Users, { id: user.id }, { mfa_enabled: 1 });
|
||||||
const result = await svc.setupMfa(user.id, user.email);
|
const result = await asLegacyResult(svc.setupMfa(user.id, user.email));
|
||||||
expect(result.status).toBe(400);
|
expect(result.status).toBe(400);
|
||||||
expect(result.error).toMatch(/already enabled/i);
|
expect(result.error).toMatch(/already enabled/i);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('AUTH-DB-024: returns secret and otpauth_url when MFA setup starts successfully', async () => {
|
it('AUTH-DB-024: returns secret and otpauth_url when MFA setup starts successfully', async () => {
|
||||||
const { user } = createUser(testDb);
|
const { user } = createUser(testDb);
|
||||||
const result = await svc.setupMfa(user.id, user.email);
|
const result = await asLegacyResult(svc.setupMfa(user.id, user.email));
|
||||||
expect(result.error).toBeUndefined();
|
expect(result.error).toBeUndefined();
|
||||||
expect(typeof result.secret).toBe('string');
|
expect(typeof result.secret).toBe('string');
|
||||||
expect(result.secret!.length).toBeGreaterThan(0);
|
expect(result.secret!.length).toBeGreaterThan(0);
|
||||||
@@ -365,7 +366,7 @@ describe('setupMfa', () => {
|
|||||||
describe('enableMfa', () => {
|
describe('enableMfa', () => {
|
||||||
it('AUTH-DB-025: returns 400 when no verification code is provided', async () => {
|
it('AUTH-DB-025: returns 400 when no verification code is provided', async () => {
|
||||||
const { user } = createUser(testDb);
|
const { user } = createUser(testDb);
|
||||||
const result = await svc.enableMfa(user.id, undefined);
|
const result = await asLegacyResult(svc.enableMfa(user.id, undefined));
|
||||||
expect(result.status).toBe(400);
|
expect(result.status).toBe(400);
|
||||||
expect(result.error).toMatch(/code is required/i);
|
expect(result.error).toMatch(/code is required/i);
|
||||||
});
|
});
|
||||||
@@ -373,7 +374,7 @@ describe('enableMfa', () => {
|
|||||||
it('AUTH-DB-026: returns 400 when there is no pending MFA setup', async () => {
|
it('AUTH-DB-026: returns 400 when there is no pending MFA setup', async () => {
|
||||||
const { user } = createUser(testDb);
|
const { user } = createUser(testDb);
|
||||||
// No setupMfa called first, so no pending entry exists
|
// No setupMfa called first, so no pending entry exists
|
||||||
const result = await svc.enableMfa(user.id, '123456');
|
const result = await asLegacyResult(svc.enableMfa(user.id, '123456'));
|
||||||
expect(result.status).toBe(400);
|
expect(result.status).toBe(400);
|
||||||
expect(result.error).toMatch(/no mfa setup in progress/i);
|
expect(result.error).toMatch(/no mfa setup in progress/i);
|
||||||
});
|
});
|
||||||
@@ -387,10 +388,10 @@ describe('disableMfa', () => {
|
|||||||
it('AUTH-DB-027: returns 403 in demo mode for demo@nomad.app', async () => {
|
it('AUTH-DB-027: returns 403 in demo mode for demo@nomad.app', async () => {
|
||||||
vi.stubEnv('DEMO_MODE', 'true');
|
vi.stubEnv('DEMO_MODE', 'true');
|
||||||
const { user } = createUser(testDb, { email: 'demo@nomad.app' });
|
const { user } = createUser(testDb, { email: 'demo@nomad.app' });
|
||||||
const result = await svc.disableMfa(user.id, 'demo@nomad.app', {
|
const result = await asLegacyResult(svc.disableMfa(user.id, 'demo@nomad.app', {
|
||||||
password: 'password123',
|
password: 'password123',
|
||||||
code: '000000',
|
code: '000000',
|
||||||
});
|
}));
|
||||||
expect(result.status).toBe(403);
|
expect(result.status).toBe(403);
|
||||||
expect(result.error).toMatch(/demo mode/i);
|
expect(result.error).toMatch(/demo mode/i);
|
||||||
vi.unstubAllEnvs();
|
vi.unstubAllEnvs();
|
||||||
@@ -399,11 +400,11 @@ describe('disableMfa', () => {
|
|||||||
it('AUTH-DB-028: returns 400 when password or code is missing', async () => {
|
it('AUTH-DB-028: returns 400 when password or code is missing', async () => {
|
||||||
const { user } = createUser(testDb);
|
const { user } = createUser(testDb);
|
||||||
|
|
||||||
const missingCode = await svc.disableMfa(user.id, user.email, { password: 'pass', code: undefined });
|
const missingCode = await asLegacyResult(svc.disableMfa(user.id, user.email, { password: 'pass', code: undefined }));
|
||||||
expect(missingCode.status).toBe(400);
|
expect(missingCode.status).toBe(400);
|
||||||
expect(missingCode.error).toMatch(/password and authenticator code/i);
|
expect(missingCode.error).toMatch(/password and authenticator code/i);
|
||||||
|
|
||||||
const missingPassword = await svc.disableMfa(user.id, user.email, { password: undefined, code: '123456' });
|
const missingPassword = await asLegacyResult(svc.disableMfa(user.id, user.email, { password: undefined, code: '123456' }));
|
||||||
expect(missingPassword.status).toBe(400);
|
expect(missingPassword.status).toBe(400);
|
||||||
expect(missingPassword.error).toMatch(/password and authenticator code/i);
|
expect(missingPassword.error).toMatch(/password and authenticator code/i);
|
||||||
});
|
});
|
||||||
@@ -411,7 +412,7 @@ describe('disableMfa', () => {
|
|||||||
it('AUTH-DB-029: returns 400 when MFA is not enabled on the account', async () => {
|
it('AUTH-DB-029: returns 400 when MFA is not enabled on the account', async () => {
|
||||||
const { user } = createUser(testDb);
|
const { user } = createUser(testDb);
|
||||||
// mfa_enabled defaults to 0 / not set
|
// mfa_enabled defaults to 0 / not set
|
||||||
const result = await svc.disableMfa(user.id, user.email, { password: 'password123', code: '000000' });
|
const result = await asLegacyResult(svc.disableMfa(user.id, user.email, { password: 'password123', code: '000000' }));
|
||||||
expect(result.status).toBe(400);
|
expect(result.status).toBe(400);
|
||||||
expect(result.error).toMatch(/not enabled/i);
|
expect(result.error).toMatch(/not enabled/i);
|
||||||
});
|
});
|
||||||
@@ -423,7 +424,7 @@ describe('disableMfa', () => {
|
|||||||
|
|
||||||
describe('validateInviteToken', () => {
|
describe('validateInviteToken', () => {
|
||||||
it('AUTH-DB-030: returns 404 for unknown token', async () => {
|
it('AUTH-DB-030: returns 404 for unknown token', async () => {
|
||||||
const result = await svc.validateInviteToken('no-such-token');
|
const result = await asLegacyResult(svc.validateInviteToken('no-such-token'));
|
||||||
expect(result.status).toBe(404);
|
expect(result.status).toBe(404);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -432,13 +433,13 @@ describe('validateInviteToken', () => {
|
|||||||
const invite = createInviteToken(testDb, { max_uses: 1 });
|
const invite = createInviteToken(testDb, { max_uses: 1 });
|
||||||
// manually set used_count = 1 to simulate exhaustion
|
// manually set used_count = 1 to simulate exhaustion
|
||||||
await updateRows(orm, InviteTokens, { id: invite.id }, { used_count: 1 });
|
await updateRows(orm, InviteTokens, { id: invite.id }, { used_count: 1 });
|
||||||
const result = await svc.validateInviteToken(invite.token);
|
const result = await asLegacyResult(svc.validateInviteToken(invite.token));
|
||||||
expect(result.status).toBe(410);
|
expect(result.status).toBe(410);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('AUTH-DB-032: returns 410 when expired', async () => {
|
it('AUTH-DB-032: returns 410 when expired', async () => {
|
||||||
const invite = createInviteToken(testDb, { expires_at: '2000-01-01T00:00:00.000Z' });
|
const invite = createInviteToken(testDb, { expires_at: '2000-01-01T00:00:00.000Z' });
|
||||||
const result = await svc.validateInviteToken(invite.token);
|
const result = await asLegacyResult(svc.validateInviteToken(invite.token));
|
||||||
expect(result.status).toBe(410);
|
expect(result.status).toBe(410);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -454,7 +455,7 @@ describe('registerUser — OIDC-only / registration-disabled', () => {
|
|||||||
await insertRow(orm, AppSettings, { key: 'oidc_issuer', value: 'https://x' });
|
await insertRow(orm, AppSettings, { key: 'oidc_issuer', value: 'https://x' });
|
||||||
await insertRow(orm, AppSettings, { key: 'oidc_client_id', value: 'id' });
|
await insertRow(orm, AppSettings, { key: 'oidc_client_id', value: 'id' });
|
||||||
|
|
||||||
const result = await svc.registerUser({ username: 'u', email: 'new@x.com', password: 'Secure123!' });
|
const result = await asLegacyResult(svc.registerUser({ username: 'u', email: 'new@x.com', password: 'Secure123!' }));
|
||||||
expect(result.status).toBe(403);
|
expect(result.status).toBe(403);
|
||||||
expect(result.error).toMatch(/password registration is disabled/i);
|
expect(result.error).toMatch(/password registration is disabled/i);
|
||||||
});
|
});
|
||||||
@@ -463,7 +464,7 @@ describe('registerUser — OIDC-only / registration-disabled', () => {
|
|||||||
createUser(testDb); // ensure userCount > 0
|
createUser(testDb); // ensure userCount > 0
|
||||||
await appSetting('allow_registration', 'false');
|
await appSetting('allow_registration', 'false');
|
||||||
|
|
||||||
const result = await svc.registerUser({ username: 'u2', email: 'n2@x.com', password: 'Secure123!' });
|
const result = await asLegacyResult(svc.registerUser({ username: 'u2', email: 'n2@x.com', password: 'Secure123!' }));
|
||||||
expect(result.status).toBe(403);
|
expect(result.status).toBe(403);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -495,7 +496,7 @@ describe('changePassword — OIDC-only mode', () => {
|
|||||||
await appSetting('oidc_issuer', 'https://x');
|
await appSetting('oidc_issuer', 'https://x');
|
||||||
await appSetting('oidc_client_id', 'id');
|
await appSetting('oidc_client_id', 'id');
|
||||||
|
|
||||||
const result = await svc.changePassword(user.id, user.email, { current_password: password, new_password: 'New1234!' });
|
const result = await asLegacyResult(svc.changePassword(user.id, user.email, { current_password: password, new_password: 'New1234!' }));
|
||||||
expect(result.status).toBe(403);
|
expect(result.status).toBe(403);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -511,7 +512,7 @@ describe('changePassword — session invalidation', () => {
|
|||||||
expect(await pvOf(user.id)).toBe(0);
|
expect(await pvOf(user.id)).toBe(0);
|
||||||
expect(await mcpCount(user.id)).toBe(1);
|
expect(await mcpCount(user.id)).toBe(1);
|
||||||
|
|
||||||
const result = await svc.changePassword(user.id, user.email, { current_password: password, new_password: 'New1234!' });
|
const result = await asLegacyResult(svc.changePassword(user.id, user.email, { current_password: password, new_password: 'New1234!' }));
|
||||||
|
|
||||||
expect(result.success).toBe(true);
|
expect(result.success).toBe(true);
|
||||||
expect(typeof result.token).toBe('string'); // fresh session for the current device
|
expect(typeof result.token).toBe('string'); // fresh session for the current device
|
||||||
@@ -525,7 +526,7 @@ describe('changePassword — session invalidation', () => {
|
|||||||
|
|
||||||
expect(await verifyJwtAndLoadUser(stolen, await createTestUsersRepo(testDb), await createTestUserSessionsRepo(testDb))).not.toBeNull();
|
expect(await verifyJwtAndLoadUser(stolen, await createTestUsersRepo(testDb), await createTestUserSessionsRepo(testDb))).not.toBeNull();
|
||||||
|
|
||||||
await svc.changePassword(user.id, user.email, { current_password: password, new_password: 'New1234!' });
|
await asLegacyResult(svc.changePassword(user.id, user.email, { current_password: password, new_password: 'New1234!' }));
|
||||||
|
|
||||||
expect(await verifyJwtAndLoadUser(stolen, await createTestUsersRepo(testDb), await createTestUserSessionsRepo(testDb))).toBeNull(); // invalidated by the pv bump
|
expect(await verifyJwtAndLoadUser(stolen, await createTestUsersRepo(testDb), await createTestUserSessionsRepo(testDb))).toBeNull(); // invalidated by the pv bump
|
||||||
});
|
});
|
||||||
@@ -534,7 +535,7 @@ describe('changePassword — session invalidation', () => {
|
|||||||
const { user, password } = createUser(testDb);
|
const { user, password } = createUser(testDb);
|
||||||
await svc.generateToken({ id: user.id });
|
await svc.generateToken({ id: user.id });
|
||||||
|
|
||||||
const result = await svc.changePassword(user.id, user.email, { current_password: password, new_password: 'New1234!' }, undefined, {}, false);
|
const result = await asLegacyResult(svc.changePassword(user.id, user.email, { current_password: password, new_password: 'New1234!' }, undefined, {}, false));
|
||||||
|
|
||||||
expect(result).toEqual({ success: true });
|
expect(result).toEqual({ success: true });
|
||||||
expect(await pvOf(user.id)).toBe(1);
|
expect(await pvOf(user.id)).toBe(1);
|
||||||
@@ -548,7 +549,7 @@ describe('changePassword — session invalidation', () => {
|
|||||||
const jwt = require('jsonwebtoken');
|
const jwt = require('jsonwebtoken');
|
||||||
const { user, password } = createUser(testDb);
|
const { user, password } = createUser(testDb);
|
||||||
|
|
||||||
const result = await svc.changePassword(user.id, user.email, { current_password: password, new_password: 'New1234!' }, true);
|
const result = await asLegacyResult(svc.changePassword(user.id, user.email, { current_password: password, new_password: 'New1234!' }, true));
|
||||||
expect(result.success).toBe(true);
|
expect(result.success).toBe(true);
|
||||||
const decoded = jwt.decode(result.token!) as { remember?: boolean; iat: number; exp: number };
|
const decoded = jwt.decode(result.token!) as { remember?: boolean; iat: number; exp: number };
|
||||||
expect(decoded.remember).toBe(true);
|
expect(decoded.remember).toBe(true);
|
||||||
@@ -580,7 +581,7 @@ describe('changePassword — session invalidation', () => {
|
|||||||
await addPushDevice(user.id, 'https://web.push.apple.com/mine-2');
|
await addPushDevice(user.id, 'https://web.push.apple.com/mine-2');
|
||||||
await addPushDevice(other.id, 'https://fcm.googleapis.com/fcm/send/theirs');
|
await addPushDevice(other.id, 'https://fcm.googleapis.com/fcm/send/theirs');
|
||||||
|
|
||||||
const result = await svc.changePassword(user.id, user.email, { current_password: password, new_password: 'New1234!' });
|
const result = await asLegacyResult(svc.changePassword(user.id, user.email, { current_password: password, new_password: 'New1234!' }));
|
||||||
|
|
||||||
expect(result.success).toBe(true);
|
expect(result.success).toBe(true);
|
||||||
expect(await pushDeviceCount(user.id)).toBe(0);
|
expect(await pushDeviceCount(user.id)).toBe(0);
|
||||||
@@ -591,7 +592,7 @@ describe('changePassword — session invalidation', () => {
|
|||||||
const { user } = createUser(testDb);
|
const { user } = createUser(testDb);
|
||||||
await addPushDevice(user.id, 'https://fcm.googleapis.com/fcm/send/mine');
|
await addPushDevice(user.id, 'https://fcm.googleapis.com/fcm/send/mine');
|
||||||
|
|
||||||
const result = await svc.changePassword(user.id, user.email, { current_password: 'wrong', new_password: 'New1234!' });
|
const result = await asLegacyResult(svc.changePassword(user.id, user.email, { current_password: 'wrong', new_password: 'New1234!' }));
|
||||||
|
|
||||||
expect(result.status).toBe(401);
|
expect(result.status).toBe(401);
|
||||||
expect(await pushDeviceCount(user.id)).toBe(1);
|
expect(await pushDeviceCount(user.id)).toBe(1);
|
||||||
@@ -614,7 +615,7 @@ describe('changePassword — session invalidation', () => {
|
|||||||
expect(await mcpCount(user.id)).toBe(1);
|
expect(await mcpCount(user.id)).toBe(1);
|
||||||
expect(await pushDeviceCount(user.id)).toBe(1);
|
expect(await pushDeviceCount(user.id)).toBe(1);
|
||||||
// The new hash rolled back as well, so the old password is still the one that counts.
|
// The new hash rolled back as well, so the old password is still the one that counts.
|
||||||
expect((await svc.changePassword(user.id, user.email, { current_password: password, new_password: 'New1234!' })).success)
|
expect((await asLegacyResult(svc.changePassword(user.id, user.email, { current_password: password, new_password: 'New1234!' }))).success)
|
||||||
.toBe(true);
|
.toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -628,7 +629,7 @@ describe('disableMfa — require_mfa policy', () => {
|
|||||||
const { user } = createUser(testDb);
|
const { user } = createUser(testDb);
|
||||||
await appSetting('require_mfa', 'true');
|
await appSetting('require_mfa', 'true');
|
||||||
|
|
||||||
const result = await svc.disableMfa(user.id, user.email, { password: 'pass', code: '123456' });
|
const result = await asLegacyResult(svc.disableMfa(user.id, user.email, { password: 'pass', code: '123456' }));
|
||||||
expect(result.status).toBe(403);
|
expect(result.status).toBe(403);
|
||||||
expect(result.error).toMatch(/cannot be disabled/i);
|
expect(result.error).toMatch(/cannot be disabled/i);
|
||||||
});
|
});
|
||||||
@@ -640,7 +641,7 @@ describe('disableMfa — require_mfa policy', () => {
|
|||||||
|
|
||||||
describe('verifyMfaLogin — validation', () => {
|
describe('verifyMfaLogin — validation', () => {
|
||||||
it('AUTH-DB-038: returns 400 when mfa_token or code is missing', async () => {
|
it('AUTH-DB-038: returns 400 when mfa_token or code is missing', async () => {
|
||||||
const result = await svc.verifyMfaLogin({ mfa_token: undefined, code: undefined });
|
const result = await asLegacyResult(svc.verifyMfaLogin({ mfa_token: undefined, code: undefined }));
|
||||||
expect(result.status).toBe(400);
|
expect(result.status).toBe(400);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -648,7 +649,7 @@ describe('verifyMfaLogin — validation', () => {
|
|||||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||||
const jwt = require('jsonwebtoken');
|
const jwt = require('jsonwebtoken');
|
||||||
const tok = jwt.sign({ id: 1, purpose: 'wrong' }, 'test-secret', { expiresIn: '5m', algorithm: 'HS256' });
|
const tok = jwt.sign({ id: 1, purpose: 'wrong' }, 'test-secret', { expiresIn: '5m', algorithm: 'HS256' });
|
||||||
const result = await svc.verifyMfaLogin({ mfa_token: tok, code: '123456' });
|
const result = await asLegacyResult(svc.verifyMfaLogin({ mfa_token: tok, code: '123456' }));
|
||||||
expect(result.status).toBe(401);
|
expect(result.status).toBe(401);
|
||||||
expect(result.error).toMatch(/invalid/i);
|
expect(result.error).toMatch(/invalid/i);
|
||||||
});
|
});
|
||||||
@@ -657,7 +658,7 @@ describe('verifyMfaLogin — validation', () => {
|
|||||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||||
const jwt = require('jsonwebtoken');
|
const jwt = require('jsonwebtoken');
|
||||||
const tok = jwt.sign({ id: 99999, purpose: 'mfa_login' }, 'test-secret', { expiresIn: '5m', algorithm: 'HS256' });
|
const tok = jwt.sign({ id: 99999, purpose: 'mfa_login' }, 'test-secret', { expiresIn: '5m', algorithm: 'HS256' });
|
||||||
const result = await svc.verifyMfaLogin({ mfa_token: tok, code: '123456' });
|
const result = await asLegacyResult(svc.verifyMfaLogin({ mfa_token: tok, code: '123456' }));
|
||||||
expect(result.status).toBe(401);
|
expect(result.status).toBe(401);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -789,16 +790,16 @@ describe('getAppConfig', () => {
|
|||||||
describe('demoLogin', () => {
|
describe('demoLogin', () => {
|
||||||
it('AUTH-DB-053: 404 outside demo mode', async () => {
|
it('AUTH-DB-053: 404 outside demo mode', async () => {
|
||||||
vi.stubEnv('DEMO_MODE', '');
|
vi.stubEnv('DEMO_MODE', '');
|
||||||
expect(await svc.demoLogin()).toEqual({ error: 'Not found', status: 404 });
|
expect(await asLegacyResult(svc.demoLogin())).toEqual({ error: 'Not found', status: 404 });
|
||||||
vi.unstubAllEnvs();
|
vi.unstubAllEnvs();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('AUTH-DB-054: 500 when the demo user row is missing; token + safe user when present', async () => {
|
it('AUTH-DB-054: 500 when the demo user row is missing; token + safe user when present', async () => {
|
||||||
vi.stubEnv('DEMO_MODE', 'true');
|
vi.stubEnv('DEMO_MODE', 'true');
|
||||||
expect(await svc.demoLogin()).toEqual({ error: 'Demo user not found', status: 500 });
|
expect(await asLegacyResult(svc.demoLogin())).toEqual({ error: 'Demo user not found', status: 500 });
|
||||||
// demoLogin looks up DEMO_EMAIL_PRIMARY specifically (not any demo alias).
|
// demoLogin looks up DEMO_EMAIL_PRIMARY specifically (not any demo alias).
|
||||||
const { user } = createUser(testDb, { email: 'demo@trek.app' });
|
const { user } = createUser(testDb, { email: 'demo@trek.app' });
|
||||||
const result = await svc.demoLogin({ userAgent: 'Demo visitor' });
|
const result = await asLegacyResult(svc.demoLogin({ userAgent: 'Demo visitor' }));
|
||||||
expect(typeof result.token).toBe('string');
|
expect(typeof result.token).toBe('string');
|
||||||
expect(result.user).not.toHaveProperty('password_hash');
|
expect(result.user).not.toHaveProperty('password_hash');
|
||||||
expect((await sessionRows(testDb, user.id)).map((r) => r.user_agent)).toEqual(['Demo visitor']);
|
expect((await sessionRows(testDb, user.id)).map((r) => r.user_agent)).toEqual(['Demo visitor']);
|
||||||
@@ -809,7 +810,7 @@ describe('demoLogin', () => {
|
|||||||
describe('validateInviteToken — valid path', () => {
|
describe('validateInviteToken — valid path', () => {
|
||||||
it('AUTH-DB-055: returns valid with usage metadata', async () => {
|
it('AUTH-DB-055: returns valid with usage metadata', async () => {
|
||||||
const invite = createInviteToken(testDb, { max_uses: 5 });
|
const invite = createInviteToken(testDb, { max_uses: 5 });
|
||||||
const result = await svc.validateInviteToken(invite.token);
|
const result = await asLegacyResult(svc.validateInviteToken(invite.token));
|
||||||
expect(result.valid).toBe(true);
|
expect(result.valid).toBe(true);
|
||||||
expect(result.max_uses).toBe(5);
|
expect(result.max_uses).toBe(5);
|
||||||
expect(result.used_count).toBe(0);
|
expect(result.used_count).toBe(0);
|
||||||
@@ -818,7 +819,7 @@ describe('validateInviteToken — valid path', () => {
|
|||||||
|
|
||||||
describe('registerUser — success paths', () => {
|
describe('registerUser — success paths', () => {
|
||||||
it('AUTH-DB-056: first user becomes admin and gets a token', async () => {
|
it('AUTH-DB-056: first user becomes admin and gets a token', async () => {
|
||||||
const result = await svc.registerUser({ username: 'first', email: 'first@x.com', password: 'Secure123!' });
|
const result = await asLegacyResult(svc.registerUser({ username: 'first', email: 'first@x.com', password: 'Secure123!' }));
|
||||||
expect(result.error).toBeUndefined();
|
expect(result.error).toBeUndefined();
|
||||||
expect(typeof result.token).toBe('string');
|
expect(typeof result.token).toBe('string');
|
||||||
expect((result.user as { role: string }).role).toBe('admin');
|
expect((result.user as { role: string }).role).toBe('admin');
|
||||||
@@ -827,11 +828,11 @@ describe('registerUser — success paths', () => {
|
|||||||
|
|
||||||
it('AUTH-DB-057: missing fields / bad email / duplicate answer their bespoke 400/409s', async () => {
|
it('AUTH-DB-057: missing fields / bad email / duplicate answer their bespoke 400/409s', async () => {
|
||||||
createUser(testDb, { username: 'taken', email: 'taken@x.com' });
|
createUser(testDb, { username: 'taken', email: 'taken@x.com' });
|
||||||
expect(await svc.registerUser({ username: '', email: 'a@x.com', password: 'Secure123!' }))
|
expect(await asLegacyResult(svc.registerUser({ username: '', email: 'a@x.com', password: 'Secure123!' })))
|
||||||
.toEqual({ error: 'Username, email and password are required', status: 400 });
|
.toEqual({ error: 'Username, email and password are required', status: 400 });
|
||||||
expect(await svc.registerUser({ username: 'u', email: 'not-an-email', password: 'Secure123!' }))
|
expect(await asLegacyResult(svc.registerUser({ username: 'u', email: 'not-an-email', password: 'Secure123!' })))
|
||||||
.toEqual({ error: 'Invalid email format', status: 400 });
|
.toEqual({ error: 'Invalid email format', status: 400 });
|
||||||
expect(await svc.registerUser({ username: 'TAKEN', email: 'other@x.com', password: 'Secure123!' }))
|
expect(await asLegacyResult(svc.registerUser({ username: 'TAKEN', email: 'other@x.com', password: 'Secure123!' })))
|
||||||
.toEqual({ error: 'Registration failed. Please try different credentials.', status: 409 });
|
.toEqual({ error: 'Registration failed. Please try different credentials.', status: 409 });
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -839,7 +840,7 @@ describe('registerUser — success paths', () => {
|
|||||||
createUser(testDb);
|
createUser(testDb);
|
||||||
await appSetting('allow_registration', 'false');
|
await appSetting('allow_registration', 'false');
|
||||||
const invite = createInviteToken(testDb, { max_uses: 2 });
|
const invite = createInviteToken(testDb, { max_uses: 2 });
|
||||||
const result = await svc.registerUser({ username: 'invited', email: 'invited@x.com', password: 'Secure123!', invite_token: invite.token });
|
const result = await asLegacyResult(svc.registerUser({ username: 'invited', email: 'invited@x.com', password: 'Secure123!', invite_token: invite.token }));
|
||||||
expect(result.error).toBeUndefined();
|
expect(result.error).toBeUndefined();
|
||||||
const { used_count } = (await findRow(orm, InviteTokens, { id: invite.id })) ?? {};
|
const { used_count } = (await findRow(orm, InviteTokens, { id: invite.id })) ?? {};
|
||||||
expect(used_count).toBe(1);
|
expect(used_count).toBe(1);
|
||||||
@@ -911,20 +912,20 @@ describe('getCurrentUser', () => {
|
|||||||
describe('deleteAccount', () => {
|
describe('deleteAccount', () => {
|
||||||
it('AUTH-DB-065: refuses to delete the last admin', async () => {
|
it('AUTH-DB-065: refuses to delete the last admin', async () => {
|
||||||
const { user } = createAdmin(testDb);
|
const { user } = createAdmin(testDb);
|
||||||
expect(await svc.deleteAccount(user.id, user.email, 'admin'))
|
expect(await asLegacyResult(svc.deleteAccount(user.id, user.email, 'admin')))
|
||||||
.toEqual({ error: 'Cannot delete the last admin account', status: 400 });
|
.toEqual({ error: 'Cannot delete the last admin account', status: 400 });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('AUTH-DB-066: demo mode blocks deletion', async () => {
|
it('AUTH-DB-066: demo mode blocks deletion', async () => {
|
||||||
vi.stubEnv('DEMO_MODE', 'true');
|
vi.stubEnv('DEMO_MODE', 'true');
|
||||||
expect(await svc.deleteAccount(1, 'demo@nomad.app', 'user'))
|
expect(await asLegacyResult(svc.deleteAccount(1, 'demo@nomad.app', 'user')))
|
||||||
.toEqual({ error: 'Account deletion is disabled in demo mode.', status: 403 });
|
.toEqual({ error: 'Account deletion is disabled in demo mode.', status: 403 });
|
||||||
vi.unstubAllEnvs();
|
vi.unstubAllEnvs();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('AUTH-DB-067: deletes a regular user row', async () => {
|
it('AUTH-DB-067: deletes a regular user row', async () => {
|
||||||
const { user } = createUser(testDb);
|
const { user } = createUser(testDb);
|
||||||
expect(await svc.deleteAccount(user.id, user.email, 'user')).toEqual({ success: true });
|
expect(await asLegacyResult(svc.deleteAccount(user.id, user.email, 'user'))).toEqual({ success: true });
|
||||||
expect(await findRow(orm, Users, { id: user.id })).toBeNull();
|
expect(await findRow(orm, Users, { id: user.id })).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -932,12 +933,12 @@ describe('deleteAccount', () => {
|
|||||||
describe('updateAppSettings', () => {
|
describe('updateAppSettings', () => {
|
||||||
it('AUTH-DB-071: 403 for non-admin', async () => {
|
it('AUTH-DB-071: 403 for non-admin', async () => {
|
||||||
const { user } = createUser(testDb);
|
const { user } = createUser(testDb);
|
||||||
expect((await svc.updateAppSettings(user.id, {})).status).toBe(403);
|
expect((await asLegacyResult(svc.updateAppSettings(user.id, {}))).status).toBe(403);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('AUTH-DB-072: require_mfa=true refuses when the admin has neither MFA nor a passkey', async () => {
|
it('AUTH-DB-072: require_mfa=true refuses when the admin has neither MFA nor a passkey', async () => {
|
||||||
const { user } = createAdmin(testDb);
|
const { user } = createAdmin(testDb);
|
||||||
const result = await svc.updateAppSettings(user.id, { require_mfa: true });
|
const result = await asLegacyResult(svc.updateAppSettings(user.id, { require_mfa: true }));
|
||||||
expect(result.status).toBe(400);
|
expect(result.status).toBe(400);
|
||||||
expect(result.error).toMatch(/secure your own account/i);
|
expect(result.error).toMatch(/secure your own account/i);
|
||||||
});
|
});
|
||||||
@@ -945,7 +946,7 @@ describe('updateAppSettings', () => {
|
|||||||
it('AUTH-DB-073: require_mfa=true is allowed once the admin has MFA enabled', async () => {
|
it('AUTH-DB-073: require_mfa=true is allowed once the admin has MFA enabled', async () => {
|
||||||
const { user } = createAdmin(testDb);
|
const { user } = createAdmin(testDb);
|
||||||
await updateRows(orm, Users, { id: user.id }, { mfa_enabled: 1 });
|
await updateRows(orm, Users, { id: user.id }, { mfa_enabled: 1 });
|
||||||
const result = await svc.updateAppSettings(user.id, { require_mfa: true });
|
const result = await asLegacyResult(svc.updateAppSettings(user.id, { require_mfa: true }));
|
||||||
expect(result.success).toBe(true);
|
expect(result.success).toBe(true);
|
||||||
expect(result.auditSummary).toMatchObject({ require_mfa: true });
|
expect(result.auditSummary).toMatchObject({ require_mfa: true });
|
||||||
await deleteRows(orm, AppSettings, { key: 'require_mfa' });
|
await deleteRows(orm, AppSettings, { key: 'require_mfa' });
|
||||||
@@ -953,7 +954,7 @@ describe('updateAppSettings', () => {
|
|||||||
|
|
||||||
it('AUTH-DB-074: lockout prevention refuses disabling every login method', async () => {
|
it('AUTH-DB-074: lockout prevention refuses disabling every login method', async () => {
|
||||||
const { user } = createAdmin(testDb);
|
const { user } = createAdmin(testDb);
|
||||||
const result = await svc.updateAppSettings(user.id, { password_login: 'false', oidc_login: 'false' });
|
const result = await asLegacyResult(svc.updateAppSettings(user.id, { password_login: 'false', oidc_login: 'false' }));
|
||||||
expect(result).toEqual({ error: 'Cannot disable all login methods. At least one must remain enabled.', status: 400 });
|
expect(result).toEqual({ error: 'Cannot disable all login methods. At least one must remain enabled.', status: 400 });
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -962,7 +963,7 @@ describe('updateAppSettings', () => {
|
|||||||
// longer flags (or needs) a scheduler restart.
|
// longer flags (or needs) a scheduler restart.
|
||||||
const { user } = createAdmin(testDb);
|
const { user } = createAdmin(testDb);
|
||||||
await appSetting('smtp_pass', 'stored');
|
await appSetting('smtp_pass', 'stored');
|
||||||
const result = await svc.updateAppSettings(user.id, { smtp_pass: '••••••••', notification_channels: 'email' });
|
const result = await asLegacyResult(svc.updateAppSettings(user.id, { smtp_pass: '••••••••', notification_channels: 'email' }));
|
||||||
expect(result.success).toBe(true);
|
expect(result.success).toBe(true);
|
||||||
const value = await readAppSetting(orm, 'smtp_pass');
|
const value = await readAppSetting(orm, 'smtp_pass');
|
||||||
expect(value).toBe('stored'); // sentinel never overwrites the secret
|
expect(value).toBe('stored'); // sentinel never overwrites the secret
|
||||||
@@ -974,9 +975,9 @@ describe('updateAppSettings', () => {
|
|||||||
describe('MFA success flows', () => {
|
describe('MFA success flows', () => {
|
||||||
it('AUTH-DB-076: setup → enable with a valid TOTP code returns backup codes and persists the encrypted secret', async () => {
|
it('AUTH-DB-076: setup → enable with a valid TOTP code returns backup codes and persists the encrypted secret', async () => {
|
||||||
const { user } = createUser(testDb);
|
const { user } = createUser(testDb);
|
||||||
const setup = await svc.setupMfa(user.id, user.email);
|
const setup = await asLegacyResult(svc.setupMfa(user.id, user.email));
|
||||||
const code = authenticator.generate(setup.secret!);
|
const code = authenticator.generate(setup.secret!);
|
||||||
const result = await svc.enableMfa(user.id, code);
|
const result = await asLegacyResult(svc.enableMfa(user.id, code));
|
||||||
expect(result.success).toBe(true);
|
expect(result.success).toBe(true);
|
||||||
expect(result.backup_codes).toHaveLength(10);
|
expect(result.backup_codes).toHaveLength(10);
|
||||||
const row = await readUser(orm, user.id);
|
const row = await readUser(orm, user.id);
|
||||||
@@ -986,15 +987,15 @@ describe('MFA success flows', () => {
|
|||||||
|
|
||||||
it('AUTH-DB-077: enable with a wrong code answers 401 and keeps the pending secret', async () => {
|
it('AUTH-DB-077: enable with a wrong code answers 401 and keeps the pending secret', async () => {
|
||||||
const { user } = createUser(testDb);
|
const { user } = createUser(testDb);
|
||||||
await svc.setupMfa(user.id, user.email);
|
await asLegacyResult(svc.setupMfa(user.id, user.email));
|
||||||
expect(await svc.enableMfa(user.id, '000000')).toEqual({ error: 'Invalid verification code', status: 401 });
|
expect(await asLegacyResult(svc.enableMfa(user.id, '000000'))).toEqual({ error: 'Invalid verification code', status: 401 });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('AUTH-DB-078: disableMfa succeeds with the right password + TOTP code', async () => {
|
it('AUTH-DB-078: disableMfa succeeds with the right password + TOTP code', async () => {
|
||||||
const { user, password } = createUser(testDb);
|
const { user, password } = createUser(testDb);
|
||||||
const secret = authenticator.generateSecret();
|
const secret = authenticator.generateSecret();
|
||||||
await updateRows(orm, Users, { id: user.id }, { mfa_enabled: 1, mfa_secret: 'enc:' + secret });
|
await updateRows(orm, Users, { id: user.id }, { mfa_enabled: 1, mfa_secret: 'enc:' + secret });
|
||||||
const result = await svc.disableMfa(user.id, user.email, { password, code: authenticator.generate(secret) });
|
const result = await asLegacyResult(svc.disableMfa(user.id, user.email, { password, code: authenticator.generate(secret) }));
|
||||||
expect(result).toEqual({ success: true, mfa_enabled: false });
|
expect(result).toEqual({ success: true, mfa_enabled: false });
|
||||||
const row = await readUser(orm, user.id);
|
const row = await readUser(orm, user.id);
|
||||||
expect(row.mfa_enabled).toBe(0);
|
expect(row.mfa_enabled).toBe(0);
|
||||||
@@ -1005,7 +1006,7 @@ describe('MFA success flows', () => {
|
|||||||
const { user } = createUser(testDb);
|
const { user } = createUser(testDb);
|
||||||
const secret = authenticator.generateSecret();
|
const secret = authenticator.generateSecret();
|
||||||
await updateRows(orm, Users, { id: user.id }, { mfa_enabled: 1, mfa_secret: 'enc:' + secret });
|
await updateRows(orm, Users, { id: user.id }, { mfa_enabled: 1, mfa_secret: 'enc:' + secret });
|
||||||
expect(await svc.disableMfa(user.id, user.email, { password: 'wrong', code: authenticator.generate(secret) }))
|
expect(await asLegacyResult(svc.disableMfa(user.id, user.email, { password: 'wrong', code: authenticator.generate(secret) })))
|
||||||
.toEqual({ error: 'Incorrect password', status: 401 });
|
.toEqual({ error: 'Incorrect password', status: 401 });
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1015,7 +1016,7 @@ describe('MFA success flows', () => {
|
|||||||
await updateRows(orm, Users, { id: user.id }, { mfa_enabled: 1, mfa_secret: 'enc:' + secret });
|
await updateRows(orm, Users, { id: user.id }, { mfa_enabled: 1, mfa_secret: 'enc:' + secret });
|
||||||
const interstitial = await svc.loginUser({ email: user.email, password });
|
const interstitial = await svc.loginUser({ email: user.email, password });
|
||||||
expect(interstitial.mfa_required).toBe(true);
|
expect(interstitial.mfa_required).toBe(true);
|
||||||
const result = await svc.verifyMfaLogin({ mfa_token: interstitial.mfa_token, code: authenticator.generate(secret) });
|
const result = await asLegacyResult(svc.verifyMfaLogin({ mfa_token: interstitial.mfa_token, code: authenticator.generate(secret) }));
|
||||||
expect(typeof result.token).toBe('string');
|
expect(typeof result.token).toBe('string');
|
||||||
expect(result.auditUserId).toBe(user.id);
|
expect(result.auditUserId).toBe(user.id);
|
||||||
});
|
});
|
||||||
@@ -1029,7 +1030,7 @@ describe('MFA success flows', () => {
|
|||||||
mfa_enabled: 1, mfa_secret: 'enc:' + secret, mfa_backup_codes: JSON.stringify(codes.map(hashBackupCode)), updated_at: '2000-01-01T00:00:00.000Z',
|
mfa_enabled: 1, mfa_secret: 'enc:' + secret, mfa_backup_codes: JSON.stringify(codes.map(hashBackupCode)), updated_at: '2000-01-01T00:00:00.000Z',
|
||||||
});
|
});
|
||||||
const interstitial = await svc.loginUser({ email: user.email, password });
|
const interstitial = await svc.loginUser({ email: user.email, password });
|
||||||
const result = await svc.verifyMfaLogin({ mfa_token: interstitial.mfa_token, code: 'AAAA-1111' });
|
const result = await asLegacyResult(svc.verifyMfaLogin({ mfa_token: interstitial.mfa_token, code: 'AAAA-1111' }));
|
||||||
expect(typeof result.token).toBe('string');
|
expect(typeof result.token).toBe('string');
|
||||||
const row = await readUser(orm, user.id);
|
const row = await readUser(orm, user.id);
|
||||||
expect(JSON.parse(row.mfa_backup_codes)).toHaveLength(1); // used code spliced out
|
expect(JSON.parse(row.mfa_backup_codes)).toHaveLength(1); // used code spliced out
|
||||||
@@ -1038,7 +1039,7 @@ describe('MFA success flows', () => {
|
|||||||
expect(row.updated_at).not.toBe('2000-01-01T00:00:00.000Z');
|
expect(row.updated_at).not.toBe('2000-01-01T00:00:00.000Z');
|
||||||
// the spent code no longer verifies
|
// the spent code no longer verifies
|
||||||
const again = await svc.loginUser({ email: user.email, password });
|
const again = await svc.loginUser({ email: user.email, password });
|
||||||
expect((await svc.verifyMfaLogin({ mfa_token: again.mfa_token, code: 'AAAA-1111' })).status).toBe(401);
|
expect((await asLegacyResult(svc.verifyMfaLogin({ mfa_token: again.mfa_token, code: 'AAAA-1111' }))).status).toBe(401);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1047,12 +1048,12 @@ describe('resetPassword', () => {
|
|||||||
const { user } = createUser(testDb);
|
const { user } = createUser(testDb);
|
||||||
const issued = await svc.requestPasswordReset(user.email, '1.2.3.4');
|
const issued = await svc.requestPasswordReset(user.email, '1.2.3.4');
|
||||||
expect(issued.reason).toBe('issued');
|
expect(issued.reason).toBe('issued');
|
||||||
const result = await svc.resetPassword({ token: issued.tokenForDelivery!, new_password: 'Fresh123!' });
|
const result = await asLegacyResult(svc.resetPassword({ token: issued.tokenForDelivery!, new_password: 'Fresh123!' }));
|
||||||
expect(result).toEqual({ success: true, userId: user.id });
|
expect(result).toEqual({ success: true, userId: user.id });
|
||||||
const row = await readUser(orm, user.id);
|
const row = await readUser(orm, user.id);
|
||||||
expect(row.password_version).toBe(1);
|
expect(row.password_version).toBe(1);
|
||||||
// token is burned — a second use answers the bespoke 400
|
// token is burned — a second use answers the bespoke 400
|
||||||
expect(await svc.resetPassword({ token: issued.tokenForDelivery!, new_password: 'Fresh456!' }))
|
expect(await asLegacyResult(svc.resetPassword({ token: issued.tokenForDelivery!, new_password: 'Fresh456!' })))
|
||||||
.toEqual({ error: 'This reset link has already been used', status: 400 });
|
.toEqual({ error: 'This reset link has already been used', status: 400 });
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1064,7 +1065,7 @@ describe('resetPassword', () => {
|
|||||||
await addPushDevice(other.id, 'https://fcm.googleapis.com/fcm/send/theirs');
|
await addPushDevice(other.id, 'https://fcm.googleapis.com/fcm/send/theirs');
|
||||||
const issued = await svc.requestPasswordReset(user.email, null);
|
const issued = await svc.requestPasswordReset(user.email, null);
|
||||||
|
|
||||||
expect(await svc.resetPassword({ token: issued.tokenForDelivery!, new_password: 'Fresh123!' }))
|
expect(await asLegacyResult(svc.resetPassword({ token: issued.tokenForDelivery!, new_password: 'Fresh123!' })))
|
||||||
.toEqual({ success: true, userId: user.id });
|
.toEqual({ success: true, userId: user.id });
|
||||||
|
|
||||||
expect(await countRows(orm, McpTokens, { user: user.id })).toBe(0);
|
expect(await countRows(orm, McpTokens, { user: user.id })).toBe(0);
|
||||||
@@ -1087,21 +1088,21 @@ describe('resetPassword', () => {
|
|||||||
const row = await readUser(orm, user.id);
|
const row = await readUser(orm, user.id);
|
||||||
expect(row.password_version).toBe(0);
|
expect(row.password_version).toBe(0);
|
||||||
// Nothing was burned, so the same link still completes the reset.
|
// Nothing was burned, so the same link still completes the reset.
|
||||||
expect(await svc.resetPassword({ token: issued.tokenForDelivery!, new_password: 'Fresh123!' }))
|
expect(await asLegacyResult(svc.resetPassword({ token: issued.tokenForDelivery!, new_password: 'Fresh123!' })))
|
||||||
.toEqual({ success: true, userId: user.id });
|
.toEqual({ success: true, userId: user.id });
|
||||||
expect(await pushDeviceCount(user.id)).toBe(0);
|
expect(await pushDeviceCount(user.id)).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('AUTH-DB-083: bespoke 400s for missing/unknown/expired tokens', async () => {
|
it('AUTH-DB-083: bespoke 400s for missing/unknown/expired tokens', async () => {
|
||||||
expect(await svc.resetPassword({ new_password: 'Fresh123!' })).toEqual({ error: 'Reset token is required', status: 400 });
|
expect(await asLegacyResult(svc.resetPassword({ new_password: 'Fresh123!' }))).toEqual({ error: 'Reset token is required', status: 400 });
|
||||||
expect(await svc.resetPassword({ token: 't' })).toEqual({ error: 'New password is required', status: 400 });
|
expect(await asLegacyResult(svc.resetPassword({ token: 't' }))).toEqual({ error: 'New password is required', status: 400 });
|
||||||
expect(await svc.resetPassword({ token: 'unknown-token', new_password: 'Fresh123!' }))
|
expect(await asLegacyResult(svc.resetPassword({ token: 'unknown-token', new_password: 'Fresh123!' })))
|
||||||
.toEqual({ error: 'Invalid or expired reset link', status: 400 });
|
.toEqual({ error: 'Invalid or expired reset link', status: 400 });
|
||||||
|
|
||||||
const { user } = createUser(testDb);
|
const { user } = createUser(testDb);
|
||||||
const issued = await svc.requestPasswordReset(user.email, null);
|
const issued = await svc.requestPasswordReset(user.email, null);
|
||||||
await updateRows(orm, PasswordResetTokens, { user: user.id }, { expires_at: '2000-01-01T00:00:00.000Z' });
|
await updateRows(orm, PasswordResetTokens, { user: user.id }, { expires_at: '2000-01-01T00:00:00.000Z' });
|
||||||
expect(await svc.resetPassword({ token: issued.tokenForDelivery!, new_password: 'Fresh123!' }))
|
expect(await asLegacyResult(svc.resetPassword({ token: issued.tokenForDelivery!, new_password: 'Fresh123!' })))
|
||||||
.toEqual({ error: 'Reset link has expired. Please request a new one.', status: 400 });
|
.toEqual({ error: 'Reset link has expired. Please request a new one.', status: 400 });
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1113,7 +1114,7 @@ describe('resetPassword', () => {
|
|||||||
|
|
||||||
const aMinuteAgo = new Date(Date.now() - 60_000).toISOString().slice(0, 19).replace('T', ' ');
|
const aMinuteAgo = new Date(Date.now() - 60_000).toISOString().slice(0, 19).replace('T', ' ');
|
||||||
await updateRows(orm, PasswordResetTokens, { user: user.id }, { expires_at: aMinuteAgo });
|
await updateRows(orm, PasswordResetTokens, { user: user.id }, { expires_at: aMinuteAgo });
|
||||||
expect(await svc.resetPassword({ token: issued.tokenForDelivery ?? '', new_password: 'Fresh123!' }))
|
expect(await asLegacyResult(svc.resetPassword({ token: issued.tokenForDelivery ?? '', new_password: 'Fresh123!' })))
|
||||||
.toEqual({ error: 'Reset link has expired. Please request a new one.', status: 400 });
|
.toEqual({ error: 'Reset link has expired. Please request a new one.', status: 400 });
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1123,10 +1124,10 @@ describe('resetPassword', () => {
|
|||||||
await updateRows(orm, Users, { id: user.id }, { mfa_enabled: 1, mfa_secret: 'enc:' + secret, mfa_backup_codes: JSON.stringify([hashBackupCode('CCCC-3333')]) });
|
await updateRows(orm, Users, { id: user.id }, { mfa_enabled: 1, mfa_secret: 'enc:' + secret, mfa_backup_codes: JSON.stringify([hashBackupCode('CCCC-3333')]) });
|
||||||
const issued = await svc.requestPasswordReset(user.email, null);
|
const issued = await svc.requestPasswordReset(user.email, null);
|
||||||
// no code → mfa_required interstitial, token NOT burned
|
// no code → mfa_required interstitial, token NOT burned
|
||||||
expect(await svc.resetPassword({ token: issued.tokenForDelivery!, new_password: 'Fresh123!' }))
|
expect(await asLegacyResult(svc.resetPassword({ token: issued.tokenForDelivery!, new_password: 'Fresh123!' })))
|
||||||
.toEqual({ mfa_required: true, status: 200 });
|
.toEqual({ mfa_required: true, status: 200 });
|
||||||
// wrong code → 401
|
// wrong code → 401
|
||||||
expect(await svc.resetPassword({ token: issued.tokenForDelivery!, new_password: 'Fresh123!', mfa_code: '000000' }))
|
expect(await asLegacyResult(svc.resetPassword({ token: issued.tokenForDelivery!, new_password: 'Fresh123!', mfa_code: '000000' })))
|
||||||
.toEqual({ error: 'Invalid MFA code', status: 401 });
|
.toEqual({ error: 'Invalid MFA code', status: 401 });
|
||||||
|
|
||||||
// AU44 vs AU33 (F3, task-5-review-security.md): resetPassword's backup-code
|
// AU44 vs AU33 (F3, task-5-review-security.md): resetPassword's backup-code
|
||||||
@@ -1141,7 +1142,7 @@ describe('resetPassword', () => {
|
|||||||
const setBackupCodesAndTouchSpy = vi.spyOn(usersRepo, 'setBackupCodesAndTouch');
|
const setBackupCodesAndTouchSpy = vi.spyOn(usersRepo, 'setBackupCodesAndTouch');
|
||||||
try {
|
try {
|
||||||
// backup code → success + code consumed
|
// backup code → success + code consumed
|
||||||
expect(await svc.resetPassword({ token: issued.tokenForDelivery!, new_password: 'Fresh123!', mfa_code: 'CCCC-3333' }))
|
expect(await asLegacyResult(svc.resetPassword({ token: issued.tokenForDelivery!, new_password: 'Fresh123!', mfa_code: 'CCCC-3333' })))
|
||||||
.toEqual({ success: true, userId: user.id });
|
.toEqual({ success: true, userId: user.id });
|
||||||
expect(setBackupCodesSpy).toHaveBeenCalledTimes(1);
|
expect(setBackupCodesSpy).toHaveBeenCalledTimes(1);
|
||||||
expect(setBackupCodesAndTouchSpy).not.toHaveBeenCalled();
|
expect(setBackupCodesAndTouchSpy).not.toHaveBeenCalled();
|
||||||
@@ -1203,7 +1204,7 @@ describe('auth quirk fixes', () => {
|
|||||||
await updateRows(orm, InviteTokens, { id: invite.id }, { trip: trip.id });
|
await updateRows(orm, InviteTokens, { id: invite.id }, { trip: trip.id });
|
||||||
joinTripAsMember.mockImplementationOnce(() => { throw new Error('boom'); });
|
joinTripAsMember.mockImplementationOnce(() => { throw new Error('boom'); });
|
||||||
|
|
||||||
const result = await svc.registerUser({ username: 'rollback', email: 'rollback@x.com', password: 'Secure123!', invite_token: invite.token });
|
const result = await asLegacyResult(svc.registerUser({ username: 'rollback', email: 'rollback@x.com', password: 'Secure123!', invite_token: invite.token }));
|
||||||
|
|
||||||
expect(result).toEqual({ error: 'Error creating user', status: 500 });
|
expect(result).toEqual({ error: 'Error creating user', status: 500 });
|
||||||
expect(await findRow(orm, Users, { email: 'rollback@x.com' })).toBeNull();
|
expect(await findRow(orm, Users, { email: 'rollback@x.com' })).toBeNull();
|
||||||
@@ -1217,7 +1218,7 @@ describe('auth quirk fixes', () => {
|
|||||||
await updateRows(orm, Users, { id: user.id }, { mfa_enabled: 1, mfa_secret: 'enc:' + secret, mfa_backup_codes: JSON.stringify([hashBackupCode('DDDD-4444')]) });
|
await updateRows(orm, Users, { id: user.id }, { mfa_enabled: 1, mfa_secret: 'enc:' + secret, mfa_backup_codes: JSON.stringify([hashBackupCode('DDDD-4444')]) });
|
||||||
const interstitial = await svc.loginUser({ email: user.email, password });
|
const interstitial = await svc.loginUser({ email: user.email, password });
|
||||||
|
|
||||||
const result = await svc.verifyMfaLogin({ mfa_token: interstitial.mfa_token, code: 'DDDD-4444' });
|
const result = await asLegacyResult(svc.verifyMfaLogin({ mfa_token: interstitial.mfa_token, code: 'DDDD-4444' }));
|
||||||
|
|
||||||
expect(typeof result.token).toBe('string');
|
expect(typeof result.token).toBe('string');
|
||||||
const row = await readUser(orm, user.id);
|
const row = await readUser(orm, user.id);
|
||||||
@@ -1281,7 +1282,7 @@ describe('registerUser loses the race for an email', () => {
|
|||||||
it('AUTH-IDENT-001: a signup the database refuses after the check passed answers the same 409, not a 500', async () => {
|
it('AUTH-IDENT-001: a signup the database refuses after the check passed answers the same 409, not a 500', async () => {
|
||||||
createUser(testDb, { username: 'anna', email: 'anna@x.com' });
|
createUser(testDb, { username: 'anna', email: 'anna@x.com' });
|
||||||
vi.spyOn(UsersRepository.prototype, 'findIdByEmailOrUsernameCI').mockResolvedValue(null);
|
vi.spyOn(UsersRepository.prototype, 'findIdByEmailOrUsernameCI').mockResolvedValue(null);
|
||||||
expect(await svc.registerUser({ username: 'anna2', email: 'ANNA@x.com', password: 'Secure123!' })).toEqual({
|
expect(await asLegacyResult(svc.registerUser({ username: 'anna2', email: 'ANNA@x.com', password: 'Secure123!' }))).toEqual({
|
||||||
error: 'Registration failed. Please try different credentials.',
|
error: 'Registration failed. Please try different credentials.',
|
||||||
status: 409,
|
status: 409,
|
||||||
});
|
});
|
||||||
@@ -1325,7 +1326,7 @@ describe('session tracking', () => {
|
|||||||
const laptop = await svc.generateToken({ id: user.id });
|
const laptop = await svc.generateToken({ id: user.id });
|
||||||
const phone = await svc.generateToken({ id: user.id });
|
const phone = await svc.generateToken({ id: user.id });
|
||||||
|
|
||||||
const result = await svc.changePassword(user.id, user.email, { current_password: password, new_password: 'New1234!' }, undefined, { userAgent: 'Safari' });
|
const result = await asLegacyResult(svc.changePassword(user.id, user.email, { current_password: password, new_password: 'New1234!' }, undefined, { userAgent: 'Safari' }));
|
||||||
|
|
||||||
expect(await svc.verifyJwtToken(laptop)).toBeNull();
|
expect(await svc.verifyJwtToken(laptop)).toBeNull();
|
||||||
expect(await svc.verifyJwtToken(phone)).toBeNull();
|
expect(await svc.verifyJwtToken(phone)).toBeNull();
|
||||||
@@ -1338,7 +1339,7 @@ describe('session tracking', () => {
|
|||||||
const token = await svc.generateToken({ id: user.id });
|
const token = await svc.generateToken({ id: user.id });
|
||||||
const issued = await svc.requestPasswordReset(user.email, null);
|
const issued = await svc.requestPasswordReset(user.email, null);
|
||||||
|
|
||||||
expect(await svc.resetPassword({ token: issued.tokenForDelivery!, new_password: 'Fresh123!' })).toEqual({ success: true, userId: user.id });
|
expect(await asLegacyResult(svc.resetPassword({ token: issued.tokenForDelivery!, new_password: 'Fresh123!' }))).toEqual({ success: true, userId: user.id });
|
||||||
expect(await active(user.id)).toEqual([]);
|
expect(await active(user.id)).toEqual([]);
|
||||||
expect(await svc.verifyJwtToken(token)).toBeNull();
|
expect(await svc.verifyJwtToken(token)).toBeNull();
|
||||||
});
|
});
|
||||||
@@ -1351,7 +1352,7 @@ describe('session tracking', () => {
|
|||||||
const elsewhere = await svc.generateToken({ id: user.id });
|
const elsewhere = await svc.generateToken({ id: user.id });
|
||||||
const hereId = (decodeJwt(here) as { jti: string }).jti;
|
const hereId = (decodeJwt(here) as { jti: string }).jti;
|
||||||
|
|
||||||
expect(await svc.disableMfa(user.id, user.email, { password, code: authenticator.generate(secret) }, hereId)).toEqual({ success: true, mfa_enabled: false });
|
expect(await asLegacyResult(svc.disableMfa(user.id, user.email, { password, code: authenticator.generate(secret) }, hereId))).toEqual({ success: true, mfa_enabled: false });
|
||||||
expect(await svc.verifyJwtToken(here)).not.toBeNull();
|
expect(await svc.verifyJwtToken(here)).not.toBeNull();
|
||||||
expect(await svc.verifyJwtToken(elsewhere)).toBeNull();
|
expect(await svc.verifyJwtToken(elsewhere)).toBeNull();
|
||||||
});
|
});
|
||||||
@@ -1362,7 +1363,7 @@ describe('session tracking', () => {
|
|||||||
await updateRows(orm, Users, { id: user.id }, { mfa_enabled: 1, mfa_secret: 'enc:' + secret });
|
await updateRows(orm, Users, { id: user.id }, { mfa_enabled: 1, mfa_secret: 'enc:' + secret });
|
||||||
const token = await svc.generateToken({ id: user.id });
|
const token = await svc.generateToken({ id: user.id });
|
||||||
|
|
||||||
expect((await svc.disableMfa(user.id, user.email, { password: 'wrong', code: authenticator.generate(secret) })).status).toBe(401);
|
expect((await asLegacyResult(svc.disableMfa(user.id, user.email, { password: 'wrong', code: authenticator.generate(secret) }))).status).toBe(401);
|
||||||
expect(await svc.verifyJwtToken(token)).not.toBeNull();
|
expect(await svc.verifyJwtToken(token)).not.toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1372,13 +1373,13 @@ describe('session tracking', () => {
|
|||||||
await updateRows(orm, Users, { id: user.id }, { mfa_enabled: 1, mfa_secret: 'enc:' + secret });
|
await updateRows(orm, Users, { id: user.id }, { mfa_enabled: 1, mfa_secret: 'enc:' + secret });
|
||||||
const interstitial = await svc.loginUser({ email: user.email, password });
|
const interstitial = await svc.loginUser({ email: user.email, password });
|
||||||
|
|
||||||
const result = await svc.verifyMfaLogin({ mfa_token: interstitial.mfa_token, code: authenticator.generate(secret) }, { userAgent: 'Edge' });
|
const result = await asLegacyResult(svc.verifyMfaLogin({ mfa_token: interstitial.mfa_token, code: authenticator.generate(secret) }, { userAgent: 'Edge' }));
|
||||||
expect(await svc.verifyJwtToken(result.token!)).not.toBeNull();
|
expect(await svc.verifyJwtToken(result.token!)).not.toBeNull();
|
||||||
expect((await sessionRows(testDb, user.id)).map((row) => row.user_agent)).toEqual(['Edge']);
|
expect((await sessionRows(testDb, user.id)).map((row) => row.user_agent)).toEqual(['Edge']);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('AUTH-SESS-009: register records the first session with its device', async () => {
|
it('AUTH-SESS-009: register records the first session with its device', async () => {
|
||||||
const result = await svc.registerUser({ username: 'sess-reg', email: 'sess-reg@example.test', password: 'Secure123!' }, { userAgent: 'Chrome' });
|
const result = await asLegacyResult(svc.registerUser({ username: 'sess-reg', email: 'sess-reg@example.test', password: 'Secure123!' }, { userAgent: 'Chrome' }));
|
||||||
const userId = (decodeJwt(result.token!) as { id: number }).id;
|
const userId = (decodeJwt(result.token!) as { id: number }).id;
|
||||||
expect((await sessionRows(testDb, userId)).map((row) => row.user_agent)).toEqual(['Chrome']);
|
expect((await sessionRows(testDb, userId)).map((row) => row.user_agent)).toEqual(['Chrome']);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ describe('DomainError', () => {
|
|||||||
|
|
||||||
describe('catchDomainError', () => {
|
describe('catchDomainError', () => {
|
||||||
it('returns the value of a call that succeeds', async () => {
|
it('returns the value of a call that succeeds', async () => {
|
||||||
expect(await catchDomainError(() => 5)).toEqual({ ok: true, value: 5 });
|
expect(await catchDomainError(() => 5)).toBe(5);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns the DomainError a call raised', async () => {
|
it('returns the DomainError a call raised', async () => {
|
||||||
@@ -73,7 +73,7 @@ describe('catchDomainError', () => {
|
|||||||
await catchDomainError(async () => {
|
await catchDomainError(async () => {
|
||||||
throw err;
|
throw err;
|
||||||
}),
|
}),
|
||||||
).toEqual({ ok: false, error: err });
|
).toBe(err);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rethrows any other error', async () => {
|
it('rethrows any other error', async () => {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
* ReDoS-sensitive issuer trailing-slash regex. Constructed directly (no
|
* ReDoS-sensitive issuer trailing-slash regex. Constructed directly (no
|
||||||
* TestingModule, repo convention) over a real in-memory SQLite database.
|
* TestingModule, repo convention) over a real in-memory SQLite database.
|
||||||
*/
|
*/
|
||||||
|
import { asLegacyResult } from '../../helpers/domain-error';
|
||||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll, afterEach } from 'vitest';
|
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll, afterEach } from 'vitest';
|
||||||
import { generateKeyPairSync } from 'crypto';
|
import { generateKeyPairSync } from 'crypto';
|
||||||
import jwtLib from 'jsonwebtoken';
|
import jwtLib from 'jsonwebtoken';
|
||||||
@@ -1487,7 +1488,7 @@ describe('OIDC configured — the four checks', () => {
|
|||||||
const configSaysSo = (await auth.getAppConfig(null)).oidc_configured;
|
const configSaysSo = (await auth.getAppConfig(null)).oidc_configured;
|
||||||
// 3: turning password login off is refused when OIDC is not configured (oidc_login is on by default).
|
// 3: turning password login off is refused when OIDC is not configured (oidc_login is on by default).
|
||||||
const admin = createAdmin(testDb).user;
|
const admin = createAdmin(testDb).user;
|
||||||
const save = await auth.updateAppSettings(admin.id, { password_login: false });
|
const save = await asLegacyResult(auth.updateAppSettings(admin.id, { password_login: false }));
|
||||||
const lockoutSaysSo = save.error === undefined;
|
const lockoutSaysSo = save.error === undefined;
|
||||||
await deleteRows(o, AppSettings, { key: { $in: ['password_login'] } });
|
await deleteRows(o, AppSettings, { key: { $in: ['password_login'] } });
|
||||||
// 4: the provider config.
|
// 4: the provider config.
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
* crypto is not under test, the service's handling of its verdicts is.
|
* crypto is not under test, the service's handling of its verdicts is.
|
||||||
* Constructed directly (no TestingModule, repo convention).
|
* Constructed directly (no TestingModule, repo convention).
|
||||||
*/
|
*/
|
||||||
|
import { asLegacyResult } from '../../helpers/domain-error';
|
||||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
||||||
|
|
||||||
// ── DB setup ──────────────────────────────────────────────────────────────────
|
// ── DB setup ──────────────────────────────────────────────────────────────────
|
||||||
@@ -255,17 +256,17 @@ function registrationVerdict(overrides: Record<string, unknown> = {}, credential
|
|||||||
describe('passkeyRegisterOptions', () => {
|
describe('passkeyRegisterOptions', () => {
|
||||||
it('PASSKEY-SVC-001: returns the not-configured 400 when no RP config resolves', async () => {
|
it('PASSKEY-SVC-001: returns the not-configured 400 when no RP config resolves', async () => {
|
||||||
resolveWebauthnConfigMock.mockReturnValue(null);
|
resolveWebauthnConfigMock.mockReturnValue(null);
|
||||||
expect(await svc.passkeyRegisterOptions(1, 'pw')).toEqual({ error: NOT_CONFIGURED_ERROR, status: 400 });
|
expect(await asLegacyResult(svc.passkeyRegisterOptions(1, 'pw'))).toEqual({ error: NOT_CONFIGURED_ERROR, status: 400 });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('PASSKEY-SVC-002: 404s for an unknown user', async () => {
|
it('PASSKEY-SVC-002: 404s for an unknown user', async () => {
|
||||||
expect(await svc.passkeyRegisterOptions(999_999, 'pw')).toEqual({ error: 'User not found', status: 404 });
|
expect(await asLegacyResult(svc.passkeyRegisterOptions(999_999, 'pw'))).toEqual({ error: 'User not found', status: 404 });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('PASSKEY-SVC-003: requires the current password (missing and wrong both 401)', async () => {
|
it('PASSKEY-SVC-003: requires the current password (missing and wrong both 401)', async () => {
|
||||||
const { user, password } = createUser(testDb);
|
const { user, password } = createUser(testDb);
|
||||||
expect(await svc.passkeyRegisterOptions(user.id, undefined)).toEqual({ error: 'Incorrect password', status: 401 });
|
expect(await asLegacyResult(svc.passkeyRegisterOptions(user.id, undefined))).toEqual({ error: 'Incorrect password', status: 401 });
|
||||||
expect(await svc.passkeyRegisterOptions(user.id, `${password}x`)).toEqual({ error: 'Incorrect password', status: 401 });
|
expect(await asLegacyResult(svc.passkeyRegisterOptions(user.id, `${password}x`))).toEqual({ error: 'Incorrect password', status: 401 });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('PASSKEY-SVC-004: generates options excluding existing credentials and stores the challenge', async () => {
|
it('PASSKEY-SVC-004: generates options excluding existing credentials and stores the challenge', async () => {
|
||||||
@@ -275,7 +276,7 @@ describe('passkeyRegisterOptions', () => {
|
|||||||
await seedChallenge('stale', user.id, 'registration', Date.now() - 1000); // purged on the way in
|
await seedChallenge('stale', user.id, 'registration', Date.now() - 1000); // purged on the way in
|
||||||
swMock.generateRegistrationOptions.mockResolvedValue({ challenge: 'reg-chal' });
|
swMock.generateRegistrationOptions.mockResolvedValue({ challenge: 'reg-chal' });
|
||||||
|
|
||||||
const result = await svc.passkeyRegisterOptions(user.id, password);
|
const result = await asLegacyResult(svc.passkeyRegisterOptions(user.id, password));
|
||||||
expect(result).toEqual({ options: { challenge: 'reg-chal' } });
|
expect(result).toEqual({ options: { challenge: 'reg-chal' } });
|
||||||
|
|
||||||
const call = swMock.generateRegistrationOptions.mock.calls[0][0];
|
const call = swMock.generateRegistrationOptions.mock.calls[0][0];
|
||||||
@@ -302,7 +303,7 @@ describe('passkeyRegisterOptions', () => {
|
|||||||
// not-configured 400 comes BEFORE the ceremony, not as a cryptic verify 400.
|
// not-configured 400 comes BEFORE the ceremony, not as a cryptic verify 400.
|
||||||
resolveWebauthnConfigMock.mockReturnValue(LOCALHOST_CFG);
|
resolveWebauthnConfigMock.mockReturnValue(LOCALHOST_CFG);
|
||||||
const { user, password } = createUser(testDb);
|
const { user, password } = createUser(testDb);
|
||||||
expect(await svc.passkeyRegisterOptions(user.id, password, 'https://trip.example.org'))
|
expect(await asLegacyResult(svc.passkeyRegisterOptions(user.id, password, 'https://trip.example.org')))
|
||||||
.toEqual({ error: NOT_CONFIGURED_ERROR, status: 400 });
|
.toEqual({ error: NOT_CONFIGURED_ERROR, status: 400 });
|
||||||
expect(swMock.generateRegistrationOptions).not.toHaveBeenCalled();
|
expect(swMock.generateRegistrationOptions).not.toHaveBeenCalled();
|
||||||
expect(await challengeCount()).toBe(0);
|
expect(await challengeCount()).toBe(0);
|
||||||
@@ -315,9 +316,9 @@ describe('passkeyRegisterOptions', () => {
|
|||||||
swMock.generateRegistrationOptions.mockImplementation(async () => ({ challenge: `reg-chal-${++seq}` }));
|
swMock.generateRegistrationOptions.mockImplementation(async () => ({ challenge: `reg-chal-${++seq}` }));
|
||||||
|
|
||||||
// Listed origin, in-scope origin on another port, no header (curl) — all pass.
|
// Listed origin, in-scope origin on another port, no header (curl) — all pass.
|
||||||
expect(await svc.passkeyRegisterOptions(user.id, password, 'http://localhost:5173')).toEqual({ options: { challenge: 'reg-chal-1' } });
|
expect(await asLegacyResult(svc.passkeyRegisterOptions(user.id, password, 'http://localhost:5173'))).toEqual({ options: { challenge: 'reg-chal-1' } });
|
||||||
expect(await svc.passkeyRegisterOptions(user.id, password, 'http://localhost:8080')).toEqual({ options: { challenge: 'reg-chal-2' } });
|
expect(await asLegacyResult(svc.passkeyRegisterOptions(user.id, password, 'http://localhost:8080'))).toEqual({ options: { challenge: 'reg-chal-2' } });
|
||||||
expect(await svc.passkeyRegisterOptions(user.id, password, undefined)).toEqual({ options: { challenge: 'reg-chal-3' } });
|
expect(await asLegacyResult(svc.passkeyRegisterOptions(user.id, password, undefined))).toEqual({ options: { challenge: 'reg-chal-3' } });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('PASSKEY-SVC-035: a mismatching origin never blocks a real or explicit config (proxy tolerance)', async () => {
|
it('PASSKEY-SVC-035: a mismatching origin never blocks a real or explicit config (proxy tolerance)', async () => {
|
||||||
@@ -329,10 +330,10 @@ describe('passkeyRegisterOptions', () => {
|
|||||||
swMock.generateRegistrationOptions.mockImplementation(async () => ({ challenge: `reg-chal-${++seq}` }));
|
swMock.generateRegistrationOptions.mockImplementation(async () => ({ challenge: `reg-chal-${++seq}` }));
|
||||||
|
|
||||||
resolveWebauthnConfigMock.mockReturnValue(CFG); // derived from a real APP_URL
|
resolveWebauthnConfigMock.mockReturnValue(CFG); // derived from a real APP_URL
|
||||||
expect(await svc.passkeyRegisterOptions(user.id, password, 'http://10.0.0.5:3001')).toEqual({ options: { challenge: 'reg-chal-1' } });
|
expect(await asLegacyResult(svc.passkeyRegisterOptions(user.id, password, 'http://10.0.0.5:3001'))).toEqual({ options: { challenge: 'reg-chal-1' } });
|
||||||
|
|
||||||
resolveWebauthnConfigMock.mockReturnValue({ ...LOCALHOST_CFG, explicitOrigins: true }); // operator contract
|
resolveWebauthnConfigMock.mockReturnValue({ ...LOCALHOST_CFG, explicitOrigins: true }); // operator contract
|
||||||
expect(await svc.passkeyRegisterOptions(user.id, password, 'https://trip.example.org')).toEqual({ options: { challenge: 'reg-chal-2' } });
|
expect(await asLegacyResult(svc.passkeyRegisterOptions(user.id, password, 'https://trip.example.org'))).toEqual({ options: { challenge: 'reg-chal-2' } });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -341,21 +342,21 @@ describe('passkeyRegisterOptions', () => {
|
|||||||
describe('passkeyRegisterVerify', () => {
|
describe('passkeyRegisterVerify', () => {
|
||||||
it('PASSKEY-SVC-005: returns the not-configured 400 when no RP config resolves', async () => {
|
it('PASSKEY-SVC-005: returns the not-configured 400 when no RP config resolves', async () => {
|
||||||
resolveWebauthnConfigMock.mockReturnValue(null);
|
resolveWebauthnConfigMock.mockReturnValue(null);
|
||||||
expect(await svc.passkeyRegisterVerify(1, { attestationResponse: {} })).toEqual({ error: NOT_CONFIGURED_ERROR, status: 400 });
|
expect(await asLegacyResult(svc.passkeyRegisterVerify(1, { attestationResponse: {} }))).toEqual({ error: NOT_CONFIGURED_ERROR, status: 400 });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('PASSKEY-SVC-006: 400s on a missing or undecodable attestation response', async () => {
|
it('PASSKEY-SVC-006: 400s on a missing or undecodable attestation response', async () => {
|
||||||
expect(await svc.passkeyRegisterVerify(1, {})).toEqual({ error: 'Invalid registration response', status: 400 });
|
expect(await asLegacyResult(svc.passkeyRegisterVerify(1, {}))).toEqual({ error: 'Invalid registration response', status: 400 });
|
||||||
expect(await svc.passkeyRegisterVerify(1, { attestationResponse: { response: { clientDataJSON: '!!!not-base64-json' } } }))
|
expect(await asLegacyResult(svc.passkeyRegisterVerify(1, { attestationResponse: { response: { clientDataJSON: '!!!not-base64-json' } } })))
|
||||||
.toEqual({ error: 'Invalid registration response', status: 400 });
|
.toEqual({ error: 'Invalid registration response', status: 400 });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('PASSKEY-SVC-007: 400s on an unknown or expired challenge', async () => {
|
it('PASSKEY-SVC-007: 400s on an unknown or expired challenge', async () => {
|
||||||
const { user } = createUser(testDb);
|
const { user } = createUser(testDb);
|
||||||
expect(await svc.passkeyRegisterVerify(user.id, { attestationResponse: ceremonyResponse('never-stored') }))
|
expect(await asLegacyResult(svc.passkeyRegisterVerify(user.id, { attestationResponse: ceremonyResponse('never-stored') })))
|
||||||
.toEqual({ error: 'Registration challenge expired. Please try again.', status: 400 });
|
.toEqual({ error: 'Registration challenge expired. Please try again.', status: 400 });
|
||||||
await seedChallenge('expired', user.id, 'registration', Date.now() - 1);
|
await seedChallenge('expired', user.id, 'registration', Date.now() - 1);
|
||||||
expect(await svc.passkeyRegisterVerify(user.id, { attestationResponse: ceremonyResponse('expired') }))
|
expect(await asLegacyResult(svc.passkeyRegisterVerify(user.id, { attestationResponse: ceremonyResponse('expired') })))
|
||||||
.toEqual({ error: 'Registration challenge expired. Please try again.', status: 400 });
|
.toEqual({ error: 'Registration challenge expired. Please try again.', status: 400 });
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -363,11 +364,11 @@ describe('passkeyRegisterVerify', () => {
|
|||||||
const { user: alice } = createUser(testDb);
|
const { user: alice } = createUser(testDb);
|
||||||
const { user: bob } = createUser(testDb);
|
const { user: bob } = createUser(testDb);
|
||||||
await seedChallenge('cross', alice.id, 'registration');
|
await seedChallenge('cross', alice.id, 'registration');
|
||||||
expect(await svc.passkeyRegisterVerify(bob.id, { attestationResponse: ceremonyResponse('cross') }))
|
expect(await asLegacyResult(svc.passkeyRegisterVerify(bob.id, { attestationResponse: ceremonyResponse('cross') })))
|
||||||
.toEqual({ error: 'Registration challenge expired. Please try again.', status: 400 });
|
.toEqual({ error: 'Registration challenge expired. Please try again.', status: 400 });
|
||||||
// The mismatched claim still consumed the row: the rightful owner can't use it either.
|
// The mismatched claim still consumed the row: the rightful owner can't use it either.
|
||||||
expect(await challengeCount()).toBe(0);
|
expect(await challengeCount()).toBe(0);
|
||||||
expect(await svc.passkeyRegisterVerify(alice.id, { attestationResponse: ceremonyResponse('cross') }))
|
expect(await asLegacyResult(svc.passkeyRegisterVerify(alice.id, { attestationResponse: ceremonyResponse('cross') })))
|
||||||
.toEqual({ error: 'Registration challenge expired. Please try again.', status: 400 });
|
.toEqual({ error: 'Registration challenge expired. Please try again.', status: 400 });
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -375,12 +376,12 @@ describe('passkeyRegisterVerify', () => {
|
|||||||
const { user } = createUser(testDb);
|
const { user } = createUser(testDb);
|
||||||
await seedChallenge('c1', user.id, 'registration');
|
await seedChallenge('c1', user.id, 'registration');
|
||||||
swMock.verifyRegistrationResponse.mockRejectedValue(new Error('boom'));
|
swMock.verifyRegistrationResponse.mockRejectedValue(new Error('boom'));
|
||||||
expect(await svc.passkeyRegisterVerify(user.id, { attestationResponse: ceremonyResponse('c1') }))
|
expect(await asLegacyResult(svc.passkeyRegisterVerify(user.id, { attestationResponse: ceremonyResponse('c1') })))
|
||||||
.toEqual({ error: 'Could not register this passkey.', status: 400 });
|
.toEqual({ error: 'Could not register this passkey.', status: 400 });
|
||||||
|
|
||||||
await seedChallenge('c2', user.id, 'registration');
|
await seedChallenge('c2', user.id, 'registration');
|
||||||
swMock.verifyRegistrationResponse.mockResolvedValue({ verified: false });
|
swMock.verifyRegistrationResponse.mockResolvedValue({ verified: false });
|
||||||
expect(await svc.passkeyRegisterVerify(user.id, { attestationResponse: ceremonyResponse('c2') }))
|
expect(await asLegacyResult(svc.passkeyRegisterVerify(user.id, { attestationResponse: ceremonyResponse('c2') })))
|
||||||
.toEqual({ error: 'Could not register this passkey.', status: 400 });
|
.toEqual({ error: 'Could not register this passkey.', status: 400 });
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -389,7 +390,7 @@ describe('passkeyRegisterVerify', () => {
|
|||||||
await insertCredential(user.id, { credential_id: 'dup-cred' });
|
await insertCredential(user.id, { credential_id: 'dup-cred' });
|
||||||
await seedChallenge('c3', user.id, 'registration');
|
await seedChallenge('c3', user.id, 'registration');
|
||||||
swMock.verifyRegistrationResponse.mockResolvedValue(registrationVerdict({}, { id: 'dup-cred' }));
|
swMock.verifyRegistrationResponse.mockResolvedValue(registrationVerdict({}, { id: 'dup-cred' }));
|
||||||
expect(await svc.passkeyRegisterVerify(user.id, { attestationResponse: ceremonyResponse('c3') }))
|
expect(await asLegacyResult(svc.passkeyRegisterVerify(user.id, { attestationResponse: ceremonyResponse('c3') })))
|
||||||
.toEqual({ error: 'This passkey is already registered.', status: 409 });
|
.toEqual({ error: 'This passkey is already registered.', status: 409 });
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -398,7 +399,7 @@ describe('passkeyRegisterVerify', () => {
|
|||||||
await seedChallenge('c4', user.id, 'registration');
|
await seedChallenge('c4', user.id, 'registration');
|
||||||
swMock.verifyRegistrationResponse.mockResolvedValue(registrationVerdict({}, { counter: 7 }));
|
swMock.verifyRegistrationResponse.mockResolvedValue(registrationVerdict({}, { counter: 7 }));
|
||||||
|
|
||||||
const result = await svc.passkeyRegisterVerify(user.id, { attestationResponse: ceremonyResponse('c4'), name: ' My Key ' });
|
const result = await asLegacyResult(svc.passkeyRegisterVerify(user.id, { attestationResponse: ceremonyResponse('c4'), name: ' My Key ' }));
|
||||||
expect(result.success).toBe(true);
|
expect(result.success).toBe(true);
|
||||||
expect(result.credential).toMatchObject({ name: 'My Key', device_type: 'singleDevice', backed_up: false, last_used_at: null });
|
expect(result.credential).toMatchObject({ name: 'My Key', device_type: 'singleDevice', backed_up: false, last_used_at: null });
|
||||||
|
|
||||||
@@ -412,12 +413,12 @@ describe('passkeyRegisterVerify', () => {
|
|||||||
|
|
||||||
await seedChallenge('c5', user.id, 'registration');
|
await seedChallenge('c5', user.id, 'registration');
|
||||||
swMock.verifyRegistrationResponse.mockResolvedValue(registrationVerdict({ credentialDeviceType: 'multiDevice', credentialBackedUp: true }, { id: 'synced-cred' }));
|
swMock.verifyRegistrationResponse.mockResolvedValue(registrationVerdict({ credentialDeviceType: 'multiDevice', credentialBackedUp: true }, { id: 'synced-cred' }));
|
||||||
const synced = await svc.passkeyRegisterVerify(user.id, { attestationResponse: ceremonyResponse('c5'), name: ' ' });
|
const synced = await asLegacyResult(svc.passkeyRegisterVerify(user.id, { attestationResponse: ceremonyResponse('c5'), name: ' ' }));
|
||||||
expect(synced.credential).toMatchObject({ name: 'Passkey (synced)', backed_up: true });
|
expect(synced.credential).toMatchObject({ name: 'Passkey (synced)', backed_up: true });
|
||||||
|
|
||||||
await seedChallenge('c6', user.id, 'registration');
|
await seedChallenge('c6', user.id, 'registration');
|
||||||
swMock.verifyRegistrationResponse.mockResolvedValue(registrationVerdict({}, { id: 'named-cred' }));
|
swMock.verifyRegistrationResponse.mockResolvedValue(registrationVerdict({}, { id: 'named-cred' }));
|
||||||
const long = await svc.passkeyRegisterVerify(user.id, { attestationResponse: ceremonyResponse('c6'), name: 'x'.repeat(80) });
|
const long = await asLegacyResult(svc.passkeyRegisterVerify(user.id, { attestationResponse: ceremonyResponse('c6'), name: 'x'.repeat(80) }));
|
||||||
expect(long.credential).toMatchObject({ name: 'x'.repeat(60) });
|
expect(long.credential).toMatchObject({ name: 'x'.repeat(60) });
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -428,7 +429,7 @@ describe('passkeyRegisterVerify', () => {
|
|||||||
testDb.exec('PRAGMA foreign_keys = OFF');
|
testDb.exec('PRAGMA foreign_keys = OFF');
|
||||||
await seedChallenge('c7', 999_999, 'registration');
|
await seedChallenge('c7', 999_999, 'registration');
|
||||||
testDb.exec('PRAGMA foreign_keys = ON');
|
testDb.exec('PRAGMA foreign_keys = ON');
|
||||||
expect(await svc.passkeyRegisterVerify(999_999, { attestationResponse: ceremonyResponse('c7') }))
|
expect(await asLegacyResult(svc.passkeyRegisterVerify(999_999, { attestationResponse: ceremonyResponse('c7') })))
|
||||||
.toEqual({ error: 'Could not register this passkey.', status: 400 });
|
.toEqual({ error: 'Could not register this passkey.', status: 400 });
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -439,7 +440,7 @@ describe('passkeyRegisterVerify', () => {
|
|||||||
testDb.exec('PRAGMA foreign_keys = OFF');
|
testDb.exec('PRAGMA foreign_keys = OFF');
|
||||||
await seedChallenge('c8', 999_999, 'registration');
|
await seedChallenge('c8', 999_999, 'registration');
|
||||||
testDb.exec('PRAGMA foreign_keys = ON');
|
testDb.exec('PRAGMA foreign_keys = ON');
|
||||||
await svc.passkeyRegisterVerify(999_999, { attestationResponse: ceremonyResponse('c8') });
|
await asLegacyResult(svc.passkeyRegisterVerify(999_999, { attestationResponse: ceremonyResponse('c8') }));
|
||||||
expect(await countRows(orm, WebauthnCredentials)).toBe(0);
|
expect(await countRows(orm, WebauthnCredentials)).toBe(0);
|
||||||
expect(await challengeCount()).toBe(0); // the claim stays spent — the challenge is not resurrected
|
expect(await challengeCount()).toBe(0); // the claim stays spent — the challenge is not resurrected
|
||||||
});
|
});
|
||||||
@@ -449,7 +450,7 @@ describe('passkeyRegisterVerify', () => {
|
|||||||
await insertCredential(user.id, { credential_id: 'dup-cred-2', name: 'Original' });
|
await insertCredential(user.id, { credential_id: 'dup-cred-2', name: 'Original' });
|
||||||
await seedChallenge('c9', user.id, 'registration');
|
await seedChallenge('c9', user.id, 'registration');
|
||||||
swMock.verifyRegistrationResponse.mockResolvedValue(registrationVerdict({}, { id: 'dup-cred-2' }));
|
swMock.verifyRegistrationResponse.mockResolvedValue(registrationVerdict({}, { id: 'dup-cred-2' }));
|
||||||
expect(await svc.passkeyRegisterVerify(user.id, { attestationResponse: ceremonyResponse('c9'), name: 'Impostor' }))
|
expect(await asLegacyResult(svc.passkeyRegisterVerify(user.id, { attestationResponse: ceremonyResponse('c9'), name: 'Impostor' })))
|
||||||
.toEqual({ error: 'This passkey is already registered.', status: 409 });
|
.toEqual({ error: 'This passkey is already registered.', status: 409 });
|
||||||
const row = (await findRow(orm, WebauthnCredentials, { credential_id: 'dup-cred-2' }));
|
const row = (await findRow(orm, WebauthnCredentials, { credential_id: 'dup-cred-2' }));
|
||||||
expect(row).toMatchObject({ name: 'Original', user_id: user.id });
|
expect(row).toMatchObject({ name: 'Original', user_id: user.id });
|
||||||
@@ -466,9 +467,9 @@ describe('passkeyRegisterVerify', () => {
|
|||||||
await seedChallenge('w1', user.id, 'registration');
|
await seedChallenge('w1', user.id, 'registration');
|
||||||
swMock.verifyRegistrationResponse.mockResolvedValue(registrationVerdict());
|
swMock.verifyRegistrationResponse.mockResolvedValue(registrationVerdict());
|
||||||
|
|
||||||
const result = await svc.passkeyRegisterVerify(user.id, {
|
const result = await asLegacyResult(svc.passkeyRegisterVerify(user.id, {
|
||||||
attestationResponse: ceremonyResponse('w1', {}, { origin: 'https://trek.example.org' }),
|
attestationResponse: ceremonyResponse('w1', {}, { origin: 'https://trek.example.org' }),
|
||||||
});
|
}));
|
||||||
expect(result.success).toBe(true);
|
expect(result.success).toBe(true);
|
||||||
expect(swMock.verifyRegistrationResponse.mock.calls[0][0].expectedOrigin)
|
expect(swMock.verifyRegistrationResponse.mock.calls[0][0].expectedOrigin)
|
||||||
.toEqual(['http://trek.example.org', 'https://trek.example.org']);
|
.toEqual(['http://trek.example.org', 'https://trek.example.org']);
|
||||||
@@ -482,13 +483,13 @@ describe('passkeyRegisterVerify', () => {
|
|||||||
resolveWebauthnConfigMock.mockReturnValue({ ...CFG, origins: ['https://trek.example.com'], explicitOrigins: true });
|
resolveWebauthnConfigMock.mockReturnValue({ ...CFG, origins: ['https://trek.example.com'], explicitOrigins: true });
|
||||||
await seedChallenge('w2', user.id, 'registration');
|
await seedChallenge('w2', user.id, 'registration');
|
||||||
swMock.verifyRegistrationResponse.mockResolvedValue(registrationVerdict());
|
swMock.verifyRegistrationResponse.mockResolvedValue(registrationVerdict());
|
||||||
await svc.passkeyRegisterVerify(user.id, { attestationResponse: ceremonyResponse('w2', {}, { origin: 'https://app.trek.example.com' }) });
|
await asLegacyResult(svc.passkeyRegisterVerify(user.id, { attestationResponse: ceremonyResponse('w2', {}, { origin: 'https://app.trek.example.com' }) }));
|
||||||
expect(swMock.verifyRegistrationResponse.mock.calls[0][0].expectedOrigin).toEqual(['https://trek.example.com']);
|
expect(swMock.verifyRegistrationResponse.mock.calls[0][0].expectedOrigin).toEqual(['https://trek.example.com']);
|
||||||
|
|
||||||
// Derived config, but the asserted origin is outside the RP scope.
|
// Derived config, but the asserted origin is outside the RP scope.
|
||||||
resolveWebauthnConfigMock.mockReturnValue(CFG);
|
resolveWebauthnConfigMock.mockReturnValue(CFG);
|
||||||
await seedChallenge('w3', user.id, 'registration');
|
await seedChallenge('w3', user.id, 'registration');
|
||||||
await svc.passkeyRegisterVerify(user.id, { attestationResponse: ceremonyResponse('w3', {}, { origin: 'https://evil.example.net' }) });
|
await asLegacyResult(svc.passkeyRegisterVerify(user.id, { attestationResponse: ceremonyResponse('w3', {}, { origin: 'https://evil.example.net' }) }));
|
||||||
expect(swMock.verifyRegistrationResponse.mock.calls[1][0].expectedOrigin).toEqual(['https://trek.example.com']);
|
expect(swMock.verifyRegistrationResponse.mock.calls[1][0].expectedOrigin).toEqual(['https://trek.example.com']);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -502,12 +503,12 @@ describe('passkeyRegisterVerify', () => {
|
|||||||
|
|
||||||
await seedChallenge('w4', user.id, 'registration');
|
await seedChallenge('w4', user.id, 'registration');
|
||||||
swMock.verifyRegistrationResponse.mockResolvedValueOnce(registrationVerdict({}, { id: 'sibling-cred' }));
|
swMock.verifyRegistrationResponse.mockResolvedValueOnce(registrationVerdict({}, { id: 'sibling-cred' }));
|
||||||
await svc.passkeyRegisterVerify(user.id, { attestationResponse: ceremonyResponse('w4', {}, { origin: 'https://blog.example.com' }) });
|
await asLegacyResult(svc.passkeyRegisterVerify(user.id, { attestationResponse: ceremonyResponse('w4', {}, { origin: 'https://blog.example.com' }) }));
|
||||||
expect(swMock.verifyRegistrationResponse.mock.calls[0][0].expectedOrigin).toEqual(['https://example.com']);
|
expect(swMock.verifyRegistrationResponse.mock.calls[0][0].expectedOrigin).toEqual(['https://example.com']);
|
||||||
|
|
||||||
await seedChallenge('w5', user.id, 'registration');
|
await seedChallenge('w5', user.id, 'registration');
|
||||||
swMock.verifyRegistrationResponse.mockResolvedValueOnce(registrationVerdict({}, { id: 'port-cred' }));
|
swMock.verifyRegistrationResponse.mockResolvedValueOnce(registrationVerdict({}, { id: 'port-cred' }));
|
||||||
await svc.passkeyRegisterVerify(user.id, { attestationResponse: ceremonyResponse('w5', {}, { origin: 'https://example.com:8443' }) });
|
await asLegacyResult(svc.passkeyRegisterVerify(user.id, { attestationResponse: ceremonyResponse('w5', {}, { origin: 'https://example.com:8443' }) }));
|
||||||
expect(swMock.verifyRegistrationResponse.mock.calls[1][0].expectedOrigin).toEqual(['https://example.com', 'https://example.com:8443']);
|
expect(swMock.verifyRegistrationResponse.mock.calls[1][0].expectedOrigin).toEqual(['https://example.com', 'https://example.com:8443']);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -517,14 +518,14 @@ describe('passkeyRegisterVerify', () => {
|
|||||||
describe('passkeyLoginOptions', () => {
|
describe('passkeyLoginOptions', () => {
|
||||||
it('PASSKEY-SVC-014: returns the not-configured 400 when no RP config resolves', async () => {
|
it('PASSKEY-SVC-014: returns the not-configured 400 when no RP config resolves', async () => {
|
||||||
resolveWebauthnConfigMock.mockReturnValue(null);
|
resolveWebauthnConfigMock.mockReturnValue(null);
|
||||||
expect(await svc.passkeyLoginOptions()).toEqual({ error: NOT_CONFIGURED_ERROR, status: 400 });
|
expect(await asLegacyResult(svc.passkeyLoginOptions())).toEqual({ error: NOT_CONFIGURED_ERROR, status: 400 });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('PASSKEY-SVC-015: stores an anonymous authentication challenge (discoverable flow)', async () => {
|
it('PASSKEY-SVC-015: stores an anonymous authentication challenge (discoverable flow)', async () => {
|
||||||
await seedChallenge('stale', null, 'authentication', Date.now() - 1000);
|
await seedChallenge('stale', null, 'authentication', Date.now() - 1000);
|
||||||
swMock.generateAuthenticationOptions.mockResolvedValue({ challenge: 'auth-chal' });
|
swMock.generateAuthenticationOptions.mockResolvedValue({ challenge: 'auth-chal' });
|
||||||
|
|
||||||
expect(await svc.passkeyLoginOptions()).toEqual({ options: { challenge: 'auth-chal' } });
|
expect(await asLegacyResult(svc.passkeyLoginOptions())).toEqual({ options: { challenge: 'auth-chal' } });
|
||||||
// No allowCredentials → the endpoint can't enumerate accounts.
|
// No allowCredentials → the endpoint can't enumerate accounts.
|
||||||
expect(swMock.generateAuthenticationOptions.mock.calls[0][0]).toEqual({ rpID: CFG.rpID, userVerification: 'required' });
|
expect(swMock.generateAuthenticationOptions.mock.calls[0][0]).toEqual({ rpID: CFG.rpID, userVerification: 'required' });
|
||||||
|
|
||||||
@@ -535,12 +536,12 @@ describe('passkeyLoginOptions', () => {
|
|||||||
|
|
||||||
it('PASSKEY-SVC-038: the derived localhost fallback fails fast for a foreign request origin (#2147)', async () => {
|
it('PASSKEY-SVC-038: the derived localhost fallback fails fast for a foreign request origin (#2147)', async () => {
|
||||||
resolveWebauthnConfigMock.mockReturnValue(LOCALHOST_CFG);
|
resolveWebauthnConfigMock.mockReturnValue(LOCALHOST_CFG);
|
||||||
expect(await svc.passkeyLoginOptions('https://trip.example.org')).toEqual({ error: NOT_CONFIGURED_ERROR, status: 400 });
|
expect(await asLegacyResult(svc.passkeyLoginOptions('https://trip.example.org'))).toEqual({ error: NOT_CONFIGURED_ERROR, status: 400 });
|
||||||
expect(swMock.generateAuthenticationOptions).not.toHaveBeenCalled();
|
expect(swMock.generateAuthenticationOptions).not.toHaveBeenCalled();
|
||||||
|
|
||||||
// No Origin header (non-browser client) → unchanged legacy behavior.
|
// No Origin header (non-browser client) → unchanged legacy behavior.
|
||||||
swMock.generateAuthenticationOptions.mockResolvedValue({ challenge: 'auth-chal' });
|
swMock.generateAuthenticationOptions.mockResolvedValue({ challenge: 'auth-chal' });
|
||||||
expect(await svc.passkeyLoginOptions()).toEqual({ options: { challenge: 'auth-chal' } });
|
expect(await asLegacyResult(svc.passkeyLoginOptions())).toEqual({ options: { challenge: 'auth-chal' } });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -738,9 +739,9 @@ describe('renamePasskey', () => {
|
|||||||
it('PASSKEY-SVC-026: rejects a missing, non-string or whitespace-only name', async () => {
|
it('PASSKEY-SVC-026: rejects a missing, non-string or whitespace-only name', async () => {
|
||||||
const { user } = createUser(testDb);
|
const { user } = createUser(testDb);
|
||||||
const cred = await insertCredential(user.id);
|
const cred = await insertCredential(user.id);
|
||||||
expect(await svc.renamePasskey(user.id, String(cred.id), undefined)).toEqual({ error: 'Name is required', status: 400 });
|
expect(await asLegacyResult(svc.renamePasskey(user.id, String(cred.id), undefined))).toEqual({ error: 'Name is required', status: 400 });
|
||||||
expect(await svc.renamePasskey(user.id, String(cred.id), 42)).toEqual({ error: 'Name is required', status: 400 });
|
expect(await asLegacyResult(svc.renamePasskey(user.id, String(cred.id), 42))).toEqual({ error: 'Name is required', status: 400 });
|
||||||
expect(await svc.renamePasskey(user.id, String(cred.id), ' ')).toEqual({ error: 'Name is required', status: 400 });
|
expect(await asLegacyResult(svc.renamePasskey(user.id, String(cred.id), ' '))).toEqual({ error: 'Name is required', status: 400 });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('PASSKEY-SVC-027: renames (trimmed, capped at 60) and 404s on foreign or unknown ids', async () => {
|
it('PASSKEY-SVC-027: renames (trimmed, capped at 60) and 404s on foreign or unknown ids', async () => {
|
||||||
@@ -748,18 +749,18 @@ describe('renamePasskey', () => {
|
|||||||
const { user: other } = createUser(testDb);
|
const { user: other } = createUser(testDb);
|
||||||
const cred = await insertCredential(user.id);
|
const cred = await insertCredential(user.id);
|
||||||
|
|
||||||
expect(await svc.renamePasskey(user.id, String(cred.id), ` ${'n'.repeat(80)} `)).toEqual({ success: true });
|
expect(await asLegacyResult(svc.renamePasskey(user.id, String(cred.id), ` ${'n'.repeat(80)} `))).toEqual({ success: true });
|
||||||
const row = (await findRow(orm, WebauthnCredentials, { id: cred.id }));
|
const row = (await findRow(orm, WebauthnCredentials, { id: cred.id }));
|
||||||
expect(row?.name).toBe('n'.repeat(60));
|
expect(row?.name).toBe('n'.repeat(60));
|
||||||
|
|
||||||
expect(await svc.renamePasskey(other.id, String(cred.id), 'Steal')).toEqual({ error: 'Passkey not found', status: 404 });
|
expect(await asLegacyResult(svc.renamePasskey(other.id, String(cred.id), 'Steal'))).toEqual({ error: 'Passkey not found', status: 404 });
|
||||||
expect(await svc.renamePasskey(user.id, '999999', 'Ghost')).toEqual({ error: 'Passkey not found', status: 404 });
|
expect(await asLegacyResult(svc.renamePasskey(user.id, '999999', 'Ghost'))).toEqual({ error: 'Passkey not found', status: 404 });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('PASSKEY-SVC-042: 404s (not a 500) on a non-numeric id — Plan 3b Task 3 review, F1', async () => {
|
it('PASSKEY-SVC-042: 404s (not a 500) on a non-numeric id — Plan 3b Task 3 review, F1', async () => {
|
||||||
const { user } = createUser(testDb);
|
const { user } = createUser(testDb);
|
||||||
expect(await svc.renamePasskey(user.id, 'abc', 'Ghost')).toEqual({ error: 'Passkey not found', status: 404 });
|
expect(await asLegacyResult(svc.renamePasskey(user.id, 'abc', 'Ghost'))).toEqual({ error: 'Passkey not found', status: 404 });
|
||||||
expect(await svc.renamePasskey(user.id, '1abc', 'Ghost')).toEqual({ error: 'Passkey not found', status: 404 });
|
expect(await asLegacyResult(svc.renamePasskey(user.id, '1abc', 'Ghost'))).toEqual({ error: 'Passkey not found', status: 404 });
|
||||||
});
|
});
|
||||||
|
|
||||||
// task-3-rereview.md R1: the legacy `renamePasskey` bound `Number(id)`
|
// task-3-rereview.md R1: the legacy `renamePasskey` bound `Number(id)`
|
||||||
@@ -769,7 +770,7 @@ describe('renamePasskey', () => {
|
|||||||
// (accepted deviation, not parity), not "the legacy 404".
|
// (accepted deviation, not parity), not "the legacy 404".
|
||||||
it('PASSKEY-SVC-042B: refuses a prefixed-numeric id (0x10) via the toRowId narrowing', async () => {
|
it('PASSKEY-SVC-042B: refuses a prefixed-numeric id (0x10) via the toRowId narrowing', async () => {
|
||||||
const { user } = createUser(testDb);
|
const { user } = createUser(testDb);
|
||||||
expect(await svc.renamePasskey(user.id, '0x10', 'Ghost')).toEqual({ error: 'Passkey not found', status: 404 });
|
expect(await asLegacyResult(svc.renamePasskey(user.id, '0x10', 'Ghost'))).toEqual({ error: 'Passkey not found', status: 404 });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -779,10 +780,10 @@ describe('deletePasskey', () => {
|
|||||||
it('PASSKEY-SVC-028: requires the current password (missing, wrong, or no hash all 401)', async () => {
|
it('PASSKEY-SVC-028: requires the current password (missing, wrong, or no hash all 401)', async () => {
|
||||||
const { user, password } = createUser(testDb);
|
const { user, password } = createUser(testDb);
|
||||||
const cred = await insertCredential(user.id);
|
const cred = await insertCredential(user.id);
|
||||||
expect(await svc.deletePasskey(user.id, String(cred.id), undefined)).toEqual({ error: 'Incorrect password', status: 401 });
|
expect(await asLegacyResult(svc.deletePasskey(user.id, String(cred.id), undefined))).toEqual({ error: 'Incorrect password', status: 401 });
|
||||||
expect(await svc.deletePasskey(user.id, String(cred.id), `${password}x`)).toEqual({ error: 'Incorrect password', status: 401 });
|
expect(await asLegacyResult(svc.deletePasskey(user.id, String(cred.id), `${password}x`))).toEqual({ error: 'Incorrect password', status: 401 });
|
||||||
await updateRows(orm, Users, { id: user.id }, { password_hash: '' });
|
await updateRows(orm, Users, { id: user.id }, { password_hash: '' });
|
||||||
expect(await svc.deletePasskey(user.id, String(cred.id), password)).toEqual({ error: 'Incorrect password', status: 401 });
|
expect(await asLegacyResult(svc.deletePasskey(user.id, String(cred.id), password))).toEqual({ error: 'Incorrect password', status: 401 });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('PASSKEY-SVC-029: deletes own credentials only — foreign ids 404 without leaking', async () => {
|
it('PASSKEY-SVC-029: deletes own credentials only — foreign ids 404 without leaking', async () => {
|
||||||
@@ -790,14 +791,14 @@ describe('deletePasskey', () => {
|
|||||||
const { user: other, password: otherPassword } = createUser(testDb);
|
const { user: other, password: otherPassword } = createUser(testDb);
|
||||||
const cred = await insertCredential(user.id);
|
const cred = await insertCredential(user.id);
|
||||||
|
|
||||||
expect(await svc.deletePasskey(other.id, String(cred.id), otherPassword)).toEqual({ error: 'Passkey not found', status: 404 });
|
expect(await asLegacyResult(svc.deletePasskey(other.id, String(cred.id), otherPassword))).toEqual({ error: 'Passkey not found', status: 404 });
|
||||||
expect(await svc.deletePasskey(user.id, String(cred.id), password)).toEqual({ success: true });
|
expect(await asLegacyResult(svc.deletePasskey(user.id, String(cred.id), password))).toEqual({ success: true });
|
||||||
expect(await countRows(orm, WebauthnCredentials)).toBe(0);
|
expect(await countRows(orm, WebauthnCredentials)).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('PASSKEY-SVC-043: 404s (not a 500) on a non-numeric id once the password check passes — Plan 3b Task 3 review, F1', async () => {
|
it('PASSKEY-SVC-043: 404s (not a 500) on a non-numeric id once the password check passes — Plan 3b Task 3 review, F1', async () => {
|
||||||
const { user, password } = createUser(testDb);
|
const { user, password } = createUser(testDb);
|
||||||
expect(await svc.deletePasskey(user.id, 'abc', password)).toEqual({ error: 'Passkey not found', status: 404 });
|
expect(await asLegacyResult(svc.deletePasskey(user.id, 'abc', password))).toEqual({ error: 'Passkey not found', status: 404 });
|
||||||
});
|
});
|
||||||
|
|
||||||
// task-3-rereview.md R1: same distinction as PASSKEY-SVC-042B —
|
// task-3-rereview.md R1: same distinction as PASSKEY-SVC-042B —
|
||||||
@@ -805,12 +806,12 @@ describe('deletePasskey', () => {
|
|||||||
// acted on credential 16, not 404'd. This pins the `toRowId` narrowing.
|
// acted on credential 16, not 404'd. This pins the `toRowId` narrowing.
|
||||||
it('PASSKEY-SVC-043B: refuses a prefixed-numeric id (0x10) via the toRowId narrowing, once the password check passes', async () => {
|
it('PASSKEY-SVC-043B: refuses a prefixed-numeric id (0x10) via the toRowId narrowing, once the password check passes', async () => {
|
||||||
const { user, password } = createUser(testDb);
|
const { user, password } = createUser(testDb);
|
||||||
expect(await svc.deletePasskey(user.id, '0x10', password)).toEqual({ error: 'Passkey not found', status: 404 });
|
expect(await asLegacyResult(svc.deletePasskey(user.id, '0x10', password))).toEqual({ error: 'Passkey not found', status: 404 });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('PASSKEY-SVC-044: a wrong password still wins over a non-numeric id — 401, not 404 (legacy statement order)', async () => {
|
it('PASSKEY-SVC-044: a wrong password still wins over a non-numeric id — 401, not 404 (legacy statement order)', async () => {
|
||||||
const { user, password } = createUser(testDb);
|
const { user, password } = createUser(testDb);
|
||||||
expect(await svc.deletePasskey(user.id, 'abc', `${password}x`)).toEqual({ error: 'Incorrect password', status: 401 });
|
expect(await asLegacyResult(svc.deletePasskey(user.id, 'abc', `${password}x`))).toEqual({ error: 'Incorrect password', status: 401 });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -818,7 +819,7 @@ describe('deletePasskey', () => {
|
|||||||
|
|
||||||
describe('adminResetPasskeys', () => {
|
describe('adminResetPasskeys', () => {
|
||||||
it('PASSKEY-SVC-030: 404s on an unknown user, else clears all credentials and reports the count', async () => {
|
it('PASSKEY-SVC-030: 404s on an unknown user, else clears all credentials and reports the count', async () => {
|
||||||
expect(await svc.adminResetPasskeys(999_999)).toEqual({ error: 'User not found', status: 404 });
|
expect(await asLegacyResult(svc.adminResetPasskeys(999_999))).toEqual({ error: 'User not found', status: 404 });
|
||||||
|
|
||||||
const { user } = createUser(testDb);
|
const { user } = createUser(testDb);
|
||||||
const { user: other } = createUser(testDb);
|
const { user: other } = createUser(testDb);
|
||||||
@@ -826,12 +827,12 @@ describe('adminResetPasskeys', () => {
|
|||||||
await insertCredential(user.id);
|
await insertCredential(user.id);
|
||||||
const kept = await insertCredential(other.id);
|
const kept = await insertCredential(other.id);
|
||||||
|
|
||||||
expect(await svc.adminResetPasskeys(user.id)).toEqual({ success: true, deleted: 2, email: user.email });
|
expect(await asLegacyResult(svc.adminResetPasskeys(user.id))).toEqual({ success: true, deleted: 2, email: user.email });
|
||||||
expect(await svc.adminResetPasskeys(user.id)).toEqual({ success: true, deleted: 0, email: user.email });
|
expect(await asLegacyResult(svc.adminResetPasskeys(user.id))).toEqual({ success: true, deleted: 0, email: user.email });
|
||||||
expect((await findRows(orm, WebauthnCredentials)).map((r) => ({ id: r.id }))).toEqual([{ id: kept.id }]);
|
expect((await findRows(orm, WebauthnCredentials)).map((r) => ({ id: r.id }))).toEqual([{ id: kept.id }]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('PASSKEY-SVC-045: 404s (not a 500) when the caller hands in NaN — AdminService.resetUserPasskeys converts a non-numeric route id with a bare Number(id) before calling in (Plan 3b Task 3 review, F1)', async () => {
|
it('PASSKEY-SVC-045: 404s (not a 500) when the caller hands in NaN — AdminService.resetUserPasskeys converts a non-numeric route id with a bare Number(id) before calling in (Plan 3b Task 3 review, F1)', async () => {
|
||||||
expect(await svc.adminResetPasskeys(Number('abc'))).toEqual({ error: 'User not found', status: 404 });
|
expect(await asLegacyResult(svc.adminResetPasskeys(Number('abc')))).toEqual({ error: 'User not found', status: 404 });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
* stays greppable. Constructed directly (no TestingModule, repo convention).
|
* stays greppable. Constructed directly (no TestingModule, repo convention).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { asLegacyResult } from '../../helpers/domain-error';
|
||||||
import { describe, it, expect, beforeAll, beforeEach, afterAll, vi } from 'vitest';
|
import { describe, it, expect, beforeAll, beforeEach, afterAll, vi } from 'vitest';
|
||||||
|
|
||||||
vi.mock('../../../src/db/database', async () => {
|
vi.mock('../../../src/db/database', async () => {
|
||||||
@@ -58,14 +59,14 @@ describe('Invites', () => {
|
|||||||
it('ADMIN-SVC-027 — deleteInvite removes invite', async () => {
|
it('ADMIN-SVC-027 — deleteInvite removes invite', async () => {
|
||||||
const { user: admin } = createAdmin(testDb);
|
const { user: admin } = createAdmin(testDb);
|
||||||
const invite = createInviteToken(testDb, { created_by: admin.id }) as any;
|
const invite = createInviteToken(testDb, { created_by: admin.id }) as any;
|
||||||
const result = await svc.deleteInvite(String(invite.id)) as any;
|
const result = await asLegacyResult(svc.deleteInvite(String(invite.id))) as any;
|
||||||
expect(result.error).toBeUndefined();
|
expect(result.error).toBeUndefined();
|
||||||
const check = await findRow(await sharedTestOrm(testDb), InviteTokens, { id: invite.id });
|
const check = await findRow(await sharedTestOrm(testDb), InviteTokens, { id: invite.id });
|
||||||
expect(check).toBeNull();
|
expect(check).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('ADMIN-SVC-028 — deleteInvite returns 404 for non-existent invite', async () => {
|
it('ADMIN-SVC-028 — deleteInvite returns 404 for non-existent invite', async () => {
|
||||||
const result = await svc.deleteInvite('99999') as any;
|
const result = await asLegacyResult(svc.deleteInvite('99999')) as any;
|
||||||
expect(result.status).toBe(404);
|
expect(result.status).toBe(404);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -73,7 +74,7 @@ describe('Invites', () => {
|
|||||||
const { user: admin } = createAdmin(testDb);
|
const { user: admin } = createAdmin(testDb);
|
||||||
await insertRow(await sharedTestOrm(testDb), InviteTokens, { id: 16, token: 'hex-survivor', max_uses: 1, used_count: 0, expires_at: null, createdByRef: admin.id });
|
await insertRow(await sharedTestOrm(testDb), InviteTokens, { id: 16, token: 'hex-survivor', max_uses: 1, used_count: 0, expires_at: null, createdByRef: admin.id });
|
||||||
|
|
||||||
const result = await svc.deleteInvite('0x10');
|
const result = await asLegacyResult(svc.deleteInvite('0x10'));
|
||||||
|
|
||||||
expect(result).toEqual({ error: 'Invite not found', status: 404 });
|
expect(result).toEqual({ error: 'Invite not found', status: 404 });
|
||||||
expect(await findRow(await sharedTestOrm(testDb), InviteTokens, { id: 16 })).not.toBeNull();
|
expect(await findRow(await sharedTestOrm(testDb), InviteTokens, { id: 16 })).not.toBeNull();
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ vi.mock('../../../src/mcp/sessionManager', () => ({ revokeUserSessions: vi.fn()
|
|||||||
// Imports (after mocks)
|
// Imports (after mocks)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
import { asLegacyResult } from '../../helpers/domain-error';
|
||||||
import { db as testDb } from '../../../src/db/database';
|
import { db as testDb } from '../../../src/db/database';
|
||||||
import { describe, it, expect, beforeAll, beforeEach, afterAll, vi } from 'vitest';
|
import { describe, it, expect, beforeAll, beforeEach, afterAll, vi } from 'vitest';
|
||||||
import { resetTestDb } from '../../helpers/test-db';
|
import { resetTestDb } from '../../helpers/test-db';
|
||||||
@@ -79,19 +80,19 @@ afterAll(() => {
|
|||||||
describe('MCP token service', () => {
|
describe('MCP token service', () => {
|
||||||
it('AUTH-DB-041: createMcpToken returns 400 when name is missing', async () => {
|
it('AUTH-DB-041: createMcpToken returns 400 when name is missing', async () => {
|
||||||
const { user } = createUser(testDb);
|
const { user } = createUser(testDb);
|
||||||
const result = await svc.createMcpToken(user.id, undefined);
|
const result = await asLegacyResult(svc.createMcpToken(user.id, undefined));
|
||||||
expect(result.status).toBe(400);
|
expect(result.status).toBe(400);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('AUTH-DB-042: createMcpToken returns 400 when name exceeds 100 chars', async () => {
|
it('AUTH-DB-042: createMcpToken returns 400 when name exceeds 100 chars', async () => {
|
||||||
const { user } = createUser(testDb);
|
const { user } = createUser(testDb);
|
||||||
const result = await svc.createMcpToken(user.id, 'a'.repeat(101));
|
const result = await asLegacyResult(svc.createMcpToken(user.id, 'a'.repeat(101)));
|
||||||
expect(result.status).toBe(400);
|
expect(result.status).toBe(400);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('AUTH-DB-043: createMcpToken creates token and returns raw_token', async () => {
|
it('AUTH-DB-043: createMcpToken creates token and returns raw_token', async () => {
|
||||||
const { user } = createUser(testDb);
|
const { user } = createUser(testDb);
|
||||||
const result = await svc.createMcpToken(user.id, 'My Token');
|
const result = await asLegacyResult(svc.createMcpToken(user.id, 'My Token'));
|
||||||
expect(result.token).toBeDefined();
|
expect(result.token).toBeDefined();
|
||||||
expect((result.token as any).raw_token).toMatch(/^trek_/);
|
expect((result.token as any).raw_token).toMatch(/^trek_/);
|
||||||
});
|
});
|
||||||
@@ -101,22 +102,22 @@ describe('MCP token service', () => {
|
|||||||
for (let i = 0; i < 10; i++) {
|
for (let i = 0; i < 10; i++) {
|
||||||
await insertRow(await sharedTestOrm(testDb), McpTokens, { user: user.id, name: `Token ${i}`, token_hash: `hash${i}`, token_prefix: `trek_prefix${i}` });
|
await insertRow(await sharedTestOrm(testDb), McpTokens, { user: user.id, name: `Token ${i}`, token_hash: `hash${i}`, token_prefix: `trek_prefix${i}` });
|
||||||
}
|
}
|
||||||
const result = await svc.createMcpToken(user.id, 'One More');
|
const result = await asLegacyResult(svc.createMcpToken(user.id, 'One More'));
|
||||||
expect(result.status).toBe(400);
|
expect(result.status).toBe(400);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('AUTH-DB-045: deleteMcpToken returns 404 for non-existent token', async () => {
|
it('AUTH-DB-045: deleteMcpToken returns 404 for non-existent token', async () => {
|
||||||
const { user } = createUser(testDb);
|
const { user } = createUser(testDb);
|
||||||
const result = await svc.deleteMcpToken(user.id, '99999');
|
const result = await asLegacyResult(svc.deleteMcpToken(user.id, '99999'));
|
||||||
expect(result.status).toBe(404);
|
expect(result.status).toBe(404);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('AUTH-DB-046: deleteMcpToken deletes the token and returns success', async () => {
|
it('AUTH-DB-046: deleteMcpToken deletes the token and returns success', async () => {
|
||||||
const { user } = createUser(testDb);
|
const { user } = createUser(testDb);
|
||||||
const created = await svc.createMcpToken(user.id, 'Deletable Token');
|
const created = await asLegacyResult(svc.createMcpToken(user.id, 'Deletable Token'));
|
||||||
const tokenId = String((created.token as any).id);
|
const tokenId = String((created.token as any).id);
|
||||||
|
|
||||||
const result = await svc.deleteMcpToken(user.id, tokenId);
|
const result = await asLegacyResult(svc.deleteMcpToken(user.id, tokenId));
|
||||||
expect(result).toEqual({ success: true });
|
expect(result).toEqual({ success: true });
|
||||||
|
|
||||||
const row = await tokenRow(tokenId);
|
const row = await tokenRow(tokenId);
|
||||||
@@ -125,19 +126,19 @@ describe('MCP token service', () => {
|
|||||||
|
|
||||||
it('AUTH-DB-092: deleteMcpToken succeeds even when the session sweep throws (best-effort)', async () => {
|
it('AUTH-DB-092: deleteMcpToken succeeds even when the session sweep throws (best-effort)', async () => {
|
||||||
const { user } = createUser(testDb);
|
const { user } = createUser(testDb);
|
||||||
const created = await svc.createMcpToken(user.id, 'sweep-down');
|
const created = await asLegacyResult(svc.createMcpToken(user.id, 'sweep-down'));
|
||||||
const tokenId = String((created.token as { id: number }).id);
|
const tokenId = String((created.token as { id: number }).id);
|
||||||
vi.mocked(revokeUserSessions).mockImplementationOnce(() => { throw new Error('sweep down'); });
|
vi.mocked(revokeUserSessions).mockImplementationOnce(() => { throw new Error('sweep down'); });
|
||||||
|
|
||||||
expect(await svc.deleteMcpToken(user.id, tokenId)).toEqual({ success: true });
|
expect(await asLegacyResult(svc.deleteMcpToken(user.id, tokenId))).toEqual({ success: true });
|
||||||
expect(await tokenRow(tokenId)).toBeNull();
|
expect(await tokenRow(tokenId)).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('TOKEN-001: listMcpTokens is scoped to the caller and never exposes the hash', async () => {
|
it('TOKEN-001: listMcpTokens is scoped to the caller and never exposes the hash', async () => {
|
||||||
const { user } = createUser(testDb);
|
const { user } = createUser(testDb);
|
||||||
const { user: other } = createUser(testDb);
|
const { user: other } = createUser(testDb);
|
||||||
await svc.createMcpToken(user.id, 'mine');
|
await asLegacyResult(svc.createMcpToken(user.id, 'mine'));
|
||||||
await svc.createMcpToken(other.id, 'theirs');
|
await asLegacyResult(svc.createMcpToken(other.id, 'theirs'));
|
||||||
|
|
||||||
const mine = await svc.listMcpTokens(user.id) as Record<string, unknown>[];
|
const mine = await svc.listMcpTokens(user.id) as Record<string, unknown>[];
|
||||||
expect(mine).toHaveLength(1);
|
expect(mine).toHaveLength(1);
|
||||||
@@ -148,21 +149,21 @@ describe('MCP token service', () => {
|
|||||||
it('TOKEN-002: deleteMcpToken refuses a token that belongs to someone else', async () => {
|
it('TOKEN-002: deleteMcpToken refuses a token that belongs to someone else', async () => {
|
||||||
const { user } = createUser(testDb);
|
const { user } = createUser(testDb);
|
||||||
const { user: other } = createUser(testDb);
|
const { user: other } = createUser(testDb);
|
||||||
const created = await svc.createMcpToken(other.id, 'not-yours');
|
const created = await asLegacyResult(svc.createMcpToken(other.id, 'not-yours'));
|
||||||
const tokenId = String((created.token as { id: number }).id);
|
const tokenId = String((created.token as { id: number }).id);
|
||||||
|
|
||||||
expect(await svc.deleteMcpToken(user.id, tokenId)).toEqual({ error: 'Token not found', status: 404 });
|
expect(await asLegacyResult(svc.deleteMcpToken(user.id, tokenId))).toEqual({ error: 'Token not found', status: 404 });
|
||||||
expect(await tokenRow(tokenId)).not.toBeNull();
|
expect(await tokenRow(tokenId)).not.toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('TOKEN-017: deleteMcpToken 404s (not 500) on a non-numeric id — Plan 3b Task 2 review, F1', async () => {
|
it('TOKEN-017: deleteMcpToken 404s (not 500) on a non-numeric id — Plan 3b Task 2 review, F1', async () => {
|
||||||
const { user } = createUser(testDb);
|
const { user } = createUser(testDb);
|
||||||
expect(await svc.deleteMcpToken(user.id, 'abc')).toEqual({ error: 'Token not found', status: 404 });
|
expect(await asLegacyResult(svc.deleteMcpToken(user.id, 'abc'))).toEqual({ error: 'Token not found', status: 404 });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('TOKEN-018: deleteMcpToken 404s on a hex-shaped id — Number("0x10") is 16, a safe integer a bare Number() conversion would accept, but SQLite affinity never would (Plan 3b Task 2 review, F1)', async () => {
|
it('TOKEN-018: deleteMcpToken 404s on a hex-shaped id — Number("0x10") is 16, a safe integer a bare Number() conversion would accept, but SQLite affinity never would (Plan 3b Task 2 review, F1)', async () => {
|
||||||
const { user } = createUser(testDb);
|
const { user } = createUser(testDb);
|
||||||
expect(await svc.deleteMcpToken(user.id, '0x10')).toEqual({ error: 'Token not found', status: 404 });
|
expect(await asLegacyResult(svc.deleteMcpToken(user.id, '0x10'))).toEqual({ error: 'Token not found', status: 404 });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -177,7 +178,7 @@ describe('MCP token service', () => {
|
|||||||
describe('API key service', () => {
|
describe('API key service', () => {
|
||||||
it('TOKEN-010: an API key does not verify as an MCP token', async () => {
|
it('TOKEN-010: an API key does not verify as an MCP token', async () => {
|
||||||
const { user } = createUser(testDb);
|
const { user } = createUser(testDb);
|
||||||
const created = await svc.createApiToken(user.id, 'dawarich');
|
const created = await asLegacyResult(svc.createApiToken(user.id, 'dawarich'));
|
||||||
const raw = (created.token as { raw_token: string }).raw_token;
|
const raw = (created.token as { raw_token: string }).raw_token;
|
||||||
|
|
||||||
expect((await svc.verifyApiToken(raw))?.id).toBe(user.id);
|
expect((await svc.verifyApiToken(raw))?.id).toBe(user.id);
|
||||||
@@ -186,7 +187,7 @@ describe('API key service', () => {
|
|||||||
|
|
||||||
it('TOKEN-011: an MCP token does not verify as an API key', async () => {
|
it('TOKEN-011: an MCP token does not verify as an API key', async () => {
|
||||||
const { user } = createUser(testDb);
|
const { user } = createUser(testDb);
|
||||||
const created = await svc.createMcpToken(user.id, 'claude');
|
const created = await asLegacyResult(svc.createMcpToken(user.id, 'claude'));
|
||||||
const raw = (created.token as { raw_token: string }).raw_token;
|
const raw = (created.token as { raw_token: string }).raw_token;
|
||||||
|
|
||||||
expect((await svc.verifyMcpToken(raw))?.id).toBe(user.id);
|
expect((await svc.verifyMcpToken(raw))?.id).toBe(user.id);
|
||||||
@@ -195,8 +196,8 @@ describe('API key service', () => {
|
|||||||
|
|
||||||
it('TOKEN-012: each list shows only its own kind', async () => {
|
it('TOKEN-012: each list shows only its own kind', async () => {
|
||||||
const { user } = createUser(testDb);
|
const { user } = createUser(testDb);
|
||||||
await svc.createMcpToken(user.id, 'claude');
|
await asLegacyResult(svc.createMcpToken(user.id, 'claude'));
|
||||||
await svc.createApiToken(user.id, 'dawarich');
|
await asLegacyResult(svc.createApiToken(user.id, 'dawarich'));
|
||||||
|
|
||||||
const mcp = await svc.listMcpTokens(user.id) as Record<string, unknown>[];
|
const mcp = await svc.listMcpTokens(user.id) as Record<string, unknown>[];
|
||||||
const api = await svc.listApiTokens(user.id) as Record<string, unknown>[];
|
const api = await svc.listApiTokens(user.id) as Record<string, unknown>[];
|
||||||
@@ -206,11 +207,11 @@ describe('API key service', () => {
|
|||||||
|
|
||||||
it('TOKEN-013: deleting across kinds 404s, identically to an unknown id', async () => {
|
it('TOKEN-013: deleting across kinds 404s, identically to an unknown id', async () => {
|
||||||
const { user } = createUser(testDb);
|
const { user } = createUser(testDb);
|
||||||
const mcpId = String((await svc.createMcpToken(user.id, 'claude')).token!.id as number);
|
const mcpId = String((await asLegacyResult(svc.createMcpToken(user.id, 'claude'))).token!.id as number);
|
||||||
const apiId = String((await svc.createApiToken(user.id, 'dawarich')).token!.id as number);
|
const apiId = String((await asLegacyResult(svc.createApiToken(user.id, 'dawarich'))).token!.id as number);
|
||||||
|
|
||||||
expect(await svc.deleteApiToken(user.id, mcpId)).toEqual({ error: 'Token not found', status: 404 });
|
expect(await asLegacyResult(svc.deleteApiToken(user.id, mcpId))).toEqual({ error: 'Token not found', status: 404 });
|
||||||
expect(await svc.deleteMcpToken(user.id, apiId)).toEqual({ error: 'Token not found', status: 404 });
|
expect(await asLegacyResult(svc.deleteMcpToken(user.id, apiId))).toEqual({ error: 'Token not found', status: 404 });
|
||||||
// Neither row was touched: a wrong-kind delete must not be a way to revoke
|
// Neither row was touched: a wrong-kind delete must not be a way to revoke
|
||||||
// someone's assistant access from the API-key screen.
|
// someone's assistant access from the API-key screen.
|
||||||
expect(await tokenRow(mcpId)).not.toBeNull();
|
expect(await tokenRow(mcpId)).not.toBeNull();
|
||||||
@@ -219,16 +220,16 @@ describe('API key service', () => {
|
|||||||
|
|
||||||
it('TOKEN-014: the ten-token ceiling counts each kind on its own', async () => {
|
it('TOKEN-014: the ten-token ceiling counts each kind on its own', async () => {
|
||||||
const { user } = createUser(testDb);
|
const { user } = createUser(testDb);
|
||||||
for (let i = 0; i < 10; i += 1) await svc.createMcpToken(user.id, `mcp-${i}`);
|
for (let i = 0; i < 10; i += 1) await asLegacyResult(svc.createMcpToken(user.id, `mcp-${i}`));
|
||||||
|
|
||||||
expect((await svc.createMcpToken(user.id, 'one-too-many')).status).toBe(400);
|
expect((await asLegacyResult(svc.createMcpToken(user.id, 'one-too-many'))).status).toBe(400);
|
||||||
// A full MCP shelf must not lock the user out of minting an API key.
|
// A full MCP shelf must not lock the user out of minting an API key.
|
||||||
expect((await svc.createApiToken(user.id, 'dawarich')).status).toBeUndefined();
|
expect((await asLegacyResult(svc.createApiToken(user.id, 'dawarich'))).status).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('TOKEN-015: an API key is stored hashed, with only a prefix in the clear', async () => {
|
it('TOKEN-015: an API key is stored hashed, with only a prefix in the clear', async () => {
|
||||||
const { user } = createUser(testDb);
|
const { user } = createUser(testDb);
|
||||||
const created = await svc.createApiToken(user.id, 'dawarich');
|
const created = await asLegacyResult(svc.createApiToken(user.id, 'dawarich'));
|
||||||
const raw = (created.token as { raw_token: string }).raw_token;
|
const raw = (created.token as { raw_token: string }).raw_token;
|
||||||
|
|
||||||
const row = (await findRow(await sharedTestOrm(testDb), McpTokens, { user: user.id }))!;
|
const row = (await findRow(await sharedTestOrm(testDb), McpTokens, { user: user.id }))!;
|
||||||
@@ -239,7 +240,7 @@ describe('API key service', () => {
|
|||||||
|
|
||||||
it('TOKEN-016: verifying an API key records last_used_at, so a stale key is visible', async () => {
|
it('TOKEN-016: verifying an API key records last_used_at, so a stale key is visible', async () => {
|
||||||
const { user } = createUser(testDb);
|
const { user } = createUser(testDb);
|
||||||
const raw = (await svc.createApiToken(user.id, 'dawarich')).token!.raw_token as string;
|
const raw = (await asLegacyResult(svc.createApiToken(user.id, 'dawarich'))).token!.raw_token as string;
|
||||||
|
|
||||||
const before = await svc.listApiTokens(user.id) as Record<string, unknown>[];
|
const before = await svc.listApiTokens(user.id) as Record<string, unknown>[];
|
||||||
expect(before[0].last_used_at).toBeNull();
|
expect(before[0].last_used_at).toBeNull();
|
||||||
@@ -265,7 +266,7 @@ describe('MCP token service (admin view)', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('ADMIN-SVC-069 — adminDeleteMcpToken returns 404 for non-existent token', async () => {
|
it('ADMIN-SVC-069 — adminDeleteMcpToken returns 404 for non-existent token', async () => {
|
||||||
const result = await svc.adminDeleteMcpToken('99999') as any;
|
const result = await asLegacyResult(svc.adminDeleteMcpToken('99999')) as any;
|
||||||
expect(result.status).toBe(404);
|
expect(result.status).toBe(404);
|
||||||
expect(result.error).toBeDefined();
|
expect(result.error).toBeDefined();
|
||||||
});
|
});
|
||||||
@@ -273,8 +274,8 @@ describe('MCP token service (admin view)', () => {
|
|||||||
it('TOKEN-003: listAllMcpTokens spans users and carries the owner username', async () => {
|
it('TOKEN-003: listAllMcpTokens spans users and carries the owner username', async () => {
|
||||||
const { user } = createUser(testDb);
|
const { user } = createUser(testDb);
|
||||||
const { user: other } = createUser(testDb);
|
const { user: other } = createUser(testDb);
|
||||||
await svc.createMcpToken(user.id, 'a');
|
await asLegacyResult(svc.createMcpToken(user.id, 'a'));
|
||||||
await svc.createMcpToken(other.id, 'b');
|
await asLegacyResult(svc.createMcpToken(other.id, 'b'));
|
||||||
|
|
||||||
// `listAllMcpTokens` returns the repository's own typed
|
// `listAllMcpTokens` returns the repository's own typed
|
||||||
// `McpTokenWithUsernameRow[]` now (Plan 3b Task 2) — no cast needed
|
// `McpTokenWithUsernameRow[]` now (Plan 3b Task 2) — no cast needed
|
||||||
@@ -289,26 +290,26 @@ describe('MCP token service (admin view)', () => {
|
|||||||
|
|
||||||
it('TOKEN-004: adminDeleteMcpToken removes any user token and revokes that user', async () => {
|
it('TOKEN-004: adminDeleteMcpToken removes any user token and revokes that user', async () => {
|
||||||
const { user } = createUser(testDb);
|
const { user } = createUser(testDb);
|
||||||
const created = await svc.createMcpToken(user.id, 'admin-killed');
|
const created = await asLegacyResult(svc.createMcpToken(user.id, 'admin-killed'));
|
||||||
const tokenId = String((created.token as { id: number }).id);
|
const tokenId = String((created.token as { id: number }).id);
|
||||||
|
|
||||||
expect(await svc.adminDeleteMcpToken(tokenId)).toEqual({});
|
expect(await asLegacyResult(svc.adminDeleteMcpToken(tokenId))).toEqual({});
|
||||||
expect(await tokenRow(tokenId)).toBeNull();
|
expect(await tokenRow(tokenId)).toBeNull();
|
||||||
expect(revokeUserSessions).toHaveBeenCalledWith(user.id);
|
expect(revokeUserSessions).toHaveBeenCalledWith(user.id);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('TOKEN-005: adminDeleteMcpToken 404s on an unknown id without revoking anyone', async () => {
|
it('TOKEN-005: adminDeleteMcpToken 404s on an unknown id without revoking anyone', async () => {
|
||||||
expect(await svc.adminDeleteMcpToken('99999')).toEqual({ error: 'Token not found', status: 404 });
|
expect(await asLegacyResult(svc.adminDeleteMcpToken('99999'))).toEqual({ error: 'Token not found', status: 404 });
|
||||||
expect(revokeUserSessions).not.toHaveBeenCalled();
|
expect(revokeUserSessions).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('TOKEN-019: adminDeleteMcpToken 404s (not 500) on a non-numeric id, without revoking anyone — Plan 3b Task 2 review, F1', async () => {
|
it('TOKEN-019: adminDeleteMcpToken 404s (not 500) on a non-numeric id, without revoking anyone — Plan 3b Task 2 review, F1', async () => {
|
||||||
expect(await svc.adminDeleteMcpToken('abc')).toEqual({ error: 'Token not found', status: 404 });
|
expect(await asLegacyResult(svc.adminDeleteMcpToken('abc'))).toEqual({ error: 'Token not found', status: 404 });
|
||||||
expect(revokeUserSessions).not.toHaveBeenCalled();
|
expect(revokeUserSessions).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('TOKEN-020: adminDeleteMcpToken 404s on a hex-shaped id (Plan 3b Task 2 review, F1)', async () => {
|
it('TOKEN-020: adminDeleteMcpToken 404s on a hex-shaped id (Plan 3b Task 2 review, F1)', async () => {
|
||||||
expect(await svc.adminDeleteMcpToken('0x10')).toEqual({ error: 'Token not found', status: 404 });
|
expect(await asLegacyResult(svc.adminDeleteMcpToken('0x10'))).toEqual({ error: 'Token not found', status: 404 });
|
||||||
expect(revokeUserSessions).not.toHaveBeenCalled();
|
expect(revokeUserSessions).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -329,7 +330,7 @@ describe('ephemeral tokens', () => {
|
|||||||
it('AUTH-DB-087: createWsToken returns the ephemeral token when the store answers', async () => {
|
it('AUTH-DB-087: createWsToken returns the ephemeral token when the store answers', async () => {
|
||||||
const { user } = createUser(testDb);
|
const { user } = createUser(testDb);
|
||||||
vi.mocked(createEphemeralToken).mockReturnValueOnce('ws-tok');
|
vi.mocked(createEphemeralToken).mockReturnValueOnce('ws-tok');
|
||||||
expect(await svc.createWsToken(user.id)).toEqual({ token: 'ws-tok' });
|
expect(await asLegacyResult(svc.createWsToken(user.id))).toEqual({ token: 'ws-tok' });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('TOKEN-006: createWsToken binds the caller password_version, so a pre-reset token is rejected on connect', async () => {
|
it('TOKEN-006: createWsToken binds the caller password_version, so a pre-reset token is rejected on connect', async () => {
|
||||||
@@ -337,20 +338,20 @@ describe('ephemeral tokens', () => {
|
|||||||
await updateRows(await sharedTestOrm(testDb), Users, { id: user.id }, { password_version: 7 });
|
await updateRows(await sharedTestOrm(testDb), Users, { id: user.id }, { password_version: 7 });
|
||||||
vi.mocked(createEphemeralToken).mockReturnValueOnce('ws-tok');
|
vi.mocked(createEphemeralToken).mockReturnValueOnce('ws-tok');
|
||||||
|
|
||||||
await svc.createWsToken(user.id);
|
await asLegacyResult(svc.createWsToken(user.id));
|
||||||
expect(createEphemeralToken).toHaveBeenCalledWith(user.id, 'ws', { pv: 7 });
|
expect(createEphemeralToken).toHaveBeenCalledWith(user.id, 'ws', { pv: 7 });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('TOKEN-007: createWsToken falls back to pv 0 for a user row without one', async () => {
|
it('TOKEN-007: createWsToken falls back to pv 0 for a user row without one', async () => {
|
||||||
vi.mocked(createEphemeralToken).mockReturnValueOnce('ws-tok');
|
vi.mocked(createEphemeralToken).mockReturnValueOnce('ws-tok');
|
||||||
await svc.createWsToken(99999);
|
await asLegacyResult(svc.createWsToken(99999));
|
||||||
expect(createEphemeralToken).toHaveBeenCalledWith(99999, 'ws', { pv: 0 });
|
expect(createEphemeralToken).toHaveBeenCalledWith(99999, 'ws', { pv: 0 });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('TOKEN-008: createWsToken reports 503 when the store refuses', async () => {
|
it('TOKEN-008: createWsToken reports 503 when the store refuses', async () => {
|
||||||
const { user } = createUser(testDb);
|
const { user } = createUser(testDb);
|
||||||
vi.mocked(createEphemeralToken).mockReturnValueOnce(null as unknown as string);
|
vi.mocked(createEphemeralToken).mockReturnValueOnce(null as unknown as string);
|
||||||
expect(await svc.createWsToken(user.id)).toEqual({ error: 'Service unavailable', status: 503 });
|
expect(await asLegacyResult(svc.createWsToken(user.id))).toEqual({ error: 'Service unavailable', status: 503 });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -361,7 +362,7 @@ describe('ephemeral tokens', () => {
|
|||||||
describe('verifyMcpToken', () => {
|
describe('verifyMcpToken', () => {
|
||||||
it('AUTH-BR-002: verifyMcpToken resolves a freshly created token to its user', async () => {
|
it('AUTH-BR-002: verifyMcpToken resolves a freshly created token to its user', async () => {
|
||||||
const { user } = createUser(testDb);
|
const { user } = createUser(testDb);
|
||||||
const created = await svc.createMcpToken(user.id, 'bridge-case');
|
const created = await asLegacyResult(svc.createMcpToken(user.id, 'bridge-case'));
|
||||||
const raw = (created.token as { raw_token: string }).raw_token;
|
const raw = (created.token as { raw_token: string }).raw_token;
|
||||||
|
|
||||||
const resolved = await svc.verifyMcpToken(raw);
|
const resolved = await svc.verifyMcpToken(raw);
|
||||||
@@ -371,7 +372,7 @@ describe('verifyMcpToken', () => {
|
|||||||
|
|
||||||
it('TOKEN-009: a successful verify stamps last_used_at, a failed one changes nothing', async () => {
|
it('TOKEN-009: a successful verify stamps last_used_at, a failed one changes nothing', async () => {
|
||||||
const { user } = createUser(testDb);
|
const { user } = createUser(testDb);
|
||||||
const created = await svc.createMcpToken(user.id, 'stamped');
|
const created = await asLegacyResult(svc.createMcpToken(user.id, 'stamped'));
|
||||||
const raw = (created.token as { raw_token: string }).raw_token;
|
const raw = (created.token as { raw_token: string }).raw_token;
|
||||||
const id = (created.token as { id: number }).id;
|
const id = (created.token as { id: number }).id;
|
||||||
expect((await tokenRow(id))!.last_used_at).toBeNull();
|
expect((await tokenRow(id))!.last_used_at).toBeNull();
|
||||||
@@ -385,7 +386,7 @@ describe('verifyMcpToken', () => {
|
|||||||
|
|
||||||
it('TOKEN-010: verifyMcpToken returns identity columns only, never the password hash', async () => {
|
it('TOKEN-010: verifyMcpToken returns identity columns only, never the password hash', async () => {
|
||||||
const { user } = createUser(testDb);
|
const { user } = createUser(testDb);
|
||||||
const created = await svc.createMcpToken(user.id, 'lean');
|
const created = await asLegacyResult(svc.createMcpToken(user.id, 'lean'));
|
||||||
const raw = (created.token as { raw_token: string }).raw_token;
|
const raw = (created.token as { raw_token: string }).raw_token;
|
||||||
|
|
||||||
const resolved = await svc.verifyMcpToken(raw) as unknown as Record<string, unknown>;
|
const resolved = await svc.verifyMcpToken(raw) as unknown as Record<string, unknown>;
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ vi.mock('../../../src/nest/common/crypto/apiKeyCrypto', () => ({
|
|||||||
// Imports (after mocks)
|
// Imports (after mocks)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
import { asLegacyResult } from '../../helpers/domain-error';
|
||||||
import { db as testDb } from '../../../src/db/database';
|
import { db as testDb } from '../../../src/db/database';
|
||||||
import { describe, it, expect, beforeAll, beforeEach, afterAll, vi } from 'vitest';
|
import { describe, it, expect, beforeAll, beforeEach, afterAll, vi } from 'vitest';
|
||||||
import { resetTestDb } from '../../helpers/test-db';
|
import { resetTestDb } from '../../helpers/test-db';
|
||||||
@@ -83,21 +84,21 @@ afterAll(() => {
|
|||||||
describe('updateSettings', () => {
|
describe('updateSettings', () => {
|
||||||
it('AUTH-DB-001: updates username successfully', async () => {
|
it('AUTH-DB-001: updates username successfully', async () => {
|
||||||
const { user } = createUser(testDb);
|
const { user } = createUser(testDb);
|
||||||
const result = await profile.updateSettings(user.id, { username: 'newname' });
|
const result = await asLegacyResult(profile.updateSettings(user.id, { username: 'newname' }));
|
||||||
expect(result.success).toBe(true);
|
expect(result.success).toBe(true);
|
||||||
expect(result.user?.username).toBe('newname');
|
expect(result.user?.username).toBe('newname');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('AUTH-DB-002: returns 400 when username is too short (< 2 chars)', async () => {
|
it('AUTH-DB-002: returns 400 when username is too short (< 2 chars)', async () => {
|
||||||
const { user } = createUser(testDb);
|
const { user } = createUser(testDb);
|
||||||
const result = await profile.updateSettings(user.id, { username: 'x' });
|
const result = await asLegacyResult(profile.updateSettings(user.id, { username: 'x' }));
|
||||||
expect(result.status).toBe(400);
|
expect(result.status).toBe(400);
|
||||||
expect(result.error).toMatch(/between 2 and 50/i);
|
expect(result.error).toMatch(/between 2 and 50/i);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('AUTH-DB-003: returns 400 when username has invalid characters (spaces)', async () => {
|
it('AUTH-DB-003: returns 400 when username has invalid characters (spaces)', async () => {
|
||||||
const { user } = createUser(testDb);
|
const { user } = createUser(testDb);
|
||||||
const result = await profile.updateSettings(user.id, { username: 'bad name' });
|
const result = await asLegacyResult(profile.updateSettings(user.id, { username: 'bad name' }));
|
||||||
expect(result.status).toBe(400);
|
expect(result.status).toBe(400);
|
||||||
expect(result.error).toMatch(/only contain/i);
|
expect(result.error).toMatch(/only contain/i);
|
||||||
});
|
});
|
||||||
@@ -105,21 +106,21 @@ describe('updateSettings', () => {
|
|||||||
it('AUTH-DB-004: returns 409 when username is already taken by another user', async () => {
|
it('AUTH-DB-004: returns 409 when username is already taken by another user', async () => {
|
||||||
const { user: user1 } = createUser(testDb, { username: 'alice' });
|
const { user: user1 } = createUser(testDb, { username: 'alice' });
|
||||||
const { user: user2 } = createUser(testDb, { username: 'bob' });
|
const { user: user2 } = createUser(testDb, { username: 'bob' });
|
||||||
const result = await profile.updateSettings(user2.id, { username: user1.username });
|
const result = await asLegacyResult(profile.updateSettings(user2.id, { username: user1.username }));
|
||||||
expect(result.status).toBe(409);
|
expect(result.status).toBe(409);
|
||||||
expect(result.error).toMatch(/already taken/i);
|
expect(result.error).toMatch(/already taken/i);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('AUTH-DB-005: updates email successfully', async () => {
|
it('AUTH-DB-005: updates email successfully', async () => {
|
||||||
const { user } = createUser(testDb);
|
const { user } = createUser(testDb);
|
||||||
const result = await profile.updateSettings(user.id, { email: 'new@example.com' });
|
const result = await asLegacyResult(profile.updateSettings(user.id, { email: 'new@example.com' }));
|
||||||
expect(result.success).toBe(true);
|
expect(result.success).toBe(true);
|
||||||
expect(result.user?.email).toBe('new@example.com');
|
expect(result.user?.email).toBe('new@example.com');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('AUTH-DB-006: returns 400 for invalid email format', async () => {
|
it('AUTH-DB-006: returns 400 for invalid email format', async () => {
|
||||||
const { user } = createUser(testDb);
|
const { user } = createUser(testDb);
|
||||||
const result = await profile.updateSettings(user.id, { email: 'not-an-email' });
|
const result = await asLegacyResult(profile.updateSettings(user.id, { email: 'not-an-email' }));
|
||||||
expect(result.status).toBe(400);
|
expect(result.status).toBe(400);
|
||||||
expect(result.error).toMatch(/invalid email/i);
|
expect(result.error).toMatch(/invalid email/i);
|
||||||
});
|
});
|
||||||
@@ -127,14 +128,14 @@ describe('updateSettings', () => {
|
|||||||
it('AUTH-DB-007: returns 409 when email is already taken by another user', async () => {
|
it('AUTH-DB-007: returns 409 when email is already taken by another user', async () => {
|
||||||
const { user: user1 } = createUser(testDb, { email: 'taken@example.com' });
|
const { user: user1 } = createUser(testDb, { email: 'taken@example.com' });
|
||||||
const { user: user2 } = createUser(testDb);
|
const { user: user2 } = createUser(testDb);
|
||||||
const result = await profile.updateSettings(user2.id, { email: user1.email });
|
const result = await asLegacyResult(profile.updateSettings(user2.id, { email: user1.email }));
|
||||||
expect(result.status).toBe(409);
|
expect(result.status).toBe(409);
|
||||||
expect(result.error).toMatch(/already taken/i);
|
expect(result.error).toMatch(/already taken/i);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('AUTH-DB-008: returns success with no field changes when empty body is passed', async () => {
|
it('AUTH-DB-008: returns success with no field changes when empty body is passed', async () => {
|
||||||
const { user } = createUser(testDb);
|
const { user } = createUser(testDb);
|
||||||
const result = await profile.updateSettings(user.id, {});
|
const result = await asLegacyResult(profile.updateSettings(user.id, {}));
|
||||||
expect(result.success).toBe(true);
|
expect(result.success).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -146,7 +147,7 @@ describe('updateSettings', () => {
|
|||||||
describe('getSettings', () => {
|
describe('getSettings', () => {
|
||||||
it('AUTH-DB-009: returns 403 for non-admin user', async () => {
|
it('AUTH-DB-009: returns 403 for non-admin user', async () => {
|
||||||
const { user } = createUser(testDb);
|
const { user } = createUser(testDb);
|
||||||
const result = await profile.getSettings(user.id);
|
const result = await asLegacyResult(profile.getSettings(user.id));
|
||||||
expect(result.status).toBe(403);
|
expect(result.status).toBe(403);
|
||||||
expect(result.error).toMatch(/admin/i);
|
expect(result.error).toMatch(/admin/i);
|
||||||
});
|
});
|
||||||
@@ -154,7 +155,7 @@ describe('getSettings', () => {
|
|||||||
it('AUTH-DB-010: returns maps_api_key and openweather_api_key for admin', async () => {
|
it('AUTH-DB-010: returns maps_api_key and openweather_api_key for admin', async () => {
|
||||||
const { user } = createAdmin(testDb);
|
const { user } = createAdmin(testDb);
|
||||||
await setUserColumns(user.id, { maps_api_key: 'maps-key-value', openweather_api_key: 'weather-key-value' });
|
await setUserColumns(user.id, { maps_api_key: 'maps-key-value', openweather_api_key: 'weather-key-value' });
|
||||||
const result = await profile.getSettings(user.id);
|
const result = await asLegacyResult(profile.getSettings(user.id));
|
||||||
expect(result.status).toBeUndefined();
|
expect(result.status).toBeUndefined();
|
||||||
expect(result.settings).toBeDefined();
|
expect(result.settings).toBeDefined();
|
||||||
expect(result.settings).toHaveProperty('maps_api_key');
|
expect(result.settings).toHaveProperty('maps_api_key');
|
||||||
@@ -167,7 +168,7 @@ describe('getSettings', () => {
|
|||||||
// Returned to the client masked, never in plaintext.
|
// Returned to the client masked, never in plaintext.
|
||||||
expect(result.user.unsplash_api_key).toBe('-----key');
|
expect(result.user.unsplash_api_key).toBe('-----key');
|
||||||
// getSettings returns the stored key to the admin.
|
// getSettings returns the stored key to the admin.
|
||||||
expect((await profile.getSettings(user.id)).settings?.unsplash_api_key).toBe('unsplash-secret-key');
|
expect((await asLegacyResult(profile.getSettings(user.id))).settings?.unsplash_api_key).toBe('unsplash-secret-key');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('AUTH-DB-010c: a key set in the environment comes back as its variable name, not a value (#1881)', async () => {
|
it('AUTH-DB-010c: a key set in the environment comes back as its variable name, not a value (#1881)', async () => {
|
||||||
@@ -176,7 +177,7 @@ describe('getSettings', () => {
|
|||||||
try {
|
try {
|
||||||
const { user } = createAdmin(testDb);
|
const { user } = createAdmin(testDb);
|
||||||
await profile.updateApiKeys(user.id, { maps_api_key: 'stored-but-overridden', unsplash_api_key: 'stored-unsplash' });
|
await profile.updateApiKeys(user.id, { maps_api_key: 'stored-but-overridden', unsplash_api_key: 'stored-unsplash' });
|
||||||
const settings = (await profile.getSettings(user.id)).settings;
|
const settings = (await asLegacyResult(profile.getSettings(user.id))).settings;
|
||||||
// Neither the stored value nobody searches with nor the operator's own.
|
// Neither the stored value nobody searches with nor the operator's own.
|
||||||
expect(settings?.maps_api_key).toBeNull();
|
expect(settings?.maps_api_key).toBeNull();
|
||||||
expect(settings?.env_keys).toEqual({ maps_api_key: 'PLACES_API_KEY' });
|
expect(settings?.env_keys).toEqual({ maps_api_key: 'PLACES_API_KEY' });
|
||||||
@@ -191,7 +192,7 @@ describe('getSettings', () => {
|
|||||||
|
|
||||||
it('AUTH-DB-010d: without a variable env_keys is empty', async () => {
|
it('AUTH-DB-010d: without a variable env_keys is empty', async () => {
|
||||||
const { user } = createAdmin(testDb);
|
const { user } = createAdmin(testDb);
|
||||||
expect((await profile.getSettings(user.id)).settings?.env_keys).toEqual({});
|
expect((await asLegacyResult(profile.getSettings(user.id))).settings?.env_keys).toEqual({});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -226,16 +227,14 @@ describe('listUsers', () => {
|
|||||||
describe('validateKeys', () => {
|
describe('validateKeys', () => {
|
||||||
it('AUTH-DB-015: returns 403 for non-admin', async () => {
|
it('AUTH-DB-015: returns 403 for non-admin', async () => {
|
||||||
const { user } = createUser(testDb);
|
const { user } = createUser(testDb);
|
||||||
const result = await profile.validateKeys(user.id);
|
const result = await asLegacyResult(profile.validateKeys(user.id));
|
||||||
expect(result.status).toBe(403);
|
expect(result.status).toBe(403);
|
||||||
expect(result.error).toMatch(/admin/i);
|
expect(result.error).toMatch(/admin/i);
|
||||||
expect(result.maps).toBe(false);
|
|
||||||
expect(result.weather).toBe(false);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('AUTH-DB-016: returns { maps: false, weather: false } when no API keys are stored', async () => {
|
it('AUTH-DB-016: returns { maps: false, weather: false } when no API keys are stored', async () => {
|
||||||
const { user } = createAdmin(testDb);
|
const { user } = createAdmin(testDb);
|
||||||
const result = await profile.validateKeys(user.id);
|
const result = await asLegacyResult(profile.validateKeys(user.id));
|
||||||
expect(result.maps).toBe(false);
|
expect(result.maps).toBe(false);
|
||||||
expect(result.weather).toBe(false);
|
expect(result.weather).toBe(false);
|
||||||
expect(result.maps_details).toBeNull();
|
expect(result.maps_details).toBeNull();
|
||||||
@@ -251,7 +250,7 @@ describe('validateKeys', () => {
|
|||||||
text: async () => '',
|
text: async () => '',
|
||||||
} as Response);
|
} as Response);
|
||||||
|
|
||||||
const result = await profile.validateKeys(user.id);
|
const result = await asLegacyResult(profile.validateKeys(user.id));
|
||||||
expect(result.maps).toBe(true);
|
expect(result.maps).toBe(true);
|
||||||
expect(result.maps_details?.ok).toBe(true);
|
expect(result.maps_details?.ok).toBe(true);
|
||||||
|
|
||||||
@@ -266,7 +265,7 @@ describe('validateKeys', () => {
|
|||||||
.spyOn(global, 'fetch')
|
.spyOn(global, 'fetch')
|
||||||
.mockRejectedValueOnce(new Error('Network failure'));
|
.mockRejectedValueOnce(new Error('Network failure'));
|
||||||
|
|
||||||
const result = await profile.validateKeys(user.id);
|
const result = await asLegacyResult(profile.validateKeys(user.id));
|
||||||
expect(result.maps).toBe(false);
|
expect(result.maps).toBe(false);
|
||||||
expect(result.maps_details?.error_status).toBe('FETCH_ERROR');
|
expect(result.maps_details?.error_status).toBe('FETCH_ERROR');
|
||||||
expect(result.maps_details?.error_message).toBe('Network failure');
|
expect(result.maps_details?.error_message).toBe('Network failure');
|
||||||
@@ -286,7 +285,7 @@ describe('validateKeys', () => {
|
|||||||
text: async () => '',
|
text: async () => '',
|
||||||
} as Response);
|
} as Response);
|
||||||
|
|
||||||
await profile.validateKeys(user.id);
|
await asLegacyResult(profile.validateKeys(user.id));
|
||||||
const headers = (fetchSpy.mock.calls[0]?.[1]?.headers ?? {}) as Record<string, string>;
|
const headers = (fetchSpy.mock.calls[0]?.[1]?.headers ?? {}) as Record<string, string>;
|
||||||
expect(headers.Referer).toBe('https://trek.example.com');
|
expect(headers.Referer).toBe('https://trek.example.com');
|
||||||
|
|
||||||
@@ -309,7 +308,7 @@ describe('validateKeys', () => {
|
|||||||
// key belongs to whoever runs the box, and the panel used to answer
|
// key belongs to whoever runs the box, and the panel used to answer
|
||||||
// "no maps key" while every search on that same install worked.
|
// "no maps key" while every search on that same install worked.
|
||||||
const { user } = createAdmin(testDb);
|
const { user } = createAdmin(testDb);
|
||||||
const result = await profile.validateKeys(user.id);
|
const result = await asLegacyResult(profile.validateKeys(user.id));
|
||||||
const headers = (fetchSpy.mock.calls[0]?.[1]?.headers ?? {}) as Record<string, string>;
|
const headers = (fetchSpy.mock.calls[0]?.[1]?.headers ?? {}) as Record<string, string>;
|
||||||
expect(headers['X-Goog-Api-Key']).toBe('operator-key');
|
expect(headers['X-Goog-Api-Key']).toBe('operator-key');
|
||||||
expect(result.maps).toBe(true);
|
expect(result.maps).toBe(true);
|
||||||
@@ -334,7 +333,7 @@ describe('validateKeys', () => {
|
|||||||
text: async () => '',
|
text: async () => '',
|
||||||
} as Response);
|
} as Response);
|
||||||
|
|
||||||
await profile.validateKeys(user.id);
|
await asLegacyResult(profile.validateKeys(user.id));
|
||||||
const headers = (fetchSpy.mock.calls[0]?.[1]?.headers ?? {}) as Record<string, string>;
|
const headers = (fetchSpy.mock.calls[0]?.[1]?.headers ?? {}) as Record<string, string>;
|
||||||
expect(headers['X-Goog-Api-Key']).toBe('live-instance-key');
|
expect(headers['X-Goog-Api-Key']).toBe('live-instance-key');
|
||||||
// And it asks for the fields the real search asks for, so a key restricted
|
// And it asks for the fields the real search asks for, so a key restricted
|
||||||
@@ -454,9 +453,9 @@ describe('instance-wide API keys', () => {
|
|||||||
const { user } = createAdmin(testDb);
|
const { user } = createAdmin(testDb);
|
||||||
await setUserColumns(user.id, { maps_api_key: 'stale-personal-key' });
|
await setUserColumns(user.id, { maps_api_key: 'stale-personal-key' });
|
||||||
await setAppSetting(await sharedTestOrm(testDb), 'maps_api_key', 'live-instance-key');
|
await setAppSetting(await sharedTestOrm(testDb), 'maps_api_key', 'live-instance-key');
|
||||||
expect((await profile.getSettings(user.id)).settings?.maps_api_key).toBe('live-instance-key');
|
expect((await asLegacyResult(profile.getSettings(user.id))).settings?.maps_api_key).toBe('live-instance-key');
|
||||||
// openweather is per-user and unaffected.
|
// openweather is per-user and unaffected.
|
||||||
expect((await profile.getSettings(user.id)).settings?.openweather_api_key).toBeNull();
|
expect((await asLegacyResult(profile.getSettings(user.id))).settings?.openweather_api_key).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('AUTH-DB-106: updateMapsKey mirrors for an admin and stays personal for a member', async () => {
|
it('AUTH-DB-106: updateMapsKey mirrors for an admin and stays personal for a member', async () => {
|
||||||
@@ -470,7 +469,7 @@ describe('instance-wide API keys', () => {
|
|||||||
|
|
||||||
it('AUTH-DB-107: updateSettings mirrors the key half without touching name/email handling', async () => {
|
it('AUTH-DB-107: updateSettings mirrors the key half without touching name/email handling', async () => {
|
||||||
const { user } = createAdmin(testDb);
|
const { user } = createAdmin(testDb);
|
||||||
const result = await profile.updateSettings(user.id, { maps_api_key: 'from-settings-route', username: 'renamed' });
|
const result = await asLegacyResult(profile.updateSettings(user.id, { maps_api_key: 'from-settings-route', username: 'renamed' }));
|
||||||
expect(result.success).toBe(true);
|
expect(result.success).toBe(true);
|
||||||
expect(result.user?.username).toBe('renamed');
|
expect(result.user?.username).toBe('renamed');
|
||||||
expect(await instanceRow('maps_api_key')).toBe('from-settings-route');
|
expect(await instanceRow('maps_api_key')).toBe('from-settings-route');
|
||||||
@@ -508,7 +507,7 @@ describe('changedKeys', () => {
|
|||||||
expect(result.changedKeys).toEqual([]);
|
expect(result.changedKeys).toEqual([]);
|
||||||
expect(await instanceRow('maps_api_key')).toBeUndefined();
|
expect(await instanceRow('maps_api_key')).toBeUndefined();
|
||||||
expect((await profile.updateMapsKey(user.id, 'operator-owns-this')).changedKeys).toEqual([]);
|
expect((await profile.updateMapsKey(user.id, 'operator-owns-this')).changedKeys).toEqual([]);
|
||||||
expect((await profile.updateSettings(user.id, { maps_api_key: 'operator-owns-this' })).changedKeys).toEqual([]);
|
expect((await asLegacyResult(profile.updateSettings(user.id, { maps_api_key: 'operator-owns-this' }))).changedKeys).toEqual([]);
|
||||||
} finally {
|
} finally {
|
||||||
if (prev === undefined) delete process.env.TREK_MANAGED;
|
if (prev === undefined) delete process.env.TREK_MANAGED;
|
||||||
else process.env.TREK_MANAGED = prev;
|
else process.env.TREK_MANAGED = prev;
|
||||||
@@ -522,7 +521,7 @@ describe('updateSettings loses the race for an email', () => {
|
|||||||
const { user } = createUser(testDb, { email: 'carl@example.com' });
|
const { user } = createUser(testDb, { email: 'carl@example.com' });
|
||||||
const check = vi.spyOn(UsersRepository.prototype, 'findIdByEmailCI').mockResolvedValue(null);
|
const check = vi.spyOn(UsersRepository.prototype, 'findIdByEmailCI').mockResolvedValue(null);
|
||||||
try {
|
try {
|
||||||
const result = await profile.updateSettings(user.id, { email: 'ANNA@example.com' });
|
const result = await asLegacyResult(profile.updateSettings(user.id, { email: 'ANNA@example.com' }));
|
||||||
expect(result).toEqual({ error: 'Email already taken', status: 409 });
|
expect(result).toEqual({ error: 'Email already taken', status: 409 });
|
||||||
} finally {
|
} finally {
|
||||||
check.mockRestore();
|
check.mockRestore();
|
||||||
|
|||||||
Reference in New Issue
Block a user