mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
refactor(admin): user CRUD, stats, audit log, version check and addon config through repositories
This commit is contained in:
@@ -47,4 +47,47 @@ export class AddonsRepository extends TrekRepository<Addons> {
|
||||
const rows = await this.find({ enabled: true }, { orderBy: { sort_order: 'asc' } });
|
||||
return rows.map((row) => toRow(row) as AddonRow);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Plan 3i Task 1 (`AdminService` — AD26/AD30/AD33/AD36/AD39, admin's
|
||||
// addon-shelf listing + config/enabled writes).
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* AD26 (`admin.service.ts#listAddons`) — `SELECT * FROM addons ORDER BY
|
||||
* sort_order, id`, UNFILTERED (unlike {@link listEnabled}'s `WHERE
|
||||
* enabled = 1`) — admin's own catalog listing, filtered afterward in JS
|
||||
* for managed-instance hiding.
|
||||
*/
|
||||
async listAllOrdered(): Promise<AddonRow[]> {
|
||||
const rows = await this.find({}, { orderBy: { sort_order: 'asc', id: 'asc' } });
|
||||
return rows.map((row) => toRow(row) as AddonRow);
|
||||
}
|
||||
|
||||
/** AD30/AD39 (`admin.service.ts#updateAddon`'s pre-write read and post-TX re-select, byte-identical text at both sites) — `SELECT * FROM addons WHERE id = ?`. */
|
||||
async findById(id: string): Promise<AddonRow | null> {
|
||||
const row = await this.findOne({ id });
|
||||
return row ? (toRow(row) as AddonRow) : null;
|
||||
}
|
||||
|
||||
/** AD33 (`admin.service.ts#updateAddon`, inside `uow.transactional`) — `UPDATE addons SET enabled = ? WHERE id = ?`. */
|
||||
async setEnabled(id: string, enabled: boolean): Promise<void> {
|
||||
await this.nativeUpdate({ id }, { enabled: !!enabled });
|
||||
}
|
||||
|
||||
/**
|
||||
* AD36 (`admin.service.ts#updateAddon`, inside `uow.transactional`) —
|
||||
* `UPDATE addons SET config = ? WHERE id = ?`. The LLM-parsing addon's API
|
||||
* key is already encrypted by `AdminService`
|
||||
* (`prepareLlmAddonConfigForWrite`) before this call — this repository
|
||||
* never touches the crypto, only persists whatever JSON blob it is
|
||||
* handed (3e R6's encrypted-column precedent: the service encrypts, the
|
||||
* repository passes the ciphertext through untouched). `config` is a
|
||||
* `p.json()` column — MikroORM serializes the object to the stored TEXT
|
||||
* column itself, the same JSON text the legacy statement's hand-called
|
||||
* `JSON.stringify` produced.
|
||||
*/
|
||||
async setConfig(id: string, config: AddonConfig): Promise<void> {
|
||||
await this.nativeUpdate({ id }, { config });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { DocumentProviders } from '../entities/DocumentProviders.entity';
|
||||
import { type AssertRowKeys } from './_shared/rows';
|
||||
import { toRow, type AssertRowKeys } from './_shared/rows';
|
||||
import { TrekRepository } from './_shared/trek-repository';
|
||||
|
||||
/** DSCTRL2's wider catalog row — id/name/description/icon, no `enabled`/`sort_order` (those only gate/order the read, they are never returned to the client). */
|
||||
@@ -69,4 +69,45 @@ export class DocumentProvidersRepository extends TrekRepository<DocumentProvider
|
||||
);
|
||||
return rows.map((r) => ({ id: r.id as string, name: r.name, description: r.description ?? null, icon: r.icon ?? null }));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Plan 3i Task 1 (`AdminService` — AD29/AD32/AD35/AD38, admin's
|
||||
// addon-shelf listing + `enabled` toggle — R6: this repository is 3h's
|
||||
// doc-sync task's to own; these four are additive, on top of its three
|
||||
// read shapes above, for admin's reuse).
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* AD29 (`admin.service.ts#listAddons`) — `SELECT id, name, description,
|
||||
* icon, enabled, sort_order FROM document_providers ORDER BY sort_order,
|
||||
* id`, UNFILTERED (unlike {@link listEnabledCatalog}'s `WHERE enabled =
|
||||
* 1`).
|
||||
*/
|
||||
async listAllOrdered(): Promise<DocumentProviderRow[]> {
|
||||
const rows = await this.find({}, { orderBy: { sort_order: 'asc', id: 'asc' } });
|
||||
return rows.map((row) => toRow(row) as DocumentProviderRow);
|
||||
}
|
||||
|
||||
/** AD32 (`admin.service.ts#updateAddon`'s pre-write read) — `SELECT * FROM document_providers WHERE id = ?`. */
|
||||
async findById(id: string): Promise<DocumentProviderRow | null> {
|
||||
const row = await this.findOne({ id });
|
||||
return row ? (toRow(row) as DocumentProviderRow) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* AD35 (`admin.service.ts#updateAddon`, inside `uow.transactional`, the
|
||||
* Documents-off cascade) — `UPDATE document_providers SET enabled = 0`,
|
||||
* deliberately UNSCOPED (every row, no WHERE). Named `disableAll`, not a
|
||||
* generic `setEnabled` overload that could be called with an
|
||||
* accidentally omitted id (R4), same reasoning as
|
||||
* `PhotoProvidersRepository.disableAll` (AD34).
|
||||
*/
|
||||
async disableAll(): Promise<void> {
|
||||
await this.nativeUpdate({}, { enabled: 0 });
|
||||
}
|
||||
|
||||
/** AD38 (`admin.service.ts#updateAddon`, inside `uow.transactional`) — `UPDATE document_providers SET enabled = ? WHERE id = ?`. */
|
||||
async setEnabled(id: string, enabled: number): Promise<void> {
|
||||
await this.nativeUpdate({ id }, { enabled });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,4 +37,48 @@ export class PhotoProviderFieldsRepository extends TrekRepository<PhotoProviderF
|
||||
const rows = await this.find({}, { orderBy: { sort_order: 'asc', id: 'asc' } });
|
||||
return rows.map((row) => toRow(row) as PhotoProviderFieldRow);
|
||||
}
|
||||
|
||||
/**
|
||||
* AD28 (`admin.service.ts#listAddons`) — `SELECT provider_id, field_key,
|
||||
* label, input_type, placeholder, required, secret, settings_key,
|
||||
* payload_key, sort_order FROM photo_provider_fields ORDER BY sort_order,
|
||||
* id`. A NARROWER projection than {@link listAllOrdered} — no `id`, no
|
||||
* `hint` — a genuinely different column set for a different caller, not a
|
||||
* superset reuse (Task 0's report: parity is column-exact here).
|
||||
*/
|
||||
async listAllOrderedForAdminShelf(): Promise<PhotoProviderFieldForAdminRow[]> {
|
||||
const rows = await this.find(
|
||||
{},
|
||||
{
|
||||
fields: ['provider_id', 'field_key', 'label', 'input_type', 'placeholder', 'required', 'secret', 'settings_key', 'payload_key', 'sort_order'],
|
||||
orderBy: { sort_order: 'asc', id: 'asc' },
|
||||
},
|
||||
);
|
||||
return rows.map((row) => ({
|
||||
provider_id: row.provider_id,
|
||||
field_key: row.field_key,
|
||||
label: row.label,
|
||||
input_type: row.input_type,
|
||||
placeholder: row.placeholder ?? null,
|
||||
required: row.required ?? null,
|
||||
secret: row.secret ?? null,
|
||||
settings_key: row.settings_key ?? null,
|
||||
payload_key: row.payload_key ?? null,
|
||||
sort_order: row.sort_order ?? null,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
/** AD28's own row shape — see {@link PhotoProviderFieldsRepository.listAllOrderedForAdminShelf}. */
|
||||
export interface PhotoProviderFieldForAdminRow {
|
||||
provider_id: string;
|
||||
field_key: string;
|
||||
label: string;
|
||||
input_type: string;
|
||||
placeholder: string | null;
|
||||
required: number | null;
|
||||
secret: number | null;
|
||||
settings_key: string | null;
|
||||
payload_key: string | null;
|
||||
sort_order: number | null;
|
||||
}
|
||||
|
||||
@@ -55,4 +55,41 @@ export class PhotoProvidersRepository extends TrekRepository<PhotoProviders> {
|
||||
const row = await this.findOne({ id }, { fields: ['enabled'] });
|
||||
return row ? { enabled: row.enabled ?? null } : null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Plan 3i Task 1 (`AdminService` — AD27/AD31/AD34/AD37/AD40, admin's
|
||||
// addon-shelf listing + enabled writes).
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* AD27 (`admin.service.ts#listAddons`) — `SELECT id, name, description,
|
||||
* icon, enabled, sort_order FROM photo_providers ORDER BY sort_order,
|
||||
* id`, UNFILTERED (unlike {@link listEnabled}'s `WHERE enabled = 1`).
|
||||
*/
|
||||
async listAllOrdered(): Promise<PhotoProviderRow[]> {
|
||||
const rows = await this.find({}, { orderBy: { sort_order: 'asc', id: 'asc' } });
|
||||
return rows.map((row) => toRow(row) as PhotoProviderRow);
|
||||
}
|
||||
|
||||
/** AD31/AD40 (`admin.service.ts#updateAddon`'s pre-write read and post-TX re-select, byte-identical text at both sites) — `SELECT * FROM photo_providers WHERE id = ?`. */
|
||||
async findById(id: string): Promise<PhotoProviderRow | null> {
|
||||
const row = await this.findOne({ id });
|
||||
return row ? (toRow(row) as PhotoProviderRow) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* AD34 (`admin.service.ts#updateAddon`, inside `uow.transactional`, the
|
||||
* Journey-off cascade — "Journey off takes its providers with it") —
|
||||
* `UPDATE photo_providers SET enabled = 0`, deliberately UNSCOPED (every
|
||||
* row, no WHERE). Named `disableAll`, not a generic `setEnabled` overload
|
||||
* that could be called with an accidentally omitted id (R4).
|
||||
*/
|
||||
async disableAll(): Promise<void> {
|
||||
await this.nativeUpdate({}, { enabled: 0 });
|
||||
}
|
||||
|
||||
/** AD37 (`admin.service.ts#updateAddon`, inside `uow.transactional`) — `UPDATE photo_providers SET enabled = ? WHERE id = ?`. `enabled` stays the raw stored int (this entity's column is `p.integer()`, not `p.boolean()` — see {@link listEnabled}'s docstring), matching the legacy `data.enabled ? 1 : 0` bind exactly. */
|
||||
async setEnabled(id: string, enabled: number): Promise<void> {
|
||||
await this.nativeUpdate({ id }, { enabled });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -191,6 +191,52 @@ export interface ResetTargetRow {
|
||||
password_version: number;
|
||||
}
|
||||
|
||||
/** AD1 (`admin.service.ts#listUsers`) — the admin user-list projection. */
|
||||
export interface AdminUserListRow {
|
||||
id: number;
|
||||
username: string;
|
||||
email: string;
|
||||
role: string;
|
||||
avatar: string | null;
|
||||
created_at: string | null;
|
||||
updated_at: string | null;
|
||||
last_login: string | null;
|
||||
}
|
||||
|
||||
/** AD5/AD14 (`admin.service.ts#createUser`/`updateUser`'s post-write re-select), byte-identical text at both sites. */
|
||||
export interface AdminUserSummaryRow {
|
||||
id: number;
|
||||
username: string;
|
||||
email: string;
|
||||
role: string;
|
||||
created_at: string | null;
|
||||
updated_at: string | null;
|
||||
}
|
||||
|
||||
/** AD16 (`admin.service.ts#resetUserMfa`'s pre-write read). */
|
||||
export interface AdminUserMfaRow {
|
||||
id: number;
|
||||
email: string;
|
||||
mfa_enabled: number | null;
|
||||
}
|
||||
|
||||
/** AD4 (`admin.service.ts#createUser`'s write) — narrower than {@link NewUserRow}: no `first_seen_version`/`login_count`/oidc/avatar columns named, letting the entity's own defaults (`'0.0.0'`/`0`) fill them, the same values the legacy INSERT's omitted columns fell back to via the schema's `DEFAULT`. */
|
||||
export interface NewAdminUserRow {
|
||||
username: string;
|
||||
email: string;
|
||||
password_hash: string;
|
||||
role: string;
|
||||
}
|
||||
|
||||
/** AD11 (`admin.service.ts#updateUser`, inside `uow.transactional`) — the columns the legacy COALESCE-shaped UPDATE can touch; a key the caller omits is left unchanged, the same net effect as binding NULL into `COALESCE(?, column)`. */
|
||||
export interface AdminEditPatch {
|
||||
username?: string;
|
||||
email?: string;
|
||||
role?: string;
|
||||
password_hash?: string;
|
||||
password_version?: number;
|
||||
}
|
||||
|
||||
export class UsersRepository extends TrekRepository<Users> {
|
||||
/**
|
||||
* **Ruling (Plan 3b Task 1 fix round, supersedes Plan 3a's I1 "`refresh:
|
||||
@@ -1329,6 +1375,138 @@ export class UsersRepository extends TrekRepository<Users> {
|
||||
async clearAirtrailApiKey(id: number): Promise<void> {
|
||||
await this.nativeUpdate({ id }, { airtrail_api_key: null });
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Plan 3i Task 1 (`AdminService` — AD1-AD17, admin's user-CRUD surface).
|
||||
// Every statement not already covered by an existing method above
|
||||
// (`findById` = AD6, `getRole` = AD9, `countAdmins` = AD10, `disableMfa`
|
||||
// = AD17, `findIdAndEmail` = AD15 — all reused as-is, no new method).
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* AD1 (`listUsers`) — `SELECT id, username, email, role, avatar,
|
||||
* created_at, updated_at, last_login FROM users WHERE COALESCE(is_guest,
|
||||
* 0) = 0 ORDER BY created_at DESC`. `is_guest: 0` is exact parity for the
|
||||
* COALESCE guard (same reasoning as `countNonGuest` above — the column is
|
||||
* `NOT NULL DEFAULT 0`).
|
||||
*/
|
||||
async listForAdmin(): Promise<AdminUserListRow[]> {
|
||||
const rows = await this.find(
|
||||
{ is_guest: 0 },
|
||||
{
|
||||
fields: ['id', 'username', 'email', 'role', 'avatar', 'created_at', 'updated_at', 'last_login'],
|
||||
orderBy: { created_at: 'desc' },
|
||||
},
|
||||
);
|
||||
return rows.map((row) => ({
|
||||
id: row.id,
|
||||
username: row.username,
|
||||
email: row.email,
|
||||
role: row.role,
|
||||
avatar: row.avatar ?? null,
|
||||
created_at: row.created_at ?? null,
|
||||
updated_at: row.updated_at ?? null,
|
||||
last_login: row.last_login ?? null,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* AD2 (`createUser`) — `SELECT id FROM users WHERE username = ? AND
|
||||
* COALESCE(is_guest, 0) = 0`. Case-**sensitive** (plain equality, no
|
||||
* `LOWER()`) — distinct from `findIdByUsernameCIAny`'s folded comparison,
|
||||
* which is a genuinely different legacy statement.
|
||||
*/
|
||||
async findIdByUsernameExact(username: string): Promise<number | null> {
|
||||
const row = await this.findOne({ username, is_guest: 0 }, { fields: ['id'] });
|
||||
return row?.id ?? null;
|
||||
}
|
||||
|
||||
/** AD3 (`createUser`) — `SELECT id FROM users WHERE email = ? AND COALESCE(is_guest, 0) = 0`. Case-sensitive, same reasoning as {@link findIdByUsernameExact}. */
|
||||
async findIdByEmailExact(email: string): Promise<number | null> {
|
||||
const row = await this.findOne({ email, is_guest: 0 }, { fields: ['id'] });
|
||||
return row?.id ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* AD4 (`createUser`'s write) — `INSERT INTO users (username, email,
|
||||
* password_hash, role) VALUES (?, ?, ?, ?)`. Only these four columns are
|
||||
* named, matching the legacy statement's own column list exactly; every
|
||||
* other column (`first_seen_version`, `login_count`, …) is left to the
|
||||
* entity's own default the same way the legacy INSERT left them to the
|
||||
* schema's `DEFAULT` — not reused from {@link insertUser} (AU10/O14),
|
||||
* whose `NewUserRow` requires `first_seen_version` as a caller-supplied
|
||||
* value, a column this statement never named at all. Returns the
|
||||
* generated id only (no re-select) — AD5 is a separate, distinct
|
||||
* statement handled by {@link findAdminSummary}.
|
||||
*/
|
||||
async insertAdminCreatedUser(row: NewAdminUserRow): Promise<number> {
|
||||
return await this.insert({
|
||||
username: row.username,
|
||||
email: row.email,
|
||||
password_hash: row.password_hash,
|
||||
role: row.role,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* AD5/AD14 (`createUser`'s post-insert re-select, `updateUser`'s
|
||||
* post-update re-select) — `SELECT id, username, email, role, created_at,
|
||||
* updated_at FROM users WHERE id = ?`, byte-identical text at both sites,
|
||||
* one method (D4).
|
||||
*/
|
||||
async findAdminSummary(id: number): Promise<AdminUserSummaryRow | null> {
|
||||
const row = await this.findOne({ id }, { fields: ['id', 'username', 'email', 'role', 'created_at', 'updated_at'] });
|
||||
return row
|
||||
? { id: row.id, username: row.username, email: row.email, role: row.role, created_at: row.created_at ?? null, updated_at: row.updated_at ?? null }
|
||||
: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* AD7 (`updateUser`) — `SELECT id FROM users WHERE username = ? AND id !=
|
||||
* ? AND COALESCE(is_guest, 0) = 0`. Case-**sensitive**, excludes the
|
||||
* user's own row — distinct from `findIdByUsernameCI`'s `LOWER()`-folded,
|
||||
* otherwise identically-shaped statement (UP5's own-username check).
|
||||
*/
|
||||
async findIdByUsernameExactExcluding(username: string, excludeId: number): Promise<number | null> {
|
||||
const row = await this.findOne({ username, id: { $ne: excludeId }, is_guest: 0 }, { fields: ['id'] });
|
||||
return row?.id ?? null;
|
||||
}
|
||||
|
||||
/** AD8 (`updateUser`) — `SELECT id FROM users WHERE email = ? AND id != ? AND COALESCE(is_guest, 0) = 0`. Case-sensitive, excludes self, same reasoning as {@link findIdByUsernameExactExcluding}. */
|
||||
async findIdByEmailExactExcluding(email: string, excludeId: number): Promise<number | null> {
|
||||
const row = await this.findOne({ email, id: { $ne: excludeId }, is_guest: 0 }, { fields: ['id'] });
|
||||
return row?.id ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* AD11 (`updateUser`, inside `uow.transactional` alongside AD12/AD13) —
|
||||
* `UPDATE users SET username=COALESCE(?,username), email=COALESCE(?,email),
|
||||
* role=COALESCE(?,role), password_hash=COALESCE(?,password_hash),
|
||||
* password_version=COALESCE(?,password_version), updated_at=CURRENT_TIMESTAMP
|
||||
* WHERE id=?`. The legacy binds a NULL parameter for "no change" — a
|
||||
* `nativeUpdate` that simply omits an unchanged key from `patch` has the
|
||||
* identical net effect (coalesceParam: the new value wins when the
|
||||
* caller supplies a key, the existing column wins when it is left out) —
|
||||
* the caller decides which keys to include, the same discipline
|
||||
* `patchProfile` (UP7) already uses for this table. `updated_at` is
|
||||
* ALWAYS stamped, even when `patch` is `{}` — matching the legacy
|
||||
* statement's own unconditional `updated_at = CURRENT_TIMESTAMP` (every
|
||||
* `updateUser` call runs this UPDATE regardless of what changed).
|
||||
*/
|
||||
async applyAdminEdit(id: number, patch: AdminEditPatch): Promise<void> {
|
||||
const platform = this.getEntityManager().getPlatform();
|
||||
await this.nativeUpdate({ id }, { ...patch, updated_at: currentTimestamp(platform) });
|
||||
}
|
||||
|
||||
/**
|
||||
* AD16 (`resetUserMfa`'s pre-write read) — `SELECT id, email, mfa_enabled
|
||||
* FROM users WHERE id = ?`. Distinct from `getMfaEnabled` (MP2/AU23/AU27),
|
||||
* which selects only `mfa_enabled` for a different caller.
|
||||
*/
|
||||
async findIdEmailMfaEnabled(id: number): Promise<AdminUserMfaRow | null> {
|
||||
const row = await this.findOne({ id }, { fields: ['id', 'email', 'mfa_enabled'] });
|
||||
return row ? { id: row.id, email: row.email, mfa_enabled: row.mfa_enabled ?? null } : null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { MikroOrmModule } from '@mikro-orm/nestjs';
|
||||
import { TokensModule } from '../tokens/tokens.module';
|
||||
import { OauthModule } from '../oauth/oauth.module';
|
||||
import { KitineraryExtractorModule } from '../booking-import/kitinerary-extractor.module';
|
||||
@@ -23,9 +24,35 @@ import { PackingModule } from '../packing/packing.module';
|
||||
// not @Global, so the import must be explicit.
|
||||
import { PermissionsModule } from '../permissions/permissions.module';
|
||||
import { AppConfigModule } from '../app-config/app-config.module';
|
||||
import { Users } from '../../db/entities/Users.entity';
|
||||
import { AuditLog } from '../../db/entities/AuditLog.entity';
|
||||
import { AppSettings } from '../../db/entities/AppSettings.entity';
|
||||
import { Addons } from '../../db/entities/Addons.entity';
|
||||
import { PhotoProviders } from '../../db/entities/PhotoProviders.entity';
|
||||
import { PhotoProviderFields } from '../../db/entities/PhotoProviderFields.entity';
|
||||
import { DocumentProviders } from '../../db/entities/DocumentProviders.entity';
|
||||
import { McpTokens } from '../../db/entities/McpTokens.entity';
|
||||
import { OauthTokens } from '../../db/entities/OauthTokens.entity';
|
||||
import { Trips } from '../../db/entities/Trips.entity';
|
||||
import { Places } from '../../db/entities/Places.entity';
|
||||
import { TripFiles } from '../../db/entities/TripFiles.entity';
|
||||
|
||||
/**
|
||||
* MikroOrmModule.forFeature registers the ten repositories `AdminService`'s
|
||||
* `@InjectRepository` constructor needs (Plan 3i Task 1 — AD1-AD40's
|
||||
* conversion off `DatabaseService`): `Users`/`AuditLog`/`AppSettings` for the
|
||||
* user-CRUD + audit-log + instance-settings surface, `Addons`/
|
||||
* `PhotoProviders`/`PhotoProviderFields`/`DocumentProviders` for the addon
|
||||
* shelf, `McpTokens`/`OauthTokens` for the password-reset session revoke, and
|
||||
* `Trips`/`Places`/`TripFiles` (owned by other domains, 3c/3e) for
|
||||
* `getStats`'s three bare cross-domain counts — the same forFeature +
|
||||
* @InjectRepository wiring pattern `AddonsModule` already uses.
|
||||
*/
|
||||
@Module({
|
||||
imports: [AppConfigModule, PluginsRuntimeModule, SettingsModule, AuditModule, AddonsModule, AuthModule, NotificationsModule, PackingModule, PermissionsModule, TokensModule, OauthModule, SchedulingModule, KitineraryExtractorModule],
|
||||
imports: [
|
||||
MikroOrmModule.forFeature([Users, AuditLog, AppSettings, Addons, PhotoProviders, PhotoProviderFields, DocumentProviders, McpTokens, OauthTokens, Trips, Places, TripFiles]),
|
||||
AppConfigModule, PluginsRuntimeModule, SettingsModule, AuditModule, AddonsModule, AuthModule, NotificationsModule, PackingModule, PermissionsModule, TokensModule, OauthModule, SchedulingModule, KitineraryExtractorModule,
|
||||
],
|
||||
controllers: [AdminController],
|
||||
providers: [AdminService, VersionCheckJob, DemoResetJob],
|
||||
})
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@mikro-orm/nestjs';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import crypto from 'crypto';
|
||||
import fs from 'fs';
|
||||
@@ -14,14 +15,12 @@ import { updateJwtSecret } from '../../config';
|
||||
import { revokeUserSessions, revokeUserSessionsForClient } from '../../mcp/sessionManager';
|
||||
import { invalidateMcpSessions } from '../../mcp';
|
||||
import { emitUserDeleted } from '../../plugin-user-lifecycle';
|
||||
import type { User, Addon } from '../../types';
|
||||
import { maybe_encrypt_api_key, decrypt_api_key } from '../common/crypto/apiKeyCrypto';
|
||||
import { avatarUrl } from '../common/avatarUrl';
|
||||
import { prepareLlmAddonConfigForWrite, maskLlmAddonConfig } from '../llm-parse/llm-config';
|
||||
import { getPhotoProviderConfig } from '../memories/memories.helpers';
|
||||
import { validatePassword } from '../common/passwordPolicy';
|
||||
import { UserCleanupService } from '../auth/user-cleanup.service';
|
||||
import { DatabaseService } from '../database/database.service';
|
||||
import { UnitOfWork } from '../database/unit-of-work';
|
||||
import { AddonsService } from '../addons/addons.service';
|
||||
import { RealtimeService } from '../realtime/realtime.service';
|
||||
@@ -30,6 +29,31 @@ import { AuthService } from '../auth/auth.service';
|
||||
import { PermissionsService } from '../permissions/permissions.service';
|
||||
import { PERMISSION_ACTIONS } from '../permissions/permissions.service';
|
||||
import { NotificationsService } from '../notifications/notifications.service';
|
||||
import { Users } from '../../db/entities/Users.entity';
|
||||
import type { UsersRepository, AdminEditPatch } from '../../db/repositories/Users.repository';
|
||||
import { AuditLog } from '../../db/entities/AuditLog.entity';
|
||||
import type { AuditLogRepository } from '../../db/repositories/AuditLog.repository';
|
||||
import { AppSettings } from '../../db/entities/AppSettings.entity';
|
||||
import type { AppSettingsRepository } from '../../db/repositories/AppSettings.repository';
|
||||
import { Addons } from '../../db/entities/Addons.entity';
|
||||
import type { AddonsRepository } from '../../db/repositories/Addons.repository';
|
||||
import { PhotoProviders } from '../../db/entities/PhotoProviders.entity';
|
||||
import type { PhotoProvidersRepository } from '../../db/repositories/PhotoProviders.repository';
|
||||
import { PhotoProviderFields } from '../../db/entities/PhotoProviderFields.entity';
|
||||
import type { PhotoProviderFieldsRepository } from '../../db/repositories/PhotoProviderFields.repository';
|
||||
import { DocumentProviders } from '../../db/entities/DocumentProviders.entity';
|
||||
import type { DocumentProvidersRepository } from '../../db/repositories/DocumentProviders.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 { Trips } from '../../db/entities/Trips.entity';
|
||||
import type { TripsRepository } from '../../db/repositories/Trips.repository';
|
||||
import { Places } from '../../db/entities/Places.entity';
|
||||
import type { PlacesRepository } from '../../db/repositories/Places.repository';
|
||||
import { TripFiles } from '../../db/entities/TripFiles.entity';
|
||||
import type { TripFilesRepository } from '../../db/repositories/TripFiles.repository';
|
||||
import type { AddonConfig } from '../../db/entities/Addons.entity';
|
||||
import {
|
||||
BCRYPT_COST,
|
||||
compareVersions,
|
||||
@@ -48,18 +72,25 @@ const GITHUB_MAX_BYTES = 2_000_000;
|
||||
const VERSION_FAILURE_TTL = 60_000;
|
||||
|
||||
/**
|
||||
* Admin domain service — owns the admin SQL (folded from the legacy
|
||||
* services/adminService.ts with the 2026-08 migration): user CRUD, instance
|
||||
* stats, the permission matrix, the audit-log read side, OIDC settings, the
|
||||
* demo baseline, GitHub release/version checks, invite tokens, the three
|
||||
* places feature toggles, addons + photo providers, MCP tokens, OAuth sessions
|
||||
* and JWT rotation.
|
||||
* Admin domain service — owns admin's data access (folded from the legacy
|
||||
* services/adminService.ts with the 2026-08 migration, converted onto
|
||||
* repositories by Plan 3i Task 1): user CRUD, instance stats, the permission
|
||||
* matrix, the audit-log read side, OIDC settings, the demo baseline, GitHub
|
||||
* release/version checks, invite tokens, the three places feature toggles,
|
||||
* addons + photo providers, MCP tokens, OAuth sessions and JWT rotation.
|
||||
*
|
||||
* Every quirk relocated byte-for-byte: the `||` falsy defaults (never `??`),
|
||||
* post-insert/post-update re-selects instead of RETURNING, the COALESCE partial
|
||||
* update, the #1362 guest exclusions and the exact error strings. The legacy
|
||||
* `{ error, status }` envelope is the return contract — the controller's `ok()`
|
||||
* helper turns it into an HttpException, so nothing here throws.
|
||||
* Every quirk stays byte-for-byte: the `||` falsy defaults (never `??`),
|
||||
* post-insert/post-update re-selects instead of RETURNING, the COALESCE-shaped
|
||||
* partial update (now a repository call that omits unchanged keys — the same
|
||||
* net effect), the #1362 guest exclusions and the exact error strings. The
|
||||
* legacy `{ error, status }` envelope is the return contract — the
|
||||
* controller's `ok()` helper turns it into an HttpException, so nothing here
|
||||
* throws.
|
||||
*
|
||||
* The three self-protection invariants (own-account delete, own-MFA reset,
|
||||
* last-admin demote) stay exactly where the legacy had them: plain JS
|
||||
* comparisons in this service, never folded into a repository method's WHERE
|
||||
* clause (R4).
|
||||
*
|
||||
* The bag-tracking/collab-feature toggles, user defaults, passkey reset and
|
||||
* packing templates delegate to the services that own those tables. The pure
|
||||
@@ -69,7 +100,18 @@ const VERSION_FAILURE_TTL = 60_000;
|
||||
@Injectable()
|
||||
export class AdminService {
|
||||
constructor(
|
||||
private readonly db: DatabaseService,
|
||||
@InjectRepository(Users) private readonly users: UsersRepository,
|
||||
@InjectRepository(AuditLog) private readonly auditLog: AuditLogRepository,
|
||||
@InjectRepository(AppSettings) private readonly appSettings: AppSettingsRepository,
|
||||
@InjectRepository(Addons) private readonly addonsRepo: AddonsRepository,
|
||||
@InjectRepository(PhotoProviders) private readonly photoProviders: PhotoProvidersRepository,
|
||||
@InjectRepository(PhotoProviderFields) private readonly photoProviderFields: PhotoProviderFieldsRepository,
|
||||
@InjectRepository(DocumentProviders) private readonly documentProviders: DocumentProvidersRepository,
|
||||
@InjectRepository(McpTokens) private readonly mcpTokens: McpTokensRepository,
|
||||
@InjectRepository(OauthTokens) private readonly oauthTokens: OauthTokensRepository,
|
||||
@InjectRepository(Trips) private readonly trips: TripsRepository,
|
||||
@InjectRepository(Places) private readonly places: PlacesRepository,
|
||||
@InjectRepository(TripFiles) private readonly tripFiles: TripFilesRepository,
|
||||
private readonly addons: AddonsService,
|
||||
private readonly passkeys: PasskeyService,
|
||||
private readonly auth: AuthService,
|
||||
@@ -85,13 +127,7 @@ export class AdminService {
|
||||
async listUsers() {
|
||||
// Guests (#1362) are accountless trip participants, not real users — keep them out
|
||||
// of admin user management entirely.
|
||||
const users = this.db.all<
|
||||
Pick<User, 'id' | 'username' | 'email' | 'role' | 'created_at' | 'updated_at' | 'last_login'> & {
|
||||
avatar?: string | null;
|
||||
}
|
||||
>(
|
||||
'SELECT id, username, email, role, avatar, created_at, updated_at, last_login FROM users WHERE COALESCE(is_guest, 0) = 0 ORDER BY created_at DESC',
|
||||
);
|
||||
const users = await this.users.listForAdmin();
|
||||
let onlineUserIds = new Set<number>();
|
||||
try {
|
||||
// The catch stays here rather than in the facade: an admin list that shows
|
||||
@@ -128,36 +164,33 @@ export class AdminService {
|
||||
}
|
||||
|
||||
// Guests (#1362) live in a reserved synthetic namespace; never let one block a real account.
|
||||
const existingUsername = this.db.get('SELECT id FROM users WHERE username = ? AND COALESCE(is_guest, 0) = 0', username);
|
||||
const existingUsername = await this.users.findIdByUsernameExact(username);
|
||||
if (existingUsername) return { error: 'Username already taken', status: 409 };
|
||||
|
||||
const existingEmail = this.db.get('SELECT id FROM users WHERE email = ? AND COALESCE(is_guest, 0) = 0', email);
|
||||
const existingEmail = await this.users.findIdByEmailExact(email);
|
||||
if (existingEmail) return { error: 'Email already taken', status: 409 };
|
||||
|
||||
const passwordHash = bcrypt.hashSync(password, BCRYPT_COST);
|
||||
|
||||
const result = this.db.run(
|
||||
'INSERT INTO users (username, email, password_hash, role) VALUES (?, ?, ?, ?)',
|
||||
username, email, passwordHash, data.role || 'user',
|
||||
);
|
||||
const insertedId = await this.users.insertAdminCreatedUser({
|
||||
username, email, password_hash: passwordHash, role: data.role || 'user',
|
||||
});
|
||||
|
||||
const user = this.db.get(
|
||||
'SELECT id, username, email, role, created_at, updated_at FROM users WHERE id = ?',
|
||||
result.lastInsertRowid,
|
||||
);
|
||||
const user = await this.users.findAdminSummary(insertedId);
|
||||
|
||||
return {
|
||||
user,
|
||||
insertedId: Number(result.lastInsertRowid),
|
||||
insertedId,
|
||||
auditDetails: { username, email, role: data.role || 'user' },
|
||||
};
|
||||
}
|
||||
|
||||
async updateUser(id: string, data: { username?: string; email?: string; role?: string; password?: string }) {
|
||||
const userId = Number(id);
|
||||
const username = typeof data.username === 'string' ? data.username.trim() : data.username;
|
||||
const email = typeof data.email === 'string' ? data.email.trim() : data.email;
|
||||
const { role, password } = data;
|
||||
const user = this.db.get<User>('SELECT * FROM users WHERE id = ?', id);
|
||||
const user = await this.users.findById(userId);
|
||||
|
||||
if (!user) return { error: 'User not found', status: 404 };
|
||||
|
||||
@@ -171,11 +204,11 @@ export class AdminService {
|
||||
if (email === '') return { error: 'Email cannot be empty', status: 400 };
|
||||
|
||||
if (username && username !== user.username) {
|
||||
const conflict = this.db.get('SELECT id FROM users WHERE username = ? AND id != ? AND COALESCE(is_guest, 0) = 0', username, id);
|
||||
const conflict = await this.users.findIdByUsernameExactExcluding(username, userId);
|
||||
if (conflict) return { error: 'Username already taken', status: 409 };
|
||||
}
|
||||
if (email && email !== user.email) {
|
||||
const conflict = this.db.get('SELECT id FROM users WHERE email = ? AND id != ? AND COALESCE(is_guest, 0) = 0', email, id);
|
||||
const conflict = await this.users.findIdByEmailExactExcluding(email, userId);
|
||||
if (conflict) return { error: 'Email already taken', status: 409 };
|
||||
}
|
||||
|
||||
@@ -187,10 +220,13 @@ export class AdminService {
|
||||
|
||||
// Don't let the admin UI demote the last remaining admin — that would leave the
|
||||
// instance with no one able to manage it (and on OIDC-only setups, no recovery). #1274
|
||||
// SECURITY: plain JS comparisons, deliberately never folded into a
|
||||
// repository method's WHERE clause (R4) — two repository reads, one
|
||||
// service-level `if`.
|
||||
if (role && role !== 'admin') {
|
||||
const current = this.db.get<{ role?: string }>('SELECT role FROM users WHERE id = ?', id);
|
||||
if (current?.role === 'admin') {
|
||||
const adminCount = this.db.get<{ count: number }>("SELECT COUNT(*) as count FROM users WHERE role = 'admin'")!.count;
|
||||
const currentRole = await this.users.getRole(userId);
|
||||
if (currentRole === 'admin') {
|
||||
const adminCount = await this.users.countAdmins();
|
||||
if (adminCount <= 1) return { error: 'Cannot remove the last admin', status: 400 };
|
||||
}
|
||||
}
|
||||
@@ -200,33 +236,31 @@ export class AdminService {
|
||||
// accepting every cookie the intruder already holds, so the one action taken
|
||||
// to lock them out was the one action that did not. Both self-service paths
|
||||
// (changePassword, resetPassword) have always done this; this one had not.
|
||||
const newPv = password ? ((user as User & { password_version?: number }).password_version ?? 0) + 1 : null;
|
||||
const newPv = password ? (user.password_version ?? 0) + 1 : null;
|
||||
|
||||
const patch: AdminEditPatch = {};
|
||||
if (username) patch.username = username;
|
||||
if (email) patch.email = email;
|
||||
if (role) patch.role = role;
|
||||
if (password) {
|
||||
patch.password_hash = passwordHash!;
|
||||
patch.password_version = newPv!;
|
||||
}
|
||||
|
||||
// The password-reset transaction's CURRENT boundary, preserved exactly
|
||||
// (R4): the users UPDATE, the mcp_tokens DELETE and the oauth_tokens
|
||||
// revoke stay inside the SAME uow.transactional call, never split across
|
||||
// separate un-transacted repository calls.
|
||||
await this.uow.transactional(async () => {
|
||||
this.db.run(
|
||||
`
|
||||
UPDATE users SET
|
||||
username = COALESCE(?, username),
|
||||
email = COALESCE(?, email),
|
||||
role = COALESCE(?, role),
|
||||
password_hash = COALESCE(?, password_hash),
|
||||
password_version = COALESCE(?, password_version),
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?
|
||||
`,
|
||||
username || null, email || null, role || null, passwordHash, newPv, id,
|
||||
);
|
||||
await this.users.applyAdminEdit(userId, patch);
|
||||
|
||||
if (password) {
|
||||
// The version bump only invalidates JWT cookies. These two stores carry
|
||||
// their own credentials and are revoked separately, exactly as the
|
||||
// self-service paths do it.
|
||||
this.db.run('DELETE FROM mcp_tokens WHERE user_id = ?', id);
|
||||
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',
|
||||
id,
|
||||
);
|
||||
await this.oauthTokens.revokeAllForUser(userId);
|
||||
} catch { /* very old installs predate oauth_tokens */ }
|
||||
}
|
||||
});
|
||||
@@ -235,7 +269,7 @@ export class AdminService {
|
||||
try { revokeUserSessions(Number(id)); } catch { /* best-effort, same as elsewhere */ }
|
||||
}
|
||||
|
||||
const updated = this.db.get('SELECT id, username, email, role, created_at, updated_at FROM users WHERE id = ?', id);
|
||||
const updated = await this.users.findAdminSummary(userId);
|
||||
|
||||
const changed: string[] = [];
|
||||
if (username) changed.push('username');
|
||||
@@ -251,11 +285,13 @@ export class AdminService {
|
||||
}
|
||||
|
||||
async deleteUser(id: string, currentUserId: number) {
|
||||
// SECURITY: plain JS comparison, deliberately kept exactly here — never
|
||||
// folded into a repository method's WHERE clause (R4).
|
||||
if (Number.parseInt(id) === currentUserId) {
|
||||
return { error: 'Cannot delete own account', status: 400 };
|
||||
}
|
||||
|
||||
const userToDel = this.db.get<{ id: number; email: string }>('SELECT id, email FROM users WHERE id = ?', id);
|
||||
const userToDel = await this.users.findIdAndEmail(Number(id));
|
||||
if (!userToDel) return { error: 'User not found', status: 404 };
|
||||
|
||||
await this.userCleanup.deleteUserCompletely(userToDel.id);
|
||||
@@ -280,21 +316,17 @@ export class AdminService {
|
||||
*/
|
||||
async resetUserMfa(id: string, actingUserId: number): Promise<{ error?: string; status?: number; success?: boolean; email?: string }> {
|
||||
const targetId = Number(id);
|
||||
// SECURITY: plain JS comparison, deliberately kept exactly here — never
|
||||
// folded into a repository method's WHERE clause (R4).
|
||||
if (targetId === actingUserId) {
|
||||
return { error: 'Use Settings to change your own two-factor setup', status: 400 };
|
||||
}
|
||||
const target = this.db.get<{ id: number; email: string; mfa_enabled: number | boolean }>(
|
||||
'SELECT id, email, mfa_enabled FROM users WHERE id = ?',
|
||||
targetId,
|
||||
);
|
||||
const target = await this.users.findIdEmailMfaEnabled(targetId);
|
||||
if (!target) return { error: 'User not found', status: 404 };
|
||||
|
||||
// Same three columns disableMfa clears, so an admin reset and a self-service
|
||||
// disable leave the account in exactly one state rather than two.
|
||||
this.db.run(
|
||||
'UPDATE users SET mfa_enabled = 0, mfa_secret = NULL, mfa_backup_codes = NULL, updated_at = CURRENT_TIMESTAMP WHERE id = ?',
|
||||
targetId,
|
||||
);
|
||||
await this.users.disableMfa(targetId);
|
||||
|
||||
return { success: true, email: target.email };
|
||||
}
|
||||
@@ -302,10 +334,10 @@ export class AdminService {
|
||||
// ── Stats ──────────────────────────────────────────────────────────────────
|
||||
|
||||
async getStats() {
|
||||
const totalUsers = this.db.get<{ count: number }>('SELECT COUNT(*) as count FROM users WHERE COALESCE(is_guest, 0) = 0')!.count;
|
||||
const totalTrips = this.db.get<{ count: number }>('SELECT COUNT(*) as count FROM trips')!.count;
|
||||
const totalPlaces = this.db.get<{ count: number }>('SELECT COUNT(*) as count FROM places')!.count;
|
||||
const totalFiles = this.db.get<{ count: number }>('SELECT COUNT(*) as count FROM trip_files')!.count;
|
||||
const totalUsers = await this.users.countNonGuest();
|
||||
const totalTrips = await this.trips.count();
|
||||
const totalPlaces = await this.places.count();
|
||||
const totalFiles = await this.tripFiles.count();
|
||||
return { totalUsers, totalTrips, totalPlaces, totalFiles };
|
||||
}
|
||||
|
||||
@@ -335,30 +367,8 @@ export class AdminService {
|
||||
const limit = Math.min(Math.max(Number.isFinite(limitRaw) ? limitRaw : 100, 1), 500);
|
||||
const offset = Math.max(Number.isFinite(offsetRaw) ? offsetRaw : 0, 0);
|
||||
|
||||
type Row = {
|
||||
id: number;
|
||||
created_at: string;
|
||||
user_id: number | null;
|
||||
username: string | null;
|
||||
user_email: string | null;
|
||||
action: string;
|
||||
resource: string | null;
|
||||
details: string | null;
|
||||
ip: string | null;
|
||||
};
|
||||
|
||||
const rows = this.db.all<Row>(
|
||||
`
|
||||
SELECT a.id, a.created_at, a.user_id, u.username, u.email as user_email, a.action, a.resource, a.details, a.ip
|
||||
FROM audit_log a
|
||||
LEFT JOIN users u ON u.id = a.user_id
|
||||
ORDER BY a.id DESC
|
||||
LIMIT ? OFFSET ?
|
||||
`,
|
||||
limit, offset,
|
||||
);
|
||||
|
||||
const total = this.db.get<{ c: number }>('SELECT COUNT(*) as c FROM audit_log')!.c;
|
||||
const rows = await this.auditLog.listPage(limit, offset);
|
||||
const total = await this.auditLog.count();
|
||||
|
||||
const entries = rows.map((r) => {
|
||||
// Unparseable details fall back to the raw string rather than the old
|
||||
@@ -516,15 +526,14 @@ export class AdminService {
|
||||
const result = await this.checkVersion();
|
||||
if (!result.update_available) return;
|
||||
|
||||
const lastNotified = this.db.get<{ value: string }>(
|
||||
'SELECT value FROM app_settings WHERE key = ?', 'last_notified_version',
|
||||
)?.value;
|
||||
const lastNotified = await this.appSettings.getValue('last_notified_version');
|
||||
if (lastNotified === result.latest) return;
|
||||
|
||||
this.db.run(
|
||||
'INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)',
|
||||
'last_notified_version', result.latest,
|
||||
);
|
||||
// INSERT OR REPLACE shape — the SAME dialect storage's `upsertOrReplace`
|
||||
// (Plan 3i Task 2, SS1) already added to AppSettingsRepository, reused
|
||||
// here rather than adding a second method for the identical SQL text
|
||||
// (`INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)`).
|
||||
await this.appSettings.upsertOrReplace('last_notified_version', result.latest);
|
||||
|
||||
await this.notifications.send({
|
||||
event: 'version_available',
|
||||
@@ -541,8 +550,7 @@ export class AdminService {
|
||||
// ── Addons ─────────────────────────────────────────────────────────────────
|
||||
|
||||
async listAddons() {
|
||||
const addons = this.db
|
||||
.all<Addon>('SELECT * FROM addons ORDER BY sort_order, id')
|
||||
const addons = (await this.addonsRepo.listAllOrdered())
|
||||
// Hidden rather than shown-and-refused, because a toggle that answers 403
|
||||
// is worse than no toggle.
|
||||
//
|
||||
@@ -561,38 +569,12 @@ export class AdminService {
|
||||
a.id === ADDON_IDS.DAWARICH)
|
||||
),
|
||||
);
|
||||
const providers = this.db.all<{
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string | null;
|
||||
icon: string;
|
||||
enabled: number;
|
||||
sort_order: number;
|
||||
}>(`
|
||||
SELECT id, name, description, icon, enabled, sort_order
|
||||
FROM photo_providers
|
||||
ORDER BY sort_order, id
|
||||
`)
|
||||
const providers = (await this.photoProviders.listAllOrdered())
|
||||
// Immich and Synology Photos are servers the admin runs at home. A managed
|
||||
// instance cannot reach one (its egress does not go there, and it should
|
||||
// not), so offering the connection would only produce a timeout.
|
||||
.filter(() => !readEnv().managed.enabled);
|
||||
const fields = this.db.all<{
|
||||
provider_id: string;
|
||||
field_key: string;
|
||||
label: string;
|
||||
input_type: string;
|
||||
placeholder?: string | null;
|
||||
required: number;
|
||||
secret: number;
|
||||
settings_key?: string | null;
|
||||
payload_key?: string | null;
|
||||
sort_order: number;
|
||||
}>(`
|
||||
SELECT provider_id, field_key, label, input_type, placeholder, required, secret, settings_key, payload_key, sort_order
|
||||
FROM photo_provider_fields
|
||||
ORDER BY sort_order, id
|
||||
`);
|
||||
const fields = await this.photoProviderFields.listAllOrderedForAdminShelf();
|
||||
const fieldsByProvider = new Map<string, typeof fields>();
|
||||
for (const field of fields) {
|
||||
const arr = fieldsByProvider.get(field.provider_id) || [];
|
||||
@@ -609,18 +591,8 @@ export class AdminService {
|
||||
// not belong to a user here but to a trip, so they are entered in the trip
|
||||
// rather than in settings. The admin decides only whether a provider may be
|
||||
// offered at all.
|
||||
const docProviders = this.db.all<{
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string | null;
|
||||
icon: string;
|
||||
enabled: number;
|
||||
sort_order: number;
|
||||
}>(`
|
||||
SELECT id, name, description, icon, enabled, sort_order
|
||||
FROM document_providers
|
||||
ORDER BY sort_order, id
|
||||
`).filter(() => !readEnv().managed.enabled);
|
||||
const docProviders = (await this.documentProviders.listAllOrdered())
|
||||
.filter(() => !readEnv().managed.enabled);
|
||||
|
||||
return [
|
||||
...addons.map((a) => ({
|
||||
@@ -628,8 +600,8 @@ export class AdminService {
|
||||
enabled: !!a.enabled,
|
||||
config:
|
||||
a.id === ADDON_IDS.LLM_PARSING
|
||||
? maskLlmAddonConfig(JSON.parse(a.config || '{}'))
|
||||
: JSON.parse(a.config || '{}'),
|
||||
? maskLlmAddonConfig(a.config ?? {})
|
||||
: (a.config ?? {}),
|
||||
})),
|
||||
...providers.map((p) => ({
|
||||
id: p.id,
|
||||
@@ -638,8 +610,8 @@ export class AdminService {
|
||||
type: 'photo_provider',
|
||||
icon: p.icon,
|
||||
enabled: !!p.enabled,
|
||||
config: getPhotoProviderConfig(p.id),
|
||||
fields: (fieldsByProvider.get(p.id) || []).map((f) => ({
|
||||
config: getPhotoProviderConfig(p.id!),
|
||||
fields: (fieldsByProvider.get(p.id!) || []).map((f) => ({
|
||||
key: f.field_key,
|
||||
label: f.label,
|
||||
input_type: f.input_type,
|
||||
@@ -667,10 +639,9 @@ export class AdminService {
|
||||
}
|
||||
|
||||
async updateAddon(id: string, data: { enabled?: boolean; config?: Record<string, unknown> }) {
|
||||
type ProviderRow = { id: string; name: string; description?: string | null; icon: string; enabled: number; sort_order: number };
|
||||
const addon = this.db.get<Addon>('SELECT * FROM addons WHERE id = ?', id);
|
||||
const provider = this.db.get<ProviderRow>('SELECT * FROM photo_providers WHERE id = ?', id);
|
||||
const docProvider = this.db.get<ProviderRow>('SELECT * FROM document_providers WHERE id = ?', id);
|
||||
const addon = await this.addonsRepo.findById(id);
|
||||
const provider = await this.photoProviders.findById(id);
|
||||
const docProvider = await this.documentProviders.findById(id);
|
||||
if (!addon && !provider && !docProvider) return { error: 'Addon not found', status: 404 };
|
||||
|
||||
// The whole addon, not just its config: on a centrally administered install
|
||||
@@ -696,46 +667,46 @@ export class AdminService {
|
||||
}
|
||||
|
||||
await this.uow.transactional(async () => {
|
||||
if (addon) {
|
||||
if (data.enabled !== undefined) {
|
||||
this.db.run('UPDATE addons SET enabled = ? WHERE id = ?', data.enabled ? 1 : 0, id);
|
||||
// Journey off takes its providers with it: a row left enabled would
|
||||
// resurface the moment journey returns, which nobody switched on.
|
||||
if (id === ADDON_IDS.JOURNEY && !data.enabled)
|
||||
this.db.run('UPDATE photo_providers SET enabled = 0');
|
||||
// Documents off takes its providers with it, for the reason above: a
|
||||
// row left enabled would resurface the moment the addon returns.
|
||||
if (id === ADDON_IDS.DOCUMENTS && !data.enabled)
|
||||
this.db.run('UPDATE document_providers SET enabled = 0');
|
||||
if (addon) {
|
||||
if (data.enabled !== undefined) {
|
||||
await this.addonsRepo.setEnabled(id, !!data.enabled);
|
||||
// Journey off takes its providers with it: a row left enabled would
|
||||
// resurface the moment journey returns, which nobody switched on.
|
||||
if (id === ADDON_IDS.JOURNEY && !data.enabled)
|
||||
await this.photoProviders.disableAll();
|
||||
// Documents off takes its providers with it, for the reason above: a
|
||||
// row left enabled would resurface the moment the addon returns.
|
||||
if (id === ADDON_IDS.DOCUMENTS && !data.enabled)
|
||||
await this.documentProviders.disableAll();
|
||||
}
|
||||
if (data.config !== undefined) {
|
||||
// The AI-parsing addon holds an API key — encrypt it at rest and preserve
|
||||
// the stored key when the client echoes the mask sentinel (see llmConfig.ts).
|
||||
const configToStore =
|
||||
id === ADDON_IDS.LLM_PARSING
|
||||
? prepareLlmAddonConfigForWrite(data.config, addon.config ?? {})
|
||||
: data.config;
|
||||
await this.addonsRepo.setConfig(id, configToStore as AddonConfig);
|
||||
}
|
||||
} else if (provider) {
|
||||
if (data.enabled !== undefined)
|
||||
await this.photoProviders.setEnabled(id, data.enabled ? 1 : 0);
|
||||
} else {
|
||||
if (data.enabled !== undefined)
|
||||
await this.documentProviders.setEnabled(id, data.enabled ? 1 : 0);
|
||||
}
|
||||
if (data.config !== undefined) {
|
||||
// The AI-parsing addon holds an API key — encrypt it at rest and preserve
|
||||
// the stored key when the client echoes the mask sentinel (see llmConfig.ts).
|
||||
const configToStore =
|
||||
id === ADDON_IDS.LLM_PARSING
|
||||
? prepareLlmAddonConfigForWrite(data.config, JSON.parse(addon.config || '{}'))
|
||||
: data.config;
|
||||
this.db.run('UPDATE addons SET config = ? WHERE id = ?', JSON.stringify(configToStore), id);
|
||||
}
|
||||
} else if (provider) {
|
||||
if (data.enabled !== undefined)
|
||||
this.db.run('UPDATE photo_providers SET enabled = ? WHERE id = ?', data.enabled ? 1 : 0, id);
|
||||
} else {
|
||||
if (data.enabled !== undefined)
|
||||
this.db.run('UPDATE document_providers SET enabled = ? WHERE id = ?', data.enabled ? 1 : 0, id);
|
||||
}
|
||||
});
|
||||
|
||||
const updatedAddon = this.db.get<Addon>('SELECT * FROM addons WHERE id = ?', id);
|
||||
const updatedProvider = this.db.get<ProviderRow>('SELECT * FROM photo_providers WHERE id = ?', id);
|
||||
const updatedAddon = await this.addonsRepo.findById(id);
|
||||
const updatedProvider = await this.photoProviders.findById(id);
|
||||
const updated = updatedAddon
|
||||
? {
|
||||
...updatedAddon,
|
||||
enabled: !!updatedAddon.enabled,
|
||||
config:
|
||||
updatedAddon.id === ADDON_IDS.LLM_PARSING
|
||||
? maskLlmAddonConfig(JSON.parse(updatedAddon.config || '{}'))
|
||||
: JSON.parse(updatedAddon.config || '{}'),
|
||||
? maskLlmAddonConfig(updatedAddon.config ?? {})
|
||||
: (updatedAddon.config ?? {}),
|
||||
}
|
||||
: updatedProvider
|
||||
? {
|
||||
@@ -745,7 +716,7 @@ export class AdminService {
|
||||
type: 'photo_provider',
|
||||
icon: updatedProvider.icon,
|
||||
enabled: !!updatedProvider.enabled,
|
||||
config: getPhotoProviderConfig(updatedProvider.id),
|
||||
config: getPhotoProviderConfig(updatedProvider.id!),
|
||||
sort_order: updatedProvider.sort_order,
|
||||
}
|
||||
: null;
|
||||
@@ -761,7 +732,11 @@ export class AdminService {
|
||||
// after an admin switched them off, while the REST half answered 404 for the
|
||||
// same user in the same moment.
|
||||
const MCP_RELEVANT_ADDONS = new Set<string>(MCP_GATED_ADDON_IDS);
|
||||
const enabledChanged = !!addon && data.enabled !== undefined && (data.enabled ? 1 : 0) !== addon.enabled;
|
||||
// `addon.enabled` is a JS boolean now (the repository's `AddonRow.enabled`
|
||||
// shape, not the raw stored int the legacy `SELECT *` returned) — comparing
|
||||
// two booleans for a real flip is the same predicate the legacy int
|
||||
// comparison (`(data.enabled ? 1 : 0) !== addon.enabled`) expressed.
|
||||
const enabledChanged = !!addon && data.enabled !== undefined && !!data.enabled !== addon.enabled;
|
||||
|
||||
return {
|
||||
addon: updated,
|
||||
|
||||
@@ -22,12 +22,25 @@ const { db } = vi.hoisted(() => {
|
||||
const tmp = new Database(':memory:');
|
||||
tmp.exec('PRAGMA journal_mode = WAL');
|
||||
// `users` carries the columns listUsers/createUser/updateUser select, plus the
|
||||
// is_guest flag the #1362 COALESCE guards read.
|
||||
// is_guest flag the #1362 COALESCE guards read. Plan 3i Task 1 (admin's
|
||||
// repository conversion) surfaced the SAME class of drift the
|
||||
// `invite_tokens`/`used_count` comment below already documents: `UsersRepository
|
||||
// .insertAdminCreatedUser` writes through entity metadata (MikroORM's native
|
||||
// insert), which — unlike the legacy raw `INSERT INTO users (username, email,
|
||||
// password_hash, role) VALUES (...)` — also applies every OTHER column's
|
||||
// class-level JS default (`mfa_enabled = 0`, `first_seen_version = '0.0.0'`, …),
|
||||
// even though this repository's own method never names them. Fixed at the
|
||||
// source (the fixture, adding the columns the real migrated schema has), not
|
||||
// worked around in the repository.
|
||||
tmp.exec(`CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT NOT NULL,
|
||||
email TEXT NOT NULL UNIQUE, role TEXT NOT NULL DEFAULT 'user', password_version INTEGER NOT NULL DEFAULT 0,
|
||||
password_hash TEXT, avatar TEXT, is_guest INTEGER DEFAULT 0,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
last_login DATETIME);`);
|
||||
last_login DATETIME, mfa_enabled INTEGER DEFAULT 0, mfa_secret TEXT, mfa_backup_codes TEXT,
|
||||
must_change_password INTEGER DEFAULT 0, synology_skip_ssl INTEGER DEFAULT 0,
|
||||
first_seen_version TEXT DEFAULT '0.0.0', login_count INTEGER DEFAULT 0,
|
||||
immich_auto_upload INTEGER DEFAULT 0, airtrail_allow_insecure_tls INTEGER DEFAULT 0,
|
||||
airtrail_write_enabled INTEGER DEFAULT 0);`);
|
||||
tmp.exec(`CREATE TABLE settings (id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL,
|
||||
key TEXT NOT NULL, value TEXT, UNIQUE(user_id, key));`);
|
||||
tmp.exec('CREATE TABLE app_settings (key TEXT PRIMARY KEY, value TEXT);');
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* AD36 (`admin.service.ts#updateAddon`, the LLM-parsing addon's encrypted
|
||||
* API key) — Plan 3i Task 1's encryption-boundary proof. Deliberately does
|
||||
* NOT mock `apiKeyCrypto` (unlike `admin.service.test.ts`, which stubs it
|
||||
* to identity functions) — this file exercises the REAL
|
||||
* `maybe_encrypt_api_key`/`decrypt_api_key` so the round trip is genuine,
|
||||
* not a no-op through a mock. `ENCRYPTION_KEY` comes from
|
||||
* `tests/global-setup.ts`.
|
||||
*/
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { createSnapshotTestDb } from '../../../helpers/db-mock';
|
||||
import { resetTestDb } from '../../../helpers/test-db';
|
||||
import { createTestOrm, type TestOrm } from '../../../helpers/test-orm';
|
||||
import { Addons } from '../../../../src/db/entities/Addons.entity';
|
||||
import type { AddonsRepository } from '../../../../src/db/repositories/Addons.repository';
|
||||
import { prepareLlmAddonConfigForWrite } from '../../../../src/nest/llm-parse/llm-config';
|
||||
import { decrypt_api_key } from '../../../../src/nest/common/crypto/apiKeyCrypto';
|
||||
|
||||
const testDb = createSnapshotTestDb();
|
||||
let t: TestOrm;
|
||||
let addons: AddonsRepository;
|
||||
|
||||
beforeAll(async () => {
|
||||
t = await createTestOrm(testDb);
|
||||
addons = t.repo(Addons);
|
||||
});
|
||||
beforeEach(() => {
|
||||
resetTestDb(testDb);
|
||||
testDb.exec('DELETE FROM addons');
|
||||
testDb
|
||||
.prepare(`INSERT INTO addons (id, name, description, type, icon, enabled, sort_order) VALUES ('llm_parsing', 'AI Parsing', NULL, 'global', 'Sparkles', 0, 0)`)
|
||||
.run();
|
||||
t.clear();
|
||||
});
|
||||
afterAll(async () => {
|
||||
await t.close();
|
||||
testDb.close();
|
||||
});
|
||||
|
||||
describe('AddonsRepository.setConfig — AD36 encryption boundary', () => {
|
||||
it('ADDONSREPO-010: the stored apiKey is never the plaintext, but decrypt_api_key always round-trips it back', async () => {
|
||||
const plaintext = 'sk-live-abc123-a-real-looking-secret';
|
||||
// Mirrors admin.service.ts#updateAddon's own call: the SERVICE encrypts
|
||||
// before handing the repository a config object — the repository never
|
||||
// calls maybe_encrypt_api_key/decrypt_api_key itself.
|
||||
const configToStore = prepareLlmAddonConfigForWrite({ provider: 'openai', apiKey: plaintext }, undefined);
|
||||
|
||||
await addons.setConfig('llm_parsing', configToStore as { apiKey?: string; [k: string]: unknown });
|
||||
|
||||
const row = await addons.findById('llm_parsing');
|
||||
const storedApiKey = (row?.config as { apiKey?: string } | null)?.apiKey;
|
||||
|
||||
expect(storedApiKey).toBeDefined();
|
||||
// Never assert a literal ciphertext string (the IV is random on every
|
||||
// encrypt call, so the exact bytes are non-deterministic) — only that it
|
||||
// differs from the plaintext and that the real decrypt function recovers it.
|
||||
expect(storedApiKey).not.toBe(plaintext);
|
||||
expect(decrypt_api_key(storedApiKey)).toBe(plaintext);
|
||||
});
|
||||
|
||||
it('ADDONSREPO-011: a second write of the SAME plaintext produces a different stored ciphertext (random IV), both still decrypting to the same plaintext', async () => {
|
||||
const plaintext = 'sk-live-same-secret-both-times';
|
||||
const first = prepareLlmAddonConfigForWrite({ apiKey: plaintext }, undefined);
|
||||
await addons.setConfig('llm_parsing', first as { apiKey?: string; [k: string]: unknown });
|
||||
const firstStored = ((await addons.findById('llm_parsing'))?.config as { apiKey?: string } | null)?.apiKey;
|
||||
|
||||
const second = prepareLlmAddonConfigForWrite({ apiKey: plaintext }, undefined);
|
||||
await addons.setConfig('llm_parsing', second as { apiKey?: string; [k: string]: unknown });
|
||||
const secondStored = ((await addons.findById('llm_parsing'))?.config as { apiKey?: string } | null)?.apiKey;
|
||||
|
||||
expect(firstStored).not.toBe(secondStored);
|
||||
expect(decrypt_api_key(firstStored)).toBe(plaintext);
|
||||
expect(decrypt_api_key(secondStored)).toBe(plaintext);
|
||||
});
|
||||
});
|
||||
@@ -95,7 +95,17 @@ import {
|
||||
createTestTripsRepo,
|
||||
createTestTripMembersRepo,
|
||||
createTestSettingsRepo,
|
||||
createTestPlacesRepo,
|
||||
sharedTestOrm,
|
||||
} from '../../helpers/test-uow';
|
||||
import { AuditLog } from '../../../src/db/entities/AuditLog.entity';
|
||||
import type { AuditLogRepository } from '../../../src/db/repositories/AuditLog.repository';
|
||||
import { Addons } from '../../../src/db/entities/Addons.entity';
|
||||
import { PhotoProviders } from '../../../src/db/entities/PhotoProviders.entity';
|
||||
import { PhotoProviderFields } from '../../../src/db/entities/PhotoProviderFields.entity';
|
||||
import { DocumentProviders } from '../../../src/db/entities/DocumentProviders.entity';
|
||||
import { TripFiles } from '../../../src/db/entities/TripFiles.entity';
|
||||
import type { McpTokensRepository } from '../../../src/db/repositories/McpTokens.repository';
|
||||
import { budgetRepoArgs } from '../../helpers/budget-repos';
|
||||
import { createTestShareTokensRepo } from '../../helpers/share-repos';
|
||||
import { createTestBudgetItemsRepo } from '../../helpers/files-repos';
|
||||
@@ -116,6 +126,8 @@ let permissions: PermissionsService;
|
||||
let userCleanup: UserCleanupService;
|
||||
let auth: AuthService;
|
||||
let svc: AdminService;
|
||||
let mcpTokensRepo: McpTokensRepository;
|
||||
let auditLogRepo: AuditLogRepository;
|
||||
beforeAll(async () => {
|
||||
webauthn = new WebauthnConfigService(await createTestAppSettingsRepo(dbs.connection));
|
||||
permissions = new PermissionsService(await createTestAppSettingsRepo(dbs.connection), await createTestUnitOfWork(dbs.connection));
|
||||
@@ -125,8 +137,22 @@ beforeAll(async () => {
|
||||
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),
|
||||
);
|
||||
const t = await sharedTestOrm(testDb);
|
||||
mcpTokensRepo = await createTestMcpTokensRepo(dbs.connection);
|
||||
auditLogRepo = t.repo(AuditLog);
|
||||
svc = new AdminService(
|
||||
dbs,
|
||||
await createTestUsersRepo(dbs.connection),
|
||||
auditLogRepo,
|
||||
await createTestAppSettingsRepo(dbs.connection),
|
||||
t.repo(Addons),
|
||||
t.repo(PhotoProviders),
|
||||
t.repo(PhotoProviderFields),
|
||||
t.repo(DocumentProviders),
|
||||
mcpTokensRepo,
|
||||
await createTestOauthTokensRepo(dbs.connection),
|
||||
await createTestTripsRepo(dbs.connection),
|
||||
await createTestPlacesRepo(dbs.connection),
|
||||
t.repo(TripFiles),
|
||||
await createTestAddonsService(testDb, dbs),
|
||||
new PasskeyService(auth, webauthn, await createTestUnitOfWork(dbs.connection), await createTestWebauthnCredentialsRepo(dbs.connection), await createTestWebauthnChallengesRepo(dbs.connection), await createTestUsersRepo(dbs.connection)),
|
||||
auth,
|
||||
@@ -738,3 +764,112 @@ describe('checkVersion on a centrally administered install', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ── Plan 3i Task 1 — R4's guard-location + TX-boundary + AD22 parity proofs ──
|
||||
|
||||
describe('updateUser — last-admin guard (AD9/AD10, R4)', () => {
|
||||
it('ADMIN-SVC-090 — refuses to demote the sole remaining admin, row untouched', async () => {
|
||||
const { user: soleAdmin } = createAdmin(testDb);
|
||||
|
||||
const result = (await updateUser(String(soleAdmin.id), { role: 'user' })) as { error?: string; status?: number };
|
||||
|
||||
expect(result).toEqual({ error: 'Cannot remove the last admin', status: 400 });
|
||||
const row = testDb.prepare('SELECT role FROM users WHERE id = ?').get(soleAdmin.id) as { role: string };
|
||||
expect(row.role).toBe('admin');
|
||||
});
|
||||
|
||||
it('ADMIN-SVC-091 — demoting one of SEVERAL admins is allowed', async () => {
|
||||
createAdmin(testDb);
|
||||
const { user: secondAdmin } = createAdmin(testDb);
|
||||
|
||||
const result = (await updateUser(String(secondAdmin.id), { role: 'user' })) as { user?: { role: string }; error?: string };
|
||||
|
||||
expect(result.error).toBeUndefined();
|
||||
const row = testDb.prepare('SELECT role FROM users WHERE id = ?').get(secondAdmin.id) as { role: string };
|
||||
expect(row.role).toBe('user');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAuditLog — AD22 parity through the service (LEFT JOIN survives a deleted user, rule 16)', () => {
|
||||
it('ADMIN-SVC-092 — a deleted user\'s audit row keeps its row (not dropped) with username/user_email both null, matching the legacy raw LEFT JOIN', async () => {
|
||||
const { user: liveUser } = createUser(testDb);
|
||||
const { user: doomedUser } = createUser(testDb);
|
||||
|
||||
testDb.prepare('INSERT INTO audit_log (user_id, action, resource, details, ip) VALUES (?, ?, ?, ?, ?)')
|
||||
.run(liveUser.id, 'live_user_action', 'trip', null, '127.0.0.1');
|
||||
testDb.prepare('INSERT INTO audit_log (user_id, action, resource, details, ip) VALUES (?, ?, ?, ?, ?)')
|
||||
.run(doomedUser.id, 'about_to_be_deleted_action', 'trip', null, '127.0.0.1');
|
||||
|
||||
// audit_log.user_id is ON DELETE SET NULL (Task 0's report) — deleting the
|
||||
// user directly (not through the service) makes the row's user_id
|
||||
// genuinely NULL, the exact LEFT JOIN shape rule 16 warns about.
|
||||
testDb.prepare('DELETE FROM users WHERE id = ?').run(doomedUser.id);
|
||||
|
||||
// The legacy statement, run raw on the SAME seeded rows — the parity anchor.
|
||||
const legacyRows = testDb
|
||||
.prepare(
|
||||
`SELECT a.id, a.created_at, a.user_id, u.username, u.email as user_email, a.action, a.resource, a.details, a.ip
|
||||
FROM audit_log a
|
||||
LEFT JOIN users u ON u.id = a.user_id
|
||||
ORDER BY a.id DESC
|
||||
LIMIT ? OFFSET ?`,
|
||||
)
|
||||
.all(500, 0) as Array<{
|
||||
id: number; created_at: string; user_id: number | null; username: string | null;
|
||||
user_email: string | null; action: string; resource: string | null; details: string | null; ip: string | null;
|
||||
}>;
|
||||
|
||||
const result = (await getAuditLog({ limit: '500', offset: '0' })) as {
|
||||
entries: Array<{ user_id: number | null; username: string | null; user_email: string | null; action: string }>;
|
||||
total: number;
|
||||
};
|
||||
|
||||
expect(result.entries.length).toBe(legacyRows.length);
|
||||
|
||||
const deletedEntry = result.entries.find((e) => e.action === 'about_to_be_deleted_action');
|
||||
const legacyDeletedRow = legacyRows.find((r) => r.action === 'about_to_be_deleted_action');
|
||||
expect(deletedEntry).toBeDefined();
|
||||
expect(legacyDeletedRow).toBeDefined();
|
||||
// The row survives the LEFT JOIN (never dropped): user_id is preserved,
|
||||
// username/user_email both null — never `undefined` (rule 16: T | null).
|
||||
expect(deletedEntry!.user_id).toBe(legacyDeletedRow!.user_id);
|
||||
expect(deletedEntry!.username).toBeNull();
|
||||
expect(deletedEntry!.user_email).toBeNull();
|
||||
expect(legacyDeletedRow!.username).toBeNull();
|
||||
expect(legacyDeletedRow!.user_email).toBeNull();
|
||||
|
||||
const liveEntry = result.entries.find((e) => e.action === 'live_user_action');
|
||||
const legacyLiveRow = legacyRows.find((r) => r.action === 'live_user_action');
|
||||
expect(liveEntry!.username).toBe(legacyLiveRow!.username);
|
||||
expect(liveEntry!.user_email).toBe(legacyLiveRow!.user_email);
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateUser — password-reset transaction boundary (AD11/12/13)', () => {
|
||||
it('ADMIN-SVC-093 — a failure on the mcp_tokens delete (AD12, not try/caught) rolls back the already-run users UPDATE (AD11) too', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const before = testDb
|
||||
.prepare('SELECT username, password_version FROM users WHERE id = ?')
|
||||
.get(user.id) as { username: string; password_version: number };
|
||||
|
||||
const spy = vi.spyOn(mcpTokensRepo, 'deleteAllForUser').mockRejectedValueOnce(new Error('simulated mcp_tokens failure'));
|
||||
|
||||
await expect(
|
||||
updateUser(String(user.id), { username: 'should-roll-back', password: 'ANewStrongPass123!' }),
|
||||
).rejects.toThrow('simulated mcp_tokens failure');
|
||||
|
||||
const after = testDb
|
||||
.prepare('SELECT username, password_version FROM users WHERE id = ?')
|
||||
.get(user.id) as { username: string; password_version: number };
|
||||
|
||||
// The users UPDATE (AD11) ran FIRST, inside the SAME uow.transactional
|
||||
// boundary as the failing mcp_tokens delete — proves the TX boundary
|
||||
// survived conversion (not just that each statement individually works):
|
||||
// an uncaught failure anywhere in the block rolls the whole thing back,
|
||||
// including a statement that already "succeeded" earlier in the same TX.
|
||||
expect(after.username).toBe(before.username);
|
||||
expect(after.password_version).toBe(before.password_version);
|
||||
|
||||
spy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -73,7 +73,15 @@ import {
|
||||
createTestTripsRepo,
|
||||
createTestTripMembersRepo,
|
||||
createTestSettingsRepo,
|
||||
createTestPlacesRepo,
|
||||
sharedTestOrm,
|
||||
} from '../../helpers/test-uow';
|
||||
import { AuditLog } from '../../../src/db/entities/AuditLog.entity';
|
||||
import { Addons } from '../../../src/db/entities/Addons.entity';
|
||||
import { PhotoProviders } from '../../../src/db/entities/PhotoProviders.entity';
|
||||
import { PhotoProviderFields } from '../../../src/db/entities/PhotoProviderFields.entity';
|
||||
import { DocumentProviders } from '../../../src/db/entities/DocumentProviders.entity';
|
||||
import { TripFiles } from '../../../src/db/entities/TripFiles.entity';
|
||||
import { budgetRepoArgs } from '../../helpers/budget-repos';
|
||||
import { createTestShareTokensRepo } from '../../helpers/share-repos';
|
||||
import { createTestBudgetItemsRepo } from '../../helpers/files-repos';
|
||||
@@ -103,8 +111,20 @@ beforeAll(async () => {
|
||||
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),
|
||||
);
|
||||
const t = await sharedTestOrm(testDb);
|
||||
svc = new AdminService(
|
||||
dbs,
|
||||
await createTestUsersRepo(dbs.connection),
|
||||
t.repo(AuditLog),
|
||||
await createTestAppSettingsRepo(dbs.connection),
|
||||
t.repo(Addons),
|
||||
t.repo(PhotoProviders),
|
||||
t.repo(PhotoProviderFields),
|
||||
t.repo(DocumentProviders),
|
||||
await createTestMcpTokensRepo(dbs.connection),
|
||||
await createTestOauthTokensRepo(dbs.connection),
|
||||
await createTestTripsRepo(dbs.connection),
|
||||
await createTestPlacesRepo(dbs.connection),
|
||||
t.repo(TripFiles),
|
||||
await createTestAddonsService(testDb, dbs),
|
||||
new PasskeyService(auth, webauthn, await createTestUnitOfWork(dbs.connection), await createTestWebauthnCredentialsRepo(dbs.connection), await createTestWebauthnChallengesRepo(dbs.connection), await createTestUsersRepo(dbs.connection)),
|
||||
auth,
|
||||
|
||||
Reference in New Issue
Block a user