diff --git a/server/src/nest/accommodations/accommodations.mcp.ts b/server/src/nest/accommodations/accommodations.mcp.ts index e0de5bca7..7c5d60d91 100644 --- a/server/src/nest/accommodations/accommodations.mcp.ts +++ b/server/src/nest/accommodations/accommodations.mcp.ts @@ -68,7 +68,7 @@ export class AccommodationsMcp { }, ctx: McpContext, ) { - if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); + if (await this.auth.isDemoUser(ctx.userId)) return demoDenied(); if (!this.accommodations.verifyTripAccess(tripId, ctx.userId)) return noAccess(); if (!(await this.guards.hasTripPermission('day_edit', tripId, ctx.userId))) return permissionDenied(); const errors = this.accommodations.validateAccommodationRefs(tripId, place_id, start_day_id, end_day_id); @@ -119,7 +119,7 @@ export class AccommodationsMcp { }, ctx: McpContext, ) { - if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); + if (await this.auth.isDemoUser(ctx.userId)) return demoDenied(); if (!this.accommodations.verifyTripAccess(tripId, ctx.userId)) return noAccess(); if (!(await this.guards.hasTripPermission('day_edit', tripId, ctx.userId))) return permissionDenied(); const dayErrors = this.accommodations.validateAccommodationRefs(tripId, undefined, start_day_id, end_day_id); @@ -167,7 +167,7 @@ export class AccommodationsMcp { }, ctx: McpContext, ) { - if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); + if (await this.auth.isDemoUser(ctx.userId)) return demoDenied(); if (!this.accommodations.verifyTripAccess(tripId, ctx.userId)) return noAccess(); if (!(await this.guards.hasTripPermission('day_edit', tripId, ctx.userId))) return permissionDenied(); const existing = this.accommodations.getAccommodation(accommodationId, tripId); @@ -191,7 +191,7 @@ export class AccommodationsMcp { access: { group: 'trips', mode: 'write' }, }) async deleteAccommodation({ tripId, accommodationId }: { tripId: number; accommodationId: number }, ctx: McpContext) { - if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); + if (await this.auth.isDemoUser(ctx.userId)) return demoDenied(); if (!this.accommodations.verifyTripAccess(tripId, ctx.userId)) return noAccess(); if (!(await this.guards.hasTripPermission('day_edit', tripId, ctx.userId))) return permissionDenied(); if (!this.accommodations.getAccommodation(accommodationId, tripId)) return { content: [{ type: 'text' as const, text: 'Accommodation not found.' }], isError: true }; diff --git a/server/src/nest/admin/admin.controller.ts b/server/src/nest/admin/admin.controller.ts index a48bcb987..e3d141acc 100644 --- a/server/src/nest/admin/admin.controller.ts +++ b/server/src/nest/admin/admin.controller.ts @@ -103,7 +103,7 @@ export class AdminController { @Delete('users/:id/passkeys') async resetUserPasskeys(@CurrentUser() user: User, @Param('id') id: string, @Req() req: Request) { - const result = ok(this.admin.resetUserPasskeys(id)); + const result = ok(await this.admin.resetUserPasskeys(id)); await this.audit.writeAudit({ userId: user.id, action: 'admin.user_passkeys_reset', resource: String(id), ip: getClientIp(req), details: { targetUser: result.email, deleted: result.deleted } }); return { success: true, deleted: result.deleted }; } @@ -163,23 +163,23 @@ export class AdminController { // ── Invites ── @Get('invites') - listInvites() { return { invites: this.invites.listInvites() }; } + async listInvites() { return { invites: await this.invites.listInvites() }; } // Trips an admin can optionally bind a registration invite to (#1402). @Get('invites/trips') - listInviteTrips() { return { trips: this.invites.listTripsForInvite() }; } + async listInviteTrips() { return { trips: await this.invites.listTripsForInvite() }; } @Post('invites') @HttpCode(201) async createInvite(@CurrentUser() user: User, @Body() body: AdminInviteCreateDto, @Req() req: Request) { - const result = this.invites.createInvite(user.id, body); + const result = await this.invites.createInvite(user.id, body); await this.audit.writeAudit({ userId: user.id, action: 'admin.invite_create', resource: String(result.inviteId), ip: getClientIp(req), details: { max_uses: result.uses, expires_in_days: result.expiresInDays, trip_id: result.tripId } }); return { invite: result.invite }; } @Delete('invites/:id') async deleteInvite(@CurrentUser() user: User, @Param('id') id: string, @Req() req: Request) { - ok(this.invites.deleteInvite(id)); + ok(await this.invites.deleteInvite(id)); await this.audit.writeAudit({ userId: user.id, action: 'admin.invite_delete', resource: String(id), ip: getClientIp(req) }); return { success: true }; } diff --git a/server/src/nest/assignments/assignments.mcp.ts b/server/src/nest/assignments/assignments.mcp.ts index b4e050b38..9cd5bdbf5 100644 --- a/server/src/nest/assignments/assignments.mcp.ts +++ b/server/src/nest/assignments/assignments.mcp.ts @@ -46,7 +46,7 @@ export class AssignmentsMcp { { tripId, dayId, placeId, notes }: { tripId: number; dayId: number; placeId: number; notes?: string }, ctx: McpContext, ) { - if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); + if (await this.auth.isDemoUser(ctx.userId)) return demoDenied(); if (!this.assignments.verifyTripAccess(tripId, ctx.userId)) return noAccess(); if (!(await this.guards.hasTripPermission('day_edit', tripId, ctx.userId))) return permissionDenied(); if (!this.assignments.dayExists(dayId, tripId)) return errorResult('Day not found.'); @@ -72,7 +72,7 @@ export class AssignmentsMcp { { tripId, assignmentId, end_day }: AssignmentEndDayRequest & { tripId: number; assignmentId: number }, ctx: McpContext, ) { - if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); + if (await this.auth.isDemoUser(ctx.userId)) return demoDenied(); if (!this.assignments.verifyTripAccess(tripId, ctx.userId)) return noAccess(); if (!(await this.guards.hasTripPermission('day_edit', tripId, ctx.userId))) return permissionDenied(); if (!this.assignments.getAssignmentForTrip(assignmentId, tripId)) return errorResult('Assignment not found.'); @@ -96,7 +96,7 @@ export class AssignmentsMcp { { tripId, dayId, assignmentId }: { tripId: number; dayId: number; assignmentId: number }, ctx: McpContext, ) { - if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); + if (await this.auth.isDemoUser(ctx.userId)) return demoDenied(); if (!this.assignments.verifyTripAccess(tripId, ctx.userId)) return noAccess(); if (!(await this.guards.hasTripPermission('day_edit', tripId, ctx.userId))) return permissionDenied(); if (!this.assignments.assignmentExistsInDay(assignmentId, dayId, tripId)) @@ -125,7 +125,7 @@ export class AssignmentsMcp { }, ctx: McpContext, ) { - if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); + if (await this.auth.isDemoUser(ctx.userId)) return demoDenied(); if (!this.assignments.verifyTripAccess(tripId, ctx.userId)) return noAccess(); if (!(await this.guards.hasTripPermission('day_edit', tripId, ctx.userId))) return permissionDenied(); const existing = this.assignments.getAssignmentForTrip(assignmentId, tripId); @@ -161,7 +161,7 @@ export class AssignmentsMcp { { tripId, assignmentId, notes }: { tripId: number; assignmentId: number; notes: string | null }, ctx: McpContext, ) { - if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); + if (await this.auth.isDemoUser(ctx.userId)) return demoDenied(); if (!this.assignments.verifyTripAccess(tripId, ctx.userId)) return noAccess(); if (!(await this.guards.hasTripPermission('day_edit', tripId, ctx.userId))) return permissionDenied(); if (!this.assignments.getAssignmentForTrip(assignmentId, tripId)) return errorResult('Assignment not found.'); @@ -188,7 +188,7 @@ export class AssignmentsMcp { }, ctx: McpContext, ) { - if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); + if (await this.auth.isDemoUser(ctx.userId)) return demoDenied(); if (!this.assignments.verifyTripAccess(tripId, ctx.userId)) return noAccess(); if (!(await this.guards.hasTripPermission('day_edit', tripId, ctx.userId))) return permissionDenied(); if (!this.assignments.getAssignmentForTrip(assignmentId, tripId)) return errorResult('Assignment not found.'); @@ -218,7 +218,7 @@ export class AssignmentsMcp { }, ctx: McpContext, ) { - if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); + if (await this.auth.isDemoUser(ctx.userId)) return demoDenied(); if (!this.assignments.verifyTripAccess(tripId, ctx.userId)) return noAccess(); if (!(await this.guards.hasTripPermission('day_edit', tripId, ctx.userId))) return permissionDenied(); if (!this.assignments.getAssignmentForTrip(assignmentId, tripId)) return errorResult('Assignment not found.'); @@ -267,7 +267,7 @@ export class AssignmentsMcp { { tripId, assignmentId, userIds }: { tripId: number; assignmentId: number; userIds: number[] }, ctx: McpContext, ) { - if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); + if (await this.auth.isDemoUser(ctx.userId)) return demoDenied(); if (!this.assignments.verifyTripAccess(tripId, ctx.userId)) return noAccess(); if (!(await this.guards.hasTripPermission('day_edit', tripId, ctx.userId))) return permissionDenied(); if (!this.assignments.getAssignmentForTrip(assignmentId, tripId)) return errorResult('Assignment not found.'); @@ -291,7 +291,7 @@ export class AssignmentsMcp { { tripId, dayId, assignmentIds }: { tripId: number; dayId: number; assignmentIds: number[] }, ctx: McpContext, ) { - if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); + if (await this.auth.isDemoUser(ctx.userId)) return demoDenied(); if (!this.assignments.verifyTripAccess(tripId, ctx.userId)) return noAccess(); if (!(await this.guards.hasTripPermission('day_edit', tripId, ctx.userId))) return permissionDenied(); if (!(await this.days.getDay(dayId, tripId))) return errorResult('Day not found.'); diff --git a/server/src/nest/atlas/atlas.mcp.ts b/server/src/nest/atlas/atlas.mcp.ts index 4afe30009..51b17eb7c 100644 --- a/server/src/nest/atlas/atlas.mcp.ts +++ b/server/src/nest/atlas/atlas.mcp.ts @@ -86,7 +86,7 @@ export class AtlasMcp { { name, lat, lng, country_code, notes, target_date }: { name: string; lat?: number; lng?: number; country_code?: string; notes?: string; target_date?: string | null }, ctx: McpContext, ) { - if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); + if (await this.auth.isDemoUser(ctx.userId)) return demoDenied(); try { const item = await this.atlas.createBucketItem(ctx.userId, { name, lat, lng, country_code, notes, target_date }); return ok({ item }); @@ -107,7 +107,7 @@ export class AtlasMcp { access: { group: 'atlas', mode: 'write' }, }) async deleteBucketListItem({ itemId }: { itemId: number }, ctx: McpContext) { - if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); + if (await this.auth.isDemoUser(ctx.userId)) return demoDenied(); const deleted = await this.atlas.deleteBucketItem(ctx.userId, itemId); if (!deleted) return { content: [{ type: 'text' as const, text: 'Bucket list item not found.' }], isError: true }; return ok({ success: true }); @@ -126,7 +126,7 @@ export class AtlasMcp { access: { group: 'atlas', mode: 'write' }, }) async markCountryVisited({ country_code }: { country_code: string }, ctx: McpContext) { - if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); + if (await this.auth.isDemoUser(ctx.userId)) return demoDenied(); await this.atlas.markCountry(ctx.userId, country_code.toUpperCase()); return ok({ success: true, country_code: country_code.toUpperCase() }); } @@ -142,7 +142,7 @@ export class AtlasMcp { access: { group: 'atlas', mode: 'write' }, }) async unmarkCountryVisited({ country_code }: { country_code: string }, ctx: McpContext) { - if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); + if (await this.auth.isDemoUser(ctx.userId)) return demoDenied(); await this.atlas.unmarkCountry(ctx.userId, country_code.toUpperCase()); return ok({ success: true, country_code: country_code.toUpperCase() }); } @@ -226,7 +226,7 @@ export class AtlasMcp { { regionCode, regionName, countryCode }: { regionCode: string; regionName: string; countryCode: string }, ctx: McpContext, ) { - if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); + if (await this.auth.isDemoUser(ctx.userId)) return demoDenied(); // Post-fold quirk fix: uppercase both codes, matching the REST controller // (the legacy registrar passed them through verbatim, so a lowercase mark // created a row REST's uppercased unmark could never hit). @@ -251,7 +251,7 @@ export class AtlasMcp { access: { group: 'atlas', mode: 'write' }, }) async unmarkRegionVisited({ regionCode }: { regionCode: string }, ctx: McpContext) { - if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); + if (await this.auth.isDemoUser(ctx.userId)) return demoDenied(); // Post-fold quirk fix: uppercase, matching the REST controller. await this.atlas.unmarkRegion(ctx.userId, regionCode.toUpperCase()); return ok({ success: true }); @@ -302,7 +302,7 @@ export class AtlasMcp { }, ctx: McpContext, ) { - if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); + if (await this.auth.isDemoUser(ctx.userId)) return demoDenied(); let item: unknown; try { item = await this.atlas.updateBucketItem(ctx.userId, itemId, { name, notes, lat, lng, country_code, target_date }); diff --git a/server/src/nest/auth/auth-public.controller.ts b/server/src/nest/auth/auth-public.controller.ts index b29b25c57..0093a02f1 100644 --- a/server/src/nest/auth/auth-public.controller.ts +++ b/server/src/nest/auth/auth-public.controller.ts @@ -45,8 +45,8 @@ export class AuthPublicController { @Post('demo-login') @Public('issues a session for the demo account; there is nothing to authenticate yet') @HttpCode(200) - demoLogin(@Req() req: Request, @Res({ passthrough: true }) res: Response) { - const result = this.auth.demoLogin(); + async demoLogin(@Req() req: Request, @Res({ passthrough: true }) res: Response) { + const result = await this.auth.demoLogin(); if (result.error) { throw new HttpException({ error: result.error }, result.status!); } @@ -56,9 +56,9 @@ export class AuthPublicController { @Get('invite/:token') @Public('the invite token IS the credential') - invite(@Param('token') token: string, @Req() req: Request) { + async invite(@Param('token') token: string, @Req() req: Request) { this.limit('login', req, 10); - const result = this.auth.validateInviteToken(token); + const result = await this.auth.validateInviteToken(token); if (result.error) { throw new HttpException({ error: result.error }, result.status!); } @@ -85,7 +85,7 @@ export class AuthPublicController { async login(@Body() body: LoginDto, @Req() req: Request, @Res({ passthrough: true }) res: Response) { this.limit('login', req, 10); const started = Date.now(); - const result = this.auth.loginUser(body); + const result = await this.auth.loginUser(body); if (result.auditAction) { await this.audit.writeAudit({ userId: result.auditUserId ?? null, action: result.auditAction, ip: getClientIp(req), details: result.auditDetails }); } @@ -116,7 +116,7 @@ export class AuthPublicController { const rawEmail = typeof body?.email === 'string' ? body.email : ''; const ip = getClientIp(req); - const outcome = this.auth.requestPasswordReset(rawEmail, ip); + const outcome = await this.auth.requestPasswordReset(rawEmail, ip); if (outcome.reason === 'issued' && outcome.tokenForDelivery && outcome.userEmail) { const origin = this.auth.getAppUrl(); const url = `${origin.replace(/\/$/, '')}/reset-password?token=${encodeURIComponent(outcome.tokenForDelivery)}`; @@ -143,7 +143,7 @@ export class AuthPublicController { // a dedicated bucket) — without it reset tokens could be guessed unthrottled. this.limit('reset', req, 5); const ip = getClientIp(req); - const result = this.auth.resetPassword(body); + const result = await this.auth.resetPassword(body); if (result.error) { await this.audit.writeAudit({ userId: null, action: 'user.password_reset_fail', ip, details: { reason: result.error } }); throw new HttpException({ error: result.error }, result.status!); @@ -160,7 +160,7 @@ export class AuthPublicController { @HttpCode(200) async verifyMfaLogin(@Body() body: MfaVerifyLoginDto, @Req() req: Request, @Res({ passthrough: true }) res: Response) { this.limit('mfa', req, 5); - const result = this.auth.verifyMfaLogin(body); + const result = await this.auth.verifyMfaLogin(body); if (result.error) { throw new HttpException({ error: result.error }, result.status!); } diff --git a/server/src/nest/auth/auth.controller.ts b/server/src/nest/auth/auth.controller.ts index eadbd6fff..b7c1ef24f 100644 --- a/server/src/nest/auth/auth.controller.ts +++ b/server/src/nest/auth/auth.controller.ts @@ -98,8 +98,8 @@ export class AuthController { @Get('me') @MfaExempt('the client needs to know who it is to render the setup screen') - me(@CurrentUser() user: User) { - const loaded = this.auth.getCurrentUser(user.id); + async me(@CurrentUser() user: User) { + const loaded = await this.auth.getCurrentUser(user.id); if (!loaded) { throw new HttpException({ error: 'User not found' }, 404); } @@ -113,7 +113,7 @@ export class AuthController { // "remember me" login survives a password change (#1927). Bearer callers // have no cookie → undefined → the historical default duration. const remember = decodeSessionClaims((req.cookies as Record | undefined)?.trek_session)?.remember; - const result = this.auth.changePassword(user.id, user.email, body, remember); + const result = await this.auth.changePassword(user.id, user.email, body, remember); if (result.error) { throw new HttpException({ error: result.error }, result.status!); } @@ -213,8 +213,8 @@ export class AuthController { } @Get('users') - users(@CurrentUser() user: User) { - return { users: this.profile.listUsers(user.id) }; + async users(@CurrentUser() user: User) { + return { users: await this.profile.listUsers(user.id) }; } @ManagedForbidden('validating a key spends the operator quota on a test click') @@ -229,8 +229,8 @@ export class AuthController { @Get('app-settings') @MfaExempt('the setup screen reads the policy it is asking the user to satisfy') - getAppSettings(@CurrentUser() user: User) { - const result = this.auth.getAppSettings(user.id); + async getAppSettings(@CurrentUser() user: User) { + const result = await this.auth.getAppSettings(user.id); if (result.error) { throw new HttpException({ error: result.error }, result.status!); } @@ -240,7 +240,7 @@ export class AuthController { @Put('app-settings') @MfaExempt('an admin locked out by their own policy must still be able to lift it') async updateAppSettings(@CurrentUser() user: User, @Body() body: AppSettingsUpdateDto, @Req() req: Request) { - const result = this.auth.updateAppSettings(user.id, body); + const result = await this.auth.updateAppSettings(user.id, body); if (result.error) { throw new HttpException({ error: result.error }, result.status!); } @@ -258,7 +258,7 @@ export class AuthController { @MfaExempt('completing setup is the way out of the policy') @HttpCode(200) async mfaSetup(@CurrentUser() user: User) { - const result = this.auth.setupMfa(user.id, user.email); + const result = await this.auth.setupMfa(user.id, user.email); if (result.error) { throw new HttpException({ error: result.error }, result.status!); } @@ -276,7 +276,7 @@ export class AuthController { @HttpCode(200) async mfaEnable(@CurrentUser() user: User, @Body() body: MfaEnableDto, @Req() req: Request) { this.limit('mfa', req, 5); - const result = this.auth.enableMfa(user.id, body.code); + const result = await this.auth.enableMfa(user.id, body.code); if (result.error) { throw new HttpException({ error: result.error }, result.status!); } @@ -288,7 +288,7 @@ export class AuthController { @HttpCode(200) async mfaDisable(@CurrentUser() user: User, @Body() body: MfaDisableDto, @Req() req: Request) { this.limit('login', req, 5); - const result = this.auth.disableMfa(user.id, user.email, body); + const result = await this.auth.disableMfa(user.id, user.email, body); if (result.error) { throw new HttpException({ error: result.error }, result.status!); } diff --git a/server/src/nest/auth/auth.service.ts b/server/src/nest/auth/auth.service.ts index d6fc43afc..c55ef280d 100644 --- a/server/src/nest/auth/auth.service.ts +++ b/server/src/nest/auth/auth.service.ts @@ -148,13 +148,13 @@ export class AuthService { // Toggles + tokens // ------------------------------------------------------------------------- - resolveAuthToggles(): { + async resolveAuthToggles(): Promise<{ password_login: boolean; password_registration: boolean; oidc_login: boolean; oidc_registration: boolean; passkey_login: boolean; - } { + }> { const get = (key: string) => this.db.get<{ value: string }>("SELECT value FROM app_settings WHERE key = ?", key)?.value ?? null; @@ -200,11 +200,11 @@ export class AuthService { }; } - isOidcOnlyMode(): boolean { - return !this.resolveAuthToggles().password_login; + async isOidcOnlyMode(): Promise { + return !(await this.resolveAuthToggles()).password_login; } - generateToken(user: { id: number | bigint; password_version?: number }, remember?: boolean) { + async generateToken(user: { id: number | bigint; password_version?: number }, remember?: boolean) { const pv = typeof user.password_version === 'number' ? user.password_version : (this.db.get<{ password_version?: number }>('SELECT password_version FROM users WHERE id = ?', user.id)?.password_version ?? 0); @@ -244,8 +244,8 @@ export class AuthService { * the operator pointed here themselves (APP_URL, ALLOWED_ORIGINS or the * webauthn settings) is a real single-machine install and still counts. */ - private passkeyConfigured(): boolean { - const cfg = this.webauthn.resolve(); + private async passkeyConfigured(): Promise { + const cfg = await this.webauthn.resolve(); if (!cfg) return false; if (cfg.rpID !== 'localhost' || cfg.explicitOrigins) return true; const env = readEnv(); @@ -258,7 +258,7 @@ export class AuthService { async getAppConfig(authenticatedUser: User | undefined | null) { const userCount = this.db.get<{ count: number }>('SELECT COUNT(*) as count FROM users WHERE COALESCE(is_guest, 0) = 0')!.count; const isDemo = readEnv().demo.enabled; - const toggles = this.resolveAuthToggles(); + const toggles = await this.resolveAuthToggles(); // One directory deeper than the legacy src/services location — the extra // '../' keeps resolving to the workspace package.json. const version: string = readEnv().app.appVersion ?? require('../../../package.json').version; @@ -317,7 +317,7 @@ export class AuthService { // are true. `passkey_configured` stays a pure boolean — it never leaks the // resolved RP ID / origin / APP_URL on this unauthenticated endpoint. passkey_login: toggles.passkey_login, - passkey_configured: this.passkeyConfigured(), + passkey_configured: await this.passkeyConfigured(), env_override_oidc_only: readEnv().oidc.only, has_users: userCount > 0, setup_complete: setupComplete, @@ -333,7 +333,7 @@ export class AuthService { // fallback the upload filters use, so the client's picker and the // server's acceptance can never drift (the historical inline copy here // dropped pkpass, pkpasses, md and markdown). - allowed_file_types: this.allowedFileTypes.get(), + allowed_file_types: await this.allowedFileTypes.get(), // Whether the configuration belongs to whoever operates this install // rather than to its admin. The client uses it to stop offering settings // the server would refuse anyway — it is an honesty flag for the UI, never @@ -362,18 +362,18 @@ export class AuthService { // Auth: register, login, demo // ------------------------------------------------------------------------- - demoLogin(): { error?: string; status?: number; token?: string; user?: Record } { + async demoLogin(): Promise<{ error?: string; status?: number; token?: string; user?: Record }> { if (!readEnv().demo.enabled) { return { error: 'Not found', status: 404 }; } const user = this.db.get('SELECT * FROM users WHERE email = ?', DEMO_EMAIL_PRIMARY); if (!user) return { error: 'Demo user not found', status: 500 }; - const token = this.generateToken(user); + const token = await this.generateToken(user); const safe = stripUserForClient(user) as Record; return { token, user: { ...safe, avatar_url: avatarUrl(user) } }; } - validateInviteToken(token: string): { error?: string; status?: number; valid?: boolean; max_uses?: number; used_count?: number; expires_at?: string } { + async validateInviteToken(token: string): Promise<{ error?: string; status?: number; valid?: boolean; max_uses?: number; used_count?: number; expires_at?: string }> { const invite = this.db.get('SELECT * FROM invite_tokens WHERE token = ?', token) as any; if (!invite) return { error: 'Invalid invite link', status: 404 }; if (invite.max_uses > 0 && invite.used_count >= invite.max_uses) return { error: 'Invite link has been fully used', status: 410 }; @@ -398,7 +398,7 @@ export class AuthService { } if (userCount > 0 && !validInvite) { - const toggles = this.resolveAuthToggles(); + const toggles = await this.resolveAuthToggles(); if (!toggles.password_registration) { return { error: 'Password registration is disabled. Contact your administrator.', status: 403 }; } @@ -435,7 +435,7 @@ export class AuthService { ); const user = { id: result.lastInsertRowid, username, email, role, avatar: null, mfa_enabled: false }; - const token = this.generateToken(user); + const token = await this.generateToken(user); if (validInvite) { const updated = this.db.get( @@ -464,7 +464,7 @@ export class AuthService { } } - loginUser(rawBody: unknown): { + async loginUser(rawBody: unknown): Promise<{ error?: string; status?: number; token?: string; @@ -475,9 +475,9 @@ export class AuthService { auditUserId?: number | null; auditAction?: string; auditDetails?: Record; - } { + }> { const body = rawBody as { email?: string; password?: string; remember_me?: boolean }; - if (this.isOidcOnlyMode()) { + if (await this.isOidcOnlyMode()) { return { error: 'Password authentication is disabled. Please sign in with SSO.', status: 403 }; } @@ -526,7 +526,7 @@ export class AuthService { } this.db.run('UPDATE users SET last_login = CURRENT_TIMESTAMP, login_count = login_count + 1 WHERE id = ?', user.id); - const token = this.generateToken(user, remember); + const token = await this.generateToken(user, remember); const userSafe = stripUserForClient(user) as Record; return { @@ -543,9 +543,9 @@ export class AuthService { // Session // ------------------------------------------------------------------------- - getCurrentUser( + async getCurrentUser( userId: number - ): (Record & Pick & { avatar_url: string }) | null { + ): Promise<(Record & Pick & { avatar_url: string }) | null> { const user = this.db.get( 'SELECT id, username, email, role, avatar, oidc_issuer, created_at, mfa_enabled, must_change_password FROM users WHERE id = ?', userId @@ -559,14 +559,14 @@ export class AuthService { // Password & account // ------------------------------------------------------------------------- - changePassword( + async changePassword( userId: number, userEmail: string, rawBody: unknown, remember?: boolean, - ): { error?: string; status?: number; success?: boolean; token?: string } { + ): Promise<{ error?: string; status?: number; success?: boolean; token?: string }> { const body = rawBody as { current_password?: string; new_password?: string }; - if (this.isOidcOnlyMode()) { + if (await this.isOidcOnlyMode()) { return { error: 'Password authentication is disabled.', status: 403 }; } if (readEnv().demo.enabled && isDemoEmail(userEmail)) { @@ -588,7 +588,7 @@ export class AuthService { const hash = bcrypt.hashSync(new_password, BCRYPT_COST); const newPv = (user.password_version ?? 0) + 1; - this.db.transaction(() => { + await this.uow.transactional(async () => { this.db.run('UPDATE users SET password_hash = ?, must_change_password = 0, password_version = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?', hash, newPv, userId); // A password change rotates the user's sessions: bumping password_version // invalidates existing JWT cookie sessions, and the separate MCP static @@ -606,7 +606,7 @@ export class AuthService { // stays logged in while other existing sessions are rotated out by the pv // gate — preserving the login's remember choice instead of downgrading a // remembered session to the default duration (#1927). - const token = this.generateToken({ id: userId, password_version: newPv }, remember); + const token = await this.generateToken({ id: userId, password_version: newPv }, remember); return { success: true, token }; } @@ -639,7 +639,7 @@ export class AuthService { // work, not with a move. // ------------------------------------------------------------------------- - getAppSettings(userId: number): { error?: string; status?: number; data?: Record } { + async getAppSettings(userId: number): Promise<{ error?: string; status?: number; data?: Record }> { const user = this.db.get<{ role: string }>('SELECT role FROM users WHERE id = ?', userId); if (user?.role !== 'admin') return { error: 'Admin access required', status: 403 }; @@ -651,10 +651,10 @@ export class AuthService { return { data: result }; } - updateAppSettings( + async updateAppSettings( userId: number, rawBody: unknown - ): { + ): Promise<{ error?: string; status?: number; success?: boolean; @@ -662,7 +662,7 @@ export class AuthService { auditDebugDetails?: Record; /** Names the operator holds, skipped rather than written. Empty when self-hosted. */ managedKeys?: string[]; - } { + }> { const body = rawBody as Record; const user = this.db.get<{ role: string }>('SELECT role FROM users WHERE id = ?', userId); if (user?.role !== 'admin') return { error: 'Admin access required', status: 403 }; @@ -683,7 +683,7 @@ export class AuthService { // Lockout prevention: can't disable all login methods if (body.password_login !== undefined || body.oidc_login !== undefined) { - const current = this.resolveAuthToggles(); + const current = await this.resolveAuthToggles(); const oidcConfigured = !!( (readEnv().oidc.issuer || this.db.get<{ value: string }>("SELECT value FROM app_settings WHERE key = 'oidc_issuer'")?.value) && (readEnv().oidc.clientId || this.db.get<{ value: string }>("SELECT value FROM app_settings WHERE key = 'oidc_client_id'")?.value) @@ -748,7 +748,7 @@ export class AuthService { // MFA // ------------------------------------------------------------------------- - setupMfa(userId: number, userEmail: string): { error?: string; status?: number; secret?: string; otpauth_url?: string; qrPromise?: Promise } { + async setupMfa(userId: number, userEmail: string): Promise<{ error?: string; status?: number; secret?: string; otpauth_url?: string; qrPromise?: Promise }> { if (readEnv().demo.enabled && isDemoEmail(userEmail)) { return { error: 'MFA is not available in demo mode.', status: 403 }; } @@ -768,7 +768,7 @@ export class AuthService { return { secret, otpauth_url, qrPromise: QRCode.toString(otpauth_url, { type: 'svg', width: 250 }) }; } - enableMfa(userId: number, rawCode: unknown): { error?: string; status?: number; success?: boolean; mfa_enabled?: boolean; backup_codes?: string[] } { + async enableMfa(userId: number, rawCode: unknown): Promise<{ error?: string; status?: number; success?: boolean; mfa_enabled?: boolean; backup_codes?: string[] }> { const code = rawCode as string | undefined; if (!code) { return { error: 'Verification code is required', status: 400 }; @@ -794,11 +794,11 @@ export class AuthService { return { success: true, mfa_enabled: true, backup_codes: backupCodes }; } - disableMfa( + async disableMfa( userId: number, userEmail: string, rawBody: unknown - ): { error?: string; status?: number; success?: boolean; mfa_enabled?: boolean } { + ): Promise<{ error?: string; status?: number; success?: boolean; mfa_enabled?: boolean }> { const body = rawBody as { password?: string; code?: string }; if (readEnv().demo.enabled && isDemoEmail(userEmail)) { return { error: 'MFA cannot be changed in demo mode.', status: 403 }; @@ -831,14 +831,14 @@ export class AuthService { return { success: true, mfa_enabled: false }; } - verifyMfaLogin(rawBody: unknown): { + async verifyMfaLogin(rawBody: unknown): Promise<{ error?: string; status?: number; token?: string; user?: Record; remember?: boolean; auditUserId?: number; - } { + }> { const body = rawBody as { mfa_token?: string; code?: string; remember_me?: boolean }; const { mfa_token, code, remember_me } = body; const remember = remember_me === true; @@ -868,7 +868,7 @@ export class AuthService { hashes.splice(idx, 1); // Consume the backup code and record the login atomically — the code // must not burn without the login landing (or vice versa). - this.db.transaction(() => { + await this.uow.transactional(async () => { this.db.run('UPDATE users SET mfa_backup_codes = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?', JSON.stringify(hashes), user.id @@ -878,7 +878,7 @@ export class AuthService { } else { this.db.run('UPDATE users SET last_login = CURRENT_TIMESTAMP, login_count = login_count + 1 WHERE id = ?', user.id); } - const sessionToken = this.generateToken(user, remember); + const sessionToken = await this.generateToken(user, remember); const userSafe = stripUserForClient(user) as Record; return { token: sessionToken, @@ -895,7 +895,7 @@ export class AuthService { // Password reset // ------------------------------------------------------------------------- - requestPasswordReset(rawEmail: string, createdIp: string | null): PasswordResetRequestOutcome { + async requestPasswordReset(rawEmail: string, createdIp: string | null): Promise { const email = String(rawEmail || '').trim().toLowerCase(); // Basic shape check — a fully empty / malformed email is treated like // "no user" so we still spend the same time internally. Same "x@y.z somewhere @@ -904,7 +904,7 @@ export class AuthService { const looksLikeEmail = email.length > 0 && /^.[^@\r\n\u2028\u2029]*@.[^.\r\n\u2028\u2029]*\../m.test(email); // Global policy check: password login disabled → no reset possible. - const toggles = this.resolveAuthToggles(); + const toggles = await this.resolveAuthToggles(); if (!toggles.password_login) { return { tokenForDelivery: null, userId: null, userEmail: null, reason: 'password_login_disabled' }; } @@ -971,7 +971,7 @@ export class AuthService { * compromised email alone therefore does NOT allow taking over a * 2FA-protected account. */ - resetPassword(rawBody: unknown): ResetPasswordOutcome { + async resetPassword(rawBody: unknown): Promise { const body = rawBody as { token?: string; new_password?: string; mfa_code?: string }; const { token, new_password, mfa_code } = body; if (!token || typeof token !== 'string') { @@ -1028,7 +1028,7 @@ export class AuthService { const newHash = bcrypt.hashSync(new_password, BCRYPT_COST); const newPv = (user.password_version ?? 0) + 1; - this.db.transaction(() => { + await this.uow.transactional(async () => { // Burn the token first to keep it atomic with the password change. this.db.run('UPDATE password_reset_tokens SET consumed_at = CURRENT_TIMESTAMP WHERE id = ?', row.id); // Also burn every OTHER live token for this user — a fresh login @@ -1074,7 +1074,7 @@ export class AuthService { // which is about minting a token. // ------------------------------------------------------------------------- - isDemoUser(userId: number): boolean { + async isDemoUser(userId: number): Promise { if (!readEnv().demo.enabled) return false; const user = this.db.get<{ email: string }>('SELECT email FROM users WHERE id = ?', userId); return isDemoEmail(user?.email); @@ -1089,7 +1089,7 @@ export class AuthService { * (MCP bearer, WebSocket handshake, file-download query tokens, photo * route) should go through. */ - verifyJwtToken(token: string): User | null { + async verifyJwtToken(token: string): Promise { return verifyJwtAndLoadUser(token); } } diff --git a/server/src/nest/auth/cookie-auth.guard.ts b/server/src/nest/auth/cookie-auth.guard.ts index 6c93bdc19..5867ae08c 100644 --- a/server/src/nest/auth/cookie-auth.guard.ts +++ b/server/src/nest/auth/cookie-auth.guard.ts @@ -10,13 +10,13 @@ import { verifyJwtAndLoadUser } from './jwt-verify'; */ @Injectable() export class CookieAuthGuard implements CanActivate { - canActivate(context: ExecutionContext): boolean { + async canActivate(context: ExecutionContext): Promise { const req = context.switchToHttp().getRequest }>(); const cookieToken = req.cookies?.trek_session; if (!cookieToken) { throw new HttpException({ error: 'Cookie session required for this endpoint', code: 'COOKIE_AUTH_REQUIRED' }, 401); } - const user = verifyJwtAndLoadUser(cookieToken); + const user = await verifyJwtAndLoadUser(cookieToken); if (!user) { throw new HttpException({ error: 'Invalid or expired session', code: 'AUTH_REQUIRED' }, 401); } diff --git a/server/src/nest/auth/global-auth.guard.ts b/server/src/nest/auth/global-auth.guard.ts index eea4d1dd5..93788eeb5 100644 --- a/server/src/nest/auth/global-auth.guard.ts +++ b/server/src/nest/auth/global-auth.guard.ts @@ -30,7 +30,7 @@ import { IS_PUBLIC, OPTIONAL_AUTH } from './public.decorator'; export class GlobalAuthGuard implements CanActivate { constructor(private readonly reflector: Reflector) {} - canActivate(context: ExecutionContext): boolean { + async canActivate(context: ExecutionContext): Promise { const handler = context.getHandler(); const controller = context.getClass(); @@ -40,7 +40,7 @@ export class GlobalAuthGuard implements CanActivate { if (this.reflector.getAllAndOverride(OPTIONAL_AUTH, [handler, controller])) { const token = extractToken(req); - (req as { user: unknown }).user = (token ? verifyJwtAndLoadUser(token) : null) || null; + (req as { user: unknown }).user = (token ? await verifyJwtAndLoadUser(token) : null) || null; return true; } @@ -57,7 +57,7 @@ export class GlobalAuthGuard implements CanActivate { ]; if (declared.length > 0) { const declaredToken = extractToken(req); - (req as { user: unknown }).user = (declaredToken ? verifyJwtAndLoadUser(declaredToken) : null) || null; + (req as { user: unknown }).user = (declaredToken ? await verifyJwtAndLoadUser(declaredToken) : null) || null; return true; } @@ -65,7 +65,7 @@ export class GlobalAuthGuard implements CanActivate { if (!token) { throw new HttpException({ error: 'Access token required', code: 'AUTH_REQUIRED' }, 401); } - const user = verifyJwtAndLoadUser(token); + const user = await verifyJwtAndLoadUser(token); if (!user) { throw new HttpException({ error: 'Invalid or expired token', code: 'AUTH_REQUIRED' }, 401); } diff --git a/server/src/nest/auth/jwt-auth.guard.ts b/server/src/nest/auth/jwt-auth.guard.ts index e622826d8..e81acf568 100644 --- a/server/src/nest/auth/jwt-auth.guard.ts +++ b/server/src/nest/auth/jwt-auth.guard.ts @@ -12,13 +12,13 @@ import { extractToken, verifyJwtAndLoadUser } from './jwt-verify'; */ @Injectable() export class JwtAuthGuard implements CanActivate { - canActivate(context: ExecutionContext): boolean { + async canActivate(context: ExecutionContext): Promise { const req = context.switchToHttp().getRequest(); const token = extractToken(req); if (!token) { throw new HttpException({ error: 'Access token required', code: 'AUTH_REQUIRED' }, 401); } - const user = verifyJwtAndLoadUser(token); + const user = await verifyJwtAndLoadUser(token); if (!user) { throw new HttpException({ error: 'Invalid or expired token', code: 'AUTH_REQUIRED' }, 401); } diff --git a/server/src/nest/auth/jwt-verify.ts b/server/src/nest/auth/jwt-verify.ts index c22d881b7..b68454d08 100644 --- a/server/src/nest/auth/jwt-verify.ts +++ b/server/src/nest/auth/jwt-verify.ts @@ -54,7 +54,7 @@ export function decodeSessionClaims(token: string | undefined): SessionClaims | return decoded as SessionClaims; } -export function verifyJwtAndLoadUser(token: string): User | null { +export async function verifyJwtAndLoadUser(token: string): Promise { try { const decoded = jwt.verify(token, JWT_SECRET, { algorithms: ['HS256'] }) as { id: number; pv?: number; purpose?: string }; // Purpose-scoped tokens (e.g. the short-lived mfa_login token) share this diff --git a/server/src/nest/auth/mfa-policy.guard.ts b/server/src/nest/auth/mfa-policy.guard.ts index cf66f9b16..720902452 100644 --- a/server/src/nest/auth/mfa-policy.guard.ts +++ b/server/src/nest/auth/mfa-policy.guard.ts @@ -49,7 +49,7 @@ export class MfaPolicyGuard implements CanActivate { private readonly reflector: Reflector, ) {} - canActivate(context: ExecutionContext): boolean { + async canActivate(context: ExecutionContext): Promise { const handler = context.getHandler(); const controller = context.getClass(); diff --git a/server/src/nest/auth/optional-jwt.guard.ts b/server/src/nest/auth/optional-jwt.guard.ts index 58588f4ca..8f7817dbb 100644 --- a/server/src/nest/auth/optional-jwt.guard.ts +++ b/server/src/nest/auth/optional-jwt.guard.ts @@ -10,10 +10,10 @@ import { extractToken, verifyJwtAndLoadUser } from './jwt-verify'; */ @Injectable() export class OptionalJwtGuard implements CanActivate { - canActivate(context: ExecutionContext): boolean { + async canActivate(context: ExecutionContext): Promise { const req = context.switchToHttp().getRequest(); const token = extractToken(req); - (req as { user: unknown }).user = (token ? verifyJwtAndLoadUser(token) : null) || null; + (req as { user: unknown }).user = (token ? await verifyJwtAndLoadUser(token) : null) || null; return true; } } diff --git a/server/src/nest/auth/passkey-enabled.guard.ts b/server/src/nest/auth/passkey-enabled.guard.ts index 296675391..ac32e4431 100644 --- a/server/src/nest/auth/passkey-enabled.guard.ts +++ b/server/src/nest/auth/passkey-enabled.guard.ts @@ -15,8 +15,8 @@ import { AuthService } from './auth.service'; export class PasskeyEnabledGuard implements CanActivate { constructor(private readonly auth: AuthService) {} - canActivate(): boolean { - if (!this.auth.resolveAuthToggles().passkey_login) { + async canActivate(): Promise { + if (!(await this.auth.resolveAuthToggles()).passkey_login) { throw new HttpException({ error: 'Passkey login is not enabled' }, 404); } return true; diff --git a/server/src/nest/auth/passkey.controller.ts b/server/src/nest/auth/passkey.controller.ts index d1141771f..ce7d3a4cf 100644 --- a/server/src/nest/auth/passkey.controller.ts +++ b/server/src/nest/auth/passkey.controller.ts @@ -104,14 +104,14 @@ export class PasskeyController { @Get('credentials') @MfaExempt('the setup screen lists what the user already has') @UseGuards(JwtAuthGuard) - list(@CurrentUser() user: User) { - return { credentials: this.passkeys.listPasskeys(user.id) }; + async list(@CurrentUser() user: User) { + return { credentials: await this.passkeys.listPasskeys(user.id) }; } @Patch('credentials/:id') @UseGuards(JwtAuthGuard) - rename(@CurrentUser() user: User, @Param('id') id: string, @Body() body: PasskeyRenameDto) { - const result = this.passkeys.renamePasskey(user.id, id, body?.name); + async rename(@CurrentUser() user: User, @Param('id') id: string, @Body() body: PasskeyRenameDto) { + const result = await this.passkeys.renamePasskey(user.id, id, body?.name); if (result.error) throw new HttpException({ error: result.error }, result.status!); return { success: true }; } @@ -120,7 +120,7 @@ export class PasskeyController { @UseGuards(JwtAuthGuard) async remove(@CurrentUser() user: User, @Param('id') id: string, @Body() body: PasskeyDeleteDto, @Req() req: Request) { this.limit('login', req, 5); - const result = this.passkeys.deletePasskey(user.id, id, body?.password); + const result = await this.passkeys.deletePasskey(user.id, id, body?.password); if (result.error) throw new HttpException({ error: result.error }, result.status!); await this.audit.writeAudit({ userId: user.id, action: 'user.passkey_delete', resource: String(id), ip: getClientIp(req) }); return { success: true }; diff --git a/server/src/nest/auth/passkey.service.ts b/server/src/nest/auth/passkey.service.ts index c0e14e886..81f2f5a5a 100644 --- a/server/src/nest/auth/passkey.service.ts +++ b/server/src/nest/auth/passkey.service.ts @@ -12,6 +12,7 @@ import { avatarUrl } from '../common/avatarUrl'; import { stripUserForClient } from './auth.helpers'; import { AuthService } from './auth.service'; import { DatabaseService } from '../database/database.service'; +import { UnitOfWork } from '../database/unit-of-work'; import type { User } from '../../types'; // --------------------------------------------------------------------------- @@ -107,17 +108,18 @@ export class PasskeyService { private readonly db: DatabaseService, private readonly auth: AuthService, private readonly webauthn: WebauthnConfigService, + private readonly uow: UnitOfWork, ) {} // ------------------------------------------------------------------------- // Challenge store (DB-backed, single-use, TTL'd) // ------------------------------------------------------------------------- - private purgeExpiredChallenges(now: number): void { + private async purgeExpiredChallenges(now: number): Promise { this.db.run('DELETE FROM webauthn_challenges WHERE expires_at < ?', now); } - private storeChallenge(challenge: string, userId: number | null, type: 'registration' | 'authentication', now: number): void { + private async storeChallenge(challenge: string, userId: number | null, type: 'registration' | 'authentication', now: number): Promise { this.db.run( 'INSERT INTO webauthn_challenges (challenge, user_id, type, expires_at) VALUES (?, ?, ?, ?)', challenge, userId, type, now + CHALLENGE_TTL_MS, @@ -130,7 +132,7 @@ export class PasskeyService { * concurrent double-submit of the same assertion can never spend one challenge * twice (the replay window a SELECT→await→DELETE ordering would open). */ - private claimChallenge(challenge: string, type: 'registration' | 'authentication', now: number): { user_id: number | null } | null { + private async claimChallenge(challenge: string, type: 'registration' | 'authentication', now: number): Promise<{ user_id: number | null } | null> { const row = this.db.get<{ user_id: number | null }>( 'DELETE FROM webauthn_challenges WHERE challenge = ? AND type = ? AND expires_at > ? RETURNING user_id', challenge, type, now, @@ -203,7 +205,7 @@ export class PasskeyService { password: string | undefined, requestOrigin?: string, ): Promise<{ error?: string; status?: number; options?: Awaited> }> { - const cfg = this.webauthn.resolve(); + const cfg = await this.webauthn.resolve(); if (!cfg) return { ...NOT_CONFIGURED }; if (this.originCannotVerify(cfg, requestOrigin)) return { ...NOT_CONFIGURED }; @@ -222,7 +224,7 @@ export class PasskeyService { ); const now = Date.now(); - this.purgeExpiredChallenges(now); + await this.purgeExpiredChallenges(now); const options = await generateRegistrationOptions({ rpName: cfg.rpName, @@ -237,7 +239,7 @@ export class PasskeyService { supportedAlgorithmIDs: SUPPORTED_ALGORITHM_IDS, }); - this.storeChallenge(options.challenge, userId, 'registration', now); + await this.storeChallenge(options.challenge, userId, 'registration', now); return { options }; } @@ -245,7 +247,7 @@ export class PasskeyService { userId: number, body: { attestationResponse?: unknown; name?: unknown }, ): Promise<{ error?: string; status?: number; success?: boolean; credential?: unknown }> { - const cfg = this.webauthn.resolve(); + const cfg = await this.webauthn.resolve(); if (!cfg) return { ...NOT_CONFIGURED }; const resp = body?.attestationResponse; @@ -255,7 +257,7 @@ export class PasskeyService { if (!challenge) return { error: 'Invalid registration response', status: 400 }; const now = Date.now(); - const claimed = this.claimChallenge(challenge, 'registration', now); + const claimed = await this.claimChallenge(challenge, 'registration', now); if (!claimed || claimed.user_id !== userId) { return { error: 'Registration challenge expired. Please try again.', status: 400 }; } @@ -291,15 +293,14 @@ export class PasskeyService { // Duplicate check + INSERT in one transaction so the UNIQUE race can't slip // between them; the sentinel keeps the legacy 409-vs-400 split intact. try { - this.db.transaction((conn) => { - if (conn.prepare('SELECT id FROM webauthn_credentials WHERE credential_id = ?').get(credential.id)) { + await this.uow.transactional(async () => { + if (this.db.get('SELECT id FROM webauthn_credentials WHERE credential_id = ?', credential.id)) { throw DUPLICATE_CREDENTIAL; } - conn.prepare( + this.db.run( `INSERT INTO webauthn_credentials (user_id, credential_id, public_key, counter, transports, device_type, backed_up, name, aaguid, last_used_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, NULL)`, - ).run( userId, credential.id, Buffer.from(credential.publicKey), @@ -334,12 +335,12 @@ export class PasskeyService { status?: number; options?: Awaited>; }> { - const cfg = this.webauthn.resolve(); + const cfg = await this.webauthn.resolve(); if (!cfg) return { ...NOT_CONFIGURED }; if (this.originCannotVerify(cfg, requestOrigin)) return { ...NOT_CONFIGURED }; const now = Date.now(); - this.purgeExpiredChallenges(now); + await this.purgeExpiredChallenges(now); const options = await generateAuthenticationOptions({ rpID: cfg.rpID, @@ -348,7 +349,7 @@ export class PasskeyService { // accounts have passkeys, so the endpoint can't be used to enumerate users. }); - this.storeChallenge(options.challenge, null, 'authentication', now); + await this.storeChallenge(options.challenge, null, 'authentication', now); return { options }; } @@ -360,7 +361,7 @@ export class PasskeyService { auditUserId?: number | null; auditAction?: string; }> { - const cfg = this.webauthn.resolve(); + const cfg = await this.webauthn.resolve(); if (!cfg) return { ...NOT_CONFIGURED }; const resp = body?.assertionResponse; @@ -371,7 +372,7 @@ export class PasskeyService { // Claim the challenge (single-use) BEFORE looking anything up or verifying. const now = Date.now(); - if (!this.claimChallenge(challenge, 'authentication', now)) return { ...AUTH_FAILED }; + if (!(await this.claimChallenge(challenge, 'authentication', now))) return { ...AUTH_FAILED }; const credId = (resp as { id?: unknown; rawId?: unknown }).id ?? (resp as { rawId?: unknown }).rawId; if (typeof credId !== 'string') return { ...AUTH_FAILED }; @@ -417,15 +418,15 @@ export class PasskeyService { if (!user) return { ...AUTH_FAILED }; // Persist the new counter + last-used and bump login bookkeeping atomically. - this.db.transaction((conn) => { - conn.prepare('UPDATE webauthn_credentials SET counter = ?, last_used_at = CURRENT_TIMESTAMP WHERE id = ?').run(newCounter, cred.id); - conn.prepare('UPDATE users SET last_login = CURRENT_TIMESTAMP, login_count = login_count + 1 WHERE id = ?').run(user.id); + await this.uow.transactional(async () => { + this.db.run('UPDATE webauthn_credentials SET counter = ?, last_used_at = CURRENT_TIMESTAMP WHERE id = ?', newCounter, cred.id); + this.db.run('UPDATE users SET last_login = CURRENT_TIMESTAMP, login_count = login_count + 1 WHERE id = ?', user.id); }); // A user-verified passkey is phishing-resistant and inherently two-factor // (device possession + biometric/PIN), so it mints the real session directly // — the SAME path as password and OIDC login (no new token shape). - const token = this.auth.generateToken(user); + const token = await this.auth.generateToken(user); const userSafe = stripUserForClient(user) as Record; return { token, user: { ...userSafe, avatar_url: avatarUrl(user) }, auditUserId: Number(user.id) }; } @@ -434,7 +435,7 @@ export class PasskeyService { // Management (authenticated, owner-scoped) // ------------------------------------------------------------------------- - listPasskeys(userId: number): Array> { + async listPasskeys(userId: number): Promise>> { const rows = this.db.all<{ backed_up: number } & Record>( 'SELECT id, name, device_type, backed_up, created_at, last_used_at FROM webauthn_credentials WHERE user_id = ? ORDER BY created_at DESC', userId, @@ -442,7 +443,7 @@ export class PasskeyService { return rows.map((r) => ({ ...r, backed_up: r.backed_up === 1 })); } - renamePasskey(userId: number, id: string, name: unknown): { error?: string; status?: number; success?: boolean } { + async renamePasskey(userId: number, id: string, name: unknown): Promise<{ error?: string; status?: number; success?: boolean }> { const cleanName = sanitizeName(name); if (!cleanName) return { error: 'Name is required', status: 400 }; // Ownership enforced in SQL (404 on miss, never a 403 that leaks existence). @@ -451,11 +452,11 @@ export class PasskeyService { return { success: true }; } - deletePasskey( + async deletePasskey( userId: number, id: string, password: string | undefined, - ): { error?: string; status?: number; success?: boolean } { + ): Promise<{ error?: string; status?: number; success?: boolean }> { // Re-auth before removing a credential (a hijacked session must not be able to // strip the victim's passkeys). Deleting is always allowed because every // account keeps a usable password as recovery fallback — losing all passkeys @@ -470,7 +471,7 @@ export class PasskeyService { } /** Admin: clear all of a user's passkeys (e.g. on suspected compromise). */ - adminResetPasskeys(targetUserId: number): { error?: string; status?: number; success?: boolean; deleted?: number; email?: string } { + async adminResetPasskeys(targetUserId: number): Promise<{ error?: string; status?: number; success?: boolean; deleted?: number; email?: string }> { const target = this.db.get<{ id: number; email: string }>('SELECT id, email FROM users WHERE id = ?', targetUserId); if (!target) return { error: 'User not found', status: 404 }; const result = this.db.run('DELETE FROM webauthn_credentials WHERE user_id = ?', targetUserId); diff --git a/server/src/nest/auth/registration-invites.service.ts b/server/src/nest/auth/registration-invites.service.ts index 581592600..c0b68deed 100644 --- a/server/src/nest/auth/registration-invites.service.ts +++ b/server/src/nest/auth/registration-invites.service.ts @@ -21,7 +21,7 @@ import { DatabaseService } from '../database/database.service'; export class RegistrationInvitesService { constructor(private readonly db: DatabaseService) {} - listInvites() { + async listInvites() { return this.db.all(` SELECT i.*, u.username as created_by_name, t.title as trip_title FROM invite_tokens i @@ -32,11 +32,11 @@ export class RegistrationInvitesService { } /** Trips an admin can bind an invite to — id + title only, for the picker (#1402). */ - listTripsForInvite() { + async listTripsForInvite() { return this.db.all('SELECT id, title FROM trips ORDER BY title COLLATE NOCASE ASC'); } - createInvite( + async createInvite( createdBy: number, data: { max_uses?: string | number; expires_in_days?: string | number; trip_id?: string | number | null }, ) { @@ -77,7 +77,7 @@ export class RegistrationInvitesService { return { invite, inviteId, uses, expiresInDays: data.expires_in_days ?? null, tripId }; } - deleteInvite(id: string) { + async deleteInvite(id: string) { const invite = this.db.get('SELECT id FROM invite_tokens WHERE id = ?', id); if (!invite) return { error: 'Invite not found', status: 404 }; this.db.run('DELETE FROM invite_tokens WHERE id = ?', id); diff --git a/server/src/nest/auth/session-renewal.interceptor.ts b/server/src/nest/auth/session-renewal.interceptor.ts index a0f688e17..84af7f037 100644 --- a/server/src/nest/auth/session-renewal.interceptor.ts +++ b/server/src/nest/auth/session-renewal.interceptor.ts @@ -33,16 +33,16 @@ import { setAuthCookie } from '../common/cookie'; export class SessionRenewalInterceptor implements NestInterceptor { constructor(private readonly auth: AuthService) {} - intercept(context: ExecutionContext, next: CallHandler): Observable { + async intercept(context: ExecutionContext, next: CallHandler): Promise> { if (context.getType() === 'http') { const req = context.switchToHttp().getRequest(); const res = context.switchToHttp().getResponse(); - this.maybeRenew(req, res); + await this.maybeRenew(req, res); } return next.handle(); } - private maybeRenew(req: Request & { user?: { id: number } }, res: Response): void { + private async maybeRenew(req: Request & { user?: { id: number } }, res: Response): Promise { const cookieToken: string | undefined = (req as { cookies?: Record }).cookies?.trek_session; if (!req.user || !cookieToken || res.headersSent) return; @@ -53,7 +53,7 @@ export class SessionRenewalInterceptor implements NestInterceptor { const halfLife = claims.iat + (claims.exp - claims.iat) / 2; if (Date.now() / 1000 < halfLife) return; - const token = this.auth.generateToken( + const token = await this.auth.generateToken( { id: req.user.id, password_version: claims.pv ?? 0 }, claims.remember, ); diff --git a/server/src/nest/auth/user-cleanup.service.ts b/server/src/nest/auth/user-cleanup.service.ts index ee6b70768..466c6b3b2 100644 --- a/server/src/nest/auth/user-cleanup.service.ts +++ b/server/src/nest/auth/user-cleanup.service.ts @@ -39,7 +39,7 @@ export class UserCleanupService { * Best-effort per table so a slimmed-down schema (some tests) can't fail the user * deletion itself. */ - erasePluginUserData(userId: number): void { + 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) */ } } @@ -81,7 +81,7 @@ export class UserCleanupService { async deleteUserCompletely(userId: number): Promise { await this.uow.transactional(async () => { await this.cleanupUserReferences(userId); - this.erasePluginUserData(userId); + await this.erasePluginUserData(userId); this.db.run('DELETE FROM users WHERE id = ?', userId); }); } diff --git a/server/src/nest/auth/user-profile.service.ts b/server/src/nest/auth/user-profile.service.ts index ccc7815bc..de73e347e 100644 --- a/server/src/nest/auth/user-profile.service.ts +++ b/server/src/nest/auth/user-profile.service.ts @@ -54,7 +54,7 @@ export class UserProfileService { } /** The three key columns plus the role that decides where a save lands. */ - private currentKeys(userId: number) { + private async currentKeys(userId: number) { return this.db.get>( 'SELECT role, maps_api_key, openweather_api_key, unsplash_api_key, amap_api_key FROM users WHERE id = ?', userId @@ -128,7 +128,7 @@ export class UserProfileService { if (this.managed) { return { success: true, maps_api_key: null, managed_keys: ['maps_api_key'], changedKeys: [] }; } - const current = this.currentKeys(userId); + const current = await this.currentKeys(userId); const isAdmin = current?.role === 'admin'; const changedKeys = await this.changedKeyNames({ maps_api_key }, current, isAdmin); await this.uow.transactional(async () => { @@ -145,7 +145,7 @@ export class UserProfileService { const body = rawBody as { maps_api_key?: string; openweather_api_key?: string; unsplash_api_key?: string; amap_api_key?: string }; const { blocked } = splitManagedKeys(body, this.managed); for (const key of blocked) delete body[key as keyof typeof body]; - const current = this.currentKeys(userId); + const current = await this.currentKeys(userId); const isAdmin = current?.role === 'admin'; const changedKeys = await this.changedKeyNames(body, current, isAdmin, blocked); @@ -222,7 +222,7 @@ export class UserProfileService { // Read before the write, so the comparison sees the old value; the role in // the same row decides whether the two instance-wide names travel with it. - const current = this.currentKeys(userId); + const current = await this.currentKeys(userId); const isAdmin = current?.role === 'admin'; const changedKeys = keyLocked ? [] : await this.changedKeyNames(body, current, isAdmin, blocked); @@ -322,7 +322,7 @@ export class UserProfileService { // User directory // ------------------------------------------------------------------------- - listUsers(excludeUserId: number) { + async listUsers(excludeUserId: number) { // The global user directory feeds the trip member-add / contributor pickers — // guests (#1362) are trip-scoped and must never be selectable here. const users = this.db.all>( diff --git a/server/src/nest/auth/webauthn-config.service.ts b/server/src/nest/auth/webauthn-config.service.ts index b9f918c1e..36bf015a8 100644 --- a/server/src/nest/auth/webauthn-config.service.ts +++ b/server/src/nest/auth/webauthn-config.service.ts @@ -70,7 +70,7 @@ export interface WebauthnConfig { export class WebauthnConfigService { constructor(private readonly db: DatabaseService) {} - private setting(key: string): string | null { + private async setting(key: string): Promise { const raw = this.db.get<{ value: string }>('SELECT value FROM app_settings WHERE key = ?', key)?.value; const trimmed = raw?.trim(); return trimmed ? trimmed : null; @@ -81,10 +81,10 @@ export class WebauthnConfigService { * configured) — the feature then reports itself as "not configured" and stays * disabled so nobody can enrol a credential bound to the wrong origin. */ - resolve(): WebauthnConfig | null { + async resolve(): Promise { // 1. Explicit operator config always wins. - const explicitRpId = (readEnv().webauthn.rpId || this.setting('webauthn_rp_id'))?.trim() || null; - const explicitOrigins = (readEnv().webauthn.origins || this.setting('webauthn_origins') || '') + const explicitRpId = (readEnv().webauthn.rpId || (await this.setting('webauthn_rp_id')))?.trim() || null; + const explicitOrigins = (readEnv().webauthn.origins || (await this.setting('webauthn_origins')) || '') .split(',') // The lookbehind matches only the first slash of the trailing run. Without it the // engine retries from every slash, which is quadratic on a slash-heavy value. @@ -118,7 +118,7 @@ export class WebauthnConfigService { } /** True when a usable RP ID resolves for this deployment (exposed as a pure boolean on app-config). */ - isConfigured(): boolean { - return this.resolve() !== null; + async isConfigured(): Promise { + return (await this.resolve()) !== null; } } diff --git a/server/src/nest/collab/collab.mcp.ts b/server/src/nest/collab/collab.mcp.ts index 4bd065a7e..6d7a12402 100644 --- a/server/src/nest/collab/collab.mcp.ts +++ b/server/src/nest/collab/collab.mcp.ts @@ -94,7 +94,7 @@ export class CollabMcp { }, ctx: McpContext, ) { - if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); + if (await this.auth.isDemoUser(ctx.userId)) return demoDenied(); if (!this.collab.verifyTripAccess(tripId, ctx.userId)) return noAccess(); if (!(await this.guards.hasTripPermission('collab_edit', tripId, ctx.userId))) return permissionDenied(); const note = this.collab.createNote(tripId, ctx.userId, { title, content, category, color, website, pinned }); @@ -125,7 +125,7 @@ export class CollabMcp { }, ctx: McpContext, ) { - if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); + if (await this.auth.isDemoUser(ctx.userId)) return demoDenied(); if (!this.collab.verifyTripAccess(tripId, ctx.userId)) return noAccess(); if (!(await this.guards.hasTripPermission('collab_edit', tripId, ctx.userId))) return permissionDenied(); const note = this.collab.updateNote(tripId, noteId, { title, content, category, color, website, pinned }); @@ -146,7 +146,7 @@ export class CollabMcp { access: { group: 'collab', mode: 'write' }, }) async deleteCollabNote({ tripId, noteId }: { tripId: number; noteId: number }, ctx: McpContext) { - if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); + if (await this.auth.isDemoUser(ctx.userId)) return demoDenied(); if (!this.collab.verifyTripAccess(tripId, ctx.userId)) return noAccess(); if (!(await this.guards.hasTripPermission('collab_edit', tripId, ctx.userId))) return permissionDenied(); const deleted = await this.collab.deleteNote(tripId, noteId); @@ -193,7 +193,7 @@ export class CollabMcp { }, ctx: McpContext, ) { - if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); + if (await this.auth.isDemoUser(ctx.userId)) return demoDenied(); if (!this.collab.verifyTripAccess(tripId, ctx.userId)) return noAccess(); if (!(await this.guards.hasTripPermission('collab_edit', tripId, ctx.userId))) return permissionDenied(); const poll = this.collab.createPoll(tripId, ctx.userId, { question, options, multiple, deadline }); @@ -214,7 +214,7 @@ export class CollabMcp { access: { group: 'collab', mode: 'write' }, }) async voteCollabPoll({ tripId, pollId, optionIndex }: { tripId: number; pollId: number; optionIndex: number }, ctx: McpContext) { - if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); + if (await this.auth.isDemoUser(ctx.userId)) return demoDenied(); if (!this.collab.verifyTripAccess(tripId, ctx.userId)) return noAccess(); if (!(await this.guards.hasTripPermission('collab_edit', tripId, ctx.userId))) return permissionDenied(); const result = this.collab.votePoll(tripId, pollId, ctx.userId, optionIndex); @@ -235,7 +235,7 @@ export class CollabMcp { access: { group: 'collab', mode: 'write' }, }) async closeCollabPoll({ tripId, pollId }: { tripId: number; pollId: number }, ctx: McpContext) { - if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); + if (await this.auth.isDemoUser(ctx.userId)) return demoDenied(); if (!this.collab.verifyTripAccess(tripId, ctx.userId)) return noAccess(); if (!(await this.guards.hasTripPermission('collab_edit', tripId, ctx.userId))) return permissionDenied(); const poll = this.collab.closePoll(tripId, pollId); @@ -256,7 +256,7 @@ export class CollabMcp { access: { group: 'collab', mode: 'write' }, }) async deleteCollabPoll({ tripId, pollId }: { tripId: number; pollId: number }, ctx: McpContext) { - if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); + if (await this.auth.isDemoUser(ctx.userId)) return demoDenied(); if (!this.collab.verifyTripAccess(tripId, ctx.userId)) return noAccess(); if (!(await this.guards.hasTripPermission('collab_edit', tripId, ctx.userId))) return permissionDenied(); const deleted = this.collab.deletePoll(tripId, pollId); @@ -295,7 +295,7 @@ export class CollabMcp { access: { group: 'collab', mode: 'write' }, }) async sendCollabMessage({ tripId, text, replyTo }: { tripId: number; text: string; replyTo?: number }, ctx: McpContext) { - if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); + if (await this.auth.isDemoUser(ctx.userId)) return demoDenied(); if (!this.collab.verifyTripAccess(tripId, ctx.userId)) return noAccess(); if (!(await this.guards.hasTripPermission('collab_edit', tripId, ctx.userId))) return permissionDenied(); const result = this.collab.createMessage(tripId, ctx.userId, text, replyTo ?? null); @@ -316,7 +316,7 @@ export class CollabMcp { access: { group: 'collab', mode: 'write' }, }) async deleteCollabMessage({ tripId, messageId }: { tripId: number; messageId: number }, ctx: McpContext) { - if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); + if (await this.auth.isDemoUser(ctx.userId)) return demoDenied(); if (!this.collab.verifyTripAccess(tripId, ctx.userId)) return noAccess(); if (!(await this.guards.hasTripPermission('collab_edit', tripId, ctx.userId))) return permissionDenied(); const result = this.collab.deleteMessage(tripId, messageId, ctx.userId); @@ -338,7 +338,7 @@ export class CollabMcp { access: { group: 'collab', mode: 'write' }, }) async reactCollabMessage({ tripId, messageId, emoji }: { tripId: number; messageId: number; emoji: string }, ctx: McpContext) { - if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); + if (await this.auth.isDemoUser(ctx.userId)) return demoDenied(); if (!this.collab.verifyTripAccess(tripId, ctx.userId)) return noAccess(); if (!(await this.guards.hasTripPermission('collab_edit', tripId, ctx.userId))) return permissionDenied(); const result = this.collab.reactMessage(messageId, tripId, ctx.userId, emoji); diff --git a/server/src/nest/collections/collections.mcp.ts b/server/src/nest/collections/collections.mcp.ts index efbef2807..3f68b426e 100644 --- a/server/src/nest/collections/collections.mcp.ts +++ b/server/src/nest/collections/collections.mcp.ts @@ -62,8 +62,8 @@ export class CollectionsMcp { readonly addons: AddonsService, ) {} - private denyDemo(userId: number) { - return this.auth.isDemoUser(userId) ? demoDenied() : null; + private async denyDemo(userId: number) { + return (await this.auth.isDemoUser(userId)) ? demoDenied() : null; } // ── Read ────────────────────────────────────────────────────────────── @@ -144,7 +144,7 @@ export class CollectionsMcp { access: { group: 'collections', mode: 'write' }, }) async createCollection(body: CollectionCreateRequest, ctx: McpContext) { - const demo = this.denyDemo(ctx.userId); if (demo) return demo; + const demo = await this.denyDemo(ctx.userId); if (demo) return demo; try { return ok({ collection: this.collections.createCollection(ctx.userId, body) }); } catch (err) { return fail(err); } } @@ -157,7 +157,7 @@ export class CollectionsMcp { access: { group: 'collections', mode: 'write' }, }) async updateCollection({ collectionId, ...body }: { collectionId: number } & CollectionUpdateRequest, ctx: McpContext) { - const demo = this.denyDemo(ctx.userId); if (demo) return demo; + const demo = await this.denyDemo(ctx.userId); if (demo) return demo; try { return ok({ collection: this.collections.updateCollection(ctx.userId, collectionId, body) }); } catch (err) { return fail(err); } } @@ -170,7 +170,7 @@ export class CollectionsMcp { access: { group: 'collections', mode: 'write' }, }) async deleteCollection({ collectionId }: { collectionId: number }, ctx: McpContext) { - const demo = this.denyDemo(ctx.userId); if (demo) return demo; + const demo = await this.denyDemo(ctx.userId); if (demo) return demo; try { this.collections.deleteCollection(ctx.userId, collectionId); return ok({ success: true }); } catch (err) { return fail(err); } } @@ -183,7 +183,7 @@ export class CollectionsMcp { access: { group: 'collections', mode: 'write' }, }) async reorderCollections({ orderedIds }: { orderedIds: number[] }, ctx: McpContext) { - const demo = this.denyDemo(ctx.userId); if (demo) return demo; + const demo = await this.denyDemo(ctx.userId); if (demo) return demo; try { this.collections.reorderCollections(ctx.userId, orderedIds); return ok({ success: true }); } catch (err) { return fail(err); } } @@ -198,7 +198,7 @@ export class CollectionsMcp { access: { group: 'collections', mode: 'write' }, }) async savePlaceToCollection(body: CollectionSavePlaceRequest, ctx: McpContext) { - const demo = this.denyDemo(ctx.userId); if (demo) return demo; + const demo = await this.denyDemo(ctx.userId); if (demo) return demo; try { return ok(this.collections.savePlace(ctx.userId, body)); } catch (err) { return fail(err); } } @@ -219,7 +219,7 @@ export class CollectionsMcp { { collectionId, tripId, placeIds, force }: { collectionId: number; tripId: number; placeIds: number[]; force?: boolean }, ctx: McpContext, ) { - const demo = this.denyDemo(ctx.userId); if (demo) return demo; + const demo = await this.denyDemo(ctx.userId); if (demo) return demo; try { return ok(this.collections.saveFromTripPlaces(ctx.userId, collectionId, tripId, placeIds, force)); } catch (err) { return fail(err); } } @@ -232,7 +232,7 @@ export class CollectionsMcp { access: { group: 'collections', mode: 'write' }, }) async updateCollectionPlace({ placeId, ...body }: { placeId: number } & CollectionPlaceUpdateRequest, ctx: McpContext) { - const demo = this.denyDemo(ctx.userId); if (demo) return demo; + const demo = await this.denyDemo(ctx.userId); if (demo) return demo; try { return ok({ place: await this.collections.updatePlace(ctx.userId, placeId, body) }); } catch (err) { return fail(err); } } @@ -245,7 +245,7 @@ export class CollectionsMcp { access: { group: 'collections', mode: 'write' }, }) async setCollectionPlaceStatus({ placeId, status }: { placeId: number; status: CollectionStatus }, ctx: McpContext) { - const demo = this.denyDemo(ctx.userId); if (demo) return demo; + const demo = await this.denyDemo(ctx.userId); if (demo) return demo; try { return ok({ place: this.collections.setStatus(ctx.userId, placeId, status) }); } catch (err) { return fail(err); } } @@ -261,7 +261,7 @@ export class CollectionsMcp { { trip_id, place_ids, status }: CollectionSetStatusFromTripRequest, ctx: McpContext, ) { - const demo = this.denyDemo(ctx.userId); if (demo) return demo; + const demo = await this.denyDemo(ctx.userId); if (demo) return demo; try { return ok(this.collections.setStatusFromTrip(ctx.userId, trip_id, place_ids, status)); } catch (err) { return fail(err); } } @@ -277,7 +277,7 @@ export class CollectionsMcp { access: { group: 'collections', mode: 'write' }, }) async rateCollectionPlace({ placeId, rating }: { placeId: number; rating?: number | null }, ctx: McpContext) { - const demo = this.denyDemo(ctx.userId); if (demo) return demo; + const demo = await this.denyDemo(ctx.userId); if (demo) return demo; try { return ok({ place: this.collections.setRating(ctx.userId, placeId, rating ?? null) }); } catch (err) { return fail(err); } } @@ -290,7 +290,7 @@ export class CollectionsMcp { access: { group: 'collections', mode: 'write' }, }) async deleteCollectionPlace({ placeId }: { placeId: number }, ctx: McpContext) { - const demo = this.denyDemo(ctx.userId); if (demo) return demo; + const demo = await this.denyDemo(ctx.userId); if (demo) return demo; try { await this.collections.deletePlace(ctx.userId, placeId); return ok({ success: true }); } catch (err) { return fail(err); } } @@ -303,7 +303,7 @@ export class CollectionsMcp { access: { group: 'collections', mode: 'write' }, }) async copyCollectionPlacesToTrip(body: CollectionCopyToTripRequest, ctx: McpContext) { - const demo = this.denyDemo(ctx.userId); if (demo) return demo; + const demo = await this.denyDemo(ctx.userId); if (demo) return demo; try { return ok(await this.collections.copyToTrip(ctx.userId, body)); } catch (err) { return fail(err); } } @@ -318,7 +318,7 @@ export class CollectionsMcp { access: { group: 'collections', mode: 'write' }, }) async createCollectionLabel({ collection_id, name, color }: CollectionLabelCreateRequest, ctx: McpContext) { - const demo = this.denyDemo(ctx.userId); if (demo) return demo; + const demo = await this.denyDemo(ctx.userId); if (demo) return demo; try { return ok({ label: this.collections.createLabel(ctx.userId, collection_id, name, color) }); } catch (err) { return fail(err); } } @@ -331,7 +331,7 @@ export class CollectionsMcp { access: { group: 'collections', mode: 'write' }, }) async updateCollectionLabel({ labelId, ...body }: { labelId: number } & CollectionLabelUpdateRequest, ctx: McpContext) { - const demo = this.denyDemo(ctx.userId); if (demo) return demo; + const demo = await this.denyDemo(ctx.userId); if (demo) return demo; try { return ok({ label: this.collections.updateLabel(ctx.userId, labelId, body) }); } catch (err) { return fail(err); } } @@ -344,7 +344,7 @@ export class CollectionsMcp { access: { group: 'collections', mode: 'write' }, }) async deleteCollectionLabel({ labelId }: { labelId: number }, ctx: McpContext) { - const demo = this.denyDemo(ctx.userId); if (demo) return demo; + const demo = await this.denyDemo(ctx.userId); if (demo) return demo; try { this.collections.deleteLabel(ctx.userId, labelId); return ok({ success: true }); } catch (err) { return fail(err); } } @@ -360,7 +360,7 @@ export class CollectionsMcp { { label_ids, place_ids, remove }: CollectionLabelAssignRequest & { remove?: boolean }, ctx: McpContext, ) { - const demo = this.denyDemo(ctx.userId); if (demo) return demo; + const demo = await this.denyDemo(ctx.userId); if (demo) return demo; try { return ok(this.collections.assignLabels(ctx.userId, label_ids, place_ids, remove ?? false)); } catch (err) { return fail(err); } } @@ -375,7 +375,7 @@ export class CollectionsMcp { access: { group: 'collections', mode: 'write' }, }) async inviteToCollection({ collection_id, user_id, role }: CollectionInviteRequest, ctx: McpContext) { - const demo = this.denyDemo(ctx.userId); if (demo) return demo; + const demo = await this.denyDemo(ctx.userId); if (demo) return demo; // try/catch added with the post-fold quirk pass — the legacy handler was one // of two where an unexpected throw escaped to the SDK instead of isError. try { @@ -402,7 +402,7 @@ export class CollectionsMcp { { collectionId, userId: targetUserId, role }: { collectionId: number; userId: number; role: CollectionRole }, ctx: McpContext, ) { - const demo = this.denyDemo(ctx.userId); if (demo) return demo; + const demo = await this.denyDemo(ctx.userId); if (demo) return demo; try { this.collections.setMemberRole(ctx.userId, collectionId, targetUserId, role); return ok({ success: true }); } catch (err) { return fail(err); } } @@ -418,7 +418,7 @@ export class CollectionsMcp { { collectionId, userId: targetUserId }: { collectionId: number; userId: number }, ctx: McpContext, ) { - const demo = this.denyDemo(ctx.userId); if (demo) return demo; + const demo = await this.denyDemo(ctx.userId); if (demo) return demo; try { this.collections.removeMember(ctx.userId, collectionId, targetUserId); return ok({ success: true }); } catch (err) { return fail(err); } } @@ -434,7 +434,7 @@ export class CollectionsMcp { { collectionId, userId: targetUserId }: { collectionId: number; userId: number }, ctx: McpContext, ) { - const demo = this.denyDemo(ctx.userId); if (demo) return demo; + const demo = await this.denyDemo(ctx.userId); if (demo) return demo; try { this.collections.cancelInvite(collectionId, ctx.userId, targetUserId); return ok({ success: true }); } catch (err) { return fail(err); } } @@ -447,7 +447,7 @@ export class CollectionsMcp { access: { group: 'collections', mode: 'write' }, }) async acceptCollectionInvite({ collectionId }: { collectionId: number }, ctx: McpContext) { - const demo = this.denyDemo(ctx.userId); if (demo) return demo; + const demo = await this.denyDemo(ctx.userId); if (demo) return demo; // try/catch added with the post-fold quirk pass (see inviteToCollection). try { const res = this.collections.acceptInvite(ctx.userId, collectionId, undefined); @@ -465,7 +465,7 @@ export class CollectionsMcp { access: { group: 'collections', mode: 'write' }, }) async declineCollectionInvite({ collectionId }: { collectionId: number }, ctx: McpContext) { - const demo = this.denyDemo(ctx.userId); if (demo) return demo; + const demo = await this.denyDemo(ctx.userId); if (demo) return demo; try { this.collections.declineInvite(ctx.userId, collectionId, undefined); return ok({ success: true }); } catch (err) { return fail(err); } } @@ -478,7 +478,7 @@ export class CollectionsMcp { access: { group: 'collections', mode: 'write' }, }) async leaveCollection({ collectionId }: { collectionId: number }, ctx: McpContext) { - const demo = this.denyDemo(ctx.userId); if (demo) return demo; + const demo = await this.denyDemo(ctx.userId); if (demo) return demo; try { this.collections.leaveCollection(ctx.userId, collectionId, undefined); return ok({ success: true }); } catch (err) { return fail(err); } } } diff --git a/server/src/nest/day-notes/day-notes.mcp.ts b/server/src/nest/day-notes/day-notes.mcp.ts index 4427b794e..a10d6d031 100644 --- a/server/src/nest/day-notes/day-notes.mcp.ts +++ b/server/src/nest/day-notes/day-notes.mcp.ts @@ -67,7 +67,7 @@ export class DayNotesMcp { }, ctx: McpContext, ) { - if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); + if (await this.auth.isDemoUser(ctx.userId)) return demoDenied(); if (!(await this.notes.verifyTripAccess(tripId, ctx.userId))) return noAccess(); if (!(await this.guards.hasTripPermission('day_edit', tripId, ctx.userId))) return permissionDenied(); if (!(await this.notes.dayExists(dayId, tripId))) return { content: [{ type: 'text' as const, text: 'Day not found.' }], isError: true }; @@ -98,7 +98,7 @@ export class DayNotesMcp { }, ctx: McpContext, ) { - if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); + if (await this.auth.isDemoUser(ctx.userId)) return demoDenied(); if (!(await this.notes.verifyTripAccess(tripId, ctx.userId))) return noAccess(); if (!(await this.guards.hasTripPermission('day_edit', tripId, ctx.userId))) return permissionDenied(); const existing = await this.notes.getNote(noteId, dayId, tripId); @@ -120,7 +120,7 @@ export class DayNotesMcp { access: { group: 'trips', mode: 'write' }, }) async deleteDayNote({ tripId, dayId, noteId }: { tripId: number; dayId: number; noteId: number }, ctx: McpContext) { - if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); + if (await this.auth.isDemoUser(ctx.userId)) return demoDenied(); if (!(await this.notes.verifyTripAccess(tripId, ctx.userId))) return noAccess(); if (!(await this.guards.hasTripPermission('day_edit', tripId, ctx.userId))) return permissionDenied(); const note = await this.notes.getNote(noteId, dayId, tripId); diff --git a/server/src/nest/days/days.mcp.ts b/server/src/nest/days/days.mcp.ts index d65157de5..17e702402 100644 --- a/server/src/nest/days/days.mcp.ts +++ b/server/src/nest/days/days.mcp.ts @@ -62,7 +62,7 @@ export class DaysMcp { { tripId, dayId, ...fields }: { tripId: number; dayId: number } & DayUpdateRequest, ctx: McpContext, ) { - if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); + if (await this.auth.isDemoUser(ctx.userId)) return demoDenied(); if (!(await this.days.verifyTripAccess(tripId, ctx.userId))) return noAccess(); if (!(await this.guards.hasTripPermission('day_edit', tripId, ctx.userId))) return permissionDenied(); const current = await this.days.getDay(dayId, tripId); @@ -91,7 +91,7 @@ export class DaysMcp { { tripId, date, notes, position }: { tripId: number } & DayCreateRequest, ctx: McpContext, ) { - if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); + if (await this.auth.isDemoUser(ctx.userId)) return demoDenied(); if (!(await this.days.verifyTripAccess(tripId, ctx.userId))) return noAccess(); if (!(await this.guards.hasTripPermission('day_edit', tripId, ctx.userId))) return permissionDenied(); if (position === undefined) { @@ -126,7 +126,7 @@ export class DaysMcp { { tripId, orderedIds }: { tripId: number } & DayReorderRequest, ctx: McpContext, ) { - if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); + if (await this.auth.isDemoUser(ctx.userId)) return demoDenied(); if (!(await this.days.verifyTripAccess(tripId, ctx.userId))) return noAccess(); if (!(await this.guards.hasTripPermission('day_edit', tripId, ctx.userId))) return permissionDenied(); try { @@ -153,7 +153,7 @@ export class DaysMcp { access: { group: 'trips', mode: 'write' }, }) async deleteDay({ tripId, dayId }: { tripId: number; dayId: number }, ctx: McpContext) { - if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); + if (await this.auth.isDemoUser(ctx.userId)) return demoDenied(); if (!(await this.days.verifyTripAccess(tripId, ctx.userId))) return noAccess(); if (!(await this.guards.hasTripPermission('day_edit', tripId, ctx.userId))) return permissionDenied(); if (!(await this.days.getDay(dayId, tripId))) return errorResult('Day not found.'); @@ -179,7 +179,7 @@ export class DaysMcp { { tripId, dayId, transport_mode }: { tripId: number; dayId: number; transport_mode?: string | null }, ctx: McpContext, ) { - if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); + if (await this.auth.isDemoUser(ctx.userId)) return demoDenied(); if (!(await this.days.verifyTripAccess(tripId, ctx.userId))) return noAccess(); if (!(await this.guards.hasTripPermission('day_edit', tripId, ctx.userId))) return permissionDenied(); if (!(await this.days.getDay(dayId, tripId))) return errorResult('Day not found.'); diff --git a/server/src/nest/doc-sync/doc-sync.mcp.ts b/server/src/nest/doc-sync/doc-sync.mcp.ts index f84240901..4545f125b 100644 --- a/server/src/nest/doc-sync/doc-sync.mcp.ts +++ b/server/src/nest/doc-sync/doc-sync.mcp.ts @@ -52,7 +52,7 @@ export class DocSyncMcp { when: documentsAddonOn, }) async getTripDocumentSync({ tripId }: { tripId: number }, ctx: McpContext) { - if (!this.files.verifyTripAccess(tripId, ctx.userId)) return noAccess(); + if (!(await this.files.verifyTripAccess(tripId, ctx.userId))) return noAccess(); return ok(await this.sync.status(tripId)); } @@ -67,8 +67,8 @@ export class DocSyncMcp { access: { group: 'files', mode: 'read' }, when: documentsAddonOn, }) - listIssues({ tripId }: { tripId: number }, ctx: McpContext) { - if (!this.files.verifyTripAccess(tripId, ctx.userId)) return noAccess(); + async listIssues({ tripId }: { tripId: number }, ctx: McpContext) { + if (!(await this.files.verifyTripAccess(tripId, ctx.userId))) return noAccess(); const links = this.config.listLinks(tripId); if (links.length === 0) return ok({ configured: false, issues: [] }); return ok({ configured: true, issues: this.sync.issues(tripId) }); @@ -91,7 +91,7 @@ export class DocSyncMcp { when: documentsAddonOn, }) async syncNow({ tripId, full }: { tripId: number; full?: boolean }, ctx: McpContext) { - if (!this.files.verifyTripAccess(tripId, ctx.userId)) return noAccess(); + if (!(await this.files.verifyTripAccess(tripId, ctx.userId))) return noAccess(); const links = this.config.listLinks(tripId); if (links.length === 0) { return errorResult('This trip is not connected to a document store. Connect one in the trip\'s file manager first.'); diff --git a/server/src/nest/doc-sync/doc-sync.service.ts b/server/src/nest/doc-sync/doc-sync.service.ts index 812224160..9c3a6a482 100644 --- a/server/src/nest/doc-sync/doc-sync.service.ts +++ b/server/src/nest/doc-sync/doc-sync.service.ts @@ -10,6 +10,7 @@ import { DatabaseService } from '../database/database.service'; import { AddonsService } from '../addons/addons.service'; import { ADDON_IDS } from '../../addons'; import { StorageService } from '../storage/storage.service'; +import { UnitOfWork } from '../database/unit-of-work'; import { FilesService } from '../files/files.service'; import { AllowedFileTypesService } from '../files/allowed-file-types.service'; import { RealtimeService } from '../realtime/realtime.service'; @@ -88,6 +89,7 @@ export class DocSyncService { private readonly allowedTypes: AllowedFileTypesService, private readonly realtime: RealtimeService, private readonly addons: AddonsService, + private readonly uow: UnitOfWork, ) {} // ── Entry points ─────────────────────────────────────────────────────────── @@ -332,7 +334,7 @@ export class DocSyncService { case 'rename_local': { this.db.connection.prepare('UPDATE trip_files SET original_name = ? WHERE id = ?').run(action.name, action.fileId); this.realtime.broadcast(ctx.link.trip_id, 'file:updated', { - file: this.files.getFileById(action.fileId, ctx.link.trip_id), + file: await this.files.getFileById(action.fileId, ctx.link.trip_id), }); this.db.connection .prepare(`UPDATE document_sync_items SET remote_name = ?, ${FOUND_AGAIN}, last_seen_at = CURRENT_TIMESTAMP WHERE id = ?`) @@ -464,7 +466,7 @@ export class DocSyncService { // Video is admitted the way the upload admits it, whatever the operator's // list says: a clip the file manager takes must not come back as refused // when it arrives through the store instead. - const allowed = isVideoExtension(path.extname(name)) || isAllowedByOperator(name, this.allowedTypes.get()); + const allowed = isVideoExtension(path.extname(name)) || isAllowedByOperator(name, await this.allowedTypes.get()); if (isBlockedName(name) || !allowed) { this.upsertItem(ctx.link, { itemId, remote, state: 'rejected_type', errorCode: 'unsupported_type' }); return 'unsupported_type'; @@ -518,7 +520,7 @@ export class DocSyncService { .prepare('SELECT file_id, remote_name, content_sha256 FROM document_sync_items WHERE id = ?') .get(itemId) as { file_id: number | null; remote_name: string | null; content_sha256: string | null } | undefined); const supersededId = pairing?.file_id ?? null; - const superseded = supersededId === null ? undefined : this.files.getFileById(supersededId, ctx.link.trip_id); + const superseded = supersededId === null ? undefined : await this.files.getFileById(supersededId, ctx.link.trip_id); // The bytes TREK already holds, under a version marker that moved on // metadata: Paperless bumps it for a tag or a correspondent as much as for @@ -560,8 +562,8 @@ export class DocSyncService { * a new revision is the same document to the trip, and without this every * edit in the store quietly took the attachment off its booking. */ - const { created, retired } = this.db.transaction(() => { - const file = this.files.createFile( + const { created, retired } = await this.uow.transactional(async () => { + const file = await this.files.createFile( ctx.link.trip_id, { filename: storageKey, originalname: name, size: bytes, mimetype: remote.mimeType || 'application/octet-stream' }, // Attributed to the person whose connection brought it in, which is the @@ -583,7 +585,7 @@ export class DocSyncService { SELECT ?, reservation_id, assignment_id, place_id, budget_item_id FROM file_links WHERE file_id = ?`, ) .run(file.id, supersededId); - this.files.softDeleteFile(supersededId as number); + await this.files.softDeleteFile(supersededId as number); } this.upsertItem(ctx.link, { itemId, @@ -624,7 +626,7 @@ export class DocSyncService { return 'too_large'; } - const file = this.files.getFileById(local.fileId, ctx.link.trip_id); + const file = await this.files.getFileById(local.fileId, ctx.link.trip_id); if (!file) return 'not_found'; // Re-read rather than trust the plan: a run walks a whole folder, and a // document somebody deleted in the meantime would otherwise still be diff --git a/server/src/nest/files/allowed-file-types.service.ts b/server/src/nest/files/allowed-file-types.service.ts index 224df2948..403388c54 100644 --- a/server/src/nest/files/allowed-file-types.service.ts +++ b/server/src/nest/files/allowed-file-types.service.ts @@ -16,7 +16,7 @@ export class AllowedFileTypesService { constructor(private readonly db: DatabaseService) {} /** Comma-separated, as the admin panel stores it. `*` means anything. */ - get(): string { + async get(): Promise { try { const row = this.db.get<{ value: string }>( "SELECT value FROM app_settings WHERE key = 'allowed_file_types'", diff --git a/server/src/nest/files/files-download.controller.ts b/server/src/nest/files/files-download.controller.ts index 44b48eb46..c823ab91d 100644 --- a/server/src/nest/files/files-download.controller.ts +++ b/server/src/nest/files/files-download.controller.ts @@ -31,17 +31,17 @@ export class FilesDownloadController { @Param('tripId') tripId: string, @Param('id') id: string, ): Promise { - const auth = this.files.authenticateDownload(req); + const auth = await this.files.authenticateDownload(req); if ('error' in auth) { throw new HttpException({ error: auth.error }, auth.status); } - const trip = this.files.verifyTripAccess(tripId, auth.userId); + const trip = await this.files.verifyTripAccess(tripId, auth.userId); if (!trip) { throw new HttpException({ error: 'Trip not found' }, 404); } - const file = this.files.getFileById(id, tripId); + const file = await this.files.getFileById(id, tripId); if (!file) { throw new HttpException({ error: 'File not found' }, 404); } diff --git a/server/src/nest/files/files.controller.ts b/server/src/nest/files/files.controller.ts index e03f566af..b303839a0 100644 --- a/server/src/nest/files/files.controller.ts +++ b/server/src/nest/files/files.controller.ts @@ -52,11 +52,18 @@ export function filesUploadFileFilter(allowedTypes: AllowedFileTypesService): Op cb(err); }; if (BLOCKED_EXTENSIONS.includes(ext) || file.mimetype.includes('svg')) return reject(); - const allowed = allowedTypes.get().split(',').map((e) => e.trim().toLowerCase()); - const fileExt = ext.replace('.', ''); - // Video is accepted as media regardless of the admin doc-types allowlist (#823). - if (allowed.includes(fileExt) || isVideoExtension(fileExt) || (allowed.includes('*') && !BLOCKED_EXTENSIONS.includes(ext))) return cb(null, true); - reject(); + // R1.5: multer's fileFilter is a callback API that cannot await, and the + // allowed-extension list is now an async read. The decision runs in a + // detached async function that always answers through `cb` — multer holds + // the file stream until it does (make-middleware.js says as much). A + // rejection refuses the file, so the filter still fails closed. + void (async () => { + const allowed = (await allowedTypes.get()).split(',').map((e) => e.trim().toLowerCase()); + const fileExt = ext.replace('.', ''); + // Video is accepted as media regardless of the admin doc-types allowlist (#823). + if (allowed.includes(fileExt) || isVideoExtension(fileExt) || (allowed.includes('*') && !BLOCKED_EXTENSIONS.includes(ext))) return cb(null, true); + reject(); + })().catch(() => reject()); }; } @@ -94,16 +101,16 @@ export class FilesController { // A file may only point at reservations/assignments/places/budget_items from its own trip. // Reject cross-trip ids before they are stored — the reservation JOIN would // otherwise leak the foreign reservation's title back to the caller. - private assertLinkTargets(tripId: string, body: { reservation_id?: string | number | null; assignment_id?: string | number | null; place_id?: string | number | null; budget_item_id?: string | number | null }) { - if (this.files.findForeignLinkTarget(tripId, body)) { + private async assertLinkTargets(tripId: string, body: { reservation_id?: string | number | null; assignment_id?: string | number | null; place_id?: string | number | null; budget_item_id?: string | number | null }) { + if (await this.files.findForeignLinkTarget(tripId, body)) { throw new HttpException({ error: 'Linked item does not belong to this trip' }, 400); } } @UseGuards(TripAccessGuard) @Get() - list(@CurrentUser() user: User, @Trip() trip: TripAccess, @Param('tripId') tripId: string, @Query('trash') trash?: string) { - return { files: this.files.listFiles(tripId, trash === 'true') }; + async list(@CurrentUser() user: User, @Trip() trip: TripAccess, @Param('tripId') tripId: string, @Query('trash') trash?: string) { + return { files: await this.files.listFiles(tripId, trash === 'true') }; } @Post() @@ -120,7 +127,7 @@ export class FilesController { // leaves up to the 500 MB video cap on disk (#823). const cleanup = () => { if (file?.path) { try { fs.unlinkSync(file.path); } catch { /* best-effort */ } } }; try { - const trip = this.files.verifyTripAccess(tripId, user.id); + const trip = await this.files.verifyTripAccess(tripId, user.id); if (!trip) { throw new HttpException({ error: 'Trip not found' }, 404); } @@ -148,7 +155,7 @@ export class FilesController { throw new HttpException({ error: 'File is too large' }, 400); } try { - this.assertLinkTargets(tripId, { reservation_id: body.reservation_id, place_id: body.place_id, budget_item_id: body.budget_item_id }); + await this.assertLinkTargets(tripId, { reservation_id: body.reservation_id, place_id: body.place_id, budget_item_id: body.budget_item_id }); } catch (err) { cleanup(); throw err; @@ -161,7 +168,7 @@ export class FilesController { cleanup(); throw err; } - const created = this.files.createFile(tripId, file, user.id, { + const created = await this.files.createFile(tripId, file, user.id, { place_id: body.place_id, description: body.description, reservation_id: body.reservation_id, @@ -177,12 +184,12 @@ export class FilesController { if (!(await this.files.can('file_edit', trip, user))) { throw new HttpException({ error: 'No permission to edit files' }, 403); } - const file = this.files.getFileById(id, tripId); + const file = await this.files.getFileById(id, tripId); if (!file) { throw new HttpException({ error: 'File not found' }, 404); } - this.assertLinkTargets(tripId, { reservation_id: body.reservation_id, place_id: body.place_id, budget_item_id: body.budget_item_id }); - const updated = this.files.updateFile(id, file, { + await this.assertLinkTargets(tripId, { reservation_id: body.reservation_id, place_id: body.place_id, budget_item_id: body.budget_item_id }); + const updated = await this.files.updateFile(id, file, { description: body.description, place_id: body.place_id, reservation_id: body.reservation_id, @@ -198,11 +205,11 @@ export class FilesController { if (!(await this.files.can('file_edit', trip, user))) { throw new HttpException({ error: 'No permission' }, 403); } - const file = this.files.getFileById(id, tripId); + const file = await this.files.getFileById(id, tripId); if (!file) { throw new HttpException({ error: 'File not found' }, 404); } - const updated = this.files.toggleStarred(id, file.starred); + const updated = await this.files.toggleStarred(id, file.starred); this.files.broadcast(tripId, 'file:updated', { file: updated }, socketId); return { file: updated }; } @@ -223,7 +230,7 @@ export class FilesController { if (!(await this.files.can('file_delete', trip, user))) { throw new HttpException({ error: 'No permission' }, 403); } - const file = this.files.getDeletedFile(id, tripId); + const file = await this.files.getDeletedFile(id, tripId); if (!file) { throw new HttpException({ error: 'File not found in trash' }, 404); } @@ -238,11 +245,11 @@ export class FilesController { if (!(await this.files.can('file_delete', trip, user))) { throw new HttpException({ error: 'No permission to delete files' }, 403); } - const file = this.files.getFileById(id, tripId); + const file = await this.files.getFileById(id, tripId); if (!file) { throw new HttpException({ error: 'File not found' }, 404); } - this.files.softDeleteFile(id); + await this.files.softDeleteFile(id); this.files.broadcast(tripId, 'file:deleted', { fileId: Number(id) }, socketId); return { success: true }; } @@ -254,11 +261,11 @@ export class FilesController { if (!(await this.files.can('file_delete', trip, user))) { throw new HttpException({ error: 'No permission' }, 403); } - const file = this.files.getDeletedFile(id, tripId); + const file = await this.files.getDeletedFile(id, tripId); if (!file) { throw new HttpException({ error: 'File not found in trash' }, 404); } - const restored = this.files.restoreFile(id); + const restored = await this.files.restoreFile(id); this.files.broadcast(tripId, 'file:created', { file: restored }, socketId); return { file: restored }; } @@ -270,12 +277,12 @@ export class FilesController { if (!(await this.files.can('file_edit', trip, user))) { throw new HttpException({ error: 'No permission' }, 403); } - const file = this.files.getFileById(id, tripId); + const file = await this.files.getFileById(id, tripId); if (!file) { throw new HttpException({ error: 'File not found' }, 404); } - this.assertLinkTargets(tripId, { reservation_id: body.reservation_id, assignment_id: body.assignment_id, place_id: body.place_id, budget_item_id: body.budget_item_id }); - const links = this.files.createFileLink(id, { reservation_id: body.reservation_id, assignment_id: body.assignment_id, place_id: body.place_id, budget_item_id: body.budget_item_id }); + await this.assertLinkTargets(tripId, { reservation_id: body.reservation_id, assignment_id: body.assignment_id, place_id: body.place_id, budget_item_id: body.budget_item_id }); + const links = await this.files.createFileLink(id, { reservation_id: body.reservation_id, assignment_id: body.assignment_id, place_id: body.place_id, budget_item_id: body.budget_item_id }); return { success: true, links }; } @@ -288,21 +295,21 @@ export class FilesController { // deleteFileLink scopes by (linkId, fileId) only, so the file itself has to // be resolved against :tripId first. Otherwise a member of any trip could // drop a link row belonging to a foreign trip's file. - const file = this.files.getFileById(id, tripId); + const file = await this.files.getFileById(id, tripId); if (!file) { throw new HttpException({ error: 'File not found' }, 404); } - this.files.deleteFileLink(linkId, id); + await this.files.deleteFileLink(linkId, id); return { success: true }; } @UseGuards(TripAccessGuard) @Get(':id/links') - links(@CurrentUser() user: User, @Trip() trip: TripAccess, @Param('tripId') tripId: string, @Param('id') id: string) { - const file = this.files.getFileById(id, tripId); + async links(@CurrentUser() user: User, @Trip() trip: TripAccess, @Param('tripId') tripId: string, @Param('id') id: string) { + const file = await this.files.getFileById(id, tripId); if (!file) { throw new HttpException({ error: 'File not found' }, 404); } - return { links: this.files.getFileLinks(id) }; + return { links: await this.files.getFileLinks(id) }; } } diff --git a/server/src/nest/files/files.mcp.ts b/server/src/nest/files/files.mcp.ts index 76119722c..414409d4f 100644 --- a/server/src/nest/files/files.mcp.ts +++ b/server/src/nest/files/files.mcp.ts @@ -65,8 +65,8 @@ export class FilesMcp { access: { group: 'files', mode: 'read' }, }) async listTripFiles({ tripId, trash }: { tripId: number; trash?: boolean }, ctx: McpContext) { - if (!this.files.verifyTripAccess(tripId, ctx.userId)) return noAccess(); - return ok({ files: this.files.listFiles(tripId, trash === true) }); + if (!(await this.files.verifyTripAccess(tripId, ctx.userId))) return noAccess(); + return ok({ files: await this.files.listFiles(tripId, trash === true) }); } @Tool({ @@ -83,7 +83,7 @@ export class FilesMcp { access: { group: 'files', mode: 'content' }, }) async readTripFile({ tripId, fileId }: { tripId: number; fileId: number }, ctx: McpContext) { - if (!this.files.verifyTripAccess(tripId, ctx.userId)) return noAccess(); + if (!(await this.files.verifyTripAccess(tripId, ctx.userId))) return noAccess(); try { const { name, mimetype, bytes } = await this.files.readContent(tripId, fileId); const asText = isTextual(mimetype); @@ -121,15 +121,15 @@ export class FilesMcp { { tripId, fileId, ...fields }: { tripId: number; fileId: number } & FileUpdateRequest, ctx: McpContext, ) { - if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); - if (!this.files.verifyTripAccess(tripId, ctx.userId)) return noAccess(); + if (await this.auth.isDemoUser(ctx.userId)) return demoDenied(); + if (!(await this.files.verifyTripAccess(tripId, ctx.userId))) return noAccess(); if (!(await this.guards.hasTripPermission('file_edit', tripId, ctx.userId))) return permissionDenied(); - const current = this.files.getFileById(fileId, tripId); + const current = await this.files.getFileById(fileId, tripId); if (!current) return errorResult('File not found.'); // Same enforcement as the REST route's assertLinkTargets, through the same // service method: a foreign reservation id would otherwise come back with its // title attached the next time this file is listed. - const foreign = this.files.findForeignLinkTarget(tripId, { + const foreign = await this.files.findForeignLinkTarget(tripId, { reservation_id: fields.reservation_id, place_id: fields.place_id, budget_item_id: fields.budget_item_id, @@ -138,7 +138,7 @@ export class FilesMcp { // The rest spread carries only the keys the caller actually sent, which is what // updateFile's presence sentinels need: naming the fields here would hand it an // undefined description on a link-only call and wipe the description. - const file = this.files.updateFile(fileId, current, fields); + const file = await this.files.updateFile(fileId, current, fields); this.guards.safeBroadcast(tripId, 'file:updated', { file }); return ok({ file }); } @@ -161,19 +161,19 @@ export class FilesMcp { { tripId, fileId, ...targets }: { tripId: number; fileId: number } & FileLinkRequest, ctx: McpContext, ) { - if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); - if (!this.files.verifyTripAccess(tripId, ctx.userId)) return noAccess(); + if (await this.auth.isDemoUser(ctx.userId)) return demoDenied(); + if (!(await this.files.verifyTripAccess(tripId, ctx.userId))) return noAccess(); if (!(await this.guards.hasTripPermission('file_edit', tripId, ctx.userId))) return permissionDenied(); - if (!this.files.getFileById(fileId, tripId)) return errorResult('File not found.'); + if (!(await this.files.getFileById(fileId, tripId))) return errorResult('File not found.'); // The REST body allows all three to be absent and stores a link row pointing at // nothing. A tool caller that gets here with no target made a mistake, and saying // so is more useful than a success that attached the file to nothing. if (!targets.reservation_id && !targets.assignment_id && !targets.place_id && !targets.budget_item_id) { return errorResult('Pass at least one of reservation_id, assignment_id, place_id or budget_item_id.'); } - const foreign = this.files.findForeignLinkTarget(tripId, targets); + const foreign = await this.files.findForeignLinkTarget(tripId, targets); if (foreign) return errorResult(`Linked item does not belong to this trip (${foreign}).`); - const links = this.files.createFileLink(fileId, targets); + const links = await this.files.createFileLink(fileId, targets); return ok({ success: true, links }); } @@ -192,14 +192,14 @@ export class FilesMcp { { tripId, fileId, linkId }: { tripId: number; fileId: number; linkId: number }, ctx: McpContext, ) { - if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); - if (!this.files.verifyTripAccess(tripId, ctx.userId)) return noAccess(); + if (await this.auth.isDemoUser(ctx.userId)) return demoDenied(); + if (!(await this.files.verifyTripAccess(tripId, ctx.userId))) return noAccess(); if (!(await this.guards.hasTripPermission('file_edit', tripId, ctx.userId))) return permissionDenied(); // deleteFileLink scopes by (linkId, fileId) only, so the file has to be resolved // against :tripId first, exactly as the REST route does it. Otherwise a member of // any trip could drop a link row belonging to a foreign trip's file. - if (!this.files.getFileById(fileId, tripId)) return errorResult('File not found.'); - this.files.deleteFileLink(linkId, fileId); + if (!(await this.files.getFileById(fileId, tripId))) return errorResult('File not found.'); + await this.files.deleteFileLink(linkId, fileId); return ok({ success: true }); } @@ -214,8 +214,8 @@ export class FilesMcp { access: { group: 'files', mode: 'read' }, }) async listTripFileLinks({ tripId, fileId }: { tripId: number; fileId: number }, ctx: McpContext) { - if (!this.files.verifyTripAccess(tripId, ctx.userId)) return noAccess(); - if (!this.files.getFileById(fileId, tripId)) return errorResult('File not found.'); - return ok({ links: this.files.getFileLinks(fileId) }); + if (!(await this.files.verifyTripAccess(tripId, ctx.userId))) return noAccess(); + if (!(await this.files.getFileById(fileId, tripId))) return errorResult('File not found.'); + return ok({ links: await this.files.getFileLinks(fileId) }); } } diff --git a/server/src/nest/files/files.rpc.ts b/server/src/nest/files/files.rpc.ts index 439f3a272..e7d0a2b1a 100644 --- a/server/src/nest/files/files.rpc.ts +++ b/server/src/nest/files/files.rpc.ts @@ -54,9 +54,9 @@ export class FilesRpc { ) {} @PluginMethod('files.list', { permission: 'db:read:files' }) - list(params: Record, ctx: PluginRpcContext): unknown { + async list(params: Record, ctx: PluginRpcContext): Promise { // Trash excluded, the same view the files tab shows. - return this.guards.tripRead(params, ctx, () => this.files.listFiles(num(params.tripId, 'tripId'), false)); + return await this.guards.tripRead(params, ctx, async () => this.files.listFiles(num(params.tripId, 'tripId'), false)); } @PluginMethod('files.getContent', { permission: 'db:read:files:content' }) @@ -121,7 +121,7 @@ export class FilesRpc { const buf = Buffer.from(input.content_base64, 'base64'); if (buf.length === 0) throw new BadParams('file content is empty'); if (buf.length > CONTENT_MAX) throw new BadParams('file exceeds the 10MB plugin upload cap'); - const foreign = this.files.findForeignLinkTarget(tripId, { + const foreign = await this.files.findForeignLinkTarget(tripId, { reservation_id: input.reservation_id ?? null, place_id: input.place_id ?? null, }); @@ -133,7 +133,7 @@ export class FilesRpc { await this.storage.put('files', filename, Readable.from(buf), { contentType: input.mimetype || 'application/octet-stream', }); - const file = this.files.createFile( + const file = await this.files.createFile( tripId, { filename, originalname: original, size: buf.length, mimetype: input.mimetype || 'application/octet-stream' }, actingUserId, @@ -153,12 +153,12 @@ export class FilesRpc { const fileId = num(params.fileId, 'fileId'); const actor = this.guards.requireActor(ctx, 'file link'); await this.guards.requireTripEdit(tripId, actor, EDIT_ACTION); - if (!this.files.getFileById(fileId, tripId)) throw new ForbiddenResource(`no file ${fileId} on trip ${tripId}`); + if (!(await this.files.getFileById(fileId, tripId))) throw new ForbiddenResource(`no file ${fileId} on trip ${tripId}`); const opts = asPayload(params.opts) as { reservation_id?: number; assignment_id?: number; place_id?: number }; // A link target on another trip would otherwise attach this trip's file to it. - const foreign = this.files.findForeignLinkTarget(tripId, opts); + const foreign = await this.files.findForeignLinkTarget(tripId, opts); if (foreign) throw new ForbiddenResource(`${foreign} does not belong to trip ${tripId}`); - return this.files.createFileLink(fileId, { + return await this.files.createFileLink(fileId, { reservation_id: opts.reservation_id != null ? String(opts.reservation_id) : null, assignment_id: opts.assignment_id != null ? String(opts.assignment_id) : null, place_id: opts.place_id != null ? String(opts.place_id) : null, @@ -171,15 +171,15 @@ export class FilesRpc { const fileId = num(params.fileId, 'fileId'); const actor = this.guards.requireActor(ctx, 'file'); await this.guards.requireTripEdit(tripId, actor, EDIT_ACTION); - const current = this.files.getFileById(fileId, tripId); + const current = await this.files.getFileById(fileId, tripId); if (!current) throw new ForbiddenResource(`no file ${fileId} on trip ${tripId}`); const input = asPayload(params.input) as { description?: string; place_id?: number | null; reservation_id?: number | null }; - const foreign = this.files.findForeignLinkTarget(tripId, { + const foreign = await this.files.findForeignLinkTarget(tripId, { reservation_id: input.reservation_id ?? null, place_id: input.place_id ?? null, }); if (foreign) throw new ForbiddenResource(`${foreign} does not belong to trip ${tripId}`); - const file = this.files.updateFile(fileId, current, { + const file = await this.files.updateFile(fileId, current, { description: input.description, // null clears the link, undefined leaves it alone: the two are distinct here. place_id: input.place_id != null ? String(input.place_id) : input.place_id === null ? null : undefined, @@ -195,8 +195,8 @@ export class FilesRpc { const fileId = num(params.fileId, 'fileId'); const actor = this.guards.requireActor(ctx, 'file'); await this.guards.requireTripEdit(tripId, actor, DELETE_ACTION); - if (!this.files.getFileById(fileId, tripId)) throw new ForbiddenResource(`no file ${fileId} on trip ${tripId}`); - this.files.softDeleteFile(fileId); + if (!(await this.files.getFileById(fileId, tripId))) throw new ForbiddenResource(`no file ${fileId} on trip ${tripId}`); + await this.files.softDeleteFile(fileId); this.realtime.broadcast(tripId, 'file:deleted', { fileId }, undefined); return { deleted: true }; } diff --git a/server/src/nest/files/files.service.ts b/server/src/nest/files/files.service.ts index 0033b9eb4..5f1a56a61 100644 --- a/server/src/nest/files/files.service.ts +++ b/server/src/nest/files/files.service.ts @@ -81,7 +81,7 @@ export class FilesService { private readonly storage: StorageService, ) {} - verifyTripAccess(tripId: string | number, userId: number) { + async verifyTripAccess(tripId: string | number, userId: number) { return this.db.canAccessTrip(tripId, userId); } @@ -98,7 +98,7 @@ export class FilesService { // download route) // --------------------------------------------------------------------------- - authenticateDownload(req: Request): { userId: number } | { error: string; status: number } { + async authenticateDownload(req: Request): Promise<{ userId: number } | { error: string; status: number }> { const cookieToken = (req as { cookies?: Record }).cookies?.trek_session; const authHeader = req.headers['authorization']; const bearerToken = authHeader ? (authHeader.split(' ')[1] || undefined) : undefined; @@ -110,7 +110,7 @@ export class FilesService { // Use the shared helper so the password_version gate applies here too; // previously this bypassed the check and stolen download tokens stayed // valid across a password reset. - const user = verifyJwtAndLoadUser(jwtToken); + const user = await verifyJwtAndLoadUser(jwtToken); if (!user) return { error: 'Invalid or expired token', status: 401 }; return { userId: user.id }; } @@ -136,10 +136,10 @@ export class FilesService { * the first field that escapes `tripId`, or null when every supplied id belongs * to the trip. Absent / null / zero ids are ignored (they clear the link). */ - findForeignLinkTarget( + async findForeignLinkTarget( tripId: string | number, opts: { reservation_id?: string | number | null; assignment_id?: string | number | null; place_id?: string | number | null; budget_item_id?: string | number | null } - ): 'reservation_id' | 'assignment_id' | 'place_id' | 'budget_item_id' | null { + ): Promise<'reservation_id' | 'assignment_id' | 'place_id' | 'budget_item_id' | null> { if (opts.reservation_id && !this.db.get('SELECT 1 FROM reservations WHERE id = ? AND trip_id = ?', opts.reservation_id, tripId)) { return 'reservation_id'; } @@ -159,11 +159,11 @@ export class FilesService { // CRUD // --------------------------------------------------------------------------- - getFileById(id: string | number, tripId: string | number): TripFile | undefined { + async getFileById(id: string | number, tripId: string | number): Promise { return this.db.get('SELECT * FROM trip_files WHERE id = ? AND trip_id = ?', id, tripId); } - getDeletedFile(id: string | number, tripId: string | number): TripFile | undefined { + async getDeletedFile(id: string | number, tripId: string | number): Promise { return this.db.get('SELECT * FROM trip_files WHERE id = ? AND trip_id = ? AND deleted_at IS NOT NULL', id, tripId); } @@ -185,7 +185,7 @@ export class FilesService { tripId: string | number, fileId: string | number, ): Promise<{ name: string; mimetype: string; bytes: Buffer }> { - const file = this.getFileById(fileId, tripId); + const file = await this.getFileById(fileId, tripId); if (!file || file.deleted_at) throw new FileContentError('not-found', `no file ${fileId} on trip ${tripId}`); if ((file.file_size ?? 0) > FILE_CONTENT_MAX) { throw new FileContentError('too-large', `file too large to read (>${FILE_CONTENT_MAX} bytes); use the download UI`); @@ -225,7 +225,7 @@ export class FilesService { }; } - listFiles(tripId: string | number, showTrash: boolean) { + async listFiles(tripId: string | number, showTrash: boolean) { const where = showTrash ? 'f.trip_id = ? AND f.deleted_at IS NOT NULL AND f.message_id IS NULL' : 'f.trip_id = ? AND f.deleted_at IS NULL AND f.message_id IS NULL'; @@ -253,7 +253,7 @@ export class FilesService { }); } - createFile( + async createFile( tripId: string | number, file: { filename: string; originalname: string; size: number; mimetype: string }, uploadedBy: number, @@ -282,7 +282,7 @@ export class FilesService { return formatFile(created); } - updateFile( + async updateFile( id: string | number, current: TripFile, updates: { description?: string; place_id?: string | number | null; reservation_id?: string | number | null; budget_item_id?: string | number | null } @@ -312,7 +312,7 @@ export class FilesService { return formatFile(updated); } - toggleStarred(id: string | number, currentStarred: number | undefined) { + async toggleStarred(id: string | number, currentStarred: number | undefined) { const newStarred = currentStarred ? 0 : 1; this.db.run('UPDATE trip_files SET starred = ? WHERE id = ?', newStarred, id); @@ -320,11 +320,11 @@ export class FilesService { return formatFile(updated); } - softDeleteFile(id: string | number) { + async softDeleteFile(id: string | number) { this.db.run('UPDATE trip_files SET deleted_at = CURRENT_TIMESTAMP WHERE id = ?', id); } - restoreFile(id: string | number) { + async restoreFile(id: string | number) { this.db.run('UPDATE trip_files SET deleted_at = NULL WHERE id = ?', id); const restored = this.db.get(`${FILE_SELECT} WHERE f.id = ?`, id)!; return formatFile(restored); @@ -372,7 +372,7 @@ export class FilesService { // Dedupe rides INSERT OR IGNORE + the UNIQUE(file_id, ) constraints; // a genuine insert failure propagates to the global exception filter instead // of returning a success-shaped links list (the legacy catch swallowed it). - createFileLink( + async createFileLink( fileId: string | number, opts: { reservation_id?: string | number | null; assignment_id?: string | number | null; place_id?: string | number | null; budget_item_id?: string | number | null } ) { @@ -382,11 +382,11 @@ export class FilesService { return this.db.all('SELECT * FROM file_links WHERE file_id = ?', fileId); } - deleteFileLink(linkId: string | number, fileId: string | number) { + async deleteFileLink(linkId: string | number, fileId: string | number) { this.db.run('DELETE FROM file_links WHERE id = ? AND file_id = ?', linkId, fileId); } - getFileLinks(fileId: string | number) { + async getFileLinks(fileId: string | number) { return this.db.all(` SELECT fl.*, r.title as reservation_title FROM file_links fl diff --git a/server/src/nest/integrations/dawarich.mcp.ts b/server/src/nest/integrations/dawarich.mcp.ts index c77b2b22f..a4b5faaa5 100644 --- a/server/src/nest/integrations/dawarich.mcp.ts +++ b/server/src/nest/integrations/dawarich.mcp.ts @@ -104,11 +104,11 @@ export class DawarichMcp { access: { group: 'places', mode: 'write' }, when: dawarichAddonOn, }) - acceptAsPlace( + async acceptAsPlace( args: { suggestionId: number; tripId?: number; dayId?: number; name?: string; notes?: string; lat?: number; lng?: number }, ctx: McpContext, ) { - if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); + if (await this.auth.isDemoUser(ctx.userId)) return demoDenied(); const { suggestionId, ...rest } = args; return this.run(() => this.suggestions.accept(ctx.userId, suggestionId, { target: 'place', ...rest })); } @@ -129,11 +129,11 @@ export class DawarichMcp { access: { group: 'journey', mode: 'write' }, when: dawarichAddonOn, }) - acceptAsJournalEntry( + async acceptAsJournalEntry( args: { suggestionId: number; journalId: number; name?: string; notes?: string; date?: string; time?: string }, ctx: McpContext, ) { - if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); + if (await this.auth.isDemoUser(ctx.userId)) return demoDenied(); const { suggestionId, ...rest } = args; return this.run(() => this.suggestions.accept(ctx.userId, suggestionId, { target: 'journal', ...rest })); } @@ -151,11 +151,11 @@ export class DawarichMcp { access: { group: 'atlas', mode: 'write' }, when: dawarichAddonOn, }) - markBucketVisited( + async markBucketVisited( { suggestionId, bucketListItemId }: { suggestionId: number; bucketListItemId?: number }, ctx: McpContext, ) { - if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); + if (await this.auth.isDemoUser(ctx.userId)) return demoDenied(); return this.run(() => this.suggestions.accept(ctx.userId, suggestionId, { target: 'bucket_list', bucketListItemId }), ); @@ -173,11 +173,11 @@ export class DawarichMcp { access: { group: 'journey', mode: 'write' }, when: dawarichAddonOn, }) - dismiss( + async dismiss( { suggestionId, state }: { suggestionId: number; state?: 'dismissed' | 'new' }, ctx: McpContext, ) { - if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); + if (await this.auth.isDemoUser(ctx.userId)) return demoDenied(); const updated = this.suggestions.setState(ctx.userId, suggestionId, state ?? 'dismissed'); if (!updated) return errorResult('Suggestion not found'); return ok({ suggestion: updated }); diff --git a/server/src/nest/journey/journal.rpc.ts b/server/src/nest/journey/journal.rpc.ts index e0982f311..5e146bc93 100644 --- a/server/src/nest/journey/journal.rpc.ts +++ b/server/src/nest/journey/journal.rpc.ts @@ -158,7 +158,7 @@ export class JournalRpc { } // The operator's allow-list gates this path too, or the RPC would be the way // around an admin setting that the REST upload obeys (journeyImageFileFilter). - const allowed = this.allowedTypes.get().split(',').map((e) => e.trim().toLowerCase()); + const allowed = (await this.allowedTypes.get()).split(',').map((e) => e.trim().toLowerCase()); if (!allowed.includes('*') && !allowed.includes(ext.slice(1))) { throw new BadParams(`file type ${ext} is not allowed`); } diff --git a/server/src/nest/journey/journey.controller.ts b/server/src/nest/journey/journey.controller.ts index 64ad4e4ac..0e14a3509 100644 --- a/server/src/nest/journey/journey.controller.ts +++ b/server/src/nest/journey/journey.controller.ts @@ -78,13 +78,23 @@ export function journeyImageFileFilter(allowedTypes: AllowedFileTypesService): O return cb(err); } const ext = path.extname(file.originalname).toLowerCase().replace('.', ''); - const allowed = allowedTypes.get().split(',').map((e) => e.trim().toLowerCase()); - if (!allowed.includes('*') && !allowed.includes(ext)) { + // R1.5: multer's fileFilter is a callback API that cannot await, and the + // allowed-extension list is now an async read. The decision runs in a + // detached async function that always answers through `cb`; a rejection + // refuses the file, so the filter still fails closed. + void (async () => { + const allowed = (await allowedTypes.get()).split(',').map((e) => e.trim().toLowerCase()); + if (!allowed.includes('*') && !allowed.includes(ext)) { + const err: Error & { statusCode?: number } = new Error(`File type .${ext} is not allowed`); + err.statusCode = 400; + return cb(err); + } + cb(null, true); + })().catch(() => { const err: Error & { statusCode?: number } = new Error(`File type .${ext} is not allowed`); err.statusCode = 400; - return cb(err); - } - cb(null, true); + cb(err); + }); }; } diff --git a/server/src/nest/journey/journey.mcp.ts b/server/src/nest/journey/journey.mcp.ts index 57a1aa719..451d5f113 100644 --- a/server/src/nest/journey/journey.mcp.ts +++ b/server/src/nest/journey/journey.mcp.ts @@ -228,11 +228,11 @@ export class JourneyMcp { when: journeyAddonOn, access: { group: 'journey', mode: 'write' }, }) - createJourney( + async createJourney( { title, subtitle, trip_ids }: { title: string; subtitle?: string; trip_ids?: number[] }, ctx: McpContext, ) { - if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); + if (await this.auth.isDemoUser(ctx.userId)) return demoDenied(); const journey = this.journey.createJourney(ctx.userId, { title, subtitle, trip_ids }); // Return the fully-hydrated journey (entries/contributors/trips/stats/my_role), // matching get_journey, rather than the bare row. @@ -255,14 +255,14 @@ export class JourneyMcp { when: journeyAddonOn, access: { group: 'journey', mode: 'write' }, }) - updateJourney( + async updateJourney( { journeyId, ...data }: { journeyId: number; title?: string; subtitle?: string; status?: string; show_verdict?: boolean; show_mood?: boolean; show_weather?: boolean; }, ctx: McpContext, ) { - if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); + if (await this.auth.isDemoUser(ctx.userId)) return demoDenied(); const journey = this.journey.updateJourney(journeyId, ctx.userId, data); if (!journey) return notFound('Journey not found or access denied.'); return ok({ journey }); @@ -278,8 +278,8 @@ export class JourneyMcp { when: journeyAddonOn, access: { group: 'journey', mode: 'write' }, }) - restoreJourneySuggestions({ journeyId }: { journeyId: number }, ctx: McpContext) { - if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); + async restoreJourneySuggestions({ journeyId }: { journeyId: number }, ctx: McpContext) { + if (await this.auth.isDemoUser(ctx.userId)) return demoDenied(); const result = this.journey.restoreDismissedSuggestions(journeyId, ctx.userId); if (!result) return notFound('Journey not found or access denied.'); return ok(result); @@ -293,8 +293,8 @@ export class JourneyMcp { when: journeyAddonOn, access: { group: 'journey', mode: 'write' }, }) - deleteJourney({ journeyId }: { journeyId: number }, ctx: McpContext) { - if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); + async deleteJourney({ journeyId }: { journeyId: number }, ctx: McpContext) { + if (await this.auth.isDemoUser(ctx.userId)) return demoDenied(); if (!this.journey.deleteJourney(journeyId, ctx.userId)) return notFound('Journey not found or access denied.'); return ok({ success: true }); } @@ -307,8 +307,8 @@ export class JourneyMcp { when: journeyAddonOn, access: { group: 'journey', mode: 'write' }, }) - addJourneyTrip({ journeyId, tripId }: { journeyId: number; tripId: number }, ctx: McpContext) { - if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); + async addJourneyTrip({ journeyId, tripId }: { journeyId: number; tripId: number }, ctx: McpContext) { + if (await this.auth.isDemoUser(ctx.userId)) return demoDenied(); if (!this.journey.canAccessJourney(journeyId, ctx.userId)) return notFound('Journey not found or access denied.'); return ok({ success: this.journey.addTripToJourney(journeyId, tripId, ctx.userId) }); } @@ -321,8 +321,8 @@ export class JourneyMcp { when: journeyAddonOn, access: { group: 'journey', mode: 'write' }, }) - removeJourneyTrip({ journeyId, tripId }: { journeyId: number; tripId: number }, ctx: McpContext) { - if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); + async removeJourneyTrip({ journeyId, tripId }: { journeyId: number; tripId: number }, ctx: McpContext) { + if (await this.auth.isDemoUser(ctx.userId)) return demoDenied(); const success = this.journey.removeTripFromJourney(journeyId, tripId, ctx.userId); if (!success) return notFound('Journey not found or access denied.'); return ok({ success }); @@ -352,7 +352,7 @@ export class JourneyMcp { when: journeyAddonOn, access: { group: 'journey', mode: 'write' }, }) - createJourneyEntry( + async createJourneyEntry( { journeyId, ...data }: { journeyId: number; entry_date: string; title?: string; story?: string; entry_time?: string; location_name?: string; location_lat?: number; location_lng?: number; mood?: string; weather?: string; @@ -361,7 +361,7 @@ export class JourneyMcp { }, ctx: McpContext, ) { - if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); + if (await this.auth.isDemoUser(ctx.userId)) return demoDenied(); const entry = this.journey.createEntry(journeyId, ctx.userId, data); if (!entry) return notFound('Journey not found or access denied.'); // Return through the listEntries enrichment (parsed tags/pros_cons, photos, source_trip_name). @@ -395,7 +395,7 @@ export class JourneyMcp { when: journeyAddonOn, access: { group: 'journey', mode: 'write' }, }) - updateJourneyEntry( + async updateJourneyEntry( { entryId, ...data }: { entryId: number; title?: string | null; story?: string | null; entry_date?: string; entry_time?: string | null; location_name?: string | null; location_lat?: number | null; @@ -406,7 +406,7 @@ export class JourneyMcp { }, ctx: McpContext, ) { - if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); + if (await this.auth.isDemoUser(ctx.userId)) return demoDenied(); const entry = this.journey.updateEntry(entryId, ctx.userId, data, undefined); if (!entry) return notFound('Entry not found or access denied.'); // Return through the listEntries enrichment (parsed tags/pros_cons, photos), matching create_journey_entry. @@ -422,8 +422,8 @@ export class JourneyMcp { when: journeyAddonOn, access: { group: 'journey', mode: 'write' }, }) - deleteJourneyEntry({ entryId }: { entryId: number }, ctx: McpContext) { - if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); + async deleteJourneyEntry({ entryId }: { entryId: number }, ctx: McpContext) { + if (await this.auth.isDemoUser(ctx.userId)) return demoDenied(); if (!this.journey.deleteEntry(entryId, ctx.userId, undefined)) return notFound('Entry not found or access denied.'); return ok({ success: true }); } @@ -439,8 +439,8 @@ export class JourneyMcp { when: journeyAddonOn, access: { group: 'journey', mode: 'write' }, }) - reorderJourneyEntries({ journeyId, orderedIds }: { journeyId: number; orderedIds: number[] }, ctx: McpContext) { - if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); + async reorderJourneyEntries({ journeyId, orderedIds }: { journeyId: number; orderedIds: number[] }, ctx: McpContext) { + if (await this.auth.isDemoUser(ctx.userId)) return demoDenied(); const success = this.journey.reorderEntries(journeyId, ctx.userId, orderedIds, undefined); if (!success) return notFound('Journey not found, access denied, or entry IDs do not belong to this journey.'); return ok({ success: true }); @@ -458,11 +458,11 @@ export class JourneyMcp { when: journeyAddonOn, access: { group: 'journey', mode: 'write' }, }) - addJourneyContributor( + async addJourneyContributor( { journeyId, targetUserId, role }: { journeyId: number; targetUserId: number; role: 'editor' | 'viewer' }, ctx: McpContext, ) { - if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); + if (await this.auth.isDemoUser(ctx.userId)) return demoDenied(); if (!this.journey.addContributor(journeyId, ctx.userId, targetUserId, role)) return notFound('Journey not found or access denied.'); return ok({ success: true }); } @@ -479,11 +479,11 @@ export class JourneyMcp { when: journeyAddonOn, access: { group: 'journey', mode: 'write' }, }) - updateJourneyContributorRole( + async updateJourneyContributorRole( { journeyId, targetUserId, role }: { journeyId: number; targetUserId: number; role: 'editor' | 'viewer' }, ctx: McpContext, ) { - if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); + if (await this.auth.isDemoUser(ctx.userId)) return demoDenied(); if (!this.journey.updateContributorRole(journeyId, ctx.userId, targetUserId, role)) return notFound('Journey not found or access denied.'); return ok({ success: true }); } @@ -499,8 +499,8 @@ export class JourneyMcp { when: journeyAddonOn, access: { group: 'journey', mode: 'write' }, }) - removeJourneyContributor({ journeyId, targetUserId }: { journeyId: number; targetUserId: number }, ctx: McpContext) { - if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); + async removeJourneyContributor({ journeyId, targetUserId }: { journeyId: number; targetUserId: number }, ctx: McpContext) { + if (await this.auth.isDemoUser(ctx.userId)) return demoDenied(); if (!this.journey.removeContributor(journeyId, ctx.userId, targetUserId)) return notFound('Journey not found or access denied.'); return ok({ success: true }); } @@ -516,11 +516,11 @@ export class JourneyMcp { when: journeyAddonOn, access: { group: 'journey', mode: 'write' }, }) - updateJourneyPreferences( + async updateJourneyPreferences( { journeyId, hide_skeletons }: { journeyId: number; hide_skeletons?: boolean }, ctx: McpContext, ) { - if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); + if (await this.auth.isDemoUser(ctx.userId)) return demoDenied(); const result = this.journey.updateJourneyPreferences(journeyId, ctx.userId, { hide_skeletons }); if (!result) return notFound('Journey not found or access denied.'); // Return the service result ({ hide_skeletons }), matching the REST route. @@ -543,14 +543,14 @@ export class JourneyMcp { when: journeyAddonOn, access: { group: 'journey', mode: 'write' }, }) - addJourneyProviderPhotos( + async addJourneyProviderPhotos( { journeyId, entryId, provider, asset_ids, media_types, caption, passphrase }: { journeyId: number; entryId?: number; provider: z.infer; asset_ids: string[]; media_types?: Array<'image' | 'video'>; caption?: string; passphrase?: string; }, ctx: McpContext, ) { - if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); + if (await this.auth.isDemoUser(ctx.userId)) return demoDenied(); // The REST routes derive the journey from the entry and let each per-asset // add answer for itself, which in the array branch means a caller with no // access gets an empty 200. Checking up front instead turns that silence @@ -614,8 +614,8 @@ export class JourneyMcp { when: journeyAddonOn, access: { group: 'journey', mode: 'share' }, }) - createJourneyShareLink({ journeyId, ...permissions }: { journeyId: number; share_timeline?: boolean; share_gallery?: boolean; share_map?: boolean; newest_first?: boolean }, ctx: McpContext) { - if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); + async createJourneyShareLink({ journeyId, ...permissions }: { journeyId: number; share_timeline?: boolean; share_gallery?: boolean; share_map?: boolean; newest_first?: boolean }, ctx: McpContext) { + if (await this.auth.isDemoUser(ctx.userId)) return demoDenied(); const shareLink = this.share.createOrUpdateJourneyShareLink(journeyId, ctx.userId, permissions); if (!shareLink) return notFound('Journey not found or access denied.'); return ok({ shareLink }); @@ -629,8 +629,8 @@ export class JourneyMcp { when: journeyAddonOn, access: { group: 'journey', mode: 'share' }, }) - deleteJourneyShareLink({ journeyId }: { journeyId: number }, ctx: McpContext) { - if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); + async deleteJourneyShareLink({ journeyId }: { journeyId: number }, ctx: McpContext) { + if (await this.auth.isDemoUser(ctx.userId)) return demoDenied(); if (!this.share.deleteJourneyShareLink(journeyId, ctx.userId)) return notFound('Journey not found or access denied.'); return ok({ success: true }); } diff --git a/server/src/nest/mcp-transport/mcp-transport.service.ts b/server/src/nest/mcp-transport/mcp-transport.service.ts index 187d3365b..5fb5ab782 100644 --- a/server/src/nest/mcp-transport/mcp-transport.service.ts +++ b/server/src/nest/mcp-transport/mcp-transport.service.ts @@ -153,7 +153,7 @@ export class McpTransportService { } // Short-lived JWT (TREK web session used directly) — full access, no notice - const user = this.auth.verifyJwtToken(token); + const user = await this.auth.verifyJwtToken(token); if (!user) return null; return { user, scopes: null, clientId: null, isStaticToken: false }; } diff --git a/server/src/nest/notifications/notifications.mcp.ts b/server/src/nest/notifications/notifications.mcp.ts index d64d77ee2..d9ddac9c4 100644 --- a/server/src/nest/notifications/notifications.mcp.ts +++ b/server/src/nest/notifications/notifications.mcp.ts @@ -77,7 +77,7 @@ export class NotificationsMcp { access: { group: 'notifications', mode: 'write' }, }) async markNotificationRead({ notificationId }: { notificationId: number }, ctx: McpContext) { - if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); + if (await this.auth.isDemoUser(ctx.userId)) return demoDenied(); const success = await this.notifications.markRead(notificationId, ctx.userId); if (!success) return { content: [{ type: 'text' as const, text: 'Notification not found.' }], isError: true }; return ok({ success: true }); @@ -93,7 +93,7 @@ export class NotificationsMcp { access: { group: 'notifications', mode: 'write' }, }) async markNotificationUnread({ notificationId }: { notificationId: number }, ctx: McpContext) { - if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); + if (await this.auth.isDemoUser(ctx.userId)) return demoDenied(); const success = await this.notifications.markUnread(notificationId, ctx.userId); if (!success) return { content: [{ type: 'text' as const, text: 'Notification not found.' }], isError: true }; return ok({ success: true }); @@ -107,7 +107,7 @@ export class NotificationsMcp { access: { group: 'notifications', mode: 'write' }, }) async markAllNotificationsRead(_input: Record, ctx: McpContext) { - if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); + if (await this.auth.isDemoUser(ctx.userId)) return demoDenied(); const count = await this.notifications.markAllRead(ctx.userId); return ok({ success: true, count }); } diff --git a/server/src/nest/oidc/oidc.controller.ts b/server/src/nest/oidc/oidc.controller.ts index bd34c4fe8..5c58a3da2 100644 --- a/server/src/nest/oidc/oidc.controller.ts +++ b/server/src/nest/oidc/oidc.controller.ts @@ -41,7 +41,7 @@ export class OidcController { @Get('login') async login(@Req() req: Request, @Res() res: Response): Promise { - if (!this.oidc.oidcLoginEnabled()) { + if (!(await this.oidc.oidcLoginEnabled())) { res.status(403).json({ error: 'SSO login is disabled.' }); return; } @@ -107,7 +107,7 @@ export class OidcController { const boundState = (req.cookies as Record | undefined)?.[OIDC_STATE_COOKIE]; res.clearCookie(OIDC_STATE_COOKIE, cookieOptions(true, req)); - if (!this.oidc.oidcLoginEnabled()) return f('/login?oidc_error=sso_disabled'); + if (!(await this.oidc.oidcLoginEnabled())) return f('/login?oidc_error=sso_disabled'); if (oidcError) { console.error('[OIDC] Provider error:', oidcError); return f('/login?oidc_error=' + encodeURIComponent(oidcError)); @@ -252,7 +252,7 @@ export class AdminOidcController { @ManagedForbidden('an instance-supplied issuer could assert any address as verified') @Put() async update(@CurrentUser() user: User, @Body() body: AdminOidcUpdateDto, @Req() req: Request) { - const result = this.oidc.updateOidcSettings(body); + const result = await this.oidc.updateOidcSettings(body); if (result.error) { throw new HttpException({ error: result.error }, result.status || 400); } diff --git a/server/src/nest/oidc/oidc.service.ts b/server/src/nest/oidc/oidc.service.ts index ffb172056..e3e647f5f 100644 --- a/server/src/nest/oidc/oidc.service.ts +++ b/server/src/nest/oidc/oidc.service.ts @@ -250,7 +250,7 @@ export class OidcService implements OnModuleDestroy { clearInterval(this.codeSweeper); } - oidcLoginEnabled(): boolean { return this.auth.resolveAuthToggles().oidc_login; } + async oidcLoginEnabled(): Promise { return (await this.auth.resolveAuthToggles()).oidc_login; } getAppUrl() { return getAppUrl(); } @@ -705,7 +705,7 @@ export class OidcService implements OnModuleDestroy { } if (!isFirstUser && !validInvite) { - const { oidc_registration } = this.auth.resolveAuthToggles(); + const { oidc_registration } = await this.auth.resolveAuthToggles(); if (!oidc_registration) { return { error: 'registration_disabled' }; } @@ -792,15 +792,15 @@ export class OidcService implements OnModuleDestroy { }; } - updateOidcSettings(data: { + async updateOidcSettings(data: { issuer?: string; client_id?: string; client_secret?: string; display_name?: string; discovery_url?: string; - }): { error?: string; status?: number; success?: boolean } { + }): Promise<{ error?: string; status?: number; success?: boolean }> { // Lockout prevention: can't remove OIDC config when password login is disabled - if ((data.issuer === '' || data.client_id === '') && !this.auth.resolveAuthToggles().password_login) { + if ((data.issuer === '' || data.client_id === '') && !(await this.auth.resolveAuthToggles()).password_login) { return { error: 'Cannot remove SSO configuration while password login is disabled. Enable password login first.', status: 400, diff --git a/server/src/nest/packing/packing.mcp.ts b/server/src/nest/packing/packing.mcp.ts index 2f17ee32b..e5dc3f4c6 100644 --- a/server/src/nest/packing/packing.mcp.ts +++ b/server/src/nest/packing/packing.mcp.ts @@ -79,7 +79,7 @@ export class PackingMcp { { tripId, name, category, bag_id, quantity, weight_grams, checked, is_private, visibility, recipient_ids }: { tripId: number; name: string; category?: string; bag_id?: number | null; quantity?: number; weight_grams?: number | null; checked?: boolean | number; is_private?: boolean; visibility?: PackingVisibility; recipient_ids?: number[] }, ctx: McpContext, ) { - if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); + if (await this.auth.isDemoUser(ctx.userId)) return demoDenied(); if (!this.packing.verifyTripAccess(tripId, ctx.userId)) return noAccess(); if (!(await this.guards.hasTripPermission('packing_edit', tripId, ctx.userId))) return permissionDenied(); const item = this.packing.createItem(tripId, { @@ -116,7 +116,7 @@ export class PackingMcp { access: { group: 'packing', mode: 'write' }, }) async togglePackingItem({ tripId, itemId, checked }: { tripId: number; itemId: number; checked: boolean }, ctx: McpContext) { - if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); + if (await this.auth.isDemoUser(ctx.userId)) return demoDenied(); if (!this.packing.verifyTripAccess(tripId, ctx.userId)) return noAccess(); if (!(await this.guards.hasTripPermission('packing_edit', tripId, ctx.userId))) return permissionDenied(); const item = this.packing.updateItem(tripId, itemId, { checked: checked ? 1 : 0 }, ['checked'], undefined, ctx.userId); @@ -139,7 +139,7 @@ export class PackingMcp { access: { group: 'packing', mode: 'write' }, }) async deletePackingItem({ tripId, itemId }: { tripId: number; itemId: number }, ctx: McpContext) { - if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); + if (await this.auth.isDemoUser(ctx.userId)) return demoDenied(); if (!this.packing.verifyTripAccess(tripId, ctx.userId)) return noAccess(); if (!(await this.guards.hasTripPermission('packing_edit', tripId, ctx.userId))) return permissionDenied(); const deleted = this.packing.deleteItem(tripId, itemId, ctx.userId); @@ -174,7 +174,7 @@ export class PackingMcp { { tripId, itemId, name, category, bag_id, quantity, weight_grams, is_private }: { tripId: number; itemId: number; name?: string; category?: string; bag_id?: number | null; quantity?: number; weight_grams?: number | null; is_private?: boolean }, ctx: McpContext, ) { - if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); + if (await this.auth.isDemoUser(ctx.userId)) return demoDenied(); if (!this.packing.verifyTripAccess(tripId, ctx.userId)) return noAccess(); if (!(await this.guards.hasTripPermission('packing_edit', tripId, ctx.userId))) return permissionDenied(); const fields = { name, category, bag_id, quantity, weight_grams, is_private }; @@ -230,7 +230,7 @@ export class PackingMcp { { tripId, itemId, visibility, recipient_ids }: { tripId: number; itemId: number; visibility: PackingVisibility; recipient_ids?: number[] }, ctx: McpContext, ) { - if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); + if (await this.auth.isDemoUser(ctx.userId)) return demoDenied(); if (!this.packing.verifyTripAccess(tripId, ctx.userId)) return noAccess(); if (!(await this.guards.hasTripPermission('packing_edit', tripId, ctx.userId))) return permissionDenied(); const item = this.packing.setItemSharing(tripId, itemId, ctx.userId, visibility, recipient_ids ?? []); @@ -258,7 +258,7 @@ export class PackingMcp { access: { group: 'packing', mode: 'write' }, }) async reorderPackingItems({ tripId, orderedIds }: { tripId: number; orderedIds: number[] }, ctx: McpContext) { - if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); + if (await this.auth.isDemoUser(ctx.userId)) return demoDenied(); if (!this.packing.verifyTripAccess(tripId, ctx.userId)) return noAccess(); if (!(await this.guards.hasTripPermission('packing_edit', tripId, ctx.userId))) return permissionDenied(); this.packing.reorderItems(tripId, orderedIds); @@ -296,7 +296,7 @@ export class PackingMcp { access: { group: 'packing', mode: 'write' }, }) async createPackingBag({ tripId, name, color, weight_limit_grams }: { tripId: number; name: string; color?: string; weight_limit_grams?: number | null }, ctx: McpContext) { - if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); + if (await this.auth.isDemoUser(ctx.userId)) return demoDenied(); if (!this.packing.verifyTripAccess(tripId, ctx.userId)) return noAccess(); if (!(await this.guards.hasTripPermission('packing_edit', tripId, ctx.userId))) return permissionDenied(); // createBag returns a bare row; hydrate with the empty members array that @@ -325,7 +325,7 @@ export class PackingMcp { { tripId, bagId, name, color, weight_limit_grams, user_id }: { tripId: number; bagId: number; name?: string; color?: string; weight_limit_grams?: number | null; user_id?: number | null }, ctx: McpContext, ) { - if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); + if (await this.auth.isDemoUser(ctx.userId)) return demoDenied(); if (!this.packing.verifyTripAccess(tripId, ctx.userId)) return noAccess(); if (!(await this.guards.hasTripPermission('packing_edit', tripId, ctx.userId))) return permissionDenied(); const fields: { name?: string; color?: string; weight_limit_grams?: number | null; user_id?: number | null } = {}; @@ -356,7 +356,7 @@ export class PackingMcp { access: { group: 'packing', mode: 'write' }, }) async deletePackingBag({ tripId, bagId }: { tripId: number; bagId: number }, ctx: McpContext) { - if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); + if (await this.auth.isDemoUser(ctx.userId)) return demoDenied(); if (!this.packing.verifyTripAccess(tripId, ctx.userId)) return noAccess(); if (!(await this.guards.hasTripPermission('packing_edit', tripId, ctx.userId))) return permissionDenied(); this.packing.deleteBag(tripId, bagId); @@ -382,7 +382,7 @@ export class PackingMcp { access: { group: 'packing', mode: 'write' }, }) async setBagMembers({ tripId, bagId, userIds }: { tripId: number; bagId: number; userIds: number[] }, ctx: McpContext) { - if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); + if (await this.auth.isDemoUser(ctx.userId)) return demoDenied(); if (!this.packing.verifyTripAccess(tripId, ctx.userId)) return noAccess(); if (!(await this.guards.hasTripPermission('packing_edit', tripId, ctx.userId))) return permissionDenied(); const members = this.packing.setBagMembers(tripId, bagId, userIds); @@ -420,7 +420,7 @@ export class PackingMcp { access: { group: 'packing', mode: 'write' }, }) async setPackingCategoryAssignees({ tripId, categoryName, userIds }: { tripId: number; categoryName: string; userIds: number[] }, ctx: McpContext) { - if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); + if (await this.auth.isDemoUser(ctx.userId)) return demoDenied(); if (!this.packing.verifyTripAccess(tripId, ctx.userId)) return noAccess(); if (!(await this.guards.hasTripPermission('packing_edit', tripId, ctx.userId))) return permissionDenied(); const assignees = this.packing.updateCategoryAssignees(tripId, categoryName, userIds); @@ -440,7 +440,7 @@ export class PackingMcp { access: { group: 'packing', mode: 'write' }, }) async applyPackingTemplate({ tripId, templateId }: { tripId: number; templateId: number }, ctx: McpContext) { - if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); + if (await this.auth.isDemoUser(ctx.userId)) return demoDenied(); if (!this.packing.verifyTripAccess(tripId, ctx.userId)) return noAccess(); if (!(await this.guards.hasTripPermission('packing_edit', tripId, ctx.userId))) return permissionDenied(); const items = this.packing.applyTemplate(tripId, templateId); @@ -477,7 +477,7 @@ export class PackingMcp { access: { group: 'packing', mode: 'write' }, }) async savePackingTemplate({ tripId, templateName }: { tripId: number; templateName: string }, ctx: McpContext) { - if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); + if (await this.auth.isDemoUser(ctx.userId)) return demoDenied(); if (!this.packing.verifyTripAccess(tripId, ctx.userId)) return noAccess(); if (!(await this.guards.hasTripPermission('packing_edit', tripId, ctx.userId))) return permissionDenied(); // Templates are global; the REST route restricts saving to admins. Match it. @@ -498,7 +498,7 @@ export class PackingMcp { access: { group: 'packing', mode: 'write' }, }) async deletePackingTemplate({ templateId }: { templateId: number }, ctx: McpContext) { - if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); + if (await this.auth.isDemoUser(ctx.userId)) return demoDenied(); // Templates are global; the REST route restricts management to admins. Match it. if (!(await this.guards.isAdminUser(ctx.userId))) return adminRequired(); const result = this.packing.deletePackingTemplate(String(templateId)); @@ -528,7 +528,7 @@ export class PackingMcp { { tripId, items }: { tripId: number; items: { name: string; category?: string; quantity?: number; bag?: string; weight_grams?: number; checked?: boolean }[] }, ctx: McpContext, ) { - if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); + if (await this.auth.isDemoUser(ctx.userId)) return demoDenied(); if (!this.packing.verifyTripAccess(tripId, ctx.userId)) return noAccess(); if (!(await this.guards.hasTripPermission('packing_edit', tripId, ctx.userId))) return permissionDenied(); const created = this.packing.bulkImport(tripId, items, ctx.userId); diff --git a/server/src/nest/places/places.mcp.ts b/server/src/nest/places/places.mcp.ts index 9d17ffcaf..76e87164d 100644 --- a/server/src/nest/places/places.mcp.ts +++ b/server/src/nest/places/places.mcp.ts @@ -96,7 +96,7 @@ export class PlacesMcp { }, ctx: McpContext, ) { - if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); + if (await this.auth.isDemoUser(ctx.userId)) return demoDenied(); if (!this.db.canAccessTrip(tripId, ctx.userId)) return noAccess(); if (!(await this.guards.hasTripPermission('place_edit', tripId, ctx.userId))) return permissionDenied(); const place = this.places.create(String(tripId), { name, description, lat, lng, address, category_id, google_place_id, google_ftid, osm_id, amap_poi_id, notes, website, phone, image_url, price, currency, stop_type }); @@ -141,7 +141,7 @@ export class PlacesMcp { }, ctx: McpContext, ) { - if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); + if (await this.auth.isDemoUser(ctx.userId)) return demoDenied(); if (!this.db.canAccessTrip(tripId, ctx.userId)) return noAccess(); if (!(await this.guards.hasTripPermission('place_edit', tripId, ctx.userId))) return permissionDenied(); if (!this.assignments.dayExists(dayId, tripId)) return { content: [{ type: 'text' as const, text: 'Day not found.' }], isError: true }; @@ -204,7 +204,7 @@ export class PlacesMcp { }, ctx: McpContext, ) { - if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); + if (await this.auth.isDemoUser(ctx.userId)) return demoDenied(); if (!this.db.canAccessTrip(tripId, ctx.userId)) return noAccess(); if (!(await this.guards.hasTripPermission('place_edit', tripId, ctx.userId))) return permissionDenied(); const place = await this.places.update(String(tripId), String(placeId), { name, description, lat, lng, address, category_id, price, currency, place_time, end_time, duration_minutes, notes, website, phone, image_url, transport_mode, osm_id, google_place_id, google_ftid, amap_poi_id, stop_type, fill_percent }); @@ -228,7 +228,7 @@ export class PlacesMcp { { tripId, placeId, rating }: { tripId: number; placeId: number; rating?: number | null }, ctx: McpContext, ) { - if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); + if (await this.auth.isDemoUser(ctx.userId)) return demoDenied(); // Rating is a personal vote — any trip member may cast one, place_edit not required. if (!this.db.canAccessTrip(tripId, ctx.userId)) return noAccess(); const place = this.places.rate(String(tripId), String(placeId), ctx.userId, rating ?? null); @@ -248,7 +248,7 @@ export class PlacesMcp { access: { group: 'places', mode: 'write' }, }) async deletePlace({ tripId, placeId }: { tripId: number; placeId: number }, ctx: McpContext) { - if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); + if (await this.auth.isDemoUser(ctx.userId)) return demoDenied(); if (!this.db.canAccessTrip(tripId, ctx.userId)) return noAccess(); if (!(await this.guards.hasTripPermission('place_edit', tripId, ctx.userId))) return permissionDenied(); // Scope the id to the trip before the hook: onPlaceDeleted keys on the place @@ -342,7 +342,7 @@ export class PlacesMcp { { tripId, url, source, enrich }: { tripId: number; url: string; source: 'google-list' | 'naver-list'; enrich?: boolean }, ctx: McpContext, ) { - if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); + if (await this.auth.isDemoUser(ctx.userId)) return demoDenied(); if (!this.db.canAccessTrip(tripId, ctx.userId)) return noAccess(); if (!(await this.guards.hasTripPermission('place_edit', tripId, ctx.userId))) return permissionDenied(); @@ -373,7 +373,7 @@ export class PlacesMcp { access: { group: 'places', mode: 'write' }, }) async importGpx(input: RoadtripGpxImport, ctx: McpContext) { - if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); + if (await this.auth.isDemoUser(ctx.userId)) return demoDenied(); if (!this.db.canAccessTrip(input.tripId, ctx.userId)) return noAccess(); if (!(await this.guards.hasTripPermission('place_edit', input.tripId, ctx.userId))) return permissionDenied(); if (!input.importWaypoints && !input.importRoutes && !input.importTracks) return errorResult('No import types selected.'); @@ -424,7 +424,7 @@ export class PlacesMcp { access: { group: 'places', mode: 'write' }, }) async bulkDeletePlaces({ tripId, placeIds }: { tripId: number; placeIds: number[] }, ctx: McpContext) { - if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); + if (await this.auth.isDemoUser(ctx.userId)) return demoDenied(); if (!this.db.canAccessTrip(tripId, ctx.userId)) return noAccess(); if (!(await this.guards.hasTripPermission('place_edit', tripId, ctx.userId))) return permissionDenied(); @@ -481,7 +481,7 @@ export class PlacesMcp { }, ctx: McpContext, ) { - if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); + if (await this.auth.isDemoUser(ctx.userId)) return demoDenied(); if (!this.db.canAccessTrip(tripId, ctx.userId)) return noAccess(); if (!(await this.guards.hasTripPermission('place_edit', tripId, ctx.userId))) return permissionDenied(); diff --git a/server/src/nest/platform/platform.routes.ts b/server/src/nest/platform/platform.routes.ts index 1d30bf779..e9816ed92 100644 --- a/server/src/nest/platform/platform.routes.ts +++ b/server/src/nest/platform/platform.routes.ts @@ -102,7 +102,7 @@ async function servePhoto(storage: StorageService, req: Request, res: Response): } // JWT session path (with pv check). - const user = verifyJwtAndLoadUser(rawToken); + const user = await verifyJwtAndLoadUser(rawToken); if (user) return sendPhoto(); // Share-token path: require the token to cover the exact trip the diff --git a/server/src/nest/plugins/plugins-proxy.controller.ts b/server/src/nest/plugins/plugins-proxy.controller.ts index f75ae8cf8..92706c366 100644 --- a/server/src/nest/plugins/plugins-proxy.controller.ts +++ b/server/src/nest/plugins/plugins-proxy.controller.ts @@ -95,7 +95,7 @@ export class PluginsProxyController { let user: { id: number; username: string; role?: 'admin' | 'user' } | null = null; if (route.auth) { const token = extractToken(req); - const loaded = token ? verifyJwtAndLoadUser(token) : null; + const loaded = token ? await verifyJwtAndLoadUser(token) : null; if (!loaded) { res.status(401).json({ error: 'Access token required', code: 'AUTH_REQUIRED' }); return; diff --git a/server/src/nest/reservation-import/reservation-import.mcp.ts b/server/src/nest/reservation-import/reservation-import.mcp.ts index 17b4268a7..827d01109 100644 --- a/server/src/nest/reservation-import/reservation-import.mcp.ts +++ b/server/src/nest/reservation-import/reservation-import.mcp.ts @@ -69,7 +69,7 @@ export class ReservationImportMcp { { tripId, flightIds, connections }: { tripId: number; flightIds: string[]; connections?: string[][] }, ctx: McpContext, ) { - if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); + if (await this.auth.isDemoUser(ctx.userId)) return demoDenied(); if (!this.db.canAccessTrip(tripId, ctx.userId)) return noAccess(); if (!(await this.guards.hasTripPermission('reservation_edit', tripId, ctx.userId))) return permissionDenied(); diff --git a/server/src/nest/reservations/reservations.mcp.ts b/server/src/nest/reservations/reservations.mcp.ts index 49fe12390..f768e1b1e 100644 --- a/server/src/nest/reservations/reservations.mcp.ts +++ b/server/src/nest/reservations/reservations.mcp.ts @@ -361,7 +361,7 @@ export class ReservationsMcp { }, ctx: McpContext, ) { - if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); + if (await this.auth.isDemoUser(ctx.userId)) return demoDenied(); if (!this.reservations.verifyTripAccess(tripId, ctx.userId)) return noAccess(); if (!(await this.guards.hasTripPermission('reservation_edit', tripId, ctx.userId))) return permissionDenied(); @@ -437,7 +437,7 @@ export class ReservationsMcp { }, ctx: McpContext, ) { - if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); + if (await this.auth.isDemoUser(ctx.userId)) return demoDenied(); if (!this.reservations.verifyTripAccess(tripId, ctx.userId)) return noAccess(); if (!(await this.guards.hasTripPermission('reservation_edit', tripId, ctx.userId))) return permissionDenied(); const existing = this.reservations.getReservation(reservationId, tripId); @@ -468,7 +468,7 @@ export class ReservationsMcp { access: { group: 'reservations', mode: 'write' }, }) async deleteReservation({ tripId, reservationId }: { tripId: number; reservationId: number }, ctx: McpContext) { - if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); + if (await this.auth.isDemoUser(ctx.userId)) return demoDenied(); if (!this.reservations.verifyTripAccess(tripId, ctx.userId)) return noAccess(); if (!(await this.guards.hasTripPermission('reservation_edit', tripId, ctx.userId))) return permissionDenied(); const { deleted, accommodationDeleted } = await this.reservations.remove(reservationId, tripId); @@ -495,7 +495,7 @@ export class ReservationsMcp { { tripId, reservationId, user_ids }: { tripId: number; reservationId: number; user_ids: number[] }, ctx: McpContext, ) { - if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); + if (await this.auth.isDemoUser(ctx.userId)) return demoDenied(); if (!this.reservations.verifyTripAccess(tripId, ctx.userId)) return noAccess(); if (!(await this.guards.hasTripPermission('reservation_edit', tripId, ctx.userId))) return permissionDenied(); @@ -533,7 +533,7 @@ export class ReservationsMcp { { tripId, positions, dayId }: { tripId: number; positions: { id: number; day_plan_position: number }[]; dayId?: number }, ctx: McpContext, ) { - if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); + if (await this.auth.isDemoUser(ctx.userId)) return demoDenied(); if (!this.reservations.verifyTripAccess(tripId, ctx.userId)) return noAccess(); if (!(await this.guards.hasTripPermission('reservation_edit', tripId, ctx.userId))) return permissionDenied(); @@ -570,7 +570,7 @@ export class ReservationsMcp { }, ctx: McpContext, ) { - if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); + if (await this.auth.isDemoUser(ctx.userId)) return demoDenied(); if (!this.reservations.verifyTripAccess(tripId, ctx.userId)) return noAccess(); if (!(await this.guards.hasTripPermission('reservation_edit', tripId, ctx.userId))) return permissionDenied(); const current = this.reservations.getReservation(reservationId, tripId); @@ -694,7 +694,7 @@ export class ReservationsMcp { }, ctx: McpContext, ) { - if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); + if (await this.auth.isDemoUser(ctx.userId)) return demoDenied(); if (!this.reservations.verifyTripAccess(tripId, ctx.userId)) return noAccess(); if (!(await this.guards.hasTripPermission('reservation_edit', tripId, ctx.userId))) return permissionDenied(); @@ -802,7 +802,7 @@ export class ReservationsMcp { }, ctx: McpContext, ) { - if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); + if (await this.auth.isDemoUser(ctx.userId)) return demoDenied(); if (!this.reservations.verifyTripAccess(tripId, ctx.userId)) return noAccess(); if (!(await this.guards.hasTripPermission('reservation_edit', tripId, ctx.userId))) return permissionDenied(); @@ -898,7 +898,7 @@ export class ReservationsMcp { access: { group: 'reservations', mode: 'write' }, }) async deleteTransport({ tripId, reservationId }: { tripId: number; reservationId: number }, ctx: McpContext) { - if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); + if (await this.auth.isDemoUser(ctx.userId)) return demoDenied(); if (!this.reservations.verifyTripAccess(tripId, ctx.userId)) return noAccess(); if (!(await this.guards.hasTripPermission('reservation_edit', tripId, ctx.userId))) return permissionDenied(); const { deleted } = await this.reservations.remove(reservationId, tripId); diff --git a/server/src/nest/roadtrip/day-boundaries.mcp.ts b/server/src/nest/roadtrip/day-boundaries.mcp.ts index 0767d1ac5..192f391f5 100644 --- a/server/src/nest/roadtrip/day-boundaries.mcp.ts +++ b/server/src/nest/roadtrip/day-boundaries.mcp.ts @@ -42,7 +42,7 @@ export class DayBoundariesMcp { annotations: TOOL_ANNOTATIONS_WRITE, access: { group: 'trips', mode: 'write' }, when, }) async save({ tripId, dayNumber, boundary }: { tripId: number; dayNumber: number; boundary: RoadtripDayBoundary | null }, ctx: McpContext) { - if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); + if (await this.auth.isDemoUser(ctx.userId)) return demoDenied(); if (!this.db.canAccessTrip(tripId, ctx.userId)) return noAccess(); if (!(await this.guards.hasTripPermission('day_edit', tripId, ctx.userId))) return permissionDenied(); // A stop from another trip is refused by the service, with the reason. diff --git a/server/src/nest/roadtrip/google-route.mcp.ts b/server/src/nest/roadtrip/google-route.mcp.ts index c37e678fb..6e8ad700c 100644 --- a/server/src/nest/roadtrip/google-route.mcp.ts +++ b/server/src/nest/roadtrip/google-route.mcp.ts @@ -25,8 +25,8 @@ export class GoogleRouteMcp { // and create_and_assign_place are gated on. trips:write does not imply it, and // a client that withheld places:write was getting an itinerary written anyway. access: { group: 'places', mode: 'write' }, when: addonGate(ADDON_IDS.ROADTRIP) }) - import(input: GoogleRouteImport & { tripId: number }, ctx: McpContext) { - if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); + async import(input: GoogleRouteImport & { tripId: number }, ctx: McpContext) { + if (await this.auth.isDemoUser(ctx.userId)) return demoDenied(); return answeringRefusals(async () => ok(await this.routes.import(input.tripId, ctx.userId, input))); } } diff --git a/server/src/nest/roadtrip/roadtrip-preferences.mcp.ts b/server/src/nest/roadtrip/roadtrip-preferences.mcp.ts index 5fbd63480..7a537ade2 100644 --- a/server/src/nest/roadtrip/roadtrip-preferences.mcp.ts +++ b/server/src/nest/roadtrip/roadtrip-preferences.mcp.ts @@ -56,7 +56,7 @@ export class RoadtripPreferencesMcp { when, }) async update({ tripId, settings }: { tripId: number; settings: RoadtripPreferences }, ctx: McpContext) { - if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); + if (await this.auth.isDemoUser(ctx.userId)) return demoDenied(); if (!this.db.canAccessTrip(tripId, ctx.userId)) return noAccess(); if (!(await this.guards.hasTripPermission('day_edit', tripId, ctx.userId))) return permissionDenied(); // A day window that ends before it starts is refused by the service, with the reason. diff --git a/server/src/nest/roadtrip/roadtrip.mcp.ts b/server/src/nest/roadtrip/roadtrip.mcp.ts index 3028e4418..579a6bda2 100644 --- a/server/src/nest/roadtrip/roadtrip.mcp.ts +++ b/server/src/nest/roadtrip/roadtrip.mcp.ts @@ -74,7 +74,7 @@ export class RoadtripMcp { { tripId, dayId, after_order_index, lat, lng }: { tripId: number; dayId: number; after_order_index: number; lat: number; lng: number }, ctx: McpContext, ) { - if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); + if (await this.auth.isDemoUser(ctx.userId)) return demoDenied(); if (!this.db.canAccessTrip(tripId, ctx.userId)) return noAccess(); if (!(await this.guards.hasTripPermission('day_edit', tripId, ctx.userId))) return permissionDenied(); if (!this.roadtrip.dayExists(dayId, tripId)) return noAccess(); @@ -116,7 +116,7 @@ export class RoadtripMcp { }, ctx: McpContext, ) { - if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); + if (await this.auth.isDemoUser(ctx.userId)) return demoDenied(); if (!this.db.canAccessTrip(tripId, ctx.userId)) return noAccess(); if (!(await this.guards.hasTripPermission('day_edit', tripId, ctx.userId))) return permissionDenied(); if (!this.roadtrip.dayExists(dayId, tripId)) return noAccess(); @@ -154,7 +154,7 @@ export class RoadtripMcp { { tripId, dayId, vias, remove }: { tripId: number; dayId: number; vias: { id: number; after_order_index: number }[]; remove?: number[] }, ctx: McpContext, ) { - if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); + if (await this.auth.isDemoUser(ctx.userId)) return demoDenied(); if (!this.db.canAccessTrip(tripId, ctx.userId)) return noAccess(); if (!(await this.guards.hasTripPermission('day_edit', tripId, ctx.userId))) return permissionDenied(); if (!this.roadtrip.dayExists(dayId, tripId)) return noAccess(); @@ -176,7 +176,7 @@ export class RoadtripMcp { when: roadtripAddonOn, }) async removeVia({ tripId, dayId, viaId }: { tripId: number; dayId: number; viaId: number }, ctx: McpContext) { - if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); + if (await this.auth.isDemoUser(ctx.userId)) return demoDenied(); if (!this.db.canAccessTrip(tripId, ctx.userId)) return noAccess(); if (!(await this.guards.hasTripPermission('day_edit', tripId, ctx.userId))) return permissionDenied(); if (!this.roadtrip.dayExists(dayId, tripId)) return noAccess(); @@ -192,7 +192,7 @@ export class RoadtripMcp { annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT, access: { group: 'trips', mode: 'write' }, when: roadtripAddonOn, }) async updateVia(input: RoadtripViaUpdateRequest & { tripId: number; dayId: number; viaId: number }, ctx: McpContext) { - if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); + if (await this.auth.isDemoUser(ctx.userId)) return demoDenied(); if (!this.db.canAccessTrip(input.tripId, ctx.userId)) return noAccess(); if (!(await this.guards.hasTripPermission('day_edit', input.tripId, ctx.userId))) return permissionDenied(); if (!this.roadtrip.dayExists(input.dayId, input.tripId)) return noAccess(); diff --git a/server/src/nest/settings/settings.mcp.ts b/server/src/nest/settings/settings.mcp.ts index fda5c62d9..e2919677c 100644 --- a/server/src/nest/settings/settings.mcp.ts +++ b/server/src/nest/settings/settings.mcp.ts @@ -132,7 +132,7 @@ export class SettingsMcp { access: { group: 'settings', mode: 'write' }, }) async updateDisplaySettings({ settings }: { settings: Record }, ctx: McpContext) { - if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); + if (await this.auth.isDemoUser(ctx.userId)) return demoDenied(); const entries = Object.entries(settings); if (entries.length === 0) { diff --git a/server/src/nest/share/share.mcp.ts b/server/src/nest/share/share.mcp.ts index 3b4bee79f..4d5303442 100644 --- a/server/src/nest/share/share.mcp.ts +++ b/server/src/nest/share/share.mcp.ts @@ -66,7 +66,7 @@ export class ShareMcp { }, ctx: McpContext, ) { - if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); + if (await this.auth.isDemoUser(ctx.userId)) return demoDenied(); if (!this.share.verifyTripAccess(String(tripId), ctx.userId)) return noAccess(); if (!(await this.guards.hasTripPermission('share_manage', tripId, ctx.userId))) return permissionDenied(); // The zod .default()s above fill omitted flags, and ShareService applies @@ -87,7 +87,7 @@ export class ShareMcp { access: (ctx) => canShareTrips(ctx.scopes), }) async deleteShareLink({ tripId }: { tripId: number }, ctx: McpContext) { - if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); + if (await this.auth.isDemoUser(ctx.userId)) return demoDenied(); if (!this.share.verifyTripAccess(String(tripId), ctx.userId)) return noAccess(); if (!(await this.guards.hasTripPermission('share_manage', tripId, ctx.userId))) return permissionDenied(); this.share.remove(String(tripId)); diff --git a/server/src/nest/tags/tags.mcp.ts b/server/src/nest/tags/tags.mcp.ts index fbc12e23b..1a7793849 100644 --- a/server/src/nest/tags/tags.mcp.ts +++ b/server/src/nest/tags/tags.mcp.ts @@ -41,7 +41,7 @@ export class TagsMcp { access: { group: 'places', mode: 'write' }, }) async createTag({ name, color }: { name: string; color?: string }, ctx: McpContext) { - if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); + if (await this.auth.isDemoUser(ctx.userId)) return demoDenied(); const tag = await this.tags.create(ctx.userId, name, color); return ok({ tag }); } @@ -58,7 +58,7 @@ export class TagsMcp { access: { group: 'places', mode: 'write' }, }) async updateTag({ tagId, name, color }: { tagId: number; name?: string; color?: string }, ctx: McpContext) { - if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); + if (await this.auth.isDemoUser(ctx.userId)) return demoDenied(); if (!(await this.tags.getByIdAndUser(tagId, ctx.userId))) return errorResult('Tag not found.'); const tag = await this.tags.update(tagId, name, color); if (!(await tag)) return errorResult('Tag not found.'); @@ -75,7 +75,7 @@ export class TagsMcp { access: { group: 'places', mode: 'write' }, }) async deleteTag({ tagId }: { tagId: number }, ctx: McpContext) { - if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); + if (await this.auth.isDemoUser(ctx.userId)) return demoDenied(); if (!(await this.tags.getByIdAndUser(tagId, ctx.userId))) return errorResult('Tag not found.'); await this.tags.remove(tagId); return ok({ success: true }); diff --git a/server/src/nest/todo/todo.mcp.ts b/server/src/nest/todo/todo.mcp.ts index 3711e5773..beb7341fa 100644 --- a/server/src/nest/todo/todo.mcp.ts +++ b/server/src/nest/todo/todo.mcp.ts @@ -77,7 +77,7 @@ export class TodoMcp { }, ctx: McpContext, ) { - if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); + if (await this.auth.isDemoUser(ctx.userId)) return demoDenied(); if (!(await this.todos.verifyTripAccess(tripId, ctx.userId))) return noAccess(); if (!(await this.guards.hasTripPermission('packing_edit', tripId, ctx.userId))) return permissionDenied(); const item = await this.todos.createItem(tripId, { name, category, due_date, description, assigned_user_id, priority }); @@ -108,7 +108,7 @@ export class TodoMcp { }, ctx: McpContext, ) { - if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); + if (await this.auth.isDemoUser(ctx.userId)) return demoDenied(); if (!(await this.todos.verifyTripAccess(tripId, ctx.userId))) return noAccess(); if (!(await this.guards.hasTripPermission('packing_edit', tripId, ctx.userId))) return permissionDenied(); // Build bodyKeys to signal which nullable fields were explicitly provided @@ -136,7 +136,7 @@ export class TodoMcp { access: { group: 'todos', mode: 'write' }, }) async toggleTodo({ tripId, itemId, checked }: { tripId: number; itemId: number; checked: boolean }, ctx: McpContext) { - if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); + if (await this.auth.isDemoUser(ctx.userId)) return demoDenied(); if (!(await this.todos.verifyTripAccess(tripId, ctx.userId))) return noAccess(); if (!(await this.guards.hasTripPermission('packing_edit', tripId, ctx.userId))) return permissionDenied(); const item = await this.todos.updateItem(tripId, itemId, { checked: checked ? 1 : 0 }, []); @@ -157,7 +157,7 @@ export class TodoMcp { access: { group: 'todos', mode: 'write' }, }) async deleteTodo({ tripId, itemId }: { tripId: number; itemId: number }, ctx: McpContext) { - if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); + if (await this.auth.isDemoUser(ctx.userId)) return demoDenied(); if (!(await this.todos.verifyTripAccess(tripId, ctx.userId))) return noAccess(); if (!(await this.guards.hasTripPermission('packing_edit', tripId, ctx.userId))) return permissionDenied(); const deleted = await this.todos.deleteItem(tripId, itemId); @@ -178,7 +178,7 @@ export class TodoMcp { access: { group: 'todos', mode: 'write' }, }) async reorderTodos({ tripId, orderedIds }: { tripId: number; orderedIds: number[] }, ctx: McpContext) { - if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); + if (await this.auth.isDemoUser(ctx.userId)) return demoDenied(); if (!(await this.todos.verifyTripAccess(tripId, ctx.userId))) return noAccess(); if (!(await this.guards.hasTripPermission('packing_edit', tripId, ctx.userId))) return permissionDenied(); await this.todos.reorderItems(tripId, orderedIds); @@ -214,7 +214,7 @@ export class TodoMcp { access: { group: 'todos', mode: 'write' }, }) async setTodoCategoryAssignees({ tripId, categoryName, userIds }: { tripId: number; categoryName: string; userIds: number[] }, ctx: McpContext) { - if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); + if (await this.auth.isDemoUser(ctx.userId)) return demoDenied(); if (!(await this.todos.verifyTripAccess(tripId, ctx.userId))) return noAccess(); if (!(await this.guards.hasTripPermission('packing_edit', tripId, ctx.userId))) return permissionDenied(); const assignees = await this.todos.updateCategoryAssignees(tripId, categoryName, userIds); diff --git a/server/src/nest/transit/transit.mcp.ts b/server/src/nest/transit/transit.mcp.ts index 4ba265176..5944c04a5 100644 --- a/server/src/nest/transit/transit.mcp.ts +++ b/server/src/nest/transit/transit.mcp.ts @@ -183,7 +183,7 @@ export class TransitMcp { }, ctx: McpContext, ) { - if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); + if (await this.auth.isDemoUser(ctx.userId)) return demoDenied(); if (!this.db.canAccessTrip(tripId, ctx.userId)) return noAccess(); if (!(await this.guards.hasTripPermission('reservation_edit', tripId, ctx.userId))) return permissionDenied(); const day = await this.days.getDay(dayId, tripId); diff --git a/server/src/nest/trip-members/trip-members.service.ts b/server/src/nest/trip-members/trip-members.service.ts index aa24d4763..17d3555a9 100644 --- a/server/src/nest/trip-members/trip-members.service.ts +++ b/server/src/nest/trip-members/trip-members.service.ts @@ -256,7 +256,7 @@ export class TripMembersService { // A guest is still a user id a plugin may hold data for, so erase that too — the // host-side per-user tables + a durable own-db erasure per granted plugin — exactly // like a full account deletion (otherwise a deleted guest's plugin data lingers). - this.userCleanup.erasePluginUserData(guestUserId); + await this.userCleanup.erasePluginUserData(guestUserId); // Quirk fix on top of the 1:1 move: the budget re-split and the user delete // run in one transaction, so a failure mid-flow can't leave the expense // divisors re-derived for a guest that still exists (or vice versa). The diff --git a/server/src/nest/trip-read-model/trip-read-model.service.ts b/server/src/nest/trip-read-model/trip-read-model.service.ts index 6a1651875..d487b92d0 100644 --- a/server/src/nest/trip-read-model/trip-read-model.service.ts +++ b/server/src/nest/trip-read-model/trip-read-model.service.ts @@ -110,7 +110,7 @@ export class TripReadModelService { todoItems: await this.todo.listItems(tripId), budgetItems: await this.budget.listBudgetItems(tripId), reservations: this.reservations.list(tripId), - files: this.files.listFiles(tripId, false), + files: await this.files.listFiles(tripId, false), accommodations: this.accommodations.list(tripId), members: [owner, ...(members || [])].filter(Boolean), }; diff --git a/server/src/nest/trips/trips.mcp.ts b/server/src/nest/trips/trips.mcp.ts index 8d7608d3e..fe25362b6 100644 --- a/server/src/nest/trips/trips.mcp.ts +++ b/server/src/nest/trips/trips.mcp.ts @@ -104,7 +104,7 @@ export class TripsMcp { }, ctx: McpContext, ) { - if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); + if (await this.auth.isDemoUser(ctx.userId)) return demoDenied(); if (start_date) { const d = new Date(start_date + 'T00:00:00Z'); if (Number.isNaN(d.getTime()) || d.toISOString().slice(0, 10) !== start_date) @@ -159,7 +159,7 @@ export class TripsMcp { }, ctx: McpContext, ) { - if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); + if (await this.auth.isDemoUser(ctx.userId)) return demoDenied(); if (!this.trips.canAccessTrip(tripId, ctx.userId)) return noAccess(); if (!(await this.guards.hasTripPermission('trip_edit', tripId, ctx.userId))) return permissionDenied(); if (clear_dates && (start_date || end_date)) @@ -223,7 +223,7 @@ export class TripsMcp { access: (ctx) => canDeleteTrips(ctx.scopes), }) async deleteTrip({ tripId }: { tripId: number }, ctx: McpContext) { - if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); + if (await this.auth.isDemoUser(ctx.userId)) return demoDenied(); if (!this.trips.isOwner(tripId, ctx.userId)) return noAccess(); this.trips.remove(tripId, ctx.userId, 'user'); return ok({ success: true, tripId }); @@ -350,7 +350,7 @@ export class TripsMcp { access: { group: 'trips', mode: 'write' }, }) async addTripMember({ tripId, identifier }: { tripId: number; identifier: string }, ctx: McpContext) { - if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); + if (await this.auth.isDemoUser(ctx.userId)) return demoDenied(); if (!this.trips.canAccessTrip(tripId, ctx.userId)) return noAccess(); const ownerRow = this.trips.getOwner(tripId); if (!ownerRow) return noAccess(); @@ -379,7 +379,7 @@ export class TripsMcp { access: { group: 'trips', mode: 'write' }, }) async removeTripMember({ tripId, memberId }: { tripId: number; memberId: number }, ctx: McpContext) { - if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); + if (await this.auth.isDemoUser(ctx.userId)) return demoDenied(); if (!this.trips.canAccessTrip(tripId, ctx.userId)) return noAccess(); // Giving up your own access is not member management, so it carries no permission // requirement: the same self-removal bypass DELETE /api/trips/:id/members/:userId has. @@ -400,7 +400,7 @@ export class TripsMcp { access: { group: 'trips', mode: 'write' }, }) async leaveTrip({ tripId }: { tripId: number }, ctx: McpContext) { - if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); + if (await this.auth.isDemoUser(ctx.userId)) return demoDenied(); if (!this.trips.canAccessTrip(tripId, ctx.userId)) return noAccess(); const ownerRow = this.trips.getOwner(tripId); if (!ownerRow) return noAccess(); @@ -434,7 +434,7 @@ export class TripsMcp { access: { group: 'trips', mode: 'write' }, }) async createTripGuest({ tripId, name }: { tripId: number; name: string }, ctx: McpContext) { - if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); + if (await this.auth.isDemoUser(ctx.userId)) return demoDenied(); if (!this.trips.canAccessTrip(tripId, ctx.userId)) return noAccess(); const ownerRow = this.trips.getOwner(tripId); if (!ownerRow || ownerRow.user_id !== ctx.userId) @@ -462,7 +462,7 @@ export class TripsMcp { access: { group: 'trips', mode: 'write' }, }) async renameTripGuest({ tripId, guestId, name }: { tripId: number; guestId: number; name: string }, ctx: McpContext) { - if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); + if (await this.auth.isDemoUser(ctx.userId)) return demoDenied(); if (!this.trips.canAccessTrip(tripId, ctx.userId)) return noAccess(); const ownerRow = this.trips.getOwner(tripId); if (!ownerRow || ownerRow.user_id !== ctx.userId) @@ -488,7 +488,7 @@ export class TripsMcp { access: { group: 'trips', mode: 'write' }, }) async deleteTripGuest({ tripId, guestId }: { tripId: number; guestId: number }, ctx: McpContext) { - if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); + if (await this.auth.isDemoUser(ctx.userId)) return demoDenied(); if (!this.trips.canAccessTrip(tripId, ctx.userId)) return noAccess(); const ownerRow = this.trips.getOwner(tripId); if (!ownerRow || ownerRow.user_id !== ctx.userId) @@ -510,7 +510,7 @@ export class TripsMcp { access: { group: 'trips', mode: 'write' }, }) async copyTrip({ tripId, title }: { tripId: number; title?: string }, ctx: McpContext) { - if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); + if (await this.auth.isDemoUser(ctx.userId)) return demoDenied(); if (!this.trips.canAccessTrip(tripId, ctx.userId)) return noAccess(); try { const newTripId = this.trips.copy(tripId, ctx.userId, title); diff --git a/server/src/nest/vacay/vacay.mcp.ts b/server/src/nest/vacay/vacay.mcp.ts index 47f6df03e..5904d286c 100644 --- a/server/src/nest/vacay/vacay.mcp.ts +++ b/server/src/nest/vacay/vacay.mcp.ts @@ -127,7 +127,7 @@ export class VacayMcp { }: VacayUpdatePlanRequest, ctx: McpContext, ) { - if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); + if (await this.auth.isDemoUser(ctx.userId)) return demoDenied(); const planId = this.vacay.getActivePlanId(ctx.userId); // updatePlan already returns the fully-hydrated { plan }; surface it so the // AI consumer sees the updated plan, matching get_vacay_plan. @@ -149,7 +149,7 @@ export class VacayMcp { access: { group: 'vacay', mode: 'write' }, }) async setVacayColor({ color }: { color: string }, ctx: McpContext) { - if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); + if (await this.auth.isDemoUser(ctx.userId)) return demoDenied(); const planId = this.vacay.getActivePlanId(ctx.userId); this.vacay.setUserColor(ctx.userId, planId, color, undefined); // Echo the persisted color (mirrors the service default) so the AI consumer sees what was set. @@ -181,9 +181,9 @@ export class VacayMcp { access: { group: 'vacay', mode: 'write' }, }) async sendVacayInvite({ targetUserId }: { targetUserId: number }, ctx: McpContext) { - if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); + if (await this.auth.isDemoUser(ctx.userId)) return demoDenied(); const planId = this.vacay.getActivePlanId(ctx.userId); - const me = this.auth.getCurrentUser(ctx.userId); + const me = await this.auth.getCurrentUser(ctx.userId); if (!me) return errorResult('User not found.'); const result = this.vacay.sendInvite(planId, ctx.userId, me.username, me.email, targetUserId); if (result.error) return errorResult(result.error); @@ -201,7 +201,7 @@ export class VacayMcp { access: { group: 'vacay', mode: 'write' }, }) async acceptVacayInvite({ planId }: { planId: number }, ctx: McpContext) { - if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); + if (await this.auth.isDemoUser(ctx.userId)) return demoDenied(); const result = this.vacay.acceptInvite(ctx.userId, planId, undefined); if (result.error) return errorResult(result.error); return ok({ success: true }); @@ -218,7 +218,7 @@ export class VacayMcp { access: { group: 'vacay', mode: 'write' }, }) async declineVacayInvite({ planId }: { planId: number }, ctx: McpContext) { - if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); + if (await this.auth.isDemoUser(ctx.userId)) return demoDenied(); this.vacay.declineInvite(ctx.userId, planId, undefined); return ok({ success: true }); } @@ -234,7 +234,7 @@ export class VacayMcp { access: { group: 'vacay', mode: 'write' }, }) async cancelVacayInvite({ targetUserId }: { targetUserId: number }, ctx: McpContext) { - if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); + if (await this.auth.isDemoUser(ctx.userId)) return demoDenied(); const planId = this.vacay.getActivePlanId(ctx.userId); this.vacay.cancelInvite(planId, targetUserId); return ok({ success: true }); @@ -249,7 +249,7 @@ export class VacayMcp { access: { group: 'vacay', mode: 'write' }, }) async dissolveVacayPlan(_args: Record, ctx: McpContext) { - if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); + if (await this.auth.isDemoUser(ctx.userId)) return demoDenied(); this.vacay.dissolvePlan(ctx.userId, undefined); return ok({ success: true }); } @@ -279,7 +279,7 @@ export class VacayMcp { access: { group: 'vacay', mode: 'write' }, }) async addVacayYear({ year }: { year: number }, ctx: McpContext) { - if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); + if (await this.auth.isDemoUser(ctx.userId)) return demoDenied(); const planId = this.vacay.getActivePlanId(ctx.userId); const years = this.vacay.addYear(planId, year, undefined); return ok({ years }); @@ -296,7 +296,7 @@ export class VacayMcp { access: { group: 'vacay', mode: 'write' }, }) async deleteVacayYear({ year }: { year: number }, ctx: McpContext) { - if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); + if (await this.auth.isDemoUser(ctx.userId)) return demoDenied(); const planId = this.vacay.getActivePlanId(ctx.userId); const years = this.vacay.deleteYear(planId, year, undefined); return ok({ years }); @@ -335,7 +335,7 @@ export class VacayMcp { { date, fraction, kind, targetUserId }: { date: string; fraction?: 0.5 | 1; kind?: 'vacation' | 'comp'; targetUserId?: number }, ctx: McpContext, ) { - if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); + if (await this.auth.isDemoUser(ctx.userId)) return demoDenied(); const planId = this.vacay.getActivePlanId(ctx.userId); let userId = ctx.userId; if (targetUserId !== undefined && targetUserId !== ctx.userId) { @@ -363,7 +363,7 @@ export class VacayMcp { access: { group: 'vacay', mode: 'write' }, }) async toggleCompanyHoliday({ date, note }: { date: string; note?: string }, ctx: McpContext) { - if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); + if (await this.auth.isDemoUser(ctx.userId)) return demoDenied(); const planId = this.vacay.getActivePlanId(ctx.userId); const result = this.vacay.toggleCompanyHoliday(planId, date, note, undefined); return ok(result); @@ -397,7 +397,7 @@ export class VacayMcp { access: { group: 'vacay', mode: 'write' }, }) async updateVacayStats({ year, vacationDays }: { year: number; vacationDays: number }, ctx: McpContext) { - if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); + if (await this.auth.isDemoUser(ctx.userId)) return demoDenied(); const planId = this.vacay.getActivePlanId(ctx.userId); this.vacay.updateStats(ctx.userId, planId, year, vacationDays, undefined); return ok({ success: true }); @@ -421,7 +421,7 @@ export class VacayMcp { { region, type, label, color, sortOrder }: { region: string; type?: 'public_holiday' | 'school_holiday'; label?: string | null; color?: string; sortOrder?: number }, ctx: McpContext, ) { - if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); + if (await this.auth.isDemoUser(ctx.userId)) return demoDenied(); const planId = this.vacay.getActivePlanId(ctx.userId); // An omitted type stays undefined so the service default (and the default // colour that goes with it) applies, exactly as on the REST route. @@ -445,7 +445,7 @@ export class VacayMcp { { calendarId, label, color }: { calendarId: number; label?: string | null; color?: string }, ctx: McpContext, ) { - if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); + if (await this.auth.isDemoUser(ctx.userId)) return demoDenied(); const planId = this.vacay.getActivePlanId(ctx.userId); const cal = this.vacay.updateHolidayCalendar(calendarId, planId, { label, color }, undefined); if (!cal) return errorResult('Holiday calendar not found.'); @@ -463,7 +463,7 @@ export class VacayMcp { access: { group: 'vacay', mode: 'write' }, }) async deleteHolidayCalendar({ calendarId }: { calendarId: number }, ctx: McpContext) { - if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); + if (await this.auth.isDemoUser(ctx.userId)) return demoDenied(); const planId = this.vacay.getActivePlanId(ctx.userId); this.vacay.deleteHolidayCalendar(calendarId, planId, undefined); return ok({ success: true }); @@ -573,8 +573,8 @@ export class VacayMcp { access: { group: 'vacay', mode: 'write' }, }) async shareVacayCalendar({ targetUserId }: { targetUserId: number }, ctx: McpContext) { - if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); - const me = this.auth.getCurrentUser(ctx.userId); + if (await this.auth.isDemoUser(ctx.userId)) return demoDenied(); + const me = await this.auth.getCurrentUser(ctx.userId); if (!me) return errorResult('User not found.'); const result = this.vacay.shareCalendar(ctx.userId, me.email, targetUserId); if (result.error) return errorResult(result.error); @@ -592,7 +592,7 @@ export class VacayMcp { access: { group: 'vacay', mode: 'write' }, }) async unshareVacayCalendar({ shareId }: { shareId: number }, ctx: McpContext) { - if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); + if (await this.auth.isDemoUser(ctx.userId)) return demoDenied(); if (!this.vacay.removeShare(shareId, ctx.userId, undefined)) { return errorResult('Share not found.'); } diff --git a/server/tests/unit/nest/admin.controller.test.ts b/server/tests/unit/nest/admin.controller.test.ts index e11cc84d6..eb31c2252 100644 --- a/server/tests/unit/nest/admin.controller.test.ts +++ b/server/tests/unit/nest/admin.controller.test.ts @@ -188,7 +188,7 @@ describe('AdminController read-only getters', () => { expect(adminCtl(svc({ getPermissions: vi.fn().mockReturnValue({ a: 1 }) } as Partial)).permissions()).toEqual({ a: 1 }); expect(adminCtl(svc({ getAuditLog: vi.fn().mockReturnValue({ entries: [] }) } as Partial)).auditLog({})).toEqual({ entries: [] }); await expect(adminCtl(svc({ checkVersion: vi.fn().mockResolvedValue({ current: '1' }) } as Partial)).versionCheck()).resolves.toEqual({ current: '1' }); - expect(adminCtl(svc(), undefined, undefined, {}, { listInvites: vi.fn().mockReturnValue([{ id: 1 }]) }).listInvites()).toEqual({ invites: [{ id: 1 }] }); + expect(await adminCtl(svc(), undefined, undefined, {}, { listInvites: vi.fn().mockReturnValue([{ id: 1 }]) }).listInvites()).toEqual({ invites: [{ id: 1 }] }); expect(adminCtl(svc({ listAddons: vi.fn().mockReturnValue([{ id: 'mcp' }]) } as Partial)).listAddons()).toEqual({ addons: [{ id: 'mcp' }] }); expect(adminCtl(svc(), undefined, undefined, { listAllMcpTokens: vi.fn().mockReturnValue([{ id: 1 }]) }).listMcpTokens()).toEqual({ tokens: [{ id: 1 }] }); expect(await adminCtl(svc(), undefined, undefined, {}, {}, { listAllOAuthSessions: vi.fn().mockResolvedValue([{ id: 1 }]) }).listOAuthSessions()).toEqual({ sessions: [{ id: 1 }] }); diff --git a/server/tests/unit/nest/admin.service.test.ts b/server/tests/unit/nest/admin.service.test.ts index 4f0e2d672..74d83819f 100644 --- a/server/tests/unit/nest/admin.service.test.ts +++ b/server/tests/unit/nest/admin.service.test.ts @@ -106,7 +106,7 @@ beforeAll(async () => { svc = new AdminService( dbs, new AddonsService(dbs), - new PasskeyService(dbs, auth, webauthn), + new PasskeyService(dbs, auth, webauthn, await createTestUnitOfWork(dbs.connection)), auth, permissions, await makeNotificationsService(dbs, realtime), diff --git a/server/tests/unit/nest/admin.version-notification.test.ts b/server/tests/unit/nest/admin.version-notification.test.ts index 857853602..7a6d22033 100644 --- a/server/tests/unit/nest/admin.version-notification.test.ts +++ b/server/tests/unit/nest/admin.version-notification.test.ts @@ -84,7 +84,7 @@ beforeAll(async () => { svc = new AdminService( dbs, new AddonsService(dbs), - new PasskeyService(dbs, auth, webauthn), + new PasskeyService(dbs, auth, webauthn, await createTestUnitOfWork(dbs.connection)), auth, permissions, await makeNotificationsService(dbs, realtime), diff --git a/server/tests/unit/nest/auth-guard.test.ts b/server/tests/unit/nest/auth-guard.test.ts index 3d2ad8905..b53ce60d7 100644 --- a/server/tests/unit/nest/auth-guard.test.ts +++ b/server/tests/unit/nest/auth-guard.test.ts @@ -66,28 +66,28 @@ beforeEach(() => vi.clearAllMocks()); describe('JwtAuthGuard', () => { const guard = new JwtAuthGuard(); - it('rejects with the legacy 401 { error, code } when no token is present', () => { + it('rejects with the legacy 401 { error, code } when no token is present', async () => { vi.mocked(extractToken).mockReturnValue(null); - expect(thrown(() => guard.canActivate(context({ headers: {}, cookies: {} })))).toEqual({ + expect(await thrownAsync(() => guard.canActivate(context({ headers: {}, cookies: {} })))).toEqual({ status: 401, body: { error: 'Access token required', code: 'AUTH_REQUIRED' }, }); }); - it('rejects an invalid/expired token (verify returns null)', () => { + it('rejects an invalid/expired token (verify returns null)', async () => { vi.mocked(extractToken).mockReturnValue('tok'); - vi.mocked(verifyJwtAndLoadUser).mockReturnValue(null); - expect(thrown(() => guard.canActivate(context({ headers: {} })))).toEqual({ + vi.mocked(verifyJwtAndLoadUser).mockResolvedValue(null); + expect(await thrownAsync(() => guard.canActivate(context({ headers: {} })))).toEqual({ status: 401, body: { error: 'Invalid or expired token', code: 'AUTH_REQUIRED' }, }); }); - it('attaches the loaded user and allows a valid token through', () => { + it('attaches the loaded user and allows a valid token through', async () => { const req: Record = { headers: {} }; vi.mocked(extractToken).mockReturnValue('tok'); - vi.mocked(verifyJwtAndLoadUser).mockReturnValue(user); - expect(guard.canActivate(context(req))).toBe(true); + vi.mocked(verifyJwtAndLoadUser).mockResolvedValue(user); + expect(await guard.canActivate(context(req))).toBe(true); expect(req.user).toBe(user); }); }); @@ -95,30 +95,30 @@ describe('JwtAuthGuard', () => { describe('CookieAuthGuard', () => { const guard = new CookieAuthGuard(); - it('401s when the trek_session cookie is missing', () => { - expect(thrown(() => guard.canActivate(context({ cookies: {} })))).toEqual({ + it('401s when the trek_session cookie is missing', async () => { + expect(await thrownAsync(() => guard.canActivate(context({ cookies: {} })))).toEqual({ status: 401, body: { error: 'Cookie session required for this endpoint', code: 'COOKIE_AUTH_REQUIRED' }, }); // and when there is no cookies object at all - expect(thrown(() => guard.canActivate(context({})))).toEqual({ + expect(await thrownAsync(() => guard.canActivate(context({})))).toEqual({ status: 401, body: { error: 'Cookie session required for this endpoint', code: 'COOKIE_AUTH_REQUIRED' }, }); }); - it('401s when the cookie token fails verification', () => { - vi.mocked(verifyJwtAndLoadUser).mockReturnValue(null); - expect(thrown(() => guard.canActivate(context({ cookies: { trek_session: 'tok' } })))).toEqual({ + it('401s when the cookie token fails verification', async () => { + vi.mocked(verifyJwtAndLoadUser).mockResolvedValue(null); + expect(await thrownAsync(() => guard.canActivate(context({ cookies: { trek_session: 'tok' } })))).toEqual({ status: 401, body: { error: 'Invalid or expired session', code: 'AUTH_REQUIRED' }, }); }); - it('attaches the user and allows a valid cookie session through', () => { + it('attaches the user and allows a valid cookie session through', async () => { const req: Record = { cookies: { trek_session: 'tok' } }; - vi.mocked(verifyJwtAndLoadUser).mockReturnValue(user); - expect(guard.canActivate(context(req))).toBe(true); + vi.mocked(verifyJwtAndLoadUser).mockResolvedValue(user); + expect(await guard.canActivate(context(req))).toBe(true); expect(req.user).toBe(user); }); }); @@ -126,27 +126,27 @@ describe('CookieAuthGuard', () => { describe('OptionalJwtGuard', () => { const guard = new OptionalJwtGuard(); - it('always allows; sets req.user to null when no token', () => { + it('always allows; sets req.user to null when no token', async () => { const req: Record = { headers: {} }; vi.mocked(extractToken).mockReturnValue(null); - expect(guard.canActivate(context(req))).toBe(true); + expect(await guard.canActivate(context(req))).toBe(true); expect(req.user).toBeNull(); expect(verifyJwtAndLoadUser).not.toHaveBeenCalled(); }); - it('sets req.user to null when a token verifies to nothing', () => { + it('sets req.user to null when a token verifies to nothing', async () => { const req: Record = { headers: {} }; vi.mocked(extractToken).mockReturnValue('tok'); - vi.mocked(verifyJwtAndLoadUser).mockReturnValue(null); - expect(guard.canActivate(context(req))).toBe(true); + vi.mocked(verifyJwtAndLoadUser).mockResolvedValue(null); + expect(await guard.canActivate(context(req))).toBe(true); expect(req.user).toBeNull(); }); - it('populates req.user from a valid token', () => { + it('populates req.user from a valid token', async () => { const req: Record = { headers: {} }; vi.mocked(extractToken).mockReturnValue('tok'); - vi.mocked(verifyJwtAndLoadUser).mockReturnValue(user); - expect(guard.canActivate(context(req))).toBe(true); + vi.mocked(verifyJwtAndLoadUser).mockResolvedValue(user); + expect(await guard.canActivate(context(req))).toBe(true); expect(req.user).toBe(user); }); }); @@ -170,14 +170,14 @@ describe('PasskeyEnabledGuard', () => { const resolveAuthToggles = vi.fn(); const guard = new PasskeyEnabledGuard({ resolveAuthToggles } as unknown as AuthService); - it('404s when passkey_login is off', () => { - resolveAuthToggles.mockReturnValue({ passkey_login: false }); - expect(thrown(() => guard.canActivate())).toEqual({ status: 404, body: { error: 'Passkey login is not enabled' } }); + it('404s when passkey_login is off', async () => { + resolveAuthToggles.mockResolvedValue({ passkey_login: false }); + expect(await thrownAsync(() => guard.canActivate())).toEqual({ status: 404, body: { error: 'Passkey login is not enabled' } }); }); - it('allows when passkey_login is on', () => { - resolveAuthToggles.mockReturnValue({ passkey_login: true }); - expect(guard.canActivate()).toBe(true); + it('allows when passkey_login is on', async () => { + resolveAuthToggles.mockResolvedValue({ passkey_login: true }); + expect(await guard.canActivate()).toBe(true); }); }); @@ -241,17 +241,17 @@ describe('PasskeyController', () => { }, 10000); it('credentials: list, rename (error + success), delete (error + success)', async () => { - passkey.listPasskeys.mockReturnValue([{ id: 'a' }]); - expect(pc(rl()).list(user)).toEqual({ credentials: [{ id: 'a' }] }); + passkey.listPasskeys.mockResolvedValue([{ id: 'a' }]); + expect(await pc(rl()).list(user)).toEqual({ credentials: [{ id: 'a' }] }); - passkey.renamePasskey.mockReturnValue({ error: 'Not found', status: 404 }); - expect(thrown(() => pc(rl()).rename(user, 'cid', { name: 'x' }))).toEqual({ status: 404, body: { error: 'Not found' } }); - passkey.renamePasskey.mockReturnValue({ success: true }); - expect(pc(rl()).rename(user, 'cid', { name: 'x' })).toEqual({ success: true }); + passkey.renamePasskey.mockResolvedValue({ error: 'Not found', status: 404 }); + expect(await thrownAsync(() => pc(rl()).rename(user, 'cid', { name: 'x' }))).toEqual({ status: 404, body: { error: 'Not found' } }); + passkey.renamePasskey.mockResolvedValue({ success: true }); + expect(await pc(rl()).rename(user, 'cid', { name: 'x' })).toEqual({ success: true }); - passkey.deletePasskey.mockReturnValue({ error: 'Incorrect password', status: 401 }); + passkey.deletePasskey.mockResolvedValue({ error: 'Incorrect password', status: 401 }); expect(await thrownAsync(() => pc(rl()).remove(user, 'cid', { password: 'x' }, req))).toEqual({ status: 401, body: { error: 'Incorrect password' } }); - passkey.deletePasskey.mockReturnValue({ success: true }); + passkey.deletePasskey.mockResolvedValue({ success: true }); expect(await pc(rl()).remove(user, 'cid', { password: 'p' }, req)).toEqual({ success: true }); expect(writeAudit).toHaveBeenCalledWith(expect.objectContaining({ action: 'user.passkey_delete' })); }); diff --git a/server/tests/unit/nest/auth.controller.test.ts b/server/tests/unit/nest/auth.controller.test.ts index f60c11872..e33c5e9f9 100644 --- a/server/tests/unit/nest/auth.controller.test.ts +++ b/server/tests/unit/nest/auth.controller.test.ts @@ -83,11 +83,11 @@ describe('RateLimitService', () => { }); describe('AuthPublicController', () => { - it('demo-login maps error, else sets the cookie + returns token/user', () => { - expect(thrown(() => apc(asvc({ demoLogin: vi.fn().mockReturnValue({ error: 'Demo disabled', status: 403 }) } as Partial), rl()).demoLogin(req, res))).toEqual({ status: 403, body: { error: 'Demo disabled' } }); + it('demo-login maps error, else sets the cookie + returns token/user', async () => { + expect(await thrownAsync(() => apc(asvc({ demoLogin: vi.fn().mockReturnValue({ error: 'Demo disabled', status: 403 }) } as Partial), rl()).demoLogin(req, res))).toEqual({ status: 403, body: { error: 'Demo disabled' } }); const setAuthCookie = vi.fn(); const c = apc(asvc({ demoLogin: vi.fn().mockReturnValue({ token: 'tk', user }), setAuthCookie } as Partial), rl()); - expect(c.demoLogin(req, res)).toEqual({ token: 'tk', user }); + expect(await c.demoLogin(req, res)).toEqual({ token: 'tk', user }); expect(setAuthCookie).toHaveBeenCalledWith(res, 'tk', req); }); @@ -100,11 +100,11 @@ describe('AuthPublicController', () => { expect(setAuthCookie).toHaveBeenCalled(); }); - it('invite 429 when rate-limited', () => { + it('invite 429 when rate-limited', async () => { const s = rl(); s.check('login', '9.9.9.9', 10, 15 * 60 * 1000, Date.now()); // not exhausted yet const c = apc(asvc({ validateInviteToken: vi.fn().mockReturnValue({ valid: true, max_uses: 1, used_count: 0, expires_at: null }) } as Partial), s); - expect(c.invite('tok', req)).toEqual({ valid: true, max_uses: 1, used_count: 0, expires_at: null }); + expect(await c.invite('tok', req)).toEqual({ valid: true, max_uses: 1, used_count: 0, expires_at: null }); }); it('login: mfa branch, success cookie, error mapping', async () => { @@ -142,9 +142,9 @@ describe('AuthPublicController', () => { expect(getAppConfig).toHaveBeenLastCalledWith(undefined); }); - it('invite maps a service error', () => { + it('invite maps a service error', async () => { const c = apc(asvc({ validateInviteToken: vi.fn().mockReturnValue({ error: 'Expired', status: 410 }) } as Partial), rl()); - expect(thrown(() => c.invite('tok', req))).toEqual({ status: 410, body: { error: 'Expired' } }); + expect(await thrownAsync(() => c.invite('tok', req))).toEqual({ status: 410, body: { error: 'Expired' } }); }); it('login takes the mfa-required branch and never sets a cookie', async () => { @@ -195,7 +195,7 @@ describe('AuthPublicController', () => { for (let i = 0; i < 10; i++) s.check('login', '9.9.9.9', 10, 15 * 60 * 1000, now); const c = apc(asvc({ registerUser: vi.fn(), validateInviteToken: vi.fn() } as Partial), s); expect(await thrownAsync(() => c.register(anyBody(), req, res))).toEqual({ status: 429, body: { error: 'Too many attempts. Please try again later.' } }); - expect(thrown(() => c.invite('t', req))).toEqual({ status: 429, body: { error: 'Too many attempts. Please try again later.' } }); + expect(await thrownAsync(() => c.invite('t', req))).toEqual({ status: 429, body: { error: 'Too many attempts. Please try again later.' } }); }); it('mfa/verify-login sets cookie + audits; logout clears cookie', async () => { @@ -210,9 +210,9 @@ describe('AuthPublicController', () => { }); describe('AuthController (authenticated)', () => { - it('GET /me 404 when missing, else returns the loaded user', () => { - expect(thrown(() => ac(asvc({ getCurrentUser: vi.fn().mockReturnValue(undefined) } as Partial), rl()).me(user))).toEqual({ status: 404, body: { error: 'User not found' } }); - expect(ac(asvc({ getCurrentUser: vi.fn().mockReturnValue({ id: 1 }) } as Partial), rl()).me(user)).toEqual({ user: { id: 1 } }); + it('GET /me 404 when missing, else returns the loaded user', async () => { + expect(await thrownAsync(() => ac(asvc({ getCurrentUser: vi.fn().mockReturnValue(undefined) } as Partial), rl()).me(user))).toEqual({ status: 404, body: { error: 'User not found' } }); + expect(await ac(asvc({ getCurrentUser: vi.fn().mockReturnValue({ id: 1 }) } as Partial), rl()).me(user)).toEqual({ user: { id: 1 } }); }); it('change-password maps error, else audits', async () => { @@ -377,7 +377,7 @@ describe('AuthController (authenticated)', () => { const deleteAvatar = vi.fn().mockResolvedValue({ removed: true }); expect(await ac(asvc({}), rl(), {}, { deleteAvatar }).deleteAvatar(user)).toEqual({ removed: true }); const listUsers = vi.fn().mockReturnValue([{ id: 1 }]); - expect(ac(asvc({}), rl(), {}, { listUsers }).users(user)).toEqual({ users: [{ id: 1 }] }); + expect(await ac(asvc({}), rl(), {}, { listUsers }).users(user)).toEqual({ users: [{ id: 1 }] }); expect(listUsers).toHaveBeenCalledWith(1); }); @@ -388,8 +388,8 @@ describe('AuthController (authenticated)', () => { }); it('app-settings get maps error, else returns data; put maps error, else audits', async () => { - expect(thrown(() => ac(asvc({ getAppSettings: vi.fn().mockReturnValue({ error: 'denied', status: 403 }) } as Partial), rl()).getAppSettings(user))).toEqual({ status: 403, body: { error: 'denied' } }); - expect(ac(asvc({ getAppSettings: vi.fn().mockReturnValue({ data: { x: 1 } }) } as Partial), rl()).getAppSettings(user)).toEqual({ x: 1 }); + expect(await thrownAsync(() => ac(asvc({ getAppSettings: vi.fn().mockReturnValue({ error: 'denied', status: 403 }) } as Partial), rl()).getAppSettings(user))).toEqual({ status: 403, body: { error: 'denied' } }); + expect(await ac(asvc({ getAppSettings: vi.fn().mockReturnValue({ data: { x: 1 } }) } as Partial), rl()).getAppSettings(user)).toEqual({ x: 1 }); expect(await thrownAsync(() => ac(asvc({ updateAppSettings: vi.fn().mockReturnValue({ error: 'bad', status: 400 }) } as Partial), rl()).updateAppSettings(user, {}, req))).toEqual({ status: 400, body: { error: 'bad' } }); expect(await ac(asvc({ updateAppSettings: vi.fn().mockReturnValue({ auditSummary: 's', auditDebugDetails: 'd' }) } as Partial), rl()).updateAppSettings(user, {}, req)).toEqual({ success: true }); expect(writeAudit).toHaveBeenCalledWith(expect.objectContaining({ action: 'settings.app_update' })); diff --git a/server/tests/unit/nest/auth.service.test.ts b/server/tests/unit/nest/auth.service.test.ts index 356d6024d..8f962a6b7 100644 --- a/server/tests/unit/nest/auth.service.test.ts +++ b/server/tests/unit/nest/auth.service.test.ts @@ -131,14 +131,14 @@ afterAll(() => testDb.close()); // --------------------------------------------------------------------------- describe('requestPasswordReset — OIDC/SSO accounts', () => { - it('AUTH-DB-PR1: refuses a reset for an OIDC-linked account that has a (random) password hash', () => { + it('AUTH-DB-PR1: refuses a reset for an OIDC-linked account that has a (random) password hash', async () => { const { user } = createUser(testDb); // OIDC users are created with a random bcrypt hash, so password_hash is set — // the old guard keyed off a missing hash and therefore let the reset through. testDb.prepare('UPDATE users SET oidc_sub = ?, oidc_issuer = ? WHERE id = ?') .run('sub-1129', 'https://idp.example', user.id); - const result = svc.requestPasswordReset(user.email, null); + const result = await svc.requestPasswordReset(user.email, null); expect(result.reason).toBe('oidc_only'); expect(result.tokenForDelivery).toBeNull(); @@ -147,9 +147,9 @@ describe('requestPasswordReset — OIDC/SSO accounts', () => { expect(n).toBe(0); }); - it('AUTH-DB-PR2: still issues a reset for a normal local (non-SSO) account', () => { + it('AUTH-DB-PR2: still issues a reset for a normal local (non-SSO) account', async () => { const { user } = createUser(testDb); - const result = svc.requestPasswordReset(user.email, null); + const result = await svc.requestPasswordReset(user.email, null); expect(result.reason).toBe('issued'); expect(result.tokenForDelivery).toBeTruthy(); }); @@ -160,19 +160,19 @@ describe('requestPasswordReset — OIDC/SSO accounts', () => { // --------------------------------------------------------------------------- describe('getAppSettings', () => { - it('AUTH-DB-013: returns 403 for non-admin', () => { + it('AUTH-DB-013: returns 403 for non-admin', async () => { const { user } = createUser(testDb); - const result = svc.getAppSettings(user.id); + const result = await svc.getAppSettings(user.id); expect(result.status).toBe(403); expect(result.error).toMatch(/admin/i); }); - it('AUTH-DB-014: returns settings object for admin with known key allow_registration', () => { + it('AUTH-DB-014: returns settings object for admin with known key allow_registration', async () => { const { user } = createAdmin(testDb); testDb .prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('allow_registration', 'true')") .run(); - const result = svc.getAppSettings(user.id); + const result = await svc.getAppSettings(user.id); expect(result.status).toBeUndefined(); expect(result.data).toBeDefined(); expect(result.data).toHaveProperty('allow_registration', 'true'); @@ -184,25 +184,25 @@ describe('getAppSettings', () => { // --------------------------------------------------------------------------- describe('isOidcOnlyMode', () => { - it('AUTH-DB-019: returns false when OIDC_ONLY env var is not set', () => { + it('AUTH-DB-019: returns false when OIDC_ONLY env var is not set', async () => { vi.stubEnv('OIDC_ONLY', ''); - expect(svc.isOidcOnlyMode()).toBe(false); + expect(await svc.isOidcOnlyMode()).toBe(false); vi.unstubAllEnvs(); }); - it('AUTH-DB-020: returns false when OIDC_ONLY=true but no OIDC_ISSUER configured', () => { + it('AUTH-DB-020: returns false when OIDC_ONLY=true but no OIDC_ISSUER configured', async () => { vi.stubEnv('OIDC_ONLY', 'true'); vi.stubEnv('OIDC_ISSUER', ''); vi.stubEnv('OIDC_CLIENT_ID', ''); - expect(svc.isOidcOnlyMode()).toBe(false); + expect(await svc.isOidcOnlyMode()).toBe(false); vi.unstubAllEnvs(); }); - it('AUTH-DB-021: returns true when OIDC_ONLY=true AND OIDC_ISSUER AND OIDC_CLIENT_ID are set', () => { + it('AUTH-DB-021: returns true when OIDC_ONLY=true AND OIDC_ISSUER AND OIDC_CLIENT_ID are set', async () => { vi.stubEnv('OIDC_ONLY', 'true'); vi.stubEnv('OIDC_ISSUER', 'https://sso.example.com'); vi.stubEnv('OIDC_CLIENT_ID', 'trek-client'); - expect(svc.isOidcOnlyMode()).toBe(true); + expect(await svc.isOidcOnlyMode()).toBe(true); vi.unstubAllEnvs(); }); }); @@ -217,63 +217,63 @@ describe('resolveAuthToggles', () => { testDb.prepare("DELETE FROM app_settings WHERE key IN ('password_login','password_registration','oidc_login','oidc_registration','oidc_only','allow_registration')").run(); }); - it('AUTH-DB-022a: returns all true by default (no DB keys, no env override)', () => { + it('AUTH-DB-022a: returns all true by default (no DB keys, no env override)', async () => { vi.stubEnv('OIDC_ONLY', ''); - const t = svc.resolveAuthToggles(); + const t = await svc.resolveAuthToggles(); expect(t.password_login).toBe(true); expect(t.password_registration).toBe(true); expect(t.oidc_login).toBe(true); expect(t.oidc_registration).toBe(true); }); - it('AUTH-DB-022b: legacy — OIDC_ONLY=true with OIDC configured disables password_login and password_registration', () => { + it('AUTH-DB-022b: legacy — OIDC_ONLY=true with OIDC configured disables password_login and password_registration', async () => { vi.stubEnv('OIDC_ONLY', 'true'); vi.stubEnv('OIDC_ISSUER', 'https://sso.example.com'); vi.stubEnv('OIDC_CLIENT_ID', 'trek-client'); - const t = svc.resolveAuthToggles(); + const t = await svc.resolveAuthToggles(); expect(t.password_login).toBe(false); expect(t.password_registration).toBe(false); expect(t.oidc_login).toBe(true); expect(t.oidc_registration).toBe(true); }); - it('AUTH-DB-022c: legacy — allow_registration=false disables both password and oidc registration', () => { + it('AUTH-DB-022c: legacy — allow_registration=false disables both password and oidc registration', async () => { vi.stubEnv('OIDC_ONLY', ''); testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('allow_registration', 'false')").run(); - const t = svc.resolveAuthToggles(); + const t = await svc.resolveAuthToggles(); expect(t.password_login).toBe(true); expect(t.password_registration).toBe(false); expect(t.oidc_login).toBe(true); expect(t.oidc_registration).toBe(false); }); - it('AUTH-DB-022d: new granular keys take precedence over legacy keys', () => { + it('AUTH-DB-022d: new granular keys take precedence over legacy keys', async () => { vi.stubEnv('OIDC_ONLY', ''); testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('allow_registration', 'false')").run(); testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('password_registration', 'true')").run(); - const t = svc.resolveAuthToggles(); + const t = await svc.resolveAuthToggles(); // New key present → use new keys, allow_registration ignored expect(t.password_registration).toBe(true); expect(t.oidc_registration).toBe(true); // defaults to true when key not set }); - it('AUTH-DB-022e: OIDC_ONLY env var overrides new granular keys for password toggles', () => { + it('AUTH-DB-022e: OIDC_ONLY env var overrides new granular keys for password toggles', async () => { vi.stubEnv('OIDC_ONLY', 'true'); testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('password_login', 'true')").run(); testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('password_registration', 'true')").run(); - const t = svc.resolveAuthToggles(); + const t = await svc.resolveAuthToggles(); // OIDC_ONLY forces password toggles off even when DB says true expect(t.password_login).toBe(false); expect(t.password_registration).toBe(false); }); - it('AUTH-DB-022f: individual granular keys can be set independently', () => { + it('AUTH-DB-022f: individual granular keys can be set independently', async () => { vi.stubEnv('OIDC_ONLY', ''); testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('password_login', 'true')").run(); testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('password_registration', 'false')").run(); testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('oidc_login', 'true')").run(); testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('oidc_registration', 'false')").run(); - const t = svc.resolveAuthToggles(); + const t = await svc.resolveAuthToggles(); expect(t.password_login).toBe(true); expect(t.password_registration).toBe(false); expect(t.oidc_login).toBe(true); @@ -286,26 +286,26 @@ describe('resolveAuthToggles', () => { // --------------------------------------------------------------------------- describe('setupMfa', () => { - it('AUTH-DB-022: returns 403 in demo mode for demo@nomad.app', () => { + it('AUTH-DB-022: returns 403 in demo mode for demo@nomad.app', async () => { vi.stubEnv('DEMO_MODE', 'true'); const { user } = createUser(testDb, { email: 'demo@nomad.app' }); - const result = svc.setupMfa(user.id, 'demo@nomad.app'); + const result = await svc.setupMfa(user.id, 'demo@nomad.app'); expect(result.status).toBe(403); expect(result.error).toMatch(/demo mode/i); vi.unstubAllEnvs(); }); - it('AUTH-DB-023: returns 400 when MFA is already enabled', () => { + it('AUTH-DB-023: returns 400 when MFA is already enabled', async () => { const { user } = createUser(testDb); testDb.prepare('UPDATE users SET mfa_enabled = 1 WHERE id = ?').run(user.id); - const result = svc.setupMfa(user.id, user.email); + const result = await svc.setupMfa(user.id, user.email); expect(result.status).toBe(400); expect(result.error).toMatch(/already enabled/i); }); - it('AUTH-DB-024: returns secret and otpauth_url when MFA setup starts successfully', () => { + it('AUTH-DB-024: returns secret and otpauth_url when MFA setup starts successfully', async () => { const { user } = createUser(testDb); - const result = svc.setupMfa(user.id, user.email); + const result = await svc.setupMfa(user.id, user.email); expect(result.error).toBeUndefined(); expect(typeof result.secret).toBe('string'); expect(result.secret!.length).toBeGreaterThan(0); @@ -320,17 +320,17 @@ describe('setupMfa', () => { // --------------------------------------------------------------------------- describe('enableMfa', () => { - it('AUTH-DB-025: returns 400 when no verification code is provided', () => { + it('AUTH-DB-025: returns 400 when no verification code is provided', async () => { const { user } = createUser(testDb); - const result = svc.enableMfa(user.id, undefined); + const result = await svc.enableMfa(user.id, undefined); expect(result.status).toBe(400); expect(result.error).toMatch(/code is required/i); }); - it('AUTH-DB-026: returns 400 when there is no pending MFA setup', () => { + it('AUTH-DB-026: returns 400 when there is no pending MFA setup', async () => { const { user } = createUser(testDb); // No setupMfa called first, so no pending entry exists - const result = svc.enableMfa(user.id, '123456'); + const result = await svc.enableMfa(user.id, '123456'); expect(result.status).toBe(400); expect(result.error).toMatch(/no mfa setup in progress/i); }); @@ -341,10 +341,10 @@ describe('enableMfa', () => { // --------------------------------------------------------------------------- describe('disableMfa', () => { - it('AUTH-DB-027: returns 403 in demo mode for demo@nomad.app', () => { + it('AUTH-DB-027: returns 403 in demo mode for demo@nomad.app', async () => { vi.stubEnv('DEMO_MODE', 'true'); const { user } = createUser(testDb, { email: 'demo@nomad.app' }); - const result = svc.disableMfa(user.id, 'demo@nomad.app', { + const result = await svc.disableMfa(user.id, 'demo@nomad.app', { password: 'password123', code: '000000', }); @@ -353,22 +353,22 @@ describe('disableMfa', () => { vi.unstubAllEnvs(); }); - it('AUTH-DB-028: returns 400 when password or code is missing', () => { + it('AUTH-DB-028: returns 400 when password or code is missing', async () => { const { user } = createUser(testDb); - const missingCode = svc.disableMfa(user.id, user.email, { password: 'pass', code: undefined }); + const missingCode = await svc.disableMfa(user.id, user.email, { password: 'pass', code: undefined }); expect(missingCode.status).toBe(400); expect(missingCode.error).toMatch(/password and authenticator code/i); - const missingPassword = svc.disableMfa(user.id, user.email, { password: undefined, code: '123456' }); + const missingPassword = await svc.disableMfa(user.id, user.email, { password: undefined, code: '123456' }); expect(missingPassword.status).toBe(400); expect(missingPassword.error).toMatch(/password and authenticator code/i); }); - it('AUTH-DB-029: returns 400 when MFA is not enabled on the account', () => { + it('AUTH-DB-029: returns 400 when MFA is not enabled on the account', async () => { const { user } = createUser(testDb); // mfa_enabled defaults to 0 / not set - const result = svc.disableMfa(user.id, user.email, { password: 'password123', code: '000000' }); + const result = await svc.disableMfa(user.id, user.email, { password: 'password123', code: '000000' }); expect(result.status).toBe(400); expect(result.error).toMatch(/not enabled/i); }); @@ -379,23 +379,23 @@ describe('disableMfa', () => { // --------------------------------------------------------------------------- describe('validateInviteToken', () => { - it('AUTH-DB-030: returns 404 for unknown token', () => { - const result = svc.validateInviteToken('no-such-token'); + it('AUTH-DB-030: returns 404 for unknown token', async () => { + const result = await svc.validateInviteToken('no-such-token'); expect(result.status).toBe(404); }); - it('AUTH-DB-031: returns 410 when max_uses exceeded', () => { + it('AUTH-DB-031: returns 410 when max_uses exceeded', async () => { // createInviteToken with used_count already at max const invite = createInviteToken(testDb, { max_uses: 1 }); // manually set used_count = 1 to simulate exhaustion testDb.prepare('UPDATE invite_tokens SET used_count = 1 WHERE id = ?').run(invite.id); - const result = svc.validateInviteToken(invite.token); + const result = await svc.validateInviteToken(invite.token); expect(result.status).toBe(410); }); - it('AUTH-DB-032: returns 410 when expired', () => { + it('AUTH-DB-032: returns 410 when expired', async () => { const invite = createInviteToken(testDb, { expires_at: '2000-01-01T00:00:00.000Z' }); - const result = svc.validateInviteToken(invite.token); + const result = await svc.validateInviteToken(invite.token); expect(result.status).toBe(410); }); }); @@ -430,13 +430,13 @@ describe('registerUser — OIDC-only / registration-disabled', () => { // --------------------------------------------------------------------------- describe('loginUser — OIDC-only mode', () => { - it('AUTH-DB-035: returns 403 when oidc_only=true', () => { + it('AUTH-DB-035: returns 403 when oidc_only=true', async () => { const { user, password } = createUser(testDb); testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('oidc_only', 'true')").run(); testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('oidc_issuer', 'https://x')").run(); testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('oidc_client_id', 'id')").run(); - const result = svc.loginUser({ email: user.email, password }); + const result = await svc.loginUser({ email: user.email, password }); expect(result.status).toBe(403); }); }); @@ -446,13 +446,13 @@ describe('loginUser — OIDC-only mode', () => { // --------------------------------------------------------------------------- describe('changePassword — OIDC-only mode', () => { - it('AUTH-DB-036: returns 403 when oidc_only=true', () => { + it('AUTH-DB-036: returns 403 when oidc_only=true', async () => { const { user, password } = createUser(testDb); testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('oidc_only', 'true')").run(); testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('oidc_issuer', 'https://x')").run(); testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('oidc_client_id', 'id')").run(); - const result = svc.changePassword(user.id, user.email, { current_password: password, new_password: 'New1234!' }); + const result = await svc.changePassword(user.id, user.email, { current_password: password, new_password: 'New1234!' }); expect(result.status).toBe(403); }); }); @@ -463,14 +463,14 @@ describe('changePassword — session invalidation', () => { const mcpCount = (id: number) => (testDb.prepare('SELECT COUNT(*) c FROM mcp_tokens WHERE user_id = ?').get(id) as { c: number }).c; - it('AUTH-DB-036b: bumps password_version, prunes MCP tokens, and re-issues a session', () => { + it('AUTH-DB-036b: bumps password_version, prunes MCP tokens, and re-issues a session', async () => { const { user, password } = createUser(testDb); tokens.createMcpToken(user.id, 'cli'); expect(pvOf(user.id)).toBe(0); expect(mcpCount(user.id)).toBe(1); - const result = svc.changePassword(user.id, user.email, { current_password: password, new_password: 'New1234!' }); + const result = await svc.changePassword(user.id, user.email, { current_password: password, new_password: 'New1234!' }); expect(result.success).toBe(true); expect(typeof result.token).toBe('string'); // fresh session for the current device @@ -478,23 +478,23 @@ describe('changePassword — session invalidation', () => { expect(mcpCount(user.id)).toBe(0); // static MCP tokens revoked }); - it('AUTH-DB-036c: a token minted before the change no longer validates afterwards', () => { + it('AUTH-DB-036c: a token minted before the change no longer validates afterwards', async () => { const { user, password } = createUser(testDb); - const stolen = svc.generateToken({ id: user.id }); // pv=0 at mint time + const stolen = await svc.generateToken({ id: user.id }); // pv=0 at mint time - expect(verifyJwtAndLoadUser(stolen)).not.toBeNull(); + expect(await verifyJwtAndLoadUser(stolen)).not.toBeNull(); - svc.changePassword(user.id, user.email, { current_password: password, new_password: 'New1234!' }); + await svc.changePassword(user.id, user.email, { current_password: password, new_password: 'New1234!' }); - expect(verifyJwtAndLoadUser(stolen)).toBeNull(); // invalidated by the pv bump + expect(await verifyJwtAndLoadUser(stolen)).toBeNull(); // invalidated by the pv bump }); - it('AUTH-DB-036d: preserves the remember choice in the re-issued session (#1927)', () => { + it('AUTH-DB-036d: preserves the remember choice in the re-issued session (#1927)', async () => { // eslint-disable-next-line @typescript-eslint/no-require-imports const jwt = require('jsonwebtoken'); const { user, password } = createUser(testDb); - const result = svc.changePassword(user.id, user.email, { current_password: password, new_password: 'New1234!' }, true); + const result = await svc.changePassword(user.id, user.email, { current_password: password, new_password: 'New1234!' }, true); expect(result.success).toBe(true); const decoded = jwt.decode(result.token!) as { remember?: boolean; iat: number; exp: number }; expect(decoded.remember).toBe(true); @@ -507,11 +507,11 @@ describe('changePassword — session invalidation', () => { // --------------------------------------------------------------------------- describe('disableMfa — require_mfa policy', () => { - it('AUTH-DB-037: returns 403 when require_mfa=true is set globally', () => { + it('AUTH-DB-037: returns 403 when require_mfa=true is set globally', async () => { const { user } = createUser(testDb); testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('require_mfa', 'true')").run(); - const result = svc.disableMfa(user.id, user.email, { password: 'pass', code: '123456' }); + const result = await svc.disableMfa(user.id, user.email, { password: 'pass', code: '123456' }); expect(result.status).toBe(403); expect(result.error).toMatch(/cannot be disabled/i); }); @@ -522,25 +522,25 @@ describe('disableMfa — require_mfa policy', () => { // --------------------------------------------------------------------------- describe('verifyMfaLogin — validation', () => { - it('AUTH-DB-038: returns 400 when mfa_token or code is missing', () => { - const result = svc.verifyMfaLogin({ mfa_token: undefined, code: undefined }); + it('AUTH-DB-038: returns 400 when mfa_token or code is missing', async () => { + const result = await svc.verifyMfaLogin({ mfa_token: undefined, code: undefined }); expect(result.status).toBe(400); }); - it('AUTH-DB-039: returns 401 when mfa_token has wrong purpose', () => { + it('AUTH-DB-039: returns 401 when mfa_token has wrong purpose', async () => { // eslint-disable-next-line @typescript-eslint/no-require-imports const jwt = require('jsonwebtoken'); const tok = jwt.sign({ id: 1, purpose: 'wrong' }, 'test-secret', { expiresIn: '5m', algorithm: 'HS256' }); - const result = svc.verifyMfaLogin({ mfa_token: tok, code: '123456' }); + const result = await svc.verifyMfaLogin({ mfa_token: tok, code: '123456' }); expect(result.status).toBe(401); expect(result.error).toMatch(/invalid/i); }); - it('AUTH-DB-040: returns 401 when user not found for valid mfa_token', () => { + it('AUTH-DB-040: returns 401 when user not found for valid mfa_token', async () => { // eslint-disable-next-line @typescript-eslint/no-require-imports const jwt = require('jsonwebtoken'); const tok = jwt.sign({ id: 99999, purpose: 'mfa_login' }, 'test-secret', { expiresIn: '5m', algorithm: 'HS256' }); - const result = svc.verifyMfaLogin({ mfa_token: tok, code: '123456' }); + const result = await svc.verifyMfaLogin({ mfa_token: tok, code: '123456' }); expect(result.status).toBe(401); }); }); @@ -670,18 +670,18 @@ describe('getAppConfig', () => { }); describe('demoLogin', () => { - it('AUTH-DB-053: 404 outside demo mode', () => { + it('AUTH-DB-053: 404 outside demo mode', async () => { vi.stubEnv('DEMO_MODE', ''); - expect(svc.demoLogin()).toEqual({ error: 'Not found', status: 404 }); + expect(await svc.demoLogin()).toEqual({ error: 'Not found', status: 404 }); vi.unstubAllEnvs(); }); - it('AUTH-DB-054: 500 when the demo user row is missing; token + safe user when present', () => { + it('AUTH-DB-054: 500 when the demo user row is missing; token + safe user when present', async () => { vi.stubEnv('DEMO_MODE', 'true'); - expect(svc.demoLogin()).toEqual({ error: 'Demo user not found', status: 500 }); + expect(await svc.demoLogin()).toEqual({ error: 'Demo user not found', status: 500 }); // demoLogin looks up DEMO_EMAIL_PRIMARY specifically (not any demo alias). createUser(testDb, { email: 'demo@trek.app' }); - const result = svc.demoLogin(); + const result = await svc.demoLogin(); expect(typeof result.token).toBe('string'); expect(result.user).not.toHaveProperty('password_hash'); vi.unstubAllEnvs(); @@ -689,9 +689,9 @@ describe('demoLogin', () => { }); describe('validateInviteToken — valid path', () => { - it('AUTH-DB-055: returns valid with usage metadata', () => { + it('AUTH-DB-055: returns valid with usage metadata', async () => { const invite = createInviteToken(testDb, { max_uses: 5 }); - const result = svc.validateInviteToken(invite.token); + const result = await svc.validateInviteToken(invite.token); expect(result.valid).toBe(true); expect(result.max_uses).toBe(5); expect(result.used_count).toBe(0); @@ -730,26 +730,26 @@ describe('registerUser — success paths', () => { }); describe('loginUser — credential branches', () => { - it('AUTH-DB-059: unknown email answers the generic 401', () => { - const result = svc.loginUser({ email: 'nobody@x.com', password: 'pw' }); + it('AUTH-DB-059: unknown email answers the generic 401', async () => { + const result = await svc.loginUser({ email: 'nobody@x.com', password: 'pw' }); expect(result).toMatchObject({ error: 'Invalid email or password', status: 401, auditUserId: null }); expect(result.auditDetails).toMatchObject({ reason: 'unknown_email' }); }); - it('AUTH-DB-060: missing fields answer the bespoke 400', () => { - expect(svc.loginUser({ email: '', password: '' })).toEqual({ error: 'Email and password are required', status: 400 }); + it('AUTH-DB-060: missing fields answer the bespoke 400', async () => { + expect(await svc.loginUser({ email: '', password: '' })).toEqual({ error: 'Email and password are required', status: 400 }); }); - it('AUTH-DB-061: wrong password answers the generic 401 with the wrong_password audit reason', () => { + it('AUTH-DB-061: wrong password answers the generic 401 with the wrong_password audit reason', async () => { const { user } = createUser(testDb); - const result = svc.loginUser({ email: user.email, password: 'nope' }); + const result = await svc.loginUser({ email: user.email, password: 'nope' }); expect(result.status).toBe(401); expect(result.auditDetails).toMatchObject({ reason: 'wrong_password' }); }); - it('AUTH-DB-062: success returns token + stripped user and bumps last_login/login_count', () => { + it('AUTH-DB-062: success returns token + stripped user and bumps last_login/login_count', async () => { const { user, password } = createUser(testDb); - const result = svc.loginUser({ email: user.email, password }); + const result = await svc.loginUser({ email: user.email, password }); expect(typeof result.token).toBe('string'); expect(result.user).not.toHaveProperty('password_hash'); expect(result.auditAction).toBe('user.login'); @@ -758,24 +758,24 @@ describe('loginUser — credential branches', () => { expect(row.last_login).not.toBeNull(); }); - it('AUTH-DB-063: an MFA-enabled account gets the interstitial mfa_token instead of a session', () => { + it('AUTH-DB-063: an MFA-enabled account gets the interstitial mfa_token instead of a session', async () => { const { user } = createUser(testDb); testDb.prepare("UPDATE users SET mfa_enabled = 1, mfa_secret = 'enc:JBSWY3DPEHPK3PXP' WHERE id = ?").run(user.id); const { password } = { password: undefined }; void password; - const result = svc.loginUser({ email: user.email, password: 'TestPass' }); + const result = await svc.loginUser({ email: user.email, password: 'TestPass' }); // wrong password still 401s before the MFA branch expect(result.status).toBe(401); }); }); describe('getCurrentUser', () => { - it('AUTH-DB-064: returns the stripped row with avatar_url; null for a missing id', () => { + it('AUTH-DB-064: returns the stripped row with avatar_url; null for a missing id', async () => { const { user } = createUser(testDb); - const loaded = svc.getCurrentUser(user.id); + const loaded = await svc.getCurrentUser(user.id); expect(loaded?.id).toBe(user.id); expect(loaded).not.toHaveProperty('password_hash'); - expect(svc.getCurrentUser(999999)).toBeNull(); + expect(await svc.getCurrentUser(999999)).toBeNull(); }); }); @@ -801,39 +801,39 @@ describe('deleteAccount', () => { }); describe('updateAppSettings', () => { - it('AUTH-DB-071: 403 for non-admin', () => { + it('AUTH-DB-071: 403 for non-admin', async () => { const { user } = createUser(testDb); - expect(svc.updateAppSettings(user.id, {}).status).toBe(403); + expect((await svc.updateAppSettings(user.id, {})).status).toBe(403); }); - it('AUTH-DB-072: require_mfa=true refuses when the admin has neither MFA nor a passkey', () => { + it('AUTH-DB-072: require_mfa=true refuses when the admin has neither MFA nor a passkey', async () => { const { user } = createAdmin(testDb); - const result = svc.updateAppSettings(user.id, { require_mfa: true }); + const result = await svc.updateAppSettings(user.id, { require_mfa: true }); expect(result.status).toBe(400); expect(result.error).toMatch(/secure your own account/i); }); - it('AUTH-DB-073: require_mfa=true is allowed once the admin has MFA enabled', () => { + it('AUTH-DB-073: require_mfa=true is allowed once the admin has MFA enabled', async () => { const { user } = createAdmin(testDb); testDb.prepare('UPDATE users SET mfa_enabled = 1 WHERE id = ?').run(user.id); - const result = svc.updateAppSettings(user.id, { require_mfa: true }); + const result = await svc.updateAppSettings(user.id, { require_mfa: true }); expect(result.success).toBe(true); expect(result.auditSummary).toMatchObject({ require_mfa: true }); testDb.prepare("DELETE FROM app_settings WHERE key = 'require_mfa'").run(); }); - it('AUTH-DB-074: lockout prevention refuses disabling every login method', () => { + it('AUTH-DB-074: lockout prevention refuses disabling every login method', async () => { const { user } = createAdmin(testDb); - const result = svc.updateAppSettings(user.id, { password_login: 'false', oidc_login: 'false' }); + const result = await svc.updateAppSettings(user.id, { password_login: 'false', oidc_login: 'false' }); expect(result).toEqual({ error: 'Cannot disable all login methods. At least one must remain enabled.', status: 400 }); }); - it('AUTH-DB-075: the smtp_pass masking sentinel is skipped on a notification-settings change', () => { + it('AUTH-DB-075: the smtp_pass masking sentinel is skipped on a notification-settings change', async () => { // The reminder crons read their gates per tick now, so a settings change no // longer flags (or needs) a scheduler restart. const { user } = createAdmin(testDb); testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('smtp_pass', 'stored')").run(); - const result = svc.updateAppSettings(user.id, { smtp_pass: '••••••••', notification_channels: 'email' }); + const result = await svc.updateAppSettings(user.id, { smtp_pass: '••••••••', notification_channels: 'email' }); expect(result.success).toBe(true); const { value } = testDb.prepare("SELECT value FROM app_settings WHERE key = 'smtp_pass'").get() as { value: string }; expect(value).toBe('stored'); // sentinel never overwrites the secret @@ -843,11 +843,11 @@ describe('updateAppSettings', () => { }); describe('MFA success flows', () => { - it('AUTH-DB-076: setup → enable with a valid TOTP code returns backup codes and persists the encrypted secret', () => { + it('AUTH-DB-076: setup → enable with a valid TOTP code returns backup codes and persists the encrypted secret', async () => { const { user } = createUser(testDb); - const setup = svc.setupMfa(user.id, user.email); + const setup = await svc.setupMfa(user.id, user.email); const code = authenticator.generate(setup.secret!); - const result = svc.enableMfa(user.id, code); + const result = await svc.enableMfa(user.id, code); expect(result.success).toBe(true); expect(result.backup_codes).toHaveLength(10); const row = testDb.prepare('SELECT mfa_enabled, mfa_secret FROM users WHERE id = ?').get(user.id) as { mfa_enabled: number; mfa_secret: string }; @@ -855,129 +855,129 @@ describe('MFA success flows', () => { expect(row.mfa_secret).toBe('enc:' + setup.secret); }); - it('AUTH-DB-077: enable with a wrong code answers 401 and keeps the pending secret', () => { + it('AUTH-DB-077: enable with a wrong code answers 401 and keeps the pending secret', async () => { const { user } = createUser(testDb); - svc.setupMfa(user.id, user.email); - expect(svc.enableMfa(user.id, '000000')).toEqual({ error: 'Invalid verification code', status: 401 }); + await svc.setupMfa(user.id, user.email); + expect(await svc.enableMfa(user.id, '000000')).toEqual({ error: 'Invalid verification code', status: 401 }); }); - it('AUTH-DB-078: disableMfa succeeds with the right password + TOTP code', () => { + it('AUTH-DB-078: disableMfa succeeds with the right password + TOTP code', async () => { const { user, password } = createUser(testDb); const secret = authenticator.generateSecret(); testDb.prepare('UPDATE users SET mfa_enabled = 1, mfa_secret = ? WHERE id = ?').run('enc:' + secret, user.id); - const result = svc.disableMfa(user.id, user.email, { password, code: authenticator.generate(secret) }); + const result = await svc.disableMfa(user.id, user.email, { password, code: authenticator.generate(secret) }); expect(result).toEqual({ success: true, mfa_enabled: false }); const row = testDb.prepare('SELECT mfa_enabled, mfa_secret FROM users WHERE id = ?').get(user.id) as { mfa_enabled: number; mfa_secret: string | null }; expect(row.mfa_enabled).toBe(0); expect(row.mfa_secret).toBeNull(); }); - it('AUTH-DB-079: disableMfa answers 401 on a wrong password', () => { + it('AUTH-DB-079: disableMfa answers 401 on a wrong password', async () => { const { user } = createUser(testDb); const secret = authenticator.generateSecret(); testDb.prepare('UPDATE users SET mfa_enabled = 1, mfa_secret = ? WHERE id = ?').run('enc:' + secret, user.id); - expect(svc.disableMfa(user.id, user.email, { password: 'wrong', code: authenticator.generate(secret) })) + expect(await svc.disableMfa(user.id, user.email, { password: 'wrong', code: authenticator.generate(secret) })) .toEqual({ error: 'Incorrect password', status: 401 }); }); - it('AUTH-DB-080: verifyMfaLogin succeeds with a TOTP code from the interstitial token', () => { + it('AUTH-DB-080: verifyMfaLogin succeeds with a TOTP code from the interstitial token', async () => { const { user, password } = createUser(testDb); const secret = authenticator.generateSecret(); testDb.prepare('UPDATE users SET mfa_enabled = 1, mfa_secret = ? WHERE id = ?').run('enc:' + secret, user.id); - const interstitial = svc.loginUser({ email: user.email, password }); + const interstitial = await svc.loginUser({ email: user.email, password }); expect(interstitial.mfa_required).toBe(true); - const result = svc.verifyMfaLogin({ mfa_token: interstitial.mfa_token, code: authenticator.generate(secret) }); + const result = await svc.verifyMfaLogin({ mfa_token: interstitial.mfa_token, code: authenticator.generate(secret) }); expect(typeof result.token).toBe('string'); expect(result.auditUserId).toBe(user.id); }); - it('AUTH-DB-081: verifyMfaLogin consumes a backup code when the TOTP fails', () => { + it('AUTH-DB-081: verifyMfaLogin consumes a backup code when the TOTP fails', async () => { const { user, password } = createUser(testDb); const secret = authenticator.generateSecret(); const codes = ['AAAA-1111', 'BBBB-2222']; // hashBackupCode (legacy SHA-256) hashes still verify via matchBackupCode. testDb.prepare('UPDATE users SET mfa_enabled = 1, mfa_secret = ?, mfa_backup_codes = ? WHERE id = ?') .run('enc:' + secret, JSON.stringify(codes.map(hashBackupCode)), user.id); - const interstitial = svc.loginUser({ email: user.email, password }); - const result = svc.verifyMfaLogin({ mfa_token: interstitial.mfa_token, code: 'AAAA-1111' }); + const interstitial = await svc.loginUser({ email: user.email, password }); + const result = await svc.verifyMfaLogin({ mfa_token: interstitial.mfa_token, code: 'AAAA-1111' }); expect(typeof result.token).toBe('string'); const row = testDb.prepare('SELECT mfa_backup_codes FROM users WHERE id = ?').get(user.id) as { mfa_backup_codes: string }; expect(JSON.parse(row.mfa_backup_codes)).toHaveLength(1); // used code spliced out // the spent code no longer verifies - const again = svc.loginUser({ email: user.email, password }); - expect(svc.verifyMfaLogin({ mfa_token: again.mfa_token, code: 'AAAA-1111' }).status).toBe(401); + const again = await svc.loginUser({ email: user.email, password }); + expect((await svc.verifyMfaLogin({ mfa_token: again.mfa_token, code: 'AAAA-1111' })).status).toBe(401); }); }); describe('resetPassword', () => { - it('AUTH-DB-082: consumes an issued token, bumps password_version and burns sibling tokens', () => { + it('AUTH-DB-082: consumes an issued token, bumps password_version and burns sibling tokens', async () => { const { user } = createUser(testDb); - const issued = svc.requestPasswordReset(user.email, '1.2.3.4'); + const issued = await svc.requestPasswordReset(user.email, '1.2.3.4'); expect(issued.reason).toBe('issued'); - const result = svc.resetPassword({ token: issued.tokenForDelivery!, new_password: 'Fresh123!' }); + const result = await svc.resetPassword({ token: issued.tokenForDelivery!, new_password: 'Fresh123!' }); expect(result).toEqual({ success: true, userId: user.id }); const row = testDb.prepare('SELECT password_version FROM users WHERE id = ?').get(user.id) as { password_version: number }; expect(row.password_version).toBe(1); // token is burned — a second use answers the bespoke 400 - expect(svc.resetPassword({ token: issued.tokenForDelivery!, new_password: 'Fresh456!' })) + expect(await svc.resetPassword({ token: issued.tokenForDelivery!, new_password: 'Fresh456!' })) .toEqual({ error: 'This reset link has already been used', status: 400 }); }); - it('AUTH-DB-083: bespoke 400s for missing/unknown/expired tokens', () => { - expect(svc.resetPassword({ new_password: 'Fresh123!' })).toEqual({ error: 'Reset token is required', status: 400 }); - expect(svc.resetPassword({ token: 't' })).toEqual({ error: 'New password is required', status: 400 }); - expect(svc.resetPassword({ token: 'unknown-token', new_password: 'Fresh123!' })) + it('AUTH-DB-083: bespoke 400s for missing/unknown/expired tokens', async () => { + expect(await svc.resetPassword({ new_password: 'Fresh123!' })).toEqual({ error: 'Reset token is required', status: 400 }); + expect(await svc.resetPassword({ token: 't' })).toEqual({ error: 'New password is required', status: 400 }); + expect(await svc.resetPassword({ token: 'unknown-token', new_password: 'Fresh123!' })) .toEqual({ error: 'Invalid or expired reset link', status: 400 }); const { user } = createUser(testDb); - const issued = svc.requestPasswordReset(user.email, null); + const issued = await svc.requestPasswordReset(user.email, null); testDb.prepare("UPDATE password_reset_tokens SET expires_at = '2000-01-01T00:00:00.000Z' WHERE user_id = ?").run(user.id); - expect(svc.resetPassword({ token: issued.tokenForDelivery!, new_password: 'Fresh123!' })) + expect(await svc.resetPassword({ token: issued.tokenForDelivery!, new_password: 'Fresh123!' })) .toEqual({ error: 'Reset link has expired. Please request a new one.', status: 400 }); }); - it('AUTH-DB-084: an MFA-enabled account demands a code, then consumes a backup code', () => { + it('AUTH-DB-084: an MFA-enabled account demands a code, then consumes a backup code', async () => { const { user } = createUser(testDb); const secret = authenticator.generateSecret(); testDb.prepare('UPDATE users SET mfa_enabled = 1, mfa_secret = ?, mfa_backup_codes = ? WHERE id = ?') .run('enc:' + secret, JSON.stringify([hashBackupCode('CCCC-3333')]), user.id); - const issued = svc.requestPasswordReset(user.email, null); + const issued = await svc.requestPasswordReset(user.email, null); // no code → mfa_required interstitial, token NOT burned - expect(svc.resetPassword({ token: issued.tokenForDelivery!, new_password: 'Fresh123!' })) + expect(await svc.resetPassword({ token: issued.tokenForDelivery!, new_password: 'Fresh123!' })) .toEqual({ mfa_required: true, status: 200 }); // wrong code → 401 - expect(svc.resetPassword({ token: issued.tokenForDelivery!, new_password: 'Fresh123!', mfa_code: '000000' })) + expect(await svc.resetPassword({ token: issued.tokenForDelivery!, new_password: 'Fresh123!', mfa_code: '000000' })) .toEqual({ error: 'Invalid MFA code', status: 401 }); // backup code → success + code consumed - expect(svc.resetPassword({ token: issued.tokenForDelivery!, new_password: 'Fresh123!', mfa_code: 'CCCC-3333' })) + expect(await svc.resetPassword({ token: issued.tokenForDelivery!, new_password: 'Fresh123!', mfa_code: 'CCCC-3333' })) .toEqual({ success: true, userId: user.id }); const row = testDb.prepare('SELECT mfa_backup_codes FROM users WHERE id = ?').get(user.id) as { mfa_backup_codes: string }; expect(JSON.parse(row.mfa_backup_codes)).toHaveLength(0); }); - it('AUTH-DB-085: password-login-disabled and per-email throttle short-circuit the request', () => { + it('AUTH-DB-085: password-login-disabled and per-email throttle short-circuit the request', async () => { vi.stubEnv('OIDC_ONLY', 'true'); vi.stubEnv('OIDC_ISSUER', 'https://sso.example.com'); vi.stubEnv('OIDC_CLIENT_ID', 'trek-client'); - expect(svc.requestPasswordReset('whoever@x.com', null).reason).toBe('password_login_disabled'); + expect((await svc.requestPasswordReset('whoever@x.com', null)).reason).toBe('password_login_disabled'); vi.unstubAllEnvs(); const { user } = createUser(testDb); - svc.requestPasswordReset(user.email, null); - svc.requestPasswordReset(user.email, null); - svc.requestPasswordReset(user.email, null); - expect(svc.requestPasswordReset(user.email, null).reason).toBe('throttled_per_email'); + await svc.requestPasswordReset(user.email, null); + await svc.requestPasswordReset(user.email, null); + await svc.requestPasswordReset(user.email, null); + expect((await svc.requestPasswordReset(user.email, null)).reason).toBe('throttled_per_email'); }); }); describe('ephemeral + demo helpers', () => { - it('AUTH-DB-088: isDemoUser is true only for the demo email in demo mode', () => { + it('AUTH-DB-088: isDemoUser is true only for the demo email in demo mode', async () => { const { user } = createUser(testDb, { email: 'demo@nomad.app' }); const { user: other } = createUser(testDb); - expect(svc.isDemoUser(user.id)).toBe(false); // demo mode off + expect(await svc.isDemoUser(user.id)).toBe(false); // demo mode off vi.stubEnv('DEMO_MODE', 'true'); - expect(svc.isDemoUser(user.id)).toBe(true); - expect(svc.isDemoUser(other.id)).toBe(false); + expect(await svc.isDemoUser(user.id)).toBe(true); + expect(await svc.isDemoUser(other.id)).toBe(false); vi.unstubAllEnvs(); }); }); @@ -1004,14 +1004,14 @@ describe('auth quirk fixes', () => { expect(used_count).toBe(0); }); - it('AUTH-DB-091: a backup-code login burns the code and records the login as one atomic pair', () => { + it('AUTH-DB-091: a backup-code login burns the code and records the login as one atomic pair', async () => { const { user, password } = createUser(testDb); const secret = authenticator.generateSecret(); testDb.prepare('UPDATE users SET mfa_enabled = 1, mfa_secret = ?, mfa_backup_codes = ? WHERE id = ?') .run('enc:' + secret, JSON.stringify([hashBackupCode('DDDD-4444')]), user.id); - const interstitial = svc.loginUser({ email: user.email, password }); + const interstitial = await svc.loginUser({ email: user.email, password }); - const result = svc.verifyMfaLogin({ mfa_token: interstitial.mfa_token, code: 'DDDD-4444' }); + const result = await svc.verifyMfaLogin({ mfa_token: interstitial.mfa_token, code: 'DDDD-4444' }); expect(typeof result.token).toBe('string'); const row = testDb.prepare('SELECT mfa_backup_codes, login_count, last_login FROM users WHERE id = ?').get(user.id) as { mfa_backup_codes: string; login_count: number; last_login: string | null }; @@ -1037,31 +1037,31 @@ describe('MCP token verification round-trips', () => { expect(tokens.verifyMcpToken('trek_no_such_token')).toBeNull(); }); - it('AUTH-BR-003: verifyJwtToken round-trips a service-minted token through the pv gate', () => { + it('AUTH-BR-003: verifyJwtToken round-trips a service-minted token through the pv gate', async () => { const { user } = createUser(testDb); - const token = svc.generateToken({ id: user.id }); - expect(svc.verifyJwtToken(token)?.id).toBe(user.id); - expect(svc.verifyJwtToken('not-a-jwt')).toBeNull(); + const token = await svc.generateToken({ id: user.id }); + expect((await svc.verifyJwtToken(token))?.id).toBe(user.id); + expect(await svc.verifyJwtToken('not-a-jwt')).toBeNull(); }); }); describe('generateToken remember claim (#1927)', () => { const jwt = require('jsonwebtoken'); - it('AUTH-TOKEN-001: embeds remember and picks the matching lifetime when the caller chose', () => { + it('AUTH-TOKEN-001: embeds remember and picks the matching lifetime when the caller chose', async () => { const { user } = createUser(testDb); - const long = jwt.decode(svc.generateToken({ id: user.id }, true)) as { remember?: boolean; iat: number; exp: number }; + const long = jwt.decode(await svc.generateToken({ id: user.id }, true)) as { remember?: boolean; iat: number; exp: number }; expect(long.remember).toBe(true); expect(long.exp - long.iat).toBe(2592000); - const short = jwt.decode(svc.generateToken({ id: user.id }, false)) as { remember?: boolean; iat: number; exp: number }; + const short = jwt.decode(await svc.generateToken({ id: user.id }, false)) as { remember?: boolean; iat: number; exp: number }; expect(short.remember).toBe(false); expect(short.exp - short.iat).toBe(86400); }); - it('AUTH-TOKEN-002: omits the claim entirely when the caller did not choose (legacy payload)', () => { + it('AUTH-TOKEN-002: omits the claim entirely when the caller did not choose (legacy payload)', async () => { const { user } = createUser(testDb); - const decoded = jwt.decode(svc.generateToken({ id: user.id })) as { remember?: boolean; iat: number; exp: number }; + const decoded = jwt.decode(await svc.generateToken({ id: user.id })) as { remember?: boolean; iat: number; exp: number }; expect('remember' in decoded).toBe(false); expect(decoded.exp - decoded.iat).toBe(86400); }); diff --git a/server/tests/unit/nest/auth/jwt-verify.test.ts b/server/tests/unit/nest/auth/jwt-verify.test.ts index 0017fdf40..c795f4e3e 100644 --- a/server/tests/unit/nest/auth/jwt-verify.test.ts +++ b/server/tests/unit/nest/auth/jwt-verify.test.ts @@ -71,65 +71,65 @@ describe('extractToken', () => { }); describe('verifyJwtAndLoadUser', () => { - it('AUTH-JWT-001: returns the user for a valid token, without password_version', () => { + it('AUTH-JWT-001: returns the user for a valid token, without password_version', async () => { userRow({ id: 1, username: 'alice', email: 'alice@example.com', role: 'user', password_version: 0 }); const token = jwt.sign({ id: 1 }, 'test-secret', { algorithm: 'HS256' }); - expect(verifyJwtAndLoadUser(token)).toEqual({ id: 1, username: 'alice', email: 'alice@example.com', role: 'user' }); + expect(await verifyJwtAndLoadUser(token)).toEqual({ id: 1, username: 'alice', email: 'alice@example.com', role: 'user' }); }); - it('AUTH-JWT-002: returns null for a malformed token', () => { - expect(verifyJwtAndLoadUser('invalid.jwt.token')).toBeNull(); + it('AUTH-JWT-002: returns null for a malformed token', async () => { + expect(await verifyJwtAndLoadUser('invalid.jwt.token')).toBeNull(); }); - it('AUTH-JWT-003: returns null when the user no longer exists', () => { + it('AUTH-JWT-003: returns null when the user no longer exists', async () => { userRow(undefined); - expect(verifyJwtAndLoadUser(jwt.sign({ id: 99999 }, 'test-secret', { algorithm: 'HS256' }))).toBeNull(); + expect(await verifyJwtAndLoadUser(jwt.sign({ id: 99999 }, 'test-secret', { algorithm: 'HS256' }))).toBeNull(); }); - it('AUTH-JWT-004: returns null for an expired token', () => { + it('AUTH-JWT-004: returns null for an expired token', async () => { const expired = jwt.sign({ id: 1, exp: Math.floor(Date.now() / 1000) - 3600 }, 'test-secret', { algorithm: 'HS256' }); - expect(verifyJwtAndLoadUser(expired)).toBeNull(); + expect(await verifyJwtAndLoadUser(expired)).toBeNull(); }); - it('AUTH-JWT-005: returns null for a token signed with the wrong secret', () => { - expect(verifyJwtAndLoadUser(jwt.sign({ id: 1 }, 'wrong-secret', { algorithm: 'HS256' }))).toBeNull(); + it('AUTH-JWT-005: returns null for a token signed with the wrong secret', async () => { + expect(await verifyJwtAndLoadUser(jwt.sign({ id: 1 }, 'wrong-secret', { algorithm: 'HS256' }))).toBeNull(); }); - it('AUTH-JWT-006: rejects a purpose-scoped mfa_login token even when the user is valid', () => { + it('AUTH-JWT-006: rejects a purpose-scoped mfa_login token even when the user is valid', async () => { // Issued after the password check but before TOTP, signed with the same // secret. It must never authenticate an ordinary request. userRow({ id: 1, username: 'alice', email: 'alice@example.com', role: 'user', password_version: 0 }); const mfaToken = jwt.sign({ id: 1, purpose: 'mfa_login' }, 'test-secret', { algorithm: 'HS256' }); - expect(verifyJwtAndLoadUser(mfaToken)).toBeNull(); + expect(await verifyJwtAndLoadUser(mfaToken)).toBeNull(); }); - it('AUTH-JWT-007: rejects a token whose password_version predates the user row', () => { + it('AUTH-JWT-007: rejects a token whose password_version predates the user row', async () => { userRow({ id: 1, username: 'alice', email: 'alice@example.com', role: 'user', password_version: 2 }); const stale = jwt.sign({ id: 1, pv: 1 }, 'test-secret', { algorithm: 'HS256' }); - expect(verifyJwtAndLoadUser(stale)).toBeNull(); + expect(await verifyJwtAndLoadUser(stale)).toBeNull(); }); - it('AUTH-JWT-008: accepts a token whose password_version matches', () => { + it('AUTH-JWT-008: accepts a token whose password_version matches', async () => { userRow({ id: 1, username: 'alice', email: 'alice@example.com', role: 'user', password_version: 2 }); const current = jwt.sign({ id: 1, pv: 2 }, 'test-secret', { algorithm: 'HS256' }); - expect(verifyJwtAndLoadUser(current)).not.toBeNull(); + expect(await verifyJwtAndLoadUser(current)).not.toBeNull(); }); - it('AUTH-JWT-009: a pre-pv token still works against a never-reset user (both read as 0)', () => { + it('AUTH-JWT-009: a pre-pv token still works against a never-reset user (both read as 0)', async () => { userRow({ id: 1, username: 'alice', email: 'alice@example.com', role: 'user', password_version: null }); const legacy = jwt.sign({ id: 1 }, 'test-secret', { algorithm: 'HS256' }); - expect(verifyJwtAndLoadUser(legacy)).not.toBeNull(); + expect(await verifyJwtAndLoadUser(legacy)).not.toBeNull(); }); - it('AUTH-JWT-010: but a pre-pv token stops working once the user has reset', () => { + it('AUTH-JWT-010: but a pre-pv token stops working once the user has reset', async () => { userRow({ id: 1, username: 'alice', email: 'alice@example.com', role: 'user', password_version: 1 }); const legacy = jwt.sign({ id: 1 }, 'test-secret', { algorithm: 'HS256' }); - expect(verifyJwtAndLoadUser(legacy)).toBeNull(); + expect(await verifyJwtAndLoadUser(legacy)).toBeNull(); }); }); diff --git a/server/tests/unit/nest/dawarich.mcp.test.ts b/server/tests/unit/nest/dawarich.mcp.test.ts index 02be7a53b..25dcf4ecc 100644 --- a/server/tests/unit/nest/dawarich.mcp.test.ts +++ b/server/tests/unit/nest/dawarich.mcp.test.ts @@ -60,7 +60,7 @@ interface Mocks { /** The demo gate is off unless a case turns it on; every write tool asks it. */ function makeMcp(m: Mocks = {}) { - const auth: Partial = m.auth ?? { isDemoUser: () => false }; + const auth: Partial = m.auth ?? { isDemoUser: async () => false }; return new DawarichMcp( (m.suggestions ?? {}) as DawarichSuggestionsService, (m.tracks ?? {}) as DawarichTracksService, @@ -221,7 +221,7 @@ describe('DawarichMcp surface', () => { // draws: reading is not a write. The gate is wired to answer "yes, demo" // here, so a check added to a read would turn both of these into refusals // and payload() would fail on the isError flag. - const isDemoUser = vi.fn().mockReturnValue(true); + const isDemoUser = vi.fn().mockResolvedValue(true); const listFn = vi.fn().mockReturnValue(list(1)); const forTrip = vi.fn().mockResolvedValue({ days: [], source: 'tracks', fetchedAt: '2026-05-02T09:00:00.000Z', pointCount: 0, truncated: false, @@ -431,30 +431,30 @@ describe('DawarichMcp list_dawarich_suggestions', () => { describe('DawarichMcp accept tools', () => { it.each(['acceptAsPlace', 'acceptAsJournalEntry', 'markBucketVisited', 'dismiss'])( 'DAWARICH-MCP-040: %s refuses the demo account before the service is reached', - (method) => { + async (method) => { // One argument object for all four: the demo check runs before anything // reads the body, so the extra keys are exactly as irrelevant here as the // refusal makes them. const args = { suggestionId: 9, tripId: 2, journalId: 2 }; const accept = vi.fn(); const setState = vi.fn(); - const isDemoUser = vi.fn().mockReturnValue(true); + const isDemoUser = vi.fn().mockResolvedValue(true); const mcp = makeMcp({ suggestions: { accept, setState }, auth: { isDemoUser } }); - const call = (mcp as unknown as Record McpTextResult>)[method]; - expect(refusal(call.call(mcp, args, ctx))).toBe('Write operations are disabled in demo mode.'); + const call = (mcp as unknown as Record Promise>)[method]; + expect(refusal(await call.call(mcp, args, ctx))).toBe('Write operations are disabled in demo mode.'); expect(isDemoUser).toHaveBeenCalledWith(7); expect(accept).not.toHaveBeenCalled(); expect(setState).not.toHaveBeenCalled(); }, ); - it('DAWARICH-MCP-041: accepting as a place forwards every correction under target "place", with the id kept out of the body', () => { + it('DAWARICH-MCP-041: accepting as a place forwards every correction under target "place", with the id kept out of the body', async () => { // suggestionId is the second argument, not a body field. Leaving it in the // body would put a key into DawarichAccept that nothing reads, and the next // reader would reasonably assume it was the one being accepted. const accept = vi.fn().mockReturnValue({ createdPlaceId: 5, createdJournalEntryId: null, bucketListItemId: null }); - const out = payload(makeMcp({ suggestions: { accept } }).acceptAsPlace( + const out = payload(await makeMcp({ suggestions: { accept } }).acceptAsPlace( { suggestionId: 9, tripId: 2, dayId: 4, name: 'Hafen', notes: 'windy', lat: 53.5, lng: 9.9 }, ctx, )); @@ -472,15 +472,15 @@ describe('DawarichMcp accept tools', () => { expect(out).toMatchObject({ createdPlaceId: 5 }); }); - it('DAWARICH-MCP-042: a bare accept sends only the target, so the service keeps its own defaults for the trip and the coordinates', () => { + it('DAWARICH-MCP-042: a bare accept sends only the target, so the service keeps its own defaults for the trip and the coordinates', async () => { const accept = vi.fn().mockReturnValue({ createdPlaceId: 1 }); - makeMcp({ suggestions: { accept } }).acceptAsPlace({ suggestionId: 9 }, ctx); + await makeMcp({ suggestions: { accept } }).acceptAsPlace({ suggestionId: 9 }, ctx); expect(accept).toHaveBeenCalledWith(7, 9, { target: 'place' }); }); - it('DAWARICH-MCP-043: accepting as a journal entry forwards target "journal" with the journey, the story and the corrected stamp', () => { + it('DAWARICH-MCP-043: accepting as a journal entry forwards target "journal" with the journey, the story and the corrected stamp', async () => { const accept = vi.fn().mockReturnValue({ createdJournalEntryId: 11 }); - const out = payload(makeMcp({ suggestions: { accept } }).acceptAsJournalEntry( + const out = payload(await makeMcp({ suggestions: { accept } }).acceptAsJournalEntry( { suggestionId: 9, journalId: 3, name: 'The harbour', notes: 'It rained.', date: '2026-05-01', time: '09:30' }, ctx, )); @@ -496,15 +496,15 @@ describe('DawarichMcp accept tools', () => { expect(out).toMatchObject({ createdJournalEntryId: 11 }); }); - it('DAWARICH-MCP-044: ticking off a wish names it explicitly when the caller did', () => { + it('DAWARICH-MCP-044: ticking off a wish names it explicitly when the caller did', async () => { const accept = vi.fn().mockReturnValue({ bucketListItemId: 8 }); - makeMcp({ suggestions: { accept } }).markBucketVisited({ suggestionId: 9, bucketListItemId: 8 }, ctx); + await makeMcp({ suggestions: { accept } }).markBucketVisited({ suggestionId: 9, bucketListItemId: 8 }, ctx); expect(accept).toHaveBeenCalledWith(7, 9, { target: 'bucket_list', bucketListItemId: 8 }); }); - it('DAWARICH-MCP-045: an omitted wish stays undefined rather than being guessed here, and the service falls back to the matched one', () => { + it('DAWARICH-MCP-045: an omitted wish stays undefined rather than being guessed here, and the service falls back to the matched one', async () => { const accept = vi.fn().mockReturnValue({ bucketListItemId: 2 }); - makeMcp({ suggestions: { accept } }).markBucketVisited({ suggestionId: 9 }, ctx); + await makeMcp({ suggestions: { accept } }).markBucketVisited({ suggestionId: 9 }, ctx); expect(accept).toHaveBeenCalledWith(7, 9, { target: 'bucket_list', bucketListItemId: undefined }); }); @@ -515,9 +515,9 @@ describe('DawarichMcp accept tools', () => { ['trip_required', 'A trip is required to create a place', 400], ])( 'DAWARICH-MCP-046: the AcceptError %s comes back as a refusal an assistant can read (%s), not as an exception and not as the HTTP %i the REST half answers', - (code, message, status) => { + async (code, message, status) => { const accept = vi.fn(() => { throw new AcceptError(code, message, status); }); - const result = makeMcp({ suggestions: { accept } }).acceptAsPlace({ suggestionId: 9 }, ctx); + const result = await makeMcp({ suggestions: { accept } }).acceptAsPlace({ suggestionId: 9 }, ctx); expect(refusal(result)).toBe(message); // The status code is the REST half's vocabulary. MCP has isError and a @@ -527,46 +527,46 @@ describe('DawarichMcp accept tools', () => { }, ); - it('DAWARICH-MCP-047: the same shaping covers the journal and bucket-list tools, because run() is shared and a second copy would drift', () => { + it('DAWARICH-MCP-047: the same shaping covers the journal and bucket-list tools, because run() is shared and a second copy would drift', async () => { const accept = vi.fn(() => { throw new AcceptError('not_found', 'Bucket-list entry not found', 404); }); const mcp = makeMcp({ suggestions: { accept } }); - expect(refusal(mcp.acceptAsJournalEntry({ suggestionId: 9, journalId: 1 }, ctx))).toBe('Bucket-list entry not found'); - expect(refusal(mcp.markBucketVisited({ suggestionId: 9 }, ctx))).toBe('Bucket-list entry not found'); + expect(refusal(await mcp.acceptAsJournalEntry({ suggestionId: 9, journalId: 1 }, ctx))).toBe('Bucket-list entry not found'); + expect(refusal(await mcp.markBucketVisited({ suggestionId: 9 }, ctx))).toBe('Bucket-list entry not found'); }); - it('DAWARICH-MCP-048: anything that is not an AcceptError propagates, because a failed write must never be reported as a success', () => { + it('DAWARICH-MCP-048: anything that is not an AcceptError propagates, because a failed write must never be reported as a success', async () => { // A locked database, or a permission lookup that blew up, is not a refusal // the caller can act on. Flattening it into a text result would hand the // assistant a message with no isError flag on it to notice. const accept = vi.fn(() => { throw new Error('SQLITE_BUSY: database is locked'); }); - expect(() => makeMcp({ suggestions: { accept } }).acceptAsPlace({ suggestionId: 9 }, ctx)) - .toThrow('SQLITE_BUSY: database is locked'); + await expect(makeMcp({ suggestions: { accept } }).acceptAsPlace({ suggestionId: 9 }, ctx)) + .rejects.toThrow('SQLITE_BUSY: database is locked'); }); }); describe('DawarichMcp dismiss_dawarich_suggestion', () => { - it('DAWARICH-MCP-050: dismissed is the default, because that is what the tool is for', () => { + it('DAWARICH-MCP-050: dismissed is the default, because that is what the tool is for', async () => { const updated = { id: 4, state: 'dismissed' }; const setState = vi.fn().mockReturnValue(updated); - const out = payload(makeMcp({ suggestions: { setState } }).dismiss({ suggestionId: 4 }, ctx)); + const out = payload(await makeMcp({ suggestions: { setState } }).dismiss({ suggestionId: 4 }, ctx)); expect(setState).toHaveBeenCalledWith(7, 4, 'dismissed'); expect(out).toEqual({ suggestion: updated }); }); - it('DAWARICH-MCP-051: "new" puts a dismissed stay back into the review list', () => { + it('DAWARICH-MCP-051: "new" puts a dismissed stay back into the review list', async () => { const setState = vi.fn().mockReturnValue({ id: 4, state: 'new' }); - makeMcp({ suggestions: { setState } }).dismiss({ suggestionId: 4, state: 'new' }, ctx); + await makeMcp({ suggestions: { setState } }).dismiss({ suggestionId: 4, state: 'new' }, ctx); expect(setState).toHaveBeenCalledWith(7, 4, 'new'); }); - it("DAWARICH-MCP-052: somebody else's suggestion gets the answer a missing one gets: the service says null, the tool says not found", () => { + it("DAWARICH-MCP-052: somebody else's suggestion gets the answer a missing one gets: the service says null, the tool says not found", async () => { // The service scopes its lookup by user_id, so null covers both cases and // the tool must not separate them. A different message for "exists but is // not yours" would let a caller enumerate other people's suggestion ids. const setState = vi.fn().mockReturnValue(null); - expect(refusal(makeMcp({ suggestions: { setState } }).dismiss({ suggestionId: 999 }, ctx))) + expect(refusal(await makeMcp({ suggestions: { setState } }).dismiss({ suggestionId: 999 }, ctx))) .toBe('Suggestion not found'); }); }); diff --git a/server/tests/unit/nest/doc-sync/doc-sync.job.test.ts b/server/tests/unit/nest/doc-sync/doc-sync.job.test.ts index a1f4f91fd..ee3effefb 100644 --- a/server/tests/unit/nest/doc-sync/doc-sync.job.test.ts +++ b/server/tests/unit/nest/doc-sync/doc-sync.job.test.ts @@ -42,6 +42,7 @@ import type { RealtimeService } from '../../../../src/nest/realtime/realtime.ser import { createTables } from '../../../../src/db/schema'; import { runMigrations } from '../../../../src/db/migrations'; import { createTrip, createUser } from '../../../helpers/factories'; +import { createTestUnitOfWork } from '../../../helpers/test-uow'; const link = (id: number): LinkRow => ({ id, provider_id: 'paperless' } as LinkRow); @@ -421,12 +422,9 @@ describe('DocSyncJob and a provider switched off in the admin panel', () => { const addons = { isAddonEnabled: vi.fn(() => true) } as unknown as AddonsService; const registry = new DocumentProviderRegistry([paperless, nextcloud] as unknown as DocumentProvider[]); const config = new DocSyncConfigService(dbs, registry); - const service = new DocSyncService( - dbs, config, registry, - {} as StorageService, {} as FilesService, new AllowedFileTypesService(dbs), - { broadcast: vi.fn() } as unknown as RealtimeService, - addons, - ); + // Built in beforeAll: DocSyncService now takes a UnitOfWork, and + // createTestUnitOfWork is async — module-scope construction cannot await it. + let service: DocSyncService; const registrar = { isEnabled: () => false } as unknown as CronRegistrarService; /** A job of its own per pass, so the interval never decides whether a tick runs. */ const tick = () => new DocSyncJob(dbs, service, config, addons, registrar).tick(); @@ -435,7 +433,14 @@ describe('DocSyncJob and a provider switched off in the admin panel', () => { testDb.prepare('UPDATE document_providers SET enabled = ? WHERE id = ?').run(on ? 1 : 0, id); const linkRow = (id: number) => testDb.prepare('SELECT * FROM trip_document_links WHERE id = ?').get(id); - beforeAll(() => { + beforeAll(async () => { + service = new DocSyncService( + dbs, config, registry, + {} as StorageService, {} as FilesService, new AllowedFileTypesService(dbs), + { broadcast: vi.fn() } as unknown as RealtimeService, + addons, + await createTestUnitOfWork(testDb), + ); createTables(testDb); runMigrations(testDb); const ownerId = createUser(testDb, { username: 'owner', email: 'owner@docsync-job.test' }).user.id; diff --git a/server/tests/unit/nest/doc-sync/doc-sync.mcp.test.ts b/server/tests/unit/nest/doc-sync/doc-sync.mcp.test.ts index 983bc9b54..23cd5f052 100644 --- a/server/tests/unit/nest/doc-sync/doc-sync.mcp.test.ts +++ b/server/tests/unit/nest/doc-sync/doc-sync.mcp.test.ts @@ -166,7 +166,7 @@ describe('DocSyncMcp access', () => { it('does not reach the sync service at all for a trip the user cannot see', async () => { const { mcp, sync, config } = makeMcp({ access: false, links: [link(1)] }); await mcp.syncNow({ tripId: 3 }, ctx); - mcp.listIssues({ tripId: 3 }, ctx); + await mcp.listIssues({ tripId: 3 }, ctx); await mcp.getTripDocumentSync({ tripId: 3 }, ctx); expect(sync.syncLink).not.toHaveBeenCalled(); expect(sync.status).not.toHaveBeenCalled(); @@ -185,24 +185,24 @@ describe('get_trip_document_sync', () => { }); describe('list_trip_document_sync_issues', () => { - it('says the trip is not configured rather than answering with an empty list', () => { + it('says the trip is not configured rather than answering with an empty list', async () => { // An empty list and no connection at all read identically to an assistant, // and "everything is in step" is the wrong answer to give about documents // that were never being synced. const { mcp, sync } = makeMcp({ links: [] }); - expect(payload(mcp.listIssues({ tripId: 3 }, ctx))).toEqual({ configured: false, issues: [] }); + expect(payload(await mcp.listIssues({ tripId: 3 }, ctx))).toEqual({ configured: false, issues: [] }); expect(sync.issues).not.toHaveBeenCalled(); }); - it('reports an empty list for a configured trip with nothing to decide', () => { + it('reports an empty list for a configured trip with nothing to decide', async () => { const { mcp } = makeMcp({ links: [link(1)], issues: [] }); - expect(payload(mcp.listIssues({ tripId: 3 }, ctx))).toEqual({ configured: true, issues: [] }); + expect(payload(await mcp.listIssues({ tripId: 3 }, ctx))).toEqual({ configured: true, issues: [] }); }); - it('passes the open issues through for a configured trip', () => { + it('passes the open issues through for a configured trip', async () => { const issues = [{ id: 9, state: 'conflict', remote_name: 'boarding.pdf' }]; const { mcp, sync } = makeMcp({ links: [link(1)], issues }); - expect(payload(mcp.listIssues({ tripId: 3 }, ctx))).toEqual({ configured: true, issues }); + expect(payload(await mcp.listIssues({ tripId: 3 }, ctx))).toEqual({ configured: true, issues }); expect(sync.issues).toHaveBeenCalledWith(3); }); }); diff --git a/server/tests/unit/nest/doc-sync/doc-sync.service.test.ts b/server/tests/unit/nest/doc-sync/doc-sync.service.test.ts index 8254f6818..444027b0b 100644 --- a/server/tests/unit/nest/doc-sync/doc-sync.service.test.ts +++ b/server/tests/unit/nest/doc-sync/doc-sync.service.test.ts @@ -70,6 +70,7 @@ import type { AddonsService } from '../../../../src/nest/addons/addons.service'; import type { FilesService } from '../../../../src/nest/files/files.service'; import type { StorageService } from '../../../../src/nest/storage/storage.service'; import type { RealtimeService } from '../../../../src/nest/realtime/realtime.service'; +import { createTestUnitOfWork } from '../../../helpers/test-uow'; // ── Fixtures ───────────────────────────────────────────────────────────────── @@ -303,7 +304,7 @@ const switchProvider = (id: string, on: boolean) => // ── Suite ──────────────────────────────────────────────────────────────────── describe('DocSyncService', () => { - beforeAll(() => { + beforeAll(async () => { createTables(testDb); runMigrations(testDb); spoolDir = fs.mkdtempSync(path.join(os.tmpdir(), 'trek-docsync-')); @@ -330,6 +331,7 @@ describe('DocSyncService', () => { new AllowedFileTypesService(dbs), realtime, addons as unknown as AddonsService, + await createTestUnitOfWork(testDb), ); }); diff --git a/server/tests/unit/nest/files.controller.test.ts b/server/tests/unit/nest/files.controller.test.ts index b419374d1..ba320ada2 100644 --- a/server/tests/unit/nest/files.controller.test.ts +++ b/server/tests/unit/nest/files.controller.test.ts @@ -59,9 +59,9 @@ const trip = { id: 5, user_id: 42 } as never; // The 404 "Trip not found" cases moved to trip-access.guard.test.ts with the check. describe('FilesController (parity with the legacy /api/trips/:tripId/files route)', () => { - it('GET / lists with the trash flag', () => { + it('GET / lists with the trash flag', async () => { const listFiles = vi.fn().mockReturnValue([{ id: 1 }]); - expect(fc(fsvc({ listFiles } as Partial)).list(user, trip, '5', 'true')).toEqual({ files: [{ id: 1 }] }); + expect(await fc(fsvc({ listFiles } as Partial)).list(user, trip, '5', 'true')).toEqual({ files: [{ id: 1 }] }); expect(listFiles).toHaveBeenCalledWith('5', true); }); @@ -218,7 +218,7 @@ describe('FilesController (parity with the legacy /api/trips/:tripId/files route expect(await fc(fsvc({ getFileById: vi.fn().mockReturnValue({ id: 9 }), deleteFileLink } as Partial)).unlink(user, trip, '5', '9', '3')).toEqual({ success: true }); expect(deleteFileLink).toHaveBeenCalledWith('3', '9'); const s = fsvc({ getFileById: vi.fn().mockReturnValue({ id: 9 }), getFileLinks: vi.fn().mockReturnValue([{ id: 1 }]) } as Partial); - expect(fc(s).links(user, trip, '5', '9')).toEqual({ links: [{ id: 1 }] }); + expect(await fc(s).links(user, trip, '5', '9')).toEqual({ links: [{ id: 1 }] }); }); it('the link routes resolve the file against :tripId, so a foreign file is 404', async () => { @@ -227,8 +227,7 @@ describe('FilesController (parity with the legacy /api/trips/:tripId/files route expect(await rejected(fc(unlinkSvc).unlink(user, trip, '5', '9', '3'))).toEqual({ status: 404, body: { error: 'File not found' } }); expect(unlinkSvc.deleteFileLink).not.toHaveBeenCalled(); const listSvc = foreign(); - // `links` is still a synchronous handler, so it throws rather than rejecting. - expect(thrown(() => fc(listSvc).links(user, trip, '5', '9'))).toEqual({ status: 404, body: { error: 'File not found' } }); + expect(await rejected(fc(listSvc).links(user, trip, '5', '9'))).toEqual({ status: 404, body: { error: 'File not found' } }); expect(listSvc.getFileLinks).not.toHaveBeenCalled(); }); diff --git a/server/tests/unit/nest/files.service.test.ts b/server/tests/unit/nest/files.service.test.ts index eccb6f9d7..210bc9c32 100644 --- a/server/tests/unit/nest/files.service.test.ts +++ b/server/tests/unit/nest/files.service.test.ts @@ -93,7 +93,7 @@ afterEach(() => { vi.restoreAllMocks(); }); -afterAll(() => { +afterAll(async () => { testDb.close(); bareDb.close(); }); @@ -104,8 +104,8 @@ function seedTrip() { return { user, trip }; } -function makeFile(tripId: number, userId: number, overrides: Partial<{ filename: string; originalname: string; size: number; mimetype: string }> = {}, opts: Parameters[3] = {}) { - return svc.createFile( +async function makeFile(tripId: number, userId: number, overrides: Partial<{ filename: string; originalname: string; size: number; mimetype: string }> = {}, opts: Parameters[3] = {}) { + return await svc.createFile( tripId, { filename: 'stored-name.pdf', originalname: 'visa.pdf', size: 1234, mimetype: 'application/pdf', ...overrides }, userId, @@ -166,55 +166,55 @@ describe('files.constants', () => { // against the single remaining query owner. describe('AllowedFileTypesService.get', () => { - it('FILE-SVC-006: returns the app_settings value when set', () => { + it('FILE-SVC-006: returns the app_settings value when set', async () => { setAppSetting(testDb, 'allowed_file_types', 'pdf,txt'); - expect(new AllowedFileTypesService(new DatabaseService(testDb)).get()).toBe('pdf,txt'); + expect(await new AllowedFileTypesService(new DatabaseService(testDb)).get()).toBe('pdf,txt'); }); - it('FILE-SVC-007: returns the default when the row is absent', () => { - expect(new AllowedFileTypesService(new DatabaseService(testDb)).get()).toBe(DEFAULT_ALLOWED_EXTENSIONS); + it('FILE-SVC-007: returns the default when the row is absent', async () => { + expect(await new AllowedFileTypesService(new DatabaseService(testDb)).get()).toBe(DEFAULT_ALLOWED_EXTENSIONS); }); - it('FILE-SVC-008: returns the default for an empty value (|| coercion, not ??)', () => { + it('FILE-SVC-008: returns the default for an empty value (|| coercion, not ??)', async () => { setAppSetting(testDb, 'allowed_file_types', ''); - expect(new AllowedFileTypesService(new DatabaseService(testDb)).get()).toBe(DEFAULT_ALLOWED_EXTENSIONS); + expect(await new AllowedFileTypesService(new DatabaseService(testDb)).get()).toBe(DEFAULT_ALLOWED_EXTENSIONS); }); - it('FILE-SVC-009: returns the default when the query throws (no app_settings table)', () => { - expect(new AllowedFileTypesService(new DatabaseService(bareDb)).get()).toBe(DEFAULT_ALLOWED_EXTENSIONS); + it('FILE-SVC-009: returns the default when the query throws (no app_settings table)', async () => { + expect(await new AllowedFileTypesService(new DatabaseService(bareDb)).get()).toBe(DEFAULT_ALLOWED_EXTENSIONS); }); }); // ── trip-scoped reads ───────────────────────────────────────────────────────── describe('getFileById / getDeletedFile', () => { - it('FILE-SVC-010: getFileById is trip-scoped', () => { + it('FILE-SVC-010: getFileById is trip-scoped', async () => { const { user, trip } = seedTrip(); const other = createTrip(testDb, user.id); - const file = makeFile(trip.id, user.id); - expect(svc.getFileById(file.id, trip.id)?.id).toBe(file.id); - expect(svc.getFileById(file.id, other.id)).toBeUndefined(); + const file = await makeFile(trip.id, user.id); + expect((await svc.getFileById(file.id, trip.id))?.id).toBe(file.id); + expect(await svc.getFileById(file.id, other.id)).toBeUndefined(); }); - it('FILE-SVC-011: getDeletedFile returns only soft-deleted rows', () => { + it('FILE-SVC-011: getDeletedFile returns only soft-deleted rows', async () => { const { user, trip } = seedTrip(); - const file = makeFile(trip.id, user.id); - expect(svc.getDeletedFile(file.id, trip.id)).toBeUndefined(); - svc.softDeleteFile(file.id); - expect(svc.getDeletedFile(file.id, trip.id)?.id).toBe(file.id); + const file = await makeFile(trip.id, user.id); + expect(await svc.getDeletedFile(file.id, trip.id)).toBeUndefined(); + await svc.softDeleteFile(file.id); + expect((await svc.getDeletedFile(file.id, trip.id))?.id).toBe(file.id); }); }); describe('listFiles', () => { - it('FILE-SVC-012: active list excludes trash, is starred-first and carries the formatted shape', () => { + it('FILE-SVC-012: active list excludes trash, is starred-first and carries the formatted shape', async () => { const { user, trip } = seedTrip(); - makeFile(trip.id, user.id, { originalname: 'plain.pdf' }); - const starred = makeFile(trip.id, user.id, { originalname: 'starred.pdf' }); - const trashed = makeFile(trip.id, user.id, { originalname: 'gone.pdf' }); - svc.toggleStarred(starred.id, 0); - svc.softDeleteFile(trashed.id); + await makeFile(trip.id, user.id, { originalname: 'plain.pdf' }); + const starred = await makeFile(trip.id, user.id, { originalname: 'starred.pdf' }); + const trashed = await makeFile(trip.id, user.id, { originalname: 'gone.pdf' }); + await svc.toggleStarred(starred.id, 0); + await svc.softDeleteFile(trashed.id); - const files = svc.listFiles(trip.id, false) as Record[]; + const files = await svc.listFiles(trip.id, false) as Record[]; expect(files.map((f) => f.id)).toHaveLength(2); expect(files[0].id).toBe(starred.id); // ORDER BY f.starred DESC first expect(files.map((f) => f.id)).not.toContain(trashed.id); @@ -223,27 +223,27 @@ describe('listFiles', () => { expect(files[0]).toHaveProperty('uploaded_by_avatar'); }); - it('FILE-SVC-013: trash list returns only soft-deleted files', () => { + it('FILE-SVC-013: trash list returns only soft-deleted files', async () => { const { user, trip } = seedTrip(); - const kept = makeFile(trip.id, user.id); - const trashed = makeFile(trip.id, user.id); - svc.softDeleteFile(trashed.id); + const kept = await makeFile(trip.id, user.id); + const trashed = await makeFile(trip.id, user.id); + await svc.softDeleteFile(trashed.id); - const trash = svc.listFiles(trip.id, true) as Record[]; + const trash = await svc.listFiles(trip.id, true) as Record[]; expect(trash.map((f) => f.id)).toEqual([trashed.id]); expect(trash.map((f) => f.id)).not.toContain(kept.id); }); - it('FILE-SVC-014: batches file_links and filters null targets by truthiness', () => { + it('FILE-SVC-014: batches file_links and filters null targets by truthiness', async () => { const { user, trip } = seedTrip(); const reservation = createReservation(testDb, trip.id); const place = createPlace(testDb, trip.id); - const linked = makeFile(trip.id, user.id); - const bare = makeFile(trip.id, user.id); - svc.createFileLink(linked.id, { reservation_id: reservation.id }); - svc.createFileLink(linked.id, { place_id: place.id }); + const linked = await makeFile(trip.id, user.id); + const bare = await makeFile(trip.id, user.id); + await svc.createFileLink(linked.id, { reservation_id: reservation.id }); + await svc.createFileLink(linked.id, { place_id: place.id }); - const files = svc.listFiles(trip.id, false) as Record[]; + const files = await svc.listFiles(trip.id, false) as Record[]; const linkedRow = files.find((f) => f.id === linked.id); const bareRow = files.find((f) => f.id === bare.id); expect(linkedRow.linked_reservation_ids).toEqual([reservation.id]); @@ -252,14 +252,14 @@ describe('listFiles', () => { expect(bareRow.linked_place_ids).toEqual([]); const item = Number(testDb.prepare('INSERT INTO budget_items (trip_id, name) VALUES (?, ?)').run(trip.id, 'Dinner').lastInsertRowid); - svc.createFileLink(linked.id, { budget_item_id: item }); - const withReceipt = (svc.listFiles(trip.id, false) as Record[]).find((f) => f.id === linked.id); + await svc.createFileLink(linked.id, { budget_item_id: item }); + const withReceipt = (await svc.listFiles(trip.id, false) as Record[]).find((f) => f.id === linked.id); expect(withReceipt.linked_budget_item_ids).toEqual([item]); - expect((svc.listFiles(trip.id, false) as Record[]).find((f) => f.id === bare.id).linked_budget_item_ids).toEqual([]); + expect((await svc.listFiles(trip.id, false) as Record[]).find((f) => f.id === bare.id).linked_budget_item_ids).toEqual([]); // The empty-trip guard skips the IN () batch entirely. const empty = createTrip(testDb, user.id); - expect(svc.listFiles(empty.id, false)).toEqual([]); + expect(await svc.listFiles(empty.id, false)).toEqual([]); }); }); @@ -270,41 +270,41 @@ describe('budget receipts', () => { return Number(testDb.prepare('INSERT INTO budget_items (trip_id, name) VALUES (?, ?)').run(tripId, 'Dinner').lastInsertRowid); } - it('FILE-SVC-040: an upload naming an expense gets its link row straight away', () => { + it('FILE-SVC-040: an upload naming an expense gets its link row straight away', async () => { const { user, trip } = seedTrip(); const item = seedItem(trip.id); - const file = makeFile(trip.id, user.id, {}, { budget_item_id: item }); + const file = await makeFile(trip.id, user.id, {}, { budget_item_id: item }); expect(testDb.prepare('SELECT COUNT(*) c FROM file_links WHERE file_id = ? AND budget_item_id = ?').get(file.id, item)).toEqual({ c: 1 }); }); - it('FILE-SVC-041: an upload without one writes no link at all', () => { + it('FILE-SVC-041: an upload without one writes no link at all', async () => { const { user, trip } = seedTrip(); - const file = makeFile(trip.id, user.id); + const file = await makeFile(trip.id, user.id); expect(testDb.prepare('SELECT COUNT(*) c FROM file_links WHERE file_id = ?').get(file.id)).toEqual({ c: 0 }); }); - it('FILE-SVC-042: updateFile attaches to an expense and detaches on a falsy id', () => { + it('FILE-SVC-042: updateFile attaches to an expense and detaches on a falsy id', async () => { const { user, trip } = seedTrip(); const item = seedItem(trip.id); - const file = makeFile(trip.id, user.id); + const file = await makeFile(trip.id, user.id); - svc.updateFile(file.id, file, { budget_item_id: item }); + await svc.updateFile(file.id, file, { budget_item_id: item }); expect(testDb.prepare('SELECT COUNT(*) c FROM file_links WHERE file_id = ? AND budget_item_id = ?').get(file.id, item)).toEqual({ c: 1 }); // Sending it twice must not double the row. - svc.updateFile(file.id, file, { budget_item_id: item }); + await svc.updateFile(file.id, file, { budget_item_id: item }); expect(testDb.prepare('SELECT COUNT(*) c FROM file_links WHERE file_id = ?').get(file.id)).toEqual({ c: 1 }); - svc.updateFile(file.id, file, { budget_item_id: null }); + await svc.updateFile(file.id, file, { budget_item_id: null }); expect(testDb.prepare('SELECT COUNT(*) c FROM file_links WHERE file_id = ? AND budget_item_id IS NOT NULL').get(file.id)).toEqual({ c: 0 }); }); - it('FILE-SVC-043: leaving budget_item_id out touches no link', () => { + it('FILE-SVC-043: leaving budget_item_id out touches no link', async () => { const { user, trip } = seedTrip(); const item = seedItem(trip.id); - const file = makeFile(trip.id, user.id, {}, { budget_item_id: item }); + const file = await makeFile(trip.id, user.id, {}, { budget_item_id: item }); - svc.updateFile(file.id, file, { description: 'renamed' }); + await svc.updateFile(file.id, file, { description: 'renamed' }); expect(testDb.prepare('SELECT COUNT(*) c FROM file_links WHERE file_id = ? AND budget_item_id = ?').get(file.id, item)).toEqual({ c: 1 }); }); }); @@ -312,9 +312,9 @@ describe('budget receipts', () => { // ── createFile / updateFile / toggleStarred ─────────────────────────────────── describe('createFile', () => { - it('FILE-SVC-015: coerces falsy opts to NULL (|| null) and re-selects the formatted row', () => { + it('FILE-SVC-015: coerces falsy opts to NULL (|| null) and re-selects the formatted row', async () => { const { user, trip } = seedTrip(); - const file = makeFile(trip.id, user.id, {}, { place_id: '', reservation_id: undefined, description: '' }); + const file = await makeFile(trip.id, user.id, {}, { place_id: '', reservation_id: undefined, description: '' }); const row = testDb.prepare('SELECT * FROM trip_files WHERE id = ?').get(file.id) as Record; expect(row.place_id).toBeNull(); expect(row.reservation_id).toBeNull(); @@ -323,10 +323,10 @@ describe('createFile', () => { expect((file as unknown as Record).uploaded_by_name).toBe(user.username); }); - it('FILE-SVC-016: stores the provided metadata and links the reservation title through FILE_SELECT', () => { + it('FILE-SVC-016: stores the provided metadata and links the reservation title through FILE_SELECT', async () => { const { user, trip } = seedTrip(); const reservation = createReservation(testDb, trip.id, { title: 'Night train' }); - const file = makeFile(trip.id, user.id, { originalname: 'ticket.pdf', size: 99, mimetype: 'application/pdf' }, { + const file = await makeFile(trip.id, user.id, { originalname: 'ticket.pdf', size: 99, mimetype: 'application/pdf' }, { reservation_id: String(reservation.id), description: 'the booking', }); @@ -339,23 +339,23 @@ describe('createFile', () => { }); describe('updateFile', () => { - it('FILE-SVC-017: undefined fields keep the current values', () => { + it('FILE-SVC-017: undefined fields keep the current values', async () => { const { user, trip } = seedTrip(); const place = createPlace(testDb, trip.id); - const file = makeFile(trip.id, user.id, {}, { description: 'keep me', place_id: String(place.id) }); - const current = svc.getFileById(file.id, trip.id)!; - const updated = svc.updateFile(file.id, current, {}) as Record; + const file = await makeFile(trip.id, user.id, {}, { description: 'keep me', place_id: String(place.id) }); + const current = await svc.getFileById(file.id, trip.id)!; + const updated = await svc.updateFile(file.id, current, {}) as Record; expect(updated.description).toBe('keep me'); expect(updated.place_id).toBe(place.id); }); - it("FILE-SVC-018: '' clears every field (|| null) — description matches createFile's coercion", () => { + it("FILE-SVC-018: '' clears every field (|| null) — description matches createFile's coercion", async () => { const { user, trip } = seedTrip(); const place = createPlace(testDb, trip.id); const reservation = createReservation(testDb, trip.id); - const file = makeFile(trip.id, user.id, {}, { description: 'old', place_id: String(place.id), reservation_id: String(reservation.id) }); - const current = svc.getFileById(file.id, trip.id)!; - const updated = svc.updateFile(file.id, current, { description: '', place_id: '', reservation_id: null }) as Record; + const file = await makeFile(trip.id, user.id, {}, { description: 'old', place_id: String(place.id), reservation_id: String(reservation.id) }); + const current = await svc.getFileById(file.id, trip.id)!; + const updated = await svc.updateFile(file.id, current, { description: '', place_id: '', reservation_id: null }) as Record; expect(updated.description).toBeNull(); // '' → NULL on update too (post-migration fix: symmetric with createFile) expect(updated.place_id).toBeNull(); expect(updated.reservation_id).toBeNull(); @@ -363,28 +363,28 @@ describe('updateFile', () => { }); describe('toggleStarred / softDeleteFile / restoreFile', () => { - it('FILE-SVC-019: toggleStarred flips 0→1 and 1→0', () => { + it('FILE-SVC-019: toggleStarred flips 0→1 and 1→0', async () => { const { user, trip } = seedTrip(); - const file = makeFile(trip.id, user.id); - const on = svc.toggleStarred(file.id, 0); + const file = await makeFile(trip.id, user.id); + const on = await svc.toggleStarred(file.id, 0); expect(on.starred).toBe(1); - const off = svc.toggleStarred(file.id, on.starred); + const off = await svc.toggleStarred(file.id, on.starred); expect(off.starred).toBe(0); }); - it('FILE-SVC-020: softDeleteFile stamps deleted_at', () => { + it('FILE-SVC-020: softDeleteFile stamps deleted_at', async () => { const { user, trip } = seedTrip(); - const file = makeFile(trip.id, user.id); - svc.softDeleteFile(file.id); + const file = await makeFile(trip.id, user.id); + await svc.softDeleteFile(file.id); const row = testDb.prepare('SELECT deleted_at FROM trip_files WHERE id = ?').get(file.id) as Record; expect(row.deleted_at).not.toBeNull(); }); - it('FILE-SVC-021: restoreFile clears deleted_at and returns the formatted row', () => { + it('FILE-SVC-021: restoreFile clears deleted_at and returns the formatted row', async () => { const { user, trip } = seedTrip(); - const file = makeFile(trip.id, user.id); - svc.softDeleteFile(file.id); - const restored = svc.restoreFile(file.id) as Record; + const file = await makeFile(trip.id, user.id); + await svc.softDeleteFile(file.id); + const restored = await svc.restoreFile(file.id) as Record; expect(restored.deleted_at).toBeNull(); expect(restored.url).toBe(`/api/trips/${trip.id}/files/${file.id}/download`); }); @@ -395,46 +395,46 @@ describe('toggleStarred / softDeleteFile / restoreFile', () => { describe('permanentDeleteFile', () => { it('FILE-SVC-022: deletes the storage object (idempotent on missing), then the DB row', async () => { const { user, trip } = seedTrip(); - const file = makeFile(trip.id, user.id, { filename: 'on-disk.pdf' }); + const file = await makeFile(trip.id, user.id, { filename: 'on-disk.pdf' }); storageDelete.mockResolvedValue(undefined); - await svc.permanentDeleteFile(svc.getFileById(file.id, trip.id) as TripFile); + await svc.permanentDeleteFile(await svc.getFileById(file.id, trip.id) as TripFile); expect(storageDelete).toHaveBeenCalledWith('files', 'on-disk.pdf'); - expect(svc.getFileById(file.id, trip.id)).toBeUndefined(); + expect(await svc.getFileById(file.id, trip.id)).toBeUndefined(); }); it('FILE-SVC-023: a delete failure logs [files], rethrows and keeps the DB row', async () => { const { user, trip } = seedTrip(); - const file = makeFile(trip.id, user.id, { filename: 'stuck.pdf' }); + const file = await makeFile(trip.id, user.id, { filename: 'stuck.pdf' }); const boom = new Error('EACCES'); storageDelete.mockRejectedValue(boom); const err = vi.spyOn(console, 'error').mockImplementation(() => {}); - await expect(svc.permanentDeleteFile(svc.getFileById(file.id, trip.id) as TripFile)).rejects.toThrow('EACCES'); + await expect(svc.permanentDeleteFile(await svc.getFileById(file.id, trip.id) as TripFile)).rejects.toThrow('EACCES'); expect(err).toHaveBeenCalledWith('[files] unlink failed for stuck.pdf, keeping DB row:', boom); - expect(svc.getFileById(file.id, trip.id)).toBeDefined(); + expect(await svc.getFileById(file.id, trip.id)).toBeDefined(); }); }); describe('emptyTrash', () => { it('FILE-SVC-024: unlinks every trashed file and deletes their rows, returning the count', async () => { const { user, trip } = seedTrip(); - const a = makeFile(trip.id, user.id, { filename: 'a.pdf' }); - const b = makeFile(trip.id, user.id, { filename: 'b.pdf' }); - const kept = makeFile(trip.id, user.id, { filename: 'kept.pdf' }); - svc.softDeleteFile(a.id); - svc.softDeleteFile(b.id); + const a = await makeFile(trip.id, user.id, { filename: 'a.pdf' }); + const b = await makeFile(trip.id, user.id, { filename: 'b.pdf' }); + const kept = await makeFile(trip.id, user.id, { filename: 'kept.pdf' }); + await svc.softDeleteFile(a.id); + await svc.softDeleteFile(b.id); storageDelete.mockResolvedValue(undefined); await expect(svc.emptyTrash(trip.id)).resolves.toBe(2); - expect(svc.listFiles(trip.id, true)).toEqual([]); - expect(svc.getFileById(kept.id, trip.id)).toBeDefined(); + expect(await svc.listFiles(trip.id, true)).toEqual([]); + expect(await svc.getFileById(kept.id, trip.id)).toBeDefined(); }); it('FILE-SVC-025: a partial unlink failure keeps the failed row, swallows the error and counts only successes', async () => { const { user, trip } = seedTrip(); - const good = makeFile(trip.id, user.id, { filename: 'good.pdf' }); - const bad = makeFile(trip.id, user.id, { filename: 'bad.pdf' }); - svc.softDeleteFile(good.id); - svc.softDeleteFile(bad.id); + const good = await makeFile(trip.id, user.id, { filename: 'good.pdf' }); + const bad = await makeFile(trip.id, user.id, { filename: 'bad.pdf' }); + await svc.softDeleteFile(good.id); + await svc.softDeleteFile(bad.id); const boom = new Error('EBUSY'); storageDelete.mockImplementation((_category: string, name: string) => name.includes('bad.pdf') ? Promise.reject(boom) : Promise.resolve() @@ -443,8 +443,8 @@ describe('emptyTrash', () => { await expect(svc.emptyTrash(trip.id)).resolves.toBe(1); expect(err).toHaveBeenCalledWith('[files] unlink failed for bad.pdf, keeping DB row:', boom); - expect(svc.getFileById(good.id, trip.id)).toBeUndefined(); - expect(svc.getDeletedFile(bad.id, trip.id)).toBeDefined(); + expect(await svc.getFileById(good.id, trip.id)).toBeUndefined(); + expect(await svc.getDeletedFile(bad.id, trip.id)).toBeDefined(); }); it('FILE-SVC-026: an empty trash resolves to 0 without touching storage', async () => { @@ -464,7 +464,7 @@ describe('findForeignLinkTarget', () => { return { user, mine, foreign }; } - it('FILE-SVC-027: flags each foreign target kind and passes same-trip ids', () => { + it('FILE-SVC-027: flags each foreign target kind and passes same-trip ids', async () => { const { mine, foreign } = twoTrips(); const foreignRes = createReservation(testDb, foreign.id); const foreignPlace = createPlace(testDb, foreign.id); @@ -472,27 +472,27 @@ describe('findForeignLinkTarget', () => { const foreignAssignment = createDayAssignment(testDb, foreignDay.id, createPlace(testDb, foreign.id).id); const myRes = createReservation(testDb, mine.id); - expect(svc.findForeignLinkTarget(mine.id, { reservation_id: foreignRes.id })).toBe('reservation_id'); - expect(svc.findForeignLinkTarget(mine.id, { place_id: foreignPlace.id })).toBe('place_id'); - expect(svc.findForeignLinkTarget(mine.id, { assignment_id: foreignAssignment.id })).toBe('assignment_id'); - expect(svc.findForeignLinkTarget(mine.id, { reservation_id: myRes.id })).toBeNull(); + expect(await svc.findForeignLinkTarget(mine.id, { reservation_id: foreignRes.id })).toBe('reservation_id'); + expect(await svc.findForeignLinkTarget(mine.id, { place_id: foreignPlace.id })).toBe('place_id'); + expect(await svc.findForeignLinkTarget(mine.id, { assignment_id: foreignAssignment.id })).toBe('assignment_id'); + expect(await svc.findForeignLinkTarget(mine.id, { reservation_id: myRes.id })).toBeNull(); // A receipt may only point at an expense on the same trip. const foreignItem = Number(testDb.prepare('INSERT INTO budget_items (trip_id, name) VALUES (?, ?)').run(foreign.id, 'Foreign').lastInsertRowid); const myItem = Number(testDb.prepare('INSERT INTO budget_items (trip_id, name) VALUES (?, ?)').run(mine.id, 'Mine').lastInsertRowid); - expect(svc.findForeignLinkTarget(mine.id, { budget_item_id: foreignItem })).toBe('budget_item_id'); - expect(svc.findForeignLinkTarget(mine.id, { budget_item_id: myItem })).toBeNull(); + expect(await svc.findForeignLinkTarget(mine.id, { budget_item_id: foreignItem })).toBe('budget_item_id'); + expect(await svc.findForeignLinkTarget(mine.id, { budget_item_id: myItem })).toBeNull(); }); - it('FILE-SVC-028: falsy ids are skipped (they clear the link) and reservation is checked first', () => { + it('FILE-SVC-028: falsy ids are skipped (they clear the link) and reservation is checked first', async () => { const { mine, foreign } = twoTrips(); const foreignRes = createReservation(testDb, foreign.id); const foreignPlace = createPlace(testDb, foreign.id); - expect(svc.findForeignLinkTarget(mine.id, { reservation_id: 0, place_id: null, assignment_id: undefined })).toBeNull(); - expect(svc.findForeignLinkTarget(mine.id, { reservation_id: '' })).toBeNull(); + expect(await svc.findForeignLinkTarget(mine.id, { reservation_id: 0, place_id: null, assignment_id: undefined })).toBeNull(); + expect(await svc.findForeignLinkTarget(mine.id, { reservation_id: '' })).toBeNull(); // Both foreign — the reservation check runs before the place check. - expect(svc.findForeignLinkTarget(mine.id, { reservation_id: foreignRes.id, place_id: foreignPlace.id })).toBe('reservation_id'); + expect(await svc.findForeignLinkTarget(mine.id, { reservation_id: foreignRes.id, place_id: foreignPlace.id })).toBe('reservation_id'); }); }); @@ -511,89 +511,89 @@ type FileLinkRow = { }; describe('createFileLink / deleteFileLink / getFileLinks', () => { - it('FILE-SVC-029: inserts with || null coercion, dedupes via INSERT OR IGNORE and returns the re-select', () => { + it('FILE-SVC-029: inserts with || null coercion, dedupes via INSERT OR IGNORE and returns the re-select', async () => { const { user, trip } = seedTrip(); const reservation = createReservation(testDb, trip.id, { title: 'Ferry' }); - const file = makeFile(trip.id, user.id); + const file = await makeFile(trip.id, user.id); - const links = svc.createFileLink(file.id, { reservation_id: reservation.id, place_id: '' }) as FileLinkRow[]; + const links = await 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 FileLinkRow[]; + const again = await 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 FileLinkRow[]; + const hydrated = await svc.getFileLinks(file.id) as FileLinkRow[]; expect(hydrated[0].reservation_title).toBe('Ferry'); }); - it('FILE-SVC-030: an insert error propagates (post-migration fix: no silent swallow)', () => { + it('FILE-SVC-030: an insert error propagates (post-migration fix: no silent swallow)', async () => { const { user, trip } = seedTrip(); - const file = makeFile(trip.id, user.id); - expect(() => svc.createFileLink(file.id, { reservation_id: { bad: true } as never })).toThrow(); + const file = await makeFile(trip.id, user.id); + await expect(svc.createFileLink(file.id, { reservation_id: { bad: true } as never })).rejects.toThrow(); }); - it('FILE-SVC-031: deleteFileLink is scoped to (id AND file_id)', () => { + it('FILE-SVC-031: deleteFileLink is scoped to (id AND file_id)', async () => { const { user, trip } = seedTrip(); 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 FileLinkRow[]; + const file = await makeFile(trip.id, user.id); + const other = await makeFile(trip.id, user.id); + const [link] = await 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); - svc.deleteFileLink(link.id, file.id); - expect(svc.getFileLinks(file.id)).toHaveLength(0); + await svc.deleteFileLink(link.id, other.id); // wrong file — no-op + expect(await svc.getFileLinks(file.id)).toHaveLength(1); + await svc.deleteFileLink(link.id, file.id); + expect(await svc.getFileLinks(file.id)).toHaveLength(0); }); }); // ── download auth & glue ────────────────────────────────────────────────────── describe('authenticateDownload', () => { - it('FILE-SVC-032: a valid session cookie wins over a bearer token', () => { + it('FILE-SVC-032: a valid session cookie wins over a bearer token', async () => { verifyJwtAndLoadUser.mockReturnValue({ id: 42 }); - const result = svc.authenticateDownload(req({ cookie: 'cookie-jwt', bearer: 'bearer-jwt' })); + const result = await svc.authenticateDownload(req({ cookie: 'cookie-jwt', bearer: 'bearer-jwt' })); expect(result).toEqual({ userId: 42 }); expect(verifyJwtAndLoadUser).toHaveBeenCalledWith('cookie-jwt'); }); - it('FILE-SVC-033: a bearer token is used when no cookie is present; invalid JWTs 401', () => { + it('FILE-SVC-033: a bearer token is used when no cookie is present; invalid JWTs 401', async () => { verifyJwtAndLoadUser.mockReturnValue({ id: 7 }); - expect(svc.authenticateDownload(req({ bearer: 'bearer-jwt' }))).toEqual({ userId: 7 }); + expect(await svc.authenticateDownload(req({ bearer: 'bearer-jwt' }))).toEqual({ userId: 7 }); expect(verifyJwtAndLoadUser).toHaveBeenCalledWith('bearer-jwt'); verifyJwtAndLoadUser.mockReturnValue(null); - expect(svc.authenticateDownload(req({ bearer: 'stale' }))).toEqual({ error: 'Invalid or expired token', status: 401 }); + expect(await svc.authenticateDownload(req({ bearer: 'stale' }))).toEqual({ error: 'Invalid or expired token', status: 401 }); }); - it('FILE-SVC-034: a ?token= ephemeral token is consumed with the download purpose', () => { + it('FILE-SVC-034: a ?token= ephemeral token is consumed with the download purpose', async () => { consumeEphemeralToken.mockReturnValue(9); - expect(svc.authenticateDownload(req({ token: 'eph' }))).toEqual({ userId: 9 }); + expect(await svc.authenticateDownload(req({ token: 'eph' }))).toEqual({ userId: 9 }); expect(consumeEphemeralToken).toHaveBeenCalledWith('eph', 'download'); consumeEphemeralToken.mockReturnValue(null); - expect(svc.authenticateDownload(req({ token: 'spent' }))).toEqual({ error: 'Invalid or expired token', status: 401 }); + expect(await svc.authenticateDownload(req({ token: 'spent' }))).toEqual({ error: 'Invalid or expired token', status: 401 }); }); - it('FILE-SVC-035: no credentials at all is a 401 Authentication required', () => { - expect(svc.authenticateDownload(req({}))).toEqual({ error: 'Authentication required', status: 401 }); + it('FILE-SVC-035: no credentials at all is a 401 Authentication required', async () => { + expect(await svc.authenticateDownload(req({}))).toEqual({ error: 'Authentication required', status: 401 }); expect(verifyJwtAndLoadUser).not.toHaveBeenCalled(); expect(consumeEphemeralToken).not.toHaveBeenCalled(); }); }); describe('verifyTripAccess / can / files.bridge', () => { - it('FILE-SVC-036: verifyTripAccess resolves owner and member, refuses strangers', () => { + it('FILE-SVC-036: verifyTripAccess resolves owner and member, refuses strangers', async () => { const { user: owner } = createUser(testDb); const { user: member } = createUser(testDb); const { user: stranger } = createUser(testDb); const trip = createTrip(testDb, owner.id); addTripMember(testDb, trip.id, member.id); - expect(svc.verifyTripAccess(trip.id, owner.id)?.id).toBe(trip.id); - expect(svc.verifyTripAccess(trip.id, member.id)).toBeDefined(); - expect(svc.verifyTripAccess(trip.id, stranger.id)).toBeFalsy(); + expect((await svc.verifyTripAccess(trip.id, owner.id))?.id).toBe(trip.id); + expect(await svc.verifyTripAccess(trip.id, member.id)).toBeDefined(); + expect(await svc.verifyTripAccess(trip.id, stranger.id)).toBeFalsy(); }); it('FILE-SVC-037: can() forwards to checkPermission with the shared-trip flag', async () => { @@ -611,13 +611,13 @@ describe('verifyTripAccess / can / files.bridge', () => { expect(checkPermission).toHaveBeenLastCalledWith('file_upload', 'user', user.id, user.id, false); }); - it('FILE-SVC-038: the allowed-extension list is a live read, not a boot snapshot', () => { + it('FILE-SVC-038: the allowed-extension list is a live read, not a boot snapshot', async () => { // Was asserted against files.bridge, which built its own FilesService. The // list moved to a leaf service the multer factories inject, and the property // that matters is unchanged: an admin editing the list in settings applies // to the next upload, with no invalidation wiring. const allowed = new AllowedFileTypesService(new DatabaseService(testDb)); setAppSetting(testDb, 'allowed_file_types', 'md,markdown'); - expect(allowed.get()).toBe('md,markdown'); + expect(await allowed.get()).toBe('md,markdown'); }); }); diff --git a/server/tests/unit/nest/mfa-policy.guard.test.ts b/server/tests/unit/nest/mfa-policy.guard.test.ts index d555ace87..4b25ebac4 100644 --- a/server/tests/unit/nest/mfa-policy.guard.test.ts +++ b/server/tests/unit/nest/mfa-policy.guard.test.ts @@ -54,9 +54,9 @@ function ctx(request: Record) { } as never; } -const thrown = (run: () => unknown) => { +const thrown = async (run: () => unknown) => { try { - run(); + await run(); } catch (err) { expect(err).toBeInstanceOf(HttpException); const e = err as HttpException; @@ -68,9 +68,9 @@ const thrown = (run: () => unknown) => { const ENFORCED: Rows = { requireMfa: 'true', mfaEnabled: 0 }; describe('MfaPolicyGuard', () => { - it('MFA-001: refuses a user without MFA while the policy is on, with the legacy body', () => { + it('MFA-001: refuses a user without MFA while the policy is on, with the legacy body', async () => { const { guard } = makeGuard(ENFORCED); - expect(thrown(() => guard.canActivate(ctx({ user })))).toEqual({ + expect(await thrown(() => guard.canActivate(ctx({ user })))).toEqual({ status: 403, body: { error: 'Two-factor authentication is required. Complete setup in Settings.', @@ -79,68 +79,68 @@ describe('MfaPolicyGuard', () => { }); }); - it('MFA-002: lets a user with TOTP through', () => { + it('MFA-002: lets a user with TOTP through', async () => { const { guard } = makeGuard({ requireMfa: 'true', mfaEnabled: 1 }); - expect(guard.canActivate(ctx({ user }))).toBe(true); + expect(await guard.canActivate(ctx({ user }))).toBe(true); }); - it('MFA-003: a user-verified passkey satisfies the policy like TOTP does', () => { + it('MFA-003: a user-verified passkey satisfies the policy like TOTP does', async () => { const { guard } = makeGuard({ requireMfa: 'true', mfaEnabled: 0, hasPasskey: true }); - expect(guard.canActivate(ctx({ user }))).toBe(true); + expect(await guard.canActivate(ctx({ user }))).toBe(true); }); - it('MFA-004: does nothing when the policy is off, and never reads the users row', () => { + it('MFA-004: does nothing when the policy is off, and never reads the users row', async () => { const { guard, get } = makeGuard({ requireMfa: 'false', mfaEnabled: 0 }); - expect(guard.canActivate(ctx({ user }))).toBe(true); + expect(await guard.canActivate(ctx({ user }))).toBe(true); expect(get.mock.calls.some((c) => String(c[0]).includes('FROM users'))).toBe(false); }); - it('MFA-005: does nothing when the setting row is absent at all', () => { + it('MFA-005: does nothing when the setting row is absent at all', async () => { const { guard } = makeGuard({ mfaEnabled: 0 }); - expect(guard.canActivate(ctx({ user }))).toBe(true); + expect(await guard.canActivate(ctx({ user }))).toBe(true); }); - it('MFA-006: an anonymous request is not the policy business', () => { + it('MFA-006: an anonymous request is not the policy business', async () => { const { guard, get } = makeGuard(ENFORCED); - expect(guard.canActivate(ctx({}))).toBe(true); + expect(await guard.canActivate(ctx({}))).toBe(true); expect(get).not.toHaveBeenCalled(); }); - it('MFA-007: the demo account is exempt while demo mode is on', () => { + it('MFA-007: the demo account is exempt while demo mode is on', async () => { const demoUser = { id: 1, email: DEMO_EMAIL_PRIMARY, role: 'user' } as User; - expect(makeGuard(ENFORCED, {}, true).guard.canActivate(ctx({ user: demoUser }))).toBe(true); + expect(await makeGuard(ENFORCED, {}, true).guard.canActivate(ctx({ user: demoUser }))).toBe(true); // …and not otherwise. - expect(thrown(() => makeGuard(ENFORCED, {}, false).guard.canActivate(ctx({ user: demoUser })))).toEqual({ + expect(await thrown(() => makeGuard(ENFORCED, {}, false).guard.canActivate(ctx({ user: demoUser })))).toEqual({ status: 403, body: expect.objectContaining({ code: 'MFA_REQUIRED' }), }); }); - it('MFA-008: a user row that vanished mid-request is let through, not 403d', () => { + it('MFA-008: a user row that vanished mid-request is let through, not 403d', async () => { const { guard } = makeGuard({ requireMfa: 'true', userMissing: true }); - expect(guard.canActivate(ctx({ user }))).toBe(true); + expect(await guard.canActivate(ctx({ user }))).toBe(true); }); - it('MFA-009: @MfaExempt is the way out — setup would be unreachable otherwise', () => { + it('MFA-009: @MfaExempt is the way out — setup would be unreachable otherwise', async () => { const { guard } = makeGuard(ENFORCED, { exempt: true }); - expect(guard.canActivate(ctx({ user }))).toBe(true); + expect(await guard.canActivate(ctx({ user }))).toBe(true); }); - it('MFA-010: it reads req.user rather than verifying the token again', () => { + it('MFA-010: it reads req.user rather than verifying the token again', async () => { // The middleware this replaces called jwt.verify and SELECTed the users row a // second time on every /api request, then discarded the result. const { guard, get } = makeGuard({ requireMfa: 'true', mfaEnabled: 1 }); - guard.canActivate(ctx({ user, headers: { authorization: 'Bearer nonsense' } })); + await guard.canActivate(ctx({ user, headers: { authorization: 'Bearer nonsense' } })); expect(get.mock.calls.map((c) => c[0]).every((sql) => !String(sql).includes('password_version'))).toBe(true); }); - it('MFA-011: a @Public route no longer 403s a logged-in user without MFA', () => { + it('MFA-011: a @Public route no longer 403s a logged-in user without MFA', async () => { // The correction. /api/config, /api/help/*, the public journey and share // routes and /api/health/features all answered a stranger fine and answered // this user with a 403, because they were added after the middleware path // lists were written and nobody noticed. const { guard, get } = makeGuard(ENFORCED, { public: true }); - expect(guard.canActivate(ctx({ user }))).toBe(true); + expect(await guard.canActivate(ctx({ user }))).toBe(true); expect(get).not.toHaveBeenCalled(); }); diff --git a/server/tests/unit/nest/oidc.service.test.ts b/server/tests/unit/nest/oidc.service.test.ts index 9aa04f3d8..12b8a0107 100644 --- a/server/tests/unit/nest/oidc.service.test.ts +++ b/server/tests/unit/nest/oidc.service.test.ts @@ -1178,12 +1178,12 @@ describe('verifyIdToken', () => { // ── wrapper methods (carried over from the delegation-shim suite) ──────────── describe('wrapper methods', () => { - it('OIDC-SVC-046: oidcLoginEnabled reads the resolved auth toggle', () => { + it('OIDC-SVC-046: oidcLoginEnabled reads the resolved auth toggle', async () => { const spy = vi.spyOn(auth, 'resolveAuthToggles'); - spy.mockReturnValue({ oidc_login: true } as ReturnType); - expect(svc.oidcLoginEnabled()).toBe(true); - spy.mockReturnValue({ oidc_login: false } as ReturnType); - expect(svc.oidcLoginEnabled()).toBe(false); + spy.mockResolvedValue({ oidc_login: true } as Awaited>); + expect(await svc.oidcLoginEnabled()).toBe(true); + spy.mockResolvedValue({ oidc_login: false } as Awaited>); + expect(await svc.oidcLoginEnabled()).toBe(false); spy.mockRestore(); }); @@ -1222,22 +1222,22 @@ describe('OIDC settings', () => { expect(result.discovery_url).toBe(''); }); - it('ADMIN-SVC-048 — updateOidcSettings persists issuer and client_id, then getOidcSettings returns them', () => { - svc.updateOidcSettings({ issuer: 'https://auth.example.com', client_id: 'my-client' }); + it('ADMIN-SVC-048 — updateOidcSettings persists issuer and client_id, then getOidcSettings returns them', async () => { + await svc.updateOidcSettings({ issuer: 'https://auth.example.com', client_id: 'my-client' }); const result = svc.getOidcSettings() as any; expect(result.issuer).toBe('https://auth.example.com'); expect(result.client_id).toBe('my-client'); }); - it('ADMIN-SVC-049 — updateOidcSettings does not write oidc_only (replaced by granular toggles)', () => { - svc.updateOidcSettings({ issuer: 'https://auth.example.com', client_id: 'my-client' }); + it('ADMIN-SVC-049 — updateOidcSettings does not write oidc_only (replaced by granular toggles)', async () => { + await svc.updateOidcSettings({ issuer: 'https://auth.example.com', client_id: 'my-client' }); const result = svc.getOidcSettings() as any; // oidc_only is no longer managed by updateOidcSettings; use password_login/oidc_login toggles expect(result.oidc_only).toBe(false); }); - it('ADMIN-SVC-075 — updateOidcSettings applies all five writes atomically', () => { - const result = svc.updateOidcSettings({ issuer: 'https://idp', client_id: 'cid', display_name: 'IdP' }) as any; + it('ADMIN-SVC-075 — updateOidcSettings applies all five writes atomically', async () => { + const result = await svc.updateOidcSettings({ issuer: 'https://idp', client_id: 'cid', display_name: 'IdP' }) as any; expect(result.success).toBe(true); const settings = svc.getOidcSettings(); expect(settings).toMatchObject({ issuer: 'https://idp', client_id: 'cid', display_name: 'IdP' }); @@ -1245,31 +1245,31 @@ describe('OIDC settings', () => { }); describe('OIDC settings — the lockout guard', () => { - it('OIDC-SETTINGS-050 refuses to clear the config while password login is off', () => { + it('OIDC-SETTINGS-050 refuses to clear the config while password login is off', async () => { // Clearing the issuer with password login disabled locks every user out of the // instance: no SSO to log in through, and no password form either. - const toggles = vi.spyOn(auth, 'resolveAuthToggles').mockReturnValue({ password_login: false } as never); - expect(svc.updateOidcSettings({ issuer: '', client_id: 'x' })).toMatchObject({ status: 400 }); - expect(svc.updateOidcSettings({ issuer: 'x', client_id: '' })).toMatchObject({ status: 400 }); - expect((svc.updateOidcSettings({ issuer: '', client_id: '' }) as { error?: string }).error).toMatch(/password login/i); + const toggles = vi.spyOn(auth, 'resolveAuthToggles').mockResolvedValue({ password_login: false } as never); + expect(await svc.updateOidcSettings({ issuer: '', client_id: 'x' })).toMatchObject({ status: 400 }); + expect(await svc.updateOidcSettings({ issuer: 'x', client_id: '' })).toMatchObject({ status: 400 }); + expect(((await svc.updateOidcSettings({ issuer: '', client_id: '' })) as { error?: string }).error).toMatch(/password login/i); toggles.mockRestore(); }); - it('OIDC-SETTINGS-051 allows the same clear once password login is back on', () => { - const toggles = vi.spyOn(auth, 'resolveAuthToggles').mockReturnValue({ password_login: true } as never); - expect(svc.updateOidcSettings({ issuer: '', client_id: '' })).toEqual({ success: true }); + it('OIDC-SETTINGS-051 allows the same clear once password login is back on', async () => { + const toggles = vi.spyOn(auth, 'resolveAuthToggles').mockResolvedValue({ password_login: true } as never); + expect(await svc.updateOidcSettings({ issuer: '', client_id: '' })).toEqual({ success: true }); toggles.mockRestore(); }); - it('OIDC-SETTINGS-052 an omitted client_secret keeps the stored one, an empty string clears it', () => { - const toggles = vi.spyOn(auth, 'resolveAuthToggles').mockReturnValue({ password_login: true } as never); - svc.updateOidcSettings({ issuer: 'https://idp', client_id: 'c', client_secret: 'shh' }); + it('OIDC-SETTINGS-052 an omitted client_secret keeps the stored one, an empty string clears it', async () => { + const toggles = vi.spyOn(auth, 'resolveAuthToggles').mockResolvedValue({ password_login: true } as never); + await svc.updateOidcSettings({ issuer: 'https://idp', client_id: 'c', client_secret: 'shh' }); expect(svc.getOidcSettings().client_secret_set).toBe(true); // Omitted: the write skips the column entirely rather than blanking it, which is // what lets the admin panel save the form without re-typing the secret. - svc.updateOidcSettings({ issuer: 'https://idp', client_id: 'c' }); + await svc.updateOidcSettings({ issuer: 'https://idp', client_id: 'c' }); expect(svc.getOidcSettings().client_secret_set).toBe(true); - svc.updateOidcSettings({ issuer: 'https://idp', client_id: 'c', client_secret: '' }); + await svc.updateOidcSettings({ issuer: 'https://idp', client_id: 'c', client_secret: '' }); expect(svc.getOidcSettings().client_secret_set).toBe(false); toggles.mockRestore(); }); diff --git a/server/tests/unit/nest/passkey.service.test.ts b/server/tests/unit/nest/passkey.service.test.ts index ea8fccb1f..5a2b77ecf 100644 --- a/server/tests/unit/nest/passkey.service.test.ts +++ b/server/tests/unit/nest/passkey.service.test.ts @@ -125,7 +125,7 @@ beforeAll(async () => { new EphemeralTokenService(), new AllowedFileTypesService(new DatabaseService(testDb)), await createTestUnitOfWork(testDb), ); - svc = new PasskeyService(new DatabaseService(testDb), auth, webauthn); + svc = new PasskeyService(new DatabaseService(testDb), auth, webauthn, await createTestUnitOfWork(testDb)); }); const CFG = { rpID: 'trek.example.com', rpName: 'TREK', origins: ['https://trek.example.com'], explicitOrigins: false }; @@ -660,7 +660,7 @@ describe('passkeyLoginVerify', () => { // ── listPasskeys ────────────────────────────────────────────────────────────── describe('listPasskeys', () => { - it('PASSKEY-SVC-025: lists newest-first with backed_up remapped to a boolean', () => { + it('PASSKEY-SVC-025: lists newest-first with backed_up remapped to a boolean', async () => { const { user } = createUser(testDb); const { user: other } = createUser(testDb); testDb.prepare( @@ -669,7 +669,7 @@ describe('listPasskeys', () => { ).run(user.id, user.id); insertCredential(other.id); - const list = svc.listPasskeys(user.id); + const list = await svc.listPasskeys(user.id); expect(list.map((c) => c.name)).toEqual(['New', 'Old']); expect(list[0]).toEqual({ id: expect.any(Number), @@ -686,47 +686,47 @@ describe('listPasskeys', () => { // ── renamePasskey ───────────────────────────────────────────────────────────── describe('renamePasskey', () => { - it('PASSKEY-SVC-026: rejects a missing, non-string or whitespace-only name', () => { + it('PASSKEY-SVC-026: rejects a missing, non-string or whitespace-only name', async () => { const { user } = createUser(testDb); const cred = insertCredential(user.id); - expect(svc.renamePasskey(user.id, String(cred.id), undefined)).toEqual({ error: 'Name is required', status: 400 }); - expect(svc.renamePasskey(user.id, String(cred.id), 42)).toEqual({ error: 'Name is required', status: 400 }); - expect(svc.renamePasskey(user.id, String(cred.id), ' ')).toEqual({ error: 'Name is required', status: 400 }); + expect(await svc.renamePasskey(user.id, String(cred.id), undefined)).toEqual({ error: 'Name is required', status: 400 }); + expect(await svc.renamePasskey(user.id, String(cred.id), 42)).toEqual({ error: 'Name is required', status: 400 }); + expect(await svc.renamePasskey(user.id, String(cred.id), ' ')).toEqual({ error: 'Name is required', status: 400 }); }); - it('PASSKEY-SVC-027: renames (trimmed, capped at 60) and 404s on foreign or unknown ids', () => { + it('PASSKEY-SVC-027: renames (trimmed, capped at 60) and 404s on foreign or unknown ids', async () => { const { user } = createUser(testDb); const { user: other } = createUser(testDb); const cred = insertCredential(user.id); - expect(svc.renamePasskey(user.id, String(cred.id), ` ${'n'.repeat(80)} `)).toEqual({ success: true }); + expect(await svc.renamePasskey(user.id, String(cred.id), ` ${'n'.repeat(80)} `)).toEqual({ success: true }); const row = testDb.prepare('SELECT name FROM webauthn_credentials WHERE id = ?').get(cred.id) as { name: string }; expect(row.name).toBe('n'.repeat(60)); - expect(svc.renamePasskey(other.id, String(cred.id), 'Steal')).toEqual({ error: 'Passkey not found', status: 404 }); - expect(svc.renamePasskey(user.id, '999999', 'Ghost')).toEqual({ error: 'Passkey not found', status: 404 }); + expect(await svc.renamePasskey(other.id, String(cred.id), 'Steal')).toEqual({ error: 'Passkey not found', status: 404 }); + expect(await svc.renamePasskey(user.id, '999999', 'Ghost')).toEqual({ error: 'Passkey not found', status: 404 }); }); }); // ── deletePasskey ───────────────────────────────────────────────────────────── describe('deletePasskey', () => { - it('PASSKEY-SVC-028: requires the current password (missing, wrong, or no hash all 401)', () => { + it('PASSKEY-SVC-028: requires the current password (missing, wrong, or no hash all 401)', async () => { const { user, password } = createUser(testDb); const cred = insertCredential(user.id); - expect(svc.deletePasskey(user.id, String(cred.id), undefined)).toEqual({ error: 'Incorrect password', status: 401 }); - expect(svc.deletePasskey(user.id, String(cred.id), `${password}x`)).toEqual({ error: 'Incorrect password', status: 401 }); + expect(await svc.deletePasskey(user.id, String(cred.id), undefined)).toEqual({ error: 'Incorrect password', status: 401 }); + expect(await svc.deletePasskey(user.id, String(cred.id), `${password}x`)).toEqual({ error: 'Incorrect password', status: 401 }); testDb.prepare("UPDATE users SET password_hash = '' WHERE id = ?").run(user.id); - expect(svc.deletePasskey(user.id, String(cred.id), password)).toEqual({ error: 'Incorrect password', status: 401 }); + expect(await svc.deletePasskey(user.id, String(cred.id), password)).toEqual({ error: 'Incorrect password', status: 401 }); }); - it('PASSKEY-SVC-029: deletes own credentials only — foreign ids 404 without leaking', () => { + it('PASSKEY-SVC-029: deletes own credentials only — foreign ids 404 without leaking', async () => { const { user, password } = createUser(testDb); const { user: other, password: otherPassword } = createUser(testDb); const cred = insertCredential(user.id); - expect(svc.deletePasskey(other.id, String(cred.id), otherPassword)).toEqual({ error: 'Passkey not found', status: 404 }); - expect(svc.deletePasskey(user.id, String(cred.id), password)).toEqual({ success: true }); + expect(await svc.deletePasskey(other.id, String(cred.id), otherPassword)).toEqual({ error: 'Passkey not found', status: 404 }); + expect(await svc.deletePasskey(user.id, String(cred.id), password)).toEqual({ success: true }); expect(testDb.prepare('SELECT COUNT(*) AS n FROM webauthn_credentials').get()).toEqual({ n: 0 }); }); }); @@ -734,8 +734,8 @@ describe('deletePasskey', () => { // ── adminResetPasskeys ──────────────────────────────────────────────────────── describe('adminResetPasskeys', () => { - it('PASSKEY-SVC-030: 404s on an unknown user, else clears all credentials and reports the count', () => { - expect(svc.adminResetPasskeys(999_999)).toEqual({ error: 'User not found', status: 404 }); + it('PASSKEY-SVC-030: 404s on an unknown user, else clears all credentials and reports the count', async () => { + expect(await svc.adminResetPasskeys(999_999)).toEqual({ error: 'User not found', status: 404 }); const { user } = createUser(testDb); const { user: other } = createUser(testDb); @@ -743,8 +743,8 @@ describe('adminResetPasskeys', () => { insertCredential(user.id); const kept = insertCredential(other.id); - expect(svc.adminResetPasskeys(user.id)).toEqual({ success: true, deleted: 2, email: user.email }); - expect(svc.adminResetPasskeys(user.id)).toEqual({ success: true, deleted: 0, email: user.email }); + expect(await svc.adminResetPasskeys(user.id)).toEqual({ success: true, deleted: 2, email: user.email }); + expect(await svc.adminResetPasskeys(user.id)).toEqual({ success: true, deleted: 0, email: user.email }); expect(testDb.prepare('SELECT id FROM webauthn_credentials').all()).toEqual([{ id: kept.id }]); }); }); diff --git a/server/tests/unit/nest/registration-invites.service.test.ts b/server/tests/unit/nest/registration-invites.service.test.ts index 4a921911f..030e91d18 100644 --- a/server/tests/unit/nest/registration-invites.service.test.ts +++ b/server/tests/unit/nest/registration-invites.service.test.ts @@ -36,48 +36,48 @@ afterAll(() => { testDb.close(); }); // ── Invites ─────────────────────────────────────────────────────────────────── describe('Invites', () => { - it('ADMIN-SVC-024 — createInvite returns invite with token', () => { + it('ADMIN-SVC-024 — createInvite returns invite with token', async () => { const { user: admin } = createAdmin(testDb); - const result = svc.createInvite(admin.id, { max_uses: 5 }) as any; + const result = await svc.createInvite(admin.id, { max_uses: 5 }) as any; expect(result.invite.token).toBeDefined(); expect(result.invite.max_uses).toBe(5); }); - it('ADMIN-SVC-025 — createInvite defaults to 1 use', () => { + it('ADMIN-SVC-025 — createInvite defaults to 1 use', async () => { const { user: admin } = createAdmin(testDb); - const result = svc.createInvite(admin.id, {}) as any; + const result = await svc.createInvite(admin.id, {}) as any; expect(result.uses).toBe(1); }); - it('ADMIN-SVC-026 — listInvites returns array', () => { + it('ADMIN-SVC-026 — listInvites returns array', async () => { const { user: admin } = createAdmin(testDb); - svc.createInvite(admin.id, {}); - const invites = svc.listInvites() as any[]; + await svc.createInvite(admin.id, {}); + const invites = await svc.listInvites() as any[]; expect(invites.length).toBeGreaterThanOrEqual(1); }); - it('ADMIN-SVC-027 — deleteInvite removes invite', () => { + it('ADMIN-SVC-027 — deleteInvite removes invite', async () => { const { user: admin } = createAdmin(testDb); const invite = createInviteToken(testDb, { created_by: admin.id }) as any; - const result = svc.deleteInvite(String(invite.id)) as any; + const result = await svc.deleteInvite(String(invite.id)) as any; expect(result.error).toBeUndefined(); const check = testDb.prepare('SELECT id FROM invite_tokens WHERE id = ?').get(invite.id); expect(check).toBeUndefined(); }); - it('ADMIN-SVC-028 — deleteInvite returns 404 for non-existent invite', () => { - const result = svc.deleteInvite('99999') as any; + it('ADMIN-SVC-028 — deleteInvite returns 404 for non-existent invite', async () => { + const result = await svc.deleteInvite('99999') as any; expect(result.status).toBe(404); }); }); describe('Invites — trip binding', () => { - it('ADMIN-SVC-073 — createInvite 404s on a trip_id that does not resolve', () => { + it('ADMIN-SVC-073 — createInvite 404s on a trip_id that does not resolve', async () => { const { user: admin } = createAdmin(testDb); - expect(svc.createInvite(admin.id, { trip_id: 99999 }) as any).toMatchObject({ status: 404, error: 'Trip not found' }); - expect(svc.createInvite(admin.id, { trip_id: 'not-a-number' }) as any).toMatchObject({ status: 404 }); + expect(await svc.createInvite(admin.id, { trip_id: 99999 }) as any).toMatchObject({ status: 404, error: 'Trip not found' }); + expect(await svc.createInvite(admin.id, { trip_id: 'not-a-number' }) as any).toMatchObject({ status: 404 }); expect(testDb.prepare('SELECT COUNT(*) as c FROM invite_tokens').get()).toEqual({ c: 0 }); // An absent/blank binding is still a plain registration invite. - expect((svc.createInvite(admin.id, {}) as any).tripId).toBeNull(); + expect((await svc.createInvite(admin.id, {}) as any).tripId).toBeNull(); }); }); diff --git a/server/tests/unit/nest/roadtrip-read-tools.test.ts b/server/tests/unit/nest/roadtrip-read-tools.test.ts index 7f53a922b..f314589cb 100644 --- a/server/tests/unit/nest/roadtrip-read-tools.test.ts +++ b/server/tests/unit/nest/roadtrip-read-tools.test.ts @@ -23,12 +23,12 @@ describe('roadtrip read tools', () => { }); it('passes reviewed Google stops and the authenticated user to the importer', async () => { const routes = { preview: vi.fn(async () => ({ stops: [] })), import: vi.fn(() => ({ imported: 2 })) }; - const auth = { isDemoUser: vi.fn(() => false) }; + const auth = { isDemoUser: vi.fn(async () => false) }; const tool = new GoogleRouteMcp(routes as never, auth as never, {} as never); await tool.preview({ url: 'https://www.google.com/maps/dir/A/B' }); expect(routes.preview).toHaveBeenCalledWith('https://www.google.com/maps/dir/A/B'); const input = { tripId: 10, dayId: 1, stops: [{ name: 'A', lat: 1, lng: 2 }] }; - expect(JSON.stringify(tool.import(input, ctx))).toContain('2'); + expect(JSON.stringify(await tool.import(input, ctx))).toContain('2'); expect(routes.import).toHaveBeenCalledWith(10, 5, input); }); @@ -36,7 +36,7 @@ describe('roadtrip read tools', () => { // The one non-admin write tool that had no gate: a demo session could write // thirty places and their assignments onto the shared demo trip. const routes = { preview: vi.fn(), import: vi.fn() }; - const auth = { isDemoUser: vi.fn(() => true) }; + const auth = { isDemoUser: vi.fn(async () => true) }; const tool = new GoogleRouteMcp(routes as never, auth as never, {} as never); const res = await tool.import({ tripId: 10, dayId: 1, stops: [{ name: 'A', lat: 1, lng: 2 }] } as never, ctx); @@ -55,7 +55,7 @@ describe('roadtrip read tools', () => { const charging = new ChargingMcp({ read: refuse('Place not found', 404) } as never, db as never, {} as never); expect(text(await charging.read({ tripId: 10, placeId: 2 }, ctx))).toEqual([true, 'Place not found']); - const auth = { isDemoUser: vi.fn(() => false) }; + const auth = { isDemoUser: vi.fn(async () => false) }; const routes = { preview: refuse('Use a Google Maps directions link.', 400), import: refuse('Permission denied', 403) }; const google = new GoogleRouteMcp(routes as never, auth as never, {} as never); expect(text(await google.preview({ url: 'https://www.google.com/maps/place/A' }))).toEqual([true, 'Use a Google Maps directions link.']); diff --git a/server/tests/unit/nest/session-renewal.interceptor.test.ts b/server/tests/unit/nest/session-renewal.interceptor.test.ts index 4c4468828..da9a76b9b 100644 --- a/server/tests/unit/nest/session-renewal.interceptor.test.ts +++ b/server/tests/unit/nest/session-renewal.interceptor.test.ts @@ -52,14 +52,14 @@ function handler(): CallHandler & { handle: ReturnType } { } function makeInterceptor() { - const generateToken = vi.fn().mockReturnValue('renewed.jwt'); + const generateToken = vi.fn().mockResolvedValue('renewed.jwt'); const interceptor = new SessionRenewalInterceptor({ generateToken } as unknown as AuthService); return { interceptor, generateToken }; } async function run(interceptor: SessionRenewalInterceptor, req: ReqShape, res: ReturnType, type = 'http') { const h = handler(); - const out = await lastValueFrom(interceptor.intercept(ctx(req, res, type), h)); + const out = await lastValueFrom(await interceptor.intercept(ctx(req, res, type), h)); expect(out).toBe('ok'); expect(h.handle).toHaveBeenCalled(); } diff --git a/server/tests/unit/nest/user-cleanup.service.test.ts b/server/tests/unit/nest/user-cleanup.service.test.ts index 7c656bf42..f366cc631 100644 --- a/server/tests/unit/nest/user-cleanup.service.test.ts +++ b/server/tests/unit/nest/user-cleanup.service.test.ts @@ -94,7 +94,7 @@ afterAll(() => { }); describe('erasePluginUserData', () => { - it('USER-CLEANUP-001: deletes the host-side per-user plugin rows', () => { + it('USER-CLEANUP-001: deletes the host-side per-user plugin rows', async () => { const { user } = createUser(testDb); const { user: other } = createUser(testDb, { username: 'other' }); installPlugin('demo', []); @@ -103,34 +103,34 @@ describe('erasePluginUserData', () => { testDb.prepare('INSERT INTO plugin_user_config (plugin_id, user_id, config) VALUES (?, ?, ?)') .run('demo', other.id, '{"token":"keep-me"}'); - svc.erasePluginUserData(user.id); + await svc.erasePluginUserData(user.id); const rows = testDb.prepare('SELECT user_id FROM plugin_user_config').all() as Array<{ user_id: number }>; expect(rows.map(r => r.user_id)).toEqual([other.id]); }); - it('USER-CLEANUP-002: enqueues an erasure only for plugins holding hook:user-data', () => { + it('USER-CLEANUP-002: enqueues an erasure only for plugins holding hook:user-data', async () => { const { user } = createUser(testDb); installPlugin('with-hook', ['hook:user-data', 'trips:read']); installPlugin('without-hook', ['trips:read']); installPlugin('no-permissions', null); - svc.erasePluginUserData(user.id); + await svc.erasePluginUserData(user.id); expect(queuedFor(user.id)).toEqual(['with-hook']); }); - it('USER-CLEANUP-003: treats an unparseable permissions column as no permissions', () => { + it('USER-CLEANUP-003: treats an unparseable permissions column as no permissions', async () => { const { user } = createUser(testDb); testDb.prepare('INSERT INTO plugins (id, name, version, permissions) VALUES (?, ?, ?, ?)') .run('broken', 'broken', '1.0.0', '{not json'); - svc.erasePluginUserData(user.id); + await svc.erasePluginUserData(user.id); expect(queuedFor(user.id)).toEqual([]); }); - it('USER-CLEANUP-004: enqueues every orphan data dir — an uninstall keeps no permissions row', () => { + it('USER-CLEANUP-004: enqueues every orphan data dir — an uninstall keeps no permissions row', async () => { const { user } = createUser(testDb); const root = fs.mkdtempSync(path.join(os.tmpdir(), 'trek-plugins-data-')); dataRootRef.value = root; @@ -140,7 +140,7 @@ describe('erasePluginUserData', () => { fs.mkdirSync(path.join(root, 'installed')); try { - svc.erasePluginUserData(user.id); + await svc.erasePluginUserData(user.id); // 'installed' comes from the permissions scan, not the orphan scan, and the // INSERT OR IGNORE keeps it single. expect(queuedFor(user.id)).toEqual(['installed', 'uninstalled-but-retained']); @@ -155,7 +155,7 @@ describe('erasePluginUserData', () => { slim.prepare('INSERT INTO users (id) VALUES (1)').run(); const slimSvc = new UserCleanupService(new DatabaseService(slim), budget, await createTestUnitOfWork(testDb)); - expect(() => slimSvc.erasePluginUserData(1)).not.toThrow(); + await expect(slimSvc.erasePluginUserData(1)).resolves.toBeUndefined(); slim.close(); }); @@ -216,6 +216,10 @@ describe('deleteUserCompletely', () => { // Fail on the final statement only, after the reference cleanup has written. const failing = new DatabaseService(testDb); + // `.bind` is load-bearing here and must NOT become an arrow forwarder: it captures + // the ORIGINAL run before vi.spyOn replaces the property, whereas `(...a) => failing.run(...a)` + // would re-enter the spy and recurse. `run` stays synchronous (recipe R0), so the + // `any`-typed alias hides nothing. const realRun = failing.run.bind(failing); vi.spyOn(failing, 'run').mockImplementation((sql: string, ...params: unknown[]) => { if (sql.startsWith('DELETE FROM users')) throw new Error('boom'); diff --git a/server/tests/unit/nest/user-profile.service.test.ts b/server/tests/unit/nest/user-profile.service.test.ts index 29f5ab228..007cd6283 100644 --- a/server/tests/unit/nest/user-profile.service.test.ts +++ b/server/tests/unit/nest/user-profile.service.test.ts @@ -170,21 +170,21 @@ describe('getSettings', () => { // --------------------------------------------------------------------------- describe('listUsers', () => { - it('AUTH-DB-011: returns all users except self, sorted by username', () => { + it('AUTH-DB-011: returns all users except self, sorted by username', async () => { const { user: self } = createUser(testDb, { username: 'zzself' }); createUser(testDb, { username: 'alice' }); createUser(testDb, { username: 'charlie' }); createUser(testDb, { username: 'bob' }); - const result = profile.listUsers(self.id); + const result = await profile.listUsers(self.id); expect(result).toHaveLength(3); const names = result.map((u) => u.username); expect(names).toEqual([...names].sort()); expect(names).not.toContain('zzself'); }); - it('AUTH-DB-012: returns empty array when only one user exists', () => { + it('AUTH-DB-012: returns empty array when only one user exists', async () => { const { user } = createUser(testDb); - const result = profile.listUsers(user.id); + const result = await profile.listUsers(user.id); expect(result).toHaveLength(0); }); }); diff --git a/server/tests/unit/nest/webauthn-config.service.test.ts b/server/tests/unit/nest/webauthn-config.service.test.ts index 9637ff0b9..cf6150504 100644 --- a/server/tests/unit/nest/webauthn-config.service.test.ts +++ b/server/tests/unit/nest/webauthn-config.service.test.ts @@ -49,87 +49,87 @@ afterEach(() => { }); describe('WebauthnConfigService.resolve', () => { - it('WAC-001: derives the RP ID and single origin from a real APP_URL domain', () => { + it('WAC-001: derives the RP ID and single origin from a real APP_URL domain', async () => { appUrlRef.value = 'https://trek.example.org'; - const cfg = svc.resolve(); + const cfg = await svc.resolve(); expect(cfg).not.toBeNull(); expect(cfg!.rpID).toBe('trek.example.org'); expect(cfg!.origins).toEqual(['https://trek.example.org']); expect(cfg!.explicitOrigins).toBe(false); // derived, not operator-supplied - expect(svc.isConfigured()).toBe(true); + expect(await svc.isConfigured()).toBe(true); }); - it('WAC-002: returns null for a bare-IP host (IPs are not valid RP IDs)', () => { + it('WAC-002: returns null for a bare-IP host (IPs are not valid RP IDs)', async () => { appUrlRef.value = 'http://192.168.1.50:3001'; - expect(svc.resolve()).toBeNull(); - expect(svc.isConfigured()).toBe(false); + expect(await svc.resolve()).toBeNull(); + expect(await svc.isConfigured()).toBe(false); }); - it('WAC-003: nothing configured resolves to the localhost dev fallback (#2147)', () => { + it('WAC-003: nothing configured resolves to the localhost dev fallback (#2147)', async () => { // getAppUrl() never yields '' — an unconfigured instance lands on // http://localhost:{PORT}, so resolve() returns the localhost dev config. // The remote-origin guard for that phantom config lives in PasskeyService. - const cfg = svc.resolve(); + const cfg = await svc.resolve(); expect(cfg!.rpID).toBe('localhost'); expect(cfg!.explicitOrigins).toBe(false); - expect(svc.isConfigured()).toBe(true); + expect(await svc.isConfigured()).toBe(true); }); - it('WAC-004: localhost dev uses the browser (Vite :5173) origin, not just the API port', () => { + it('WAC-004: localhost dev uses the browser (Vite :5173) origin, not just the API port', async () => { appUrlRef.value = 'http://localhost:3001'; - const cfg = svc.resolve(); + const cfg = await svc.resolve(); expect(cfg!.rpID).toBe('localhost'); expect(cfg!.origins).toContain('http://localhost:5173'); expect(cfg!.origins).toContain('http://localhost:3001'); }); - it('WAC-005: an explicit webauthn_rp_id app-setting overrides APP_URL', () => { + it('WAC-005: an explicit webauthn_rp_id app-setting overrides APP_URL', async () => { appUrlRef.value = 'https://internal.example.org'; settingsStore.set('webauthn_rp_id', 'public.example.org'); settingsStore.set('webauthn_origins', 'https://public.example.org'); - const cfg = svc.resolve(); + const cfg = await svc.resolve(); expect(cfg!.rpID).toBe('public.example.org'); expect(cfg!.origins).toEqual(['https://public.example.org']); expect(cfg!.explicitOrigins).toBe(true); }); - it('WAC-006: webauthn_origins is parsed as a comma-separated, trimmed list', () => { + it('WAC-006: webauthn_origins is parsed as a comma-separated, trimmed list', async () => { settingsStore.set('webauthn_rp_id', 'example.org'); settingsStore.set('webauthn_origins', 'https://a.example.org , https://b.example.org/'); - const cfg = svc.resolve(); + const cfg = await svc.resolve(); expect(cfg!.origins).toEqual(['https://a.example.org', 'https://b.example.org']); }); - it('WAC-007: the WEBAUTHN_RP_ID env var takes priority', () => { + it('WAC-007: the WEBAUTHN_RP_ID env var takes priority', async () => { vi.stubEnv('WEBAUTHN_RP_ID', 'env.example.org'); vi.stubEnv('WEBAUTHN_ORIGINS', 'https://env.example.org'); appUrlRef.value = 'https://ignored.example.org'; - const cfg = svc.resolve(); + const cfg = await svc.resolve(); expect(cfg!.rpID).toBe('env.example.org'); expect(cfg!.origins).toEqual(['https://env.example.org']); }); - it('WAC-008: a configured RP ID with no origins falls back to the APP_URL origin', () => { + it('WAC-008: a configured RP ID with no origins falls back to the APP_URL origin', async () => { appUrlRef.value = 'https://trek.example.org'; settingsStore.set('webauthn_rp_id', 'trek.example.org'); - const cfg = svc.resolve(); + const cfg = await svc.resolve(); expect(cfg!.origins).toEqual(['https://trek.example.org']); expect(cfg!.explicitOrigins).toBe(false); // the origin list itself is still derived }); - it('WAC-009: an operator flipping the RP ID at runtime takes effect on the next resolve', () => { + it('WAC-009: an operator flipping the RP ID at runtime takes effect on the next resolve', async () => { appUrlRef.value = 'https://trek.example.org'; - expect(svc.resolve()!.rpID).toBe('trek.example.org'); + expect((await svc.resolve())!.rpID).toBe('trek.example.org'); settingsStore.set('webauthn_rp_id', 'moved.example.org'); - expect(svc.resolve()!.rpID).toBe('moved.example.org'); + expect((await svc.resolve())!.rpID).toBe('moved.example.org'); }); - it('WAC-010: the WEBAUTHN_ORIGINS env var also marks the origin list explicit', () => { + it('WAC-010: the WEBAUTHN_ORIGINS env var also marks the origin list explicit', async () => { vi.stubEnv('WEBAUTHN_RP_ID', 'env.example.org'); vi.stubEnv('WEBAUTHN_ORIGINS', 'https://env.example.org'); - expect(svc.resolve()!.explicitOrigins).toBe(true); + expect((await svc.resolve())!.explicitOrigins).toBe(true); }); });