diff --git a/server/src/nest/admin/admin.bridge.ts b/server/src/nest/admin/admin.bridge.ts index 74e6dc3a0..8cad2020e 100644 --- a/server/src/nest/admin/admin.bridge.ts +++ b/server/src/nest/admin/admin.bridge.ts @@ -16,6 +16,7 @@ import { NtfyService } from '../notifications/transports/ntfy.service'; import { WebhookService } from '../notifications/transports/webhook.service'; import { NotificationsService } from '../notifications/notifications.service'; import { AdminService } from './admin.service'; +import { EphemeralTokenService } from '../auth/ephemeral-token.service'; /** * Non-Nest entry point for the admin domain — for code running OUTSIDE the Nest @@ -40,7 +41,7 @@ const webauthn = new WebauthnConfigService(dbs); const userCleanup = new UserCleanupService(dbs); const mailer = new MailerService(dbs); const notifPrefs = new NotificationPreferencesService(dbs, mailer); -const auth = new AuthService(dbs, permissions, new TripMembershipService(dbs), webauthn, userCleanup, mailer); +const auth = new AuthService(dbs, permissions, new TripMembershipService(dbs), webauthn, userCleanup, mailer, new EphemeralTokenService()); const admin = new AdminService( dbs, new AddonsService(dbs), diff --git a/server/src/nest/auth/auth.bridge.ts b/server/src/nest/auth/auth.bridge.ts index 1de18f0e6..6127a243f 100644 --- a/server/src/nest/auth/auth.bridge.ts +++ b/server/src/nest/auth/auth.bridge.ts @@ -8,6 +8,7 @@ import { MailerService } from '../notifications/mailer/mailer.service'; import { UserCleanupService } from './user-cleanup.service'; import { WebauthnConfigService } from './webauthn-config.service'; import { User } from '../../types'; +import { EphemeralTokenService } from './ephemeral-token.service'; /** * Non-Nest entry point for the auth domain — for code running OUTSIDE the @@ -45,9 +46,10 @@ const auth = new AuthService( new WebauthnConfigService(dbs), new UserCleanupService(dbs), new MailerService(dbs), + new EphemeralTokenService(), ); -const tokens = new TokenService(dbs); +const tokens = new TokenService(dbs, new EphemeralTokenService()); export { stripUserForClient } from './auth.helpers'; export { avatarUrl } from '../common/avatarUrl'; diff --git a/server/src/nest/auth/auth.module.ts b/server/src/nest/auth/auth.module.ts index e87a5fcf9..f970ad50c 100644 --- a/server/src/nest/auth/auth.module.ts +++ b/server/src/nest/auth/auth.module.ts @@ -16,6 +16,7 @@ import { AuditModule } from '../audit/audit.module'; import { MailerModule } from '../notifications/mailer/mailer.module'; import { PermissionsModule } from '../permissions/permissions.module'; import { TripMembershipModule } from '../trip-membership/trip-membership.module'; +import { EphemeralTokenModule } from './ephemeral-token.module'; /** * Auth module — public flows (login/register/reset/mfa-verify/logout) and the @@ -38,7 +39,7 @@ import { TripMembershipModule } from '../trip-membership/trip-membership.module' * through auth.bridge.ts. */ @Module({ - imports: [RateLimitModule, AuditModule, PermissionsModule, TripMembershipModule, MailerModule, AppConfigModule, TokensModule], + imports: [EphemeralTokenModule, RateLimitModule, AuditModule, PermissionsModule, TripMembershipModule, MailerModule, AppConfigModule, TokensModule], controllers: [AuthPublicController, AuthController, PasskeyController], providers: [AuthService, UserProfileService, RegistrationInvitesService, PasskeyService, UserCleanupService, WebauthnConfigService, AuthMcp], exports: [AuthService, RegistrationInvitesService, PasskeyService, UserCleanupService], diff --git a/server/src/nest/auth/auth.service.ts b/server/src/nest/auth/auth.service.ts index bfb6a6db4..a0dd80b81 100644 --- a/server/src/nest/auth/auth.service.ts +++ b/server/src/nest/auth/auth.service.ts @@ -14,7 +14,7 @@ import { PermissionsService } from '../permissions/permissions.service'; import { validatePassword } from '../common/passwordPolicy'; import { encryptMfaSecret, decryptMfaSecret } from '../common/crypto/mfaCrypto'; import { decrypt_api_key, maybe_encrypt_api_key, encrypt_api_key } from '../common/crypto/apiKeyCrypto'; -import { createEphemeralToken, startTokenCleanup, stopTokenCleanup } from './ephemeral-tokens'; +import { EphemeralTokenService } from './ephemeral-token.service'; // Import from sessionManager directly, NOT the ../../mcp barrel: the barrel pulls // the whole tools fan-out (and via the domain bridges, the Nest services) into // every consumer of this module — a nest→mcp→nest module cycle. @@ -118,20 +118,7 @@ export interface ResetPasswordOutcome { * AtlasModule import AuthModule instead of going the other way around. */ @Injectable() -export class AuthService implements OnModuleInit, OnModuleDestroy { - /** - * The ephemeral-token store sweeps itself every minute. index.ts used to kick - * that off with a bare require() after listen(); it belongs to the domain that - * issues the tokens. - */ - onModuleInit(): void { - startTokenCleanup(); - } - - onModuleDestroy(): void { - stopTokenCleanup(); - } - +export class AuthService { constructor( private readonly db: DatabaseService, private readonly permissions: PermissionsService, @@ -139,6 +126,7 @@ export class AuthService implements OnModuleInit, OnModuleDestroy { private readonly webauthn: WebauthnConfigService, private readonly userCleanup: UserCleanupService, private readonly mailer: MailerService, + private readonly tokens: EphemeralTokenService, ) {} // Cookie diff --git a/server/src/nest/auth/ephemeral-token.module.ts b/server/src/nest/auth/ephemeral-token.module.ts new file mode 100644 index 000000000..c26e02226 --- /dev/null +++ b/server/src/nest/auth/ephemeral-token.module.ts @@ -0,0 +1,19 @@ +import { Module } from '@nestjs/common'; +import { EphemeralTokenService } from './ephemeral-token.service'; + +/** + * A leaf module whose only job is to hand EphemeralTokenService out. + * + * It cannot live in AuthModule: TokensModule mints download and ws tokens too, + * and AuthModule already imports TokensModule, so importing it back would close + * a cycle. TokensModule is also documented as a deliberate leaf that three + * graphs reach for, so a dependency added there lands in all of them. + * + * Same shape and same reason as MailerModule, which broke + * AuthModule <-> NotificationsModule, and PluginGuardsModule. + */ +@Module({ + providers: [EphemeralTokenService], + exports: [EphemeralTokenService], +}) +export class EphemeralTokenModule {} diff --git a/server/src/nest/auth/ephemeral-token.service.ts b/server/src/nest/auth/ephemeral-token.service.ts new file mode 100644 index 000000000..7cbc476da --- /dev/null +++ b/server/src/nest/auth/ephemeral-token.service.ts @@ -0,0 +1,59 @@ +import { Injectable, OnModuleInit, OnModuleDestroy } from '@nestjs/common'; +import { + createEphemeralToken, + consumeEphemeralToken, + consumeEphemeralTokenWithMeta, + startTokenCleanup, + stopTokenCleanup, + type EphemeralTokenMeta, +} from './ephemeral-tokens'; + +/** + * The injectable face of the ephemeral-token store, and the owner of its + * cleanup timer. + * + * The store itself stays module state next door, and that is load-bearing + * rather than a leftover: `src/websocket.ts` consumes ws tokens from OUTSIDE + * the container, on the upgrade handshake, before any injector is in reach. + * Per-instance state would hand it an empty Map and every connection would + * close with 4001 "Invalid or expired token" — a failure that no test sees, + * because the ws suites drive the transport directly. Same reasoning as the + * geo throttle cursor and the permissions cache. + * + * The MAX_STORE_SIZE back-pressure is the other reason: `createEphemeralToken` + * returns null at 10,000 live tokens and TokenService turns that into a 503. + * A second store would raise the real ceiling without anyone noticing. + * + * The lifecycle moved off AuthService, which owned it only because it was the + * domain that mints tokens. Issuing them and sweeping them are different jobs, + * and FilesService and TokenService mint or consume them too. + */ +@Injectable() +export class EphemeralTokenService implements OnModuleInit, OnModuleDestroy { + onModuleInit(): void { + startTokenCleanup(); + } + + onModuleDestroy(): void { + stopTokenCleanup(); + } + + /** Returns null when the store is at capacity; callers answer 503. */ + create(userId: number, purpose: string, meta?: EphemeralTokenMeta): string | null { + return createEphemeralToken(userId, purpose, meta); + } + + /** Single-use: the token is deleted whether or not it validates. */ + consume(token: string, purpose: string): number | null { + return consumeEphemeralToken(token, purpose); + } + + /** + * The same, plus the password_version the token was minted with. Only the ws + * handshake needs it, for the session gate that rejects a token minted before + * a password change. + */ + consumeWithMeta(token: string, purpose: string): { userId: number; pv?: number } | null { + return consumeEphemeralTokenWithMeta(token, purpose); + } +} diff --git a/server/src/nest/files/files.bridge.ts b/server/src/nest/files/files.bridge.ts index ab0cc6bb0..45bc1d9c8 100644 --- a/server/src/nest/files/files.bridge.ts +++ b/server/src/nest/files/files.bridge.ts @@ -3,6 +3,7 @@ import { DatabaseService } from '../database/database.service'; import { RealtimeService } from '../realtime/realtime.service'; import { FilesService } from './files.service'; import { PermissionsService } from '../permissions/permissions.service'; +import { EphemeralTokenService } from '../auth/ephemeral-token.service'; /** * Non-Nest entry point for the files domain. Unlike todo.bridge.ts this does @@ -16,7 +17,7 @@ import { PermissionsService } from '../permissions/permissions.service'; * Module-level construction is safe: `db` is the reinitialize-proof Proxy onto * the shared better-sqlite3 singleton. */ -const files = new FilesService(new DatabaseService(db), new PermissionsService(new DatabaseService(db)), new RealtimeService()); +const files = new FilesService(new DatabaseService(db), new PermissionsService(new DatabaseService(db)), new RealtimeService(), new EphemeralTokenService()); export function getAllowedExtensions(): string { return files.getAllowedExtensions(); diff --git a/server/src/nest/files/files.module.ts b/server/src/nest/files/files.module.ts index fd0277209..12946c0a0 100644 --- a/server/src/nest/files/files.module.ts +++ b/server/src/nest/files/files.module.ts @@ -7,9 +7,10 @@ import { PluginGuardsModule } from '../plugins/host/plugin-guards.module'; import { RealtimeModule } from '../realtime/realtime.module'; import { PermissionsModule } from '../permissions/permissions.module'; import { AppConfigModule } from '../app-config/app-config.module'; +import { EphemeralTokenModule } from '../auth/ephemeral-token.module'; @Module({ - imports: [PermissionsModule, AppConfigModule, RealtimeModule, PluginGuardsModule], + imports: [EphemeralTokenModule, PermissionsModule, AppConfigModule, RealtimeModule, PluginGuardsModule], controllers: [FilesController, FilesDownloadController], providers: [FilesService, FilesRpc], exports: [FilesService], diff --git a/server/src/nest/files/files.service.ts b/server/src/nest/files/files.service.ts index 5f37cb73f..33b52768b 100644 --- a/server/src/nest/files/files.service.ts +++ b/server/src/nest/files/files.service.ts @@ -6,7 +6,7 @@ import type { TrekWsPayload, TrekWsTripEventName } from '@trek/shared'; import { RealtimeService } from '../realtime/realtime.service'; import { PermissionsService } from '../permissions/permissions.service'; import { avatarUrl } from '../common/avatarUrl'; -import { consumeEphemeralToken } from '../auth/ephemeral-tokens'; +import { EphemeralTokenService } from '../auth/ephemeral-token.service'; import { verifyJwtAndLoadUser } from '../auth/jwt-verify'; import type { User, TripFile } from '../../types'; import { DatabaseService, type TripAccess } from '../database/database.service'; @@ -59,6 +59,7 @@ export class FilesService { private readonly db: DatabaseService, private readonly permissions: PermissionsService, private readonly realtime: RealtimeService, + private readonly tokens: EphemeralTokenService, ) {} verifyTripAccess(tripId: string | number, userId: number) { @@ -103,7 +104,7 @@ export class FilesService { } if (queryToken) { - const uid = consumeEphemeralToken(queryToken, 'download'); + const uid = this.tokens.consume(queryToken, 'download'); if (!uid) return { error: 'Invalid or expired token', status: 401 }; return { userId: uid }; } diff --git a/server/src/nest/tokens/token.service.ts b/server/src/nest/tokens/token.service.ts index c8bdca40e..bb028f265 100644 --- a/server/src/nest/tokens/token.service.ts +++ b/server/src/nest/tokens/token.service.ts @@ -1,7 +1,7 @@ import { Injectable } from '@nestjs/common'; import { randomBytes, createHash } from 'crypto'; import { DatabaseService } from '../database/database.service'; -import { createEphemeralToken } from '../auth/ephemeral-tokens'; +import { EphemeralTokenService } from '../auth/ephemeral-token.service'; // Import from sessionManager directly, NOT the ../../mcp barrel: the barrel pulls // the whole tools fan-out (and via the domain bridges, the Nest services) into // every consumer of this module — a nest→mcp→nest module cycle. @@ -27,7 +27,10 @@ import { User } from '../../types'; */ @Injectable() export class TokenService { - constructor(private readonly db: DatabaseService) {} + constructor( + private readonly db: DatabaseService, + private readonly ephemeral: EphemeralTokenService, + ) {} listMcpTokens(userId: number) { return this.db.all( @@ -79,7 +82,7 @@ export class TokenService { // Bind the ws-token to the user's current password_version so a token minted // before a password reset is rejected on connect (defence-in-depth session gate). const pv = this.db.get<{ password_version?: number }>('SELECT password_version FROM users WHERE id = ?', userId)?.password_version ?? 0; - const token = createEphemeralToken(userId, 'ws', { pv }); + const token = this.ephemeral.create(userId, 'ws', { pv }); if (!token) return { error: 'Service unavailable', status: 503 }; return { token }; } @@ -89,7 +92,7 @@ export class TokenService { if (purpose !== 'download') { return { error: 'Invalid purpose', status: 400 }; } - const token = createEphemeralToken(userId, purpose); + const token = this.ephemeral.create(userId, purpose); if (!token) return { error: 'Service unavailable', status: 503 }; return { token }; } diff --git a/server/src/nest/tokens/tokens.module.ts b/server/src/nest/tokens/tokens.module.ts index 3b4b8c38e..dea63a7a4 100644 --- a/server/src/nest/tokens/tokens.module.ts +++ b/server/src/nest/tokens/tokens.module.ts @@ -1,5 +1,6 @@ import { Module } from '@nestjs/common'; import { TokenService } from './token.service'; +import { EphemeralTokenModule } from '../auth/ephemeral-token.module'; /** * Tokens that are not the login JWT: the long-lived MCP tokens a user manages @@ -10,6 +11,7 @@ import { TokenService } from './token.service'; * for it, and any dependency added here lands in all three graphs. */ @Module({ + imports: [EphemeralTokenModule], providers: [TokenService], exports: [TokenService], }) diff --git a/server/src/nest/trips/trips.bridge.ts b/server/src/nest/trips/trips.bridge.ts index d50b80548..66a43b081 100644 --- a/server/src/nest/trips/trips.bridge.ts +++ b/server/src/nest/trips/trips.bridge.ts @@ -25,6 +25,7 @@ import { AccommodationsService } from '../accommodations/accommodations.service' import { TripMembersService } from '../trip-members/trip-members.service'; import { TripReadModelService } from '../trip-read-model/trip-read-model.service'; import { notificationsInstance } from '../notifications/notifications.instance'; +import { EphemeralTokenService } from '../auth/ephemeral-token.service'; /** * Non-Nest entry point for the trip domain — for the two consumers that cannot @@ -75,7 +76,7 @@ const readModel = new TripReadModelService( new CollabService(dbs(), permissions, realtime, notificationsInstance()), places, new TodoService(dbs(), permissions, realtime), - new FilesService(dbs(), permissions, realtime), + new FilesService(dbs(), permissions, realtime, new EphemeralTokenService()), ); export function getTripOwner(tripId: string | number) { diff --git a/server/tests/helpers/mcp-test-controllers.ts b/server/tests/helpers/mcp-test-controllers.ts index f5432dde2..4fa2aba61 100644 --- a/server/tests/helpers/mcp-test-controllers.ts +++ b/server/tests/helpers/mcp-test-controllers.ts @@ -67,6 +67,7 @@ import { RuntimeEnvService } from '../../src/nest/app-config/runtime-env.service import { makeNotificationsService, makeNotificationPreferencesService } from './notifications'; import { AddonsService } from '../../src/nest/addons/addons.service'; import { notificationsStub } from './notifications'; +import { EphemeralTokenService } from '../../src/nest/auth/ephemeral-token.service'; /** * Hand-wired counterpart of the boot-time discovery in McpRegistryService, @@ -78,7 +79,7 @@ import { notificationsStub } from './notifications'; export function createMcpTestRegistry(): McpRegistry { const dbService = new DatabaseService(db); const permissionsService = new PermissionsService(dbService); - const authService = new AuthService(dbService, permissionsService, new AtlasService(dbService)); + const authService = new AuthService(dbService, permissionsService, new AtlasService(dbService), new EphemeralTokenService()); const realtimeService = new RealtimeService(); const queryHelpersService = new QueryHelpersService(dbService); const daysService = new DaysService(dbService, permissionsService, realtimeService, queryHelpersService); @@ -115,7 +116,7 @@ export function createMcpTestRegistry(): McpRegistry { const readModelService = new TripReadModelService( dbService, membersService, daysService, accommodationsService, budgetService, packingService, reservationsService, collabService, placesService, todoService, - new FilesService(dbService, permissionsService, realtimeService), + new FilesService(dbService, permissionsService, realtimeService, new EphemeralTokenService()), ); const calendarService = new CalendarService(dbService, reservationsService); // The nine addon-gated surfaces read their toggle off an injected service now diff --git a/server/tests/helpers/plugin-host.ts b/server/tests/helpers/plugin-host.ts index 09cce17ca..25839f99f 100644 --- a/server/tests/helpers/plugin-host.ts +++ b/server/tests/helpers/plugin-host.ts @@ -63,6 +63,7 @@ import { JournalRpc } from '../../src/nest/journey/journal.rpc'; import { CollectionsRpc } from '../../src/nest/collections/collections.rpc'; import { makeNotificationsService } from './notifications'; import { notificationsStub } from './notifications'; +import { EphemeralTokenService } from '../../src/nest/auth/ephemeral-token.service'; /** * Hand-wired counterpart of the PluginsModule DI graph for no-Nest tests @@ -83,7 +84,7 @@ export function createPluginRpcHostFactory(dbs: DatabaseService): PluginRpcHostF const queryHelpers = new QueryHelpersService(dbs); const todos = new TodoService(dbs, permissions, realtime); const packing = new PackingService(dbs, permissions, realtime, notificationsStub()); - const files = new FilesService(dbs, permissions, realtime); + const files = new FilesService(dbs, permissions, realtime, new EphemeralTokenService()); const reservations = new ReservationsService(dbs, permissions, budget, realtime, notificationsStub()); const collab = new CollabService(dbs, permissions, realtime, notificationsStub()); const vacay = new VacayService(dbs, realtime, notificationsStub()); diff --git a/server/tests/unit/auto-backup.test.ts b/server/tests/unit/auto-backup.test.ts index 362d94ae2..30e2472ac 100644 --- a/server/tests/unit/auto-backup.test.ts +++ b/server/tests/unit/auto-backup.test.ts @@ -9,9 +9,12 @@ * which snapshots the DB with VACUUM INTO and bundles the key. */ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import type { PathLike } from 'node:fs'; const fsMock = vi.hoisted(() => ({ - existsSync: vi.fn(() => false), + // The parameter is declared so the double carries fs's real signature: the + // cases below replace this default with a path-dependent implementation. + existsSync: vi.fn((_p: PathLike) => false), mkdirSync: vi.fn(), readFileSync: vi.fn(() => '{}'), writeFileSync: vi.fn(), @@ -85,7 +88,14 @@ function scheduledRun(): () => Promise { fsMock.readFileSync.mockReturnValue(JSON.stringify({ enabled: true, interval: 'daily', keep_days: 7 })); // The same wiring index.ts does from the container, with the real service // function behind it — the run below is still the production code path. - setSchedulerDeps({ backups: { createBackup } }); + // Only the backup tick is started here; the scheduler's other two container + // collaborators are stubbed so the deps object is complete, and neither the + // place-photo sweep nor the AirTrail tick is scheduled by start(). + setSchedulerDeps({ + backups: { createBackup }, + placePhotos: { sweepOrphans: vi.fn(() => 0) }, + airtrail: { runAirtrailSync: vi.fn(async () => {}) }, + }); start(); return cronMock.schedule.mock.calls.at(-1)?.[1] as () => Promise; } diff --git a/server/tests/unit/mcp/tools-budget-advanced.test.ts b/server/tests/unit/mcp/tools-budget-advanced.test.ts index 1047d0771..cfc2ef4d8 100644 --- a/server/tests/unit/mcp/tools-budget-advanced.test.ts +++ b/server/tests/unit/mcp/tools-budget-advanced.test.ts @@ -38,7 +38,7 @@ import { createTables } from '../../../src/db/schema'; import { runMigrations } from '../../../src/db/migrations'; import { resetTestDb } from '../../helpers/test-db'; import { createUser, createTrip, createBudgetItem, addTripMember } from '../../helpers/factories'; -import { createMcpHarness, parseToolResult, type McpHarness } from '../../helpers/mcp-harness'; +import { createMcpHarness, parseToolResult, parseResourceResult, type McpHarness } from '../../helpers/mcp-harness'; beforeAll(() => { createTables(testDb); @@ -364,7 +364,7 @@ describe('Resource: trek://trips/{tripId}/budget/per-person', () => { const trip = createTrip(testDb, user.id); await withResourceHarness(user.id, async (h) => { const result = await h.client.readResource({ uri: `trek://trips/${trip.id}/budget/per-person` }); - const data = JSON.parse(result.contents[0].text as string); + const data = parseResourceResult(result); expect(Array.isArray(data)).toBe(true); }); }); @@ -375,7 +375,7 @@ describe('Resource: trek://trips/{tripId}/budget/per-person', () => { const trip = createTrip(testDb, other.id); await withResourceHarness(user.id, async (h) => { const result = await h.client.readResource({ uri: `trek://trips/${trip.id}/budget/per-person` }); - const data = JSON.parse(result.contents[0].text as string); + const data = parseResourceResult(result) as { error?: string }; expect(data.error).toBeDefined(); }); }); @@ -391,7 +391,9 @@ describe('Resource: trek://trips/{tripId}/budget/settlement', () => { const trip = createTrip(testDb, user.id); await withResourceHarness(user.id, async (h) => { const result = await h.client.readResource({ uri: `trek://trips/${trip.id}/budget/settlement` }); - const data = JSON.parse(result.contents[0].text as string); + // The resource may answer with a summary object or a bare array, and the + // assertion below accepts either, so keep the balances field optional. + const data = parseResourceResult(result) as { balances?: unknown }; expect(data).toBeDefined(); expect(Array.isArray(data.balances) || Array.isArray(data)).toBe(true); }); diff --git a/server/tests/unit/nest/addons.service.test.ts b/server/tests/unit/nest/addons.service.test.ts index 81ac0a2b3..ab7b4e273 100644 --- a/server/tests/unit/nest/addons.service.test.ts +++ b/server/tests/unit/nest/addons.service.test.ts @@ -19,6 +19,30 @@ function svc() { return new AddonsService(new DatabaseService(dbConn)); } +type ListAddon = ReturnType['addons'][number]; + +type PhotoProviderField = { + key: string; + label: string; + input_type: string; + placeholder: string; + hint: string | null; + required: boolean; + secret: boolean; + settings_key: string | null; + payload_key: string | null; + sort_order: number; +}; + +// list() spreads plain addons and photo providers into one array, and a provider +// object structurally extends a plain one. TypeScript reduces the element union +// to the plain shape, so `config` and `fields` are invisible on res.addons even +// though the provider entries carry them at runtime. Provider assertions read +// the fields through here instead of casting at every call site. +function providerFields(addon: ListAddon): PhotoProviderField[] { + return (addon as ListAddon & { fields: PhotoProviderField[] }).fields; +} + // Feed list()'s reads in call order: addons, providers, fields (.all), then the // collab-features rows (.all) and the bag-tracking row (.get) from app_settings. function feedReads( @@ -140,8 +164,7 @@ describe('AddonsService.list', () => { ); const res = svc().list(); - const provider = res.addons[0] as { fields: Array> }; - expect(provider.fields).toEqual([ + expect(providerFields(res.addons[0])).toEqual([ { key: 'url', label: 'URL', @@ -188,7 +211,7 @@ describe('AddonsService.list', () => { ); const res = svc().list(); - const field = (res.addons[0] as { fields: Array> }).fields[0]; + const field = providerFields(res.addons[0])[0]; expect(field).toMatchObject({ placeholder: '', hint: null, @@ -217,7 +240,7 @@ describe('AddonsService.list', () => { ); const res = svc().list(); - expect((res.addons[0] as { fields: unknown[] }).fields).toEqual([]); + expect(providerFields(res.addons[0])).toEqual([]); }); it('concatenates regular addons before the photo providers', () => { diff --git a/server/tests/unit/nest/admin.service.test.ts b/server/tests/unit/nest/admin.service.test.ts index f9ad564e9..4044cf773 100644 --- a/server/tests/unit/nest/admin.service.test.ts +++ b/server/tests/unit/nest/admin.service.test.ts @@ -74,6 +74,7 @@ import { AdminService } from '../../../src/nest/admin/admin.service'; import { checkAndNotifyVersion as bridgeCheckAndNotifyVersion } from '../../../src/nest/admin/admin.bridge'; import { __clearVersionCacheForTests } from '../../../src/nest/admin/admin.helpers'; import { makeNotificationsService, makeNotificationPreferencesService } from '../../helpers/notifications'; +import { EphemeralTokenService } from '../../../src/nest/auth/ephemeral-token.service'; const dbs = new DatabaseService(testDb); const realtime = new RealtimeService(); @@ -84,7 +85,7 @@ const userCleanup = new UserCleanupService(dbs); // and the mailer was missing entirely, so `auth` was built with its last four // collaborators shifted by one. Nothing failed, because none of the cases below // reach a path that uses them. -const auth = new AuthService(dbs, permissions, new TripMembershipService(dbs), webauthn, userCleanup, new MailerService(dbs)); +const auth = new AuthService(dbs, permissions, new TripMembershipService(dbs), webauthn, userCleanup, new MailerService(dbs), new EphemeralTokenService()); const svc = new AdminService( dbs, new AddonsService(dbs), diff --git a/server/tests/unit/nest/admin.version-notification.test.ts b/server/tests/unit/nest/admin.version-notification.test.ts index 4403247ce..84e202137 100644 --- a/server/tests/unit/nest/admin.version-notification.test.ts +++ b/server/tests/unit/nest/admin.version-notification.test.ts @@ -56,13 +56,14 @@ import { PermissionsService } from '../../../src/nest/permissions/permissions.se import { NotificationsService } from '../../../src/nest/notifications/notifications.service'; import { AdminService } from '../../../src/nest/admin/admin.service'; import { makeNotificationsService, makeNotificationPreferencesService } from '../../helpers/notifications'; +import { EphemeralTokenService } from '../../../src/nest/auth/ephemeral-token.service'; const dbs = new DatabaseService(testDb); const realtime = new RealtimeService(); const permissions = new PermissionsService(dbs); const webauthn = new WebauthnConfigService(dbs); const userCleanup = new UserCleanupService(dbs); -const auth = new AuthService(dbs, permissions, new AtlasService(dbs), new TripMembershipService(dbs), webauthn, userCleanup); +const auth = new AuthService(dbs, permissions, new AtlasService(dbs), new TripMembershipService(dbs), webauthn, userCleanup, new EphemeralTokenService()); const svc = new AdminService( dbs, new AddonsService(dbs), diff --git a/server/tests/unit/nest/assignments.service.test.ts b/server/tests/unit/nest/assignments.service.test.ts index 712aee6d8..74c84be5b 100644 --- a/server/tests/unit/nest/assignments.service.test.ts +++ b/server/tests/unit/nest/assignments.service.test.ts @@ -48,14 +48,35 @@ import { createTables } from '../../../src/db/schema'; import { runMigrations } from '../../../src/db/migrations'; import { resetTestDb } from '../../helpers/test-db'; import { createUser, createTrip, addTripMember, createDay, createPlace, createDayAssignment, createTag } from '../../helpers/factories'; -import { DatabaseService } from '../../../src/nest/database/database.service'; +import { DatabaseService, type TripAccess } from '../../../src/nest/database/database.service'; import { PermissionsService } from '../../../src/nest/permissions/permissions.service'; import { AssignmentsService } from '../../../src/nest/assignments/assignments.service'; import { createAssignment, dayExists, placeExists, getAssignmentForTrip } from '../../../src/nest/assignments/assignments.bridge'; import { RealtimeService } from '../../../src/nest/realtime/realtime.service'; import { QueryHelpersService } from '../../../src/nest/query-helpers/query-helpers.service'; +import { JourneyDomainService } from '../../../src/nest/journey/journey-domain.service'; +import { TrekPhotosRepository } from '../../../src/nest/photos/trek-photos.repository'; -const svc = new AssignmentsService(new DatabaseService(testDb), new PermissionsService(new DatabaseService(testDb)), new RealtimeService(), new QueryHelpersService(new DatabaseService(testDb))); +const dbs = new DatabaseService(testDb); +const realtime = new RealtimeService(); +const svc = new AssignmentsService( + dbs, + new PermissionsService(dbs), + realtime, + new QueryHelpersService(dbs), + // Real collaborator rather than a stub: reconcile() runs after every mutation + // and needs the same connection to see the rows these cases write. + new JourneyDomainService(dbs, realtime, new TrekPhotosRepository(dbs)), +); + +/** + * canEdit only reads trip.user_id, so the case below hands it just that field. + * The cast names the omission instead of padding the fixture with an id and a + * currency no assertion looks at. + */ +function tripOwnedBy(userId: number): TripAccess { + return { user_id: userId } as TripAccess; +} beforeAll(() => { createTables(testDb); @@ -101,8 +122,8 @@ describe('canEdit', () => { const { user, trip } = fixture(); const { user: member } = createUser(testDb); addTripMember(testDb, trip.id, member.id); - expect(svc.canEdit({ user_id: trip.user_id }, user as never)).toBe(true); - expect(svc.canEdit({ user_id: trip.user_id }, member as never)).toBe(true); + expect(svc.canEdit(tripOwnedBy(trip.user_id), user)).toBe(true); + expect(svc.canEdit(tripOwnedBy(trip.user_id), member)).toBe(true); }); }); diff --git a/server/tests/unit/nest/auth.service.test.ts b/server/tests/unit/nest/auth.service.test.ts index 1bcb3302a..9211315c0 100644 --- a/server/tests/unit/nest/auth.service.test.ts +++ b/server/tests/unit/nest/auth.service.test.ts @@ -85,6 +85,7 @@ import { UserCleanupService } from '../../../src/nest/auth/user-cleanup.service' import { WebauthnConfigService } from '../../../src/nest/auth/webauthn-config.service'; import { revokeUserSessions } from '../../../src/mcp/sessionManager'; import { MailerService } from '../../../src/nest/notifications/mailer/mailer.service'; +import { EphemeralTokenService } from '../../../src/nest/auth/ephemeral-token.service'; // MailerService is injected since the notifications fold — a stub instead of a // module mock. sendPasswordResetEmail is the only thing auth reaches for. @@ -97,7 +98,7 @@ const membershipStub = { joinTripAsMember } as unknown as TripMembershipService; // Tokens left AuthService for tokens/token.service.ts. The two cases below still // need one as a fixture: changePassword prunes MCP tokens, and the bridge parity // case verifies a token it just minted. -const tokens = new TokenService(new DatabaseService(testDb)); +const tokens = new TokenService(new DatabaseService(testDb), new EphemeralTokenService()); const svc = new AuthService( new DatabaseService(testDb), new PermissionsService(new DatabaseService(testDb)), @@ -105,6 +106,7 @@ const svc = new AuthService( new WebauthnConfigService(new DatabaseService(testDb)), new UserCleanupService(new DatabaseService(testDb)), mailerStub, + new EphemeralTokenService(), ); // --------------------------------------------------------------------------- diff --git a/server/tests/unit/nest/files.controller.test.ts b/server/tests/unit/nest/files.controller.test.ts index c0272271d..da0a1abb4 100644 --- a/server/tests/unit/nest/files.controller.test.ts +++ b/server/tests/unit/nest/files.controller.test.ts @@ -129,16 +129,16 @@ describe('FilesController (parity with the legacy /api/trips/:tripId/files route }); it('DELETE /:id/permanent 404 not in trash, else deletes', async () => { - await expect(new FilesController(fsvc({ getDeletedFile: vi.fn().mockReturnValue(undefined) } as Partial), new RuntimeEnvService()).permanent(user, '5', '9')).rejects.toBeInstanceOf(HttpException); + await expect(new FilesController(fsvc({ getDeletedFile: vi.fn().mockReturnValue(undefined) } as Partial), new RuntimeEnvService()).permanent(user, trip, '5', '9')).rejects.toBeInstanceOf(HttpException); const permanentDeleteFile = vi.fn().mockResolvedValue(undefined); const s = fsvc({ getDeletedFile: vi.fn().mockReturnValue({ id: 9 }), permanentDeleteFile, broadcast: vi.fn() } as Partial); - expect(await new FilesController(s, new RuntimeEnvService()).permanent(user, '5', '9')).toEqual({ success: true }); + expect(await new FilesController(s, new RuntimeEnvService()).permanent(user, trip, '5', '9')).toEqual({ success: true }); }); it('DELETE /trash/empty 403, else returns the count', async () => { - await expect(new FilesController(fsvc({ can: vi.fn().mockReturnValue(false) }), new RuntimeEnvService()).emptyTrash(user, '5')).rejects.toBeInstanceOf(HttpException); + await expect(new FilesController(fsvc({ can: vi.fn().mockReturnValue(false) }), new RuntimeEnvService()).emptyTrash(user, trip, '5')).rejects.toBeInstanceOf(HttpException); const s = fsvc({ emptyTrash: vi.fn().mockResolvedValue(3) } as Partial); - expect(await new FilesController(s, new RuntimeEnvService()).emptyTrash(user, '5')).toEqual({ success: true, deleted: 3 }); + expect(await new FilesController(s, new RuntimeEnvService()).emptyTrash(user, trip, '5')).toEqual({ success: true, deleted: 3 }); }); it('POST /:id/link 404 unknown file, else links', () => { @@ -158,7 +158,7 @@ describe('FilesController (parity with the legacy /api/trips/:tripId/files route it('the trash + link routes all reject without file_delete / file_edit', async () => { const denied = () => fsvc({ can: vi.fn().mockReturnValue(false) }); - await expect(new FilesController(denied(), new RuntimeEnvService()).permanent(user, '5', '9')).rejects.toMatchObject({ status: 403 }); + await expect(new FilesController(denied(), new RuntimeEnvService()).permanent(user, trip, '5', '9')).rejects.toMatchObject({ status: 403 }); expect(thrown(() => new FilesController(denied(), new RuntimeEnvService()).restore(user, trip, '5', '9'))).toEqual({ status: 403, body: { error: 'No permission' } }); expect(thrown(() => new FilesController(denied(), new RuntimeEnvService()).link(user, trip, '5', '9', {}))).toEqual({ status: 403, body: { error: 'No permission' } }); expect(thrown(() => new FilesController(denied(), new RuntimeEnvService()).unlink(user, trip, '5', '9', '3'))).toEqual({ status: 403, body: { error: 'No permission' } }); diff --git a/server/tests/unit/nest/files.service.test.ts b/server/tests/unit/nest/files.service.test.ts index ede131512..59cc01c58 100644 --- a/server/tests/unit/nest/files.service.test.ts +++ b/server/tests/unit/nest/files.service.test.ts @@ -59,7 +59,7 @@ import { createTables } from '../../../src/db/schema'; import { runMigrations } from '../../../src/db/migrations'; import { resetTestDb } from '../../helpers/test-db'; import { createUser, createTrip, addTripMember, createPlace, createReservation, createDay, createDayAssignment, setAppSetting } from '../../helpers/factories'; -import { DatabaseService } from '../../../src/nest/database/database.service'; +import { DatabaseService, type TripAccess } from '../../../src/nest/database/database.service'; import type { PermissionsService } from '../../../src/nest/permissions/permissions.service'; import { FilesService } from '../../../src/nest/files/files.service'; import { getAllowedExtensions as bridgeGetAllowedExtensions } from '../../../src/nest/files/files.bridge'; @@ -74,8 +74,9 @@ import { } from '../../../src/nest/files/files.constants'; import type { TripFile, User } from '../../../src/types'; import { RealtimeService } from '../../../src/nest/realtime/realtime.service'; +import { EphemeralTokenService } from '../../../src/nest/auth/ephemeral-token.service'; -const svc = new FilesService(new DatabaseService(testDb), permissionsStub, new RealtimeService()); +const svc = new FilesService(new DatabaseService(testDb), permissionsStub, new RealtimeService(), new EphemeralTokenService()); beforeAll(() => { createTables(testDb); @@ -183,7 +184,7 @@ describe('getAllowedExtensions', () => { }); it('FILE-SVC-009: returns the default when the query throws (no app_settings table)', () => { - const bareSvc = new FilesService(new DatabaseService(bareDb), permissionsStub, new RealtimeService()); + const bareSvc = new FilesService(new DatabaseService(bareDb), permissionsStub, new RealtimeService(), new EphemeralTokenService()); expect(bareSvc.getAllowedExtensions()).toBe(DEFAULT_ALLOWED_EXTENSIONS); }); }); @@ -317,9 +318,9 @@ describe('toggleStarred / softDeleteFile / restoreFile', () => { it('FILE-SVC-019: toggleStarred flips 0→1 and 1→0', () => { const { user, trip } = seedTrip(); const file = makeFile(trip.id, user.id); - const on = svc.toggleStarred(file.id, 0) as Record; + const on = svc.toggleStarred(file.id, 0); expect(on.starred).toBe(1); - const off = svc.toggleStarred(file.id, on.starred) as Record; + const off = svc.toggleStarred(file.id, on.starred); expect(off.starred).toBe(0); }); @@ -444,21 +445,33 @@ describe('findForeignLinkTarget', () => { // ── file links ──────────────────────────────────────────────────────────────── +// The link queries go through the untyped DatabaseService.all, so the service +// hands back unknown[]. Name the columns these cases read instead of widening +// every row to Record and losing the id's type on the way into +// deleteFileLink. +type FileLinkRow = { + id: number; + file_id: number; + reservation_id: number | null; + place_id: number | null; + reservation_title?: string | null; +}; + describe('createFileLink / deleteFileLink / getFileLinks', () => { it('FILE-SVC-029: inserts with || null coercion, dedupes via INSERT OR IGNORE and returns the re-select', () => { const { user, trip } = seedTrip(); const reservation = createReservation(testDb, trip.id, { title: 'Ferry' }); const file = makeFile(trip.id, user.id); - const links = svc.createFileLink(file.id, { reservation_id: reservation.id, place_id: '' }) as Record[]; + const links = svc.createFileLink(file.id, { reservation_id: reservation.id, place_id: '' }) as FileLinkRow[]; expect(links).toHaveLength(1); expect(links[0].reservation_id).toBe(reservation.id); expect(links[0].place_id).toBeNull(); - const again = svc.createFileLink(file.id, { reservation_id: reservation.id }) as Record[]; + const again = svc.createFileLink(file.id, { reservation_id: reservation.id }) as FileLinkRow[]; expect(again).toHaveLength(1); // UNIQUE(file_id, reservation_id) + OR IGNORE - const hydrated = svc.getFileLinks(file.id) as Record[]; + const hydrated = svc.getFileLinks(file.id) as FileLinkRow[]; expect(hydrated[0].reservation_title).toBe('Ferry'); }); @@ -473,7 +486,7 @@ describe('createFileLink / deleteFileLink / getFileLinks', () => { const reservation = createReservation(testDb, trip.id); const file = makeFile(trip.id, user.id); const other = makeFile(trip.id, user.id); - const [link] = svc.createFileLink(file.id, { reservation_id: reservation.id }) as Record[]; + const [link] = svc.createFileLink(file.id, { reservation_id: reservation.id }) as FileLinkRow[]; svc.deleteFileLink(link.id, other.id); // wrong file — no-op expect(svc.getFileLinks(file.id)).toHaveLength(1); @@ -534,7 +547,9 @@ describe('verifyTripAccess / can / files.bridge', () => { const { user, trip } = seedTrip(); const guest = { id: user.id + 1, role: 'user' } as unknown as User; checkPermission.mockReturnValue(true); - const tripRow = { id: trip.id, user_id: user.id } as Record; + // can() reads only trip.user_id, so the row stays deliberately partial: + // TripAccess also carries currency, which no assertion here looks at. + const tripRow = { id: trip.id, user_id: user.id } as TripAccess; expect(svc.can('file_edit', tripRow, guest)).toBe(true); expect(checkPermission).toHaveBeenCalledWith('file_edit', 'user', user.id, guest.id, true); diff --git a/server/tests/unit/nest/maps.service.test.ts b/server/tests/unit/nest/maps.service.test.ts index b1350d9d2..f31587166 100644 --- a/server/tests/unit/nest/maps.service.test.ts +++ b/server/tests/unit/nest/maps.service.test.ts @@ -28,6 +28,13 @@ import { import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest'; +// The seams below stand in for real collaborators, so they are typed from those +// collaborators' signatures rather than from their own default implementations. +// checkSsrf answers with a full SsrfResult; the guard path exercised here reads +// only these two fields, so the stub returns that slice instead of inventing a +// resolved IP and a privacy verdict no assertion looks at. +type SsrfCheckStub = Pick; + const { mockDbGet, mockDbRun, @@ -42,16 +49,20 @@ const { } = vi.hoisted(() => ({ mockDbGet: vi.fn(() => undefined as any), mockDbRun: vi.fn(), - mockCheckSsrf: vi.fn(async () => ({ allowed: true })), - mockCacheGet: vi.fn(() => null as any), - mockCacheGetErrored: vi.fn(() => false), + mockCheckSsrf: vi.fn(async (_url: string, _bypassInternalIpAllowed?: boolean): Promise => ({ + allowed: true, + })), + mockCacheGet: vi.fn((_placeId: string) => null as ReturnType), + mockCacheGetErrored: vi.fn((_placeId: string) => false), mockCacheMarkError: vi.fn(), mockCachePut: vi.fn(async (placeId: string, _bytes: Buffer, attribution: string | null) => ({ photoUrl: `/api/maps/place-photo/${encodeURIComponent(placeId)}/bytes`, filePath: `/tmp/${placeId}.jpg`, attribution, })), - mockCacheGetInFlight: vi.fn(() => undefined), + mockCacheGetInFlight: vi.fn( + (_placeId: string) => undefined as ReturnType, + ), mockCacheSetInFlight: vi.fn(), mockServeFilePath: vi.fn((_placeId: string) => null as string | null), })); @@ -108,6 +119,8 @@ import { db } from '../../../src/db/database'; import { DatabaseService } from '../../../src/nest/database/database.service'; import { MapsService, withPhotoFetchSlot, readWikiIdentity } from '../../../src/nest/maps/maps.service'; import type { PlacePhotoCacheService } from '../../../src/nest/place-photos/place-photo-cache.service'; +// Type-only, so the module stays mocked: this import is erased at runtime. +import type { SsrfResult } from '../../../src/utils/ssrfGuard'; // The service under test, constructed over the mocked db stub — DatabaseService // routes get/run through the stubbed prepare(), so mockDbGet/mockDbRun keep @@ -1832,7 +1845,7 @@ describe('getPlacePhoto (fetch stubbed)', () => { }); it('MAPS-043f: an in-flight lookup that found nothing resolves the waiter with photoUrl null', async () => { - mockCacheGetInFlight.mockReturnValue(Promise.resolve(null) as any); + mockCacheGetInFlight.mockReturnValue(Promise.resolve(null)); const fetchMock = vi.fn(); vi.stubGlobal('fetch', fetchMock); await expect(svc.getPlacePhoto(999, 'ChIJInFlight', 48.8, 2.3)).resolves.toEqual({ diff --git a/server/tests/unit/nest/place-enrichment.service.test.ts b/server/tests/unit/nest/place-enrichment.service.test.ts index 7b70e28f0..7754088a4 100644 --- a/server/tests/unit/nest/place-enrichment.service.test.ts +++ b/server/tests/unit/nest/place-enrichment.service.test.ts @@ -7,9 +7,15 @@ */ import { describe, it, expect, vi, beforeEach } from 'vitest'; +/** Whatever the statement under test selects: a settings row, a cache row, or nothing. */ +type DbRow = Record | undefined; + const { mockDbGet, mockDbRun, mockSafeFetchFollow } = vi.hoisted(() => ({ - mockDbGet: vi.fn(() => undefined as any), - mockDbRun: vi.fn(), + // Signature taken from the seam below rather than from this default: the + // statement text is folded in ahead of the params, so a no-argument spy + // would reject both the call site and every mockImplementation here. + mockDbGet: vi.fn((_sql: string, ..._params: unknown[]): DbRow => undefined), + mockDbRun: vi.fn((_sql: string, ..._params: unknown[]) => {}), mockSafeFetchFollow: vi.fn(async () => ({ ok: true, arrayBuffer: async () => new Uint8Array([1, 2, 3]).buffer })), })); diff --git a/server/tests/unit/nest/places.service.test.ts b/server/tests/unit/nest/places.service.test.ts index 1ff3a0769..66f123b29 100644 --- a/server/tests/unit/nest/places.service.test.ts +++ b/server/tests/unit/nest/places.service.test.ts @@ -54,6 +54,10 @@ vi.mock('../../../src/config', () => ({ // Spy on the photo-cache reclaim hook so delete tests assert the wiring without // touching disk. The removal logic itself is covered in placePhotoCache.test.ts. // Injected stub since the photo-cache fold (was a partial path mock). +// Only `removeIfUnreferenced` is stubbed: it is the single cache method anything +// in this file reaches. MapsService takes the same instance (as it does in +// production) but never gets far enough to call it, since every maps path here +// stops at the missing API key. const removeIfUnreferencedSpy = vi.fn(); const photoCacheStub = { removeIfUnreferenced: removeIfUnreferencedSpy } as unknown as PlacePhotoCacheService; @@ -69,13 +73,38 @@ import { RealtimeService } from '../../../src/nest/realtime/realtime.service'; import { PlacesService } from '../../../src/nest/places/places.service'; import { MapsService } from '../../../src/nest/maps/maps.service'; import { QueryHelpersService } from '../../../src/nest/query-helpers/query-helpers.service'; +import { JourneyDomainService } from '../../../src/nest/journey/journey-domain.service'; +import { TrekPhotosRepository } from '../../../src/nest/photos/trek-photos.repository'; import { PLACE_IMAGES_DIR } from '../../../src/nest/places/place-image'; const GPX_FIXTURE = path.join(__dirname, '../../fixtures/test.gpx'); const KML_FIXTURE = path.join(__dirname, '../../fixtures/test.kml'); const dbs = new DatabaseService(testDb); -const svc = new PlacesService(dbs, new PermissionsService(dbs), new RealtimeService(), new MapsService(dbs), new QueryHelpersService(dbs), new UnsplashService(dbs, new RuntimeEnvService()), photoCacheStub); + +/** + * Same collaborator set the container hands PlacesService (see trips.bridge.ts), + * built once here so the two construction sites cannot drift apart again. The + * journey domain is a real instance on the same in-memory DB rather than a stub: + * its place hooks are fire-and-forget behind a catch, so a missing one would have + * looked like a pass while silently swallowing a TypeError. No test in this file + * creates a journey, so every hook returns on its first lookup. + * `maps` is a parameter because the enrichment cases hand in their own provider. + */ +function makePlacesService(maps: MapsService = new MapsService(dbs, photoCacheStub)): PlacesService { + return new PlacesService( + dbs, + new PermissionsService(dbs), + new RealtimeService(), + maps, + new QueryHelpersService(dbs), + new UnsplashService(dbs, new RuntimeEnvService()), + photoCacheStub, + new JourneyDomainService(dbs, new RealtimeService(), new TrekPhotosRepository(dbs)), + ); +} + +const svc = makePlacesService(); beforeAll(() => { createTables(testDb); @@ -1012,8 +1041,9 @@ describe('PlacesService — automatic track colours (#776)', () => { // ── Import enrichment (#886) ────────────────────────────────────────────────── describe('enrichImportedPlaces', () => { + // Deliberately partial: each case stubs only the provider calls its path reaches. function enrichSvc(maps: Partial) { - return new PlacesService(dbs, new PermissionsService(dbs), new RealtimeService(), maps as MapsService, new QueryHelpersService(dbs), new UnsplashService(dbs, new RuntimeEnvService()), photoCacheStub); + return makePlacesService(maps as MapsService); } it('PLACE-SVC-058 — no-ops when no Google Maps key is configured', async () => { diff --git a/server/tests/unit/nest/token.service.test.ts b/server/tests/unit/nest/token.service.test.ts index a379b7d13..2c87dddf2 100644 --- a/server/tests/unit/nest/token.service.test.ts +++ b/server/tests/unit/nest/token.service.test.ts @@ -46,8 +46,9 @@ import { DatabaseService } from '../../../src/nest/database/database.service'; import { createEphemeralToken } from '../../../src/nest/auth/ephemeral-tokens'; import { revokeUserSessions } from '../../../src/mcp/sessionManager'; import { expectRegisteredProvider } from '../../helpers/module-providers'; +import { EphemeralTokenService } from '../../../src/nest/auth/ephemeral-token.service'; -const svc = new TokenService(new DatabaseService(testDb)); +const svc = new TokenService(new DatabaseService(testDb), new EphemeralTokenService()); beforeAll(() => { createTables(testDb); 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 59081e775..5c99e1fce 100644 --- a/server/tests/unit/nest/trip-read-model.service.test.ts +++ b/server/tests/unit/nest/trip-read-model.service.test.ts @@ -67,6 +67,7 @@ import { AccommodationsService } from '../../../src/nest/accommodations/accommod import { MapsService } from '../../../src/nest/maps/maps.service'; import { QueryHelpersService } from '../../../src/nest/query-helpers/query-helpers.service'; import { notificationsStub } from '../../helpers/notifications'; +import { EphemeralTokenService } from '../../../src/nest/auth/ephemeral-token.service'; // Real sibling services over the same in-memory DB — the aggregation runs the // actual SQL of every domain it fans out to, so a shape change downstream shows @@ -86,7 +87,7 @@ const buildReadModel = (database: DatabaseService, roster: TripMembersService = new CollabService(dbs(), new PermissionsService(dbs()), new RealtimeService(), notificationsStub()), placesSvc, new TodoService(dbs(), new PermissionsService(dbs()), new RealtimeService()), - new FilesService(dbs(), new PermissionsService(dbs()), new RealtimeService()), + new FilesService(dbs(), new PermissionsService(dbs()), new RealtimeService(), new EphemeralTokenService()), ); const svc = buildReadModel(dbs()); diff --git a/server/tests/unit/nest/trips.controller.test.ts b/server/tests/unit/nest/trips.controller.test.ts index 08dc9dd41..1b09a4fac 100644 --- a/server/tests/unit/nest/trips.controller.test.ts +++ b/server/tests/unit/nest/trips.controller.test.ts @@ -20,6 +20,7 @@ const unsplashStub = { } as unknown as UnsplashService; import { TripsController } from '../../../src/nest/trips/trips.controller'; +import type { AuditService } from '../../../src/nest/audit/audit.service'; import type { TripsService } from '../../../src/nest/trips/trips.service'; import type { CalendarService } from '../../../src/nest/calendar/calendar.service'; import type { TripReadModelService } from '../../../src/nest/trip-read-model/trip-read-model.service'; @@ -51,6 +52,14 @@ function svc(o: Partial = {}): TripsService { } as unknown as TripsService; } +// The DTO ratchet types create()'s body from the shared contract, where +// day_count is a number. Two cases below deliberately hand the handler a string, +// because that is what an unvalidated client sends and what the handler still +// guards against with Number(day_count) || 7. Naming the pre-pipe shape keeps +// those payloads exactly as they were instead of retyping them into the contract. +type CreateBody = Parameters[1]; +const prePipeCreateBody = (body: unknown) => body as CreateBody; + function thrown(fn: () => unknown): { status: number; body: unknown } { try { fn(); } catch (err) { expect(err).toBeInstanceOf(HttpException); @@ -163,13 +172,13 @@ describe('TripsController (parity with the legacy /api/trips route)', () => { it('infers start_date from end_date (-6 days) and parses day_count', () => { const create = vi.fn().mockReturnValue({ trip: { id: 9 }, tripId: 9, reminderDays: 0 }); - tc(svc({ create } as Partial)).create(user, { title: 'T', end_date: '2026-07-07', day_count: '40' }, req); + tc(svc({ create } as Partial)).create(user, prePipeCreateBody({ title: 'T', end_date: '2026-07-07', day_count: '40' }), req); expect(create).toHaveBeenCalledWith(1, expect.objectContaining({ start_date: '2026-07-01', end_date: '2026-07-07', day_count: 40 })); }); it('clamps a non-numeric day_count to the default of 7', () => { const create = vi.fn().mockReturnValue({ trip: { id: 9 }, tripId: 9, reminderDays: 0 }); - tc(svc({ create } as Partial)).create(user, { title: 'T', day_count: 'abc' }, req); + tc(svc({ create } as Partial)).create(user, prePipeCreateBody({ title: 'T', day_count: 'abc' }), req); expect(create).toHaveBeenCalledWith(1, expect.objectContaining({ day_count: 7 })); }); diff --git a/server/tests/unit/nest/trips.service.test.ts b/server/tests/unit/nest/trips.service.test.ts index a17aaa3d0..6a179ce4f 100644 --- a/server/tests/unit/nest/trips.service.test.ts +++ b/server/tests/unit/nest/trips.service.test.ts @@ -78,6 +78,7 @@ import { getTripOwner, listMembers as bridgeListMembers } from '../../../src/nes import { QueryHelpersService } from '../../../src/nest/query-helpers/query-helpers.service'; import fs from 'fs'; import { notificationsStub } from '../../helpers/notifications'; +import { EphemeralTokenService } from '../../../src/nest/auth/ephemeral-token.service'; // Real sibling services over the same in-memory DB — updateTrip's date-shift // resyncs and the summary/bundle aggregation run their actual SQL. @@ -106,7 +107,7 @@ const readModelSvc = new TripReadModelService( new CollabService(dbs(), new PermissionsService(dbs()), new RealtimeService(), notificationsStub()), placesSvc, new TodoService(dbs(), new PermissionsService(dbs()), new RealtimeService()), - new FilesService(dbs(), new PermissionsService(dbs()), new RealtimeService()), + new FilesService(dbs(), new PermissionsService(dbs()), new RealtimeService(), new EphemeralTokenService()), ); diff --git a/server/tests/unit/places/places.rpc.test.ts b/server/tests/unit/places/places.rpc.test.ts index d8fce8ccf..c807d8232 100644 --- a/server/tests/unit/places/places.rpc.test.ts +++ b/server/tests/unit/places/places.rpc.test.ts @@ -24,20 +24,26 @@ import { makeDeps } from '../../helpers/rpc-host-deps'; const req = (method: string, params: Record = {}): RpcRequest => ({ k: 'req', id: 'x', method, params }); -/** Trip 1 belongs to user 42; place 7 sits on it. */ +/** + * Trip 1 belongs to user 42; place 7 sits on it. + * + * The doubles keep their vi.fn types here and only wear the service type at the + * constructor. Stamping the service type onto the object itself puts the real method + * signature in front of the mock, and the cases below read .mock off these methods. + */ function build(opts: { canEdit?: boolean; journeyThrows?: boolean } = {}) { const places = { create: vi.fn((_t: string, i: Record) => ({ id: 10, ...i })), update: vi.fn((_t: string, id: string) => (id === '7' ? { id: 7, name: 'updated' } : null)), get: vi.fn((_t: string, id: string) => (id === '7' ? { id: 7 } : undefined)), remove: vi.fn((_t: string, id: string) => id === '7'), - } as unknown as PlacesService & Record>; + }; const journey = { onPlaceCreated: vi.fn(() => { if (opts.journeyThrows) throw new Error('journey down'); }), onPlaceUpdated: vi.fn(() => { if (opts.journeyThrows) throw new Error('journey down'); }), onPlaceDeleted: vi.fn(() => { if (opts.journeyThrows) throw new Error('journey down'); }), - } as unknown as JourneyDomainService & Record>; - const realtime = { broadcast: vi.fn() } as unknown as RealtimeService & { broadcast: ReturnType }; + }; + const realtime = { broadcast: vi.fn() }; const guards = new PluginGuards( { canAccessTrip: vi.fn((tripId: number, userId: number) => (tripId === 1 && userId === 42 ? { id: 1, user_id: 42 } : undefined)), @@ -46,7 +52,13 @@ function build(opts: { canEdit?: boolean; journeyThrows?: boolean } = {}) { { checkPermission: vi.fn(() => opts.canEdit ?? true) } as unknown as PermissionsService, { isAddonEnabled: vi.fn(() => true) } as unknown as AddonsService, ); - const rpc = new PlacesRpc(places, journey, realtime, guards); + // Each double covers only the methods PlacesRpc reaches for, hence the cast. + const rpc = new PlacesRpc( + places as unknown as PlacesService, + journey as unknown as JourneyDomainService, + realtime as unknown as RealtimeService, + guards, + ); const host = (...grants: string[]) => new PluginRpcHost('p', new Set(grants), makeDeps(), createTestPluginRegistry([rpc])); return { places, journey, realtime, host }; } diff --git a/server/tests/unit/plugins/operator-egress.test.ts b/server/tests/unit/plugins/operator-egress.test.ts index 40de0ba30..25392eabe 100644 --- a/server/tests/unit/plugins/operator-egress.test.ts +++ b/server/tests/unit/plugins/operator-egress.test.ts @@ -30,6 +30,7 @@ import { PluginRuntimeService } from '../../../src/nest/plugins/plugin-runtime.s import { createPluginRuntime } from '../../helpers/plugin-host'; import { parseManifest, ManifestError } from '../../../src/nest/plugins/install/manifest'; import { makeHostAllow } from '../../../src/nest/plugins/runtime/egress-policy'; +import { AddonsService } from '../../../src/nest/addons/addons.service'; function install(id: string, operatorEgress: boolean, perms: string[] = ['http:outbound:gotify.net']) { testDb.prepare( @@ -159,13 +160,20 @@ describe('the admin list surfaces operator egress (so the chip can be shown)', ( install('gotify', true); install('plain', false); - const before = new PluginsService(new DatabaseService(dbConn)).list().plugins; + // list() resolves required-addon dependencies through AddonsService, so it gets a real + // one over the same DB. These fixtures declare no dependencies, so it is never consulted. + const listPlugins = () => { + const dbs = new DatabaseService(dbConn); + return new PluginsService(dbs, new AddonsService(dbs)).list().plugins; + }; + + const before = listPlugins(); expect(before.find(p => p.id === 'gotify')).toMatchObject({ operatorEgress: true, egressHostCount: 0 }); // A plugin that never asked for it must never invite the admin to add hosts. expect(before.find(p => p.id === 'plain')).toMatchObject({ operatorEgress: false, egressHostCount: 0 }); await rt.setOperatorEgressHosts('gotify', ['a.example.com', 'b.example.com']); - const after = new PluginsService(new DatabaseService(dbConn)).list().plugins; + const after = listPlugins(); expect(after.find(p => p.id === 'gotify')!.egressHostCount).toBe(2); delete process.env.TREK_PLUGINS_ENABLED; }); diff --git a/server/tests/unit/plugins/supervisor-lifecycle.test.ts b/server/tests/unit/plugins/supervisor-lifecycle.test.ts index ce780c653..b52fb7a14 100644 --- a/server/tests/unit/plugins/supervisor-lifecycle.test.ts +++ b/server/tests/unit/plugins/supervisor-lifecycle.test.ts @@ -17,8 +17,9 @@ function makeSupervisor() { (s as any).spawn = spawn; return { s, spawn, dispose }; } +// Reaches into the private `running` map so a case can assert on the live entry. // eslint-disable-next-line @typescript-eslint/no-explicit-any -const entry = (s: PluginSupervisor, id: string, status: string) => (s as any).running.get(id); +const entry = (s: PluginSupervisor, id: string): { status: string } => (s as any).running.get(id); describe('supervisor re-activation after failure', () => { const supers: PluginSupervisor[] = []; diff --git a/server/tests/unit/scheduler.test.ts b/server/tests/unit/scheduler.test.ts index aea4d9455..f75801951 100644 --- a/server/tests/unit/scheduler.test.ts +++ b/server/tests/unit/scheduler.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest'; // Prevent node-cron from scheduling anything at import time vi.mock('node-cron', () => ({ @@ -41,6 +41,20 @@ vi.mock('../../src/nest/audit/audit-log.logger', () => ({ import fs from 'node:fs'; import { buildCronExpression, cleanupOldBackups } from '../../src/scheduler'; +// readdirSync and statSync are overloaded in node:fs, and vi.mocked() picks the last +// overload (Dirent[] / BigIntStats) rather than the one the scheduler calls. These handles +// point at the very same mock functions from the factory above, pinned to the plain +// string[] / Stats signatures that cleanupOldBackups actually uses. +const readdirSyncMock = fs.readdirSync as unknown as Mock<(path: string) => string[]>; +const statSyncMock = fs.statSync as unknown as Mock<(path: string) => fs.Stats>; +const unlinkSyncMock = fs.unlinkSync as unknown as Mock<(path: string) => void>; + +// cleanupOldBackups reads nothing but mtimeMs off a stat result, so the stubs below stay +// deliberately partial instead of faking a whole fs.Stats. +function statStub(partial: Partial): fs.Stats { + return partial as fs.Stats; +} + interface BackupSettings { enabled: boolean; interval: string; @@ -149,70 +163,70 @@ describe('cleanupOldBackups', () => { } beforeEach(() => { - vi.mocked(fs.readdirSync).mockReset(); - vi.mocked(fs.statSync).mockReset(); - vi.mocked(fs.unlinkSync as ReturnType).mockReset(); - (vi.mocked(fs.statSync) as ReturnType).mockReturnValue({ mtime: new Date(), mtimeMs: NOW, birthtimeMs: NOW, size: 0 }); + readdirSyncMock.mockReset(); + statSyncMock.mockReset(); + unlinkSyncMock.mockReset(); + statSyncMock.mockReturnValue(statStub({ mtime: new Date(), mtimeMs: NOW, birthtimeMs: NOW, size: 0 })); }); it('never deletes manual backup-*.zip files regardless of age', () => { const manual = isoFilename(365 * 5, 'backup'); const auto = isoFilename(0); - vi.mocked(fs.readdirSync).mockReturnValue([manual, auto] as unknown as string[]); + readdirSyncMock.mockReturnValue([manual, auto]); cleanupOldBackups(7, NOW); - const deleted = (vi.mocked(fs.unlinkSync as ReturnType)).mock.calls.map((c: unknown[]) => c[0] as string); - expect(deleted.some((p: string) => p.includes(manual))).toBe(false); + const deleted = unlinkSyncMock.mock.calls.map(([p]) => p); + expect(deleted.some(p => p.includes(manual))).toBe(false); }); it('keeps auto-backups newer than retention', () => { const recent = isoFilename(3); - vi.mocked(fs.readdirSync).mockReturnValue([recent] as unknown as string[]); + readdirSyncMock.mockReturnValue([recent]); cleanupOldBackups(7, NOW); - expect(vi.mocked(fs.unlinkSync as ReturnType)).not.toHaveBeenCalled(); + expect(unlinkSyncMock).not.toHaveBeenCalled(); }); it('deletes auto-backups older than retention', () => { const old = isoFilename(30); - vi.mocked(fs.readdirSync).mockReturnValue([old] as unknown as string[]); + readdirSyncMock.mockReturnValue([old]); cleanupOldBackups(7, NOW); - expect(vi.mocked(fs.unlinkSync as ReturnType)).toHaveBeenCalledOnce(); - const [calledPath] = (vi.mocked(fs.unlinkSync as ReturnType)).mock.calls[0] as string[]; + expect(unlinkSyncMock).toHaveBeenCalledOnce(); + const [calledPath] = unlinkSyncMock.mock.calls[0]; expect(calledPath).toContain(old); }); it('overlayfs regression: birthtimeMs=0 does not delete a same-day backup', () => { const fresh = isoFilename(0); - vi.mocked(fs.readdirSync).mockReturnValue([fresh] as unknown as string[]); - (vi.mocked(fs.statSync) as ReturnType).mockReturnValue({ birthtimeMs: 0, mtimeMs: NOW, mtime: new Date(NOW), size: 100 }); + readdirSyncMock.mockReturnValue([fresh]); + statSyncMock.mockReturnValue(statStub({ birthtimeMs: 0, mtimeMs: NOW, mtime: new Date(NOW), size: 100 })); cleanupOldBackups(7, NOW); - expect(vi.mocked(fs.unlinkSync as ReturnType)).not.toHaveBeenCalled(); + expect(unlinkSyncMock).not.toHaveBeenCalled(); }); it('malformed filename falls back to mtimeMs: keeps recent file', () => { - vi.mocked(fs.readdirSync).mockReturnValue(['auto-backup-garbage.zip'] as unknown as string[]); - (vi.mocked(fs.statSync) as ReturnType).mockReturnValue({ birthtimeMs: 0, mtimeMs: NOW - 1 * DAY, mtime: new Date(NOW - 1 * DAY), size: 0 }); + readdirSyncMock.mockReturnValue(['auto-backup-garbage.zip']); + statSyncMock.mockReturnValue(statStub({ birthtimeMs: 0, mtimeMs: NOW - 1 * DAY, mtime: new Date(NOW - 1 * DAY), size: 0 })); cleanupOldBackups(7, NOW); - expect(vi.mocked(fs.unlinkSync as ReturnType)).not.toHaveBeenCalled(); + expect(unlinkSyncMock).not.toHaveBeenCalled(); }); it('malformed filename falls back to mtimeMs: deletes stale file', () => { - vi.mocked(fs.readdirSync).mockReturnValue(['auto-backup-garbage.zip'] as unknown as string[]); - (vi.mocked(fs.statSync) as ReturnType).mockReturnValue({ birthtimeMs: 0, mtimeMs: NOW - 30 * DAY, mtime: new Date(NOW - 30 * DAY), size: 0 }); + readdirSyncMock.mockReturnValue(['auto-backup-garbage.zip']); + statSyncMock.mockReturnValue(statStub({ birthtimeMs: 0, mtimeMs: NOW - 30 * DAY, mtime: new Date(NOW - 30 * DAY), size: 0 })); cleanupOldBackups(7, NOW); - expect(vi.mocked(fs.unlinkSync as ReturnType)).toHaveBeenCalledOnce(); + expect(unlinkSyncMock).toHaveBeenCalledOnce(); }); it('ignores non-zip files and does not crash', () => { const old = isoFilename(30); - vi.mocked(fs.readdirSync).mockReturnValue([old, 'notes.txt'] as unknown as string[]); + readdirSyncMock.mockReturnValue([old, 'notes.txt']); cleanupOldBackups(7, NOW); - const calls = (vi.mocked(fs.unlinkSync as ReturnType)).mock.calls as string[][]; - expect(calls.every(([p]: string[]) => !p.includes('notes.txt'))).toBe(true); + const calls = unlinkSyncMock.mock.calls; + expect(calls.every(([p]) => !p.includes('notes.txt'))).toBe(true); expect(calls.length).toBe(1); }); it('swallows readdirSync errors without throwing', () => { - vi.mocked(fs.readdirSync).mockImplementation(() => { throw new Error('ENOENT'); }); + readdirSyncMock.mockImplementation(() => { throw new Error('ENOENT'); }); expect(() => cleanupOldBackups(7, NOW)).not.toThrow(); }); }); diff --git a/server/tests/unit/services/conflictUpdate.test.ts b/server/tests/unit/services/conflictUpdate.test.ts index fd9c2f52a..286fdcd6e 100644 --- a/server/tests/unit/services/conflictUpdate.test.ts +++ b/server/tests/unit/services/conflictUpdate.test.ts @@ -46,10 +46,31 @@ import { MapsService } from '../../../src/nest/maps/maps.service'; import { PermissionsService } from '../../../src/nest/permissions/permissions.service'; import { RealtimeService } from '../../../src/nest/realtime/realtime.service'; import { QueryHelpersService } from '../../../src/nest/query-helpers/query-helpers.service'; +import { UnsplashService } from '../../../src/nest/unsplash/unsplash.service'; +import { PlacePhotoCacheService } from '../../../src/nest/place-photos/place-photo-cache.service'; +import { JourneyDomainService } from '../../../src/nest/journey/journey-domain.service'; +import { TrekPhotosRepository } from '../../../src/nest/photos/trek-photos.repository'; +import { RuntimeEnvService } from '../../../src/nest/app-config/runtime-env.service'; import { notificationsStub } from '../../helpers/notifications'; -const packing = new PackingService(new DatabaseService(testDb), new PermissionsService(new DatabaseService(testDb)), new RealtimeService(), notificationsStub()); -const places = new PlacesService(new DatabaseService(testDb), new PermissionsService(new DatabaseService(testDb)), new RealtimeService(), new MapsService(new DatabaseService(testDb)), new QueryHelpersService(new DatabaseService(testDb))); +const dbs = new DatabaseService(testDb); +const realtime = new RealtimeService(); +const runtimeEnv = new RuntimeEnvService(); +// One cache instance shared by maps and places, the way the container wires it: +// the service's stampede guard only works if there is exactly one of them. +const photoCache = new PlacePhotoCacheService(dbs, runtimeEnv); + +const packing = new PackingService(dbs, new PermissionsService(dbs), realtime, notificationsStub()); +const places = new PlacesService( + dbs, + new PermissionsService(dbs), + realtime, + new MapsService(dbs, photoCache), + new QueryHelpersService(dbs), + new UnsplashService(dbs, runtimeEnv), + photoCache, + new JourneyDomainService(dbs, realtime, new TrekPhotosRepository(dbs)), +); beforeAll(() => { createTables(testDb); diff --git a/server/tests/unit/systemNotices/conditions.test.ts b/server/tests/unit/systemNotices/conditions.test.ts index 0450f1474..2aa214295 100644 --- a/server/tests/unit/systemNotices/conditions.test.ts +++ b/server/tests/unit/systemNotices/conditions.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect } from 'vitest'; import { evaluate } from '../../../src/systemNotices/conditions.js'; -import type { SystemNotice } from '../../../src/systemNotices/types.js'; +import type { NoticeCondition, SystemNotice } from '../../../src/systemNotices/types.js'; const baseNotice: SystemNotice = { id: 'test', @@ -13,6 +13,11 @@ const baseNotice: SystemNotice = { publishedAt: '2026-01-01T00:00:00Z', }; +// Conditions have to be contextually typed as NoticeCondition, otherwise a literal like +// `roles: ['user']` widens to string[] and no longer matches the union's +// Array<'admin' | 'user'>. Going through this helper types every case at once. +const noticeWith = (...conditions: NoticeCondition[]): SystemNotice => ({ ...baseNotice, conditions }); + const baseCtx = { // noTrips joined the context when the 'noTrips' condition kind landed. 1 keeps // every case below reading as it did: the condition is `noTrips === 0`, so a @@ -23,7 +28,7 @@ const baseCtx = { }; describe('firstLogin', () => { - const notice = { ...baseNotice, conditions: [{ kind: 'firstLogin' as const }] }; + const notice = noticeWith({ kind: 'firstLogin' }); it('passes when login_count <= 1', () => { expect(evaluate(notice, { ...baseCtx, user: { ...baseCtx.user, login_count: 1 } })).toBe(true); }); @@ -33,7 +38,7 @@ describe('firstLogin', () => { }); describe('existingUserBeforeVersion', () => { - const notice = { ...baseNotice, conditions: [{ kind: 'existingUserBeforeVersion' as const, version: '2.0.0' }] }; + const notice = noticeWith({ kind: 'existingUserBeforeVersion', version: '2.0.0' }); it('passes for user with first_seen_version < notice version when current >= notice version', () => { expect(evaluate(notice, baseCtx)).toBe(true); }); @@ -53,36 +58,36 @@ describe('existingUserBeforeVersion', () => { describe('dateWindow', () => { it('passes when now is inside window', () => { - const notice = { ...baseNotice, conditions: [{ kind: 'dateWindow' as const, startsAt: '2026-05-01T00:00:00Z', endsAt: '2026-07-01T00:00:00Z' }] }; + const notice = noticeWith({ kind: 'dateWindow', startsAt: '2026-05-01T00:00:00Z', endsAt: '2026-07-01T00:00:00Z' }); expect(evaluate(notice, baseCtx)).toBe(true); }); it('fails when now is before start', () => { - const notice = { ...baseNotice, conditions: [{ kind: 'dateWindow' as const, startsAt: '2026-07-01T00:00:00Z' }] }; + const notice = noticeWith({ kind: 'dateWindow', startsAt: '2026-07-01T00:00:00Z' }); expect(evaluate(notice, baseCtx)).toBe(false); }); it('passes when no endsAt', () => { - const notice = { ...baseNotice, conditions: [{ kind: 'dateWindow' as const, startsAt: '2026-01-01T00:00:00Z' }] }; + const notice = noticeWith({ kind: 'dateWindow', startsAt: '2026-01-01T00:00:00Z' }); expect(evaluate(notice, baseCtx)).toBe(true); }); }); describe('role', () => { it('passes for matching role', () => { - const notice = { ...baseNotice, conditions: [{ kind: 'role' as const, roles: ['user'] }] }; + const notice = noticeWith({ kind: 'role', roles: ['user'] }); expect(evaluate(notice, baseCtx)).toBe(true); }); it('fails for non-matching role', () => { - const notice = { ...baseNotice, conditions: [{ kind: 'role' as const, roles: ['admin'] }] }; + const notice = noticeWith({ kind: 'role', roles: ['admin'] }); expect(evaluate(notice, baseCtx)).toBe(false); }); }); describe('AND logic', () => { it('requires all conditions to pass', () => { - const notice = { ...baseNotice, conditions: [ - { kind: 'firstLogin' as const }, - { kind: 'role' as const, roles: ['user'] }, - ]}; + const notice = noticeWith( + { kind: 'firstLogin' }, + { kind: 'role', roles: ['user'] }, + ); // login_count=1 passes firstLogin, role=user passes role → true expect(evaluate(notice, { ...baseCtx, user: { ...baseCtx.user, login_count: 1 } })).toBe(true); // login_count=2 fails firstLogin → false