mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-20 14:06:27 +00:00
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:
@@ -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",
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
|
||||
@@ -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'. */
|
||||
|
||||
@@ -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,6 +1,6 @@
|
||||
import type { Scope, ScopeGroup } from '../mcp/scopes';
|
||||
|
||||
import type { InjectionToken, ModuleMetadata } from '@nestjs/common';
|
||||
|
||||
import type { ZodRawShape, ZodType } from 'zod';
|
||||
|
||||
/**
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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,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,4 +1,5 @@
|
||||
import { CanActivate, ExecutionContext, HttpException, Injectable } from '@nestjs/common';
|
||||
|
||||
import type { Request } from 'express';
|
||||
|
||||
/**
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
? {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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). */
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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 & Chips <best></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> & 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 });
|
||||
});
|
||||
|
||||
|
||||
@@ -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([]);
|
||||
|
||||
Reference in New Issue
Block a user