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.
This commit is contained in:
Maurice
2026-10-10 14:25:09 +02:00
parent a28044dbd4
commit 2c3fb15263
81 changed files with 1656 additions and 684 deletions
-33
View File
@@ -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",
+2 -1
View File
@@ -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
@@ -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<Users> {
* 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<void> {
async setAirtrailSettingsWithKey(
id: number,
url: string | null,
apiKey: string,
allowInsecureTls: number,
writeEnabled: number,
): Promise<void> {
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<Users> {
* 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<void> {
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<void> {
await this.nativeUpdate(
{ id },
{ airtrail_url: url, airtrail_allow_insecure_tls: allowInsecureTls, airtrail_write_enabled: writeEnabled },
);
}
/**
@@ -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<Users> {
* 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<Users> {
): 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<Users> {
{
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,
),
},
);
}
@@ -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<Users> {
}
/** 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<void> {
async setSynologySettings(
id: number,
synology_url: string,
synology_username: string,
synology_password: string | null,
synology_skip_ssl: number,
): Promise<void> {
await this.nativeUpdate({ id }, { synology_url, synology_username, synology_password, synology_skip_ssl });
}
+9 -1
View File
@@ -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'. */
+5 -1
View File
@@ -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 {
+1 -1
View File
@@ -1,6 +1,6 @@
import type { Scope, ScopeGroup } from '../mcp/scopes';
import type { InjectionToken, ModuleMetadata } from '@nestjs/common';
import type { ZodRawShape, ZodType } from 'zod';
/**
+5 -5
View File
@@ -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.
+9 -8
View File
@@ -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<void> {
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}`);
}
/**
+2 -2
View File
@@ -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()`.
@@ -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
+1 -1
View File
@@ -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.
+1
View File
@@ -1,4 +1,5 @@
import { CanActivate, ExecutionContext, HttpException, Injectable } from '@nestjs/common';
import type { Request } from 'express';
/**
+14 -6
View File
@@ -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<Request & { cookies?: Record<string, string> }>();
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);
}
@@ -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;
});
@@ -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.
@@ -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
@@ -27,11 +27,7 @@ export interface EphemeralTokenMeta {
const store = new Map<string, TokenEntry>();
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);
+14 -7
View File
@@ -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;
}
+5 -4
View File
@@ -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`
+6 -5
View File
@@ -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
+13 -12
View File
@@ -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';
@@ -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<boolean> {
const req = context.switchToHttp().getRequest<Request>();
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;
}
}
@@ -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);
+1 -2
View File
@@ -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);
}
+5 -2
View File
@@ -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<TransitProvider> {
export async function writeTransitProvider(
appSettings: AppSettingsRepository,
provider: TransitProvider,
): Promise<TransitProvider> {
await appSettings.setValue(TRANSIT_PROVIDER_SETTING, provider);
return provider;
}
@@ -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.
+21 -6
View File
@@ -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<string, string> | null, placeId: string): Record<string, unknown> {
export function indexPlaceDetails(
found: TrekPlace,
osmTags: Record<string, string> | null,
placeId: string,
): Record<string, unknown> {
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<string, stri
// fallback, not the first choice: an OSM entry describes this exact
// object and gets corrected by people who walked past, where a chain's
// website often carries one set of hours for every branch.
const fromSite = typeof found.hours?.osm === 'string' ? buildOsmDetails({ opening_hours: found.hours.osm }, '', '') : null;
const fromSite =
typeof found.hours?.osm === 'string' ? buildOsmDetails({ opening_hours: found.hours.osm }, '', '') : null;
if (!osmTags) {
return fromSite?.opening_hours
? {
+14 -7
View File
@@ -8,13 +8,13 @@
* caller to reverse-geocode: inside China Amap answers that before Nominatim,
* and choosing who answers is MapsService's job, not this resolver's.
*/
import { Injectable } from '@nestjs/common';
import { safeFetchFollow, SsrfBlockedError } from '../../utils/ssrfGuard';
import { discardBody, exceedsDeclaredLength, readCappedText } from '../../utils/cappedFetch';
import { safeFetchFollow, SsrfBlockedError } from '../../utils/ssrfGuard';
import { GOOGLE_SHORT_HOSTS, isGoogleMapsHost } from '../common/google-maps-hosts';
import { UA, googleFtidFromMapsUrl } from './maps.helpers';
import { AMAP_SHORT_HOSTS, isAmapHost, parseAmapUrl } from './providers/amap.provider';
import { OsmClient } from './providers/osm.client';
import { GOOGLE_SHORT_HOSTS, isGoogleMapsHost } from '../common/google-maps-hosts';
import { Injectable } from '@nestjs/common';
// A Google Maps place page is a few hundred KB; the coordinates sit in the
// embedded map data near the top, so two megabytes is plenty and keeps an
@@ -31,8 +31,7 @@ export interface ResolvedMapsUrl {
/** A Google link resolved in full, or an Amap point still to be named. */
export type MapsLink =
| { kind: 'resolved'; result: ResolvedMapsUrl }
| { kind: 'amap'; lat: number; lng: number; name: string | null };
{ kind: 'resolved'; result: ResolvedMapsUrl } | { kind: 'amap'; lat: number; lng: number; name: string | null };
// Extract coordinates from a string (URL or page body). Google Maps encodes
// them several ways: /@lat,lng,zoom · !3dlat!4dlng (map data param) · ?q=/?ll=.
@@ -48,7 +47,11 @@ function extractCoords(s: string): { lat: number; lng: number } | null {
async function followRedirects(target: string, init?: RequestInit): Promise<Response> {
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
+30 -14
View File
@@ -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);
}
+9 -6
View File
@@ -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
@@ -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.
@@ -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<GooglePhotoOutcome> {
// 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<ProviderPlace[]> {
async searchNearby(
origin: { lat: number; lng: number },
radius: number,
limit: number,
lang: string,
): Promise<ProviderPlace[]> {
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<ProviderSuggestion[]> {
async autocomplete(
input: string,
lang?: string,
bias?: ViewportBias,
sessionToken?: string,
): Promise<ProviderSuggestion[]> {
const body: Record<string, unknown> = {
input,
languageCode: toApiLang(lang),
+21 -13
View File
@@ -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<Record<string, unknown>[]> {
async nearby(
origin: { lat: number; lng: number },
radius: number,
limit: number,
osmLang: string,
): Promise<Record<string, unknown>[]> {
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
@@ -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<string, { thumbnail?: { source?: string } }> } };
const data = (await res.json()) as {
query?: { pages?: Record<string, { thumbnail?: { source?: string } }> };
};
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<Record<string, string>> {
async fetchWikidataSitelinks(
wikidataId: string,
sites: string[],
signal?: AbortSignal,
): Promise<Record<string, string>> {
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<string, { claims?: WikidataClaims }> };
const claims = data.entities?.[qid]?.claims;
+2 -1
View File
@@ -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) {}
@@ -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;
+3 -1
View File
@@ -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: {
+13 -8
View File
@@ -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.');
}
@@ -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,
@@ -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.
@@ -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<string, unknown> |
}
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<string, unknown> {
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<T>(schema: ZodType<T>, raw: Record<string, unknown>, 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 `<link href>`, or GPX 1.0's `<url>` beside `<urlname>`. */
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<string, unknown> = {
@@ -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 <metadata>, 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);
@@ -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;
}
@@ -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 };
}
@@ -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 <name>. 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<string, unknown>) => ({ 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),
});
}
}
@@ -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 {
@@ -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.
+6 -4
View File
@@ -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<T> {
export type JsonDecodeFailure = 'empty' | 'invalid-json' | 'schema';
export type JsonDecodeResult<T> =
| { 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<T>(col: JsonColumn<T>, 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<unknown>, 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). */
+9 -9
View File
@@ -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. */
+1 -1
View File
@@ -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,
+3 -3
View File
@@ -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';
/**
@@ -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();
@@ -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<T>(fn: () => Promise<T>): 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();
});
@@ -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<MikroORM> {
@@ -22,16 +30,31 @@ async function ormBeforeTarget(): Promise<MikroORM> {
async function seed(orm: MikroORM): Promise<void> {
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<void> {
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 = {
@@ -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',
]);
});
});
@@ -26,7 +26,13 @@ async function flags(file: string, source: string): Promise<boolean> {
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');
+29 -12
View File
@@ -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();
});
});
@@ -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'),
);
});
});
@@ -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);
@@ -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,
@@ -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);
@@ -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/);
});
@@ -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<string, string>) {
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);
});
@@ -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<unknown>): 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<string, { factory: (data: unknown, ctx: unknown) => 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 () => {
@@ -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<string, string>;
headers?: Record<string, string>;
} = {}): 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<string, string>;
headers?: Record<string, string>;
} = {},
): 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<UserWithPasswordVersion, 'password_version'> & { password_version: number | null }) | null): UsersRepository {
function usersRepo(
row: (Omit<UserWithPasswordVersion, 'password_version'> & { 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();
});
@@ -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;
@@ -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 () => {
@@ -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> = {}): 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',
);
});
});
@@ -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,
},
);
});
});
@@ -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<string, unknown> = {}) {
return {
@@ -19,12 +20,21 @@ function cache(over: Record<string, unknown> = {}) {
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<typeof cache>, wikiOutcome: unknown, key: string | null = null, googleOutcome: unknown = { kind: 'none' }) {
function resolver(
c: ReturnType<typeof cache>,
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<typeof cache>, 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();
});
@@ -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> = {}): ExportedCollectionFile => ({
format: 'trek.collection', version: 1, name: 'Lisbon', places: [], ...over,
format: 'trek.collection',
version: 1,
name: 'Lisbon',
places: [],
...over,
});
const place = (over: Partial<CollectionFilePlace> = {}): 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('<wpt lat="38.7071" lon="-9.1459">');
// name, cmt, desc, link, type, extensions: the xsd sequence, which strict readers enforce.
const order = ['<name>', '<cmt>', '<desc>', '<link ', '<type>', '<extensions>'].map(tag => gpx.indexOf(tag, gpx.indexOf('<wpt')));
const order = ['<name>', '<cmt>', '<desc>', '<link ', '<type>', '<extensions>'].map((tag) =>
gpx.indexOf(tag, gpx.indexOf('<wpt')),
);
expect(order.every((at, i) => at > -1 && (i === 0 || at > order[i - 1]))).toBe(true);
// Other apps show <desc>; the address rides under the description so they show it too.
expect(gpx).toContain('<desc>Market hall\n\nAv. 24 de Julho 49</desc>');
@@ -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 [
'<trek:address>Rua 1</trek:address>', '<trek:phone>+351 1</trek:phone>', '<trek:status>want</trek:status>',
'<trek:price>12.5</trek:price>', '<trek:currency>EUR</trek:currency>', '<trek:label>Must see</trek:label>',
'<trek:link href="https://menu.example">Menu</trek:link>', '<trek:link href="https://bare.example"/>',
'<trek:color>#ef4444</trek:color>', '<trek:icon>Utensils</trek:icon>', '<trek:label color="#ff0000">Must see</trek:label>',
]) expect(gpx, line).toContain(line);
'<trek:address>Rua 1</trek:address>',
'<trek:phone>+351 1</trek:phone>',
'<trek:status>want</trek:status>',
'<trek:price>12.5</trek:price>',
'<trek:currency>EUR</trek:currency>',
'<trek:label>Must see</trek:label>',
'<trek:link href="https://menu.example">Menu</trek:link>',
'<trek:link href="https://bare.example"/>',
'<trek:color>#ef4444</trek:color>',
'<trek:icon>Utensils</trek:icon>',
'<trek:label color="#ff0000">Must see</trek:label>',
])
expect(gpx, line).toContain(line);
expect(gpx).toContain('<metadata>\n <name>Lisbon</name>\n <desc>Three days</desc>');
});
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('<name>Here</name>');
@@ -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 <best>',
places: [place({ name: 'A "quoted" & <tagged> place\u0001\u0008', description: "Bob's \u000bnotes" })],
}));
const { gpx } = collectionFileToGpx(
listFile({
name: 'Fish & Chips <best>',
places: [place({ name: 'A "quoted" & <tagged> place\u0001\u0008', description: "Bob's \u000bnotes" })],
}),
);
expect(XMLValidator.validate(gpx)).toBe(true);
expect(gpx).toContain('<name>Fish &amp; Chips &lt;best&gt;</name>');
@@ -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 <cmt> and <desc> 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 <url>', () => {
const { file } = gpxToCollectionFile(
'<gpx version="1.0" creator="old"><name>Old file</name><wpt lat="1" lon="2"><name>A</name>'
+ '<url>https://a.example</url><urlname>A site</urlname></wpt></gpx>',
'<gpx version="1.0" creator="old"><name>Old file</name><wpt lat="1" lon="2"><name>A</name>' +
'<url>https://a.example</url><urlname>A site</urlname></wpt></gpx>',
);
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('<wpt lat="1" lon="1"><name>x</name></wpt>').replace('<gpx', '<!ENTITY a "b"><gpx'))).toBe('unreadable');
expect(refusal(wptDoc('<wpt lat="1" lon="1"><name>x</name></wpt>').replace('<gpx', '<!ENTITY a "b"><gpx'))).toBe(
'unreadable',
);
});
it('GPX-COLL-021: a file that is not XML, not well-formed, or not GPX', () => {
@@ -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(
`<wpt lat="1" lon="2"><name>${'n'.repeat(600)}</name><desc>${'d'.repeat(6000)}</desc>`
+ '<type>Museum</type></wpt>',
));
const { file, skipped } = gpxToCollectionFile(
wptDoc(
`<wpt lat="1" lon="2"><name>${'n'.repeat(600)}</name><desc>${'d'.repeat(6000)}</desc>` +
'<type>Museum</type></wpt>',
),
);
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 = '<wpt lat="1" lon="2"><name>A</name><extensions><t:status>visited</t:status><trek:status>want</trek:status></extensions></wpt>';
const ours = gpxToCollectionFile(wptDoc(body, ` xmlns:t="${COLLECTION_GPX_NAMESPACE}" xmlns:trek="https://elsewhere.example"`));
const body =
'<wpt lat="1" lon="2"><name>A</name><extensions><t:status>visited</t:status><trek:status>want</trek:status></extensions></wpt>';
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(
'<wpt lat="1" lon="2"><name>A</name><extensions><trek:status>loved</trek:status><trek:price>cheap</trek:price>'
+ `<trek:label>Ok</trek:label><trek:label>${'x'.repeat(61)}</trek:label><trek:phone>+1</trek:phone></extensions></wpt>`,
` xmlns:trek="${COLLECTION_GPX_NAMESPACE}"`,
));
const { file } = gpxToCollectionFile(
wptDoc(
'<wpt lat="1" lon="2"><name>A</name><extensions><trek:status>loved</trek:status><trek:price>cheap</trek:price>' +
`<trek:label>Ok</trek:label><trek:label>${'x'.repeat(61)}</trek:label><trek:phone>+1</trek:phone></extensions></wpt>`,
` 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,
});
@@ -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);
});
@@ -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');
});
@@ -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> = {}): 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> = {}): 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('<name>2. Ancient city</name>');
@@ -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('<name>3. 2026-05-01</name>');
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('<name>4</name>');
});
@@ -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);
@@ -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<br>Line <b>2</b> &amp; more';
@@ -152,7 +153,13 @@ describe('readKmlDocument', () => {
<Placemark><name>Walk</name><LineString><coordinates>13.4,52.5 13.5,52.6</coordinates></LineString></Placemark>
</Document></kml>`;
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('<kml><Placemark></kml>', 'utf-8'))).toThrow('Malformed KML: invalid XML structure');
expect(() => readKmlDocument(Buffer.from('<kml><Placemark></kml>', '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('<kml><Placemark><name>'), Buffer.from([0xff]), Buffer.from('</name></Placemark></kml>')]);
const bytes = Buffer.concat([
Buffer.from('<kml><Placemark><name>'),
Buffer.from([0xff]),
Buffer.from('</name></Placemark></kml>'),
]);
expect(readKmlDocument(bytes).summary.warnings).toEqual([
'The uploaded file is not valid UTF-8. Some characters may be shown incorrectly.',
]);
@@ -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<string, unknown>[], folder: Record<string, unknown> = { name: 'Seoul', bookmarkCount: bookmarkList.length }) =>
new Response(JSON.stringify({ folder, bookmarkList }), { status: 200 });
const page = (
bookmarkList: Record<string, unknown>[],
folder: Record<string, unknown> = { 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,
});
});
@@ -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('<kml><Placemark><name>A</name><Point><coordinates>13.4,52.5</coordinates></Point></Placemark></kml>');
const kml = Buffer.from(
'<kml><Placemark><name>A</name><Point><coordinates>13.4,52.5</coordinates></Point></Placemark></kml>',
);
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('<gpx><wpt lat="1" lon="2"><name>Here</name></wpt></gpx>');
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('<wpt lat="1" lon="2">');
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><link href="a"/></wpt></gpx>').gpx.wpt[0].link)).toBe(false);
expect(
Array.isArray(createGpxParser('geometry').parse('<gpx><wpt><link href="a"/></wpt></gpx>').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('<wpt');
expect(doc).toContain('<trk>');
@@ -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);
@@ -30,14 +30,20 @@ function serverRoot(files: Record<string, string>, 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<string, number> };
const lowered = JSON.parse(readFileSync(path.join(dir, 'scripts/query-api-baseline.json'), 'utf8')) as {
counts: Record<string, number>;
};
expect(lowered.counts).toEqual({ [`${REPOS}/A.repository.ts`]: 1, [`${REPOS}/B.repository.ts`]: 2 });
});
+29 -11
View File
@@ -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<string[]> = { 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([]);