diff --git a/server/src/nest/auth/auth.module.ts b/server/src/nest/auth/auth.module.ts index 06792df95..d1e0e98f4 100644 --- a/server/src/nest/auth/auth.module.ts +++ b/server/src/nest/auth/auth.module.ts @@ -17,6 +17,8 @@ import { JourneyContributors } from '../../db/entities/JourneyContributors.entit import { ShareTokens } from '../../db/entities/ShareTokens.entity'; import { Trips } from '../../db/entities/Trips.entity'; import { TripMembers } from '../../db/entities/TripMembers.entity'; +import { Plugins } from '../../db/entities/Plugins.entity'; +import { PluginUserErasureQueue } from '../../db/entities/PluginUserErasureQueue.entity'; import { TokensModule } from '../tokens/tokens.module'; import { AuthPublicController } from './auth-public.controller'; import { AuthController } from './auth.controller'; @@ -103,8 +105,13 @@ import { AllowedFileTypesModule } from '../files/allowed-file-types.module'; // TripMembers: Plan 4 Task 1, the SAME cross-domain shape as // JourneyShareTokens/ShareTokens above — `UserCleanupService // .cleanupUserReferences`'s UC4 erasure update (owned by - // `nest/trip-membership`). - MikroOrmModule.forFeature([AppSettings, Users, WebauthnCredentials, WebauthnChallenges, InviteTokens, McpTokens, OauthTokens, PasswordResetTokens, BudgetItems, JourneyShareTokens, Journeys, JourneyEntries, JourneyContributors, ShareTokens, Trips, TripMembers])], + // `nest/trip-membership`). Plugins/PluginUserErasureQueue: Plan 4 Task + // 8a, the SAME cross-domain shape again — `UserCleanupService + // .erasePluginUserData`'s UC2/UC3 erasure-enqueue half (owned by + // `nest/plugins`), narrowed off the Plan 3b Task 5 "stays raw" ruling to + // share `enqueueHookUserDataErasures` with `PluginRuntimeService + // .enqueueUserErasure` rather than re-implementing the same filter twice. + MikroOrmModule.forFeature([AppSettings, Users, WebauthnCredentials, WebauthnChallenges, InviteTokens, McpTokens, OauthTokens, PasswordResetTokens, BudgetItems, JourneyShareTokens, Journeys, JourneyEntries, JourneyContributors, ShareTokens, Trips, TripMembers, Plugins, PluginUserErasureQueue])], controllers: [AuthPublicController, AuthController, PasskeyController], providers: [AuthService, UserProfileService, RegistrationInvitesService, PasskeyService, UserCleanupService, WebauthnConfigService, AuthMcp], exports: [AuthService, RegistrationInvitesService, PasskeyService, UserCleanupService], diff --git a/server/src/nest/auth/user-cleanup.service.ts b/server/src/nest/auth/user-cleanup.service.ts index ce65a8810..ac285508e 100644 --- a/server/src/nest/auth/user-cleanup.service.ts +++ b/server/src/nest/auth/user-cleanup.service.ts @@ -24,6 +24,11 @@ import { JourneyContributors } from '../../db/entities/JourneyContributors.entit import type { JourneyContributorsRepository } from '../../db/repositories/JourneyContributors.repository'; import { ShareTokens } from '../../db/entities/ShareTokens.entity'; import type { ShareTokensRepository } from '../../db/repositories/ShareTokens.repository'; +import { Plugins } from '../../db/entities/Plugins.entity'; +import type { PluginsRepository } from '../../db/repositories/Plugins.repository'; +import { PluginUserErasureQueue } from '../../db/entities/PluginUserErasureQueue.entity'; +import type { PluginUserErasureQueueRepository } from '../../db/repositories/PluginUserErasureQueue.repository'; +import { enqueueHookUserDataErasures } from '../plugins/user-erasure-enqueue'; /** * Account erasure — everything that has to happen around `DELETE FROM users` @@ -55,6 +60,10 @@ export class UserCleanupService { @InjectRepository(JourneyContributors) private readonly journeyContributorsRepo: JourneyContributorsRepository, // Plan 3h Task 6 (UC6, R10) — the genuinely-share GDPR-erasure delete. @InjectRepository(ShareTokens) private readonly shareTokensRepo: ShareTokensRepository, + // Plan 4 Task 8a — UC2/UC3's erasure-enqueue half only (see erasePluginUserData's + // own docstring for why this narrows the Plan 3b Task 5 "stays raw" ruling). + @InjectRepository(Plugins) private readonly pluginsRepo: PluginsRepository, + @InjectRepository(PluginUserErasureQueue) private readonly pluginUserErasureQueueRepo: PluginUserErasureQueueRepository, ) {} /** @@ -68,27 +77,34 @@ export class UserCleanupService { * Best-effort per table so a slimmed-down schema (some tests) can't fail the user * deletion itself. * - * UC1 (`plugin_user_config`/`plugin_oauth_tokens`/`plugin_oauth_state`), - * UC2 (`plugins`) and UC3 (`plugin_user_erasure_queue`) stay raw - * `DatabaseService` calls — every one of these tables is owned by + * UC1 (`plugin_user_config`/`plugin_oauth_tokens`/`plugin_oauth_state`) + * stays a raw `DatabaseService` call — that table trio 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. + * 3b"), and nothing about this method's OTHER half needs it converted. + * + * UC2 (`plugins`) and UC3 (`plugin_user_erasure_queue`) — the erasure- + * ENQUEUE half — narrow that ruling (Plan 4 Task 8a): this method's queue + * writes were an independent re-implementation of the exact same + * `SELECT id, permissions FROM plugins` + `hook:user-data` filter + + * `INSERT OR IGNORE` that `PluginRuntimeService.enqueueUserErasure` (the + * `emitUserDeleted` sink's post-commit half — every account-deletion call + * site fires BOTH) already ran a second time for the very same user. + * Converted onto `PluginsRepository`/`PluginUserErasureQueueRepository` so + * both paths can share `enqueueHookUserDataErasures` (Plan 4 Task 8a) — + * ONE implementation of the filter, not two silently drifting copies. The + * orphan-directory scan below (a plugin uninstalled with its data + * retained) has no equivalent on the `enqueueUserErasure` side and is kept + * here, now via the same repository. */ async erasePluginUserData(userId: number): Promise { for (const table of ['plugin_user_config', 'plugin_oauth_tokens', 'plugin_oauth_state']) { try { this.db.run(`DELETE FROM ${table} WHERE user_id = ?`, userId); } catch { /* table absent (slim schema) */ } // UC1 — Plan 3j } try { - const rows = this.db.all<{ id: string; permissions: string | null }>('SELECT id, permissions FROM plugins'); // UC2 — Plan 3j + const rows = await this.pluginsRepo.listIdsAndPermissions(); // UC2 — Plan 4 Task 8a const installed = new Set(rows.map((r) => r.id)); - const insert = this.db.prepare('INSERT OR IGNORE INTO plugin_user_erasure_queue (plugin_id, user_id) VALUES (?, ?)'); // UC3 — Plan 3j - for (const r of rows) { - let perms: unknown; - try { perms = JSON.parse(r.permissions ?? '[]'); } catch { perms = []; } - if (Array.isArray(perms) && perms.includes('hook:user-data')) insert.run(r.id, userId); - } + await enqueueHookUserDataErasures(this.pluginUserErasureQueueRepo, rows, userId); // UC3 — Plan 4 Task 8a // Also enqueue for plugins UNINSTALLED with their data retained (deleteData=false): // their data dir still holds the user's rows and a same-id reinstall would re-adopt // them. No permissions record survives uninstall, so we can't check hook:user-data — @@ -96,7 +112,7 @@ export class UserCleanupService { // that id is reinstalled + active (erasure delivery is a duty, not grant-gated). try { for (const entry of fs.readdirSync(pluginsDataRoot(), { withFileTypes: true })) { - if (entry.isDirectory() && !installed.has(entry.name)) insert.run(entry.name, userId); + if (entry.isDirectory() && !installed.has(entry.name)) await this.pluginUserErasureQueueRepo.insertIgnore(entry.name, userId); } } catch { /* no plugin data root yet */ } } catch { /* plugins / queue table absent (slim schema) */ } diff --git a/server/src/nest/plugins/plugin-runtime.service.ts b/server/src/nest/plugins/plugin-runtime.service.ts index 6359ba471..84850cc3c 100644 --- a/server/src/nest/plugins/plugin-runtime.service.ts +++ b/server/src/nest/plugins/plugin-runtime.service.ts @@ -57,6 +57,7 @@ import { ForbiddenResource } from './host/rpc-host'; import { removePluginData } from './host/plugin-data.service'; import { isKnownPermission } from './protocol/envelope'; import { discoverPlugins, type DiscoveryRepos } from './install/discovery'; +import { enqueueHookUserDataErasures } from './user-erasure-enqueue'; import { parseJsonText, parseManifest, parseMcpToolCapabilities } from './install/manifest'; import { scanForNativeBinaries } from './install/native-scan'; import { devLinkEnabled, DEV_LINK_SOURCE } from './dev-link'; @@ -502,11 +503,7 @@ export class PluginRuntimeService implements OnApplicationBootstrap, OnModuleDes private async enqueueUserErasure(userId: number): Promise { try { const rows = await this.plugins.listIdsAndPermissions(); - for (const r of rows) { - let perms: unknown; - try { perms = JSON.parse(r.permissions ?? '[]'); } catch { perms = []; } - if (Array.isArray(perms) && perms.includes('hook:user-data')) await this.pluginUserErasureQueue.insertIgnore(r.id, userId); - } + await enqueueHookUserDataErasures(this.pluginUserErasureQueue, rows, userId); // Plan 4 Task 8a — shared with UserCleanupService.erasePluginUserData } catch { /* enqueue is best-effort; a later sweep reconciles from whatever landed */ } diff --git a/server/src/nest/plugins/user-erasure-enqueue.ts b/server/src/nest/plugins/user-erasure-enqueue.ts new file mode 100644 index 000000000..e0f3a4dfa --- /dev/null +++ b/server/src/nest/plugins/user-erasure-enqueue.ts @@ -0,0 +1,43 @@ +import type { PluginIdPermissionRow } from '../../db/repositories/Plugins.repository'; +import type { PluginUserErasureQueueRepository } from '../../db/repositories/PluginUserErasureQueue.repository'; + +/** + * Enqueue a durable GDPR erasure (`INSERT OR IGNORE`, idempotent) for every + * plugin in `rows` that holds `hook:user-data`. Shared primitive (Plan 4 + * Task 8a, a 3j carry): before this, `UserCleanupService.erasePluginUserData` + * (the account-deletion transaction's own enqueue, UC2/UC3) and + * `PluginRuntimeService.enqueueUserErasure` (the `emitUserDeleted` sink's + * post-commit enqueue) walked the IDENTICAL `SELECT id, permissions FROM + * plugins` + `JSON.parse` + `hook:user-data` filter independently — and both + * genuinely run for every account deletion: `deleteUserCompletely` calls the + * first inside its own transaction, and `emitUserDeleted` fires the second + * immediately after, from all three account-deletion call sites + * (`auth.service.ts`, `admin.service.ts`, `trip-members.service.ts`'s guest + * removal). The duplication was live, not theoretical — CLAUDE.md's "single + * source of truth over manual synchronization". + * + * `rows` is caller-supplied, not fetched here: + * `UserCleanupService.erasePluginUserData`'s own orphan-directory scan (its + * second half, enqueuing for a plugin that was uninstalled with its data + * retained) needs the SAME `plugins` read to compute which plugin ids are + * still installed, so the caller fetches once (`PluginsRepository + * .listIdsAndPermissions()`) and this function never re-queries. + * + * Never throws itself — a malformed `permissions` JSON value is treated as + * `[]` and skipped, matching both legacy implementations exactly. + */ +export async function enqueueHookUserDataErasures( + queue: PluginUserErasureQueueRepository, + rows: readonly Pick[], + userId: number, +): Promise { + for (const r of rows) { + let perms: unknown; + try { + perms = JSON.parse(r.permissions ?? '[]'); + } catch { + perms = []; + } + if (Array.isArray(perms) && perms.includes('hook:user-data')) await queue.insertIgnore(r.id, userId); + } +} diff --git a/server/tests/helpers/plugin-host.ts b/server/tests/helpers/plugin-host.ts index f4cf61588..d3e703f9a 100644 --- a/server/tests/helpers/plugin-host.ts +++ b/server/tests/helpers/plugin-host.ts @@ -111,7 +111,7 @@ import { Settings } from '../../src/db/entities/Settings.entity'; import { NotificationChannelPreferences } from '../../src/db/entities/NotificationChannelPreferences.entity'; import { createTestTripFilesRepo, createTestFileLinksRepo, createTestBudgetItemsRepo } from './files-repos'; import { budgetRepoArgs } from './budget-repos'; -import { createTestShareTokensRepo } from './share-repos'; +import { createTestShareTokensRepo, createTestPluginsRepo, createTestPluginUserErasureQueueRepo } from './share-repos'; import { createTestCollabMessageReactionsRepo, createTestCollabNotesRepo, createTestCollabPollsRepo, createTestCollabPollVotesRepo, createTestCollabLinksRepo, createTestCollabMessagesRepo, @@ -292,7 +292,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(permissions, budget, realtime, notificationsStub(), new ReservationsReadService(await createTestReservationsRepo(dbs.connection), await createTestReservationEndpointsRepo(dbs.connection), await createTestReservationTravelersRepo(dbs.connection)), accommodations, await createTestUnitOfWork(dbs.connection), await createTestReservationsRepo(dbs.connection), await createTestReservationEndpointsRepo(dbs.connection), await createTestReservationTravelersRepo(dbs.connection), await createTestReservationDayPositionsRepo(dbs.connection), await createTestDayAccommodationsRepo(dbs.connection), await createTestDaysRepo(dbs.connection), await createTestPlacesRepo(dbs.connection), await createTestDayAssignmentsRepo(dbs.connection), await createTestTripMembersRepo(dbs.connection), await createTestUsersRepo(dbs.connection), await createTestTripsRepo(dbs.connection), await createTestBudgetItemsRepo(dbs.connection)); const trips = new TripsService(dbs, reservations, days, permissions, budget, vacay, realtime, unsplash, generalStorage, await createTestUnitOfWork(dbs.connection), (await sharedTestOrm(dbs.connection)).em); - const members = new TripMembersService(budget, new UserCleanupService(dbs, budget, await createTestUnitOfWork(dbs.connection), usersRepo, await createTestTripMembersRepo(dbs.connection), await createTestBudgetItemsRepo(dbs.connection), await createTestJourneyShareTokensRepo(dbs.connection), await createTestJourneysRepo(dbs.connection), await createTestJourneyEntriesRepo(dbs.connection), await createTestJourneyContributorsRepo(dbs.connection), await createTestShareTokensRepo(dbs.connection)), permissions, realtime, notificationsStub(), await createTestUnitOfWork(dbs.connection), await createTestTripsRepo(dbs.connection), await createTestTripMembersRepo(dbs.connection), usersRepo); + const members = new TripMembersService(budget, new UserCleanupService(dbs, budget, await createTestUnitOfWork(dbs.connection), usersRepo, await createTestTripMembersRepo(dbs.connection), await createTestBudgetItemsRepo(dbs.connection), await createTestJourneyShareTokensRepo(dbs.connection), await createTestJourneysRepo(dbs.connection), await createTestJourneyEntriesRepo(dbs.connection), await createTestJourneyContributorsRepo(dbs.connection), await createTestShareTokensRepo(dbs.connection), await createTestPluginsRepo(dbs.connection), await createTestPluginUserErasureQueueRepo(dbs.connection)), permissions, realtime, notificationsStub(), await createTestUnitOfWork(dbs.connection), await createTestTripsRepo(dbs.connection), await createTestTripMembersRepo(dbs.connection), usersRepo); // Plan 3j Task 1 — PluginGuards' own role lookup (PG3/PG4) now goes // through UsersRepository.getRole; `usersRepo` above is the same // sharedTestOrm-backed repository every other service in this file uses. diff --git a/server/tests/helpers/share-repos.ts b/server/tests/helpers/share-repos.ts index b82dd63cf..a2ab51987 100644 --- a/server/tests/helpers/share-repos.ts +++ b/server/tests/helpers/share-repos.ts @@ -8,6 +8,10 @@ import { createTestPackingItemsRepo } from './packing-repos'; import { createTestCollabMessagesRepo } from './collab-repos'; import { ShareTokens } from '../../src/db/entities/ShareTokens.entity'; import type { ShareTokensRepository } from '../../src/db/repositories/ShareTokens.repository'; +import { Plugins } from '../../src/db/entities/Plugins.entity'; +import type { PluginsRepository } from '../../src/db/repositories/Plugins.repository'; +import { PluginUserErasureQueue } from '../../src/db/entities/PluginUserErasureQueue.entity'; +import type { PluginUserErasureQueueRepository } from '../../src/db/repositories/PluginUserErasureQueue.repository'; import type { ReservationsRepository } from '../../src/db/repositories/Reservations.repository'; import type { TripsRepository } from '../../src/db/repositories/Trips.repository'; import type { DaysRepository } from '../../src/db/repositories/Days.repository'; @@ -36,6 +40,21 @@ export function createTestShareTokensRepo(db: Database.Database): Promise t.repo(ShareTokens)); } +/** + * Plan 4 Task 8a — `UserCleanupService.erasePluginUserData`'s UC2/UC3 + * erasure-enqueue half, converted off `DatabaseService` onto these two + * repositories (shared with `PluginRuntimeService.enqueueUserErasure` via + * `enqueueHookUserDataErasures`). Same `sharedTestOrm` factory shape as + * `createTestShareTokensRepo` above. + */ +export function createTestPluginsRepo(db: Database.Database): Promise { + return sharedTestOrm(db).then((t) => t.repo(Plugins)); +} + +export function createTestPluginUserErasureQueueRepo(db: Database.Database): Promise { + return sharedTestOrm(db).then((t) => t.repo(PluginUserErasureQueue)); +} + /** * The 10 repository arguments `ShareService`'s constructor appends after * `uow` (`reservationsRepo`, `shareTokens`, `trips`, `days`, `dayAssignments`, diff --git a/server/tests/unit/nest/admin.service.test.ts b/server/tests/unit/nest/admin.service.test.ts index 834fb7a7c..797e73f36 100644 --- a/server/tests/unit/nest/admin.service.test.ts +++ b/server/tests/unit/nest/admin.service.test.ts @@ -104,7 +104,7 @@ import { DocumentProviders } from '../../../src/db/entities/DocumentProviders.en 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 { createTestShareTokensRepo, createTestPluginsRepo, createTestPluginUserErasureQueueRepo } from '../../helpers/share-repos'; import { createTestBudgetItemsRepo } from '../../helpers/files-repos'; import { createTestJourneysRepo, createTestJourneyEntriesRepo, createTestJourneyContributorsRepo } from '../../helpers/journey-repos'; import { createTestJourneyShareTokensRepo } from '../../helpers/journey-share-repos'; @@ -128,7 +128,7 @@ let auditLogRepo: AuditLogRepository; 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(permissions, new ExchangeRatesService(), realtime, await createTestUnitOfWork(dbs.connection), ...(await budgetRepoArgs(dbs.connection))), await createTestUnitOfWork(dbs.connection), await createTestUsersRepo(dbs.connection), await createTestTripMembersRepo(dbs.connection), await createTestBudgetItemsRepo(dbs.connection), await createTestJourneyShareTokensRepo(dbs.connection), await createTestJourneysRepo(dbs.connection), await createTestJourneyEntriesRepo(dbs.connection), await createTestJourneyContributorsRepo(dbs.connection), await createTestShareTokensRepo(dbs.connection)); + userCleanup = new UserCleanupService(dbs, new BudgetService(permissions, new ExchangeRatesService(), realtime, await createTestUnitOfWork(dbs.connection), ...(await budgetRepoArgs(dbs.connection))), await createTestUnitOfWork(dbs.connection), await createTestUsersRepo(dbs.connection), await createTestTripMembersRepo(dbs.connection), await createTestBudgetItemsRepo(dbs.connection), await createTestJourneyShareTokensRepo(dbs.connection), await createTestJourneysRepo(dbs.connection), await createTestJourneyEntriesRepo(dbs.connection), await createTestJourneyContributorsRepo(dbs.connection), await createTestShareTokensRepo(dbs.connection), await createTestPluginsRepo(dbs.connection), await createTestPluginUserErasureQueueRepo(dbs.connection)); auth = new AuthService( permissions, new TripMembershipService(await createTestTripsRepo(dbs.connection), await createTestTripMembersRepo(dbs.connection)), webauthn, userCleanup, new MailerService(await createTestUsersRepo(dbs.connection), await createTestSettingsRepo(dbs.connection), await createTestAppSettingsRepo(dbs.connection)), new EphemeralTokenService(), new AllowedFileTypesService(await createTestAppSettingsRepo(dbs.connection)), await createTestUnitOfWork(dbs.connection), await createTestAppSettingsRepo(dbs.connection), await createTestUsersRepo(dbs.connection), await createTestInviteTokensRepo(dbs.connection), await createTestMcpTokensRepo(dbs.connection), diff --git a/server/tests/unit/nest/admin.version-notification.test.ts b/server/tests/unit/nest/admin.version-notification.test.ts index 95a580738..fde93a4ca 100644 --- a/server/tests/unit/nest/admin.version-notification.test.ts +++ b/server/tests/unit/nest/admin.version-notification.test.ts @@ -81,7 +81,7 @@ import { PhotoProviderFields } from '../../../src/db/entities/PhotoProviderField 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 { createTestShareTokensRepo, createTestPluginsRepo, createTestPluginUserErasureQueueRepo } from '../../helpers/share-repos'; import { createTestBudgetItemsRepo } from '../../helpers/files-repos'; import { createTestJourneysRepo, createTestJourneyEntriesRepo, createTestJourneyContributorsRepo } from '../../helpers/journey-repos'; import { createTestJourneyShareTokensRepo } from '../../helpers/journey-share-repos'; @@ -103,7 +103,7 @@ 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(permissions, new ExchangeRatesService(), realtime, await createTestUnitOfWork(dbs.connection), ...(await budgetRepoArgs(dbs.connection))), await createTestUnitOfWork(dbs.connection), await createTestUsersRepo(dbs.connection), await createTestTripMembersRepo(dbs.connection), await createTestBudgetItemsRepo(dbs.connection), await createTestJourneyShareTokensRepo(dbs.connection), await createTestJourneysRepo(dbs.connection), await createTestJourneyEntriesRepo(dbs.connection), await createTestJourneyContributorsRepo(dbs.connection), await createTestShareTokensRepo(dbs.connection)); + userCleanup = new UserCleanupService(dbs, new BudgetService(permissions, new ExchangeRatesService(), realtime, await createTestUnitOfWork(dbs.connection), ...(await budgetRepoArgs(dbs.connection))), await createTestUnitOfWork(dbs.connection), await createTestUsersRepo(dbs.connection), await createTestTripMembersRepo(dbs.connection), await createTestBudgetItemsRepo(dbs.connection), await createTestJourneyShareTokensRepo(dbs.connection), await createTestJourneysRepo(dbs.connection), await createTestJourneyEntriesRepo(dbs.connection), await createTestJourneyContributorsRepo(dbs.connection), await createTestShareTokensRepo(dbs.connection), await createTestPluginsRepo(dbs.connection), await createTestPluginUserErasureQueueRepo(dbs.connection)); auth = new AuthService( permissions, new TripMembershipService(await createTestTripsRepo(dbs.connection), await createTestTripMembersRepo(dbs.connection)), webauthn, userCleanup, new MailerService(await createTestUsersRepo(dbs.connection), await createTestSettingsRepo(dbs.connection), await createTestAppSettingsRepo(dbs.connection)), new EphemeralTokenService(), new AllowedFileTypesService(await createTestAppSettingsRepo(dbs.connection)), await createTestUnitOfWork(dbs.connection), await createTestAppSettingsRepo(dbs.connection), await createTestUsersRepo(dbs.connection), await createTestInviteTokensRepo(dbs.connection), await createTestMcpTokensRepo(dbs.connection), diff --git a/server/tests/unit/nest/auth.service.test.ts b/server/tests/unit/nest/auth.service.test.ts index af5909fff..d67447f9c 100644 --- a/server/tests/unit/nest/auth.service.test.ts +++ b/server/tests/unit/nest/auth.service.test.ts @@ -93,7 +93,7 @@ import { createTestPasswordResetTokensRepo, } from '../../helpers/test-uow'; import { budgetRepoArgs } from '../../helpers/budget-repos'; -import { createTestShareTokensRepo } from '../../helpers/share-repos'; +import { createTestShareTokensRepo, createTestPluginsRepo, createTestPluginUserErasureQueueRepo } from '../../helpers/share-repos'; import { createTestBudgetItemsRepo } from '../../helpers/files-repos'; import { createTestJourneysRepo, createTestJourneyEntriesRepo, createTestJourneyContributorsRepo } from '../../helpers/journey-repos'; import { createTestJourneyShareTokensRepo } from '../../helpers/journey-share-repos'; @@ -117,7 +117,7 @@ beforeAll(async () => { new PermissionsService(await createTestAppSettingsRepo(testDb), await createTestUnitOfWork(testDb)), membershipStub, new WebauthnConfigService(await createTestAppSettingsRepo(testDb)), - new UserCleanupService(new DatabaseService(testDb), new BudgetService(new PermissionsService(await createTestAppSettingsRepo(testDb), await createTestUnitOfWork(testDb)), new ExchangeRatesService(), new RealtimeService(), await createTestUnitOfWork(testDb), ...(await budgetRepoArgs(testDb))), await createTestUnitOfWork(testDb), await createTestUsersRepo(testDb), await createTestTripMembersRepo(testDb), await createTestBudgetItemsRepo(testDb), await createTestJourneyShareTokensRepo(testDb), await createTestJourneysRepo(testDb), await createTestJourneyEntriesRepo(testDb), await createTestJourneyContributorsRepo(testDb), await createTestShareTokensRepo(testDb)), + new UserCleanupService(new DatabaseService(testDb), new BudgetService(new PermissionsService(await createTestAppSettingsRepo(testDb), await createTestUnitOfWork(testDb)), new ExchangeRatesService(), new RealtimeService(), await createTestUnitOfWork(testDb), ...(await budgetRepoArgs(testDb))), await createTestUnitOfWork(testDb), await createTestUsersRepo(testDb), await createTestTripMembersRepo(testDb), await createTestBudgetItemsRepo(testDb), await createTestJourneyShareTokensRepo(testDb), await createTestJourneysRepo(testDb), await createTestJourneyEntriesRepo(testDb), await createTestJourneyContributorsRepo(testDb), await createTestShareTokensRepo(testDb), await createTestPluginsRepo(testDb), await createTestPluginUserErasureQueueRepo(testDb)), mailerStub, new EphemeralTokenService(), new AllowedFileTypesService(await createTestAppSettingsRepo(testDb)), await createTestUnitOfWork(testDb), diff --git a/server/tests/unit/nest/budget.service.db.test.ts b/server/tests/unit/nest/budget.service.db.test.ts index 62d42f02c..22ce2716c 100644 --- a/server/tests/unit/nest/budget.service.db.test.ts +++ b/server/tests/unit/nest/budget.service.db.test.ts @@ -71,7 +71,7 @@ import { QueryHelpersService } from '../../../src/nest/query-helpers/query-helpe import { notificationsStub } from '../../helpers/notifications'; import { createTestUnitOfWork, createTestAppSettingsRepo, createTestUsersRepo, sharedTestOrm, createTestTripsRepo, createTestTripMembersRepo } from '../../helpers/test-uow'; import { budgetRepoArgs, createTestBudgetItemMembersRepo, createTestBudgetItemPayersRepo, createTestBudgetSettlementsRepo } from '../../helpers/budget-repos'; -import { createTestShareTokensRepo } from '../../helpers/share-repos'; +import { createTestShareTokensRepo, createTestPluginsRepo, createTestPluginUserErasureQueueRepo } from '../../helpers/share-repos'; import { createTestBudgetItemsRepo } from '../../helpers/files-repos'; import { createTestJourneysRepo, createTestJourneyEntriesRepo, createTestJourneyContributorsRepo } from '../../helpers/journey-repos'; import { createTestJourneyShareTokensRepo } from '../../helpers/journey-share-repos'; @@ -113,7 +113,7 @@ beforeAll(async () => { ); membersSvc = new TripMembersService( budget, - new UserCleanupService(dbs(), budget, await createTestUnitOfWork(testDb), await createTestUsersRepo(testDb), await createTestTripMembersRepo(testDb), await createTestBudgetItemsRepo(testDb), await createTestJourneyShareTokensRepo(testDb), await createTestJourneysRepo(testDb), await createTestJourneyEntriesRepo(testDb), await createTestJourneyContributorsRepo(testDb), await createTestShareTokensRepo(testDb)), + new UserCleanupService(dbs(), budget, await createTestUnitOfWork(testDb), await createTestUsersRepo(testDb), await createTestTripMembersRepo(testDb), await createTestBudgetItemsRepo(testDb), await createTestJourneyShareTokensRepo(testDb), await createTestJourneysRepo(testDb), await createTestJourneyEntriesRepo(testDb), await createTestJourneyContributorsRepo(testDb), await createTestShareTokensRepo(testDb), await createTestPluginsRepo(testDb), await createTestPluginUserErasureQueueRepo(testDb)), new PermissionsService(await createTestAppSettingsRepo(dbs().connection), await createTestUnitOfWork(dbs().connection)), new RealtimeService(), notificationsStub(), diff --git a/server/tests/unit/nest/oidc.service.test.ts b/server/tests/unit/nest/oidc.service.test.ts index 6f6f996e9..657a8e456 100644 --- a/server/tests/unit/nest/oidc.service.test.ts +++ b/server/tests/unit/nest/oidc.service.test.ts @@ -115,7 +115,7 @@ import { createTestTripMembersRepo, } from '../../helpers/test-uow'; import { budgetRepoArgs } from '../../helpers/budget-repos'; -import { createTestShareTokensRepo } from '../../helpers/share-repos'; +import { createTestShareTokensRepo, createTestPluginsRepo, createTestPluginUserErasureQueueRepo } from '../../helpers/share-repos'; import { createTestBudgetItemsRepo } from '../../helpers/files-repos'; import { createTestJourneysRepo, createTestJourneyEntriesRepo, createTestJourneyContributorsRepo } from '../../helpers/journey-repos'; import { createTestJourneyShareTokensRepo } from '../../helpers/journey-share-repos'; @@ -138,7 +138,7 @@ beforeAll(async () => { new PermissionsService(await createTestAppSettingsRepo(testDb), await createTestUnitOfWork(testDb)), membership, new WebauthnConfigService(await createTestAppSettingsRepo(testDb)), - new UserCleanupService(new DatabaseService(testDb), new BudgetService(new PermissionsService(await createTestAppSettingsRepo(testDb), await createTestUnitOfWork(testDb)), new ExchangeRatesService(), new RealtimeService(), await createTestUnitOfWork(testDb), ...(await budgetRepoArgs(testDb))), await createTestUnitOfWork(testDb), await createTestUsersRepo(testDb), await createTestTripMembersRepo(testDb), await createTestBudgetItemsRepo(testDb), await createTestJourneyShareTokensRepo(testDb), await createTestJourneysRepo(testDb), await createTestJourneyEntriesRepo(testDb), await createTestJourneyContributorsRepo(testDb), await createTestShareTokensRepo(testDb)), + new UserCleanupService(new DatabaseService(testDb), new BudgetService(new PermissionsService(await createTestAppSettingsRepo(testDb), await createTestUnitOfWork(testDb)), new ExchangeRatesService(), new RealtimeService(), await createTestUnitOfWork(testDb), ...(await budgetRepoArgs(testDb))), await createTestUnitOfWork(testDb), await createTestUsersRepo(testDb), await createTestTripMembersRepo(testDb), await createTestBudgetItemsRepo(testDb), await createTestJourneyShareTokensRepo(testDb), await createTestJourneysRepo(testDb), await createTestJourneyEntriesRepo(testDb), await createTestJourneyContributorsRepo(testDb), await createTestShareTokensRepo(testDb), await createTestPluginsRepo(testDb), await createTestPluginUserErasureQueueRepo(testDb)), mailerStub, new EphemeralTokenService(), new AllowedFileTypesService(await createTestAppSettingsRepo(testDb)), await createTestUnitOfWork(testDb), diff --git a/server/tests/unit/nest/passkey.service.test.ts b/server/tests/unit/nest/passkey.service.test.ts index 395133e8f..7877c0fe3 100644 --- a/server/tests/unit/nest/passkey.service.test.ts +++ b/server/tests/unit/nest/passkey.service.test.ts @@ -100,7 +100,7 @@ import { createTestTripMembersRepo, } from '../../helpers/test-uow'; import { budgetRepoArgs } from '../../helpers/budget-repos'; -import { createTestShareTokensRepo } from '../../helpers/share-repos'; +import { createTestShareTokensRepo, createTestPluginsRepo, createTestPluginUserErasureQueueRepo } from '../../helpers/share-repos'; import { createTestBudgetItemsRepo } from '../../helpers/files-repos'; import { createTestJourneysRepo, createTestJourneyEntriesRepo, createTestJourneyContributorsRepo } from '../../helpers/journey-repos'; import { createTestJourneyShareTokensRepo } from '../../helpers/journey-share-repos'; @@ -132,7 +132,7 @@ beforeAll(async () => { new PermissionsService(await createTestAppSettingsRepo(testDb), await createTestUnitOfWork(testDb)), new TripMembershipService(await createTestTripsRepo(testDb), await createTestTripMembersRepo(testDb)), new WebauthnConfigService(await createTestAppSettingsRepo(testDb)), - new UserCleanupService(new DatabaseService(testDb), new BudgetService(new PermissionsService(await createTestAppSettingsRepo(testDb), await createTestUnitOfWork(testDb)), new ExchangeRatesService(), new RealtimeService(), await createTestUnitOfWork(testDb), ...(await budgetRepoArgs(testDb))), await createTestUnitOfWork(testDb), await createTestUsersRepo(testDb), await createTestTripMembersRepo(testDb), await createTestBudgetItemsRepo(testDb), await createTestJourneyShareTokensRepo(testDb), await createTestJourneysRepo(testDb), await createTestJourneyEntriesRepo(testDb), await createTestJourneyContributorsRepo(testDb), await createTestShareTokensRepo(testDb)), + new UserCleanupService(new DatabaseService(testDb), new BudgetService(new PermissionsService(await createTestAppSettingsRepo(testDb), await createTestUnitOfWork(testDb)), new ExchangeRatesService(), new RealtimeService(), await createTestUnitOfWork(testDb), ...(await budgetRepoArgs(testDb))), await createTestUnitOfWork(testDb), await createTestUsersRepo(testDb), await createTestTripMembersRepo(testDb), await createTestBudgetItemsRepo(testDb), await createTestJourneyShareTokensRepo(testDb), await createTestJourneysRepo(testDb), await createTestJourneyEntriesRepo(testDb), await createTestJourneyContributorsRepo(testDb), await createTestShareTokensRepo(testDb), await createTestPluginsRepo(testDb), await createTestPluginUserErasureQueueRepo(testDb)), mailerStub, new EphemeralTokenService(), new AllowedFileTypesService(await createTestAppSettingsRepo(testDb)), await createTestUnitOfWork(testDb), diff --git a/server/tests/unit/nest/trip-members.service.test.ts b/server/tests/unit/nest/trip-members.service.test.ts index cb7463801..8c40c9621 100644 --- a/server/tests/unit/nest/trip-members.service.test.ts +++ b/server/tests/unit/nest/trip-members.service.test.ts @@ -66,7 +66,7 @@ import { budgetRepoArgs } from '../../helpers/budget-repos'; import { createTestBudgetItemsRepo } from '../../helpers/files-repos'; import { createTestJourneysRepo, createTestJourneyEntriesRepo, createTestJourneyContributorsRepo } from '../../helpers/journey-repos'; import { createTestJourneyShareTokensRepo } from '../../helpers/journey-share-repos'; -import { createTestShareTokensRepo } from '../../helpers/share-repos'; +import { createTestShareTokensRepo, createTestPluginsRepo, createTestPluginUserErasureQueueRepo } from '../../helpers/share-repos'; import type { EntityManager } from '@mikro-orm/core'; import type { TripsRepository } from '../../../src/db/repositories/Trips.repository'; import type { UsersRepository } from '../../../src/db/repositories/Users.repository'; @@ -99,7 +99,7 @@ beforeAll(async () => { budgetSvc = new BudgetService(new PermissionsService(await createTestAppSettingsRepo(dbs().connection), await createTestUnitOfWork(dbs().connection)), new ExchangeRatesService(), new RealtimeService(), await createTestUnitOfWork(dbs().connection), ...(await budgetRepoArgs(dbs().connection))); roster = new TripMembersService( budgetSvc, - new UserCleanupService(dbs(), budgetSvc, await createTestUnitOfWork(dbs().connection), usersRepo, await createTestTripMembersRepo(dbs().connection), await createTestBudgetItemsRepo(dbs().connection), await createTestJourneyShareTokensRepo(dbs().connection), await createTestJourneysRepo(dbs().connection), await createTestJourneyEntriesRepo(dbs().connection), await createTestJourneyContributorsRepo(dbs().connection), await createTestShareTokensRepo(dbs().connection)), + new UserCleanupService(dbs(), budgetSvc, await createTestUnitOfWork(dbs().connection), usersRepo, await createTestTripMembersRepo(dbs().connection), await createTestBudgetItemsRepo(dbs().connection), await createTestJourneyShareTokensRepo(dbs().connection), await createTestJourneysRepo(dbs().connection), await createTestJourneyEntriesRepo(dbs().connection), await createTestJourneyContributorsRepo(dbs().connection), await createTestShareTokensRepo(dbs().connection), await createTestPluginsRepo(dbs().connection), await createTestPluginUserErasureQueueRepo(dbs().connection)), new PermissionsService(await createTestAppSettingsRepo(dbs().connection), await createTestUnitOfWork(dbs().connection)), new RealtimeService(), notificationsStub(notifySend), await createTestUnitOfWork(dbs().connection), tripsRepo, tripMembersRepo, usersRepo, diff --git a/server/tests/unit/nest/trip-read-model.service.test.ts b/server/tests/unit/nest/trip-read-model.service.test.ts index 980dadeda..62d6ef246 100644 --- a/server/tests/unit/nest/trip-read-model.service.test.ts +++ b/server/tests/unit/nest/trip-read-model.service.test.ts @@ -106,7 +106,7 @@ import { createTestJourneyPhotosRepo, createTestJourneyEntryPhotosRepo, } from '../../helpers/journey-repos'; import { createTestJourneyShareTokensRepo } from '../../helpers/journey-share-repos'; -import { createTestShareTokensRepo } from '../../helpers/share-repos'; +import { createTestShareTokensRepo, createTestPluginsRepo, createTestPluginUserErasureQueueRepo } from '../../helpers/share-repos'; import { createTestCollectionPlacesRepo } from '../../helpers/test-uow'; // Real sibling services over the same in-memory DB — the aggregation runs the @@ -173,7 +173,7 @@ beforeAll(async () => { await createTestBudgetItemsRepo(dbs().connection), await createTestCollectionPlacesRepo(dbs().connection), ); - membersSvc = new TripMembersService(budgetSvc, new UserCleanupService(dbs(), budgetSvc, await createTestUnitOfWork(dbs().connection), await createTestUsersRepo(dbs().connection), await createTestTripMembersRepo(dbs().connection), await createTestBudgetItemsRepo(dbs().connection), await createTestJourneyShareTokensRepo(dbs().connection), await createTestJourneysRepo(dbs().connection), await createTestJourneyEntriesRepo(dbs().connection), await createTestJourneyContributorsRepo(dbs().connection), await createTestShareTokensRepo(dbs().connection)), new PermissionsService(await createTestAppSettingsRepo(dbs().connection), await createTestUnitOfWork(dbs().connection)), new RealtimeService(), notificationsStub(), await createTestUnitOfWork(dbs().connection), await createTestTripsRepo(dbs().connection), await createTestTripMembersRepo(dbs().connection), await createTestUsersRepo(dbs().connection)); + membersSvc = new TripMembersService(budgetSvc, new UserCleanupService(dbs(), budgetSvc, await createTestUnitOfWork(dbs().connection), await createTestUsersRepo(dbs().connection), await createTestTripMembersRepo(dbs().connection), await createTestBudgetItemsRepo(dbs().connection), await createTestJourneyShareTokensRepo(dbs().connection), await createTestJourneysRepo(dbs().connection), await createTestJourneyEntriesRepo(dbs().connection), await createTestJourneyContributorsRepo(dbs().connection), await createTestShareTokensRepo(dbs().connection), await createTestPluginsRepo(dbs().connection), await createTestPluginUserErasureQueueRepo(dbs().connection)), new PermissionsService(await createTestAppSettingsRepo(dbs().connection), await createTestUnitOfWork(dbs().connection)), new RealtimeService(), notificationsStub(), await createTestUnitOfWork(dbs().connection), await createTestTripsRepo(dbs().connection), await createTestTripMembersRepo(dbs().connection), await createTestUsersRepo(dbs().connection)); }); const buildReadModel = async (tripsRepo: TripsRepository, roster: TripMembersService = membersSvc) => diff --git a/server/tests/unit/nest/trips.service.test.ts b/server/tests/unit/nest/trips.service.test.ts index df98541bb..d2c53b6de 100644 --- a/server/tests/unit/nest/trips.service.test.ts +++ b/server/tests/unit/nest/trips.service.test.ts @@ -123,7 +123,7 @@ import { createTestJourneyPhotosRepo, createTestJourneyEntryPhotosRepo, } from '../../helpers/journey-repos'; import { createTestJourneyShareTokensRepo } from '../../helpers/journey-share-repos'; -import { createTestShareTokensRepo } from '../../helpers/share-repos'; +import { createTestShareTokensRepo, createTestPluginsRepo, createTestPluginUserErasureQueueRepo } from '../../helpers/share-repos'; import { createTestCollectionPlacesRepo } from '../../helpers/test-uow'; // Real sibling services over the same in-memory DB — updateTrip's date-shift @@ -235,7 +235,7 @@ beforeAll(async () => { await createTestUnitOfWork(dbs().connection), (await sharedTestOrm(testDb)).em, ); - membersSvc = new TripMembersService(budgetSvc, new UserCleanupService(dbs(), budgetSvc, await createTestUnitOfWork(dbs().connection), await createTestUsersRepo(dbs().connection), await createTestTripMembersRepo(dbs().connection), await createTestBudgetItemsRepo(dbs().connection), await createTestJourneyShareTokensRepo(dbs().connection), await createTestJourneysRepo(dbs().connection), await createTestJourneyEntriesRepo(dbs().connection), await createTestJourneyContributorsRepo(dbs().connection), await createTestShareTokensRepo(dbs().connection)), new PermissionsService(await createTestAppSettingsRepo(dbs().connection), await createTestUnitOfWork(dbs().connection)), new RealtimeService(), notificationsStub(), await createTestUnitOfWork(dbs().connection), await createTestTripsRepo(dbs().connection), await createTestTripMembersRepo(dbs().connection), await createTestUsersRepo(dbs().connection)); + membersSvc = new TripMembersService(budgetSvc, new UserCleanupService(dbs(), budgetSvc, await createTestUnitOfWork(dbs().connection), await createTestUsersRepo(dbs().connection), await createTestTripMembersRepo(dbs().connection), await createTestBudgetItemsRepo(dbs().connection), await createTestJourneyShareTokensRepo(dbs().connection), await createTestJourneysRepo(dbs().connection), await createTestJourneyEntriesRepo(dbs().connection), await createTestJourneyContributorsRepo(dbs().connection), await createTestShareTokensRepo(dbs().connection), await createTestPluginsRepo(dbs().connection), await createTestPluginUserErasureQueueRepo(dbs().connection)), new PermissionsService(await createTestAppSettingsRepo(dbs().connection), await createTestUnitOfWork(dbs().connection)), new RealtimeService(), notificationsStub(), await createTestUnitOfWork(dbs().connection), await createTestTripsRepo(dbs().connection), await createTestTripMembersRepo(dbs().connection), await createTestUsersRepo(dbs().connection)); readModelSvc = new TripReadModelService( await createTestTripsRepo(dbs().connection), membersSvc, daysSvc, accommodationsSvc, budgetSvc, new PackingService( diff --git a/server/tests/unit/nest/user-cleanup.service.test.ts b/server/tests/unit/nest/user-cleanup.service.test.ts index 4306db3e8..dc8aac92d 100644 --- a/server/tests/unit/nest/user-cleanup.service.test.ts +++ b/server/tests/unit/nest/user-cleanup.service.test.ts @@ -60,7 +60,7 @@ import { budgetRepoArgs } from '../../helpers/budget-repos'; import { createTestBudgetItemsRepo } from '../../helpers/files-repos'; import { createTestJourneysRepo, createTestJourneyEntriesRepo, createTestJourneyContributorsRepo } from '../../helpers/journey-repos'; import { createTestJourneyShareTokensRepo } from '../../helpers/journey-share-repos'; -import { createTestShareTokensRepo } from '../../helpers/share-repos'; +import { createTestShareTokensRepo, createTestPluginsRepo, createTestPluginUserErasureQueueRepo } from '../../helpers/share-repos'; const dbs = new DatabaseService(testDb); @@ -87,6 +87,9 @@ beforeAll(async () => { await createTestJourneyEntriesRepo(dbs.connection), await createTestJourneyContributorsRepo(dbs.connection), // Plan 3h Task 6 constructor-ripple: UC6's repository. await createTestShareTokensRepo(dbs.connection), + // Plan 4 Task 8a constructor-ripple: UC2/UC3's repositories. + await createTestPluginsRepo(dbs.connection), + await createTestPluginUserErasureQueueRepo(dbs.connection), ); }); @@ -195,6 +198,13 @@ describe('erasePluginUserData', () => { // never touches it, but MikroORM's entity metadata does not require the // physical table to exist to construct the repository object itself). await createTestShareTokensRepo(slim), + // Plan 4 Task 8a: also real repositories, not stubs — `erasePluginUserData` + // DOES call into these two (UC2/UC3), and this test's whole point is that + // the query against the missing table throws and is caught, the same + // "table absent (slim schema)" outer try/catch as before, not a + // constructor-time failure. + await createTestPluginsRepo(slim), + await createTestPluginUserErasureQueueRepo(slim), ); await expect(slimSvc.erasePluginUserData(1)).resolves.toBeUndefined(); @@ -332,6 +342,7 @@ describe('deleteUserCompletely', () => { await createTestJourneyShareTokensRepo(testDb), await createTestJourneysRepo(testDb), await createTestJourneyEntriesRepo(testDb), await createTestJourneyContributorsRepo(testDb), await createTestShareTokensRepo(testDb), + await createTestPluginsRepo(testDb), await createTestPluginUserErasureQueueRepo(testDb), ).deleteUserCompletely(victim.id)).rejects.toThrow('boom'); expect(testDb.prepare('SELECT id FROM users WHERE id = ?').get(victim.id)).toBeDefined();