From 2c3fb15263c6a4aaad5115005fbed2b5cafa939a Mon Sep 17 00:00:00 2001 From: Maurice Date: Sat, 10 Oct 2026 14:25:09 +0200 Subject: [PATCH] style(server): format the files the health lanes added or moved Prettier on every new or moved file that was not formatted, so lint:format holds them; the files taken off the format baseline are the ones that moved or were formatted on the way. --- server/scripts/format-baseline.json | 33 --- server/src/db/json-columns.ts | 3 +- .../repositories/UserAirtrail.repository.ts | 31 ++- .../db/repositories/UserImmich.repository.ts | 27 ++- .../repositories/UserSynology.repository.ts | 15 +- server/src/mcp/nest-mcp-policy.ts | 10 +- server/src/nest-mcp/mcp-registry.service.ts | 6 +- server/src/nest-mcp/types.ts | 2 +- server/src/nest-rpc/plugin-guards.module.ts | 10 +- server/src/nest-rpc/plugin-guards.service.ts | 17 +- server/src/nest-rpc/rpc-kit/decorators.ts | 4 +- .../src/nest-rpc/rpc-kit/registry.service.ts | 4 +- server/src/nest-rpc/rpc-kit/types.ts | 2 +- server/src/nest/auth-core/admin.guard.ts | 1 + .../src/nest/auth-core/cookie-auth.guard.ts | 20 +- .../nest/auth-core/current-user.decorator.ts | 10 +- .../nest/auth-core/ephemeral-token.module.ts | 2 +- .../nest/auth-core/ephemeral-token.service.ts | 2 +- server/src/nest/auth-core/ephemeral-tokens.ts | 11 +- .../src/nest/auth-core/global-auth.guard.ts | 21 +- server/src/nest/auth-core/jwt-auth.guard.ts | 9 +- server/src/nest/auth-core/jwt-verify.ts | 11 +- server/src/nest/auth-core/mfa-policy.guard.ts | 25 +- .../src/nest/auth-core/optional-jwt.guard.ts | 14 +- .../src/nest/common/app-settings.registry.ts | 25 +- server/src/nest/common/google-maps-hosts.ts | 3 +- server/src/nest/common/transit-provider.ts | 7 +- .../src/nest/journey/journey-access.module.ts | 2 +- server/src/nest/maps/maps-index.helpers.ts | 27 ++- server/src/nest/maps/maps-url.resolver.ts | 21 +- .../src/nest/maps/place-details.resolver.ts | 44 ++-- server/src/nest/maps/place-photo.resolver.ts | 15 +- .../src/nest/maps/places-provider.selector.ts | 18 +- .../maps/providers/google-places.provider.ts | 62 +++-- server/src/nest/maps/providers/osm.client.ts | 34 +-- .../nest/maps/providers/wikimedia.client.ts | 52 +++-- server/src/nest/oidc/oidc.dto.ts | 3 +- .../place-import/directions-url.helpers.ts | 7 +- server/src/nest/place-import/gpx.codec.ts | 4 +- server/src/nest/place-import/kml.codec.ts | 21 +- .../nest/place-import/place-import.module.ts | 8 +- .../nest/place-import/place-import.service.ts | 16 +- .../providers/collection-gpx.provider.ts | 35 +-- .../providers/google-directions.provider.ts | 20 +- .../providers/google-list.provider.ts | 13 +- .../place-import/providers/gpx.provider.ts | 46 +++- .../place-import/providers/kml.provider.ts | 2 +- .../trip-membership/trip-access.service.ts | 4 +- server/src/utils/json-column.ts | 10 +- server/tests/helpers/maps-service.ts | 18 +- server/tests/helpers/mcp-gate.ts | 2 +- server/tests/helpers/place-import.ts | 6 +- .../unit/app-config/running-version.test.ts | 5 +- .../UserConnections.repository.test.ts | 180 ++++++++++++--- .../db/timestamp-spelling-migration.test.ts | 42 +++- .../unit/db/users-repository-shape.test.ts | 44 ++-- .../restricted-imports-xml.test.ts | 8 +- server/tests/unit/nest-mcp/tool-gate.test.ts | 41 +++- .../tests/unit/nest-rpc/plugin-guards.test.ts | 29 ++- .../unit/nest-rpc/rpc-kit/decorators.test.ts | 5 +- .../unit/nest-rpc/rpc-kit/metadata.test.ts | 3 +- .../nest-rpc/rpc-kit/registry.service.test.ts | 15 +- .../unit/nest-rpc/rpc-kit/registry.test.ts | 13 +- .../unit/nest/app-settings.registry.test.ts | 63 ++++- .../unit/nest/auth-core/auth-guard.test.ts | 129 ++++++++--- .../unit/nest/auth-core/jwt-verify.test.ts | 128 +++++++--- .../nest/auth-core/mfa-policy.guard.test.ts | 11 +- .../common/kernel-settings-helpers.test.ts | 13 +- .../tests/unit/nest/journey-entry-row.test.ts | 10 +- .../unit/nest/maps.google-provider.test.ts | 28 ++- .../nest/maps.place-photo-resolver.test.ts | 39 +++- .../collection-gpx.provider.test.ts | 218 +++++++++++++----- .../directions-url.helpers.test.ts | 30 ++- .../place-import/google-list.provider.test.ts | 49 ++-- .../nest/place-import/gpx.provider.test.ts | 130 +++++++++-- .../unit/nest/place-import/kml.codec.test.ts | 25 +- .../nest/place-import/list-providers.test.ts | 93 ++++++-- .../place-import/place-import.service.test.ts | 74 ++++-- .../unit/scripts/import-boundaries.test.ts | 30 ++- .../unit/scripts/query-api-ratchet.test.ts | 30 ++- server/tests/unit/utils/json-column.test.ts | 40 +++- 81 files changed, 1656 insertions(+), 684 deletions(-) diff --git a/server/scripts/format-baseline.json b/server/scripts/format-baseline.json index a6026d9f8..ec95e4127 100644 --- a/server/scripts/format-baseline.json +++ b/server/scripts/format-baseline.json @@ -349,7 +349,6 @@ "src/nest/audit/audit.module.ts", "src/nest/audit/audit.service.ts", "src/nest/audit/entry-trace.logger.ts", - "src/nest/auth/admin.guard.ts", "src/nest/auth/auth-public.controller.ts", "src/nest/auth/auth.controller.ts", "src/nest/auth/auth.dto.ts", @@ -357,16 +356,6 @@ "src/nest/auth/auth.mcp.ts", "src/nest/auth/auth.module.ts", "src/nest/auth/auth.service.ts", - "src/nest/auth/cookie-auth.guard.ts", - "src/nest/auth/current-user.decorator.ts", - "src/nest/auth/ephemeral-token.module.ts", - "src/nest/auth/ephemeral-token.service.ts", - "src/nest/auth/ephemeral-tokens.ts", - "src/nest/auth/global-auth.guard.ts", - "src/nest/auth/jwt-auth.guard.ts", - "src/nest/auth/jwt-verify.ts", - "src/nest/auth/mfa-policy.guard.ts", - "src/nest/auth/optional-jwt.guard.ts", "src/nest/auth/passkey-enabled.guard.ts", "src/nest/auth/passkey.controller.ts", "src/nest/auth/passkey.service.ts", @@ -414,7 +403,6 @@ "src/nest/collab/collab.module.ts", "src/nest/collab/collab.rpc.ts", "src/nest/collab/collab.service.ts", - "src/nest/collections/collection-gpx.helpers.ts", "src/nest/collections/collections.controller.ts", "src/nest/collections/collections.dto.ts", "src/nest/collections/collections.mcp.ts", @@ -670,9 +658,6 @@ "src/nest/place-shadow/place-shadow.job.ts", "src/nest/place-shadow/place-shadow.module.ts", "src/nest/place-shadow/place-shadow.service.ts", - "src/nest/places/gpx-export.helpers.ts", - "src/nest/places/kml-import.helpers.ts", - "src/nest/places/maps-dir.helpers.ts", "src/nest/places/places.controller.ts", "src/nest/places/places.dto.ts", "src/nest/places/places.helpers.ts", @@ -718,15 +703,11 @@ "src/nest/plugins/host/daily-budget.ts", "src/nest/plugins/host/plugin-audit.ts", "src/nest/plugins/host/plugin-data.service.ts", - "src/nest/plugins/host/plugin-guards.module.ts", - "src/nest/plugins/host/plugin-guards.service.ts", "src/nest/plugins/host/plugin-host-state.ts", "src/nest/plugins/host/plugin-jobs.ts", "src/nest/plugins/host/plugin-rpc-host.factory.ts", "src/nest/plugins/host/rate-limit.ts", "src/nest/plugins/host/rpc-host.ts", - "src/nest/plugins/host/rpc-kit/decorators.ts", - "src/nest/plugins/host/rpc-kit/registry.service.ts", "src/nest/plugins/host/rpc/db.rpc.ts", "src/nest/plugins/host/rpc/host-surface.rpc.ts", "src/nest/plugins/host/rpc/meta.rpc.ts", @@ -783,7 +764,6 @@ "src/nest/reservation-import/reservation-import.dto.ts", "src/nest/reservation-import/reservation-import.mcp.ts", "src/nest/reservation-import/reservation-import.module.ts", - "src/nest/reservations/reservation-metadata.ts", "src/nest/reservations/reservations-read.module.ts", "src/nest/reservations/reservations-read.service.ts", "src/nest/reservations/reservations.controller.ts", @@ -890,7 +870,6 @@ "src/nest/tours/tours.service.ts", "src/nest/transit/google-transit.provider.ts", "src/nest/transit/transit-itinerary.helpers.ts", - "src/nest/transit/transit-provider.ts", "src/nest/transit/transit.controller.ts", "src/nest/transit/transit.mcp.ts", "src/nest/transit/transit.module.ts", @@ -1336,11 +1315,9 @@ "tests/unit/nest/atlas.service.test.ts", "tests/unit/nest/audit-log.logger.test.ts", "tests/unit/nest/audit.service.test.ts", - "tests/unit/nest/auth-guard.test.ts", "tests/unit/nest/auth.controller.test.ts", "tests/unit/nest/auth.helpers.test.ts", "tests/unit/nest/auth.service.test.ts", - "tests/unit/nest/auth/jwt-verify.test.ts", "tests/unit/nest/auth/sessions.controller.test.ts", "tests/unit/nest/auto-backup-settings.test.ts", "tests/unit/nest/backup-archive-version.test.ts", @@ -1365,7 +1342,6 @@ "tests/unit/nest/city-from-address.test.ts", "tests/unit/nest/collab.controller.test.ts", "tests/unit/nest/collab.service.test.ts", - "tests/unit/nest/collection-gpx.helpers.test.ts", "tests/unit/nest/collections.controller.test.ts", "tests/unit/nest/collections.service.test.ts", "tests/unit/nest/common/request-correlation.test.ts", @@ -1421,7 +1397,6 @@ "tests/unit/nest/google-quota.service.test.ts", "tests/unit/nest/google-route.test.ts", "tests/unit/nest/google-transit.provider.test.ts", - "tests/unit/nest/gpx-export.helpers.test.ts", "tests/unit/nest/idempotency.interceptor.test.ts", "tests/unit/nest/immich.service.test.ts", "tests/unit/nest/journey-book.service.test.ts", @@ -1456,7 +1431,6 @@ "tests/unit/nest/managed-keys.test.ts", "tests/unit/nest/managed.guard.test.ts", "tests/unit/nest/managed.test.ts", - "tests/unit/nest/maps-dir.helpers.test.ts", "tests/unit/nest/maps.amap.test.ts", "tests/unit/nest/maps.area.test.ts", "tests/unit/nest/maps.autocomplete.test.ts", @@ -1482,7 +1456,6 @@ "tests/unit/nest/memories.service.test.ts", "tests/unit/nest/memories/journey-thumbs.job.test.ts", "tests/unit/nest/memories/trek-photo-cache.job.test.ts", - "tests/unit/nest/mfa-policy.guard.test.ts", "tests/unit/nest/notification-preferences.service.test.ts", "tests/unit/nest/notification-transports.test.ts", "tests/unit/nest/notifications.controller.test.ts", @@ -1528,7 +1501,6 @@ "tests/unit/nest/places.enrichment.test.ts", "tests/unit/nest/places.helpers.test.ts", "tests/unit/nest/places.service.test.ts", - "tests/unit/nest/places/kml-import.helpers.test.ts", "tests/unit/nest/platform.controller.test.ts", "tests/unit/nest/process-state-ports.test.ts", "tests/unit/nest/public-api-scopes.test.ts", @@ -1674,7 +1646,6 @@ "tests/unit/plugins/plugin-data.test.ts", "tests/unit/plugins/plugin-event-meta.test.ts", "tests/unit/plugins/plugin-frame.test.ts", - "tests/unit/plugins/plugin-guards.test.ts", "tests/unit/plugins/plugin-hooks.test.ts", "tests/unit/plugins/plugin-host-state.test.ts", "tests/unit/plugins/plugin-instance-settings.test.ts", @@ -1708,10 +1679,6 @@ "tests/unit/plugins/rpc-fallbacks.test.ts", "tests/unit/plugins/rpc-host-inv-strip.test.ts", "tests/unit/plugins/rpc-host.test.ts", - "tests/unit/plugins/rpc-kit/decorators.test.ts", - "tests/unit/plugins/rpc-kit/metadata.test.ts", - "tests/unit/plugins/rpc-kit/registry.service.test.ts", - "tests/unit/plugins/rpc-kit/registry.test.ts", "tests/unit/plugins/rpc-trace.test.ts", "tests/unit/plugins/safe-extract.test.ts", "tests/unit/plugins/safe-fetch.test.ts", diff --git a/server/src/db/json-columns.ts b/server/src/db/json-columns.ts index f9c0e22f6..d9bfc5c19 100644 --- a/server/src/db/json-columns.ts +++ b/server/src/db/json-columns.ts @@ -1,6 +1,7 @@ -import { z } from 'zod'; import type { JsonColumn } from '../utils/json-column'; +import { z } from 'zod'; + /** * The JSON-in-TEXT columns the services decode, one declaration each: the shape * the column holds and what a reader gets when a row is empty or broken. Read diff --git a/server/src/db/repositories/UserAirtrail.repository.ts b/server/src/db/repositories/UserAirtrail.repository.ts index d878ee6f8..3269a1683 100644 --- a/server/src/db/repositories/UserAirtrail.repository.ts +++ b/server/src/db/repositories/UserAirtrail.repository.ts @@ -1,7 +1,7 @@ -import type { EntityManager } from '@mikro-orm/core'; -import type { SqlEntityManager } from '@mikro-orm/sql'; import { Users } from '../entities/Users.entity'; import { TrekRepository } from './_shared/trek-repository'; +import type { EntityManager } from '@mikro-orm/core'; +import type { SqlEntityManager } from '@mikro-orm/sql'; /** * The AirTrail connection a user keeps on their own `users` row @@ -57,10 +57,21 @@ export class UserAirtrailRepository extends TrekRepository { * wrap its writes in `uow.transactional`): preserved exactly, not * "fixed" to match Dawarich's shape. */ - async setAirtrailSettingsWithKey(id: number, url: string | null, apiKey: string, allowInsecureTls: number, writeEnabled: number): Promise { + async setAirtrailSettingsWithKey( + id: number, + url: string | null, + apiKey: string, + allowInsecureTls: number, + writeEnabled: number, + ): Promise { await this.nativeUpdate( { id }, - { airtrail_url: url, airtrail_api_key: apiKey, airtrail_allow_insecure_tls: allowInsecureTls, airtrail_write_enabled: writeEnabled }, + { + airtrail_url: url, + airtrail_api_key: apiKey, + airtrail_allow_insecure_tls: allowInsecureTls, + airtrail_write_enabled: writeEnabled, + }, ); } @@ -70,8 +81,16 @@ export class UserAirtrailRepository extends TrekRepository { * airtrail_write_enabled = ? WHERE id = ?`. Same no-transaction asymmetry * as {@link setAirtrailSettingsWithKey} — preserved, not fixed. */ - async setAirtrailSettings(id: number, url: string | null, allowInsecureTls: number, writeEnabled: number): Promise { - await this.nativeUpdate({ id }, { airtrail_url: url, airtrail_allow_insecure_tls: allowInsecureTls, airtrail_write_enabled: writeEnabled }); + async setAirtrailSettings( + id: number, + url: string | null, + allowInsecureTls: number, + writeEnabled: number, + ): Promise { + await this.nativeUpdate( + { id }, + { airtrail_url: url, airtrail_allow_insecure_tls: allowInsecureTls, airtrail_write_enabled: writeEnabled }, + ); } /** diff --git a/server/src/db/repositories/UserImmich.repository.ts b/server/src/db/repositories/UserImmich.repository.ts index 94c3fde62..a84d99dda 100644 --- a/server/src/db/repositories/UserImmich.repository.ts +++ b/server/src/db/repositories/UserImmich.repository.ts @@ -1,8 +1,8 @@ -import type { EntityManager } from '@mikro-orm/core'; -import type { SqlEntityManager } from '@mikro-orm/sql'; +import { coalesceOverrideWhileSame } from '../dialect/sql-functions'; import { Users } from '../entities/Users.entity'; import { TrekRepository } from './_shared/trek-repository'; -import { coalesceOverrideWhileSame } from '../dialect/sql-functions'; +import type { EntityManager } from '@mikro-orm/core'; +import type { SqlEntityManager } from '@mikro-orm/sql'; /** * The Immich connection a user keeps on their own `users` row (`immich_url`, @@ -26,9 +26,11 @@ export class UserImmichRepository extends TrekRepository { * immich_api_key, immich_allow_insecure_tls FROM users WHERE id = ?` * (the TLS switch joined the read with #2475). */ - async getImmichCredentials( - id: number, - ): Promise<{ immich_url: string | null; immich_api_key: string | null; immich_allow_insecure_tls: number | null } | null> { + async getImmichCredentials(id: number): Promise<{ + immich_url: string | null; + immich_api_key: string | null; + immich_allow_insecure_tls: number | null; + } | null> { const row = await this.findOne({ id }, { fields: ['immich_url', 'immich_api_key', 'immich_allow_insecure_tls'] }); return row ? { @@ -55,7 +57,10 @@ export class UserImmichRepository extends TrekRepository { ): Promise<{ immich_auto_upload: number | null; immich_allow_insecure_tls: number | null } | null> { const row = await this.findOne({ id }, { fields: ['immich_auto_upload', 'immich_allow_insecure_tls'] }); return row - ? { immich_auto_upload: row.immich_auto_upload ?? null, immich_allow_insecure_tls: row.immich_allow_insecure_tls ?? null } + ? { + immich_auto_upload: row.immich_auto_upload ?? null, + immich_allow_insecure_tls: row.immich_allow_insecure_tls ?? null, + } : null; } @@ -88,7 +93,13 @@ export class UserImmichRepository extends TrekRepository { { immich_url, immich_api_key, - immich_allow_insecure_tls: coalesceOverrideWhileSame(platform, allow_insecure_tls, 'immich_allow_insecure_tls', 'immich_url', immich_url), + immich_allow_insecure_tls: coalesceOverrideWhileSame( + platform, + allow_insecure_tls, + 'immich_allow_insecure_tls', + 'immich_url', + immich_url, + ), }, ); } diff --git a/server/src/db/repositories/UserSynology.repository.ts b/server/src/db/repositories/UserSynology.repository.ts index 853e1e07e..b122cfbb1 100644 --- a/server/src/db/repositories/UserSynology.repository.ts +++ b/server/src/db/repositories/UserSynology.repository.ts @@ -1,14 +1,15 @@ -import type { EntityManager } from '@mikro-orm/core'; -import type { SqlEntityManager } from '@mikro-orm/sql'; import { Users } from '../entities/Users.entity'; import { TrekRepository } from './_shared/trek-repository'; +import type { EntityManager } from '@mikro-orm/core'; +import type { SqlEntityManager } from '@mikro-orm/sql'; /** * The six Synology credential/session columns `_readSynologyUser` reads * selectively — a closed union, not a dynamic column string, matching * `InstanceApiKeyName`'s reasoning in `Users.repository.ts`. */ -export type SynologyUserColumn = 'synology_url' | 'synology_username' | 'synology_password' | 'synology_sid' | 'synology_did' | 'synology_skip_ssl'; +export type SynologyUserColumn = + 'synology_url' | 'synology_username' | 'synology_password' | 'synology_sid' | 'synology_did' | 'synology_skip_ssl'; /** * {@link UserSynologyRepository.getSynologyFields}'s return shape — structurally @@ -94,7 +95,13 @@ export class UserSynologyRepository extends TrekRepository { } /** SY5 (`SynologyService.updateSynologySettings`) — `UPDATE users SET synology_url = ?, synology_username = ?, synology_password = ?, synology_skip_ssl = ? WHERE id = ?`. */ - async setSynologySettings(id: number, synology_url: string, synology_username: string, synology_password: string | null, synology_skip_ssl: number): Promise { + async setSynologySettings( + id: number, + synology_url: string, + synology_username: string, + synology_password: string | null, + synology_skip_ssl: number, + ): Promise { await this.nativeUpdate({ id }, { synology_url, synology_username, synology_password, synology_skip_ssl }); } diff --git a/server/src/mcp/nest-mcp-policy.ts b/server/src/mcp/nest-mcp-policy.ts index 79400f2a9..fb1fd09da 100644 --- a/server/src/mcp/nest-mcp-policy.ts +++ b/server/src/mcp/nest-mcp-policy.ts @@ -1,4 +1,12 @@ -import { demoDenied, type McpAccessGroup, type McpAccessMode, type McpAccessPolicy, type McpAccessValidator, type McpToolGate, type ToolOptions } from '../nest-mcp'; +import { + demoDenied, + type McpAccessGroup, + type McpAccessMode, + type McpAccessPolicy, + type McpAccessValidator, + type McpToolGate, + type ToolOptions, +} from '../nest-mcp'; import { ALL_SCOPES, canRead, canWrite, type Scope, type ScopeGroup } from './scopes'; /** The mode half of every scope: 'read' | 'write' | 'delete' | 'share'. */ diff --git a/server/src/nest-mcp/mcp-registry.service.ts b/server/src/nest-mcp/mcp-registry.service.ts index 348899b17..d16aec898 100644 --- a/server/src/nest-mcp/mcp-registry.service.ts +++ b/server/src/nest-mcp/mcp-registry.service.ts @@ -17,7 +17,11 @@ export class McpRegistryService extends McpRegistry implements OnModuleInit { @Inject(MCP_MODULE_OPTIONS) options: McpModuleOptions, @Inject(MCP_TOOL_GATE) toolGate: McpToolGate | null, ) { - super({ accessPolicy: options.accessPolicy, validateAccess: options.validateAccess, toolGate: toolGate ?? undefined }); + super({ + accessPolicy: options.accessPolicy, + validateAccess: options.validateAccess, + toolGate: toolGate ?? undefined, + }); } onModuleInit(): void { diff --git a/server/src/nest-mcp/types.ts b/server/src/nest-mcp/types.ts index 5a0b94cde..a0630e887 100644 --- a/server/src/nest-mcp/types.ts +++ b/server/src/nest-mcp/types.ts @@ -1,6 +1,6 @@ import type { Scope, ScopeGroup } from '../mcp/scopes'; - import type { InjectionToken, ModuleMetadata } from '@nestjs/common'; + import type { ZodRawShape, ZodType } from 'zod'; /** diff --git a/server/src/nest-rpc/plugin-guards.module.ts b/server/src/nest-rpc/plugin-guards.module.ts index 5e169cf35..1dbd5edae 100644 --- a/server/src/nest-rpc/plugin-guards.module.ts +++ b/server/src/nest-rpc/plugin-guards.module.ts @@ -1,10 +1,10 @@ -import { Module } from '@nestjs/common'; -import { MikroOrmModule } from '@mikro-orm/nestjs'; -import { PermissionsModule } from '../nest/permissions/permissions.module'; -import { AddonsModule } from '../nest/addons/addons.module'; -import { Users } from '../db/entities/Users.entity'; import { Trips } from '../db/entities/Trips.entity'; +import { Users } from '../db/entities/Users.entity'; +import { AddonsModule } from '../nest/addons/addons.module'; +import { PermissionsModule } from '../nest/permissions/permissions.module'; import { PluginGuards } from './plugin-guards.service'; +import { MikroOrmModule } from '@mikro-orm/nestjs'; +import { Module } from '@nestjs/common'; /** * A leaf module whose only job is to hand PluginGuards to the domain modules. diff --git a/server/src/nest-rpc/plugin-guards.service.ts b/server/src/nest-rpc/plugin-guards.service.ts index 9e491253d..1056c5405 100644 --- a/server/src/nest-rpc/plugin-guards.service.ts +++ b/server/src/nest-rpc/plugin-guards.service.ts @@ -1,14 +1,14 @@ -import { Injectable } from '@nestjs/common'; -import { InjectRepository } from '@mikro-orm/nestjs'; -import { PermissionsService } from '../nest/permissions/permissions.service'; -import { AddonsService } from '../nest/addons/addons.service'; -import { Users } from '../db/entities/Users.entity'; -import type { UsersRepository } from '../db/repositories/Users.repository'; import { Trips } from '../db/entities/Trips.entity'; +import { Users } from '../db/entities/Users.entity'; import type { TripsRepository } from '../db/repositories/Trips.repository'; +import type { UsersRepository } from '../db/repositories/Users.repository'; +import { AddonsService } from '../nest/addons/addons.service'; +import { PermissionsService } from '../nest/permissions/permissions.service'; import { BadParams, ForbiddenResource } from './rpc-errors'; -import { num } from './rpc-params'; import type { PluginRpcContext } from './rpc-kit/types'; +import { num } from './rpc-params'; +import { InjectRepository } from '@mikro-orm/nestjs'; +import { Injectable } from '@nestjs/common'; /** * The resource gates every plugin RPC handler needs, lifted out of PluginRpcHost's @@ -66,7 +66,8 @@ export class PluginGuards { /** A write is allowed only if the acting user can access AND edit the trip. */ async requireTripEdit(tripId: number, userId: number, action: string): Promise { if (!(await this.trips.findAccessible(tripId, userId))) throw new ForbiddenResource(`no access to trip ${tripId}`); - if (!(await this.canEditAs(action, tripId, userId))) throw new ForbiddenResource(`no permission to edit trip ${tripId}`); + if (!(await this.canEditAs(action, tripId, userId))) + throw new ForbiddenResource(`no permission to edit trip ${tripId}`); } /** diff --git a/server/src/nest-rpc/rpc-kit/decorators.ts b/server/src/nest-rpc/rpc-kit/decorators.ts index d822a1e9f..cfd43b860 100644 --- a/server/src/nest-rpc/rpc-kit/decorators.ts +++ b/server/src/nest-rpc/rpc-kit/decorators.ts @@ -1,7 +1,7 @@ -import { Injectable } from '@nestjs/common'; -import { addEntry, markController, type ClassRef } from './metadata'; import type { HookKey, KnownMethod, MethodPermission, UnconditionalMethod } from '../../nest/plugins/protocol/envelope'; +import { addEntry, markController, type ClassRef } from './metadata'; import type { PluginHookOptions, PluginRpcEntry } from './types'; +import { Injectable } from '@nestjs/common'; /** * Marks a class as a plugin RPC controller. Implies `@Injectable()`. diff --git a/server/src/nest-rpc/rpc-kit/registry.service.ts b/server/src/nest-rpc/rpc-kit/registry.service.ts index 872e39232..207b0254f 100644 --- a/server/src/nest-rpc/rpc-kit/registry.service.ts +++ b/server/src/nest-rpc/rpc-kit/registry.service.ts @@ -1,7 +1,7 @@ -import { Injectable, type OnModuleInit } from '@nestjs/common'; -import { DiscoveryService, MetadataScanner } from '@nestjs/core'; import { isPluginController } from './metadata'; import { PluginRpcRegistry } from './registry'; +import { Injectable, type OnModuleInit } from '@nestjs/common'; +import { DiscoveryService, MetadataScanner } from '@nestjs/core'; /** * DI-discovered registry: at boot, scans every PROVIDER marked with diff --git a/server/src/nest-rpc/rpc-kit/types.ts b/server/src/nest-rpc/rpc-kit/types.ts index f4fea371e..e1734504c 100644 --- a/server/src/nest-rpc/rpc-kit/types.ts +++ b/server/src/nest-rpc/rpc-kit/types.ts @@ -1,5 +1,5 @@ -import type { HookKey, HookPermission, KnownMethod, UnconditionalMethod } from '../../nest/plugins/protocol/envelope'; import type { PluginDataDb } from '../../nest/plugins/host/plugin-data.service'; +import type { HookKey, HookPermission, KnownMethod, UnconditionalMethod } from '../../nest/plugins/protocol/envelope'; /** * Everything a plugin RPC handler may see. Built PER DISPATCH, never cached. diff --git a/server/src/nest/auth-core/admin.guard.ts b/server/src/nest/auth-core/admin.guard.ts index b41d2f793..92d27722e 100644 --- a/server/src/nest/auth-core/admin.guard.ts +++ b/server/src/nest/auth-core/admin.guard.ts @@ -1,4 +1,5 @@ import { CanActivate, ExecutionContext, HttpException, Injectable } from '@nestjs/common'; + import type { Request } from 'express'; /** diff --git a/server/src/nest/auth-core/cookie-auth.guard.ts b/server/src/nest/auth-core/cookie-auth.guard.ts index 9da291fc0..f38288eb8 100644 --- a/server/src/nest/auth-core/cookie-auth.guard.ts +++ b/server/src/nest/auth-core/cookie-auth.guard.ts @@ -1,9 +1,10 @@ -import { CanActivate, ExecutionContext, HttpException, Injectable } from '@nestjs/common'; -import { EntityManager } from '@mikro-orm/core'; -import type { Request } from 'express'; -import { Users } from '../../db/entities/Users.entity'; import { UserSessions } from '../../db/entities/UserSessions.entity'; +import { Users } from '../../db/entities/Users.entity'; import { verifyJwtAndLoadUser } from './jwt-verify'; +import { EntityManager } from '@mikro-orm/core'; +import { CanActivate, ExecutionContext, HttpException, Injectable } from '@nestjs/common'; + +import type { Request } from 'express'; /** * Mirrors the legacy `requireCookieAuth` middleware: accepts ONLY the httpOnly @@ -24,9 +25,16 @@ export class CookieAuthGuard implements CanActivate { 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); + throw new HttpException( + { error: 'Cookie session required for this endpoint', code: 'COOKIE_AUTH_REQUIRED' }, + 401, + ); } - const user = await verifyJwtAndLoadUser(cookieToken, this.em.getRepository(Users), this.em.getRepository(UserSessions)); + const user = await verifyJwtAndLoadUser( + cookieToken, + this.em.getRepository(Users), + this.em.getRepository(UserSessions), + ); if (!user) { throw new HttpException({ error: 'Invalid or expired session', code: 'AUTH_REQUIRED' }, 401); } diff --git a/server/src/nest/auth-core/current-user.decorator.ts b/server/src/nest/auth-core/current-user.decorator.ts index c4734891b..2775c827d 100644 --- a/server/src/nest/auth-core/current-user.decorator.ts +++ b/server/src/nest/auth-core/current-user.decorator.ts @@ -1,12 +1,10 @@ -import { createParamDecorator, ExecutionContext } from '@nestjs/common'; import type { User } from '../../types'; +import { createParamDecorator, ExecutionContext } from '@nestjs/common'; /** * Resolves the authenticated user attached by JwtAuthGuard. * Use on guarded handlers: `getThing(@CurrentUser() user: User) { ... }`. */ -export const CurrentUser = createParamDecorator( - (_data: unknown, context: ExecutionContext): User | undefined => { - return context.switchToHttp().getRequest().user; - }, -); +export const CurrentUser = createParamDecorator((_data: unknown, context: ExecutionContext): User | undefined => { + return context.switchToHttp().getRequest().user; +}); diff --git a/server/src/nest/auth-core/ephemeral-token.module.ts b/server/src/nest/auth-core/ephemeral-token.module.ts index c26e02226..3085b81f6 100644 --- a/server/src/nest/auth-core/ephemeral-token.module.ts +++ b/server/src/nest/auth-core/ephemeral-token.module.ts @@ -1,5 +1,5 @@ -import { Module } from '@nestjs/common'; import { EphemeralTokenService } from './ephemeral-token.service'; +import { Module } from '@nestjs/common'; /** * A leaf module whose only job is to hand EphemeralTokenService out. diff --git a/server/src/nest/auth-core/ephemeral-token.service.ts b/server/src/nest/auth-core/ephemeral-token.service.ts index 73471098e..812c07278 100644 --- a/server/src/nest/auth-core/ephemeral-token.service.ts +++ b/server/src/nest/auth-core/ephemeral-token.service.ts @@ -1,4 +1,3 @@ -import { Injectable, OnModuleInit, OnModuleDestroy } from '@nestjs/common'; import { createEphemeralToken, consumeEphemeralToken, @@ -7,6 +6,7 @@ import { stopTokenCleanup, type EphemeralTokenMeta, } from './ephemeral-tokens'; +import { Injectable, OnModuleInit, OnModuleDestroy } from '@nestjs/common'; /** * The injectable face of the ephemeral-token store, and the owner of its diff --git a/server/src/nest/auth-core/ephemeral-tokens.ts b/server/src/nest/auth-core/ephemeral-tokens.ts index aed77d614..0e83ce961 100644 --- a/server/src/nest/auth-core/ephemeral-tokens.ts +++ b/server/src/nest/auth-core/ephemeral-tokens.ts @@ -27,11 +27,7 @@ export interface EphemeralTokenMeta { const store = new Map(); -export function createEphemeralToken( - userId: number, - purpose: string, - meta?: EphemeralTokenMeta, -): string | null { +export function createEphemeralToken(userId: number, purpose: string, meta?: EphemeralTokenMeta): string | null { if (store.size >= MAX_STORE_SIZE) return null; const token = crypto.randomBytes(32).toString('hex'); const ttl = TTL[purpose] ?? 60_000; @@ -52,10 +48,7 @@ export function consumeEphemeralToken(token: string, purpose: string): number | * token was minted with. Used by the WebSocket handshake so a token issued * before a password change can be rejected even within its short TTL. */ -export function consumeEphemeralTokenWithMeta( - token: string, - purpose: string, -): { userId: number; pv?: number } | null { +export function consumeEphemeralTokenWithMeta(token: string, purpose: string): { userId: number; pv?: number } | null { const entry = store.get(token); if (!entry) return null; store.delete(token); diff --git a/server/src/nest/auth-core/global-auth.guard.ts b/server/src/nest/auth-core/global-auth.guard.ts index 2018f28c8..6a4a74439 100644 --- a/server/src/nest/auth-core/global-auth.guard.ts +++ b/server/src/nest/auth-core/global-auth.guard.ts @@ -1,12 +1,13 @@ +import { UserSessions } from '../../db/entities/UserSessions.entity'; +import { Users } from '../../db/entities/Users.entity'; +import { extractToken, verifyJwtAndLoadUser } from './jwt-verify'; +import { IS_PUBLIC, OPTIONAL_AUTH } from './public.decorator'; +import { EntityManager } from '@mikro-orm/core'; import { CanActivate, ExecutionContext, HttpException, Injectable } from '@nestjs/common'; import { GUARDS_METADATA } from '@nestjs/common/constants'; import { Reflector } from '@nestjs/core'; -import { EntityManager } from '@mikro-orm/core'; + import type { Request } from 'express'; -import { Users } from '../../db/entities/Users.entity'; -import { UserSessions } from '../../db/entities/UserSessions.entity'; -import { extractToken, verifyJwtAndLoadUser } from './jwt-verify'; -import { IS_PUBLIC, OPTIONAL_AUTH } from './public.decorator'; /** * Default-deny. Registered as an APP_GUARD, so a route is authenticated unless @@ -50,7 +51,10 @@ export class GlobalAuthGuard implements CanActivate { if (this.reflector.getAllAndOverride(OPTIONAL_AUTH, [handler, controller])) { const token = extractToken(req); - (req as { user: unknown }).user = (token ? await verifyJwtAndLoadUser(token, this.em.getRepository(Users), this.em.getRepository(UserSessions)) : null) || null; + (req as { user: unknown }).user = + (token + ? await verifyJwtAndLoadUser(token, this.em.getRepository(Users), this.em.getRepository(UserSessions)) + : null) || null; return true; } @@ -67,7 +71,10 @@ export class GlobalAuthGuard implements CanActivate { ]; if (declared.length > 0) { const declaredToken = extractToken(req); - (req as { user: unknown }).user = (declaredToken ? await verifyJwtAndLoadUser(declaredToken, this.em.getRepository(Users), this.em.getRepository(UserSessions)) : null) || null; + (req as { user: unknown }).user = + (declaredToken + ? await verifyJwtAndLoadUser(declaredToken, this.em.getRepository(Users), this.em.getRepository(UserSessions)) + : null) || null; return true; } diff --git a/server/src/nest/auth-core/jwt-auth.guard.ts b/server/src/nest/auth-core/jwt-auth.guard.ts index ef9810093..e04394cd3 100644 --- a/server/src/nest/auth-core/jwt-auth.guard.ts +++ b/server/src/nest/auth-core/jwt-auth.guard.ts @@ -1,9 +1,10 @@ -import { CanActivate, ExecutionContext, HttpException, Injectable } from '@nestjs/common'; -import { EntityManager } from '@mikro-orm/core'; -import type { Request } from 'express'; -import { Users } from '../../db/entities/Users.entity'; import { UserSessions } from '../../db/entities/UserSessions.entity'; +import { Users } from '../../db/entities/Users.entity'; import { extractToken, verifyJwtAndLoadUser } from './jwt-verify'; +import { EntityManager } from '@mikro-orm/core'; +import { CanActivate, ExecutionContext, HttpException, Injectable } from '@nestjs/common'; + +import type { Request } from 'express'; /** * Validates TREK's existing JWT session — the same httpOnly `trek_session` diff --git a/server/src/nest/auth-core/jwt-verify.ts b/server/src/nest/auth-core/jwt-verify.ts index 54b08cc9b..a6c6e8895 100644 --- a/server/src/nest/auth-core/jwt-verify.ts +++ b/server/src/nest/auth-core/jwt-verify.ts @@ -1,10 +1,11 @@ +import { JWT_SECRET } from '../../config'; +import type { UserSessionsRepository } from '../../db/repositories/UserSessions.repository'; +import type { UsersRepository } from '../../db/repositories/Users.repository'; +import { dbNow } from '../../db/types'; +import type { User } from '../../types'; + import type { Request } from 'express'; import jwt from 'jsonwebtoken'; -import { JWT_SECRET } from '../../config'; -import type { User } from '../../types'; -import type { UsersRepository } from '../../db/repositories/Users.repository'; -import type { UserSessionsRepository } from '../../db/repositories/UserSessions.repository'; -import { dbNow } from '../../db/types'; /** * The canonical JWT session check. Every auth surface goes through here — the diff --git a/server/src/nest/auth-core/mfa-policy.guard.ts b/server/src/nest/auth-core/mfa-policy.guard.ts index 26a62ced5..2fcd040bf 100644 --- a/server/src/nest/auth-core/mfa-policy.guard.ts +++ b/server/src/nest/auth-core/mfa-policy.guard.ts @@ -1,18 +1,19 @@ -import { CanActivate, ExecutionContext, HttpException, Injectable, SetMetadata } from '@nestjs/common'; -import { Reflector } from '@nestjs/core'; -import { InjectRepository } from '@mikro-orm/nestjs'; -import type { Request } from 'express'; +import { AppSettings } from '../../db/entities/AppSettings.entity'; +import { Users } from '../../db/entities/Users.entity'; +import { WebauthnCredentials } from '../../db/entities/WebauthnCredentials.entity'; +import type { AppSettingsRepository } from '../../db/repositories/AppSettings.repository'; +import type { UsersRepository } from '../../db/repositories/Users.repository'; +import type { WebauthnCredentialsRepository } from '../../db/repositories/WebauthnCredentials.repository'; +import type { User } from '../../types'; import { RuntimeEnvService } from '../app-config/runtime-env.service'; +import { readAppSetting } from '../common/app-settings.registry'; import { DEMO_EMAILS } from '../common/demo'; import { IS_PUBLIC } from './public.decorator'; -import type { User } from '../../types'; -import { AppSettings } from '../../db/entities/AppSettings.entity'; -import type { AppSettingsRepository } from '../../db/repositories/AppSettings.repository'; -import { Users } from '../../db/entities/Users.entity'; -import type { UsersRepository } from '../../db/repositories/Users.repository'; -import { WebauthnCredentials } from '../../db/entities/WebauthnCredentials.entity'; -import type { WebauthnCredentialsRepository } from '../../db/repositories/WebauthnCredentials.repository'; -import { readAppSetting } from '../common/app-settings.registry'; +import { InjectRepository } from '@mikro-orm/nestjs'; +import { CanActivate, ExecutionContext, HttpException, Injectable, SetMetadata } from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; + +import type { Request } from 'express'; /** Metadata key `@MfaExempt()` writes. */ export const MFA_EXEMPT = 'trek:mfa-exempt'; diff --git a/server/src/nest/auth-core/optional-jwt.guard.ts b/server/src/nest/auth-core/optional-jwt.guard.ts index c25669573..ac948d961 100644 --- a/server/src/nest/auth-core/optional-jwt.guard.ts +++ b/server/src/nest/auth-core/optional-jwt.guard.ts @@ -1,9 +1,10 @@ -import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common'; -import { EntityManager } from '@mikro-orm/core'; -import type { Request } from 'express'; -import { Users } from '../../db/entities/Users.entity'; import { UserSessions } from '../../db/entities/UserSessions.entity'; +import { Users } from '../../db/entities/Users.entity'; import { extractToken, verifyJwtAndLoadUser } from './jwt-verify'; +import { EntityManager } from '@mikro-orm/core'; +import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common'; + +import type { Request } from 'express'; /** * Mirrors the legacy `optionalAuth` middleware: populates req.user with the @@ -21,7 +22,10 @@ export class OptionalJwtGuard implements CanActivate { async canActivate(context: ExecutionContext): Promise { const req = context.switchToHttp().getRequest(); const token = extractToken(req); - (req as { user: unknown }).user = (token ? await verifyJwtAndLoadUser(token, this.em.getRepository(Users), this.em.getRepository(UserSessions)) : null) || null; + (req as { user: unknown }).user = + (token + ? await verifyJwtAndLoadUser(token, this.em.getRepository(Users), this.em.getRepository(UserSessions)) + : null) || null; return true; } } diff --git a/server/src/nest/common/app-settings.registry.ts b/server/src/nest/common/app-settings.registry.ts index 7e6dd4740..27ff2a7a0 100644 --- a/server/src/nest/common/app-settings.registry.ts +++ b/server/src/nest/common/app-settings.registry.ts @@ -1,7 +1,8 @@ -import { z } from 'zod'; import { readEnv } from '../../app-config'; import type { AppSettingsRepository } from '../../db/repositories/AppSettings.repository'; +import { z } from 'zod'; + /** * The instance-wide settings (`app_settings`) the server reads by name, in one * typed register. @@ -51,10 +52,25 @@ export const APP_SETTINGS = { allow_registration: { schema: flag, default: 'true', adminForm: true }, allowed_file_types: { schema: text, default: null, adminForm: true }, require_mfa: { schema: flag, default: 'false', adminForm: true, publicConfig: true }, - smtp_host: { schema: text, default: null, env: (e) => e.smtp.host, adminForm: true, managedLocked: true, publicConfig: true }, + smtp_host: { + schema: text, + default: null, + env: (e) => e.smtp.host, + adminForm: true, + managedLocked: true, + publicConfig: true, + }, smtp_port: { schema: intText, default: null, env: (e) => e.smtp.port, adminForm: true, managedLocked: true }, smtp_user: { schema: text, default: null, env: (e) => e.smtp.user, adminForm: true, managedLocked: true }, - smtp_pass: { schema: text, default: null, env: (e) => e.smtp.pass, adminForm: true, masked: true, encrypted: 'always', managedLocked: true }, + smtp_pass: { + schema: text, + default: null, + env: (e) => e.smtp.pass, + adminForm: true, + masked: true, + encrypted: 'always', + managedLocked: true, + }, smtp_from: { schema: text, default: null, env: (e) => e.smtp.from, adminForm: true, managedLocked: true }, smtp_skip_tls_verify: { schema: flag, default: 'false', adminForm: true, managedLocked: true }, notification_channels: { schema: text, default: null, adminForm: true, publicConfig: true }, @@ -101,7 +117,8 @@ export const APP_SETTINGS = { export type AppSettingKey = keyof typeof APP_SETTINGS; const ENTRIES = Object.entries(APP_SETTINGS) as Array<[AppSettingKey, AppSettingDef]>; -const keysWhere = (pick: (def: AppSettingDef) => unknown): AppSettingKey[] => ENTRIES.filter(([, def]) => pick(def)).map(([key]) => key); +const keysWhere = (pick: (def: AppSettingDef) => unknown): AppSettingKey[] => + ENTRIES.filter(([, def]) => pick(def)).map(([key]) => key); /** The admin settings form's keys, in the order its answer has always listed them. */ export const ADMIN_FORM_SETTING_KEYS: readonly AppSettingKey[] = keysWhere((d) => d.adminForm); diff --git a/server/src/nest/common/google-maps-hosts.ts b/server/src/nest/common/google-maps-hosts.ts index b6cf7487d..0b0a4b42d 100644 --- a/server/src/nest/common/google-maps-hosts.ts +++ b/server/src/nest/common/google-maps-hosts.ts @@ -15,6 +15,5 @@ export const GOOGLE_SHORT_HOSTS = ['goo.gl', 'maps.app.goo.gl']; * `google.evil.com` is not a Google host. */ export function isGoogleMapsHost(hostname: string): boolean { - return GOOGLE_SHORT_HOSTS.includes(hostname) - || /^(www\.|maps\.)?google\.[a-z]{2,3}(\.[a-z]{2})?$/.test(hostname); + return GOOGLE_SHORT_HOSTS.includes(hostname) || /^(www\.|maps\.)?google\.[a-z]{2,3}(\.[a-z]{2})?$/.test(hostname); } diff --git a/server/src/nest/common/transit-provider.ts b/server/src/nest/common/transit-provider.ts index a1c969a57..bb8d73358 100644 --- a/server/src/nest/common/transit-provider.ts +++ b/server/src/nest/common/transit-provider.ts @@ -1,5 +1,5 @@ -import { TRANSIT_PROVIDERS, type TransitProvider } from '@trek/shared'; import type { AppSettingsRepository } from '../../db/repositories/AppSettings.repository'; +import { TRANSIT_PROVIDERS, type TransitProvider } from '@trek/shared'; /** * Which backend answers /api/transit (#1699), stored as one `app_settings` row. @@ -34,7 +34,10 @@ export async function readTransitProvider(appSettings: AppSettingsRepository): P return isTransitProvider(value) ? value : DEFAULT_TRANSIT_PROVIDER; } -export async function writeTransitProvider(appSettings: AppSettingsRepository, provider: TransitProvider): Promise { +export async function writeTransitProvider( + appSettings: AppSettingsRepository, + provider: TransitProvider, +): Promise { await appSettings.setValue(TRANSIT_PROVIDER_SETTING, provider); return provider; } diff --git a/server/src/nest/journey/journey-access.module.ts b/server/src/nest/journey/journey-access.module.ts index ca9655f3e..4894a2645 100644 --- a/server/src/nest/journey/journey-access.module.ts +++ b/server/src/nest/journey/journey-access.module.ts @@ -1,7 +1,7 @@ -import { Global, Module } from '@nestjs/common'; import { JOURNEY_ACCESS } from '../realtime/journey-access.types'; import { JourneyDomainModule } from './journey-domain.module'; import { JourneyDomainService } from './journey-domain.service'; +import { Global, Module } from '@nestjs/common'; /** * Binds the realtime gateway's JOURNEY_ACCESS port to JourneyDomainService. diff --git a/server/src/nest/maps/maps-index.helpers.ts b/server/src/nest/maps/maps-index.helpers.ts index 066acb61d..ba5039b98 100644 --- a/server/src/nest/maps/maps-index.helpers.ts +++ b/server/src/nest/maps/maps-index.helpers.ts @@ -4,11 +4,18 @@ * need no branch per source. Pure: the calls themselves are trek-places.client's, * the decision to make them is MapsService's. */ +import { buildOsmDetails, parsePoiCategories } from './maps.helpers'; +import { POI_RESULT_CAP, type PoiSearchResult } from './providers/osm.client'; +import { + POI_CATEGORY_TO_TREK, + isOsmHit, + osmPlaceId, + toPlaceRecord, + type TrekPlace, + type TrekSearchHit, +} from './trek-places.client'; import { normalizePlaceWebsite } from '@trek/shared'; import type { MapsAutocompleteResult } from '@trek/shared'; -import { buildOsmDetails, parsePoiCategories } from './maps.helpers'; -import { POI_CATEGORY_TO_TREK, isOsmHit, osmPlaceId, toPlaceRecord, type TrekPlace, type TrekSearchHit } from './trek-places.client'; -import { POI_RESULT_CAP, type PoiSearchResult } from './providers/osm.client'; type PoiBbox = { south: number; west: number; north: number; east: number }; @@ -69,7 +76,10 @@ export function indexPoiPlan(category: string, bbox: PoiBbox, limit: number): In // Half the diagonal, so the circle covers the viewport corners rather // than leaving the edges of the map empty. const reach = Math.round( - Math.hypot((bbox.north - bbox.south) * 111_320, (bbox.east - bbox.west) * 111_320 * Math.cos((lat * Math.PI) / 180)) / 2, + Math.hypot( + (bbox.north - bbox.south) * 111_320, + (bbox.east - bbox.west) * 111_320 * Math.cos((lat * Math.PI) / 180), + ) / 2, ); const radius = Math.min(20000, Math.max(300, reach)); // The same budget the Overpass path spends: per category, capped, so a @@ -182,7 +192,11 @@ export function indexSuggestions(found: TrekSearchHit[]): MapsAutocompleteResult * disappeared from its card afterwards, which reads like data loss. `osmTags` * is null when no OSM match passed the identity gates. */ -export function indexPlaceDetails(found: TrekPlace, osmTags: Record | null, placeId: string): Record { +export function indexPlaceDetails( + found: TrekPlace, + osmTags: Record | null, + placeId: string, +): Record { const record = toPlaceRecord(found); // Hours the index read off the operator's own site, run through the same // expansion OSM's go through — the client reads a list of weekday lines, @@ -194,7 +208,8 @@ export function indexPlaceDetails(found: TrekPlace, osmTags: Record { try { - return await safeFetchFollow(target, { signal: AbortSignal.timeout(10000), ...init }, { bypassInternalIpAllowed: true }); + return await safeFetchFollow( + target, + { signal: AbortSignal.timeout(10000), ...init }, + { bypassInternalIpAllowed: true }, + ); } catch (err) { if (err instanceof SsrfBlockedError) { throw Object.assign(new Error('URL blocked by SSRF check'), { status: 403 }); @@ -79,7 +82,11 @@ export class MapsUrlResolver { } let resolvedHost = ''; - try { resolvedHost = new URL(resolvedUrl).hostname; } catch { /* keep the empty host, both host branches are skipped */ } + try { + resolvedHost = new URL(resolvedUrl).hostname; + } catch { + /* keep the empty host, both host branches are skipped */ + } // Amap links first, and on their own: they spell the coordinate `lng,lat` // in GCJ-02, which the Google patterns below would read as a WGS-84 diff --git a/server/src/nest/maps/place-details.resolver.ts b/server/src/nest/maps/place-details.resolver.ts index 191eb25ce..2ec20e89a 100644 --- a/server/src/nest/maps/place-details.resolver.ts +++ b/server/src/nest/maps/place-details.resolver.ts @@ -7,18 +7,18 @@ * MapsService.getPlaceDetails / getPlaceDetailsExpanded delegate here and hand * in whether the index is switched on, which is a deployment property they own. */ -import { Injectable } from '@nestjs/common'; -import { InjectRepository } from '@mikro-orm/nestjs'; -import { normalizePlaceWebsite } from '@trek/shared'; import { PlaceDetailsCache } from '../../db/entities/PlaceDetailsCache.entity'; import type { PlaceDetailsCacheRepository } from '../../db/repositories/PlaceDetailsCache.repository'; +import { indexPlaceDetails } from './maps-index.helpers'; +import { toApiLang, isGooglePlaceId, OSM_PLACE_ID } from './maps.helpers'; +import { PlacesProviderSelector } from './places-provider.selector'; +import { isAmapPlaceId } from './providers/amap.provider'; import { GooglePlacesClient } from './providers/google-places.provider'; import { OsmClient } from './providers/osm.client'; -import { isAmapPlaceId } from './providers/amap.provider'; -import { PlacesProviderSelector } from './places-provider.selector'; import { trekPlacesById } from './trek-places.client'; -import { toApiLang, isGooglePlaceId, OSM_PLACE_ID } from './maps.helpers'; -import { indexPlaceDetails } from './maps-index.helpers'; +import { InjectRepository } from '@mikro-orm/nestjs'; +import { Injectable } from '@nestjs/common'; +import { normalizePlaceWebsite } from '@trek/shared'; /** * A details row as the cache holds it. A row written before #2483 still has a @@ -66,10 +66,12 @@ export class PlaceDetailsResolver { // resolveOsmIdentity applies everywhere: within range, and sharing a // substantial word of the name. A confident description of the building // next door is worse than none. - const osm = await this.osm.resolveOsmIdentity(found.name, found.lat, found.lng, { - lang, - maxDistanceM: 150, - }).catch(() => null); + const osm = await this.osm + .resolveOsmIdentity(found.name, found.lat, found.lng, { + lang, + maxDistanceM: 150, + }) + .catch(() => null); return { place: indexPlaceDetails(found, osm?.tags ?? null, placeId) }; } @@ -122,7 +124,13 @@ export class PlaceDetailsResolver { if (!place) return { place: null }; try { - await this.placeDetailsCache.upsertEntry({ place_id: placeId, lang: langKey, expanded: 0, payload_json: JSON.stringify(place), fetched_at: Date.now() }); + await this.placeDetailsCache.upsertEntry({ + place_id: placeId, + lang: langKey, + expanded: 0, + payload_json: JSON.stringify(place), + fetched_at: Date.now(), + }); } catch (dbErr) { console.error('Failed to cache place details:', dbErr); } @@ -184,10 +192,18 @@ export class PlaceDetailsResolver { if (cached) return { place: cachedDetails(cached.payload_json) }; } - const place = await this.googlePlaces.provider({ key: apiKey, source: null, userId }).placeDetailsExpanded(placeId, lang); + const place = await this.googlePlaces + .provider({ key: apiKey, source: null, userId }) + .placeDetailsExpanded(placeId, lang); try { - await this.placeDetailsCache.upsertEntry({ place_id: placeId, lang: langKey, expanded: 1, payload_json: JSON.stringify(place), fetched_at: Date.now() }); + await this.placeDetailsCache.upsertEntry({ + place_id: placeId, + lang: langKey, + expanded: 1, + payload_json: JSON.stringify(place), + fetched_at: Date.now(), + }); } catch (dbErr) { console.error('Failed to cache expanded place details:', dbErr); } diff --git a/server/src/nest/maps/place-photo.resolver.ts b/server/src/nest/maps/place-photo.resolver.ts index e654fa597..99725533a 100644 --- a/server/src/nest/maps/place-photo.resolver.ts +++ b/server/src/nest/maps/place-photo.resolver.ts @@ -8,16 +8,16 @@ * failed" (minutes), the in-flight dedupe of concurrent requests for one place * and the shared photo-fetch slots. MapsService.getPlacePhoto delegates to it. */ -import { Injectable } from '@nestjs/common'; -import { InjectRepository } from '@mikro-orm/nestjs'; import { Places } from '../../db/entities/Places.entity'; import type { PlacesRepository } from '../../db/repositories/Places.repository'; import { PlacePhotoCacheService } from '../place-photos/place-photo-cache.service'; +import { isGooglePlaceId } from './maps.helpers'; +import { withPhotoFetchSlot } from './photo-fetch-slot'; +import { PlacesProviderSelector } from './places-provider.selector'; import { GooglePlacesClient } from './providers/google-places.provider'; import { WikimediaClient } from './providers/wikimedia.client'; -import { PlacesProviderSelector } from './places-provider.selector'; -import { withPhotoFetchSlot } from './photo-fetch-slot'; -import { isGooglePlaceId } from './maps.helpers'; +import { InjectRepository } from '@mikro-orm/nestjs'; +import { Injectable } from '@nestjs/common'; @Injectable() export class PlacePhotoResolver { @@ -63,7 +63,10 @@ export class PlacePhotoResolver { if (existing !== undefined) { const result = await existing; if (!result) return noPhoto; - return { photoUrl: `/api/maps/place-photo/${encodeURIComponent(placeId)}/bytes`, attribution: result.attribution }; + return { + photoUrl: `/api/maps/place-photo/${encodeURIComponent(placeId)}/bytes`, + attribution: result.attribution, + }; } // Tells the two empty outcomes apart for the negative cache below: a place that diff --git a/server/src/nest/maps/places-provider.selector.ts b/server/src/nest/maps/places-provider.selector.ts index 56ace3875..54a845f40 100644 --- a/server/src/nest/maps/places-provider.selector.ts +++ b/server/src/nest/maps/places-provider.selector.ts @@ -10,18 +10,18 @@ * this class only resolves it, through the same app_settings and users rows * the key chain always read, in the same order. */ -import { Injectable } from '@nestjs/common'; -import { InjectRepository } from '@mikro-orm/nestjs'; -import { isOutsideChina } from '@trek/shared'; import { readEnv } from '../../app-config'; -import { resolveApiKey, type ApiKeySource } from '../settings/instance-api-keys'; import { AppSettings } from '../../db/entities/AppSettings.entity'; -import type { AppSettingsRepository } from '../../db/repositories/AppSettings.repository'; import { Users } from '../../db/entities/Users.entity'; +import type { AppSettingsRepository } from '../../db/repositories/AppSettings.repository'; import type { UsersRepository } from '../../db/repositories/Users.repository'; import { GoogleQuotaService } from '../google-quota/google-quota.service'; -import { isPlacesProviderChoice, type PlacesProviderChoice } from './providers/places-provider'; +import { resolveApiKey, type ApiKeySource } from '../settings/instance-api-keys'; import { AmapPlacesProvider, AmapTipStash, isAmapPlaceId } from './providers/amap.provider'; +import { isPlacesProviderChoice, type PlacesProviderChoice } from './providers/places-provider'; +import { InjectRepository } from '@mikro-orm/nestjs'; +import { Injectable } from '@nestjs/common'; +import { isOutsideChina } from '@trek/shared'; /** * The app_settings row that names the keyed places provider. @@ -44,8 +44,7 @@ export const PLACES_GOOGLE_ONLY_SETTING = 'places_google_only'; * credential, an Amap provider, or nobody (the OpenStreetMap stack alone). */ export type KeyedProvider = - | { id: 'google'; key: string; source: ApiKeySource | null } - | { id: 'amap'; provider: AmapPlacesProvider }; + { id: 'google'; key: string; source: ApiKeySource | null } | { id: 'amap'; provider: AmapPlacesProvider }; @Injectable() export class PlacesProviderSelector { @@ -130,7 +129,8 @@ export class PlacesProviderSelector { const google = await this.resolveMapsKey(userId); // Past the daily ceiling (#1582) the key is spent for today: answer as if // there were none, so `auto` moves on and OpenStreetMap fills in. - if (google.key && !(await this.googleQuota.exhausted())) return { id: 'google', key: google.key, source: google.source }; + if (google.key && !(await this.googleQuota.exhausted())) + return { id: 'google', key: google.key, source: google.source }; // An explicit 'google' choice with no key is not a reason to query Amap // instead: this install is on Google and is misconfigured. OSM answers, // the way a keyless install has always been answered. diff --git a/server/src/nest/maps/providers/google-places.provider.ts b/server/src/nest/maps/providers/google-places.provider.ts index 74f30fc2e..224bbf695 100644 --- a/server/src/nest/maps/providers/google-places.provider.ts +++ b/server/src/nest/maps/providers/google-places.provider.ts @@ -15,11 +15,9 @@ * orchestrator depends on its providers, not the other way round * (lint:boundaries holds that). */ -import { Injectable } from '@nestjs/common'; -import { normalizePlaceWebsite } from '@trek/shared'; import { readEnv, getAppUrl } from '../../../app-config'; -import type { ApiKeySource } from '../../settings/instance-api-keys'; import { GoogleQuotaService } from '../../google-quota/google-quota.service'; +import type { ApiKeySource } from '../../settings/instance-api-keys'; import { googleFtidFromMapsUrl, isGooglePlaceId, @@ -37,6 +35,8 @@ import type { SearchBias, ViewportBias, } from './places-provider'; +import { Injectable } from '@nestjs/common'; +import { normalizePlaceWebsite } from '@trek/shared'; // ── Google API call counter ─────────────────────────────────────────────────── @@ -160,7 +160,11 @@ const DETAILS_FIELD_MASK = 'id,displayName,formattedAddress,location,rating,userRatingCount,websiteUri,nationalPhoneNumber,regularOpeningHours,googleMapsUri'; /** A Google error answer as the Error the callers have always thrown: Google's message, Google's status. */ -function googleError(data: { error?: { message?: string } }, fallback: string, status: number): Error & { status: number } { +function googleError( + data: { error?: { message?: string } }, + fallback: string, + status: number, +): Error & { status: number } { const err = new Error(data.error?.message || fallback) as Error & { status: number }; err.status = status; return err; @@ -223,12 +227,20 @@ export class GooglePlacesClient { * call for the whole strip, while every reference turned into an image is a * separate billed /media call. Callers fetch bytes only for what they show. */ - async fetchGooglePhotoRefs(placeId: string, apiKey: string, cap: number): Promise<{ name: string; attribution: string | null }[]> { + async fetchGooglePhotoRefs( + placeId: string, + apiKey: string, + cap: number, + ): Promise<{ name: string; attribution: string | null }[]> { if (!isGooglePlaceId(placeId) || cap < 1) return []; try { - const res = await this.fetch(`https://places.googleapis.com/v1/places/${placeId}`, `fetchGooglePhotoRefs(${placeId})`, { - headers: { 'X-Goog-Api-Key': apiKey, 'X-Goog-FieldMask': 'photos' }, - }); + const res = await this.fetch( + `https://places.googleapis.com/v1/places/${placeId}`, + `fetchGooglePhotoRefs(${placeId})`, + { + headers: { 'X-Goog-Api-Key': apiKey, 'X-Goog-FieldMask': 'photos' }, + }, + ); if (!res.ok) return []; const data = (await res.json()) as GooglePlaceDetails; return (data.photos ?? []).slice(0, cap).map((photo) => ({ @@ -292,12 +304,16 @@ export class GooglePlacesClient { */ async firstPhoto(placeId: string, apiKey: string): Promise { // Fetch details to get the photo name - const detailsRes = await this.fetch(`https://places.googleapis.com/v1/places/${placeId}`, `getPlacePhoto/details(${placeId})`, { - headers: { - 'X-Goog-Api-Key': apiKey, - 'X-Goog-FieldMask': 'photos', + const detailsRes = await this.fetch( + `https://places.googleapis.com/v1/places/${placeId}`, + `getPlacePhoto/details(${placeId})`, + { + headers: { + 'X-Goog-Api-Key': apiKey, + 'X-Goog-FieldMask': 'photos', + }, }, - }); + ); const body = await detailsRes.text(); if (!detailsRes.ok) { console.error('Google Places photo details error:', detailsRes.status, body.slice(0, 200)); @@ -384,10 +400,19 @@ export class GooglePlacesProvider implements PlacesProvider { } /** Places of any kind around a point, nearest first by Google's own ranking (#976). */ - async searchNearby(origin: { lat: number; lng: number }, radius: number, limit: number, lang: string): Promise { + async searchNearby( + origin: { lat: number; lng: number }, + radius: number, + limit: number, + lang: string, + ): Promise { const response = await this.client.fetch('https://places.googleapis.com/v1/places:searchNearby', 'searchNearby', { method: 'POST', - headers: { 'Content-Type': 'application/json', 'X-Goog-Api-Key': this.credential.key, 'X-Goog-FieldMask': SEARCH_TEXT_FIELD_MASK }, + headers: { + 'Content-Type': 'application/json', + 'X-Goog-Api-Key': this.credential.key, + 'X-Goog-FieldMask': SEARCH_TEXT_FIELD_MASK, + }, body: JSON.stringify({ maxResultCount: limit, rankPreference: 'DISTANCE', @@ -403,7 +428,12 @@ export class GooglePlacesProvider implements PlacesProvider { return (data.places || []).filter(isOpenGooglePlace).map(googlePlaceRecord); } - async autocomplete(input: string, lang?: string, bias?: ViewportBias, sessionToken?: string): Promise { + async autocomplete( + input: string, + lang?: string, + bias?: ViewportBias, + sessionToken?: string, + ): Promise { const body: Record = { input, languageCode: toApiLang(lang), diff --git a/server/src/nest/maps/providers/osm.client.ts b/server/src/nest/maps/providers/osm.client.ts index d737fdc94..a4ad58c78 100644 --- a/server/src/nest/maps/providers/osm.client.ts +++ b/server/src/nest/maps/providers/osm.client.ts @@ -16,9 +16,8 @@ * Never imports MapsService: providers sit below the orchestrator * (lint:boundaries holds that). */ -import { Injectable } from '@nestjs/common'; -import { normalizePlaceWebsite } from '@trek/shared'; import { nominatimFetch, type GeoLane } from '../../geo/nominatim.client'; +import { nearbyOverpassQuery, overpassNearbyRecords } from '../maps-nearby.helpers'; import { UA, toApiLang, @@ -35,8 +34,9 @@ import { OSM_PLACE_ID, type OverpassPoi, } from '../maps.helpers'; -import { nearbyOverpassQuery, overpassNearbyRecords } from '../maps-nearby.helpers'; import { readWikiIdentity } from './wiki-identity'; +import { Injectable } from '@nestjs/common'; +import { normalizePlaceWebsite } from '@trek/shared'; function nominatimCategory(item: { class?: string; type?: string }): string | null { if (!item.class || !item.type || item.type === 'yes') return null; @@ -202,7 +202,12 @@ export class OsmClient { * whole import. Yielding does not make the import faster, it stops it from * being the only thing the process will do for half a minute. */ - async searchNominatim(query: string, lang?: string, lane: GeoLane = 'interactive', bias?: { lat: number; lng: number }) { + async searchNominatim( + query: string, + lang?: string, + lane: GeoLane = 'interactive', + bias?: { lat: number; lng: number }, + ) { const params = new URLSearchParams({ q: query, format: 'json', @@ -259,7 +264,10 @@ export class OsmClient { } /** Suggestions out of a Nominatim search, for an install with neither the index nor a key. */ - async autocompleteNominatim(input: string, lang?: string): Promise<{ suggestions: { placeId: string; mainText: string; secondaryText: string }[]; source: 'nominatim' }> { + async autocompleteNominatim( + input: string, + lang?: string, + ): Promise<{ suggestions: { placeId: string; mainText: string; secondaryText: string }[]; source: 'nominatim' }> { try { const places = await this.searchNominatim(input, lang); const suggestions = places @@ -472,11 +480,7 @@ export class OsmClient { // Nominatim's extratags carry the wikidata/wikipedia/commons tags too, so // a place keeps its pictures and its description when Overpass times out // instead of falling back to "photographed within 300m". - const details = buildOsmDetails( - { ...(nominatim?.extratags ?? {}), ...(element?.tags ?? {}) }, - osmType, - osmId, - ); + const details = buildOsmDetails({ ...(nominatim?.extratags ?? {}), ...(element?.tags ?? {}) }, osmType, osmId); return { ...details, @@ -517,7 +521,12 @@ export class OsmClient { } /** Places of any kind around a point, from the Overpass mirrors, nearest first (#976). */ - async nearby(origin: { lat: number; lng: number }, radius: number, limit: number, osmLang: string): Promise[]> { + async nearby( + origin: { lat: number; lng: number }, + radius: number, + limit: number, + osmLang: string, + ): Promise[]> { const elements = await overpassFetch(nearbyOverpassQuery(origin.lat, origin.lng, radius, limit)); return overpassNearbyRecords(elements, origin, osmLang, limit); } @@ -586,8 +595,7 @@ export class OsmClient { // `operator` comes last but matters for the road categories: petrol stations, // charging points and service areas are routinely mapped with an operator and no // name, and dropping those would empty the road trip corridor over long stretches. - const name = - tags[`name:${osmLang}`] || tags['int_name'] || tags.name || tags.brand || tags.operator || null; + const name = tags[`name:${osmLang}`] || tags['int_name'] || tags.name || tags.brand || tags.operator || null; if (!name) continue; // unnamed POIs aren't useful to add to a plan // A shut-down place is not somewhere to plan a visit (#1341). OSM usually // re-tags one with a `disused:`/`abandoned:` prefix, and those never match diff --git a/server/src/nest/maps/providers/wikimedia.client.ts b/server/src/nest/maps/providers/wikimedia.client.ts index c0c42ac89..de9951c72 100644 --- a/server/src/nest/maps/providers/wikimedia.client.ts +++ b/server/src/nest/maps/providers/wikimedia.client.ts @@ -12,11 +12,12 @@ * column both ask through this client. It never imports MapsService: * providers sit below the orchestrator (lint:boundaries holds that). */ -import { Injectable } from '@nestjs/common'; -import { Jimp } from 'jimp'; -import { safeFetchFollow } from '../../../utils/ssrfGuard'; import { discardBody, exceedsDeclaredLength, readCapped } from '../../../utils/cappedFetch'; +import { safeFetchFollow } from '../../../utils/ssrfGuard'; import { UA, stripWikiMarkup, parseWikipediaTag } from '../maps.helpers'; +import { Injectable } from '@nestjs/common'; + +import { Jimp } from 'jimp'; interface WikidataSnak { mainsnak?: { datavalue?: { value?: string } }; @@ -84,7 +85,11 @@ function wikidataImageClaims(claims: WikidataClaims, limit: number): string[] { /** `File:` prefix off, underscores and case normalised — Commons treats these as one title. */ function normalizeFileTitle(title: string): string { - return title.replace(/^File:/i, '').replaceAll('_', ' ').trim().toLowerCase(); + return title + .replace(/^File:/i, '') + .replaceAll('_', ' ') + .trim() + .toLowerCase(); } /** @@ -325,7 +330,11 @@ export class WikimediaClient { // The same six seconds the Wikidata hop above allows. Without a deadline // this waited on undici's five-minute default, holding a request context // and a socket per stalled logo while the pin sat on its fallback icon. - const imgRes = await safeFetchFollow(url, { signal: AbortSignal.timeout(WIKI_TIMEOUT_MS) }, { bypassInternalIpAllowed: true }); + const imgRes = await safeFetchFollow( + url, + { signal: AbortSignal.timeout(WIKI_TIMEOUT_MS) }, + { bypassInternalIpAllowed: true }, + ); if (!imgRes.ok) return remember(null); if (exceedsDeclaredLength(imgRes, BRAND_LOGO_MAX_BYTES)) { @@ -360,7 +369,11 @@ export class WikimediaClient { try { const wiki = await this.fetchWikimediaPhoto(lat, lng, name); if (!wiki) return { kind: 'none' }; - const imgRes = await safeFetchFollow(wiki.photoUrl, { signal: AbortSignal.timeout(WIKI_TIMEOUT_MS) }, { bypassInternalIpAllowed: true }); + const imgRes = await safeFetchFollow( + wiki.photoUrl, + { signal: AbortSignal.timeout(WIKI_TIMEOUT_MS) }, + { bypassInternalIpAllowed: true }, + ); if (!imgRes.ok) return { kind: 'failed' }; if (exceedsDeclaredLength(imgRes, WIKIMEDIA_PHOTO_MAX_BYTES)) { discardBody(imgRes); @@ -376,7 +389,11 @@ export class WikimediaClient { // ── Wikimedia Commons photo lookup ───────────────────────────────────────── - async fetchWikimediaPhoto(lat: number, lng: number, name?: string): Promise<{ photoUrl: string; attribution: string | null } | null> { + async fetchWikimediaPhoto( + lat: number, + lng: number, + name?: string, + ): Promise<{ photoUrl: string; attribution: string | null } | null> { // Strategy 1: Search Wikipedia for the place name -> get the article image if (name) { try { @@ -397,7 +414,9 @@ export class WikimediaClient { signal: AbortSignal.timeout(WIKI_TIMEOUT_MS), }); if (res.ok) { - const data = (await res.json()) as { query?: { pages?: Record } }; + const data = (await res.json()) as { + query?: { pages?: Record }; + }; const pages = data.query?.pages; if (pages) { for (const page of Object.values(pages)) { @@ -592,7 +611,11 @@ export class WikimediaClient { * tag — which is most of them, because mappers add one or the other. One * request, a few hundred bytes. */ - async fetchWikidataSitelinks(wikidataId: string, sites: string[], signal?: AbortSignal): Promise> { + async fetchWikidataSitelinks( + wikidataId: string, + sites: string[], + signal?: AbortSignal, + ): Promise> { const qid = wikidataId.trim(); if (!/^Q\d+$/.test(qid) || sites.length === 0) return {}; const params = new URLSearchParams({ @@ -642,10 +665,13 @@ export class WikimediaClient { const qid = wikidataId.trim(); if (!/^Q\d+$/.test(qid)) return empty; try { - const res = await fetch(`https://www.wikidata.org/w/api.php?action=wbgetentities&props=claims&ids=${qid}&format=json`, { - headers: { 'User-Agent': UA }, - signal: AbortSignal.timeout(WIKI_TIMEOUT_MS), - }); + const res = await fetch( + `https://www.wikidata.org/w/api.php?action=wbgetentities&props=claims&ids=${qid}&format=json`, + { + headers: { 'User-Agent': UA }, + signal: AbortSignal.timeout(WIKI_TIMEOUT_MS), + }, + ); if (!res.ok) return empty; const data = (await res.json()) as { entities?: Record }; const claims = data.entities?.[qid]?.claims; diff --git a/server/src/nest/oidc/oidc.dto.ts b/server/src/nest/oidc/oidc.dto.ts index e6e6daf42..e351aaef1 100644 --- a/server/src/nest/oidc/oidc.dto.ts +++ b/server/src/nest/oidc/oidc.dto.ts @@ -1,5 +1,6 @@ -import { createZodDto } from 'nestjs-zod'; import { adminOidcUpdateRequestSchema } from '@trek/shared'; +import { createZodDto } from 'nestjs-zod'; + /** Body of the admin OIDC settings route OidcController serves. */ export class AdminOidcUpdateDto extends createZodDto(adminOidcUpdateRequestSchema) {} diff --git a/server/src/nest/place-import/directions-url.helpers.ts b/server/src/nest/place-import/directions-url.helpers.ts index fbf243db7..a33c0e2e1 100644 --- a/server/src/nest/place-import/directions-url.helpers.ts +++ b/server/src/nest/place-import/directions-url.helpers.ts @@ -108,7 +108,10 @@ function fromPath(url: URL): DirWaypoint[] { // The viewport and the blob end the list of stops; everything after them belongs to // how the map was framed, not to where the car goes. if (part.startsWith('@')) continue; - if (part.startsWith('data=')) { data = part; continue; } + if (part.startsWith('data=')) { + data = part; + continue; + } if (data) continue; const wp = asWaypoint(decodeSegment(part)); if (wp) names.push(wp); @@ -119,7 +122,7 @@ function fromPath(url: URL): DirWaypoint[] { // match cannot be lined up — Google drops the pair for "your location" and adds pairs // of its own for a route through several countries — and half a link read confidently // is worse than a link read as names and geocoded. - const pairs = [...(data.matchAll(DATA_LNG_LAT))] + const pairs = [...data.matchAll(DATA_LNG_LAT)] .map((m) => ({ lng: Number.parseFloat(m[1]), lat: Number.parseFloat(m[2]) })) .filter((p) => onEarth(p.lat, p.lng)); if (pairs.length !== names.length) return names; diff --git a/server/src/nest/place-import/gpx.codec.ts b/server/src/nest/place-import/gpx.codec.ts index 222b6392b..87704e708 100644 --- a/server/src/nest/place-import/gpx.codec.ts +++ b/server/src/nest/place-import/gpx.codec.ts @@ -84,7 +84,9 @@ export interface GpxDocument { /** A GPX 1.1 document, elements in the schema's order (metadata, wpt, rte, trk). */ export function writeGpx(doc: GpxDocument): string { - const namespaces = Object.fromEntries(Object.entries(doc.namespaces ?? {}).map(([prefix, uri]) => [`@_xmlns:${prefix}`, uri])); + const namespaces = Object.fromEntries( + Object.entries(doc.namespaces ?? {}).map(([prefix, uri]) => [`@_xmlns:${prefix}`, uri]), + ); return gpxBuilder.build({ '?xml': { '@_version': '1.0', '@_encoding': 'UTF-8' }, gpx: { diff --git a/server/src/nest/place-import/kml.codec.ts b/server/src/nest/place-import/kml.codec.ts index b06dbe6c0..146be85f8 100644 --- a/server/src/nest/place-import/kml.codec.ts +++ b/server/src/nest/place-import/kml.codec.ts @@ -9,12 +9,13 @@ * Which placemarks become places, and under which category, is the importing * domain's decision. */ -import { TextDecoder } from 'util'; -import { XMLParser, XMLValidator } from 'fast-xml-parser'; -import unzipper from 'unzipper'; import { stripHtmlTags } from '../common/stripHtmlTags'; import type { KmlDocumentRead, KmlImportSummary, ParsedKmlPlacemark } from './place-import.types'; +import { XMLParser, XMLValidator } from 'fast-xml-parser'; +import unzipper from 'unzipper'; +import { TextDecoder } from 'util'; + export type { KmlImportSummary, ParsedKmlPlacemark } from './place-import.types'; export interface KmlPlacemarkNode { @@ -110,14 +111,16 @@ export function sanitizeKmlDescription(value: unknown): string | null { return decoded || null; } -export function parseKmlLineStringCoordinates(value: unknown): Array<{ lat: number; lng: number; ele: number | null }> | null { +export function parseKmlLineStringCoordinates( + value: unknown, +): Array<{ lat: number; lng: number; ele: number | null }> | null { const coordinates = asTrimmedString(value); if (!coordinates) return null; const points = coordinates .trim() .split(/\s+/) - .map(coord => { + .map((coord) => { const parts = coord.split(','); const lng = Number.parseFloat(parts[0] ?? ''); const lat = Number.parseFloat(parts[1] ?? ''); @@ -189,8 +192,8 @@ export function parsePlacemarkNode(node: KmlPlacemarkNode): ParsedKmlPlacemark { const linePts = parseKmlLineStringCoordinates(node.placemark?.LineString?.coordinates); if (linePts) { pathFirstPt = { lat: linePts[0].lat, lng: linePts[0].lng }; - const hasAllEle = linePts.every(p => p.ele !== null); - routeGeometry = JSON.stringify(linePts.map(p => hasAllEle ? [p.lat, p.lng, p.ele] : [p.lat, p.lng])); + const hasAllEle = linePts.every((p) => p.ele !== null); + routeGeometry = JSON.stringify(linePts.map((p) => (hasAllEle ? [p.lat, p.lng, p.ele] : [p.lat, p.lng]))); } } @@ -249,7 +252,9 @@ export async function unpackKmzToKml( throw new Error('Invalid KMZ archive.'); } - const kmlEntries = zip.files.filter((entry) => !entry.path.endsWith('/') && entry.path.toLowerCase().endsWith('.kml')); + const kmlEntries = zip.files.filter( + (entry) => !entry.path.endsWith('/') && entry.path.toLowerCase().endsWith('.kml'), + ); if (kmlEntries.length === 0) { throw new Error('KMZ archive does not contain a KML file.'); } diff --git a/server/src/nest/place-import/place-import.module.ts b/server/src/nest/place-import/place-import.module.ts index a5e7865e1..409f2eb01 100644 --- a/server/src/nest/place-import/place-import.module.ts +++ b/server/src/nest/place-import/place-import.module.ts @@ -1,11 +1,11 @@ -import { Module } from '@nestjs/common'; import { PlaceImportService } from './place-import.service'; -import { GpxProvider } from './providers/gpx.provider'; import { CollectionGpxProvider } from './providers/collection-gpx.provider'; -import { KmlProvider } from './providers/kml.provider'; -import { GoogleListProvider } from './providers/google-list.provider'; import { GoogleDirectionsProvider } from './providers/google-directions.provider'; +import { GoogleListProvider } from './providers/google-list.provider'; +import { GpxProvider } from './providers/gpx.provider'; +import { KmlProvider } from './providers/kml.provider'; import { NaverListProvider } from './providers/naver-list.provider'; +import { Module } from '@nestjs/common'; /** * Place import (leaf module): reads places out of GPX, KML/KMZ, Google lists, diff --git a/server/src/nest/place-import/place-import.service.ts b/server/src/nest/place-import/place-import.service.ts index fa4ea030c..a5488e1aa 100644 --- a/server/src/nest/place-import/place-import.service.ts +++ b/server/src/nest/place-import/place-import.service.ts @@ -1,11 +1,3 @@ -import { Injectable } from '@nestjs/common'; -import type { CollectionGpxExport, CollectionGpxReadResult } from '@trek/shared'; -import { GpxProvider, type GpxExportInput, type GpxExportOptions } from './providers/gpx.provider'; -import { CollectionGpxProvider, type ExportedCollectionFile } from './providers/collection-gpx.provider'; -import { KmlProvider } from './providers/kml.provider'; -import { GoogleListProvider } from './providers/google-list.provider'; -import { GoogleDirectionsProvider } from './providers/google-directions.provider'; -import { NaverListProvider } from './providers/naver-list.provider'; import type { DirectionsRead, DirectionsRedirect, @@ -17,6 +9,14 @@ import type { NaverListPlace, PreparedGpxPlace, } from './place-import.types'; +import { CollectionGpxProvider, type ExportedCollectionFile } from './providers/collection-gpx.provider'; +import { GoogleDirectionsProvider } from './providers/google-directions.provider'; +import { GoogleListProvider } from './providers/google-list.provider'; +import { GpxProvider, type GpxExportInput, type GpxExportOptions } from './providers/gpx.provider'; +import { KmlProvider } from './providers/kml.provider'; +import { NaverListProvider } from './providers/naver-list.provider'; +import { Injectable } from '@nestjs/common'; +import type { CollectionGpxExport, CollectionGpxReadResult } from '@trek/shared'; // The directions-link reading is also what the road trip planner reads a // shared route with, and what the import routes dispatch on. diff --git a/server/src/nest/place-import/providers/collection-gpx.provider.ts b/server/src/nest/place-import/providers/collection-gpx.provider.ts index 4417178c4..db4375bf4 100644 --- a/server/src/nest/place-import/providers/collection-gpx.provider.ts +++ b/server/src/nest/place-import/providers/collection-gpx.provider.ts @@ -1,5 +1,6 @@ +import { coord, gpxTextParser, writeGpx } from '../gpx.codec'; +import { CollectionGpxError } from '../place-import.types'; import { Injectable } from '@nestjs/common'; -import type { ZodType } from 'zod'; import { COLLECTION_FILE_FORMAT, COLLECTION_FILE_VERSION, @@ -19,8 +20,8 @@ import { type CollectionGpxReadResult, type CollectionLink, } from '@trek/shared'; -import { coord, gpxTextParser, writeGpx } from '../gpx.codec'; -import { CollectionGpxError } from '../place-import.types'; + +import type { ZodType } from 'zod'; export { CollectionGpxError } from '../place-import.types'; @@ -51,7 +52,7 @@ const HTTP_URL = /^https?:\/\//i; /** The extension fields that hold one value each; labels and links repeat. */ const SCALAR_EXTENSIONS = (Object.keys(COLLECTION_GPX_PLACE_FIELDS) as (keyof CollectionFilePlace)[]).filter( - field => COLLECTION_GPX_PLACE_FIELDS[field] === 'extension' && field !== 'labels' && field !== 'links', + (field) => COLLECTION_GPX_PLACE_FIELDS[field] === 'extension' && field !== 'labels' && field !== 'links', ); // --------------------------------------------------------------------------- @@ -64,7 +65,7 @@ const SCALAR_EXTENSIONS = (Object.keys(COLLECTION_GPX_PLACE_FIELDS) as (keyof Co * name is enough for a strict reader to refuse the whole file. */ function xmlText(value: string): string { - return value.replace(/[\p{Cc}\p{Cs}\uFFFE\uFFFF]/gu, ch => (ch === '\t' || ch === '\n' || ch === '\r' ? ch : '')); + return value.replace(/[\p{Cc}\p{Cs}\uFFFE\uFFFF]/gu, (ch) => (ch === '\t' || ch === '\n' || ch === '\r' ? ch : '')); } /** A value as element text, or nothing, since an empty element is noise to every reader. */ @@ -92,9 +93,9 @@ function placeExtensions(place: CollectionFilePlace): Record | } const labels = (place.labels ?? []).map(textOf).filter(Boolean); if (labels.length) ext[`${TREK}:label`] = labels; - const links = (place.links ?? []).filter(link => HTTP_URL.test(link.url)); + const links = (place.links ?? []).filter((link) => HTTP_URL.test(link.url)); if (links.length) { - ext[`${TREK}:link`] = links.map(link => ({ '@_href': xmlText(link.url), '#text': textOf(link.label) })); + ext[`${TREK}:link`] = links.map((link) => ({ '@_href': xmlText(link.url), '#text': textOf(link.label) })); } return Object.keys(ext).length ? ext : undefined; } @@ -119,7 +120,7 @@ function metadata(file: ExportedCollectionFile): Record { if (file.color) ext[`${TREK}:color`] = textOf(file.color); if (file.icon) ext[`${TREK}:icon`] = textOf(file.icon); if (file.labels?.length) { - ext[`${TREK}:label`] = file.labels.map(label => ({ + ext[`${TREK}:label`] = file.labels.map((label) => ({ ...(label.color ? { '@_color': xmlText(label.color) } : {}), '#text': xmlText(label.name), })); @@ -238,8 +239,8 @@ function fit(schema: ZodType, raw: Record, required: read const given = Object.fromEntries(Object.entries(raw).filter(([, v]) => v !== null && v !== undefined)); const first = schema.safeParse(given); if (first.success) return first.data; - const refused = new Set(first.error.issues.map(issue => String(issue.path[0]))); - if (required.some(field => refused.has(field))) return null; + const refused = new Set(first.error.issues.map((issue) => String(issue.path[0]))); + if (required.some((field) => refused.has(field))) return null; const second = schema.safeParse(Object.fromEntries(Object.entries(given).filter(([key]) => !refused.has(key)))); return second.success ? second.data : null; } @@ -248,10 +249,10 @@ const LABEL_NAME = collectionFilePlaceSchema.shape.labels.unwrap().element; /** GPX 1.1 ``, or GPX 1.0's `` beside ``. */ function gpxLinks(el: XmlNode): CollectionLink[] { - const found = asArray(el.link).map(link => ({ url: attr(link, 'href'), label: text(node(link).text) })); + const found = asArray(el.link).map((link) => ({ url: attr(link, 'href'), label: text(node(link).text) })); if (found.length === 0 && text(el.url)) found.push({ url: text(el.url), label: text(el.urlname) }); return found - .map(link => fit(collectionLinkSchema, link, ['url'])) + .map((link) => fit(collectionLinkSchema, link, ['url'])) .filter((link): link is CollectionLink => link !== null); } @@ -294,7 +295,7 @@ function readPlace(value: unknown, fallbackName: string | null, prefixes: Prefix const cmt = text(el.cmt); const [website, ...moreLinks] = gpxLinks(el); const ownLinks = asArray(own('link')) - .map(link => fit(collectionLinkSchema, { url: attr(link, 'href'), label: text(link) }, ['url'])) + .map((link) => fit(collectionLinkSchema, { url: attr(link, 'href'), label: text(link) }, ['url'])) .filter((link): link is CollectionLink => link !== null); const raw: Record = { @@ -325,7 +326,11 @@ function readPlace(value: unknown, fallbackName: string | null, prefixes: Prefix } function listName(root: XmlNode, meta: XmlNode, fileName: string | undefined): string { - const fromFile = fileName?.split(/[\\/]/).pop()?.replace(/\.gpx$/i, '').trim(); + const fromFile = fileName + ?.split(/[\\/]/) + .pop() + ?.replace(/\.gpx$/i, '') + .trim(); // GPX 1.1 keeps the name in , GPX 1.0 on the root. const name = text(meta.name) ?? text(root.name) ?? (fromFile || 'GPX'); return clip(name, collectionFileSchema.shape.name.maxLength); @@ -416,7 +421,7 @@ export function gpxToCollectionFile(source: string, fileName?: string): Collecti const metaExt = node(meta.extensions); const own = (field: string): unknown => (prefixes.trek ? metaExt[`${prefixes.trek}:${field}`] : undefined); const labels = asArray(own('label')) - .map(label => fit(collectionFileLabelSchema, { name: text(label), color: attr(label, 'color') }, ['name'])) + .map((label) => fit(collectionFileLabelSchema, { name: text(label), color: attr(label, 'color') }, ['name'])) .filter((label): label is CollectionFileLabel => label !== null) .slice(0, MAX_COLLECTION_FILE_LABELS); diff --git a/server/src/nest/place-import/providers/google-directions.provider.ts b/server/src/nest/place-import/providers/google-directions.provider.ts index 591c72a97..98a94c8ac 100644 --- a/server/src/nest/place-import/providers/google-directions.provider.ts +++ b/server/src/nest/place-import/providers/google-directions.provider.ts @@ -13,11 +13,11 @@ * provider asks no geocoder: choosing which source answers is MapsService's * job, reached through PlaceImportService. */ -import { Injectable } from '@nestjs/common'; import { checkSsrf, safeFetchFollow, SsrfBlockedError } from '../../../utils/ssrfGuard'; import { GOOGLE_SHORT_HOSTS, isGoogleMapsHost } from '../../common/google-maps-hosts'; import { parseDirectionsUrl, type DirWaypoint } from '../directions-url.helpers'; import type { ListImportError } from '../place-import.types'; +import { Injectable } from '@nestjs/common'; @Injectable() export class GoogleDirectionsProvider { @@ -27,7 +27,11 @@ export class GoogleDirectionsProvider { if (!ssrf.allowed) return { error: 'URL is not allowed', status: 400 }; let parsed: URL; - try { parsed = new URL(url); } catch { return { error: 'Invalid URL', status: 400 }; } + try { + parsed = new URL(url); + } catch { + return { error: 'Invalid URL', status: 400 }; + } // Short links are resolved hop by hop through the guard, exactly as the list import // does it: a maps.app.goo.gl that 302s to an internal address is still blocked. @@ -45,14 +49,22 @@ export class GoogleDirectionsProvider { // Checked after resolving, not before: the host that counts is the one the link lands // on, and `/maps/dir/` is a path anybody could serve. let host = ''; - try { host = new URL(resolvedUrl).hostname; } catch { /* an unparseable hop fails the check below */ } + try { + host = new URL(resolvedUrl).hostname; + } catch { + /* an unparseable hop fails the check below */ + } if (!isGoogleMapsHost(host)) { return { error: 'That link is not a Google Maps link.', status: 400 }; } const waypoints = parseDirectionsUrl(resolvedUrl); if (waypoints.length < 2) { - return { error: 'Could not read any stops from that directions link. Open the route in Google Maps and use its Share button.', status: 400 }; + return { + error: + 'Could not read any stops from that directions link. Open the route in Google Maps and use its Share button.', + status: 400, + }; } return waypoints; } diff --git a/server/src/nest/place-import/providers/google-list.provider.ts b/server/src/nest/place-import/providers/google-list.provider.ts index 66d35c819..cd17bcab8 100644 --- a/server/src/nest/place-import/providers/google-list.provider.ts +++ b/server/src/nest/place-import/providers/google-list.provider.ts @@ -9,10 +9,10 @@ * attacker-influenced through the list id. Every refusal is the exact message * and status the import route has always answered. */ -import { Injectable } from '@nestjs/common'; import { checkSsrf, safeFetchFollow, SsrfBlockedError } from '../../../utils/ssrfGuard'; import { isDirectionsUrl } from '../directions-url.helpers'; import type { DirectionsRedirect, GoogleListPlace, ListImportError, ListRead } from '../place-import.types'; +import { Injectable } from '@nestjs/common'; /** Cap on a provider list-import response body — the payload is attacker-influenced via the list id. */ export const MAX_LIST_RESPONSE_BYTES = 8 * 1024 * 1024; // 8 MB @@ -37,10 +37,7 @@ export function googleMapsHexId(value: unknown): string | null { export function googleMapsFeatureIdFromItem(item: unknown): string | null { if (!Array.isArray(item)) return null; - const candidates = [ - Array.isArray(item[1]) ? item[1][6] : null, - Array.isArray(item[7]) ? item[7][1] : null, - ]; + const candidates = [Array.isArray(item[1]) ? item[1][6] : null, Array.isArray(item[7]) ? item[7][1] : null]; for (const ids of candidates) { if (!Array.isArray(ids) || ids.length < 2) continue; @@ -100,7 +97,11 @@ export class GoogleListProvider { // A single-place share link (…/maps/place/…) carries no list id — point the user at // the place search box instead of a cryptic "could not extract list ID" (#1304). if (resolvedUrl.includes('/maps/place/')) { - return { error: 'That link points to a single place, not a list. To add it, paste the link into the place search box instead of using the list import.', status: 400 }; + return { + error: + 'That link points to a single place, not a list. To add it, paste the link into the place search box instead of using the list import.', + status: 400, + }; } return { error: 'Could not extract list ID from URL. Please use a shared Google Maps list link.', status: 400 }; } diff --git a/server/src/nest/place-import/providers/gpx.provider.ts b/server/src/nest/place-import/providers/gpx.provider.ts index 6dec27197..9e4925cdb 100644 --- a/server/src/nest/place-import/providers/gpx.provider.ts +++ b/server/src/nest/place-import/providers/gpx.provider.ts @@ -1,6 +1,6 @@ -import { Injectable } from '@nestjs/common'; import { coord, gpxGeometryParser, writeGpx } from '../gpx.codec'; import type { GpxImportOptions, PreparedGpxPlace } from '../place-import.types'; +import { Injectable } from '@nestjs/common'; /** * A trip as GPX and back: the reader that turns a GPX file into the places an @@ -98,7 +98,7 @@ export function buildGpx(input: GpxExportInput, opts: GpxExportOptions = {}): st name: place.name, desc: describe(place), trkseg: { - trkpt: geometry.map(p => ({ + trkpt: geometry.map((p) => ({ '@_lat': coord(p.lat), '@_lon': coord(p.lng), ...(p.ele != null ? { ele: coord(p.ele) } : {}), @@ -131,7 +131,7 @@ export function buildGpx(input: GpxExportInput, opts: GpxExportOptions = {}): st : String(day.dayNumber); rte.push({ name: label, - rtept: day.points.map(p => ({ + rtept: day.points.map((p) => ({ '@_lat': coord(p.lat), '@_lon': coord(p.lng), name: p.name, @@ -174,7 +174,10 @@ export function prepareGpxRows(fileBuffer: Buffer, opts: GpxImportOptions = {}): if (!gpx) return []; const str = (v: unknown) => (v != null ? String(v).trim() : null); - const num = (v: unknown) => { const n = Number.parseFloat(String(v)); return Number.isNaN(n) ? null : n; }; + const num = (v: unknown) => { + const n = Number.parseFloat(String(v)); + return Number.isNaN(n) ? null : n; + }; // Routes and tracks rarely carry their own . Without one they all fall back to the // same generic label, so name-based dedup drops every import after the first. Derive a @@ -198,7 +201,12 @@ export function prepareGpxRows(fileBuffer: Buffer, opts: GpxImportOptions = {}): const lat = num(wpt['@_lat']); const lng = num(wpt['@_lon']); if (lat === null || lng === null) continue; - waypoints.push({ lat, lng, name: str(wpt.name) || `Waypoint ${waypoints.length + 1}`, description: str(wpt.desc) }); + waypoints.push({ + lat, + lng, + name: str(wpt.name) || `Waypoint ${waypoints.length + 1}`, + description: str(wpt.desc), + }); } } @@ -207,11 +215,19 @@ export function prepareGpxRows(fileBuffer: Buffer, opts: GpxImportOptions = {}): for (const rte of gpx.rte ?? []) { const pts = (rte.rtept ?? []) .map((pt: Record) => ({ lat: num(pt['@_lat']), lng: num(pt['@_lon']), ele: num(pt['ele']) })) - .filter((p: { lat: number | null; lng: number | null; ele: number | null }) => p.lat !== null && p.lng !== null) as Array<{ lat: number; lng: number; ele: number | null }>; + .filter( + (p: { lat: number | null; lng: number | null; ele: number | null }) => p.lat !== null && p.lng !== null, + ) as Array<{ lat: number; lng: number; ele: number | null }>; if (pts.length === 0) continue; - const hasAllEle = pts.every(p => p.ele !== null); - const routeGeometry = pts.map(p => hasAllEle ? [p.lat, p.lng, p.ele] : [p.lat, p.lng]); - waypoints.push({ lat: pts[0].lat, lng: pts[0].lng, name: geoName(str(rte.name), 'GPX Route'), description: str(rte.desc), routeGeometry: JSON.stringify(routeGeometry) }); + const hasAllEle = pts.every((p) => p.ele !== null); + const routeGeometry = pts.map((p) => (hasAllEle ? [p.lat, p.lng, p.ele] : [p.lat, p.lng])); + waypoints.push({ + lat: pts[0].lat, + lng: pts[0].lng, + name: geoName(str(rte.name), 'GPX Route'), + description: str(rte.desc), + routeGeometry: JSON.stringify(routeGeometry), + }); } } @@ -229,9 +245,15 @@ export function prepareGpxRows(fileBuffer: Buffer, opts: GpxImportOptions = {}): } if (trackPoints.length === 0) continue; const start = trackPoints[0]; - const hasAllEle = trackPoints.every(p => p.ele !== null); - const routeGeometry = trackPoints.map(p => hasAllEle ? [p.lat, p.lng, p.ele] : [p.lat, p.lng]); - waypoints.push({ lat: start.lat, lng: start.lng, name: geoName(str(trk.name), 'GPX Track'), description: str(trk.desc), routeGeometry: JSON.stringify(routeGeometry) }); + const hasAllEle = trackPoints.every((p) => p.ele !== null); + const routeGeometry = trackPoints.map((p) => (hasAllEle ? [p.lat, p.lng, p.ele] : [p.lat, p.lng])); + waypoints.push({ + lat: start.lat, + lng: start.lng, + name: geoName(str(trk.name), 'GPX Track'), + description: str(trk.desc), + routeGeometry: JSON.stringify(routeGeometry), + }); } } diff --git a/server/src/nest/place-import/providers/kml.provider.ts b/server/src/nest/place-import/providers/kml.provider.ts index d28a984ec..0d91115c1 100644 --- a/server/src/nest/place-import/providers/kml.provider.ts +++ b/server/src/nest/place-import/providers/kml.provider.ts @@ -3,9 +3,9 @@ * into its placemarks. The file type is the upload's extension, as it has * always been; anything else is refused with the route's own message. */ -import { Injectable } from '@nestjs/common'; import { readKmlDocument, unpackKmzToKml } from '../kml.codec'; import type { KmlDocumentRead } from '../place-import.types'; +import { Injectable } from '@nestjs/common'; @Injectable() export class KmlProvider { diff --git a/server/src/nest/trip-membership/trip-access.service.ts b/server/src/nest/trip-membership/trip-access.service.ts index fcb27483d..2af3294b7 100644 --- a/server/src/nest/trip-membership/trip-access.service.ts +++ b/server/src/nest/trip-membership/trip-access.service.ts @@ -1,7 +1,7 @@ -import { Injectable } from '@nestjs/common'; -import { InjectRepository } from '@mikro-orm/nestjs'; import { Trips } from '../../db/entities/Trips.entity'; import type { TripAccess, TripsRepository } from '../../db/repositories/Trips.repository'; +import { InjectRepository } from '@mikro-orm/nestjs'; +import { Injectable } from '@nestjs/common'; /** * "May this user see this trip?" for every domain that is not the trips domain. diff --git a/server/src/utils/json-column.ts b/server/src/utils/json-column.ts index d3d36d069..a1e16a728 100644 --- a/server/src/utils/json-column.ts +++ b/server/src/utils/json-column.ts @@ -1,6 +1,7 @@ -import type { ZodType } from 'zod'; import { logWarn } from '../nest/audit/audit-log.logger'; +import type { ZodType } from 'zod'; + /** * One JSON-in-TEXT column: where it lives, the shape it holds, and what a reader * gets when the stored text is missing or broken. @@ -27,8 +28,7 @@ export interface JsonColumn { export type JsonDecodeFailure = 'empty' | 'invalid-json' | 'schema'; export type JsonDecodeResult = - | { ok: true; value: T; reason?: never } - | { ok: false; value?: never; reason: JsonDecodeFailure }; + { ok: true; value: T; reason?: never } | { ok: false; value?: never; reason: JsonDecodeFailure }; /** * Decode a stored value without a fallback, for the readers that react to a @@ -65,7 +65,9 @@ export function decodeJson(col: JsonColumn, raw: unknown, context?: string /** Log a row whose JSON did not decode; for callers of {@link decodeJsonResult} that handle the failure themselves. */ export function logJsonFailure(col: JsonColumn, reason: JsonDecodeFailure, context?: string): void { - logWarn(`[json] ${col.column}${context ? ` (${context})` : ''}: stored value ${reason === 'schema' ? 'has the wrong shape' : 'is not valid JSON'}, using the fallback`); + logWarn( + `[json] ${col.column}${context ? ` (${context})` : ''}: stored value ${reason === 'schema' ? 'has the wrong shape' : 'is not valid JSON'}, using the fallback`, + ); } /** The text to store for a value, checked against the column's shape first (a mismatch throws: it is a bug in the writer). */ diff --git a/server/tests/helpers/maps-service.ts b/server/tests/helpers/maps-service.ts index 8a1b8a81e..c010840fc 100644 --- a/server/tests/helpers/maps-service.ts +++ b/server/tests/helpers/maps-service.ts @@ -1,17 +1,17 @@ +import type { AppSettingsRepository } from '../../src/db/repositories/AppSettings.repository'; +import type { PlaceDetailsCacheRepository } from '../../src/db/repositories/PlaceDetailsCache.repository'; +import type { PlacesRepository } from '../../src/db/repositories/Places.repository'; +import type { UsersRepository } from '../../src/db/repositories/Users.repository'; +import type { GoogleQuotaService } from '../../src/nest/google-quota/google-quota.service'; +import { MapsUrlResolver } from '../../src/nest/maps/maps-url.resolver'; import { MapsService } from '../../src/nest/maps/maps.service'; +import { PlaceDetailsResolver } from '../../src/nest/maps/place-details.resolver'; +import { PlacePhotoResolver } from '../../src/nest/maps/place-photo.resolver'; +import { PlacesProviderSelector } from '../../src/nest/maps/places-provider.selector'; import { GooglePlacesClient } from '../../src/nest/maps/providers/google-places.provider'; import { OsmClient } from '../../src/nest/maps/providers/osm.client'; import { WikimediaClient } from '../../src/nest/maps/providers/wikimedia.client'; -import { PlacesProviderSelector } from '../../src/nest/maps/places-provider.selector'; -import { PlacePhotoResolver } from '../../src/nest/maps/place-photo.resolver'; -import { MapsUrlResolver } from '../../src/nest/maps/maps-url.resolver'; -import { PlaceDetailsResolver } from '../../src/nest/maps/place-details.resolver'; import type { PlacePhotoCacheService } from '../../src/nest/place-photos/place-photo-cache.service'; -import type { AppSettingsRepository } from '../../src/db/repositories/AppSettings.repository'; -import type { UsersRepository } from '../../src/db/repositories/Users.repository'; -import type { PlaceDetailsCacheRepository } from '../../src/db/repositories/PlaceDetailsCache.repository'; -import type { PlacesRepository } from '../../src/db/repositories/Places.repository'; -import type { GoogleQuotaService } from '../../src/nest/google-quota/google-quota.service'; import { noGoogleQuota } from './google-quota'; /** MapsService and the outbound clients it was wired with, for tests that reach a client directly. */ diff --git a/server/tests/helpers/mcp-gate.ts b/server/tests/helpers/mcp-gate.ts index b58119715..bc98c2579 100644 --- a/server/tests/helpers/mcp-gate.ts +++ b/server/tests/helpers/mcp-gate.ts @@ -5,9 +5,9 @@ * keep their stubs instead of attaching a whole registry (which would also run * the `when` gates those stubs cannot answer). */ -import { getEntry, type ClassRef } from '../../src/nest-mcp/metadata'; import { trekDemoToolGate } from '../../src/mcp/nest-mcp-policy'; import type { McpContext } from '../../src/nest-mcp'; +import { getEntry, type ClassRef } from '../../src/nest-mcp/metadata'; export async function callGatedTool( instance: object, diff --git a/server/tests/helpers/place-import.ts b/server/tests/helpers/place-import.ts index 9dd753ee0..49727d7ac 100644 --- a/server/tests/helpers/place-import.ts +++ b/server/tests/helpers/place-import.ts @@ -1,9 +1,9 @@ import { PlaceImportService } from '../../src/nest/place-import/place-import.service'; -import { GpxProvider } from '../../src/nest/place-import/providers/gpx.provider'; import { CollectionGpxProvider } from '../../src/nest/place-import/providers/collection-gpx.provider'; -import { KmlProvider } from '../../src/nest/place-import/providers/kml.provider'; -import { GoogleListProvider } from '../../src/nest/place-import/providers/google-list.provider'; import { GoogleDirectionsProvider } from '../../src/nest/place-import/providers/google-directions.provider'; +import { GoogleListProvider } from '../../src/nest/place-import/providers/google-list.provider'; +import { GpxProvider } from '../../src/nest/place-import/providers/gpx.provider'; +import { KmlProvider } from '../../src/nest/place-import/providers/kml.provider'; import { NaverListProvider } from '../../src/nest/place-import/providers/naver-list.provider'; /** diff --git a/server/tests/unit/app-config/running-version.test.ts b/server/tests/unit/app-config/running-version.test.ts index a091b48c8..d418bd7d6 100644 --- a/server/tests/unit/app-config/running-version.test.ts +++ b/server/tests/unit/app-config/running-version.test.ts @@ -1,7 +1,8 @@ -import { afterEach, describe, expect, it, vi } from 'vitest'; +import pkg from '../../../package.json'; import { runningVersion } from '../../../src/app-config'; import { hostVersion } from '../../../src/nest/plugins/install/host-compat'; -import pkg from '../../../package.json'; + +import { afterEach, describe, expect, it, vi } from 'vitest'; afterEach(() => { vi.unstubAllEnvs(); diff --git a/server/tests/unit/db/repositories/UserConnections.repository.test.ts b/server/tests/unit/db/repositories/UserConnections.repository.test.ts index 045cb7975..eb40b2edf 100644 --- a/server/tests/unit/db/repositories/UserConnections.repository.test.ts +++ b/server/tests/unit/db/repositories/UserConnections.repository.test.ts @@ -4,17 +4,18 @@ * UsersRepository; the Immich cases moved here with their methods, and the rest * pin each statement's columns and its missing-user branch. */ -import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; -import { createSnapshotTestDb } from '../../../helpers/db-mock'; -import { resetTestDb } from '../../../helpers/test-db'; -import { createTestOrm, type TestOrm } from '../../../helpers/test-orm'; -import { createUser } from '../../../helpers/factories'; import { Users } from '../../../../src/db/entities/Users.entity'; -import { updateRows } from '../../../helpers/factories/rows'; -import { readUser } from '../../../helpers/factories/users'; +import { UserAirtrailRepository } from '../../../../src/db/repositories/UserAirtrail.repository'; import { UserImmichRepository } from '../../../../src/db/repositories/UserImmich.repository'; import { UserSynologyRepository } from '../../../../src/db/repositories/UserSynology.repository'; -import { UserAirtrailRepository } from '../../../../src/db/repositories/UserAirtrail.repository'; +import { createSnapshotTestDb } from '../../../helpers/db-mock'; +import { createUser } from '../../../helpers/factories'; +import { updateRows } from '../../../helpers/factories/rows'; +import { readUser } from '../../../helpers/factories/users'; +import { resetTestDb } from '../../../helpers/test-db'; +import { createTestOrm, type TestOrm } from '../../../helpers/test-orm'; + +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; const testDb = createSnapshotTestDb(); let t: TestOrm; @@ -28,8 +29,14 @@ beforeAll(async () => { synology = new UserSynologyRepository(t.orm.em); airtrail = new UserAirtrailRepository(t.orm.em); }); -beforeEach(() => { resetTestDb(testDb); t.clear(); }); -afterAll(async () => { await t.close(); testDb.close(); }); +beforeEach(() => { + resetTestDb(testDb); + t.clear(); +}); +afterAll(async () => { + await t.close(); + testDb.close(); +}); /** Counts the queries MikroORM issues over the shared connection while `fn` runs. */ async function withQueryCount(fn: () => Promise): Promise<{ value: T; queries: number }> { @@ -53,7 +60,17 @@ describe('UserImmichRepository — reads', () => { it('M1b: getImmichCredentials / getImmichConnectionPrefs carry immich_allow_insecure_tls (#2475), and the missing-user branch', async () => { const { user } = createUser(testDb); - await updateRows(t, Users, { id: user.id }, { immich_url: 'https://immich.test', immich_api_key: 'key-1', immich_auto_upload: 1, immich_allow_insecure_tls: 1 }); + await updateRows( + t, + Users, + { id: user.id }, + { + immich_url: 'https://immich.test', + immich_api_key: 'key-1', + immich_auto_upload: 1, + immich_allow_insecure_tls: 1, + }, + ); expect(await immich.getImmichCredentials(user.id)).toEqual({ immich_url: 'https://immich.test', @@ -62,7 +79,10 @@ describe('UserImmichRepository — reads', () => { }); expect(await immich.getImmichCredentials(999999)).toBeNull(); - expect(await immich.getImmichConnectionPrefs(user.id)).toEqual({ immich_auto_upload: 1, immich_allow_insecure_tls: 1 }); + expect(await immich.getImmichConnectionPrefs(user.id)).toEqual({ + immich_auto_upload: 1, + immich_allow_insecure_tls: 1, + }); expect(await immich.getImmichConnectionPrefs(999999)).toBeNull(); }); }); @@ -76,17 +96,28 @@ describe('UserImmichRepository — Immich settings write (IM4/IM5)', () => { const read = async (id: number) => { const row = await readUser(t, id); return { - immich_url: row.immich_url, immich_api_key: row.immich_api_key, immich_allow_insecure_tls: row.immich_allow_insecure_tls, + immich_url: row.immich_url, + immich_api_key: row.immich_api_key, + immich_allow_insecure_tls: row.immich_allow_insecure_tls, }; }; it('USERSREPO-085 (IM4): setImmichSettings keeps the stored switch on a null value while the URL stays the same, and starts a new URL off', async () => { const { user } = createUser(testDb); - await updateRows(t, Users, { id: user.id }, { immich_url: 'https://nas.local', immich_api_key: 'enc-old', immich_allow_insecure_tls: 1 }); + await updateRows( + t, + Users, + { id: user.id }, + { immich_url: 'https://nas.local', immich_api_key: 'enc-old', immich_allow_insecure_tls: 1 }, + ); // An older client that does not know the switch cannot clear it by saving. await immich.setImmichSettings(user.id, 'https://nas.local', 'enc-new', null); - expect(await read(user.id)).toEqual({ immich_url: 'https://nas.local', immich_api_key: 'enc-new', immich_allow_insecure_tls: 1 }); + expect(await read(user.id)).toEqual({ + immich_url: 'https://nas.local', + immich_api_key: 'enc-new', + immich_allow_insecure_tls: 1, + }); // Sent explicitly, the value wins over the stored one. await immich.setImmichSettings(user.id, 'https://nas.local', 'enc-new', 0); @@ -96,15 +127,27 @@ describe('UserImmichRepository — Immich settings write (IM4/IM5)', () => { // The switch trusts one server: another URL without it starts off ... await immich.setImmichSettings(user.id, 'https://photos.example.com', 'enc-2', null); - expect(await read(user.id)).toEqual({ immich_url: 'https://photos.example.com', immich_api_key: 'enc-2', immich_allow_insecure_tls: 0 }); + expect(await read(user.id)).toEqual({ + immich_url: 'https://photos.example.com', + immich_api_key: 'enc-2', + immich_allow_insecure_tls: 0, + }); // ... and with it, holds for that server. await immich.setImmichSettings(user.id, 'https://other.example.com', 'enc-3', 1); - expect(await read(user.id)).toEqual({ immich_url: 'https://other.example.com', immich_api_key: 'enc-3', immich_allow_insecure_tls: 1 }); + expect(await read(user.id)).toEqual({ + immich_url: 'https://other.example.com', + immich_api_key: 'enc-3', + immich_allow_insecure_tls: 1, + }); // A first connection (no stored URL) without the switch starts off too. await updateRows(t, Users, { id: user.id }, { immich_url: null, immich_allow_insecure_tls: 1 }); await immich.setImmichSettings(user.id, 'https://nas.local', 'enc-4', null); - expect(await read(user.id)).toEqual({ immich_url: 'https://nas.local', immich_api_key: 'enc-4', immich_allow_insecure_tls: 0 }); + expect(await read(user.id)).toEqual({ + immich_url: 'https://nas.local', + immich_api_key: 'enc-4', + immich_allow_insecure_tls: 0, + }); }); it('USERSREPO-086 (IM4): setImmichSettings matches the legacy CASE statement across every (stored url, stored switch, new url, value) combination, in ONE statement', async () => { @@ -119,11 +162,21 @@ describe('UserImmichRepository — Immich settings write (IM4/IM5)', () => { for (const storedFlag of [0, 1]) { for (const newUrl of ['https://nas.local', 'https://photos.example.com']) { for (const value of [null, 0, 1]) { - await updateRows(t, Users, { id: user.id }, { immich_url: storedUrl, immich_api_key: null, immich_allow_insecure_tls: storedFlag }); + await updateRows( + t, + Users, + { id: user.id }, + { immich_url: storedUrl, immich_api_key: null, immich_allow_insecure_tls: storedFlag }, + ); legacy.run(newUrl, 'enc-key', newUrl, value, value, user.id); const expected = await read(user.id); - await updateRows(t, Users, { id: user.id }, { immich_url: storedUrl, immich_api_key: null, immich_allow_insecure_tls: storedFlag }); + await updateRows( + t, + Users, + { id: user.id }, + { immich_url: storedUrl, immich_api_key: null, immich_allow_insecure_tls: storedFlag }, + ); const { queries } = await withQueryCount(() => immich.setImmichSettings(user.id, newUrl, 'enc-key', value)); expect(queries).toBe(1); expect(await read(user.id)).toEqual(expected); @@ -135,7 +188,12 @@ describe('UserImmichRepository — Immich settings write (IM4/IM5)', () => { it('USERSREPO-087 (IM5): clearImmichSettings nulls the URL, stores the key it is handed and always turns the switch off', async () => { const { user } = createUser(testDb); - await updateRows(t, Users, { id: user.id }, { immich_url: 'https://nas.local', immich_api_key: 'enc-old', immich_allow_insecure_tls: 1 }); + await updateRows( + t, + Users, + { id: user.id }, + { immich_url: 'https://nas.local', immich_api_key: 'enc-old', immich_allow_insecure_tls: 1 }, + ); await immich.clearImmichSettings(user.id, null); expect(await read(user.id)).toEqual({ immich_url: null, immich_api_key: null, immich_allow_insecure_tls: 0 }); }); @@ -144,13 +202,35 @@ describe('UserImmichRepository — Immich settings write (IM4/IM5)', () => { describe('UserSynologyRepository', () => { it('SYNOREPO-001: getSynologyFields reads only the asked columns, and null for a missing user', async () => { const { user } = createUser(testDb); - await updateRows(t, Users, { id: user.id }, { - synology_url: 'https://nas:5001/photo', synology_username: 'syno-login', synology_password: 'enc-pw', - synology_sid: 'sid-1', synology_did: 'did-1', synology_skip_ssl: 1, + await updateRows( + t, + Users, + { id: user.id }, + { + synology_url: 'https://nas:5001/photo', + synology_username: 'syno-login', + synology_password: 'enc-pw', + synology_sid: 'sid-1', + synology_did: 'did-1', + synology_skip_ssl: 1, + }, + ); + expect(await synology.getSynologyFields(user.id, ['synology_url', 'synology_sid'])).toEqual({ + synology_url: 'https://nas:5001/photo', + synology_sid: 'sid-1', }); - expect(await synology.getSynologyFields(user.id, ['synology_url', 'synology_sid'])).toEqual({ synology_url: 'https://nas:5001/photo', synology_sid: 'sid-1' }); - expect(await synology.getSynologyFields(user.id, ['synology_username', 'synology_password', 'synology_did', 'synology_skip_ssl'])).toEqual({ - synology_username: 'syno-login', synology_password: 'enc-pw', synology_did: 'did-1', synology_skip_ssl: 1, + expect( + await synology.getSynologyFields(user.id, [ + 'synology_username', + 'synology_password', + 'synology_did', + 'synology_skip_ssl', + ]), + ).toEqual({ + synology_username: 'syno-login', + synology_password: 'enc-pw', + synology_did: 'did-1', + synology_skip_ssl: 1, }); expect(await synology.getSynologyFields(999999, ['synology_url'])).toBeNull(); }); @@ -182,7 +262,12 @@ describe('UserSynologyRepository', () => { const { user } = createUser(testDb); await synology.setSynologySettings(user.id, 'https://nas', 'me', 'enc', 1); const row = await readUser(t, user.id); - expect([row.synology_url, row.synology_username, row.synology_password, row.synology_skip_ssl]).toEqual(['https://nas', 'me', 'enc', 1]); + expect([row.synology_url, row.synology_username, row.synology_password, row.synology_skip_ssl]).toEqual([ + 'https://nas', + 'me', + 'enc', + 1, + ]); }); }); @@ -190,15 +275,32 @@ describe('UserAirtrailRepository', () => { const read = async (id: number) => { const row = await readUser(t, id); return { - airtrail_url: row.airtrail_url, airtrail_api_key: row.airtrail_api_key, - airtrail_allow_insecure_tls: row.airtrail_allow_insecure_tls, airtrail_write_enabled: row.airtrail_write_enabled, + airtrail_url: row.airtrail_url, + airtrail_api_key: row.airtrail_api_key, + airtrail_allow_insecure_tls: row.airtrail_allow_insecure_tls, + airtrail_write_enabled: row.airtrail_write_enabled, }; }; it('AIRTRAILREPO-001: getAirtrailConnRow and getAirtrailWriteEnabled read the connection, and null for a missing user', async () => { const { user } = createUser(testDb); - await updateRows(t, Users, { id: user.id }, { airtrail_url: 'https://at', airtrail_api_key: 'enc', airtrail_allow_insecure_tls: 1, airtrail_write_enabled: 1 }); - expect(await airtrail.getAirtrailConnRow(user.id)).toEqual({ airtrail_url: 'https://at', airtrail_api_key: 'enc', airtrail_allow_insecure_tls: 1, airtrail_write_enabled: 1 }); + await updateRows( + t, + Users, + { id: user.id }, + { + airtrail_url: 'https://at', + airtrail_api_key: 'enc', + airtrail_allow_insecure_tls: 1, + airtrail_write_enabled: 1, + }, + ); + expect(await airtrail.getAirtrailConnRow(user.id)).toEqual({ + airtrail_url: 'https://at', + airtrail_api_key: 'enc', + airtrail_allow_insecure_tls: 1, + airtrail_write_enabled: 1, + }); expect(await airtrail.getAirtrailConnRow(999999)).toBeNull(); expect(await airtrail.getAirtrailWriteEnabled(user.id)).toBe(1); expect(await airtrail.getAirtrailWriteEnabled(999999)).toBeNull(); @@ -207,9 +309,19 @@ describe('UserAirtrailRepository', () => { it('AIRTRAILREPO-002: the settings writes, with and without a new key, and the key scrub', async () => { const { user } = createUser(testDb); await airtrail.setAirtrailSettingsWithKey(user.id, 'https://at', 'enc-1', 0, 1); - expect(await read(user.id)).toEqual({ airtrail_url: 'https://at', airtrail_api_key: 'enc-1', airtrail_allow_insecure_tls: 0, airtrail_write_enabled: 1 }); + expect(await read(user.id)).toEqual({ + airtrail_url: 'https://at', + airtrail_api_key: 'enc-1', + airtrail_allow_insecure_tls: 0, + airtrail_write_enabled: 1, + }); await airtrail.setAirtrailSettings(user.id, 'https://at2', 1, 0); - expect(await read(user.id)).toEqual({ airtrail_url: 'https://at2', airtrail_api_key: 'enc-1', airtrail_allow_insecure_tls: 1, airtrail_write_enabled: 0 }); + expect(await read(user.id)).toEqual({ + airtrail_url: 'https://at2', + airtrail_api_key: 'enc-1', + airtrail_allow_insecure_tls: 1, + airtrail_write_enabled: 0, + }); await airtrail.clearAirtrailApiKey(user.id); expect((await read(user.id)).airtrail_api_key).toBeNull(); }); diff --git a/server/tests/unit/db/timestamp-spelling-migration.test.ts b/server/tests/unit/db/timestamp-spelling-migration.test.ts index 3e212e094..7326f31bc 100644 --- a/server/tests/unit/db/timestamp-spelling-migration.test.ts +++ b/server/tests/unit/db/timestamp-spelling-migration.test.ts @@ -4,11 +4,19 @@ * canonical `YYYY-MM-DD HH:MM:SS` text; canonical values, NULLs and anything * not in the full ISO shape stay as they are, and a second run changes nothing. */ -import { describe, expect, it } from 'vitest'; -import { createMigrationOrm, migrateTo, pendingNames, rawExec, rawQuery, runMigrationDirect } from '../../helpers/migration-step'; import { Migration20200101042700_timestamps_stored_in_one_spelling } from '../../../src/db/migrations/Migration20200101042700_timestamps_stored_in_one_spelling'; +import { + createMigrationOrm, + migrateTo, + pendingNames, + rawExec, + rawQuery, + runMigrationDirect, +} from '../../helpers/migration-step'; import type { MikroORM } from '@mikro-orm/sqlite'; +import { describe, expect, it } from 'vitest'; + const TARGET = 'Migration20200101042700_timestamps_stored_in_one_spelling'; async function ormBeforeTarget(): Promise { @@ -22,16 +30,31 @@ async function ormBeforeTarget(): Promise { async function seed(orm: MikroORM): Promise { await rawExec(orm, "INSERT INTO users (id, username, email, password_hash) VALUES (1, 'owner', 'owner@test', 'x')"); - await rawExec(orm, "INSERT INTO collections (id, owner_id, name, updated_at) VALUES (1, 1, 'iso', '2026-03-04T05:06:07.890Z')"); - await rawExec(orm, "INSERT INTO collections (id, owner_id, name, updated_at) VALUES (2, 1, 'canonical', '2026-03-04 05:06:07')"); + await rawExec( + orm, + "INSERT INTO collections (id, owner_id, name, updated_at) VALUES (1, 1, 'iso', '2026-03-04T05:06:07.890Z')", + ); + await rawExec( + orm, + "INSERT INTO collections (id, owner_id, name, updated_at) VALUES (2, 1, 'canonical', '2026-03-04 05:06:07')", + ); await rawExec(orm, "INSERT INTO collections (id, owner_id, name, updated_at) VALUES (3, 1, 'null', NULL)"); - await rawExec(orm, "INSERT INTO collections (id, owner_id, name, updated_at) VALUES (4, 1, 'offset', '2026-03-04T05:06:07+02:00')"); - await rawExec(orm, "INSERT INTO collections (id, owner_id, name, updated_at) VALUES (5, 1, 'no-millis', '2026-03-04T05:06:07Z')"); + await rawExec( + orm, + "INSERT INTO collections (id, owner_id, name, updated_at) VALUES (4, 1, 'offset', '2026-03-04T05:06:07+02:00')", + ); + await rawExec( + orm, + "INSERT INTO collections (id, owner_id, name, updated_at) VALUES (5, 1, 'no-millis', '2026-03-04T05:06:07Z')", + ); await rawExec( orm, "INSERT INTO password_reset_tokens (id, user_id, token_hash, expires_at) VALUES (1, 1, 'h1', '2026-05-06T07:08:09.123Z'), (2, 1, 'h2', '2026-05-06 07:08:09')", ); - await rawExec(orm, "INSERT INTO oauth_clients (id, name, client_id, client_secret_hash) VALUES ('c', 'Client', 'cid', 'x')"); + await rawExec( + orm, + "INSERT INTO oauth_clients (id, name, client_id, client_secret_hash) VALUES ('c', 'Client', 'cid', 'x')", + ); await rawExec( orm, `INSERT INTO oauth_tokens (id, client_id, user_id, access_token_hash, refresh_token_hash, access_token_expires_at, refresh_token_expires_at) @@ -43,7 +66,10 @@ async function seed(orm: MikroORM): Promise { const read = async (orm: MikroORM) => ({ collections: await rawQuery(orm, 'SELECT id, updated_at FROM collections ORDER BY id'), resets: await rawQuery(orm, 'SELECT id, expires_at FROM password_reset_tokens ORDER BY id'), - tokens: await rawQuery(orm, 'SELECT id, access_token_expires_at, refresh_token_expires_at FROM oauth_tokens ORDER BY id'), + tokens: await rawQuery( + orm, + 'SELECT id, access_token_expires_at, refresh_token_expires_at FROM oauth_tokens ORDER BY id', + ), }); const EXPECTED = { diff --git a/server/tests/unit/db/users-repository-shape.test.ts b/server/tests/unit/db/users-repository-shape.test.ts index bd111576a..a70c29d40 100644 --- a/server/tests/unit/db/users-repository-shape.test.ts +++ b/server/tests/unit/db/users-repository-shape.test.ts @@ -6,14 +6,17 @@ * a repository of their own, and pins which columns each connection repository * is about, so a statement on another column does not drift into it. */ -import { describe, expect, it } from 'vitest'; -import { UsersRepository } from '../../../src/db/repositories/Users.repository'; +import { UserAirtrailRepository } from '../../../src/db/repositories/UserAirtrail.repository'; import { UserImmichRepository } from '../../../src/db/repositories/UserImmich.repository'; import { UserSynologyRepository } from '../../../src/db/repositories/UserSynology.repository'; -import { UserAirtrailRepository } from '../../../src/db/repositories/UserAirtrail.repository'; +import { UsersRepository } from '../../../src/db/repositories/Users.repository'; + +import { describe, expect, it } from 'vitest'; const ownMethods = (cls: { prototype: object }) => - Object.getOwnPropertyNames(cls.prototype).filter((name) => name !== 'constructor').sort(); + Object.getOwnPropertyNames(cls.prototype) + .filter((name) => name !== 'constructor') + .sort(); describe('the users repositories', () => { it('USERSHAPE-001: UsersRepository holds no more methods than it does today', () => { @@ -26,14 +29,29 @@ describe('the users repositories', () => { }); it('USERSHAPE-003: each connection repository holds its own statements', () => { - expect(ownMethods(UserImmichRepository)).toEqual( - ['clearImmichSettings', 'getImmichAutoUpload', 'getImmichConnectionPrefs', 'getImmichCredentials', 'setImmichAutoUpload', 'setImmichSettings'], - ); - expect(ownMethods(UserSynologyRepository)).toEqual( - ['clearSynologySID', 'clearSynologySession', 'getSynologyFields', 'getSynologyUsername', 'setSynologyDid', 'setSynologySettings', 'setSynologySid'], - ); - expect(ownMethods(UserAirtrailRepository)).toEqual( - ['clearAirtrailApiKey', 'getAirtrailConnRow', 'getAirtrailWriteEnabled', 'setAirtrailSettings', 'setAirtrailSettingsWithKey'], - ); + expect(ownMethods(UserImmichRepository)).toEqual([ + 'clearImmichSettings', + 'getImmichAutoUpload', + 'getImmichConnectionPrefs', + 'getImmichCredentials', + 'setImmichAutoUpload', + 'setImmichSettings', + ]); + expect(ownMethods(UserSynologyRepository)).toEqual([ + 'clearSynologySID', + 'clearSynologySession', + 'getSynologyFields', + 'getSynologyUsername', + 'setSynologyDid', + 'setSynologySettings', + 'setSynologySid', + ]); + expect(ownMethods(UserAirtrailRepository)).toEqual([ + 'clearAirtrailApiKey', + 'getAirtrailConnRow', + 'getAirtrailWriteEnabled', + 'setAirtrailSettings', + 'setAirtrailSettingsWithKey', + ]); }); }); diff --git a/server/tests/unit/eslint-rules/restricted-imports-xml.test.ts b/server/tests/unit/eslint-rules/restricted-imports-xml.test.ts index 1d73faf5c..12b91c588 100644 --- a/server/tests/unit/eslint-rules/restricted-imports-xml.test.ts +++ b/server/tests/unit/eslint-rules/restricted-imports-xml.test.ts @@ -26,7 +26,13 @@ async function flags(file: string, source: string): Promise { const linter = new Linter({ configType: 'flat' }); const result = linter.verify( source, - [{ files: ['**/*.ts'], languageOptions: { parser: tsParser }, rules: { 'no-restricted-imports': ['error', ...(options as object[])] } }], + [ + { + files: ['**/*.ts'], + languageOptions: { parser: tsParser }, + rules: { 'no-restricted-imports': ['error', ...(options as object[])] }, + }, + ], 'probe.ts', ); return result.some((m) => m.ruleId === 'no-restricted-imports'); diff --git a/server/tests/unit/nest-mcp/tool-gate.test.ts b/server/tests/unit/nest-mcp/tool-gate.test.ts index a9eb5db3e..902f7346a 100644 --- a/server/tests/unit/nest-mcp/tool-gate.test.ts +++ b/server/tests/unit/nest-mcp/tool-gate.test.ts @@ -55,7 +55,10 @@ describe('tool gate', () => { const result = await harness.client.callTool({ name: 'write_it', arguments: { n: 1 } }); expect(result).toEqual(REFUSAL); expect(mcp.calls).toBe(0); - expect(gate).toHaveBeenCalledWith(expect.objectContaining({ name: 'write_it', annotations: { readOnlyHint: false } }), asCtx({ userId: 3 })); + expect(gate).toHaveBeenCalledWith( + expect.objectContaining({ name: 'write_it', annotations: { readOnlyHint: false } }), + asCtx({ userId: 3 }), + ); }); it('runs the handler with its arguments when the gate returns undefined', async () => { @@ -85,7 +88,11 @@ describe('tool gate', () => { options: { name: 'dyn', inputSchema: {}, access: () => true }, handler: () => ({ content: [{ type: 'text', text: 'dynamic ran' }] }), }; - harness = await createAttachHarness(createTestRegistry([], { toolGate: gate }), {}, { dynamicTools: () => [dynamic] }); + harness = await createAttachHarness( + createTestRegistry([], { toolGate: gate }), + {}, + { dynamicTools: () => [dynamic] }, + ); expect(textOf(await harness.client.callTool({ name: 'dyn', arguments: {} }))).toBe('dynamic ran'); expect(gate).not.toHaveBeenCalled(); }); @@ -98,15 +105,19 @@ describe('tool gate', () => { return REFUSAL; }, }); - harness = await createAttachHarness(registry, {}, { - onInvoke: () => order.push('onInvoke'), - around: async (_info, call) => { - order.push('around:in'); - const out = await call(); - order.push('around:out'); - return out; + harness = await createAttachHarness( + registry, + {}, + { + onInvoke: () => order.push('onInvoke'), + around: async (_info, call) => { + order.push('around:in'); + const out = await call(); + order.push('around:out'); + return out; + }, }, - }); + ); await harness.client.callTool({ name: 'write_it', arguments: { n: 1 } }); expect(order).toEqual(['onInvoke', 'around:in', 'gate', 'around:out']); }); @@ -151,7 +162,10 @@ describe('McpModule.forRoot toolGate', () => { harness = await createAttachHarness(moduleRef.get(McpRegistryService), { userId: 1 }); expect(await harness.client.callTool({ name: 'write_it', arguments: { n: 1 } })).toEqual(REFUSAL); moduleRef.get(Verdicts).refuse = false; - expect(JSON.parse(textOf(await harness.client.callTool({ name: 'write_it', arguments: { n: 1 } })))).toEqual({ n: 1, userId: 1 }); + expect(JSON.parse(textOf(await harness.client.callTool({ name: 'write_it', arguments: { n: 1 } })))).toEqual({ + n: 1, + userId: 1, + }); await moduleRef.close(); }); @@ -159,7 +173,10 @@ describe('McpModule.forRoot toolGate', () => { const moduleRef = await Test.createTestingModule({ imports: [GatedModule, McpModule.forRoot()] }).compile(); await moduleRef.init(); harness = await createAttachHarness(moduleRef.get(McpRegistryService), { userId: 2 }); - expect(JSON.parse(textOf(await harness.client.callTool({ name: 'write_it', arguments: { n: 3 } })))).toEqual({ n: 3, userId: 2 }); + expect(JSON.parse(textOf(await harness.client.callTool({ name: 'write_it', arguments: { n: 3 } })))).toEqual({ + n: 3, + userId: 2, + }); await moduleRef.close(); }); }); diff --git a/server/tests/unit/nest-rpc/plugin-guards.test.ts b/server/tests/unit/nest-rpc/plugin-guards.test.ts index 77cacd3d2..d0805de55 100644 --- a/server/tests/unit/nest-rpc/plugin-guards.test.ts +++ b/server/tests/unit/nest-rpc/plugin-guards.test.ts @@ -4,21 +4,22 @@ * the legacy router, because rpc-host.test.ts asserts them and shipped plugins read * them, so these tests pin the exact strings rather than just the refusal. */ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { AppSettings } from '../../../src/db/entities/AppSettings.entity'; +import { Users } from '../../../src/db/entities/Users.entity'; +import type { TripsRepository } from '../../../src/db/repositories/Trips.repository'; +import type { UsersRepository } from '../../../src/db/repositories/Users.repository'; import { PluginGuards } from '../../../src/nest-rpc/plugin-guards.service'; import { BadParams, ForbiddenResource } from '../../../src/nest-rpc/rpc-errors'; -import type { TripsRepository } from '../../../src/db/repositories/Trips.repository'; -import { PermissionsService } from '../../../src/nest/permissions/permissions.service'; -import type { AddonsService } from '../../../src/nest/addons/addons.service'; -import type { UsersRepository } from '../../../src/db/repositories/Users.repository'; import type { PluginRpcContext } from '../../../src/nest-rpc/rpc-kit/types'; +import type { AddonsService } from '../../../src/nest/addons/addons.service'; import { UnitOfWork } from '../../../src/nest/database/unit-of-work'; -import { createTestOrm, type TestOrm } from '../../helpers/test-orm'; +import { PermissionsService } from '../../../src/nest/permissions/permissions.service'; import { createSnapshotTestDb } from '../../helpers/db-mock'; import { createUser } from '../../helpers/factories'; import { updateRows } from '../../helpers/factories/rows'; -import { AppSettings } from '../../../src/db/entities/AppSettings.entity'; -import { Users } from '../../../src/db/entities/Users.entity'; +import { createTestOrm, type TestOrm } from '../../helpers/test-orm'; + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; /** * The guards read `actingUserId` and nothing else off the context, so the two per-host @@ -122,12 +123,16 @@ describe('PluginGuards — requireTripEdit and canEditAs', () => { it('PGUARD-009 no access wins over no permission, and names the trip', async () => { const { guards } = build({ allow: false }); - await expect(guards.requireTripEdit(2, 42, 'trip_edit')).rejects.toThrow(new ForbiddenResource('no access to trip 2')); + await expect(guards.requireTripEdit(2, 42, 'trip_edit')).rejects.toThrow( + new ForbiddenResource('no access to trip 2'), + ); }); it('PGUARD-010 access without the edit permission is a different message', async () => { const { guards } = build({ allow: false }); - await expect(guards.requireTripEdit(1, 42, 'trip_edit')).rejects.toThrow(new ForbiddenResource('no permission to edit trip 1')); + await expect(guards.requireTripEdit(1, 42, 'trip_edit')).rejects.toThrow( + new ForbiddenResource('no permission to edit trip 1'), + ); }); it('PGUARD-011 canEditAs returns false rather than throwing when there is no access', async () => { @@ -243,7 +248,9 @@ describe('PluginGuards — requireAddon', () => { }); it('PGUARD-016 a disabled addon is refused with the noun in the message', async () => { - await expect(build({ addonOn: false }).guards.requireAddon('budget', 'costs')).rejects.toThrow(new ForbiddenResource('the costs addon is disabled')); + await expect(build({ addonOn: false }).guards.requireAddon('budget', 'costs')).rejects.toThrow( + new ForbiddenResource('the costs addon is disabled'), + ); }); }); diff --git a/server/tests/unit/nest-rpc/rpc-kit/decorators.test.ts b/server/tests/unit/nest-rpc/rpc-kit/decorators.test.ts index 18b2d66c7..25e0e0b2d 100644 --- a/server/tests/unit/nest-rpc/rpc-kit/decorators.test.ts +++ b/server/tests/unit/nest-rpc/rpc-kit/decorators.test.ts @@ -3,8 +3,6 @@ * enforced by tsc and cannot be asserted here; what these tests pin is what each * decorator RECORDS, because that is what validate() and bindInto() later read. */ -import { describe, it, expect } from 'vitest'; -import { SCOPE_OPTIONS_METADATA } from '@nestjs/common/constants'; import { PluginController, PluginHook, @@ -12,6 +10,9 @@ import { PluginOpenMethod, } from '../../../../src/nest-rpc/rpc-kit/decorators'; import { getEntry, isPluginController, type ClassRef } from '../../../../src/nest-rpc/rpc-kit/metadata'; +import { SCOPE_OPTIONS_METADATA } from '@nestjs/common/constants'; + +import { describe, it, expect } from 'vitest'; const entryOf = (ctor: unknown, methodName: string) => getEntry(ctor as ClassRef, methodName); diff --git a/server/tests/unit/nest-rpc/rpc-kit/metadata.test.ts b/server/tests/unit/nest-rpc/rpc-kit/metadata.test.ts index e8de2ddd5..ab5c125cd 100644 --- a/server/tests/unit/nest-rpc/rpc-kit/metadata.test.ts +++ b/server/tests/unit/nest-rpc/rpc-kit/metadata.test.ts @@ -3,7 +3,6 @@ * by constructor rather than reflect-metadata, so these tests run without a Nest app * and without any polyfill import order to get right. */ -import { describe, it, expect } from 'vitest'; import { addEntry, getEntry, @@ -13,6 +12,8 @@ import { } from '../../../../src/nest-rpc/rpc-kit/metadata'; import type { PluginRpcEntry } from '../../../../src/nest-rpc/rpc-kit/types'; +import { describe, it, expect } from 'vitest'; + const methodEntry = (methodName: string, method: 'tags.list' | 'tags.create' = 'tags.list'): PluginRpcEntry => ({ kind: 'method', methodName, diff --git a/server/tests/unit/nest-rpc/rpc-kit/registry.service.test.ts b/server/tests/unit/nest-rpc/rpc-kit/registry.service.test.ts index ffba7cac7..9de43f41b 100644 --- a/server/tests/unit/nest-rpc/rpc-kit/registry.service.test.ts +++ b/server/tests/unit/nest-rpc/rpc-kit/registry.service.test.ts @@ -3,10 +3,11 @@ * validates at boot so a wrong decorator argument fails the app start rather than a * single plugin activation. */ -import { describe, it, expect, vi } from 'vitest'; -import type { DiscoveryService, MetadataScanner } from '@nestjs/core'; -import { PluginRpcRegistryService } from '../../../../src/nest-rpc/rpc-kit/registry.service'; import { PluginController, PluginMethod } from '../../../../src/nest-rpc/rpc-kit/decorators'; +import { PluginRpcRegistryService } from '../../../../src/nest-rpc/rpc-kit/registry.service'; +import type { DiscoveryService, MetadataScanner } from '@nestjs/core'; + +import { describe, it, expect, vi } from 'vitest'; @PluginController() class TagsRpc { @@ -36,8 +37,7 @@ function serviceFor(instances: object[]): PluginRpcRegistryService { getProviders: () => instances.map((instance) => ({ instance, metatype: instance.constructor })), } as unknown as DiscoveryService; const scanner = { - getAllMethodNames: (proto: object) => - Object.getOwnPropertyNames(proto).filter((n) => n !== 'constructor'), + getAllMethodNames: (proto: object) => Object.getOwnPropertyNames(proto).filter((n) => n !== 'constructor'), } as unknown as MetadataScanner; return new PluginRpcRegistryService(discovery, scanner); } @@ -66,7 +66,10 @@ describe('PluginRpcRegistryService', () => { it('RPCKIT-SVC-004 a null instance is skipped', () => { const discovery = { - getProviders: () => [{ instance: null, metatype: TagsRpc }, { instance: undefined, metatype: TagsRpc }], + getProviders: () => [ + { instance: null, metatype: TagsRpc }, + { instance: undefined, metatype: TagsRpc }, + ], } as unknown as DiscoveryService; const scanner = { getAllMethodNames: () => [] } as unknown as MetadataScanner; const service = new PluginRpcRegistryService(discovery, scanner); diff --git a/server/tests/unit/nest-rpc/rpc-kit/registry.test.ts b/server/tests/unit/nest-rpc/rpc-kit/registry.test.ts index ac1092bc7..a604d1c66 100644 --- a/server/tests/unit/nest-rpc/rpc-kit/registry.test.ts +++ b/server/tests/unit/nest-rpc/rpc-kit/registry.test.ts @@ -4,8 +4,6 @@ * per-plugin dispatch map. Types evaporate under an `as` cast, so every compile-time * guarantee has a runtime twin here, and each one gets a test. */ -import { describe, it, expect, vi } from 'vitest'; -import { Controller } from '@nestjs/common'; import { PluginController, PluginHook, @@ -15,6 +13,9 @@ import { import { PluginRpcRegistry } from '../../../../src/nest-rpc/rpc-kit/registry'; import { createTestPluginRegistry } from '../../../../src/nest-rpc/rpc-kit/testing'; import type { PluginRpcContext } from '../../../../src/nest-rpc/rpc-kit/types'; +import { Controller } from '@nestjs/common'; + +import { describe, it, expect, vi } from 'vitest'; const ctxFor = (actingUserId: number | undefined): PluginRpcContext => ({ pluginId: 'p', @@ -148,9 +149,7 @@ describe('PluginRpcRegistry — validate', () => { // Both checks fire: it disagrees with the table AND names a permission the host // does not know. The second is what catches a permission invented out of thin air. expect(error?.message).toMatch(/but METHOD_PERMISSION says "db:read:tags"/); - expect(error?.message).toMatch( - /method "tags.list" \(Typo.list\) declares unknown permission "db:read:tapps"/, - ); + expect(error?.message).toMatch(/method "tags.list" \(Typo.list\) declares unknown permission "db:read:tapps"/); }); it('RPCKIT-REG-009 a method outside KNOWN_METHODS is named', () => { @@ -277,7 +276,9 @@ describe('PluginRpcRegistry — validate', () => { } expect(error?.message).toMatch(/method "db.query" has no handler/); expect(error?.message).toMatch(/method "plugins.call" has no handler/); - expect(error?.message).toMatch(/hook "photoProvider" has no host-side consumer \(dead grant on the consent screen\)/); + expect(error?.message).toMatch( + /hook "photoProvider" has no host-side consumer \(dead grant on the consent screen\)/, + ); // tags.list IS handled, so it must not be listed. expect(error?.message).not.toMatch(/method "tags.list" has no handler/); }); diff --git a/server/tests/unit/nest/app-settings.registry.test.ts b/server/tests/unit/nest/app-settings.registry.test.ts index d845a5af1..8b82f1953 100644 --- a/server/tests/unit/nest/app-settings.registry.test.ts +++ b/server/tests/unit/nest/app-settings.registry.test.ts @@ -1,4 +1,3 @@ -import { afterEach, describe, expect, it, vi } from 'vitest'; import { ADMIN_FORM_SETTING_KEYS, APP_SETTINGS, @@ -14,6 +13,8 @@ import { } from '../../../src/nest/common/app-settings.registry'; import { MANAGED_LOCKED_SETTING_KEYS } from '../../../src/nest/common/managed'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + /** A stand-in for AppSettingsRepository.getValue over a fixed set of rows. */ function repoOf(rows: Record) { const getValue = vi.fn(async (key: string) => (key in rows ? rows[key] : null)); @@ -25,19 +26,40 @@ afterEach(() => vi.unstubAllEnvs()); describe('the derived key lists', () => { it('APPSET-001: the admin form answers the keys it always has, in the same order', () => { expect([...ADMIN_FORM_SETTING_KEYS]).toEqual([ - 'allow_registration', 'allowed_file_types', 'require_mfa', - 'smtp_host', 'smtp_port', 'smtp_user', 'smtp_pass', 'smtp_from', 'smtp_skip_tls_verify', - 'notification_channels', 'admin_webhook_url', 'admin_ntfy_server', 'admin_ntfy_topic', 'admin_ntfy_token', + 'allow_registration', + 'allowed_file_types', + 'require_mfa', + 'smtp_host', + 'smtp_port', + 'smtp_user', + 'smtp_pass', + 'smtp_from', + 'smtp_skip_tls_verify', + 'notification_channels', + 'admin_webhook_url', + 'admin_ntfy_server', + 'admin_ntfy_topic', + 'admin_ntfy_token', 'notify_trip_reminder', - 'password_login', 'password_registration', 'oidc_login', 'oidc_registration', - 'passkey_login', 'webauthn_rp_id', 'webauthn_origins', + 'password_login', + 'password_registration', + 'oidc_login', + 'oidc_registration', + 'passkey_login', + 'webauthn_rp_id', + 'webauthn_origins', 'places_provider', ]); }); it('APPSET-002: the masked and the encrypted keys', () => { expect([...MASKED_ADMIN_SETTING_KEYS].sort()).toEqual(['admin_ntfy_token', 'admin_webhook_url', 'smtp_pass']); - expect([...ENCRYPTED_APP_SETTING_KEYS].sort()).toEqual(['admin_ntfy_token', 'admin_webhook_url', 'oidc_client_secret', 'smtp_pass']); + expect([...ENCRYPTED_APP_SETTING_KEYS].sort()).toEqual([ + 'admin_ntfy_token', + 'admin_webhook_url', + 'oidc_client_secret', + 'smtp_pass', + ]); expect((APP_SETTINGS.smtp_pass as AppSettingDef).encrypted).toBe('always'); expect((APP_SETTINGS.admin_webhook_url as AppSettingDef).encrypted).toBe('if-plain'); }); @@ -45,10 +67,20 @@ describe('the derived key lists', () => { it('APPSET-003: the public config reads the keys it always read', () => { expect([...PUBLIC_CONFIG_SETTING_KEYS].sort()).toEqual( [ - 'places_provider', 'oidc_display_name', 'oidc_issuer', 'oidc_client_id', 'require_mfa', - 'notification_channel', 'notify_trip_reminder', 'smtp_host', 'notification_channels', - 'places_photos_enabled', 'places_autocomplete_enabled', 'places_details_enabled', - 'places_enrich_enabled', 'place_shadow_enabled', + 'places_provider', + 'oidc_display_name', + 'oidc_issuer', + 'oidc_client_id', + 'require_mfa', + 'notification_channel', + 'notify_trip_reminder', + 'smtp_host', + 'notification_channels', + 'places_photos_enabled', + 'places_autocomplete_enabled', + 'places_details_enabled', + 'places_enrich_enabled', + 'place_shadow_enabled', ].sort(), ); }); @@ -108,7 +140,14 @@ describe('isOidcConfigured', () => { vi.stubEnv('OIDC_ISSUER', ''); vi.stubEnv('OIDC_CLIENT_ID', ''); expect(isOidcConfiguredIn(new Map())).toBe(false); - expect(isOidcConfiguredIn(new Map([['oidc_issuer', 'https://idp'], ['oidc_client_id', 'trek']]))).toBe(true); + expect( + isOidcConfiguredIn( + new Map([ + ['oidc_issuer', 'https://idp'], + ['oidc_client_id', 'trek'], + ]), + ), + ).toBe(true); vi.stubEnv('OIDC_CLIENT_ID', 'trek'); expect(isOidcConfiguredIn(new Map([['oidc_issuer', 'https://idp']]))).toBe(true); }); diff --git a/server/tests/unit/nest/auth-core/auth-guard.test.ts b/server/tests/unit/nest/auth-core/auth-guard.test.ts index 3d89b7cf9..c9c641c56 100644 --- a/server/tests/unit/nest/auth-core/auth-guard.test.ts +++ b/server/tests/unit/nest/auth-core/auth-guard.test.ts @@ -1,20 +1,33 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; +import type { AuditService } from '../../../../src/nest/audit/audit.service'; +import { AdminGuard } from '../../../../src/nest/auth-core/admin.guard'; +import { CookieAuthGuard } from '../../../../src/nest/auth-core/cookie-auth.guard'; +import { CurrentUser } from '../../../../src/nest/auth-core/current-user.decorator'; +import { JwtAuthGuard } from '../../../../src/nest/auth-core/jwt-auth.guard'; +import { extractToken, verifyJwtAndLoadUser } from '../../../../src/nest/auth-core/jwt-verify'; +import { OptionalJwtGuard } from '../../../../src/nest/auth-core/optional-jwt.guard'; +import type { AuthService } from '../../../../src/nest/auth/auth.service'; +import { PasskeyEnabledGuard } from '../../../../src/nest/auth/passkey-enabled.guard'; +import { PasskeyController } from '../../../../src/nest/auth/passkey.controller'; +import type { PasskeyService } from '../../../../src/nest/auth/passkey.service'; +import { setAuthCookie } from '../../../../src/nest/common/cookie'; +import { RateLimitService } from '../../../../src/nest/common/rate-limit.service'; +import type { User } from '../../../../src/types'; +import type { EntityManager } from '@mikro-orm/core'; import { HttpException } from '@nestjs/common'; + import type { Request } from 'express'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; vi.mock('../../../../src/nest/auth-core/jwt-verify', () => ({ extractToken: vi.fn(), verifyJwtAndLoadUser: vi.fn() })); vi.mock('../../../../src/nest/common/cookie', () => ({ setAuthCookie: vi.fn() })); vi.mock('../../../../src/nest/audit/client-ip', () => ({ getClientIp: vi.fn(() => '1.2.3.4') })); -vi.mock('../../../../src/nest/audit/audit-log.logger', () => ({ LOG_LEVEL: 'error', logInfo: vi.fn(), logDebug: vi.fn(), logError: vi.fn(), logWarn: vi.fn() })); - -import type { EntityManager } from '@mikro-orm/core'; -import { JwtAuthGuard } from '../../../../src/nest/auth-core/jwt-auth.guard'; -import { CookieAuthGuard } from '../../../../src/nest/auth-core/cookie-auth.guard'; -import { OptionalJwtGuard } from '../../../../src/nest/auth-core/optional-jwt.guard'; -import { AdminGuard } from '../../../../src/nest/auth-core/admin.guard'; -import { PasskeyEnabledGuard } from '../../../../src/nest/auth/passkey-enabled.guard'; -import { PasskeyController } from '../../../../src/nest/auth/passkey.controller'; -import { RateLimitService } from '../../../../src/nest/common/rate-limit.service'; +vi.mock('../../../../src/nest/audit/audit-log.logger', () => ({ + LOG_LEVEL: 'error', + logInfo: vi.fn(), + logDebug: vi.fn(), + logError: vi.fn(), + logWarn: vi.fn(), +})); // AuditService is constructor-injected since the auditLog DI migration; the // wrapper keeps the historical construction sites positional. @@ -32,13 +45,6 @@ const passkey = { deletePasskey: vi.fn(), }; const pc = (limiter: RateLimitService) => new PasskeyController(limiter, audit, passkey as unknown as PasskeyService); -import { CurrentUser } from '../../../../src/nest/auth-core/current-user.decorator'; -import { extractToken, verifyJwtAndLoadUser } from '../../../../src/nest/auth-core/jwt-verify'; -import type { AuthService } from '../../../../src/nest/auth/auth.service'; -import type { PasskeyService } from '../../../../src/nest/auth/passkey.service'; -import { setAuthCookie } from '../../../../src/nest/common/cookie'; -import type { AuditService } from '../../../../src/nest/audit/audit.service'; -import type { User } from '../../../../src/types'; const user = { id: 1, username: 'u', role: 'user', email: 'u@example.test' } as User; @@ -53,7 +59,9 @@ function context(req: unknown) { return { switchToHttp: () => ({ getRequest: () => req }) } as never; } function thrown(fn: () => unknown): { status: number; body: unknown } { - try { fn(); } catch (err) { + try { + fn(); + } catch (err) { expect(err).toBeInstanceOf(HttpException); const e = err as HttpException; return { status: e.getStatus(), body: e.getResponse() }; @@ -61,7 +69,9 @@ function thrown(fn: () => unknown): { status: number; body: unknown } { throw new Error('expected throw'); } async function thrownAsync(fn: () => Promise): Promise<{ status: number; body: unknown }> { - try { await fn(); } catch (err) { + try { + await fn(); + } catch (err) { expect(err).toBeInstanceOf(HttpException); const e = err as HttpException; return { status: e.getStatus(), body: e.getResponse() }; @@ -163,8 +173,14 @@ describe('AdminGuard', () => { const guard = new AdminGuard(); it('403s for anonymous and for a non-admin role', () => { - expect(thrown(() => guard.canActivate(context({})))).toEqual({ status: 403, body: { error: 'Admin access required' } }); - expect(thrown(() => guard.canActivate(context({ user: { role: 'user' } })))).toEqual({ status: 403, body: { error: 'Admin access required' } }); + expect(thrown(() => guard.canActivate(context({})))).toEqual({ + status: 403, + body: { error: 'Admin access required' }, + }); + expect(thrown(() => guard.canActivate(context({ user: { role: 'user' } })))).toEqual({ + status: 403, + body: { error: 'Admin access required' }, + }); }); it('allows an admin through', () => { @@ -180,7 +196,10 @@ describe('PasskeyEnabledGuard', () => { 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' } }); + expect(await thrownAsync(() => guard.canActivate())).toEqual({ + status: 404, + body: { error: 'Passkey login is not enabled' }, + }); }); it('allows when passkey_login is on', async () => { @@ -193,9 +212,14 @@ describe('CurrentUser decorator', () => { // Apply the decorator to a throwaway handler so Nest stores the param factory in // route metadata, then invoke that factory exactly as the framework would. function paramFactory(): (data: unknown, ctx: unknown) => User | undefined { - class Target { handler(_u: User) {} } + class Target { + handler(_u: User) {} + } (CurrentUser() as ParameterDecorator)(Target.prototype, 'handler', 0); - const meta = Reflect.getMetadata('__routeArguments__', Target, 'handler') as Record User | undefined }>; + const meta = Reflect.getMetadata('__routeArguments__', Target, 'handler') as Record< + string, + { factory: (data: unknown, ctx: unknown) => User | undefined } + >; return Object.values(meta)[0].factory; } @@ -211,18 +235,26 @@ describe('CurrentUser decorator', () => { describe('PasskeyController', () => { const req = { ip: '9.9.9.9', headers: {} } as Request; const res = {} as never; - function rl(): RateLimitService { return new RateLimitService(); } + function rl(): RateLimitService { + return new RateLimitService(); + } it('register/options maps a service error, else returns the options', async () => { passkey.passkeyRegisterOptions.mockResolvedValue({ error: 'Incorrect password', status: 401 }); - expect(await thrownAsync(() => pc(rl()).registerOptions(user, { password: 'x' }, req))).toEqual({ status: 401, body: { error: 'Incorrect password' } }); + expect(await thrownAsync(() => pc(rl()).registerOptions(user, { password: 'x' }, req))).toEqual({ + status: 401, + body: { error: 'Incorrect password' }, + }); passkey.passkeyRegisterOptions.mockResolvedValue({ options: { challenge: 'c' } as never }); expect(await pc(rl()).registerOptions(user, { password: 'p' }, req)).toEqual({ challenge: 'c' }); }); it('register/verify maps a service error, else audits and returns the credential', async () => { passkey.passkeyRegisterVerify.mockResolvedValue({ error: 'Verification failed', status: 400 } as never); - expect(await thrownAsync(() => pc(rl()).registerVerify(user, {}, req))).toEqual({ status: 400, body: { error: 'Verification failed' } }); + expect(await thrownAsync(() => pc(rl()).registerVerify(user, {}, req))).toEqual({ + status: 400, + body: { error: 'Verification failed' }, + }); passkey.passkeyRegisterVerify.mockResolvedValue({ credential: { id: 'cr' } } as never); expect(await pc(rl()).registerVerify(user, {}, req)).toEqual({ success: true, credential: { id: 'cr' } }); expect(writeAudit).toHaveBeenCalledWith(expect.objectContaining({ action: 'user.passkey_register' })); @@ -230,14 +262,25 @@ describe('PasskeyController', () => { it('login/options maps a service error, else returns the options', async () => { passkey.passkeyLoginOptions.mockResolvedValue({ error: 'Not configured', status: 503 } as never); - expect(await thrownAsync(() => pc(rl()).loginOptions(req))).toEqual({ status: 503, body: { error: 'Not configured' } }); + expect(await thrownAsync(() => pc(rl()).loginOptions(req))).toEqual({ + status: 503, + body: { error: 'Not configured' }, + }); passkey.passkeyLoginOptions.mockResolvedValue({ options: { challenge: 'd' } } as never); expect(await pc(rl()).loginOptions(req)).toEqual({ challenge: 'd' }); }); it('login/verify audits a failure then maps the error, padding latency', async () => { - passkey.passkeyLoginVerify.mockResolvedValue({ error: 'No match', status: 401, auditAction: 'user.login_fail', auditUserId: null } as never); - expect(await thrownAsync(() => pc(rl()).loginVerify({}, req, res))).toEqual({ status: 401, body: { error: 'No match' } }); + passkey.passkeyLoginVerify.mockResolvedValue({ + error: 'No match', + status: 401, + auditAction: 'user.login_fail', + auditUserId: null, + } as never); + expect(await thrownAsync(() => pc(rl()).loginVerify({}, req, res))).toEqual({ + status: 401, + body: { error: 'No match' }, + }); expect(writeAudit).toHaveBeenCalledWith(expect.objectContaining({ action: 'user.login_fail' })); }, 10000); @@ -245,7 +288,9 @@ describe('PasskeyController', () => { passkey.passkeyLoginVerify.mockResolvedValue({ token: 'tk', user, auditUserId: 1 } as never); expect(await pc(rl()).loginVerify({}, req, res)).toEqual({ token: 'tk', user }); expect(setAuthCookie).toHaveBeenCalledWith(res, 'tk', req); - expect(writeAudit).toHaveBeenCalledWith(expect.objectContaining({ action: 'user.login', details: { method: 'passkey' } })); + expect(writeAudit).toHaveBeenCalledWith( + expect.objectContaining({ action: 'user.login', details: { method: 'passkey' } }), + ); }, 10000); it('credentials: list, rename (error + success), delete (error + success)', async () => { @@ -253,12 +298,18 @@ describe('PasskeyController', () => { expect(await pc(rl()).list(user)).toEqual({ credentials: [{ id: 'a' }] }); 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' } }); + 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.mockResolvedValue({ error: 'Incorrect password', status: 401 }); - expect(await thrownAsync(() => pc(rl()).remove(user, 'cid', { password: 'x' }, req))).toEqual({ status: 401, body: { error: 'Incorrect password' } }); + expect(await thrownAsync(() => pc(rl()).remove(user, 'cid', { password: 'x' }, req))).toEqual({ + status: 401, + body: { error: 'Incorrect password' }, + }); 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' })); @@ -268,11 +319,17 @@ describe('PasskeyController', () => { const s = new RateLimitService(); const now = Date.now(); for (let i = 0; i < 5; i++) await s.check('mfa', '9.9.9.9', 5, 15 * 60 * 1000, now); - expect(await thrownAsync(() => pc(s).registerOptions(user, {}, req))).toEqual({ status: 429, body: { error: 'Too many attempts. Please try again later.' } }); + expect(await thrownAsync(() => pc(s).registerOptions(user, {}, req))).toEqual({ + status: 429, + body: { error: 'Too many attempts. Please try again later.' }, + }); const s2 = new RateLimitService(); for (let i = 0; i < 10; i++) await s2.check('login', '9.9.9.9', 10, 15 * 60 * 1000, now); - expect(await thrownAsync(() => pc(s2).loginOptions(req))).toEqual({ status: 429, body: { error: 'Too many attempts. Please try again later.' } }); + expect(await thrownAsync(() => pc(s2).loginOptions(req))).toEqual({ + status: 429, + body: { error: 'Too many attempts. Please try again later.' }, + }); }); it('falls back to the "unknown" rate-limit key when req.ip is absent', async () => { diff --git a/server/tests/unit/nest/auth-core/jwt-verify.test.ts b/server/tests/unit/nest/auth-core/jwt-verify.test.ts index d0b5754cc..e9e7aded7 100644 --- a/server/tests/unit/nest/auth-core/jwt-verify.test.ts +++ b/server/tests/unit/nest/auth-core/jwt-verify.test.ts @@ -18,11 +18,9 @@ * mocking `src/db/database`'s module-level `db` export, which this file no * longer imports at all. */ -import { describe, it, expect, vi, afterEach } from 'vitest'; -import jwt from 'jsonwebtoken'; - -vi.mock('../../../../src/config', () => ({ JWT_SECRET: 'test-secret' })); - +import type { UserSessionsRepository } from '../../../../src/db/repositories/UserSessions.repository'; +import type { UsersRepository, UserWithPasswordVersion } from '../../../../src/db/repositories/Users.repository'; +import { dbNow } from '../../../../src/db/types'; import { SESSION_TOUCH_INTERVAL_MS, currentSessionId, @@ -30,15 +28,19 @@ import { verifiedSessionClaims, verifyJwtAndLoadUser, } from '../../../../src/nest/auth-core/jwt-verify'; -import type { UsersRepository, UserWithPasswordVersion } from '../../../../src/db/repositories/Users.repository'; -import type { UserSessionsRepository } from '../../../../src/db/repositories/UserSessions.repository'; -import { dbNow } from '../../../../src/db/types'; -import type { Request } from 'express'; -function makeReq(overrides: { - cookies?: Record; - headers?: Record; -} = {}): Request { +import type { Request } from 'express'; +import jwt from 'jsonwebtoken'; +import { describe, it, expect, vi, afterEach } from 'vitest'; + +vi.mock('../../../../src/config', () => ({ JWT_SECRET: 'test-secret' })); + +function makeReq( + overrides: { + cookies?: Record; + headers?: Record; + } = {}, +): Request { return { cookies: overrides.cookies || {}, headers: overrides.headers || {}, @@ -59,7 +61,9 @@ function makeReq(overrides: { * loosen a production type for a test-only case), keeps that column's real * NOT-NULL guarantee intact everywhere else. */ -function usersRepo(row: (Omit & { password_version: number | null }) | null): UsersRepository { +function usersRepo( + row: (Omit & { password_version: number | null }) | null, +): UsersRepository { return { findByIdWithPasswordVersion: vi.fn(async () => row) } as unknown as UsersRepository; } @@ -114,10 +118,21 @@ describe('extractToken', () => { describe('verifyJwtAndLoadUser', () => { it('AUTH-JWT-001: returns the user for a valid token, without password_version', async () => { - const users = usersRepo({ id: 1, username: 'alice', email: 'alice@example.com', role: 'user', password_version: 0 }); + const users = usersRepo({ + id: 1, + username: 'alice', + email: 'alice@example.com', + role: 'user', + password_version: 0, + }); const token = jwt.sign({ id: 1 }, 'test-secret', { algorithm: 'HS256' }); - expect(await verifyJwtAndLoadUser(token, users, sessionsRepo())).toEqual({ id: 1, username: 'alice', email: 'alice@example.com', role: 'user' }); + expect(await verifyJwtAndLoadUser(token, users, sessionsRepo())).toEqual({ + id: 1, + username: 'alice', + email: 'alice@example.com', + role: 'user', + }); expect(users.findByIdWithPasswordVersion).toHaveBeenCalledWith(1); }); @@ -127,22 +142,38 @@ describe('verifyJwtAndLoadUser', () => { it('AUTH-JWT-003: returns null when the user no longer exists', async () => { const users = usersRepo(null); - expect(await verifyJwtAndLoadUser(jwt.sign({ id: 99999 }, 'test-secret', { algorithm: 'HS256' }), users, sessionsRepo())).toBeNull(); + expect( + await verifyJwtAndLoadUser(jwt.sign({ id: 99999 }, 'test-secret', { algorithm: 'HS256' }), users, sessionsRepo()), + ).toBeNull(); }); 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' }); + const expired = jwt.sign({ id: 1, exp: Math.floor(Date.now() / 1000) - 3600 }, 'test-secret', { + algorithm: 'HS256', + }); expect(await verifyJwtAndLoadUser(expired, usersRepo(null), sessionsRepo())).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' }), usersRepo(null), sessionsRepo())).toBeNull(); + expect( + await verifyJwtAndLoadUser( + jwt.sign({ id: 1 }, 'wrong-secret', { algorithm: 'HS256' }), + usersRepo(null), + sessionsRepo(), + ), + ).toBeNull(); }); 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. - const users = usersRepo({ id: 1, username: 'alice', email: 'alice@example.com', role: 'user', password_version: 0 }); + const users = usersRepo({ + 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(await verifyJwtAndLoadUser(mfaToken, users, sessionsRepo())).toBeNull(); @@ -150,14 +181,26 @@ describe('verifyJwtAndLoadUser', () => { }); it('AUTH-JWT-007: rejects a token whose password_version predates the user row', async () => { - const users = usersRepo({ id: 1, username: 'alice', email: 'alice@example.com', role: 'user', password_version: 2 }); + const users = usersRepo({ + 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(await verifyJwtAndLoadUser(stale, users, sessionsRepo())).toBeNull(); }); it('AUTH-JWT-008: accepts a token whose password_version matches', async () => { - const users = usersRepo({ id: 1, username: 'alice', email: 'alice@example.com', role: 'user', password_version: 2 }); + const users = usersRepo({ + 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(await verifyJwtAndLoadUser(current, users, sessionsRepo())).not.toBeNull(); @@ -168,14 +211,26 @@ describe('verifyJwtAndLoadUser', () => { // fallback branch directly (task-1-review.md F5) — unreachable through // the real UsersRepository, whose column is NOT NULL DEFAULT 0, but // worth keeping covered rather than deleting the branch it guards. - const users = usersRepo({ id: 1, username: 'alice', email: 'alice@example.com', role: 'user', password_version: null }); + const users = usersRepo({ + id: 1, + username: 'alice', + email: 'alice@example.com', + role: 'user', + password_version: null, + }); const legacy = jwt.sign({ id: 1 }, 'test-secret', { algorithm: 'HS256' }); expect(await verifyJwtAndLoadUser(legacy, users, sessionsRepo())).not.toBeNull(); }); it('AUTH-JWT-010: but a pre-pv token stops working once the user has reset', async () => { - const users = usersRepo({ id: 1, username: 'alice', email: 'alice@example.com', role: 'user', password_version: 1 }); + const users = usersRepo({ + id: 1, + username: 'alice', + email: 'alice@example.com', + role: 'user', + password_version: 1, + }); const legacy = jwt.sign({ id: 1 }, 'test-secret', { algorithm: 'HS256' }); expect(await verifyJwtAndLoadUser(legacy, users, sessionsRepo())).toBeNull(); @@ -198,8 +253,17 @@ describe('verifyJwtAndLoadUser: the session check', () => { const sessions = sessionsRepo({ id: SID, last_seen_at: dbNow() }); const token = jwt.sign({ id: 1, pv: 0 }, 'test-secret', { algorithm: 'HS256', jwtid: SID }); - expect(await verifyJwtAndLoadUser(token, usersRepo(alice), sessions)).toEqual({ id: 1, username: 'alice', email: 'alice@example.com', role: 'user' }); - expect(sessions.findActive).toHaveBeenCalledWith(SID, 1, expect.stringMatching(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/)); + expect(await verifyJwtAndLoadUser(token, usersRepo(alice), sessions)).toEqual({ + id: 1, + username: 'alice', + email: 'alice@example.com', + role: 'user', + }); + expect(sessions.findActive).toHaveBeenCalledWith( + SID, + 1, + expect.stringMatching(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/), + ); }); it('AUTH-JWT-013: a token whose session is revoked, expired or unknown is refused', async () => { @@ -262,7 +326,9 @@ describe('currentSessionId', () => { }); it('is undefined for a token without a jti, and without any token', () => { - expect(currentSessionId(makeReq({ cookies: { trek_session: jwt.sign({ id: 1 }, 'test-secret') } }))).toBeUndefined(); + expect( + currentSessionId(makeReq({ cookies: { trek_session: jwt.sign({ id: 1 }, 'test-secret') } })), + ).toBeUndefined(); expect(currentSessionId(makeReq())).toBeUndefined(); }); @@ -274,7 +340,9 @@ describe('currentSessionId', () => { describe('verifiedSessionClaims', () => { it('returns the claims of a well-signed token, even an expired one', () => { - const expired = jwt.sign({ id: 3, jti: 'sid', exp: Math.floor(Date.now() / 1000) - 60 }, 'test-secret', { algorithm: 'HS256' }); + const expired = jwt.sign({ id: 3, jti: 'sid', exp: Math.floor(Date.now() / 1000) - 60 }, 'test-secret', { + algorithm: 'HS256', + }); expect(verifiedSessionClaims(expired)).toEqual(expect.objectContaining({ id: 3, jti: 'sid' })); }); @@ -283,7 +351,9 @@ describe('verifiedSessionClaims', () => { }); it('refuses a purpose-scoped token and one without a numeric user id', () => { - expect(verifiedSessionClaims(jwt.sign({ id: 3, purpose: 'mfa_login' }, 'test-secret', { algorithm: 'HS256' }))).toBeNull(); + expect( + verifiedSessionClaims(jwt.sign({ id: 3, purpose: 'mfa_login' }, 'test-secret', { algorithm: 'HS256' })), + ).toBeNull(); expect(verifiedSessionClaims(jwt.sign({ id: 'x' }, 'test-secret', { algorithm: 'HS256' }))).toBeNull(); }); diff --git a/server/tests/unit/nest/auth-core/mfa-policy.guard.test.ts b/server/tests/unit/nest/auth-core/mfa-policy.guard.test.ts index 3a7fdb9ac..dedeb255d 100644 --- a/server/tests/unit/nest/auth-core/mfa-policy.guard.test.ts +++ b/server/tests/unit/nest/auth-core/mfa-policy.guard.test.ts @@ -14,17 +14,18 @@ * `hasAny`) rather than hand-rolling a `DatabaseService.get` stub that * branched on SQL text substrings. */ -import { describe, it, expect, vi } from 'vitest'; -import { HttpException } from '@nestjs/common'; -import { Reflector } from '@nestjs/core'; -import { MfaExempt, MFA_EXEMPT, MfaPolicyGuard } from '../../../../src/nest/auth-core/mfa-policy.guard'; -import { Public, IS_PUBLIC } from '../../../../src/nest/auth-core/public.decorator'; import type { AppSettingsRepository } from '../../../../src/db/repositories/AppSettings.repository'; import type { UsersRepository } from '../../../../src/db/repositories/Users.repository'; import type { WebauthnCredentialsRepository } from '../../../../src/db/repositories/WebauthnCredentials.repository'; import type { RuntimeEnvService } from '../../../../src/nest/app-config/runtime-env.service'; +import { MfaExempt, MFA_EXEMPT, MfaPolicyGuard } from '../../../../src/nest/auth-core/mfa-policy.guard'; +import { Public, IS_PUBLIC } from '../../../../src/nest/auth-core/public.decorator'; import { DEMO_EMAIL_PRIMARY } from '../../../../src/nest/common/demo'; import type { User } from '../../../../src/types'; +import { HttpException } from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; + +import { describe, it, expect, vi } from 'vitest'; const user = { id: 7, email: 'u@example.test', role: 'user' } as User; diff --git a/server/tests/unit/nest/common/kernel-settings-helpers.test.ts b/server/tests/unit/nest/common/kernel-settings-helpers.test.ts index f0a841bc3..b782be75a 100644 --- a/server/tests/unit/nest/common/kernel-settings-helpers.test.ts +++ b/server/tests/unit/nest/common/kernel-settings-helpers.test.ts @@ -1,12 +1,13 @@ -import { describe, expect, it, vi } from 'vitest'; import type { AppSettingsRepository } from '../../../../src/db/repositories/AppSettings.repository'; +import { getPhotoProviderConfig } from '../../../../src/nest/common/photo-provider-config'; import { DEFAULT_TRANSIT_PROVIDER, readTransitProvider, TRANSIT_PROVIDER_SETTING, writeTransitProvider, } from '../../../../src/nest/common/transit-provider'; -import { getPhotoProviderConfig } from '../../../../src/nest/common/photo-provider-config'; + +import { describe, expect, it, vi } from 'vitest'; function settings(value: string | null) { return { getValue: vi.fn(async () => value), setValue: vi.fn(async () => undefined) }; @@ -20,8 +21,12 @@ describe('transit provider setting', () => { }); it('falls back to Transitous for a missing row or a name this version does not know', async () => { - expect(await readTransitProvider(settings(null) as unknown as AppSettingsRepository)).toBe(DEFAULT_TRANSIT_PROVIDER); - expect(await readTransitProvider(settings('rail-x') as unknown as AppSettingsRepository)).toBe(DEFAULT_TRANSIT_PROVIDER); + expect(await readTransitProvider(settings(null) as unknown as AppSettingsRepository)).toBe( + DEFAULT_TRANSIT_PROVIDER, + ); + expect(await readTransitProvider(settings('rail-x') as unknown as AppSettingsRepository)).toBe( + DEFAULT_TRANSIT_PROVIDER, + ); }); it('writes the name under its one key and hands it back', async () => { diff --git a/server/tests/unit/nest/journey-entry-row.test.ts b/server/tests/unit/nest/journey-entry-row.test.ts index 712f0a63b..43a4d158a 100644 --- a/server/tests/unit/nest/journey-entry-row.test.ts +++ b/server/tests/unit/nest/journey-entry-row.test.ts @@ -1,11 +1,11 @@ +import { decodeEntryRow } from '../../../src/nest/journey/journey-entry-row'; +import type { JourneyEntry } from '../../../src/types'; + import { describe, expect, it, vi } from 'vitest'; const logMock = vi.hoisted(() => ({ logInfo: vi.fn(), logError: vi.fn(), logWarn: vi.fn(), logDebug: vi.fn() })); vi.mock('../../../src/nest/audit/audit-log.logger', () => logMock); -import { decodeEntryRow } from '../../../src/nest/journey/journey-entry-row'; -import type { JourneyEntry } from '../../../src/types'; - const row = (over: Partial = {}): JourneyEntry => ({ id: 5, @@ -44,6 +44,8 @@ describe('decodeEntryRow', () => { const wire = decodeEntryRow(row({ tags: '{oops', pros_cons: 'not json' })); expect(wire.tags).toEqual([]); expect(wire.pros_cons).toBeNull(); - expect(logMock.logWarn).toHaveBeenCalledWith('[json] journey_entries.tags (journey entry 5): stored value is not valid JSON, using the fallback'); + expect(logMock.logWarn).toHaveBeenCalledWith( + '[json] journey_entries.tags (journey entry 5): stored value is not valid JSON, using the fallback', + ); }); }); diff --git a/server/tests/unit/nest/maps.google-provider.test.ts b/server/tests/unit/nest/maps.google-provider.test.ts index 9a0bc81dc..4afed6740 100644 --- a/server/tests/unit/nest/maps.google-provider.test.ts +++ b/server/tests/unit/nest/maps.google-provider.test.ts @@ -5,9 +5,10 @@ * provider adds as a unit: the interface's reverse answer, the counted * transport and the credential each request carries. */ -import { afterEach, describe, expect, it, vi } from 'vitest'; -import { GooglePlacesClient } from '../../../src/nest/maps/providers/google-places.provider'; import type { GoogleQuotaService } from '../../../src/nest/google-quota/google-quota.service'; +import { GooglePlacesClient } from '../../../src/nest/maps/providers/google-places.provider'; + +import { afterEach, describe, expect, it, vi } from 'vitest'; const quota = () => ({ exhausted: vi.fn(async () => false), record: vi.fn(async () => {}) }); @@ -32,10 +33,14 @@ describe('GooglePlacesProvider', () => { }); it('GPROV-002: a text search sends the credential it was built with and counts the call', async () => { - const fetchMock = vi.fn(async (_url: string, _init?: RequestInit) => ok({ places: [{ id: 'ChIJ1', displayName: { text: 'Dom' } }] })); + const fetchMock = vi.fn(async (_url: string, _init?: RequestInit) => + ok({ places: [{ id: 'ChIJ1', displayName: { text: 'Dom' } }] }), + ); vi.stubGlobal('fetch', fetchMock); const { q, client: c } = client(); - const places = await c.provider({ key: 'secret', source: 'instance', userId: 7 }).searchText('dom', 'de', { lat: 50, lng: 7, radius: 900000 }); + const places = await c + .provider({ key: 'secret', source: 'instance', userId: 7 }) + .searchText('dom', 'de', { lat: 50, lng: 7, radius: 900000 }); expect(places).toEqual([expect.objectContaining({ google_place_id: 'ChIJ1', name: 'Dom', source: 'google' })]); expect(q.record).toHaveBeenCalledTimes(1); const [url, init] = fetchMock.mock.calls[0]; @@ -55,11 +60,16 @@ describe('GooglePlacesProvider', () => { }); it('GPROV-004: a details error keeps Google message and status', async () => { - vi.stubGlobal('fetch', vi.fn(async () => new Response(JSON.stringify({ error: { message: 'API key not valid' } }), { status: 403 }))); + vi.stubGlobal( + 'fetch', + vi.fn(async () => new Response(JSON.stringify({ error: { message: 'API key not valid' } }), { status: 403 })), + ); const { client: c } = client(); - await expect(c.provider({ key: 'k', source: null, userId: 0 }).placeDetailsExpanded('ChIJ1')).rejects.toMatchObject({ - message: 'API key not valid', - status: 403, - }); + await expect(c.provider({ key: 'k', source: null, userId: 0 }).placeDetailsExpanded('ChIJ1')).rejects.toMatchObject( + { + message: 'API key not valid', + status: 403, + }, + ); }); }); diff --git a/server/tests/unit/nest/maps.place-photo-resolver.test.ts b/server/tests/unit/nest/maps.place-photo-resolver.test.ts index 65fac4b08..b17bad6e5 100644 --- a/server/tests/unit/nest/maps.place-photo-resolver.test.ts +++ b/server/tests/unit/nest/maps.place-photo-resolver.test.ts @@ -4,13 +4,14 @@ * dedupe, Google then Wikimedia) runs through MapsService in * maps.service.test.ts; this pins the cases a source answer alone decides. */ -import { describe, expect, it, vi } from 'vitest'; -import { PlacePhotoResolver } from '../../../src/nest/maps/place-photo.resolver'; -import type { PlacePhotoCacheService } from '../../../src/nest/place-photos/place-photo-cache.service'; import type { PlacesRepository } from '../../../src/db/repositories/Places.repository'; +import { PlacePhotoResolver } from '../../../src/nest/maps/place-photo.resolver'; +import type { PlacesProviderSelector } from '../../../src/nest/maps/places-provider.selector'; import type { GooglePlacesClient } from '../../../src/nest/maps/providers/google-places.provider'; import type { WikimediaClient } from '../../../src/nest/maps/providers/wikimedia.client'; -import type { PlacesProviderSelector } from '../../../src/nest/maps/places-provider.selector'; +import type { PlacePhotoCacheService } from '../../../src/nest/place-photos/place-photo-cache.service'; + +import { describe, expect, it, vi } from 'vitest'; function cache(over: Record = {}) { return { @@ -19,12 +20,21 @@ function cache(over: Record = {}) { getInFlight: vi.fn(() => undefined), setInFlight: vi.fn(), markError: vi.fn(async () => {}), - put: vi.fn(async (id: string, _bytes: Buffer, attribution: string | null) => ({ photoUrl: `/p/${id}`, filePath: '/x', attribution })), + put: vi.fn(async (id: string, _bytes: Buffer, attribution: string | null) => ({ + photoUrl: `/p/${id}`, + filePath: '/x', + attribution, + })), ...over, }; } -function resolver(c: ReturnType, wikiOutcome: unknown, key: string | null = null, googleOutcome: unknown = { kind: 'none' }) { +function resolver( + c: ReturnType, + wikiOutcome: unknown, + key: string | null = null, + googleOutcome: unknown = { kind: 'none' }, +) { const places = { setImageUrlIfUnset: vi.fn(async () => 1) }; const google = { firstPhoto: vi.fn(async () => googleOutcome) }; const wiki = { downloadPhoto: vi.fn(async () => wikiOutcome) }; @@ -41,7 +51,11 @@ function resolver(c: ReturnType, wikiOutcome: unknown, key: string describe('PlacePhotoResolver', () => { it('PHOTO-R-001: a Wikimedia picture the disk cache cannot store counts as a provider failure', async () => { - const c = cache({ put: vi.fn(async () => { throw new Error('disk full'); }) }); + const c = cache({ + put: vi.fn(async () => { + throw new Error('disk full'); + }), + }); const { r } = resolver(c, { kind: 'photo', bytes: Buffer.from([1]), attribution: 'A' }); expect(await r.resolve(1, 'coords:1,2', 1, 2, 'X')).toEqual({ photoUrl: null, attribution: null }); expect(c.markError).toHaveBeenCalledWith('coords:1,2', 'provider-error'); @@ -56,8 +70,15 @@ describe('PlacePhotoResolver', () => { it('PHOTO-R-003: a Google photo is stored, its proxy URL persisted, and Wikimedia never asked', async () => { const c = cache(); - const { r, places, wiki } = resolver(c, { kind: 'none' }, 'key', { kind: 'photo', bytes: Buffer.from([1]), attribution: 'G' }); - expect(await r.resolve(1, 'ChIJabc', 1, 2)).toEqual({ photoUrl: '/api/maps/place-photo/ChIJabc/bytes', attribution: 'G' }); + const { r, places, wiki } = resolver(c, { kind: 'none' }, 'key', { + kind: 'photo', + bytes: Buffer.from([1]), + attribution: 'G', + }); + expect(await r.resolve(1, 'ChIJabc', 1, 2)).toEqual({ + photoUrl: '/api/maps/place-photo/ChIJabc/bytes', + attribution: 'G', + }); expect(places.setImageUrlIfUnset).toHaveBeenCalledWith('ChIJabc', '/p/ChIJabc'); expect(wiki.downloadPhoto).not.toHaveBeenCalled(); }); diff --git a/server/tests/unit/nest/place-import/collection-gpx.provider.test.ts b/server/tests/unit/nest/place-import/collection-gpx.provider.test.ts index 6b98a3ebc..4a7f0a769 100644 --- a/server/tests/unit/nest/place-import/collection-gpx.provider.test.ts +++ b/server/tests/unit/nest/place-import/collection-gpx.provider.test.ts @@ -6,11 +6,6 @@ * a Garmin handheld, a Windows tool that adds a BOM and CRLF, and one hostile * document. The round trip at the end is the promise the TREK extension makes. */ -import fs from 'fs'; -import path from 'path'; -import { describe, it, expect } from 'vitest'; -import { XMLValidator } from 'fast-xml-parser'; -import { COLLECTION_GPX_NAMESPACE, MAX_COLLECTION_FILE_BYTES, type CollectionFilePlace } from '@trek/shared'; import { CollectionGpxError, MAX_GPX_ELEMENTS, @@ -18,15 +13,29 @@ import { gpxToCollectionFile, type ExportedCollectionFile, } from '../../../../src/nest/place-import/providers/collection-gpx.provider'; +import { COLLECTION_GPX_NAMESPACE, MAX_COLLECTION_FILE_BYTES, type CollectionFilePlace } from '@trek/shared'; + +import { XMLValidator } from 'fast-xml-parser'; +import fs from 'fs'; +import path from 'path'; +import { describe, it, expect } from 'vitest'; const fixture = (name: string) => fs.readFileSync(path.join(__dirname, '../../../fixtures/gpx', name), 'utf8'); const listFile = (over: Partial = {}): ExportedCollectionFile => ({ - format: 'trek.collection', version: 1, name: 'Lisbon', places: [], ...over, + format: 'trek.collection', + version: 1, + name: 'Lisbon', + places: [], + ...over, }); const place = (over: Partial = {}): CollectionFilePlace => ({ - name: 'Somewhere', lat: 38.7071, lng: -9.1459, status: 'idea', ...over, + name: 'Somewhere', + lat: 38.7071, + lng: -9.1459, + status: 'idea', + ...over, }); /** The refusal a document gets, or a failure when it is read. */ @@ -45,18 +54,28 @@ const wptDoc = (body: string, rootAttrs = '') => describe('collectionFileToGpx', () => { it('GPX-COLL-001: writes each place as a waypoint in the order GPX 1.1 prescribes', () => { - const { gpx, waypoints, omitted } = collectionFileToGpx(listFile({ - places: [place({ - name: 'Time Out Market', description: 'Market hall', notes: 'Before noon', - address: 'Av. 24 de Julho 49', website: 'https://timeoutmarket.com/', category: 'Restaurant', - })], - })); + const { gpx, waypoints, omitted } = collectionFileToGpx( + listFile({ + places: [ + place({ + name: 'Time Out Market', + description: 'Market hall', + notes: 'Before noon', + address: 'Av. 24 de Julho 49', + website: 'https://timeoutmarket.com/', + category: 'Restaurant', + }), + ], + }), + ); expect(XMLValidator.validate(gpx)).toBe(true); expect({ waypoints, omitted }).toEqual({ waypoints: 1, omitted: 0 }); expect(gpx).toContain(''); // name, cmt, desc, link, type, extensions: the xsd sequence, which strict readers enforce. - const order = ['', '', '', '', ''].map(tag => gpx.indexOf(tag, gpx.indexOf('', '', '', '', ''].map((tag) => + gpx.indexOf(tag, gpx.indexOf(' at > -1 && (i === 0 || at > order[i - 1]))).toBe(true); // Other apps show ; the address rides under the description so they show it too. expect(gpx).toContain('Market hall\n\nAv. 24 de Julho 49'); @@ -66,29 +85,54 @@ describe('collectionFileToGpx', () => { }); it('GPX-COLL-002: carries what GPX has no element for in the TREK namespace', () => { - const { gpx } = collectionFileToGpx(listFile({ - color: '#ef4444', icon: 'Utensils', description: 'Three days', - labels: [{ name: 'Must see', color: '#ff0000' }], - places: [place({ - address: 'Rua 1', phone: '+351 1', status: 'want', price: 12.5, currency: 'EUR', - labels: ['Must see'], links: [{ url: 'https://menu.example', label: 'Menu' }, { url: 'https://bare.example' }], - })], - })); + const { gpx } = collectionFileToGpx( + listFile({ + color: '#ef4444', + icon: 'Utensils', + description: 'Three days', + labels: [{ name: 'Must see', color: '#ff0000' }], + places: [ + place({ + address: 'Rua 1', + phone: '+351 1', + status: 'want', + price: 12.5, + currency: 'EUR', + labels: ['Must see'], + links: [{ url: 'https://menu.example', label: 'Menu' }, { url: 'https://bare.example' }], + }), + ], + }), + ); expect(gpx).toContain(`xmlns:trek="${COLLECTION_GPX_NAMESPACE}"`); for (const line of [ - 'Rua 1', '+351 1', 'want', - '12.5', 'EUR', 'Must see', - 'Menu', '', - '#ef4444', 'Utensils', 'Must see', - ]) expect(gpx, line).toContain(line); + 'Rua 1', + '+351 1', + 'want', + '12.5', + 'EUR', + 'Must see', + 'Menu', + '', + '#ef4444', + 'Utensils', + 'Must see', + ]) + expect(gpx, line).toContain(line); expect(gpx).toContain('\n Lisbon\n Three days'); }); it('GPX-COLL-003: leaves out a place without coordinates and says how many', () => { - const result = collectionFileToGpx(listFile({ - places: [place({ name: 'Here' }), place({ name: 'Nowhere', lat: null, lng: null }), place({ name: 'Half', lng: null })], - })); + const result = collectionFileToGpx( + listFile({ + places: [ + place({ name: 'Here' }), + place({ name: 'Nowhere', lat: null, lng: null }), + place({ name: 'Half', lng: null }), + ], + }), + ); expect(result).toMatchObject({ name: 'Lisbon', waypoints: 1, omitted: 2 }); expect(result.gpx).toContain('Here'); @@ -103,10 +147,12 @@ describe('collectionFileToGpx', () => { }); it('GPX-COLL-005: escapes markup and drops the characters XML cannot hold', () => { - const { gpx } = collectionFileToGpx(listFile({ - name: 'Fish & Chips ', - places: [place({ name: 'A "quoted" & place\u0001\u0008', description: "Bob's \u000bnotes" })], - })); + const { gpx } = collectionFileToGpx( + listFile({ + name: 'Fish & Chips ', + places: [place({ name: 'A "quoted" & place\u0001\u0008', description: "Bob's \u000bnotes" })], + }), + ); expect(XMLValidator.validate(gpx)).toBe(true); expect(gpx).toContain('Fish & Chips <best>'); @@ -116,9 +162,11 @@ describe('collectionFileToGpx', () => { }); it('GPX-COLL-006: never writes a link a reader should not follow', () => { - const { gpx } = collectionFileToGpx(listFile({ - places: [place({ website: 'javascript:alert(1)', links: [{ url: 'ftp://files.example' }] })], - })); + const { gpx } = collectionFileToGpx( + listFile({ + places: [place({ website: 'javascript:alert(1)', links: [{ url: 'ftp://files.example' }] })], + }), + ); expect(gpx).not.toContain('javascript:'); expect(gpx).not.toContain('ftp://'); }); @@ -132,8 +180,12 @@ describe('gpxToCollectionFile: what other apps write', () => { expect(file.name).toBe('favorites'); expect(file.places).toHaveLength(3); expect(file.places[0]).toMatchObject({ - name: 'Time Out Market', lat: 38.7071037, lng: -9.1459227, category: 'Restaurant', - description: 'Go before noon, the queues start at one', address: 'Av. 24 de Julho 49, 1200-479 Lisboa', + name: 'Time Out Market', + lat: 38.7071037, + lng: -9.1459227, + category: 'Restaurant', + description: 'Go before noon, the queues start at one', + address: 'Av. 24 de Julho 49, 1200-479 Lisboa', }); expect(file.places[1]).toMatchObject({ name: 'Miradouro de Santa Luzia', category: 'Viewpoints' }); // A numeric character reference is text like any other. @@ -147,7 +199,11 @@ describe('gpxToCollectionFile: what other apps write', () => { const { file, skipped, track_points } = gpxToCollectionFile(fixture('organic-maps.gpx')); expect(file).toMatchObject({ name: 'Kyoto & Nara', description: 'Temples, gardens and where to eat between them' }); - expect(file.places.map(p => (p as CollectionFilePlace).name)).toEqual(['伏見稲荷大社', 'Kinkaku-ji', 'Nara Park']); + expect(file.places.map((p) => (p as CollectionFilePlace).name)).toEqual([ + '伏見稲荷大社', + 'Kinkaku-ji', + 'Nara Park', + ]); expect(file.places[1]).toMatchObject({ description: 'Golden Pavilion. Buy tickets at the gate & walk the garden loop clockwise.', website: 'https://www.shokoku-ji.jp/kinkakuji/', @@ -172,7 +228,12 @@ describe('gpxToCollectionFile: what other apps write', () => { // No name in the document, so the file name it came in. expect(file.name).toBe('Innsbruck'); - expect(file.places.map(p => (p as CollectionFilePlace).name)).toEqual(['007', 'Goldenes Dachl', 'Waypoint 5', 'Triumphpforte']); + expect(file.places.map((p) => (p as CollectionFilePlace).name)).toEqual([ + '007', + 'Goldenes Dachl', + 'Waypoint 5', + 'Triumphpforte', + ]); // A name of digits stays the text it was. expect(file.places[0]).toMatchObject({ category: 'Parking Area', description: 'Parking at the old town' }); // Garmin writes and alike; the copy is not kept twice. @@ -193,13 +254,15 @@ describe('gpxToCollectionFile: what other apps write', () => { const { file } = gpxToCollectionFile(raw); expect(file.name).toBe('Wien'); expect(file.places).toHaveLength(2); - expect((file.places[0] as CollectionFilePlace).description).toBe('South tower first,\nthe north tower has the bell'); + expect((file.places[0] as CollectionFilePlace).description).toBe( + 'South tower first,\nthe north tower has the bell', + ); }); it('GPX-COLL-015: GPX 1.0 keeps the name on the root and a link as ', () => { const { file } = gpxToCollectionFile( - 'Old fileA' - + 'https://a.exampleA site', + 'Old fileA' + + 'https://a.exampleA site', ); expect(file.name).toBe('Old file'); expect(file.places[0]).toMatchObject({ website: 'https://a.example' }); @@ -209,7 +272,9 @@ describe('gpxToCollectionFile: what other apps write', () => { describe('gpxToCollectionFile: what it refuses', () => { it('GPX-COLL-020: a DOCTYPE, before anything is expanded or fetched', () => { expect(refusal(fixture('hostile-doctype.gpx'))).toBe('unreadable'); - expect(refusal(wptDoc('x').replace('x').replace(' { @@ -241,10 +306,12 @@ describe('gpxToCollectionFile: what it refuses', () => { describe('gpxToCollectionFile: one bad field costs that field', () => { it('GPX-COLL-030: drops what the contract refuses and keeps the place', () => { - const { file, skipped } = gpxToCollectionFile(wptDoc( - `${'n'.repeat(600)}${'d'.repeat(6000)}` - + 'Museum', - )); + const { file, skipped } = gpxToCollectionFile( + wptDoc( + `${'n'.repeat(600)}${'d'.repeat(6000)}` + + 'Museum', + ), + ); expect(skipped).toBe(0); const only = file.places[0] as CollectionFilePlace; expect(only.name).toHaveLength(500); @@ -253,8 +320,11 @@ describe('gpxToCollectionFile: one bad field costs that field', () => { }); it('GPX-COLL-031: reads the TREK extension by namespace, whatever the prefix', () => { - const body = 'Avisitedwant'; - const ours = gpxToCollectionFile(wptDoc(body, ` xmlns:t="${COLLECTION_GPX_NAMESPACE}" xmlns:trek="https://elsewhere.example"`)); + const body = + 'Avisitedwant'; + const ours = gpxToCollectionFile( + wptDoc(body, ` xmlns:t="${COLLECTION_GPX_NAMESPACE}" xmlns:trek="https://elsewhere.example"`), + ); expect(ours.file.places[0]).toMatchObject({ status: 'visited' }); // Without the namespace bound, `trek:` is just somebody else's prefix. const theirs = gpxToCollectionFile(wptDoc(body, ' xmlns:trek="https://elsewhere.example"')); @@ -262,11 +332,13 @@ describe('gpxToCollectionFile: one bad field costs that field', () => { }); it('GPX-COLL-032: an unknown status, a price that is not a number and a bad label cost only themselves', () => { - const { file } = gpxToCollectionFile(wptDoc( - 'Alovedcheap' - + `Ok${'x'.repeat(61)}+1`, - ` xmlns:trek="${COLLECTION_GPX_NAMESPACE}"`, - )); + const { file } = gpxToCollectionFile( + wptDoc( + 'Alovedcheap' + + `Ok${'x'.repeat(61)}+1`, + ` xmlns:trek="${COLLECTION_GPX_NAMESPACE}"`, + ), + ); expect(file.places[0]).toMatchObject({ status: 'idea', labels: ['Ok'], phone: '+1' }); expect(file.places[0]).not.toHaveProperty('price'); }); @@ -276,18 +348,38 @@ describe('a GPX made by TREK comes back as the same list (#2301)', () => { it('GPX-COLL-040: every field of every place, and the list around them', () => { const places: CollectionFilePlace[] = [ { - name: 'Time Out Market', description: 'Market hall\nwith two floors', notes: 'Before noon', - lat: 38.70710371234, lng: -9.14592271234, address: 'Av. 24 de Julho 49', phone: '+351 210 606 040', - website: 'https://timeoutmarket.com/', category: 'Restaurant', status: 'want', price: 12.5, currency: 'EUR', - image_url: 'https://example.com/market.jpg', google_place_id: 'ChIJ123', google_ftid: '0x1:0x2', osm_id: 'node/1', - labels: ['Must see', 'Rainy day'], links: [{ url: 'https://menu.example/', label: 'Menu' }], + name: 'Time Out Market', + description: 'Market hall\nwith two floors', + notes: 'Before noon', + lat: 38.70710371234, + lng: -9.14592271234, + address: 'Av. 24 de Julho 49', + phone: '+351 210 606 040', + website: 'https://timeoutmarket.com/', + category: 'Restaurant', + status: 'want', + price: 12.5, + currency: 'EUR', + image_url: 'https://example.com/market.jpg', + google_place_id: 'ChIJ123', + google_ftid: '0x1:0x2', + osm_id: 'node/1', + labels: ['Must see', 'Rainy day'], + links: [{ url: 'https://menu.example/', label: 'Menu' }], }, { name: 'Address only', lat: 38.7, lng: -9.1, address: 'Rua 2', status: 'visited' }, { name: 'Bare', lat: 0, lng: 0, status: 'idea' }, ]; const original = listFile({ - name: 'Lisbon', description: 'Three days', color: '#ef4444', icon: 'Utensils', exported_at: '2026-09-19T10:00:00.000Z', - labels: [{ name: 'Must see', color: '#ff0000' }, { name: 'Rainy day', color: null }], + name: 'Lisbon', + description: 'Three days', + color: '#ef4444', + icon: 'Utensils', + exported_at: '2026-09-19T10:00:00.000Z', + labels: [ + { name: 'Must see', color: '#ff0000' }, + { name: 'Rainy day', color: null }, + ], places, }); diff --git a/server/tests/unit/nest/place-import/directions-url.helpers.test.ts b/server/tests/unit/nest/place-import/directions-url.helpers.test.ts index 54bdda1b3..b0b8aa5e2 100644 --- a/server/tests/unit/nest/place-import/directions-url.helpers.test.ts +++ b/server/tests/unit/nest/place-import/directions-url.helpers.test.ts @@ -6,8 +6,13 @@ * they drop two pins and copy the address bar. The data blobs are trimmed to the parts * that matter, because the full ones run to four hundred characters of protobuf. */ +import { + isDirectionsUrl, + parseDirectionsUrl, + MAX_DIR_WAYPOINTS, +} from '../../../../src/nest/place-import/directions-url.helpers'; + import { describe, it, expect } from 'vitest'; -import { isDirectionsUrl, parseDirectionsUrl, MAX_DIR_WAYPOINTS } from '../../../../src/nest/place-import/directions-url.helpers'; describe('isDirectionsUrl', () => { it('MAPS-DIR-001: tells a route apart from a list and from a single place', () => { @@ -29,8 +34,9 @@ describe('parseDirectionsUrl', () => { }); it('MAPS-DIR-011: takes the coordinates out of the data blob when there is one per stop', () => { - const url = 'https://www.google.com/maps/dir/Berlin/Dresden/@51.5,13.5,8z/' - + 'data=!4m14!4m13!1m5!1m1!1s0x0:0x0!2m2!1d13.404954!2d52.520008!1m5!1m1!1s0x0:0x0!2m2!1d13.737262!2d51.050409'; + const url = + 'https://www.google.com/maps/dir/Berlin/Dresden/@51.5,13.5,8z/' + + 'data=!4m14!4m13!1m5!1m1!1s0x0:0x0!2m2!1d13.404954!2d52.520008!1m5!1m1!1s0x0:0x0!2m2!1d13.737262!2d51.050409'; // 1d is longitude and 2d is latitude, the reverse of a place link. Getting the two // the wrong way round is the failure this pins. expect(parseDirectionsUrl(url)).toEqual([ @@ -43,8 +49,9 @@ describe('parseDirectionsUrl', () => { // Google drops the pair for "your location" and adds pairs of its own on a route // through several countries. Half a link read confidently is worse than one read as // names, so a mismatched count means names. - const url = 'https://www.google.com/maps/dir/Berlin/Dresden/Prague/@51,13,7z/' - + 'data=!4m8!1m5!1m1!1s0x0:0x0!2m2!1d13.404954!2d52.520008'; + const url = + 'https://www.google.com/maps/dir/Berlin/Dresden/Prague/@51,13,7z/' + + 'data=!4m8!1m5!1m1!1s0x0:0x0!2m2!1d13.404954!2d52.520008'; const stops = parseDirectionsUrl(url); expect(stops).toHaveLength(3); expect(stops.every((s) => s.lat === null)).toBe(true); @@ -63,8 +70,9 @@ describe('parseDirectionsUrl', () => { }); it('MAPS-DIR-015: the documented api=1 form names its parts instead of ordering them', () => { - const url = 'https://www.google.com/maps/dir/?api=1&origin=Berlin&destination=Prague' - + '&waypoints=Dresden%7CLeipzig&travelmode=driving'; + const url = + 'https://www.google.com/maps/dir/?api=1&origin=Berlin&destination=Prague' + + '&waypoints=Dresden%7CLeipzig&travelmode=driving'; expect(parseDirectionsUrl(url).map((s) => s.name)).toEqual(['Berlin', 'Dresden', 'Leipzig', 'Prague']); }); @@ -77,8 +85,10 @@ describe('parseDirectionsUrl', () => { }); it('MAPS-DIR-017: a country domain is still Google Maps', () => { - expect(parseDirectionsUrl('https://www.google.de/maps/dir/Berlin/Dresden').map((s) => s.name)) - .toEqual(['Berlin', 'Dresden']); + expect(parseDirectionsUrl('https://www.google.de/maps/dir/Berlin/Dresden').map((s) => s.name)).toEqual([ + 'Berlin', + 'Dresden', + ]); }); it('MAPS-DIR-018: one stop is a place, not a route', () => { @@ -104,7 +114,7 @@ describe('parseDirectionsUrl', () => { expect(stops[0]).toEqual({ name: '999.5,13.4', lat: null, lng: null }); }); - it('MAPS-DIR-022: a malformed escape is somebody else\'s bad link, not a throw', () => { + it("MAPS-DIR-022: a malformed escape is somebody else's bad link, not a throw", () => { expect(() => parseDirectionsUrl('https://www.google.com/maps/dir/%E0%A4%A/Dresden')).not.toThrow(); expect(parseDirectionsUrl('https://www.google.com/maps/dir/%E0%A4%A/Dresden')).toHaveLength(2); }); diff --git a/server/tests/unit/nest/place-import/google-list.provider.test.ts b/server/tests/unit/nest/place-import/google-list.provider.test.ts index 6ee21ce7b..23f165d8c 100644 --- a/server/tests/unit/nest/place-import/google-list.provider.test.ts +++ b/server/tests/unit/nest/place-import/google-list.provider.test.ts @@ -3,6 +3,13 @@ * places.helpers.test.ts with its code) and the answers the import route * gets back from it. */ +import { + GoogleListProvider, + googleMapsFeatureIdFromItem, + googleMapsHexId, +} from '../../../../src/nest/place-import/providers/google-list.provider'; +import { SsrfBlockedError } from '../../../../src/utils/ssrfGuard'; + import { describe, it, expect, vi, afterEach } from 'vitest'; const { mockCheckSsrf, mockFollow } = vi.hoisted(() => ({ @@ -15,13 +22,6 @@ vi.mock('../../../../src/utils/ssrfGuard', () => ({ SsrfBlockedError: class SsrfBlockedError extends Error {}, })); -import { - GoogleListProvider, - googleMapsFeatureIdFromItem, - googleMapsHexId, -} from '../../../../src/nest/place-import/providers/google-list.provider'; -import { SsrfBlockedError } from '../../../../src/utils/ssrfGuard'; - afterEach(() => { vi.unstubAllGlobals(); mockCheckSsrf.mockReset(); @@ -49,7 +49,9 @@ describe('googleMapsHexId / googleMapsFeatureIdFromItem', () => { it('reads the ftid pair from either item slot, else null', () => { expect(googleMapsFeatureIdFromItem([null, [0, 0, 0, 0, 0, 0, ['0x1', '0x2']]])).toBe('0x1:0x2'); - expect(googleMapsFeatureIdFromItem([null, null, null, null, null, null, null, [null, ['0x3', '0x4']]])).toBe('0x3:0x4'); + expect(googleMapsFeatureIdFromItem([null, null, null, null, null, null, null, [null, ['0x3', '0x4']]])).toBe( + '0x3:0x4', + ); expect(googleMapsFeatureIdFromItem([null, [0, 0, 0, 0, 0, 0, ['0x1']]])).toBeNull(); expect(googleMapsFeatureIdFromItem('not an array')).toBeNull(); }); @@ -60,7 +62,10 @@ describe('GoogleListProvider.read', () => { mockCheckSsrf.mockResolvedValueOnce({ allowed: false }); const fetchMock = vi.fn(); vi.stubGlobal('fetch', fetchMock); - expect(await new GoogleListProvider().read('https://10.0.0.1/maps')).toEqual({ error: 'URL is not allowed', status: 400 }); + expect(await new GoogleListProvider().read('https://10.0.0.1/maps')).toEqual({ + error: 'URL is not allowed', + status: 400, + }); expect(fetchMock).not.toHaveBeenCalled(); }); @@ -83,17 +88,26 @@ describe('GoogleListProvider.read', () => { [null, [null, null, null, null, null, [null, null, null, null]], 'No coords'], ]; const body = `)]}'\n${JSON.stringify([[null, null, null, null, 'Trip ideas', null, null, null, items]])}`; - vi.stubGlobal('fetch', vi.fn(async () => new Response(body, { status: 200 }))); - expect(await new GoogleListProvider().read('https://www.google.com/maps/placelists/list/ABCDEFGHIJKLMNOP')).toEqual({ - listName: 'Trip ideas', - places: [{ name: 'Cafe', lat: 52.5, lng: 13.4, notes: 'note', googleFtid: '0x1:0x2' }], - }); + vi.stubGlobal( + 'fetch', + vi.fn(async () => new Response(body, { status: 200 })), + ); + expect(await new GoogleListProvider().read('https://www.google.com/maps/placelists/list/ABCDEFGHIJKLMNOP')).toEqual( + { + listName: 'Trip ideas', + places: [{ name: 'Cafe', lat: 52.5, lng: 13.4, notes: 'note', googleFtid: '0x1:0x2' }], + }, + ); }); }); describe('GoogleListProvider.read: what Google sends back', () => { const LIST = 'https://www.google.com/maps/@52,13,10z/data=!4m3!11m2!2sABCDEFGHIJKLMNOPQRS!3e3'; - const respond = (body: string, init: ResponseInit = { status: 200 }) => vi.stubGlobal('fetch', vi.fn(async () => new Response(body, init))); + const respond = (body: string, init: ResponseInit = { status: 200 }) => + vi.stubGlobal( + 'fetch', + vi.fn(async () => new Response(body, init)), + ); const read = () => new GoogleListProvider().read(LIST); it('finds the list id in the data parameter too', async () => { @@ -128,7 +142,10 @@ describe('GoogleListProvider.read: what Google sends back', () => { it('refuses a short link the guard blocks on a hop, and lets a network failure through', async () => { mockFollow.mockRejectedValueOnce(new SsrfBlockedError('blocked')); - expect(await new GoogleListProvider().read('https://maps.app.goo.gl/x')).toEqual({ error: 'URL is not allowed', status: 400 }); + expect(await new GoogleListProvider().read('https://maps.app.goo.gl/x')).toEqual({ + error: 'URL is not allowed', + status: 400, + }); mockFollow.mockRejectedValueOnce(new Error('network')); await expect(new GoogleListProvider().read('https://maps.app.goo.gl/x')).rejects.toThrow('network'); }); diff --git a/server/tests/unit/nest/place-import/gpx.provider.test.ts b/server/tests/unit/nest/place-import/gpx.provider.test.ts index 6f954f835..6338b474f 100644 --- a/server/tests/unit/nest/place-import/gpx.provider.test.ts +++ b/server/tests/unit/nest/place-import/gpx.provider.test.ts @@ -4,17 +4,33 @@ * parser the importer uses, which is the closest thing to "a real reader can open * this" that does not need a device. */ -import { describe, it, expect } from 'vitest'; +import { + buildGpx, + gpxFilename, + type GpxExportPlace, + type GpxExportDay, +} from '../../../../src/nest/place-import/providers/gpx.provider'; + import { XMLParser, XMLValidator } from 'fast-xml-parser'; -import { buildGpx, gpxFilename, type GpxExportPlace, type GpxExportDay } from '../../../../src/nest/place-import/providers/gpx.provider'; +import { describe, it, expect } from 'vitest'; const place = (over: Partial = {}): GpxExportPlace => ({ - name: 'Somewhere', description: null, address: null, lat: 48.8566, lng: 2.3522, - route_geometry: null, category: null, ...over, + name: 'Somewhere', + description: null, + address: null, + lat: 48.8566, + lng: 2.3522, + route_geometry: null, + category: null, + ...over, }); const day = (over: Partial = {}): GpxExportDay => ({ - dayNumber: 1, date: '2026-05-01', title: null, points: [], ...over, + dayNumber: 1, + date: '2026-05-01', + title: null, + points: [], + ...over, }); const parser = new XMLParser({ @@ -41,7 +57,15 @@ describe('buildGpx', () => { it('writes a place carrying geometry as a track, elevation included', () => { const gpx = buildGpx({ tripTitle: 'Hike', - places: [place({ name: 'Ridge', route_geometry: JSON.stringify([[47.1, 11.2, 1830], [47.11, 11.21, 1902]]) })], + places: [ + place({ + name: 'Ridge', + route_geometry: JSON.stringify([ + [47.1, 11.2, 1830], + [47.11, 11.21, 1902], + ]), + }), + ], days: [], })!; @@ -53,7 +77,15 @@ describe('buildGpx', () => { it('does not also drop a waypoint on the start of a track', () => { const gpx = buildGpx({ tripTitle: 'Hike', - places: [place({ name: 'Ridge', route_geometry: JSON.stringify([[47.1, 11.2], [47.11, 11.21]]) })], + places: [ + place({ + name: 'Ridge', + route_geometry: JSON.stringify([ + [47.1, 11.2], + [47.11, 11.21], + ]), + }), + ], days: [], })!; @@ -64,13 +96,17 @@ describe('buildGpx', () => { const gpx = buildGpx({ tripTitle: 'Rome', places: [], - days: [day({ - dayNumber: 2, date: '2026-05-02', title: 'Ancient city', - points: [ - { name: 'Colosseum', lat: 41.89, lng: 12.49 }, - { name: 'Forum', lat: 41.892, lng: 12.485 }, - ], - })], + days: [ + day({ + dayNumber: 2, + date: '2026-05-02', + title: 'Ancient city', + points: [ + { name: 'Colosseum', lat: 41.89, lng: 12.49 }, + { name: 'Forum', lat: 41.892, lng: 12.485 }, + ], + }), + ], })!; expect(gpx).toContain('2. Ancient city'); @@ -79,10 +115,35 @@ describe('buildGpx', () => { }); it('falls back to the date, then the bare number, when a day has no title', () => { - const dated = buildGpx({ tripTitle: 'T', places: [], days: [day({ dayNumber: 3, points: [{ name: 'A', lat: 1, lng: 1 }, { name: 'B', lat: 2, lng: 2 }] })] })!; + const dated = buildGpx({ + tripTitle: 'T', + places: [], + days: [ + day({ + dayNumber: 3, + points: [ + { name: 'A', lat: 1, lng: 1 }, + { name: 'B', lat: 2, lng: 2 }, + ], + }), + ], + })!; expect(dated).toContain('3. 2026-05-01'); - const bare = buildGpx({ tripTitle: 'T', places: [], days: [day({ dayNumber: 4, date: null, points: [{ name: 'A', lat: 1, lng: 1 }, { name: 'B', lat: 2, lng: 2 }] })] })!; + const bare = buildGpx({ + tripTitle: 'T', + places: [], + days: [ + day({ + dayNumber: 4, + date: null, + points: [ + { name: 'A', lat: 1, lng: 1 }, + { name: 'B', lat: 2, lng: 2 }, + ], + }), + ], + })!; expect(bare).toContain('4'); }); @@ -99,8 +160,24 @@ describe('buildGpx', () => { it('honours each flag on its own', () => { const input = { tripTitle: 'T', - places: [place({ name: 'Point' }), place({ name: 'Line', route_geometry: JSON.stringify([[1, 1], [2, 2]]) })], - days: [day({ points: [{ name: 'A', lat: 1, lng: 1 }, { name: 'B', lat: 2, lng: 2 }] })], + places: [ + place({ name: 'Point' }), + place({ + name: 'Line', + route_geometry: JSON.stringify([ + [1, 1], + [2, 2], + ]), + }), + ], + days: [ + day({ + points: [ + { name: 'A', lat: 1, lng: 1 }, + { name: 'B', lat: 2, lng: 2 }, + ], + }), + ], }; const noWpt = buildGpx(input, { waypoints: false })!; @@ -137,9 +214,22 @@ describe('buildGpx', () => { tripTitle: 'Round trip', places: [ place({ name: 'Waypoint', lat: 10.5, lng: -3.25 }), - place({ name: 'Track', route_geometry: JSON.stringify([[1.5, 2.5], [1.6, 2.6]]) }), + place({ + name: 'Track', + route_geometry: JSON.stringify([ + [1.5, 2.5], + [1.6, 2.6], + ]), + }), + ], + days: [ + day({ + points: [ + { name: 'A', lat: 3, lng: 4 }, + { name: 'B', lat: 5, lng: 6 }, + ], + }), ], - days: [day({ points: [{ name: 'A', lat: 3, lng: 4 }, { name: 'B', lat: 5, lng: 6 }] })], })!; expect(XMLValidator.validate(gpx)).toBe(true); diff --git a/server/tests/unit/nest/place-import/kml.codec.test.ts b/server/tests/unit/nest/place-import/kml.codec.test.ts index 939091051..a6f3e7040 100644 --- a/server/tests/unit/nest/place-import/kml.codec.test.ts +++ b/server/tests/unit/nest/place-import/kml.codec.test.ts @@ -1,6 +1,3 @@ -import { describe, it, expect } from 'vitest'; -import path from 'path'; -import fs from 'fs'; import { decodeUtf8WithWarning, extractKmlPlacemarkNodes, @@ -13,6 +10,10 @@ import { } from '../../../../src/nest/place-import/kml.codec'; import { buildCategoryNameLookup, resolveCategoryIdForFolder } from '../../../../src/nest/places/places.helpers'; +import fs from 'fs'; +import path from 'path'; +import { describe, it, expect } from 'vitest'; + describe('kmlImportUtils', () => { it('sanitizes HTML descriptions with br to newline', () => { const input = 'Line 1
Line 2 & more'; @@ -152,7 +153,13 @@ describe('readKmlDocument', () => { Walk13.4,52.5 13.5,52.6 `; const read = readKmlDocument(Buffer.from(kml, 'utf-8')); - expect(read.summary).toMatchObject({ totalPlacemarks: 2, createdCount: 0, skippedCount: 0, warnings: [], errors: [] }); + expect(read.summary).toMatchObject({ + totalPlacemarks: 2, + createdCount: 0, + skippedCount: 0, + warnings: [], + errors: [], + }); expect(read.placemarks.map((p) => [p.name, p.folderName, p.routeGeometry !== null])).toEqual([ ['Walk', null, true], ['Cafe', 'Food', false], @@ -160,11 +167,17 @@ describe('readKmlDocument', () => { }); it('refuses a document that is not well-formed XML with the route message', () => { - expect(() => readKmlDocument(Buffer.from('', 'utf-8'))).toThrow('Malformed KML: invalid XML structure'); + expect(() => readKmlDocument(Buffer.from('', 'utf-8'))).toThrow( + 'Malformed KML: invalid XML structure', + ); }); it('keeps reading a file that is not valid UTF-8 and says so in the summary', () => { - const bytes = Buffer.concat([Buffer.from(''), Buffer.from([0xff]), Buffer.from('')]); + const bytes = Buffer.concat([ + Buffer.from(''), + Buffer.from([0xff]), + Buffer.from(''), + ]); expect(readKmlDocument(bytes).summary.warnings).toEqual([ 'The uploaded file is not valid UTF-8. Some characters may be shown incorrectly.', ]); diff --git a/server/tests/unit/nest/place-import/list-providers.test.ts b/server/tests/unit/nest/place-import/list-providers.test.ts index b2253da6d..9d4f7f2fb 100644 --- a/server/tests/unit/nest/place-import/list-providers.test.ts +++ b/server/tests/unit/nest/place-import/list-providers.test.ts @@ -4,6 +4,10 @@ * through the SSRF guard, and what a good link reads as. The persistence half * is PlacesService's and covered in places.service.test.ts. */ +import { GoogleDirectionsProvider } from '../../../../src/nest/place-import/providers/google-directions.provider'; +import { MAX_LIST_RESPONSE_BYTES } from '../../../../src/nest/place-import/providers/google-list.provider'; +import { NaverListProvider } from '../../../../src/nest/place-import/providers/naver-list.provider'; + import { describe, it, expect, vi, afterEach } from 'vitest'; const { mockCheckSsrf, mockFollow, SsrfBlocked } = vi.hoisted(() => { @@ -20,14 +24,12 @@ vi.mock('../../../../src/utils/ssrfGuard', () => ({ SsrfBlockedError: SsrfBlocked, })); -import { NaverListProvider } from '../../../../src/nest/place-import/providers/naver-list.provider'; -import { GoogleDirectionsProvider } from '../../../../src/nest/place-import/providers/google-directions.provider'; -import { MAX_LIST_RESPONSE_BYTES } from '../../../../src/nest/place-import/providers/google-list.provider'; - const FOLDER_URL = 'https://map.naver.com/p/favorite/myPlace/folder/abc123'; -const page = (bookmarkList: Record[], folder: Record = { name: 'Seoul', bookmarkCount: bookmarkList.length }) => - new Response(JSON.stringify({ folder, bookmarkList }), { status: 200 }); +const page = ( + bookmarkList: Record[], + folder: Record = { name: 'Seoul', bookmarkCount: bookmarkList.length }, +) => new Response(JSON.stringify({ folder, bookmarkList }), { status: 200 }); afterEach(() => { vi.unstubAllGlobals(); @@ -47,7 +49,10 @@ describe('NaverListProvider.read', () => { it('follows a naver.me short link through the guard, and refuses a hop it blocks', async () => { mockFollow.mockResolvedValueOnce({ url: FOLDER_URL }); - vi.stubGlobal('fetch', vi.fn(async () => page([{ name: 'Cafe', py: '37.5', px: '127.0' }]))); + vi.stubGlobal( + 'fetch', + vi.fn(async () => page([{ name: 'Cafe', py: '37.5', px: '127.0' }])), + ); expect(await naver().read('https://naver.me/xyz')).toEqual({ listName: 'Seoul', places: [{ name: 'Cafe', lat: 37.5, lng: 127, notes: null, address: null }], @@ -68,18 +73,39 @@ describe('NaverListProvider.read', () => { }); it('answers a failed, oversized or unreadable page with the provider message', async () => { - vi.stubGlobal('fetch', vi.fn(async () => new Response('nope', { status: 500 }))); + vi.stubGlobal( + 'fetch', + vi.fn(async () => new Response('nope', { status: 500 })), + ); expect(await naver().read(FOLDER_URL)).toEqual({ error: 'Failed to fetch list from Naver Maps', status: 502 }); - vi.stubGlobal('fetch', vi.fn(async () => new Response('{}', { status: 200, headers: { 'content-length': String(MAX_LIST_RESPONSE_BYTES + 1) } }))); + vi.stubGlobal( + 'fetch', + vi.fn( + async () => + new Response('{}', { status: 200, headers: { 'content-length': String(MAX_LIST_RESPONSE_BYTES + 1) } }), + ), + ); expect(await naver().read(FOLDER_URL)).toEqual({ error: 'Failed to fetch list from Naver Maps', status: 502 }); - vi.stubGlobal('fetch', vi.fn(async () => new Response('not json', { status: 200 }))); - expect(await naver().read(FOLDER_URL)).toEqual({ error: 'Invalid list data received from Naver Maps', status: 400 }); + vi.stubGlobal( + 'fetch', + vi.fn(async () => new Response('not json', { status: 200 })), + ); + expect(await naver().read(FOLDER_URL)).toEqual({ + error: 'Invalid list data received from Naver Maps', + status: 400, + }); }); it('pages through a folder bigger than one page and keeps what has coordinates', async () => { - const first = Array.from({ length: 20 }, (_, i) => ({ name: `P${i}`, py: '37', px: '127', memo: i === 0 ? ' note ' : '', address: i === 0 ? ' Addr ' : '' })); + const first = Array.from({ length: 20 }, (_, i) => ({ + name: `P${i}`, + py: '37', + px: '127', + memo: i === 0 ? ' note ' : '', + address: i === 0 ? ' Addr ' : '', + })); const fetchMock = vi .fn() .mockResolvedValueOnce(page(first, { name: 'Big', bookmarkCount: 22 })) @@ -97,18 +123,38 @@ describe('NaverListProvider.read', () => { it('stops paging on an empty page and fails on a failed one', async () => { const twenty = Array.from({ length: 20 }, (_, i) => ({ name: `P${i}`, py: '37', px: '127' })); - vi.stubGlobal('fetch', vi.fn().mockResolvedValueOnce(page(twenty, { bookmarkCount: 60 })).mockResolvedValueOnce(page([]))); - expect(((await naver().read(FOLDER_URL)) as { places: unknown[]; listName: string }).listName).toBe('Naver Maps List'); + vi.stubGlobal( + 'fetch', + vi + .fn() + .mockResolvedValueOnce(page(twenty, { bookmarkCount: 60 })) + .mockResolvedValueOnce(page([])), + ); + expect(((await naver().read(FOLDER_URL)) as { places: unknown[]; listName: string }).listName).toBe( + 'Naver Maps List', + ); - vi.stubGlobal('fetch', vi.fn().mockResolvedValueOnce(page(twenty, { bookmarkCount: 60 })).mockResolvedValueOnce(new Response('', { status: 503 }))); + vi.stubGlobal( + 'fetch', + vi + .fn() + .mockResolvedValueOnce(page(twenty, { bookmarkCount: 60 })) + .mockResolvedValueOnce(new Response('', { status: 503 })), + ); expect(await naver().read(FOLDER_URL)).toEqual({ error: 'Failed to fetch list from Naver Maps', status: 502 }); }); it('refuses an empty folder and one without a single coordinate', async () => { - vi.stubGlobal('fetch', vi.fn(async () => new Response(JSON.stringify({ folder: { name: 'Empty' } }), { status: 200 }))); + vi.stubGlobal( + 'fetch', + vi.fn(async () => new Response(JSON.stringify({ folder: { name: 'Empty' } }), { status: 200 })), + ); expect(await naver().read(FOLDER_URL)).toEqual({ error: 'List is empty or could not be read', status: 400 }); - vi.stubGlobal('fetch', vi.fn(async () => page([{ name: 'Nowhere' }]))); + vi.stubGlobal( + 'fetch', + vi.fn(async () => page([{ name: 'Nowhere' }])), + ); expect(await naver().read(FOLDER_URL)).toEqual({ error: 'No places with coordinates found in list', status: 400 }); }); }); @@ -135,10 +181,16 @@ describe('GoogleDirectionsProvider.read', () => { expect(await directions().read('https://maps.app.goo.gl/abc')).toHaveLength(2); mockFollow.mockResolvedValueOnce({ url: 'https://evil.example.com/maps/dir/A/B/' }); - expect(await directions().read('https://maps.app.goo.gl/abc')).toEqual({ error: 'That link is not a Google Maps link.', status: 400 }); + expect(await directions().read('https://maps.app.goo.gl/abc')).toEqual({ + error: 'That link is not a Google Maps link.', + status: 400, + }); mockFollow.mockRejectedValueOnce(new SsrfBlocked('blocked')); - expect(await directions().read('https://maps.app.goo.gl/abc')).toEqual({ error: 'URL is not allowed', status: 400 }); + expect(await directions().read('https://maps.app.goo.gl/abc')).toEqual({ + error: 'URL is not allowed', + status: 400, + }); mockFollow.mockRejectedValueOnce(new Error('network')); await expect(directions().read('https://maps.app.goo.gl/abc')).rejects.toThrow('network'); @@ -146,7 +198,8 @@ describe('GoogleDirectionsProvider.read', () => { it('refuses a link with fewer than two stops', async () => { expect(await directions().read('https://www.google.com/maps/dir/Berlin/')).toEqual({ - error: 'Could not read any stops from that directions link. Open the route in Google Maps and use its Share button.', + error: + 'Could not read any stops from that directions link. Open the route in Google Maps and use its Share button.', status: 400, }); }); diff --git a/server/tests/unit/nest/place-import/place-import.service.test.ts b/server/tests/unit/nest/place-import/place-import.service.test.ts index 0d16f325b..749dcd45b 100644 --- a/server/tests/unit/nest/place-import/place-import.service.test.ts +++ b/server/tests/unit/nest/place-import/place-import.service.test.ts @@ -4,6 +4,15 @@ * and a directions link's named stops are placed through the geocoder the * caller hands in, a failed or missing answer costing only that stop. */ +import { + gpxGeometryParser, + gpxTextParser, + createGpxParser, + writeGpx, +} from '../../../../src/nest/place-import/gpx.codec'; +import { buildPlaceImportService } from '../../../helpers/place-import'; +import { COLLECTION_FILE_FORMAT, COLLECTION_FILE_VERSION } from '@trek/shared'; + import fs from 'fs'; import path from 'path'; import { describe, it, expect, vi, afterEach } from 'vitest'; @@ -15,10 +24,6 @@ vi.mock('../../../../src/utils/ssrfGuard', () => ({ SsrfBlockedError: class SsrfBlockedError extends Error {}, })); -import { buildPlaceImportService } from '../../../helpers/place-import'; -import { gpxGeometryParser, gpxTextParser, createGpxParser, writeGpx } from '../../../../src/nest/place-import/gpx.codec'; -import { COLLECTION_FILE_FORMAT, COLLECTION_FILE_VERSION } from '@trek/shared'; - const KMZ_FIXTURE = path.join(__dirname, '../../../fixtures/test.kmz'); afterEach(() => { @@ -31,7 +36,9 @@ describe('PlaceImportService: directions', () => { const ROUTE = 'https://www.google.com/maps/dir/Berlin/52.52,13.405/Dresden/Prague/'; it('places named stops through the geocoder it is handed, coordinates as they stand', async () => { - const geocode = vi.fn(async (q: string) => (q === 'Berlin' ? { lat: 52.5, lng: 13.4 } : q === 'Dresden' ? null : Promise.reject(new Error('down')))); + const geocode = vi.fn(async (q: string) => + q === 'Berlin' ? { lat: 52.5, lng: 13.4 } : q === 'Dresden' ? null : Promise.reject(new Error('down')), + ); const out = await buildPlaceImportService().readGoogleDirections(ROUTE, geocode); expect(geocode).toHaveBeenCalledTimes(3); expect(out).toEqual({ @@ -44,14 +51,20 @@ describe('PlaceImportService: directions', () => { }); it('refuses a route of which fewer than two stops could be placed', async () => { - const out = await buildPlaceImportService().readGoogleDirections('https://www.google.com/maps/dir/A/B/', async () => null); + const out = await buildPlaceImportService().readGoogleDirections( + 'https://www.google.com/maps/dir/A/B/', + async () => null, + ); expect(out).toEqual({ error: 'None of the stops in that link could be placed on the map.', status: 400 }); }); it('hands a provider refusal straight back without geocoding', async () => { mockCheckSsrf.mockResolvedValueOnce({ allowed: false }); const geocode = vi.fn(); - expect(await buildPlaceImportService().readGoogleDirections(ROUTE, geocode)).toEqual({ error: 'URL is not allowed', status: 400 }); + expect(await buildPlaceImportService().readGoogleDirections(ROUTE, geocode)).toEqual({ + error: 'URL is not allowed', + status: 400, + }); expect(geocode).not.toHaveBeenCalled(); }); }); @@ -60,16 +73,28 @@ describe('PlaceImportService: files and lists', () => { const svc = buildPlaceImportService(); it('dispatches a map upload by its extension, and refuses anything else', async () => { - const kml = Buffer.from('A13.4,52.5'); + const kml = Buffer.from( + 'A13.4,52.5', + ); expect((await svc.readMapFile(kml, 'trip.KML')).placemarks).toHaveLength(1); - expect((await svc.readMapFile(fs.readFileSync(KMZ_FIXTURE), 'trip.kmz')).summary.totalPlacemarks).toBeGreaterThan(0); - await expect(svc.readMapFile(kml, 'trip.txt')).rejects.toThrow('Unsupported map file format: .txt. Please upload a .kml or .kmz file.'); + expect((await svc.readMapFile(fs.readFileSync(KMZ_FIXTURE), 'trip.kmz')).summary.totalPlacemarks).toBeGreaterThan( + 0, + ); + await expect(svc.readMapFile(kml, 'trip.txt')).rejects.toThrow( + 'Unsupported map file format: .txt. Please upload a .kml or .kmz file.', + ); }); it('reads a trip GPX and writes one back under a filename', () => { const gpx = Buffer.from('Here'); expect(svc.readGpx(gpx)).toEqual([{ lat: 1, lng: 2, name: 'Here', description: null }]); - const doc = svc.writeTripGpx({ tripTitle: 'T', places: [{ name: 'Here', description: null, address: null, lat: 1, lng: 2, route_geometry: null, category: null }], days: [] }); + const doc = svc.writeTripGpx({ + tripTitle: 'T', + places: [ + { name: 'Here', description: null, address: null, lat: 1, lng: 2, route_geometry: null, category: null }, + ], + days: [], + }); expect(doc).toContain(''); expect(svc.tripGpxFilename('My Trip')).toBe('My-Trip.gpx'); }); @@ -83,13 +108,23 @@ describe('PlaceImportService: files and lists', () => { places: [{ name: 'Bar', lat: 1, lng: 2 }], } as never); expect(exported.waypoints).toBe(1); - expect(svc.readCollectionGpx(exported.gpx, 'coffee.gpx').file.places[0]).toMatchObject({ name: 'Bar', lat: 1, lng: 2 }); + expect(svc.readCollectionGpx(exported.gpx, 'coffee.gpx').file.places[0]).toMatchObject({ + name: 'Bar', + lat: 1, + lng: 2, + }); }); it('answers a blocked list link before fetching anything', async () => { mockCheckSsrf.mockResolvedValue({ allowed: false }); - expect(await svc.readGoogleList('https://www.google.com/maps/placelists/list/x')).toEqual({ error: 'URL is not allowed', status: 400 }); - expect(await svc.readNaverList('https://map.naver.com/p/favorite/myPlace/folder/x')).toEqual({ error: 'URL is not allowed', status: 400 }); + expect(await svc.readGoogleList('https://www.google.com/maps/placelists/list/x')).toEqual({ + error: 'URL is not allowed', + status: 400, + }); + expect(await svc.readNaverList('https://map.naver.com/p/favorite/myPlace/folder/x')).toEqual({ + error: 'URL is not allowed', + status: 400, + }); }); }); @@ -102,11 +137,18 @@ describe('gpx.codec', () => { expect(text.name).toBe('007'); // `link` repeats only for the list reader. expect(Array.isArray(text.link)).toBe(true); - expect(Array.isArray(createGpxParser('geometry').parse('').gpx.wpt[0].link)).toBe(false); + expect( + Array.isArray(createGpxParser('geometry').parse('').gpx.wpt[0].link), + ).toBe(false); }); it('writes one envelope: namespaces after the default one, empty element lists left out', () => { - const doc = writeGpx({ namespaces: { trek: 'https://trek.example/gpx' }, metadata: { name: 'X' }, wpt: [], trk: [{ name: 'T' }] }); + const doc = writeGpx({ + namespaces: { trek: 'https://trek.example/gpx' }, + metadata: { name: 'X' }, + wpt: [], + trk: [{ name: 'T' }], + }); expect(doc).toContain('xmlns="http://www.topografix.com/GPX/1/1" xmlns:trek="https://trek.example/gpx" xmlns:xsi='); expect(doc).not.toContain(''); diff --git a/server/tests/unit/scripts/import-boundaries.test.ts b/server/tests/unit/scripts/import-boundaries.test.ts index 556d46e27..4889f16a1 100644 --- a/server/tests/unit/scripts/import-boundaries.test.ts +++ b/server/tests/unit/scripts/import-boundaries.test.ts @@ -180,20 +180,31 @@ describe('import-boundaries.mjs', () => { it('BOUND-012: a provider may not import the orchestrator above it, type-only included', () => { const dir = serverRoot({ - 'nest/maps/maps.service.ts': "import { Osm } from './providers/osm.client';\nexport class MapsService { o = Osm; }\n", - 'nest/maps/providers/osm.client.ts': "import type { MapsService } from '../maps.service';\nexport class Osm { m?: MapsService; }\n", + 'nest/maps/maps.service.ts': + "import { Osm } from './providers/osm.client';\nexport class MapsService { o = Osm; }\n", + 'nest/maps/providers/osm.client.ts': + "import type { MapsService } from '../maps.service';\nexport class Osm { m?: MapsService; }\n", 'nest/docs/docs.service.ts': 'export class DocsService {}\n', - 'nest/docs/providers/webdav/client.ts': "import { DocsService } from '../../docs.service';\nexport const c = DocsService;\n", - 'nest/trips/providers/geo.ts': "import { MapsService } from '../../maps/maps.service';\nexport const g = MapsService;\n", + 'nest/docs/providers/webdav/client.ts': + "import { DocsService } from '../../docs.service';\nexport const c = DocsService;\n", + 'nest/trips/providers/geo.ts': + "import { MapsService } from '../../maps/maps.service';\nexport const g = MapsService;\n", // A provider importing another service of its own domain is not the orchestrator. 'nest/docs/docs-cache.service.ts': 'export class DocsCacheService {}\n', - 'nest/docs/providers/cached.ts': "import { DocsCacheService } from '../docs-cache.service';\nexport const k = DocsCacheService;\n", + 'nest/docs/providers/cached.ts': + "import { DocsCacheService } from '../docs-cache.service';\nexport const k = DocsCacheService;\n", }); const { status, out } = run(dir); expect(status).toBe(1); - expect(out).toContain('FAIL providersImportOrchestrator: nest/maps/providers/osm.client.ts -> nest/maps/maps.service.ts'); - expect(out).toContain('FAIL providersImportOrchestrator: nest/docs/providers/webdav/client.ts -> nest/docs/docs.service.ts'); - expect(out).toContain('FAIL providersImportOrchestrator: nest/trips/providers/geo.ts -> nest/maps/maps.service.ts'); + expect(out).toContain( + 'FAIL providersImportOrchestrator: nest/maps/providers/osm.client.ts -> nest/maps/maps.service.ts', + ); + expect(out).toContain( + 'FAIL providersImportOrchestrator: nest/docs/providers/webdav/client.ts -> nest/docs/docs.service.ts', + ); + expect(out).toContain( + 'FAIL providersImportOrchestrator: nest/trips/providers/geo.ts -> nest/maps/maps.service.ts', + ); expect(out).not.toContain('providers/cached.ts'); }); @@ -257,7 +268,8 @@ describe('import-boundaries.mjs', () => { const owners = JSON.stringify({ Trips: 'trips', Todos: 'todo' }); const files = { 'nest/trips/trips.service.ts': '@InjectRepository(Trips) class S {}\nexport { S };\n', - 'nest/todo/todo.service.ts': '@InjectRepository(Todos) class T {}\n@InjectRepository(Trips) class U {}\nexport { T, U };\n', + 'nest/todo/todo.service.ts': + '@InjectRepository(Todos) class T {}\n@InjectRepository(Trips) class U {}\nexport { T, U };\n', }; const withOwners = (baseline: unknown) => { const dir = serverRoot(files, baseline); diff --git a/server/tests/unit/scripts/query-api-ratchet.test.ts b/server/tests/unit/scripts/query-api-ratchet.test.ts index 597107a3d..6e7e27e89 100644 --- a/server/tests/unit/scripts/query-api-ratchet.test.ts +++ b/server/tests/unit/scripts/query-api-ratchet.test.ts @@ -30,14 +30,20 @@ function serverRoot(files: Record, baseline: unknown = { counts: writeFileSync(path.join(dir, file), text); } if (baseline !== null) { - writeFileSync(path.join(dir, 'scripts/query-api-baseline.json'), typeof baseline === 'string' ? baseline : JSON.stringify(baseline)); + writeFileSync( + path.join(dir, 'scripts/query-api-baseline.json'), + typeof baseline === 'string' ? baseline : JSON.stringify(baseline), + ); } return dir; } function run(dir: string, ...args: string[]): { status: number | null; out: string } { try { - const stdout = execFileSync(process.execPath, [SCRIPT, `--dir=${dir}`, ...args], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }); + const stdout = execFileSync(process.execPath, [SCRIPT, `--dir=${dir}`, ...args], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + }); return { status: 0, out: stdout }; } catch (err) { const { status, stdout, stderr } = err as { status: number | null; stdout: string; stderr: string }; @@ -57,9 +63,13 @@ describe('query-api-ratchet.mjs', () => { }); it('QAPI-002: fails a new file holding one, and counts createQueryBuilder too', () => { - const { status, out } = run(serverRoot({ [`${REPOS}/New.repository.ts`]: `${calls(1)}em.createQueryBuilder(X);\n` })); + const { status, out } = run( + serverRoot({ [`${REPOS}/New.repository.ts`]: `${calls(1)}em.createQueryBuilder(X);\n` }), + ); expect(status).toBe(1); - expect(out).toContain(`FAIL ${REPOS}/New.repository.ts: 2 QueryBuilder call(s), a file without a baseline entry may hold none.`); + expect(out).toContain( + `FAIL ${REPOS}/New.repository.ts: 2 QueryBuilder call(s), a file without a baseline entry may hold none.`, + ); }); it('QAPI-003: holds a baselined file to its entry', () => { @@ -77,7 +87,13 @@ describe('query-api-ratchet.mjs', () => { it('QAPI-005: an entry above its file fails until --update lowers it, and --update never raises or adds one', () => { const dir = serverRoot( { [`${REPOS}/A.repository.ts`]: calls(1), [`${REPOS}/B.repository.ts`]: calls(4) }, - { counts: { [`${REPOS}/A.repository.ts`]: 3, [`${REPOS}/Gone.repository.ts`]: 1, [`${REPOS}/B.repository.ts`]: 2 } }, + { + counts: { + [`${REPOS}/A.repository.ts`]: 3, + [`${REPOS}/Gone.repository.ts`]: 1, + [`${REPOS}/B.repository.ts`]: 2, + }, + }, ); const stale = run(dir); expect(stale.status).toBe(1); @@ -85,7 +101,9 @@ describe('query-api-ratchet.mjs', () => { expect(stale.out).toContain(`FAIL ${REPOS}/Gone.repository.ts is held at 1`); run(dir, '--update'); - const lowered = JSON.parse(readFileSync(path.join(dir, 'scripts/query-api-baseline.json'), 'utf8')) as { counts: Record }; + const lowered = JSON.parse(readFileSync(path.join(dir, 'scripts/query-api-baseline.json'), 'utf8')) as { + counts: Record; + }; expect(lowered.counts).toEqual({ [`${REPOS}/A.repository.ts`]: 1, [`${REPOS}/B.repository.ts`]: 2 }); }); diff --git a/server/tests/unit/utils/json-column.test.ts b/server/tests/unit/utils/json-column.test.ts index c5f936548..c0fa8245d 100644 --- a/server/tests/unit/utils/json-column.test.ts +++ b/server/tests/unit/utils/json-column.test.ts @@ -1,10 +1,3 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { z } from 'zod'; - -const logMock = vi.hoisted(() => ({ logInfo: vi.fn(), logError: vi.fn(), logWarn: vi.fn(), logDebug: vi.fn() })); -vi.mock('../../../src/nest/audit/audit-log.logger', () => logMock); - -import { decodeJson, decodeJsonResult, encodeJson, logJsonFailure, type JsonColumn } from '../../../src/utils/json-column'; import { COLLAB_POLL_OPTIONS, JOURNEY_ENTRY_PROS_CONS, @@ -17,6 +10,19 @@ import { RESERVATION_METADATA, USER_MFA_BACKUP_CODES, } from '../../../src/db/json-columns'; +import { + decodeJson, + decodeJsonResult, + encodeJson, + logJsonFailure, + type JsonColumn, +} from '../../../src/utils/json-column'; + +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { z } from 'zod'; + +const logMock = vi.hoisted(() => ({ logInfo: vi.fn(), logError: vi.fn(), logWarn: vi.fn(), logDebug: vi.fn() })); +vi.mock('../../../src/nest/audit/audit-log.logger', () => logMock); const LIST: JsonColumn = { column: 't.list', schema: z.array(z.string()), fallback: () => [] }; @@ -44,7 +50,10 @@ describe('decodeJsonResult', () => { const doubled = JSON.stringify(JSON.stringify({ price: '12' })); expect(decodeJsonResult(RESERVATION_METADATA, doubled)).toEqual({ ok: true, value: { price: '12' } }); expect(decodeJsonResult(LIST, JSON.stringify(JSON.stringify(['a'])))).toEqual({ ok: false, reason: 'schema' }); - expect(decodeJsonResult(RESERVATION_METADATA, JSON.stringify('{broken'))).toEqual({ ok: false, reason: 'invalid-json' }); + expect(decodeJsonResult(RESERVATION_METADATA, JSON.stringify('{broken'))).toEqual({ + ok: false, + reason: 'invalid-json', + }); }); }); @@ -61,9 +70,13 @@ describe('decodeJson', () => { it('JSONCOL-012: broken text and the wrong shape fall back and are logged with the column and context', () => { expect(decodeJson(LIST, '{not json', 'row 7')).toEqual([]); - expect(logMock.logWarn).toHaveBeenLastCalledWith('[json] t.list (row 7): stored value is not valid JSON, using the fallback'); + expect(logMock.logWarn).toHaveBeenLastCalledWith( + '[json] t.list (row 7): stored value is not valid JSON, using the fallback', + ); expect(decodeJson(LIST, '{"a":1}')).toEqual([]); - expect(logMock.logWarn).toHaveBeenLastCalledWith('[json] t.list: stored value has the wrong shape, using the fallback'); + expect(logMock.logWarn).toHaveBeenLastCalledWith( + '[json] t.list: stored value has the wrong shape, using the fallback', + ); }); it('JSONCOL-013: every fallback is a fresh value', () => { @@ -94,7 +107,12 @@ describe('the declared columns', () => { }); it('JSONCOL-031: the OAuth lists fail closed to an empty list', () => { - for (const col of [OAUTH_TOKEN_SCOPES, OAUTH_CLIENT_ALLOWED_SCOPES, OAUTH_CLIENT_REDIRECT_URIS, OAUTH_CONSENT_SCOPES]) { + for (const col of [ + OAUTH_TOKEN_SCOPES, + OAUTH_CLIENT_ALLOWED_SCOPES, + OAUTH_CLIENT_REDIRECT_URIS, + OAUTH_CONSENT_SCOPES, + ]) { expect(decodeJson(col, '["trips:read"]')).toEqual(['trips:read']); expect(decodeJson(col, 'garbage')).toEqual([]); expect(decodeJson(col, '[1]')).toEqual([]);