refactor(auth): AuthService and account cleanup through repositories

This commit is contained in:
jubnl
2026-09-22 23:17:17 +02:00
parent 21ade19d44
commit 33b9488653
19 changed files with 680 additions and 210 deletions
@@ -1,4 +1,116 @@
import type { PasswordResetTokens } from '../entities/PasswordResetTokens.entity';
import type { AssertRowKeys } from './_shared/rows';
import { currentTimestamp } from '../dialect/sql-functions';
import { TrekRepository } from './_shared/trek-repository';
export class PasswordResetTokensRepository extends TrekRepository<PasswordResetTokens> {}
/** A `password_reset_tokens` row as the API emits it. */
export interface PasswordResetTokenRow {
id: number;
user_id: number;
token_hash: string;
expires_at: string;
consumed_at: string | null;
created_at: string | null;
created_ip: string | null;
}
const _passwordResetTokenRowKeys: AssertRowKeys<PasswordResetTokenRow, PasswordResetTokens> = true;
/** `AuthService.requestPasswordReset`'s write (AU38) — the raw token is never given to or stored by this repository, only its SHA-256 hash. */
export interface NewPasswordResetTokenRow {
user_id: number;
token_hash: string;
expires_at: string;
created_ip: string | null;
}
/** `AuthService.resetPassword`'s lookup (AU39). */
export interface PasswordResetTokenLookup {
id: number;
user_id: number;
expires_at: string;
consumed_at: string | null;
}
/**
* The password-reset token store — `AuthService.requestPasswordReset`/
* `resetPassword` (Plan 3b Task 5, AU37–AU42). Expiry is always compared in
* JS by the caller, exactly as before; this repository never filters on
* `expires_at` itself.
*/
export class PasswordResetTokensRepository extends TrekRepository<PasswordResetTokens> {
/**
* `UPDATE password_reset_tokens SET consumed_at = CURRENT_TIMESTAMP WHERE
* user_id = ? AND consumed_at IS NULL` (AU37, `requestPasswordReset` —
* invalidates every prior live token for the user, NOT wrapped in a
* transaction with the INSERT that follows it — legacy behaviour, kept
* exactly, see the inventory's surprise #6) and, with `exceptId` supplied,
* `... AND id != ?` (AU42, `resetPassword` — burns every OTHER live token,
* inside the same `uow.transactional` block as the password write). ONE
* method with an optional exclusion (Task 5 brief ruling) rather than two,
* since the WHERE differs only by that one extra clause.
*/
async consumeAllLiveForUser(userId: number, exceptId?: number): Promise<void> {
const platform = this.getEntityManager().getPlatform();
await this.nativeUpdate(
exceptId === undefined
? { user: userId, consumed_at: null }
: { user: userId, consumed_at: null, id: { $ne: exceptId } },
{ consumed_at: currentTimestamp(platform) },
);
}
/**
* `INSERT INTO password_reset_tokens (user_id, token_hash, expires_at,
* created_ip) VALUES (?, ?, ?, ?)` (AU38). Hash-only storage: the caller
* (`AuthService`) hashes the raw token with SHA-256 before calling this —
* the raw value is never given to or returned by this repository.
* `EntityRepository.insert()`, not `create()` + `persist().flush()` —
* same rationale as every other repository's insert method in this
* program (`WebauthnChallengesRepository.insertChallenge`'s docstring):
* a native insert has no identity-map/UnitOfWork side effects, while
* `persist().flush()` commits the entire request's UnitOfWork.
*/
async insertToken(row: NewPasswordResetTokenRow): Promise<void> {
await this.insert({
user: row.user_id,
token_hash: row.token_hash,
expires_at: row.expires_at,
created_ip: row.created_ip,
});
}
/**
* `SELECT id, user_id, expires_at, consumed_at FROM password_reset_tokens
* WHERE token_hash = ?` (AU39, `resetPassword`'s lookup —
* **security-sensitive**). Not a primary-key filter (`token_hash` is a
* unique column, not `id`) — `disableIdentityMap: true` applies all the
* same, by the base class's default (`_shared/trek-repository.ts`): the
* same row is touched again moments later in the same request by
* `markConsumed`/`consumeAllLiveForUser`'s `nativeUpdate`s, inside
* `uow.transactional` — an identity-mapped read here is exactly the
* stale-write-back shape `Users.repository.ts`'s class docstring
* describes (proven for this table by `PWDRESETREPO-012` below).
*
* `fields` names the relation property `'user'`, not the `persist(false)`
* mirror `'user_id'` (same reasoning as `McpTokensRepository.findBasic`'s
* docstring): a `fields` list naming the mirror directly leaves
* `row.user_id` `undefined`, while naming the relation loads its
* (unpopulated) `Ref`, whose `.id` reads synchronously with no second
* query.
*/
async findByTokenHash(hash: string): Promise<PasswordResetTokenLookup | null> {
const row = await this.findOne({ token_hash: hash }, { fields: ['id', 'user', 'expires_at', 'consumed_at'] });
return row ? { id: row.id, user_id: row.user.id, expires_at: row.expires_at, consumed_at: row.consumed_at ?? null } : null;
}
/**
* `UPDATE password_reset_tokens SET consumed_at = CURRENT_TIMESTAMP WHERE
* id = ?` (AU41, `resetPassword` — burns the token being used, first
* statement inside the password-change transaction).
*/
async markConsumed(id: number): Promise<void> {
const platform = this.getEntityManager().getPlatform();
await this.nativeUpdate({ id }, { consumed_at: currentTimestamp(platform) });
}
}
@@ -859,4 +859,24 @@ export class UsersRepository extends TrekRepository<Users> {
const row = await this.findOne({ id }, { fields: ['role', 'openweather_api_key'] });
return row ? { role: row.role, openweather_api_key: row.openweather_api_key ?? null } : null;
}
// ---------------------------------------------------------------------
// UC11 — account erasure (Plan 3b Task 1 review, "contract gaps" item 2:
// named in the plan's Task 1 bullet, deliberately deferred to Task 5 —
// the ONE pre-authorised UsersRepository addition this task makes).
// ---------------------------------------------------------------------
/**
* `DELETE FROM users WHERE id = ?` — `UserCleanupService.deleteUserCompletely`'s
* final statement, the root of its 13-table transaction
* (**security-sensitive**: account erasure). Named `deleteById`, not
* `remove`/`delete`: `EntityRepository#remove` is a real (persist-marking,
* needs-a-`flush()`) method on the base class — shadowing it would change
* its signature and behaviour; `deleteById` matches the naming this
* program already uses for a native, immediate delete
* (`McpTokensRepository.deleteById`).
*/
async deleteById(id: number): Promise<void> {
await this.nativeDelete({ id });
}
}
+11 -5
View File
@@ -6,6 +6,9 @@ import { Users } from '../../db/entities/Users.entity';
import { WebauthnCredentials } from '../../db/entities/WebauthnCredentials.entity';
import { WebauthnChallenges } from '../../db/entities/WebauthnChallenges.entity';
import { InviteTokens } from '../../db/entities/InviteTokens.entity';
import { McpTokens } from '../../db/entities/McpTokens.entity';
import { OauthTokens } from '../../db/entities/OauthTokens.entity';
import { PasswordResetTokens } from '../../db/entities/PasswordResetTokens.entity';
import { TokensModule } from '../tokens/tokens.module';
import { AuthPublicController } from './auth-public.controller';
import { AuthController } from './auth.controller';
@@ -73,11 +76,14 @@ import { AllowedFileTypesModule } from '../files/allowed-file-types.module';
// AppSettingsRepository/UsersRepository to instance-api-keys.ts's
// resolveApiKey/readInstanceApiKey/writeInstanceApiKey now (Plan 3a Task
// 5). WebauthnCredentials/WebauthnChallenges: PasskeyService (Plan 3b
// Task 3). InviteTokens: RegistrationInvitesService (Plan 3b Tasks 0/3)
// — everything else either service still reads/writes is raw SQL
// through DatabaseService (AuthService's own conversion is a later
// task).
MikroOrmModule.forFeature([AppSettings, Users, WebauthnCredentials, WebauthnChallenges, InviteTokens])],
// Task 3). InviteTokens: RegistrationInvitesService (Plan 3b Tasks 0/3).
// McpTokens/OauthTokens: AuthService's cross-domain session-revocation
// writes on password change/reset (AU17/AU18/AU45/AU46) — the entities
// are owned by TokensModule/OauthModule, but `@InjectRepository`
// resolves from THIS module's own `forFeature` graph regardless of who
// else also registers it (`tokens.module.ts`'s own docstring explains
// why). PasswordResetTokens: AuthService's own table (Plan 3b Task 5).
MikroOrmModule.forFeature([AppSettings, Users, WebauthnCredentials, WebauthnChallenges, InviteTokens, McpTokens, OauthTokens, PasswordResetTokens])],
controllers: [AuthPublicController, AuthController, PasskeyController],
providers: [AuthService, UserProfileService, RegistrationInvitesService, PasskeyService, UserCleanupService, WebauthnConfigService, AuthMcp],
exports: [AuthService, RegistrationInvitesService, PasskeyService, UserCleanupService],
+164 -151
View File
@@ -9,7 +9,6 @@ import { randomBytes, createHash } from 'crypto';
import type { Request, Response } from 'express';
import { readEnv } from '../../app-config';
import { JWT_SECRET, SESSION_DURATION_SECONDS, SESSION_DURATION_REMEMBER_SECONDS } from '../../config';
import { DatabaseService } from '../database/database.service';
import { UnitOfWork } from '../database/unit-of-work';
import { PermissionsService } from '../permissions/permissions.service';
import { validatePassword } from '../common/passwordPolicy';
@@ -21,6 +20,16 @@ import { AppSettings } from '../../db/entities/AppSettings.entity';
import type { AppSettingsRepository } from '../../db/repositories/AppSettings.repository';
import { Users } from '../../db/entities/Users.entity';
import type { UsersRepository } from '../../db/repositories/Users.repository';
import { InviteTokens } from '../../db/entities/InviteTokens.entity';
import type { InviteTokensRepository } from '../../db/repositories/InviteTokens.repository';
import { McpTokens } from '../../db/entities/McpTokens.entity';
import type { McpTokensRepository } from '../../db/repositories/McpTokens.repository';
import { OauthTokens } from '../../db/entities/OauthTokens.entity';
import type { OauthTokensRepository } from '../../db/repositories/OauthTokens.repository';
import { WebauthnCredentials } from '../../db/entities/WebauthnCredentials.entity';
import type { WebauthnCredentialsRepository } from '../../db/repositories/WebauthnCredentials.repository';
import { PasswordResetTokens } from '../../db/entities/PasswordResetTokens.entity';
import type { PasswordResetTokensRepository } from '../../db/repositories/PasswordResetTokens.repository';
// Type-and-guard only: the app-config read reports the provider choice, it does
// not construct one, so this does not pull the maps domain into auth.
import { isPlacesProviderChoice } from '../maps/providers/places-provider';
@@ -34,6 +43,7 @@ import { splitManagedKeys } from '../common/managed';
import { emitUserDeleted } from '../../plugin-user-lifecycle';
import { verifyJwtAndLoadUser } from './jwt-verify';
import { User } from '../../types';
import type { UserRow } from '../../db/repositories/Users.repository';
import { DEMO_EMAIL_PRIMARY, DEMO_PASS, isDemoEmail } from '../common/demo';
import { avatarUrl } from '../common/avatarUrl';
import { TripMembershipService } from '../trip-membership/trip-membership.service';
@@ -113,14 +123,40 @@ export interface ResetPasswordOutcome {
userId?: number;
}
/**
* `UsersRepository.findByEmailExact`/`findByEmailCI`/`findById` return the
* repository's full-row shape (`UserRow` — every `users` column, `role:
* string`, several `T | null` columns); the client-payload helper
* `stripUserForClient` takes the narrower `User` contract type (`role:
* 'admin' | 'user'`, those same columns `T | undefined`). Same mapping as
* `passkey.service.ts`'s own `toClientUser` (Plan 3b Task 3 review, F5) —
* kept as a second, file-local copy rather than exported/shared, matching
* that precedent's own reasoning (one small mapping, cheaper to duplicate
* than to add a cross-file dependency for).
*/
function toClientUser(row: UserRow): User {
return {
...row,
role: row.role === 'admin' ? 'admin' : 'user',
mfa_enabled: row.mfa_enabled ?? undefined,
must_change_password: row.must_change_password ?? undefined,
created_at: row.created_at ?? undefined,
updated_at: row.updated_at ?? undefined,
};
}
/**
* DI-native auth domain service. The SQL moved 1:1 from the legacy
* src/services/authService.ts (same statements, same `||` falsy-coercion
* defaults, same post-write re-selects, same error strings); the pure
* password/backup-code crypto lives in auth.helpers.ts. PermissionsService is
* injected (it replaced the permissions.bridge import); the JWT cookie
* set/clear, the reset-email delivery and the remaining legacy helpers keep
* their plain imports. Non-Nest consumers (legacy MCP registrars, legacy
* defaults, same post-write re-selects, same error strings) onto the
* repositories built across Plan 3a/3b — every statement below is now a
* repository method call instead of a `this.db.*` invocation, same shape,
* same order, same transaction scope (`.superpowers/sdd/2026-09-22-orm-phase3b/
* task-5-report.md`); the pure password/backup-code crypto stays in
* auth.helpers.ts. PermissionsService is injected (it replaced the
* permissions.bridge import); the JWT cookie set/clear, the reset-email
* delivery and the remaining legacy helpers keep their plain imports.
* Non-Nest consumers (legacy MCP registrars, legacy
* adminService/oidcService/passkeyService) go through auth.bridge.ts.
*
* AtlasService is deliberately NOT injected any more: getTravelStats, its only
@@ -130,7 +166,6 @@ export interface ResetPasswordOutcome {
@Injectable()
export class AuthService {
constructor(
private readonly db: DatabaseService,
private readonly permissions: PermissionsService,
private readonly membership: TripMembershipService,
private readonly webauthn: WebauthnConfigService,
@@ -141,6 +176,11 @@ export class AuthService {
private readonly uow: UnitOfWork,
@InjectRepository(AppSettings) private readonly appSettings: AppSettingsRepository,
@InjectRepository(Users) private readonly usersRepo: UsersRepository,
@InjectRepository(InviteTokens) private readonly inviteTokens: InviteTokensRepository,
@InjectRepository(McpTokens) private readonly mcpTokens: McpTokensRepository,
@InjectRepository(OauthTokens) private readonly oauthTokens: OauthTokensRepository,
@InjectRepository(WebauthnCredentials) private readonly webauthnCredentials: WebauthnCredentialsRepository,
@InjectRepository(PasswordResetTokens) private readonly passwordResetTokens: PasswordResetTokensRepository,
) {}
// Cookie
@@ -162,24 +202,24 @@ export class AuthService {
oidc_registration: boolean;
passkey_login: boolean;
}> {
const get = (key: string) =>
this.db.get<{ value: string }>("SELECT value FROM app_settings WHERE key = ?", key)?.value ?? null;
const get = (key: string) => this.appSettings.getValue(key);
// Passkey login is independent of the password/OIDC "new keys" probe, so it
// must be resolved OUTSIDE the branch below — otherwise on a fresh install
// that never touched the password/OIDC toggles it would silently read false
// even after an admin enabled it. Default OFF (opt-in).
const passkey_login = get('passkey_login') === 'true';
const passkey_login = (await get('passkey_login')) === 'true';
const hasNewKeys = ['password_login', 'password_registration', 'oidc_login', 'oidc_registration']
.some(k => get(k) !== null);
const hasNewKeys = (
await Promise.all(['password_login', 'password_registration', 'oidc_login', 'oidc_registration'].map((k) => get(k)))
).some((v) => v !== null);
if (hasNewKeys) {
const result = {
password_login: get('password_login') !== 'false',
password_registration: get('password_registration') !== 'false',
oidc_login: get('oidc_login') !== 'false',
oidc_registration: get('oidc_registration') !== 'false',
password_login: (await get('password_login')) !== 'false',
password_registration: (await get('password_registration')) !== 'false',
oidc_login: (await get('oidc_login')) !== 'false',
oidc_registration: (await get('oidc_registration')) !== 'false',
passkey_login,
};
if (readEnv().oidc.only) {
@@ -190,13 +230,13 @@ export class AuthService {
}
// Legacy fallback
const oidcOnlyEnabled = readEnv().oidc.only || get('oidc_only') === 'true';
const oidcOnlyEnabled = readEnv().oidc.only || (await get('oidc_only')) === 'true';
const oidcConfigured = !!(
(readEnv().oidc.issuer || get('oidc_issuer')) &&
(readEnv().oidc.clientId || get('oidc_client_id'))
(readEnv().oidc.issuer || (await get('oidc_issuer'))) &&
(readEnv().oidc.clientId || (await get('oidc_client_id')))
);
const oidcOnly = oidcOnlyEnabled && oidcConfigured;
const allowReg = (get('allow_registration') ?? 'true') === 'true';
const allowReg = ((await get('allow_registration')) ?? 'true') === 'true';
return {
password_login: !oidcOnly,
@@ -214,7 +254,7 @@ export class AuthService {
async generateToken(user: { id: number | bigint; password_version?: number }, remember?: boolean) {
const pv = typeof user.password_version === 'number'
? user.password_version
: (this.db.get<{ password_version?: number }>('SELECT password_version FROM users WHERE id = ?', user.id)?.password_version ?? 0);
: ((await this.usersRepo.getPasswordVersion(Number(user.id))) ?? 0);
// "Remember me" extends the JWT lifetime to match the persistent cookie maxAge;
// the cookie service decides session-vs-persistent off the same flag.
const expiresIn = remember === true ? SESSION_DURATION_REMEMBER_SECONDS : SESSION_DURATION_SECONDS;
@@ -257,13 +297,13 @@ export class AuthService {
if (cfg.rpID !== 'localhost' || cfg.explicitOrigins) return true;
const env = readEnv();
const declaredRpId = (
env.webauthn.rpId || this.db.get<{ value: string }>("SELECT value FROM app_settings WHERE key = 'webauthn_rp_id'")?.value
env.webauthn.rpId || (await this.appSettings.getValue('webauthn_rp_id'))
)?.trim();
return !!(declaredRpId || env.app.appUrl || env.http.allowedOriginsRaw);
}
async getAppConfig(authenticatedUser: User | undefined | null) {
const userCount = this.db.get<{ count: number }>('SELECT COUNT(*) as count FROM users WHERE COALESCE(is_guest, 0) = 0')!.count;
const userCount = await this.usersRepo.countNonGuest();
const isDemo = readEnv().demo.enabled;
const toggles = await this.resolveAuthToggles();
// One directory deeper than the legacy src/services location — the extra
@@ -279,36 +319,41 @@ export class AuthService {
// tell "search is unavailable" from "search runs on OpenStreetMap", and to
// know whether the provider the admin selected actually has a credential.
const hasAmapKey = !!(await resolveApiKey(this.appSettings, this.usersRepo, 'amap_api_key', authenticatedUser?.id ?? 0, readEnv().maps.amapApiKey)).key;
const placesProviderRow = this.db.get<{ value: string }>("SELECT value FROM app_settings WHERE key = 'places_provider'")?.value;
// AU5's 14 literal `app_settings` reads collapse into one `getValues` call
// (Task 5 brief ruling — the repository already has it, and the map is read
// the same way a missing/NULL-valued row was before: absent from the map).
const settings = await this.appSettings.getValues([
'places_provider', 'oidc_display_name', 'oidc_issuer', 'oidc_client_id', 'require_mfa',
'notification_channel', 'notify_trip_reminder', 'smtp_host', 'notification_channels',
'places_photos_enabled', 'places_autocomplete_enabled', 'places_details_enabled',
'places_enrich_enabled', 'place_shadow_enabled',
]);
const placesProviderRow = settings.get('places_provider');
const placesProvider = isPlacesProviderChoice(placesProviderRow) ? placesProviderRow : 'auto';
const oidcDisplayName = readEnv().oidc.displayName ||
this.db.get<{ value: string }>("SELECT value FROM app_settings WHERE key = 'oidc_display_name'")?.value || null;
const oidcDisplayName = readEnv().oidc.displayName || settings.get('oidc_display_name') || null;
const oidcConfigured = !!(
(readEnv().oidc.issuer || this.db.get<{ value: string }>("SELECT value FROM app_settings WHERE key = 'oidc_issuer'")?.value) &&
(readEnv().oidc.clientId || this.db.get<{ value: string }>("SELECT value FROM app_settings WHERE key = 'oidc_client_id'")?.value)
(readEnv().oidc.issuer || settings.get('oidc_issuer')) &&
(readEnv().oidc.clientId || settings.get('oidc_client_id'))
);
const requireMfaRow = this.db.get<{ value: string }>("SELECT value FROM app_settings WHERE key = 'require_mfa'");
const notifChannel = this.db.get<{ value: string }>("SELECT value FROM app_settings WHERE key = 'notification_channel'")?.value || 'none';
const tripReminderSetting = this.db.get<{ value: string }>("SELECT value FROM app_settings WHERE key = 'notify_trip_reminder'")?.value;
const hasSmtpHost = !!(readEnv().smtp.host || this.db.get<{ value: string }>("SELECT value FROM app_settings WHERE key = 'smtp_host'")?.value);
const notifChannelsRaw = this.db.get<{ value: string }>("SELECT value FROM app_settings WHERE key = 'notification_channels'")?.value || notifChannel;
const requireMfaValue = settings.get('require_mfa');
const notifChannel = settings.get('notification_channel') || 'none';
const tripReminderSetting = settings.get('notify_trip_reminder');
const hasSmtpHost = !!(readEnv().smtp.host || settings.get('smtp_host'));
const notifChannelsRaw = settings.get('notification_channels') || notifChannel;
const activeChannels = notifChannelsRaw === 'none' ? [] : notifChannelsRaw.split(',').map((c: string) => c.trim()).filter(Boolean);
const hasWebhookEnabled = activeChannels.includes('webhook');
const tripRemindersEnabled = tripReminderSetting !== 'false';
const placesPhotosSetting = this.db.get<{ value: string }>("SELECT value FROM app_settings WHERE key = 'places_photos_enabled'")?.value;
const placesPhotosEnabled = placesPhotosSetting !== 'false';
const placesAutocompleteSetting = this.db.get<{ value: string }>("SELECT value FROM app_settings WHERE key = 'places_autocomplete_enabled'")?.value;
const placesAutocompleteEnabled = placesAutocompleteSetting !== 'false';
const placesDetailsSetting = this.db.get<{ value: string }>("SELECT value FROM app_settings WHERE key = 'places_details_enabled'")?.value;
const placesDetailsEnabled = placesDetailsSetting !== 'false';
const placesEnrichSetting = this.db.get<{ value: string }>("SELECT value FROM app_settings WHERE key = 'places_enrich_enabled'")?.value;
const placesEnrichEnabled = placesEnrichSetting !== 'false';
const placesPhotosEnabled = settings.get('places_photos_enabled') !== 'false';
const placesAutocompleteEnabled = settings.get('places_autocomplete_enabled') !== 'false';
const placesDetailsEnabled = settings.get('places_details_enabled') !== 'false';
const placesEnrichEnabled = settings.get('places_enrich_enabled') !== 'false';
// Fail-closed, and deliberately on this unauthenticated endpoint: whether an
// instance records what its users search for is something a visitor is
// entitled to know before logging in, not a detail to keep behind the door.
const placeShadowSetting = this.db.get<{ value: string }>("SELECT value FROM app_settings WHERE key = 'place_shadow_enabled'")?.value;
const placeShadowEnabled = placeShadowSetting === 'true';
const setupComplete = userCount > 0 && !this.db.get("SELECT id FROM users WHERE role = 'admin' AND must_change_password = 1 LIMIT 1");
const placeShadowEnabled = settings.get('place_shadow_enabled') === 'true';
const setupComplete = userCount > 0 && (await this.usersRepo.findAdminNeedingPasswordChange()) === null;
return {
// Legacy fields (backward compat)
@@ -335,7 +380,7 @@ export class AuthService {
places_provider: placesProvider,
oidc_configured: oidcConfigured,
oidc_display_name: oidcConfigured ? (oidcDisplayName || 'SSO') : undefined,
require_mfa: requireMfaRow?.value === 'true',
require_mfa: requireMfaValue === 'true',
// The canonical live-read: same query + DEFAULT_ALLOWED_EXTENSIONS
// fallback the upload filters use, so the client's picker and the
// server's acceptance can never drift (the historical inline copy here
@@ -373,19 +418,19 @@ export class AuthService {
if (!readEnv().demo.enabled) {
return { error: 'Not found', status: 404 };
}
const user = this.db.get<User>('SELECT * FROM users WHERE email = ?', DEMO_EMAIL_PRIMARY);
const user = await this.usersRepo.findByEmailExact(DEMO_EMAIL_PRIMARY);
if (!user) return { error: 'Demo user not found', status: 500 };
const token = await this.generateToken(user);
const safe = stripUserForClient(user) as Record<string, unknown>;
const safe = stripUserForClient(toClientUser(user)) as Record<string, unknown>;
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 }> {
const invite = this.db.get('SELECT * FROM invite_tokens WHERE token = ?', token) as any;
const invite = await this.inviteTokens.findByToken(token);
if (!invite) return { error: 'Invalid invite link', status: 404 };
if (invite.max_uses > 0 && invite.used_count >= invite.max_uses) return { error: 'Invite link has been fully used', status: 410 };
if (invite.expires_at && new Date(invite.expires_at) < new Date()) return { error: 'Invite link has expired', status: 410 };
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 ?? undefined };
}
async registerUser(rawBody: unknown): Promise<{ error?: string; status?: number; token?: string; user?: Record<string, unknown>; auditUserId?: number; auditDetails?: Record<string, unknown> }> {
@@ -394,11 +439,11 @@ export class AuthService {
const email = typeof body.email === 'string' ? body.email.trim() : '';
const { password, invite_token } = body;
const userCount = this.db.get<{ count: number }>('SELECT COUNT(*) as count FROM users WHERE COALESCE(is_guest, 0) = 0')!.count;
const userCount = await this.usersRepo.countNonGuest();
let validInvite: any = null;
let validInvite: Awaited<ReturnType<InviteTokensRepository['findByToken']>> = null;
if (invite_token) {
validInvite = this.db.get('SELECT * FROM invite_tokens WHERE token = ?', invite_token);
validInvite = await this.inviteTokens.findByToken(invite_token);
if (!validInvite) return { error: 'Invalid invite link', status: 400 };
if (validInvite.max_uses > 0 && validInvite.used_count >= validInvite.max_uses) return { error: 'Invite link has been fully used', status: 410 };
if (validInvite.expires_at && new Date(validInvite.expires_at) < new Date()) return { error: 'Invite link has expired', status: 410 };
@@ -423,8 +468,8 @@ export class AuthService {
}
// Ignore guests (#1362): their synthetic username/email must never block a real signup.
const existingUser = this.db.get('SELECT id FROM users WHERE (LOWER(email) = LOWER(?) OR LOWER(username) = LOWER(?)) AND COALESCE(is_guest, 0) = 0', email, username);
if (existingUser) {
const existingUserId = await this.usersRepo.findIdByEmailOrUsernameCI(email, username);
if (existingUserId !== null) {
return { error: 'Registration failed. Please try different credentials.', status: 409 };
}
@@ -436,33 +481,29 @@ export class AuthService {
// One transaction for the whole signup: a mid-sequence throw (invite
// bookkeeping, trip auto-join) must not leave a half-registered user.
return await this.uow.transactional(async () => {
const result = this.db.run(
'INSERT INTO users (username, email, password_hash, role, first_seen_version, login_count) VALUES (?, ?, ?, ?, ?, 0)',
username, email, password_hash, role, readEnv().app.appVersion || '0.0.0'
);
const inserted = await this.usersRepo.insertUser({
username, email, password_hash, role, first_seen_version: readEnv().app.appVersion || '0.0.0',
});
const user = { id: result.lastInsertRowid, username, email, role, avatar: null, mfa_enabled: false };
const user = { id: inserted.id, username, email, role, avatar: null, mfa_enabled: false };
const token = await this.generateToken(user);
if (validInvite) {
const updated = this.db.get(
'UPDATE invite_tokens SET used_count = used_count + 1 WHERE id = ? AND (max_uses = 0 OR used_count < max_uses) RETURNING used_count',
validInvite.id
);
const updated = await this.inviteTokens.incrementUsedCount(validInvite.token);
if (!updated) {
console.warn(`[Auth] Invite token ${validInvite.token.slice(0, 8)}... exceeded max_uses due to race condition`);
}
// Trip-bound invite (#1402): auto-add the freshly registered user to the
// trip. Idempotent + owner-safe; no-ops if the bound trip was since deleted.
if (validInvite.trip_id) {
await this.membership.joinTripAsMember(Number(validInvite.trip_id), Number(result.lastInsertRowid), validInvite.created_by ?? null);
await this.membership.joinTripAsMember(Number(validInvite.trip_id), Number(inserted.id), validInvite.created_by ?? null);
}
}
return {
token,
user: { ...user, avatar_url: null },
auditUserId: Number(result.lastInsertRowid),
auditUserId: Number(inserted.id),
auditDetails: { username, email, role },
};
});
@@ -496,7 +537,7 @@ export class AuthService {
// Guests (#1362) carry a synthetic email but must never authenticate — treat a
// matched guest row exactly like an unknown email (dummy-hash timing preserved).
const user = this.db.get<User>('SELECT * FROM users WHERE LOWER(email) = LOWER(?) AND COALESCE(is_guest, 0) = 0', email);
const user = await this.usersRepo.findByEmailCI(email);
// Always run bcrypt — even for unknown/OIDC-only users — so response time
// does not reveal whether the email exists in the database (CWE-203/208).
@@ -522,8 +563,8 @@ export class AuthService {
};
}
if (user.mfa_enabled === 1 || user.mfa_enabled === true) {
const pv = (user as User & { password_version?: number }).password_version ?? 0;
if (user.mfa_enabled === 1) {
const pv = user.password_version ?? 0;
const mfa_token = jwt.sign(
{ id: Number(user.id), purpose: 'mfa_login', pv },
JWT_SECRET,
@@ -532,9 +573,9 @@ export class AuthService {
return { mfa_required: true, mfa_token };
}
this.db.run('UPDATE users SET last_login = CURRENT_TIMESTAMP, login_count = login_count + 1 WHERE id = ?', user.id);
await this.usersRepo.touchLastLogin(user.id);
const token = await this.generateToken(user, remember);
const userSafe = stripUserForClient(user) as Record<string, unknown>;
const userSafe = stripUserForClient(toClientUser(user)) as Record<string, unknown>;
return {
token,
@@ -553,13 +594,18 @@ export class AuthService {
async getCurrentUser(
userId: number
): Promise<(Record<string, unknown> & Pick<User, 'id' | 'username' | 'email' | 'role'> & { avatar_url: string }) | null> {
const user = this.db.get<User>(
'SELECT id, username, email, role, avatar, oidc_issuer, created_at, mfa_enabled, must_change_password FROM users WHERE id = ?',
userId
);
const user = await this.usersRepo.findMeRow(userId);
if (!user) return null;
const base = stripUserForClient(user as User) as Record<string, unknown>;
return { ...base, id: user.id, username: user.username, email: user.email, role: user.role, avatar_url: avatarUrl(user) };
// `findMeRow`'s projection (id/username/email/role/avatar/oidc_issuer/
// created_at/mfa_enabled/must_change_password) is narrower than `User`
// and than `UserRow` — `toClientUser` expects the FULL row, so this one
// site keeps the same "trust me" cast the legacy generic-typed
// `this.db.get<User>(...)` read effectively was: `stripUserForClient`
// only destructures a handful of keys this projection never carried
// anyway (password_hash, the API-key columns, mfa_secret), so they're
// simply absent, exactly as before.
const base = stripUserForClient(user as unknown as User) as Record<string, unknown>;
return { ...base, id: user.id, username: user.username, email: user.email, role: user.role === 'admin' ? 'admin' : 'user', avatar_url: avatarUrl(user) };
}
// -------------------------------------------------------------------------
@@ -587,7 +633,7 @@ export class AuthService {
const pwCheck = validatePassword(new_password);
if (!pwCheck.ok) return { error: pwCheck.reason, status: 400 };
const user = this.db.get<{ password_hash: string; password_version?: number }>('SELECT password_hash, password_version FROM users WHERE id = ?', userId);
const user = await this.usersRepo.getPasswordHashAndVersion(userId);
if (!user || !bcrypt.compareSync(current_password, user.password_hash)) {
return { error: 'Current password is incorrect', status: 401 };
}
@@ -596,14 +642,14 @@ export class AuthService {
const newPv = (user.password_version ?? 0) + 1;
await this.uow.transactional(async () => {
this.db.run('UPDATE users SET password_hash = ?, must_change_password = 0, password_version = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?', hash, newPv, userId);
await this.usersRepo.setPassword(userId, hash, newPv);
// A password change rotates the user's sessions: bumping password_version
// invalidates existing JWT cookie sessions, and the separate MCP static
// token and OAuth bearer-token stores are pruned to match (same set the
// password-reset path already revokes).
this.db.run('DELETE FROM mcp_tokens WHERE user_id = ?', userId);
await this.mcpTokens.deleteAllForUser(userId);
try {
this.db.run("UPDATE oauth_tokens SET revoked_at = CURRENT_TIMESTAMP WHERE user_id = ? AND revoked_at IS NULL", userId);
await this.oauthTokens.revokeAllForUser(userId);
} catch { /* oauth_tokens table may not exist in very old installs */ }
});
@@ -622,7 +668,7 @@ export class AuthService {
return { error: 'Account deletion is disabled in demo mode.', status: 403 };
}
if (userRole === 'admin') {
const adminCount = this.db.get<{ count: number }>("SELECT COUNT(*) as count FROM users WHERE role = 'admin'")!.count;
const adminCount = await this.usersRepo.countAdmins();
if (adminCount <= 1) {
return { error: 'Cannot delete the last admin account', status: 400 };
}
@@ -647,13 +693,13 @@ export class AuthService {
// -------------------------------------------------------------------------
async getAppSettings(userId: number): Promise<{ error?: string; status?: number; data?: Record<string, string> }> {
const user = this.db.get<{ role: string }>('SELECT role FROM users WHERE id = ?', userId);
if (user?.role !== 'admin') return { error: 'Admin access required', status: 403 };
const role = await this.usersRepo.getRole(userId);
if (role !== 'admin') return { error: 'Admin access required', status: 403 };
const result: Record<string, string> = {};
for (const key of ADMIN_SETTINGS_KEYS) {
const row = this.db.get<{ value: string }>("SELECT value FROM app_settings WHERE key = ?", key);
if (row) result[key] = (key === 'smtp_pass' || key === 'admin_webhook_url' || key === 'admin_ntfy_token') ? '••••••••' : row.value;
const value = await this.appSettings.getValue(key);
if (value !== null) result[key] = (key === 'smtp_pass' || key === 'admin_webhook_url' || key === 'admin_ntfy_token') ? '••••••••' : value;
}
return { data: result };
}
@@ -671,15 +717,15 @@ export class AuthService {
managedKeys?: string[];
}> {
const body = rawBody as Record<string, unknown>;
const user = this.db.get<{ role: string }>('SELECT role FROM users WHERE id = ?', userId);
if (user?.role !== 'admin') return { error: 'Admin access required', status: 403 };
const role = await this.usersRepo.getRole(userId);
if (role !== 'admin') return { error: 'Admin access required', status: 403 };
const { require_mfa } = body;
if (require_mfa === true || require_mfa === 'true') {
const adminMfa = this.db.get<{ mfa_enabled: number }>('SELECT mfa_enabled FROM users WHERE id = ?', userId);
const adminMfa = await this.usersRepo.getMfaEnabled(userId);
// A user-verified passkey satisfies the MFA policy, so an admin who secured
// their own account with a passkey may enable it too (not only TOTP).
const adminHasPasskey = !!this.db.get('SELECT 1 FROM webauthn_credentials WHERE user_id = ? LIMIT 1', userId);
const adminHasPasskey = await this.webauthnCredentials.hasAny(userId);
if (!(adminMfa?.mfa_enabled === 1) && !adminHasPasskey) {
return {
error: 'Secure your own account with two-factor authentication or a passkey before requiring it for all users.',
@@ -692,8 +738,8 @@ export class AuthService {
if (body.password_login !== undefined || body.oidc_login !== undefined) {
const current = await this.resolveAuthToggles();
const oidcConfigured = !!(
(readEnv().oidc.issuer || this.db.get<{ value: string }>("SELECT value FROM app_settings WHERE key = 'oidc_issuer'")?.value) &&
(readEnv().oidc.clientId || this.db.get<{ value: string }>("SELECT value FROM app_settings WHERE key = 'oidc_client_id'")?.value)
(readEnv().oidc.issuer || (await this.appSettings.getValue('oidc_issuer'))) &&
(readEnv().oidc.clientId || (await this.appSettings.getValue('oidc_client_id')))
);
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;
@@ -725,7 +771,7 @@ export class AuthService {
if (key === 'admin_webhook_url' && val) val = maybe_encrypt_api_key(val) ?? val;
if (key === 'admin_ntfy_token' && val === '••••••••') continue;
if (key === 'admin_ntfy_token' && val) val = maybe_encrypt_api_key(val) ?? val;
this.db.run("INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)", key, val);
await this.appSettings.setValue(key, val);
}
}
@@ -759,7 +805,7 @@ export class AuthService {
if (readEnv().demo.enabled && isDemoEmail(userEmail)) {
return { error: 'MFA is not available in demo mode.', status: 403 };
}
const row = this.db.get<{ mfa_enabled: number }>('SELECT mfa_enabled FROM users WHERE id = ?', userId);
const row = await this.usersRepo.getMfaEnabled(userId);
if (row?.mfa_enabled) {
return { error: 'MFA is already enabled', status: 400 };
}
@@ -792,11 +838,7 @@ export class AuthService {
const backupCodes = generateBackupCodes();
const backupHashes = backupCodes.map(hashBackupCodeBcrypt);
const enc = encryptMfaSecret(pending);
this.db.run('UPDATE users SET mfa_enabled = 1, mfa_secret = ?, mfa_backup_codes = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?',
enc,
JSON.stringify(backupHashes),
userId
);
await this.usersRepo.enableMfa(userId, enc, JSON.stringify(backupHashes));
mfaSetupPending.delete(userId);
return { success: true, mfa_enabled: true, backup_codes: backupCodes };
}
@@ -810,15 +852,15 @@ export class AuthService {
if (readEnv().demo.enabled && isDemoEmail(userEmail)) {
return { error: 'MFA cannot be changed in demo mode.', status: 403 };
}
const policy = this.db.get<{ value: string }>("SELECT value FROM app_settings WHERE key = 'require_mfa'");
if (policy?.value === 'true') {
const policy = await this.appSettings.getValue('require_mfa');
if (policy === 'true') {
return { error: 'Two-factor authentication cannot be disabled while it is required for all users.', status: 403 };
}
const { password, code } = body;
if (!password || !code) {
return { error: 'Password and authenticator code are required', status: 400 };
}
const user = this.db.get<User>('SELECT * FROM users WHERE id = ?', userId);
const user = await this.usersRepo.findById(userId);
if (!user?.mfa_enabled || !user.mfa_secret) {
return { error: 'MFA is not enabled', status: 400 };
}
@@ -831,9 +873,7 @@ export class AuthService {
if (!ok) {
return { error: 'Invalid verification code', status: 401 };
}
this.db.run('UPDATE users SET mfa_enabled = 0, mfa_secret = NULL, mfa_backup_codes = NULL, updated_at = CURRENT_TIMESTAMP WHERE id = ?',
userId
);
await this.usersRepo.disableMfa(userId);
mfaSetupPending.delete(userId);
return { success: true, mfa_enabled: false };
}
@@ -857,8 +897,8 @@ export class AuthService {
if (decoded.purpose !== 'mfa_login') {
return { error: 'Invalid verification token', status: 401 };
}
const user = this.db.get<User>('SELECT * FROM users WHERE id = ?', decoded.id);
if (!user || !(user.mfa_enabled === 1 || user.mfa_enabled === true) || !user.mfa_secret) {
const user = await this.usersRepo.findById(decoded.id);
if (!user || user.mfa_enabled !== 1 || !user.mfa_secret) {
return { error: 'Invalid session', status: 401 };
}
const secret = decryptMfaSecret(user.mfa_secret);
@@ -876,17 +916,14 @@ export class AuthService {
// Consume the backup code and record the login atomically — the code
// must not burn without the login landing (or vice versa).
await this.uow.transactional(async () => {
this.db.run('UPDATE users SET mfa_backup_codes = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?',
JSON.stringify(hashes),
user.id
);
this.db.run('UPDATE users SET last_login = CURRENT_TIMESTAMP, login_count = login_count + 1 WHERE id = ?', user.id);
await this.usersRepo.setBackupCodesAndTouch(user.id, JSON.stringify(hashes));
await this.usersRepo.touchLastLogin(user.id);
});
} else {
this.db.run('UPDATE users SET last_login = CURRENT_TIMESTAMP, login_count = login_count + 1 WHERE id = ?', user.id);
await this.usersRepo.touchLastLogin(user.id);
}
const sessionToken = await this.generateToken(user, remember);
const userSafe = stripUserForClient(user) as Record<string, unknown>;
const userSafe = stripUserForClient(toClientUser(user)) as Record<string, unknown>;
return {
token: sessionToken,
user: { ...userSafe, avatar_url: avatarUrl(user) },
@@ -935,10 +972,7 @@ export class AuthService {
}
// A guest (#1362) must never receive a reset link — treat its synthetic email as unknown.
const user = this.db.get<{ id: number; email: string; password_hash: string | null; oidc_sub: string | null }>(
'SELECT id, email, password_hash, oidc_sub FROM users WHERE email = ? AND COALESCE(is_guest, 0) = 0',
email
);
const user = await this.usersRepo.findForPasswordReset(email);
if (!user) {
return { tokenForDelivery: null, userId: null, userEmail: null, reason: 'no_user' };
@@ -955,19 +989,13 @@ export class AuthService {
// Invalidate any prior unconsumed tokens for this user so there is
// always at most one live reset link in flight.
this.db.run(
"UPDATE password_reset_tokens SET consumed_at = CURRENT_TIMESTAMP WHERE user_id = ? AND consumed_at IS NULL",
user.id
);
await this.passwordResetTokens.consumeAllLiveForUser(user.id);
const raw = randomBytes(PASSWORD_RESET_TOKEN_BYTES).toString('base64url');
const token_hash = hashResetToken(raw);
const expires_at = new Date(Date.now() + PASSWORD_RESET_TTL_MS).toISOString();
this.db.run(
'INSERT INTO password_reset_tokens (user_id, token_hash, expires_at, created_ip) VALUES (?, ?, ?, ?)',
user.id, token_hash, expires_at, createdIp
);
await this.passwordResetTokens.insertToken({ user_id: user.id, token_hash, expires_at, created_ip: createdIp });
return { tokenForDelivery: raw, userId: user.id, userEmail: user.email, reason: 'issued' };
}
@@ -993,10 +1021,7 @@ export class AuthService {
if (!pwCheck.ok) return { error: pwCheck.reason!, status: 400 };
const tokenHash = hashResetToken(token);
const row = this.db.get<{ id: number; user_id: number; expires_at: string; consumed_at: string | null }>(
'SELECT id, user_id, expires_at, consumed_at FROM password_reset_tokens WHERE token_hash = ?',
tokenHash
);
const row = await this.passwordResetTokens.findByTokenHash(tokenHash);
if (!row) return { error: 'Invalid or expired reset link', status: 400 };
if (row.consumed_at) return { error: 'This reset link has already been used', status: 400 };
@@ -1004,15 +1029,12 @@ export class AuthService {
return { error: 'Reset link has expired. Please request a new one.', status: 400 };
}
const user = this.db.get<{ id: number; email: string; mfa_enabled: number | boolean; mfa_secret: string | null; mfa_backup_codes: string | null; password_version: number }>(
'SELECT id, email, mfa_enabled, mfa_secret, mfa_backup_codes, password_version FROM users WHERE id = ?',
row.user_id
);
const user = await this.usersRepo.findResetTarget(row.user_id);
if (!user) return { error: 'Invalid or expired reset link', status: 400 };
// MFA gate. If enabled, require a valid TOTP or backup code.
const mfaOn = user.mfa_enabled === 1 || user.mfa_enabled === true;
const mfaOn = user.mfa_enabled === 1;
let backupCodeConsumedIndex: number | null = null;
if (mfaOn) {
if (!user.mfa_secret) {
@@ -1037,33 +1059,24 @@ export class AuthService {
await this.uow.transactional(async () => {
// Burn the token first to keep it atomic with the password change.
this.db.run('UPDATE password_reset_tokens SET consumed_at = CURRENT_TIMESTAMP WHERE id = ?', row.id);
await this.passwordResetTokens.markConsumed(row.id);
// Also burn every OTHER live token for this user — a fresh login
// should not leave a second door open.
this.db.run(
"UPDATE password_reset_tokens SET consumed_at = CURRENT_TIMESTAMP WHERE user_id = ? AND consumed_at IS NULL AND id != ?",
user.id, row.id
);
this.db.run(
'UPDATE users SET password_hash = ?, must_change_password = 0, password_version = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?',
newHash, newPv, user.id
);
await this.passwordResetTokens.consumeAllLiveForUser(user.id, row.id);
await this.usersRepo.setPassword(user.id, newHash, newPv);
// Consume backup code if one was used.
if (backupCodeConsumedIndex !== null) {
const hashes = parseBackupCodeHashes(user.mfa_backup_codes);
hashes.splice(backupCodeConsumedIndex, 1);
this.db.run('UPDATE users SET mfa_backup_codes = ? WHERE id = ?', JSON.stringify(hashes), user.id);
await this.usersRepo.setBackupCodes(user.id, JSON.stringify(hashes));
}
// Revoke every other credential class the user had. The
// password_version bump alone invalidates JWT cookie sessions, but
// MCP static tokens and OAuth 2.1 bearer tokens are separate stores
// that survive the bump unless we prune them here.
this.db.run('DELETE FROM mcp_tokens WHERE user_id = ?', user.id);
await this.mcpTokens.deleteAllForUser(user.id);
try {
this.db.run(
"UPDATE oauth_tokens SET revoked_at = CURRENT_TIMESTAMP WHERE user_id = ? AND revoked_at IS NULL",
user.id
);
await this.oauthTokens.revokeAllForUser(user.id);
} catch { /* oauth_tokens table may not exist in very old installs */ }
});
@@ -1083,8 +1096,8 @@ export class AuthService {
async isDemoUser(userId: number): Promise<boolean> {
if (!readEnv().demo.enabled) return false;
const user = this.db.get<{ email: string }>('SELECT email FROM users WHERE id = ?', userId);
return isDemoEmail(user?.email);
const email = await this.usersRepo.getEmail(userId);
return isDemoEmail(email ?? undefined);
}
/**
+36 -8
View File
@@ -1,5 +1,6 @@
import fs from 'node:fs';
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@mikro-orm/nestjs';
import { DatabaseService } from '../database/database.service';
// Injected since BudgetModule dropped its AuthModule import (BudgetMcp's demo
// guard reads RuntimeEnvService + the users table now), which un-closed the
@@ -7,6 +8,8 @@ import { DatabaseService } from '../database/database.service';
import { BudgetService } from '../budget/budget.service';
import { pluginsDataRoot } from '../plugins/paths';
import { UnitOfWork } from '../database/unit-of-work';
import { Users } from '../../db/entities/Users.entity';
import type { UsersRepository } from '../../db/repositories/Users.repository';
/**
* Account erasure — everything that has to happen around `DELETE FROM users`
@@ -26,6 +29,7 @@ export class UserCleanupService {
private readonly db: DatabaseService,
private readonly budget: BudgetService,
private readonly uow: UnitOfWork,
@InjectRepository(Users) private readonly usersRepo: UsersRepository,
) {}
/**
@@ -38,6 +42,13 @@ export class UserCleanupService {
*
* Best-effort per table so a slimmed-down schema (some tests) can't fail the user
* deletion itself.
*
* UC1–UC3 (`plugin_user_config`/`plugin_oauth_tokens`/`plugin_oauth_state`/
* `plugins`/`plugin_user_erasure_queue`) stay raw `DatabaseService` calls —
* every one of these tables is owned by `nest/plugins`, which lands in
* Plan 3j, not this plan (Plan 3b Task 5 ruling; inventory §6 "the single
* largest 'stays raw' carve-out in Plan 3b"). Only `DELETE FROM users`
* below (UC11) is this domain's own and converts.
*/
async erasePluginUserData(userId: number): Promise<void> {
for (const table of ['plugin_user_config', 'plugin_oauth_tokens', 'plugin_oauth_state']) {
@@ -65,24 +76,41 @@ export class UserCleanupService {
} catch { /* plugins / queue table absent (slim schema) */ }
}
/**
* UC4–UC10: every table here is owned by a domain outside this plan —
* `trip_members` (`nest/trip-membership`, Plan 3c), `budget_items`
* (`nest/budget`, Plan 3e), `share_tokens`/`journey_share_tokens`/
* `journeys`/`journey_entries`/`journey_contributors` (`nest/share`/
* `nest/journey-share`/`nest/journey-domain`, Plan 3g) — so they stay raw
* `DatabaseService` calls (Plan 3b Task 5 ruling; inventory §6). Verbatim
* otherwise: same statement order, same bounded 3-table
* journey/journey_entries/journey_contributors sequence,
* `this.budget.removeUserFromBudgetItems` unchanged.
*/
private async cleanupUserReferences(userId: number): Promise<void> {
this.db.run('UPDATE trip_members SET invited_by = NULL WHERE invited_by = ?', userId);
this.db.run('UPDATE budget_items SET paid_by_user_id = NULL WHERE paid_by_user_id = ?', userId);
this.db.run('UPDATE trip_members SET invited_by = NULL WHERE invited_by = ?', userId); // UC4 — Plan 3c
this.db.run('UPDATE budget_items SET paid_by_user_id = NULL WHERE paid_by_user_id = ?', userId); // UC5 — Plan 3e
await this.budget.removeUserFromBudgetItems(userId);
this.db.run('DELETE FROM share_tokens WHERE created_by = ?', userId);
this.db.run('DELETE FROM journey_share_tokens WHERE created_by = ?', userId);
this.db.run('DELETE FROM share_tokens WHERE created_by = ?', userId); // UC6 — Plan 3g
this.db.run('DELETE FROM journey_share_tokens WHERE created_by = ?', userId); // UC7 — Plan 3g
// Owned journeys cascade-delete their entries/contributors/share_tokens/photos via journey_id FKs
this.db.run('DELETE FROM journeys WHERE user_id = ?', userId);
this.db.run('DELETE FROM journeys WHERE user_id = ?', userId); // UC8 — Plan 3g
// Entries authored on other users' journeys (not covered by the cascade above)
this.db.run('DELETE FROM journey_entries WHERE author_id = ?', userId);
this.db.run('DELETE FROM journey_contributors WHERE user_id = ?', userId);
this.db.run('DELETE FROM journey_entries WHERE author_id = ?', userId); // UC9 — Plan 3g
this.db.run('DELETE FROM journey_contributors WHERE user_id = ?', userId); // UC10 — Plan 3g
}
/**
* UC11, the transaction's final statement — `UsersRepository.deleteById`
* (Plan 3b Task 5; the one pre-authorised `UsersRepository` addition, per
* `task-1-review.md`'s "contract gaps"). Everything above it (UC1–UC10)
* stays raw, per each method's own docstring.
*/
async deleteUserCompletely(userId: number): Promise<void> {
await this.uow.transactional(async () => {
await this.cleanupUserReferences(userId);
await this.erasePluginUserData(userId);
this.db.run('DELETE FROM users WHERE id = ?', userId);
await this.usersRepo.deleteById(userId);
});
}
}
+13 -4
View File
@@ -119,6 +119,11 @@ import { createTestOrm } from './test-orm';
import { AppSettings } from '../../src/db/entities/AppSettings.entity';
import { AuditLog } from '../../src/db/entities/AuditLog.entity';
import { Users } from '../../src/db/entities/Users.entity';
import { InviteTokens } from '../../src/db/entities/InviteTokens.entity';
import { McpTokens } from '../../src/db/entities/McpTokens.entity';
import { OauthTokens } from '../../src/db/entities/OauthTokens.entity';
import { WebauthnCredentials } from '../../src/db/entities/WebauthnCredentials.entity';
import { PasswordResetTokens } from '../../src/db/entities/PasswordResetTokens.entity';
/**
* Hand-wired counterpart of the boot-time discovery in McpRegistryService,
@@ -134,6 +139,11 @@ export async function createMcpTestRegistry(): Promise<McpRegistry> {
const mcpOrm = await createTestOrm(dbService.connection);
const auditLogRepo = mcpOrm.repo(AuditLog);
const usersRepo = mcpOrm.repo(Users);
const inviteTokensRepo = mcpOrm.repo(InviteTokens);
const mcpTokensRepoForAuth = mcpOrm.repo(McpTokens);
const oauthTokensRepo = mcpOrm.repo(OauthTokens);
const webauthnCredentialsRepoForAuth = mcpOrm.repo(WebauthnCredentials);
const passwordResetTokensRepo = mcpOrm.repo(PasswordResetTokens);
const permissionsService = new PermissionsService(await createTestAppSettingsRepo(dbService.connection), await createTestUnitOfWork(dbService.connection));
// Same argument list as auth.bridge.ts. AtlasService used to sit in third
// place; when getTravelStats moved onto AtlasService itself the edge was
@@ -145,15 +155,14 @@ export async function createMcpTestRegistry(): Promise<McpRegistry> {
const exchangeRatesService = new ExchangeRatesService();
const budgetService = new BudgetService(dbService, permissionsService, exchangeRatesService, realtimeService, await createTestUnitOfWork(dbService.connection));
const authService = new AuthService(
dbService,
permissionsService,
new TripMembershipService(dbService),
new WebauthnConfigService(appSettings),
new UserCleanupService(dbService, budgetService, await createTestUnitOfWork(dbService.connection)),
new UserCleanupService(dbService, budgetService, await createTestUnitOfWork(dbService.connection), usersRepo),
new MailerService(dbService),
new EphemeralTokenService(),
new AllowedFileTypesService(dbService), await createTestUnitOfWork(dbService.connection),
appSettings, usersRepo,
appSettings, usersRepo, inviteTokensRepo, mcpTokensRepoForAuth, oauthTokensRepo, webauthnCredentialsRepoForAuth, passwordResetTokensRepo,
);
const queryHelpersService = new QueryHelpersService(dbService);
const daysService = new DaysService(dbService, permissionsService, realtimeService, queryHelpersService, await createTestUnitOfWork(dbService.connection));
@@ -185,7 +194,7 @@ export async function createMcpTestRegistry(): Promise<McpRegistry> {
);
// Built after it: a hotel booking writes the stay's day stop through this one.
const reservationsService = new ReservationsService(dbService, permissionsService, budgetService, realtimeService, notificationsStub(), new ReservationsReadRepository(dbService), accommodationsService, await createTestUnitOfWork(dbService.connection));
const membersService = new TripMembersService(dbService, budgetService, new UserCleanupService(dbService, budgetService, await createTestUnitOfWork(dbService.connection)), permissionsService, realtimeService, notificationsStub(), await createTestUnitOfWork(dbService.connection));
const membersService = new TripMembersService(dbService, budgetService, new UserCleanupService(dbService, budgetService, await createTestUnitOfWork(dbService.connection), usersRepo), permissionsService, realtimeService, notificationsStub(), await createTestUnitOfWork(dbService.connection));
const tripsService = new TripsService(
dbService,
reservationsService,
+1 -1
View File
@@ -120,7 +120,7 @@ export async function createPluginRpcHostFactory(dbs: DatabaseService): Promise<
// After accommodations: a hotel booking writes the stay's day stop through it.
const reservations = new ReservationsService(dbs, permissions, budget, realtime, notificationsStub(), new ReservationsReadRepository(dbs), accommodations, await createTestUnitOfWork(dbs.connection));
const trips = new TripsService(dbs, reservations, days, permissions, budget, vacay, realtime, unsplash, generalStorage, await createTestUnitOfWork(dbs.connection));
const members = new TripMembersService(dbs, budget, new UserCleanupService(dbs, budget, await createTestUnitOfWork(dbs.connection)), permissions, realtime, notificationsStub(), await createTestUnitOfWork(dbs.connection));
const members = new TripMembersService(dbs, budget, new UserCleanupService(dbs, budget, await createTestUnitOfWork(dbs.connection), usersRepo), permissions, realtime, notificationsStub(), await createTestUnitOfWork(dbs.connection));
const guards = new PluginGuards(dbs, permissions, addons);
const registry = createTestPluginRegistry([
+36
View File
@@ -20,6 +20,10 @@ import { InviteTokens } from '../../src/db/entities/InviteTokens.entity';
import type { InviteTokensRepository } from '../../src/db/repositories/InviteTokens.repository';
import { McpTokens } from '../../src/db/entities/McpTokens.entity';
import type { McpTokensRepository } from '../../src/db/repositories/McpTokens.repository';
import { OauthTokens } from '../../src/db/entities/OauthTokens.entity';
import type { OauthTokensRepository } from '../../src/db/repositories/OauthTokens.repository';
import { PasswordResetTokens } from '../../src/db/entities/PasswordResetTokens.entity';
import type { PasswordResetTokensRepository } from '../../src/db/repositories/PasswordResetTokens.repository';
const perHandle = new WeakMap<Database.Database, Promise<UnitOfWork>>();
const appSettingsPerHandle = new WeakMap<Database.Database, Promise<AppSettingsRepository>>();
@@ -241,3 +245,35 @@ export function createTestInviteTokensRepo(db: Database.Database): Promise<Invit
inviteTokensPerHandle.set(db, pending);
return pending;
}
// ---------------------------------------------------------------------------
// Plan 3b Task 5 (AuthService / UserCleanupService) — appended at the end per
// the task's own file-ownership rule. Same memoisation-per-handle pattern as
// every helper above: one ORM per test file's better-sqlite3 handle, shared
// with whatever `UnitOfWork`/other repository that same file also builds —
// required per the Task 4 review ("append createTestOauthTokensRepo to
// test-uow.ts through the existing sharedTestOrm memoisation — a repository
// from a different ORM instance resolves a different fork and the
// transaction test lies").
// ---------------------------------------------------------------------------
const oauthTokensPerHandle = new WeakMap<Database.Database, Promise<OauthTokensRepository>>();
const passwordResetTokensPerHandle = new WeakMap<Database.Database, Promise<PasswordResetTokensRepository>>();
/** The `OauthTokensRepository` a hand-constructed `AuthService` needs (Plan 3b Task 4's `revokeAllForUser`, consumed by Task 5). */
export function createTestOauthTokensRepo(db: Database.Database): Promise<OauthTokensRepository> {
const existing = oauthTokensPerHandle.get(db);
if (existing !== undefined) return existing;
const pending = sharedTestOrm(db).then((t) => t.repo(OauthTokens));
oauthTokensPerHandle.set(db, pending);
return pending;
}
/** The `PasswordResetTokensRepository` a hand-constructed `AuthService` needs (Plan 3b Task 5, new repository). */
export function createTestPasswordResetTokensRepo(db: Database.Database): Promise<PasswordResetTokensRepository> {
const existing = passwordResetTokensPerHandle.get(db);
if (existing !== undefined) return existing;
const pending = sharedTestOrm(db).then((t) => t.repo(PasswordResetTokens));
passwordResetTokensPerHandle.set(db, pending);
return pending;
}
@@ -0,0 +1,194 @@
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
import { createHash } from 'crypto';
import { createSnapshotTestDb } from '../../../helpers/db-mock';
import { resetTestDb } from '../../../helpers/test-db';
import { createTestOrm, type TestOrm } from '../../../helpers/test-orm';
import { createUser } from '../../../helpers/factories';
import { PasswordResetTokens } from '../../../../src/db/entities/PasswordResetTokens.entity';
import type { PasswordResetTokensRepository } from '../../../../src/db/repositories/PasswordResetTokens.repository';
import { UnitOfWork } from '../../../../src/nest/database/unit-of-work';
const testDb = createSnapshotTestDb();
let t: TestOrm;
let tokens: PasswordResetTokensRepository;
beforeAll(async () => {
t = await createTestOrm(testDb);
tokens = t.repo(PasswordResetTokens);
});
beforeEach(() => {
resetTestDb(testDb);
t.clear();
});
afterAll(async () => {
await t.close();
testDb.close();
});
function hash(raw: string): string {
return createHash('sha256').update(raw).digest('hex');
}
function rawRow(id: number): { id: number; user_id: number; token_hash: string; expires_at: string; consumed_at: string | null; created_ip: string | null } {
return testDb.prepare('SELECT id, user_id, token_hash, expires_at, consumed_at, created_ip FROM password_reset_tokens WHERE id = ?').get(id) as never;
}
function seedToken(userId: number, tokenHash: string, opts: { expires_at?: string; consumed_at?: string | null; created_ip?: string | null } = {}): number {
const expiresAt = opts.expires_at ?? new Date(Date.now() + 60 * 60 * 1000).toISOString();
const result = testDb
.prepare('INSERT INTO password_reset_tokens (user_id, token_hash, expires_at, consumed_at, created_ip) VALUES (?, ?, ?, ?, ?)')
.run(userId, tokenHash, expiresAt, opts.consumed_at ?? null, opts.created_ip ?? null);
return Number(result.lastInsertRowid);
}
describe('PasswordResetTokensRepository', () => {
describe('insertToken', () => {
it('PWDRESETREPO-001: writes the exact column set, hash-only (the raw token never appears)', async () => {
const { user } = createUser(testDb);
const tokenHash = hash('raw-token-value');
const expiresAt = new Date(Date.now() + 60 * 60 * 1000).toISOString();
await tokens.insertToken({ user_id: user.id, token_hash: tokenHash, expires_at: expiresAt, created_ip: '1.2.3.4' });
const row = testDb.prepare('SELECT user_id, token_hash, expires_at, consumed_at, created_ip FROM password_reset_tokens WHERE token_hash = ?').get(tokenHash);
expect(row).toEqual({ user_id: user.id, token_hash: tokenHash, expires_at: expiresAt, consumed_at: null, created_ip: '1.2.3.4' });
});
it('PWDRESETREPO-002: writes a NULL created_ip', async () => {
const { user } = createUser(testDb);
const tokenHash = hash('no-ip-token');
await tokens.insertToken({ user_id: user.id, token_hash: tokenHash, expires_at: new Date().toISOString(), created_ip: null });
const row = testDb.prepare('SELECT created_ip FROM password_reset_tokens WHERE token_hash = ?').get(tokenHash) as { created_ip: string | null };
expect(row.created_ip).toBeNull();
});
});
describe('findByTokenHash', () => {
it('PWDRESETREPO-003: finds a live token by its hash', async () => {
const { user } = createUser(testDb);
const tokenHash = hash('lookup-me');
const id = seedToken(user.id, tokenHash);
const found = await tokens.findByTokenHash(tokenHash);
expect(found).toEqual({ id, user_id: user.id, expires_at: expect.any(String), consumed_at: null });
});
it('PWDRESETREPO-004: a consumed token is still found (consumed_at is checked by the caller, not filtered here)', async () => {
const { user } = createUser(testDb);
const tokenHash = hash('already-used');
seedToken(user.id, tokenHash, { consumed_at: '2026-01-01T00:00:00.000Z' });
const found = await tokens.findByTokenHash(tokenHash);
expect(found?.consumed_at).toBe('2026-01-01T00:00:00.000Z');
});
it('PWDRESETREPO-005: an unknown hash is null', async () => {
expect(await tokens.findByTokenHash(hash('never-issued'))).toBeNull();
});
});
describe('markConsumed', () => {
it('PWDRESETREPO-006: stamps consumed_at on the target row only', async () => {
const { user } = createUser(testDb);
const target = seedToken(user.id, hash('mark-me'));
const other = seedToken(user.id, hash('leave-me'));
await tokens.markConsumed(target);
expect(rawRow(target).consumed_at).not.toBeNull();
expect(rawRow(other).consumed_at).toBeNull();
});
});
describe('consumeAllLiveForUser', () => {
it('PWDRESETREPO-007: without an exclusion, burns every live token for the user', async () => {
const { user } = createUser(testDb);
const a = seedToken(user.id, hash('a'));
const b = seedToken(user.id, hash('b'));
await tokens.consumeAllLiveForUser(user.id);
expect(rawRow(a).consumed_at).not.toBeNull();
expect(rawRow(b).consumed_at).not.toBeNull();
});
it('PWDRESETREPO-008: with an exclusion, burns every OTHER live token but leaves the excluded id alone', async () => {
const { user } = createUser(testDb);
const keep = seedToken(user.id, hash('keep-me'));
const burn1 = seedToken(user.id, hash('burn-1'));
const burn2 = seedToken(user.id, hash('burn-2'));
await tokens.consumeAllLiveForUser(user.id, keep);
expect(rawRow(keep).consumed_at).toBeNull();
expect(rawRow(burn1).consumed_at).not.toBeNull();
expect(rawRow(burn2).consumed_at).not.toBeNull();
});
it('PWDRESETREPO-009: never touches another user\'s tokens or an already-consumed row', async () => {
const { user: victim } = createUser(testDb);
const { user: bystander } = createUser(testDb);
const already = seedToken(victim.id, hash('already-consumed'), { consumed_at: '2026-01-01T00:00:00.000Z' });
const bystanderToken = seedToken(bystander.id, hash('bystander'));
await tokens.consumeAllLiveForUser(victim.id);
// The already-consumed row's consumed_at is untouched (still the original
// stamp, not overwritten by this call — the WHERE only matches consumed_at
// IS NULL rows).
expect(rawRow(already).consumed_at).toBe('2026-01-01T00:00:00.000Z');
expect(rawRow(bystanderToken).consumed_at).toBeNull();
});
});
// ---------------------------------------------------------------------
// Identity-map isolation (Plan 3b Task 5 ruling — every row-out read
// passes `disableIdentityMap: true`, applied by `TrekRepository`'s
// default). This repository has only ONE entity-hydrating read method
// (`findByTokenHash`) — unlike `McpTokensRepository`/`Users.repository.ts`,
// which each have two differently-projected NAMED read methods to
// combine. PWDRESETREPO-010/011 are the structural + behavioural proof
// that the real call shape (`findByTokenHash` then `markConsumed`/
// `consumeAllLiveForUser` inside the same `uow.transactional`) is safe.
//
// An attempted third test forcing `disableIdentityMap: false` on two raw
// `find`/`findOne` calls with disjoint field sets (mirroring
// `McpTokensRepository`'s `MCPTOKREPO-023` reproduction) was tried here
// and did NOT reproduce the stale write-back on this table — the
// `nativeUpdate` survived the closing flush both times, the same "safe"
// outcome as the class-default path. Per `task-1-rereview.md` F-R5, this
// mechanism is sensitive to exactly which methods/entities exercise it;
// not reproducing it for a single-read-method repository is an honest
// finding (same precedent as Task 2's report: "the implementer could not
// reproduce the write-back within nativeUpdate-only shapes"), not a gap
// in these two tests' coverage of the real code path.
// ---------------------------------------------------------------------
describe('identity-map isolation', () => {
it('PWDRESETREPO-010: findByTokenHash never leaves a managed entity behind', async () => {
const { user } = createUser(testDb);
const id = seedToken(user.id, hash('isolated'));
await tokens.findByTokenHash(hash('isolated'));
expect(t.em.getUnitOfWork().getById(PasswordResetTokens, id)).toBeUndefined();
});
it('PWDRESETREPO-011: findByTokenHash then markConsumed inside uow.transactional survives the closing flush', async () => {
const { user } = createUser(testDb);
const id = seedToken(user.id, hash('survives'));
const uow = new UnitOfWork(t.em);
await uow.transactional(async () => {
const found = await tokens.findByTokenHash(hash('survives'));
expect(found?.consumed_at).toBeNull();
await tokens.markConsumed(id);
});
expect(rawRow(id).consumed_at).not.toBeNull();
});
});
});
+17 -3
View File
@@ -82,7 +82,17 @@ import { __clearVersionCacheForTests } from '../../../src/nest/admin/admin.helpe
import { makeNotificationsService, makeNotificationPreferencesService } from '../../helpers/notifications';
import { EphemeralTokenService } from '../../../src/nest/auth/ephemeral-token.service';
import { AllowedFileTypesService } from '../../../src/nest/files/allowed-file-types.service';
import { createTestUnitOfWork, createTestAppSettingsRepo, createTestUsersRepo, createTestWebauthnCredentialsRepo, createTestWebauthnChallengesRepo } from '../../helpers/test-uow';
import {
createTestUnitOfWork,
createTestAppSettingsRepo,
createTestUsersRepo,
createTestWebauthnCredentialsRepo,
createTestWebauthnChallengesRepo,
createTestInviteTokensRepo,
createTestMcpTokensRepo,
createTestOauthTokensRepo,
createTestPasswordResetTokensRepo,
} from '../../helpers/test-uow';
const dbs = new DatabaseService(testDb);
const realtime = new RealtimeService();
@@ -101,8 +111,12 @@ let svc: AdminService;
beforeAll(async () => {
webauthn = new WebauthnConfigService(await createTestAppSettingsRepo(dbs.connection));
permissions = new PermissionsService(await createTestAppSettingsRepo(dbs.connection), await createTestUnitOfWork(dbs.connection));
userCleanup = new UserCleanupService(dbs, new BudgetService(dbs, permissions, new ExchangeRatesService(), realtime, await createTestUnitOfWork(dbs.connection)), await createTestUnitOfWork(dbs.connection));
auth = new AuthService(dbs, permissions, new TripMembershipService(dbs), webauthn, userCleanup, new MailerService(dbs), new EphemeralTokenService(), new AllowedFileTypesService(dbs), await createTestUnitOfWork(dbs.connection), await createTestAppSettingsRepo(dbs.connection), await createTestUsersRepo(dbs.connection));
userCleanup = new UserCleanupService(dbs, new BudgetService(dbs, permissions, new ExchangeRatesService(), realtime, await createTestUnitOfWork(dbs.connection)), await createTestUnitOfWork(dbs.connection), await createTestUsersRepo(dbs.connection));
auth = new AuthService(
permissions, new TripMembershipService(dbs), webauthn, userCleanup, new MailerService(dbs), new EphemeralTokenService(), new AllowedFileTypesService(dbs), await createTestUnitOfWork(dbs.connection),
await createTestAppSettingsRepo(dbs.connection), await createTestUsersRepo(dbs.connection), await createTestInviteTokensRepo(dbs.connection), await createTestMcpTokensRepo(dbs.connection),
await createTestOauthTokensRepo(dbs.connection), await createTestWebauthnCredentialsRepo(dbs.connection), await createTestPasswordResetTokensRepo(dbs.connection),
);
svc = new AdminService(
dbs,
await createTestAddonsService(testDb, dbs),
@@ -60,7 +60,17 @@ import { AdminService } from '../../../src/nest/admin/admin.service';
import { makeNotificationsService, makeNotificationPreferencesService } from '../../helpers/notifications';
import { EphemeralTokenService } from '../../../src/nest/auth/ephemeral-token.service';
import { AllowedFileTypesService } from '../../../src/nest/files/allowed-file-types.service';
import { createTestUnitOfWork, createTestAppSettingsRepo, createTestUsersRepo, createTestWebauthnCredentialsRepo, createTestWebauthnChallengesRepo } from '../../helpers/test-uow';
import {
createTestUnitOfWork,
createTestAppSettingsRepo,
createTestUsersRepo,
createTestWebauthnCredentialsRepo,
createTestWebauthnChallengesRepo,
createTestInviteTokensRepo,
createTestMcpTokensRepo,
createTestOauthTokensRepo,
createTestPasswordResetTokensRepo,
} from '../../helpers/test-uow';
const dbs = new DatabaseService(testDb);
const realtime = new RealtimeService();
@@ -79,8 +89,12 @@ let svc: AdminService;
beforeAll(async () => {
webauthn = new WebauthnConfigService(await createTestAppSettingsRepo(dbs.connection));
permissions = new PermissionsService(await createTestAppSettingsRepo(dbs.connection), await createTestUnitOfWork(dbs.connection));
userCleanup = new UserCleanupService(dbs, new BudgetService(dbs, permissions, new ExchangeRatesService(), realtime, await createTestUnitOfWork(dbs.connection)), await createTestUnitOfWork(dbs.connection));
auth = new AuthService(dbs, permissions, new TripMembershipService(dbs), webauthn, userCleanup, new MailerService(dbs), new EphemeralTokenService(), new AllowedFileTypesService(dbs), await createTestUnitOfWork(dbs.connection), await createTestAppSettingsRepo(dbs.connection), await createTestUsersRepo(dbs.connection));
userCleanup = new UserCleanupService(dbs, new BudgetService(dbs, permissions, new ExchangeRatesService(), realtime, await createTestUnitOfWork(dbs.connection)), await createTestUnitOfWork(dbs.connection), await createTestUsersRepo(dbs.connection));
auth = new AuthService(
permissions, new TripMembershipService(dbs), webauthn, userCleanup, new MailerService(dbs), new EphemeralTokenService(), new AllowedFileTypesService(dbs), await createTestUnitOfWork(dbs.connection),
await createTestAppSettingsRepo(dbs.connection), await createTestUsersRepo(dbs.connection), await createTestInviteTokensRepo(dbs.connection), await createTestMcpTokensRepo(dbs.connection),
await createTestOauthTokensRepo(dbs.connection), await createTestWebauthnCredentialsRepo(dbs.connection), await createTestPasswordResetTokensRepo(dbs.connection),
);
svc = new AdminService(
dbs,
await createTestAddonsService(testDb, dbs),
+13 -3
View File
@@ -84,7 +84,16 @@ import { MailerService } from '../../../src/nest/notifications/mailer/mailer.ser
import { EphemeralTokenService } from '../../../src/nest/auth/ephemeral-token.service';
import { AllowedFileTypesService } from '../../../src/nest/files/allowed-file-types.service';
import { DEFAULT_ALLOWED_EXTENSIONS } from '../../../src/nest/files/files.constants';
import { createTestUnitOfWork, createTestAppSettingsRepo, createTestUsersRepo, createTestMcpTokensRepo } from '../../helpers/test-uow';
import {
createTestUnitOfWork,
createTestAppSettingsRepo,
createTestUsersRepo,
createTestMcpTokensRepo,
createTestInviteTokensRepo,
createTestOauthTokensRepo,
createTestWebauthnCredentialsRepo,
createTestPasswordResetTokensRepo,
} from '../../helpers/test-uow';
// MailerService is injected since the notifications fold — a stub instead of a
// module mock. sendPasswordResetEmail is the only thing auth reaches for.
@@ -102,15 +111,16 @@ let svc: AuthService;
beforeAll(async () => {
tokens = new TokenService(await createTestMcpTokensRepo(testDb), await createTestUsersRepo(testDb), new EphemeralTokenService());
svc = new AuthService(
new DatabaseService(testDb),
new PermissionsService(await createTestAppSettingsRepo(testDb), await createTestUnitOfWork(testDb)),
membershipStub,
new WebauthnConfigService(await createTestAppSettingsRepo(testDb)),
new UserCleanupService(new DatabaseService(testDb), new BudgetService(new DatabaseService(testDb), new PermissionsService(await createTestAppSettingsRepo(testDb), await createTestUnitOfWork(testDb)), new ExchangeRatesService(), new RealtimeService(), await createTestUnitOfWork(testDb)), await createTestUnitOfWork(testDb)),
new UserCleanupService(new DatabaseService(testDb), new BudgetService(new DatabaseService(testDb), new PermissionsService(await createTestAppSettingsRepo(testDb), await createTestUnitOfWork(testDb)), new ExchangeRatesService(), new RealtimeService(), await createTestUnitOfWork(testDb)), await createTestUnitOfWork(testDb), await createTestUsersRepo(testDb)),
mailerStub,
new EphemeralTokenService(),
new AllowedFileTypesService(new DatabaseService(testDb)), await createTestUnitOfWork(testDb),
await createTestAppSettingsRepo(testDb), await createTestUsersRepo(testDb),
await createTestInviteTokensRepo(testDb), await createTestMcpTokensRepo(testDb), await createTestOauthTokensRepo(testDb),
await createTestWebauthnCredentialsRepo(testDb), await createTestPasswordResetTokensRepo(testDb),
);
});
@@ -72,7 +72,7 @@ import { CollabService } from '../../../src/nest/collab/collab.service';
import { VacayService } from '../../../src/nest/vacay/vacay.service';
import { QueryHelpersService } from '../../../src/nest/query-helpers/query-helpers.service';
import { notificationsStub } from '../../helpers/notifications';
import { createTestUnitOfWork, createTestAppSettingsRepo } from '../../helpers/test-uow';
import { createTestUnitOfWork, createTestAppSettingsRepo, createTestUsersRepo } from '../../helpers/test-uow';
@@ -98,7 +98,7 @@ beforeAll(async () => {
membersSvc = new TripMembersService(
dbs(),
budget,
new UserCleanupService(dbs(), budget, await createTestUnitOfWork(testDb)),
new UserCleanupService(dbs(), budget, await createTestUnitOfWork(testDb), await createTestUsersRepo(testDb)),
new PermissionsService(await createTestAppSettingsRepo(dbs().connection), await createTestUnitOfWork(dbs().connection)),
new RealtimeService(),
notificationsStub(),
+13 -3
View File
@@ -106,7 +106,16 @@ import { EphemeralTokenService } from '../../../src/nest/auth/ephemeral-token.se
import { AllowedFileTypesService } from '../../../src/nest/files/allowed-file-types.service';
import { OidcService } from '../../../src/nest/oidc/oidc.service';
import { MailerService } from '../../../src/nest/notifications/mailer/mailer.service';
import { createTestUnitOfWork, createTestAppSettingsRepo, createTestUsersRepo } from '../../helpers/test-uow';
import {
createTestUnitOfWork,
createTestAppSettingsRepo,
createTestUsersRepo,
createTestInviteTokensRepo,
createTestMcpTokensRepo,
createTestOauthTokensRepo,
createTestWebauthnCredentialsRepo,
createTestPasswordResetTokensRepo,
} from '../../helpers/test-uow';
// MailerService is injected since the notifications fold — a stub instead of a
// module mock. sendPasswordResetEmail is the only thing auth reaches for.
@@ -122,15 +131,16 @@ let auth: AuthService;
let svc: OidcService;
beforeAll(async () => {
auth = new AuthService(
new DatabaseService(testDb),
new PermissionsService(await createTestAppSettingsRepo(testDb), await createTestUnitOfWork(testDb)),
membership,
new WebauthnConfigService(await createTestAppSettingsRepo(testDb)),
new UserCleanupService(new DatabaseService(testDb), new BudgetService(new DatabaseService(testDb), new PermissionsService(await createTestAppSettingsRepo(testDb), await createTestUnitOfWork(testDb)), new ExchangeRatesService(), new RealtimeService(), await createTestUnitOfWork(testDb)), await createTestUnitOfWork(testDb)),
new UserCleanupService(new DatabaseService(testDb), new BudgetService(new DatabaseService(testDb), new PermissionsService(await createTestAppSettingsRepo(testDb), await createTestUnitOfWork(testDb)), new ExchangeRatesService(), new RealtimeService(), await createTestUnitOfWork(testDb)), await createTestUnitOfWork(testDb), await createTestUsersRepo(testDb)),
mailerStub,
new EphemeralTokenService(),
new AllowedFileTypesService(new DatabaseService(testDb)), await createTestUnitOfWork(testDb),
await createTestAppSettingsRepo(testDb), await createTestUsersRepo(testDb),
await createTestInviteTokensRepo(testDb), await createTestMcpTokensRepo(testDb), await createTestOauthTokensRepo(testDb),
await createTestWebauthnCredentialsRepo(testDb), await createTestPasswordResetTokensRepo(testDb),
);
svc = new OidcService(new DatabaseService(testDb), auth, membership, await createTestUnitOfWork(testDb));
});
@@ -95,6 +95,10 @@ import {
createTestUsersRepo,
createTestWebauthnCredentialsRepo,
createTestWebauthnChallengesRepo,
createTestInviteTokensRepo,
createTestMcpTokensRepo,
createTestOauthTokensRepo,
createTestPasswordResetTokensRepo,
} from '../../helpers/test-uow';
// MailerService is injected since the notifications fold — a stub instead of a
@@ -121,15 +125,16 @@ let auth: AuthService;
let svc: PasskeyService;
beforeAll(async () => {
auth = new AuthService(
new DatabaseService(testDb),
new PermissionsService(await createTestAppSettingsRepo(testDb), await createTestUnitOfWork(testDb)),
new TripMembershipService(new DatabaseService(testDb)),
new WebauthnConfigService(await createTestAppSettingsRepo(testDb)),
new UserCleanupService(new DatabaseService(testDb), new BudgetService(new DatabaseService(testDb), new PermissionsService(await createTestAppSettingsRepo(testDb), await createTestUnitOfWork(testDb)), new ExchangeRatesService(), new RealtimeService(), await createTestUnitOfWork(testDb)), await createTestUnitOfWork(testDb)),
new UserCleanupService(new DatabaseService(testDb), new BudgetService(new DatabaseService(testDb), new PermissionsService(await createTestAppSettingsRepo(testDb), await createTestUnitOfWork(testDb)), new ExchangeRatesService(), new RealtimeService(), await createTestUnitOfWork(testDb)), await createTestUnitOfWork(testDb), await createTestUsersRepo(testDb)),
mailerStub,
new EphemeralTokenService(),
new AllowedFileTypesService(new DatabaseService(testDb)), await createTestUnitOfWork(testDb),
await createTestAppSettingsRepo(testDb), await createTestUsersRepo(testDb),
await createTestInviteTokensRepo(testDb), await createTestMcpTokensRepo(testDb), await createTestOauthTokensRepo(testDb),
await createTestWebauthnCredentialsRepo(testDb), await createTestPasswordResetTokensRepo(testDb),
);
svc = new PasskeyService(
auth,
@@ -65,7 +65,7 @@ import { TripMembersService } from '../../../src/nest/trip-members/trip-members.
import { NotFoundError, ValidationError } from '../../../src/nest/common/domain-errors';
import type { User } from '../../../src/types';
import { notificationsStub } from '../../helpers/notifications';
import { createTestUnitOfWork, createTestAppSettingsRepo } from '../../helpers/test-uow';
import { createTestUnitOfWork, createTestAppSettingsRepo, createTestUsersRepo } from '../../helpers/test-uow';
const dbs = () => new DatabaseService(testDb);
@@ -73,7 +73,7 @@ let budgetSvc: BudgetService;
let roster: TripMembersService;
beforeAll(async () => {
budgetSvc = new BudgetService(dbs(), new PermissionsService(await createTestAppSettingsRepo(dbs().connection), await createTestUnitOfWork(dbs().connection)), new ExchangeRatesService(), new RealtimeService(), await createTestUnitOfWork(dbs().connection));
roster = new TripMembersService(dbs(), budgetSvc, new UserCleanupService(dbs(), budgetSvc, await createTestUnitOfWork(dbs().connection)), new PermissionsService(await createTestAppSettingsRepo(dbs().connection), await createTestUnitOfWork(dbs().connection)), new RealtimeService(), notificationsStub(notifySend), await createTestUnitOfWork(dbs().connection));
roster = new TripMembersService(dbs(), budgetSvc, new UserCleanupService(dbs(), budgetSvc, await createTestUnitOfWork(dbs().connection), await createTestUsersRepo(dbs().connection)), new PermissionsService(await createTestAppSettingsRepo(dbs().connection), await createTestUnitOfWork(dbs().connection)), new RealtimeService(), notificationsStub(notifySend), await createTestUnitOfWork(dbs().connection));
});
/**
@@ -104,7 +104,7 @@ async function rosterWithMissingRow(match: string) {
},
});
const frozen = { connection: conn, canAccessTrip: dbMock.canAccessTrip, isOwner: dbMock.isOwner } as unknown as DatabaseService;
return new TripMembersService(frozen, budgetSvc, new UserCleanupService(dbs(), budgetSvc, await createTestUnitOfWork(dbs().connection)), new PermissionsService(await createTestAppSettingsRepo(dbs().connection), await createTestUnitOfWork(dbs().connection)), new RealtimeService(), notificationsStub(notifySend), await createTestUnitOfWork(dbs().connection));
return new TripMembersService(frozen, budgetSvc, new UserCleanupService(dbs(), budgetSvc, await createTestUnitOfWork(dbs().connection), await createTestUsersRepo(dbs().connection)), new PermissionsService(await createTestAppSettingsRepo(dbs().connection), await createTestUnitOfWork(dbs().connection)), new RealtimeService(), notificationsStub(notifySend), await createTestUnitOfWork(dbs().connection));
}
beforeAll(() => {
@@ -109,7 +109,7 @@ beforeAll(async () => {
makeStorageFixture('').storage,
await accommodationsOver(dbs()), await createTestUnitOfWork(dbs().connection),
);
membersSvc = new TripMembersService(dbs(), budgetSvc, new UserCleanupService(dbs(), budgetSvc, await createTestUnitOfWork(dbs().connection)), new PermissionsService(await createTestAppSettingsRepo(dbs().connection), await createTestUnitOfWork(dbs().connection)), new RealtimeService(), notificationsStub(), await createTestUnitOfWork(dbs().connection));
membersSvc = new TripMembersService(dbs(), budgetSvc, new UserCleanupService(dbs(), budgetSvc, await createTestUnitOfWork(dbs().connection), await createTestUsersRepo(dbs().connection)), new PermissionsService(await createTestAppSettingsRepo(dbs().connection), await createTestUnitOfWork(dbs().connection)), new RealtimeService(), notificationsStub(), await createTestUnitOfWork(dbs().connection));
});
const buildReadModel = async (database: DatabaseService, roster: TripMembersService = membersSvc) =>
+2 -2
View File
@@ -148,7 +148,7 @@ beforeAll(async () => {
coversFx.storage,
await createTestUnitOfWork(dbs().connection),
);
membersSvc = new TripMembersService(dbs(), budgetSvc, new UserCleanupService(dbs(), budgetSvc, await createTestUnitOfWork(dbs().connection)), new PermissionsService(await createTestAppSettingsRepo(dbs().connection), await createTestUnitOfWork(dbs().connection)), new RealtimeService(), notificationsStub(), await createTestUnitOfWork(dbs().connection));
membersSvc = new TripMembersService(dbs(), budgetSvc, new UserCleanupService(dbs(), budgetSvc, await createTestUnitOfWork(dbs().connection), await createTestUsersRepo(dbs().connection)), new PermissionsService(await createTestAppSettingsRepo(dbs().connection), await createTestUnitOfWork(dbs().connection)), new RealtimeService(), notificationsStub(), await createTestUnitOfWork(dbs().connection));
readModelSvc = new TripReadModelService(
dbs(), membersSvc, daysSvc, accommodationsSvc, budgetSvc,
new PackingService(dbs(), new PermissionsService(await createTestAppSettingsRepo(dbs().connection), await createTestUnitOfWork(dbs().connection)), new RealtimeService(), notificationsStub(), await createTestUnitOfWork(dbs().connection)),
@@ -1220,7 +1220,7 @@ describe('quirk fixes', () => {
/** Same frozen connection, for the guest deletion that now lives on the roster. */
async function failingMembers(match: string) {
return new TripMembersService(
failingConnection(match), budgetSvc, new UserCleanupService(dbs(), budgetSvc, await createTestUnitOfWork(dbs().connection)),
failingConnection(match), budgetSvc, new UserCleanupService(dbs(), budgetSvc, await createTestUnitOfWork(dbs().connection), await createTestUsersRepo(dbs().connection)),
new PermissionsService(await createTestAppSettingsRepo(dbs().connection), await createTestUnitOfWork(dbs().connection)), new RealtimeService(),
notificationsStub(), await createTestUnitOfWork(dbs().connection),
);
@@ -49,7 +49,7 @@ import { ExchangeRatesService } from '../../../src/nest/budget/exchange-rates.se
import { RealtimeService } from '../../../src/nest/realtime/realtime.service';
import { BudgetService } from '../../../src/nest/budget/budget.service';
import { UserCleanupService } from '../../../src/nest/auth/user-cleanup.service';
import { createTestUnitOfWork, createTestAppSettingsRepo } from '../../helpers/test-uow';
import { createTestUnitOfWork, createTestAppSettingsRepo, createTestUsersRepo } from '../../helpers/test-uow';
const dbs = new DatabaseService(testDb);
@@ -57,7 +57,7 @@ let budget: BudgetService;
let svc: UserCleanupService;
beforeAll(async () => {
budget = new BudgetService(dbs, new PermissionsService(await createTestAppSettingsRepo(dbs.connection), await createTestUnitOfWork(dbs.connection)), new ExchangeRatesService(), new RealtimeService(), await createTestUnitOfWork(dbs.connection));
svc = new UserCleanupService(dbs, budget, await createTestUnitOfWork(dbs.connection));
svc = new UserCleanupService(dbs, budget, await createTestUnitOfWork(dbs.connection), await createTestUsersRepo(dbs.connection));
});
const installPlugin = (id: string, permissions: string[] | null) => {
@@ -152,7 +152,7 @@ describe('erasePluginUserData', () => {
const slim = new (require('better-sqlite3'))(':memory:');
slim.exec('CREATE TABLE users (id INTEGER PRIMARY KEY)');
slim.prepare('INSERT INTO users (id) VALUES (1)').run();
const slimSvc = new UserCleanupService(new DatabaseService(slim), budget, await createTestUnitOfWork(slim));
const slimSvc = new UserCleanupService(new DatabaseService(slim), budget, await createTestUnitOfWork(slim), await createTestUsersRepo(slim));
await expect(slimSvc.erasePluginUserData(1)).resolves.toBeUndefined();
@@ -213,21 +213,20 @@ describe('deleteUserCompletely', () => {
testDb.prepare('INSERT INTO trip_members (trip_id, user_id, invited_by) VALUES (?, ?, ?)')
.run(trip.id, owner.id, victim.id);
// Fail on the final statement only, after the reference cleanup has written.
const failing = new DatabaseService(testDb);
// `.bind` is load-bearing here and must NOT become an arrow forwarder: it captures
// the ORIGINAL run before vi.spyOn replaces the property, whereas `(...a) => failing.run(...a)`
// would re-enter the spy and recurse. `run` stays synchronous (recipe R0), so the
// `any`-typed alias hides nothing.
const realRun = failing.run.bind(failing);
vi.spyOn(failing, 'run').mockImplementation((sql: string, ...params: unknown[]) => {
if (sql.startsWith('DELETE FROM users')) throw new Error('boom');
return realRun(sql, ...params);
});
// Fail on the final statement only (UC11, `UsersRepository.deleteById`),
// after the reference cleanup (UC1-UC10, still raw and unaffected by this
// spy) has written. UC11 is a repository call now, not `this.db.run`, so
// this spies on the repository method instead of the legacy SQL-text
// sniff (`sql.startsWith('DELETE FROM users')`) that check replaced.
const usersRepo = await createTestUsersRepo(testDb);
const deleteByIdSpy = vi.spyOn(usersRepo, 'deleteById').mockRejectedValue(new Error('boom'));
try {
await expect(new UserCleanupService(dbs, budget, await createTestUnitOfWork(testDb), usersRepo).deleteUserCompletely(victim.id)).rejects.toThrow('boom');
await expect(new UserCleanupService(failing, budget, await createTestUnitOfWork(testDb)).deleteUserCompletely(victim.id)).rejects.toThrow('boom');
expect(testDb.prepare('SELECT id FROM users WHERE id = ?').get(victim.id)).toBeDefined();
expect((testDb.prepare('SELECT invited_by FROM trip_members WHERE user_id = ?').get(owner.id) as { invited_by: number | null }).invited_by).toBe(victim.id);
expect(testDb.prepare('SELECT id FROM users WHERE id = ?').get(victim.id)).toBeDefined();
expect((testDb.prepare('SELECT invited_by FROM trip_members WHERE user_id = ?').get(owner.id) as { invited_by: number | null }).invited_by).toBe(victim.id);
} finally {
deleteByIdSpy.mockRestore();
}
});
});