mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 05:26:47 +00:00
refactor(auth): AuthService and account cleanup through repositories
This commit is contained in:
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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),
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user